// Vanilla History API routing, no router dependency (see the routes issue). The URL // is the source of truth: every route is derived from `location`, never from // state passed to pushState, so a manually edited or copy-pasted URL works. export type Route = | { kind: "library" } | { kind: "queues" } | { kind: "settings" } | { kind: "search"; query: string } | { kind: "movie"; movieId: number } | { kind: "releases"; movieId: number } | { kind: "series"; seriesId: number } | { kind: "seasonReleases"; seriesId: number; seasonNumber: number } | { kind: "episodeReleases"; episodeId: number }; export function parseRoute(url: URL): Route { const segments = url.pathname.split("/").filter(Boolean); if (segments.length === 1 && segments[0] === "queues") { return { kind: "queues" }; } if (segments.length === 1 && segments[0] === "settings") { return { kind: "settings" }; } if (segments.length === 1 && segments[0] === "search") { const query = url.searchParams.get("q") ?? ""; return query === "" ? { kind: "library" } : { kind: "search", query }; } if (segments.length === 2 && segments[0] === "movies") { const movieId = Number(segments[1]); if (Number.isInteger(movieId) && movieId > 0) { return { kind: "movie", movieId }; } } if (segments.length === 3 && segments[0] === "movies" && segments[2] === "releases") { const movieId = Number(segments[1]); if (Number.isInteger(movieId) && movieId > 0) { return { kind: "releases", movieId }; } } if (segments.length === 2 && segments[0] === "series") { const seriesId = Number(segments[1]); if (Number.isInteger(seriesId) && seriesId > 0) { return { kind: "series", seriesId }; } } if ( segments.length === 5 && segments[0] === "series" && segments[2] === "seasons" && segments[4] === "releases" ) { const seriesId = Number(segments[1]); const seasonNumber = Number(segments[3]); if ( Number.isInteger(seriesId) && seriesId > 0 && Number.isInteger(seasonNumber) && seasonNumber >= 0 ) { return { kind: "seasonReleases", seriesId, seasonNumber }; } } if (segments.length === 3 && segments[0] === "episodes" && segments[2] === "releases") { const episodeId = Number(segments[1]); if (Number.isInteger(episodeId) && episodeId > 0) { return { kind: "episodeReleases", episodeId }; } } // §9.7: the library is the homepage — "/" and every unrecognised path land here return { kind: "library" }; } export function routePath(route: Route): string { switch (route.kind) { case "library": return "/"; case "queues": return "/queues"; case "settings": return "/settings"; case "search": return `/search?q=${encodeURIComponent(route.query)}`; case "movie": return `/movies/${route.movieId}`; case "releases": return `/movies/${route.movieId}/releases`; case "series": return `/series/${route.seriesId}`; case "seasonReleases": return `/series/${route.seriesId}/seasons/${route.seasonNumber}/releases`; case "episodeReleases": return `/episodes/${route.episodeId}/releases`; } } export function currentRoute(): Route { return parseRoute(new URL(location.href)); } /** * Updates the URL to `route`. `replace: true` rewrites the current entry * instead of pushing one — used for search-as-you-type, so every keystroke * doesn't add a back-stack entry. */ export function navigate(route: Route, options: { replace?: boolean } = {}) { const path = routePath(route); if (location.pathname + location.search === path) { if (options.replace) { history.replaceState(null, "", path); } return; } if (options.replace) { history.replaceState(null, "", path); } else { history.pushState(null, "", path); } }