From 2d0b01d77d4f3b90b2d2e4bbf325bc81d2410a45 Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:30:35 +0200
Subject: [PATCH 01/11] feat: add channel media API plumbing
---
apps/web/src/hooks/use-podcast-episodes.ts | 28 +++++++++++++++++++
apps/web/src/hooks/use-podcasts.ts | 32 ++++++++++++++++++++++
apps/web/src/lib/api.ts | 26 +++++++++++++++++-
apps/web/src/types/api.ts | 24 ++++++++++++++++
4 files changed, 109 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/hooks/use-podcast-episodes.ts
create mode 100644 apps/web/src/hooks/use-podcasts.ts
diff --git a/apps/web/src/hooks/use-podcast-episodes.ts b/apps/web/src/hooks/use-podcast-episodes.ts
new file mode 100644
index 0000000..5b026ad
--- /dev/null
+++ b/apps/web/src/hooks/use-podcast-episodes.ts
@@ -0,0 +1,28 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { fetchPodcastEpisodes } from "../lib/api";
+import { mapVideoItem } from "../lib/mappers";
+import type { PodcastItem } from "../types/api";
+import type { VideoStream } from "../types/stream";
+
+type PodcastEpisodesPage = {
+ podcast: PodcastItem;
+ episodes: VideoStream[];
+ nextpage: string | null;
+};
+
+export function usePodcastEpisodes(podcastUrl: string) {
+ return useInfiniteQuery({
+ queryKey: ["podcast-episodes", podcastUrl],
+ enabled: podcastUrl.trim().length > 0,
+ queryFn: async ({ pageParam }): Promise => {
+ const res = await fetchPodcastEpisodes(podcastUrl, pageParam as string | undefined);
+ return {
+ podcast: res.podcast,
+ episodes: res.episodes.map(mapVideoItem),
+ nextpage: res.nextpage,
+ };
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined,
+ });
+}
diff --git a/apps/web/src/hooks/use-podcasts.ts b/apps/web/src/hooks/use-podcasts.ts
new file mode 100644
index 0000000..a9fbff5
--- /dev/null
+++ b/apps/web/src/hooks/use-podcasts.ts
@@ -0,0 +1,32 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { fetchPodcasts } from "../lib/api";
+import { mapVideoItem } from "../lib/mappers";
+import type { PodcastItem } from "../types/api";
+import type { VideoStream } from "../types/stream";
+
+type PodcastPage = {
+ channelName: string;
+ channelUrl: string;
+ podcasts: PodcastItem[];
+ episodes: VideoStream[];
+ nextpage: string | null;
+};
+
+export function usePodcasts(channelUrl: string, enabled = true) {
+ return useInfiniteQuery({
+ queryKey: ["podcasts", channelUrl],
+ enabled: enabled && channelUrl.trim().length > 0,
+ queryFn: async ({ pageParam }): Promise => {
+ const res = await fetchPodcasts(channelUrl, pageParam as string | undefined);
+ return {
+ channelName: res.channelName,
+ channelUrl: res.channelUrl,
+ podcasts: res.podcasts,
+ episodes: res.episodes.map(mapVideoItem),
+ nextpage: res.nextpage,
+ };
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined,
+ });
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 76691d0..d69eb2a 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -2,6 +2,8 @@ import type {
BulletCommentsPageResponse,
ChannelResponse,
CommentsPageResponse,
+ PodcastEpisodesResponse,
+ PodcastPageResponse,
SearchPageResponse,
StreamResponse,
} from "../types/api";
@@ -20,6 +22,8 @@ export class ApiError extends Error {
}
}
+export type ChannelSort = "latest" | "popular" | "oldest";
+
type ErrorLikeBody = {
error?: string;
message?: string;
@@ -125,12 +129,32 @@ export function fetchCommentReplies(
return request(`${BASE}/comments/replies?${params}`);
}
-export function fetchChannel(url: string, nextpage?: string): Promise {
+export function fetchChannel(
+ url: string,
+ nextpage?: string,
+ sort?: ChannelSort,
+): Promise {
const params = new URLSearchParams({ url });
+ if (sort) params.set("sort", sort);
if (nextpage) params.set("nextpage", nextpage);
return request(`${BASE}/channel?${params}`);
}
+export function fetchPodcasts(url: string, nextpage?: string): Promise {
+ const params = new URLSearchParams({ url });
+ if (nextpage) params.set("nextpage", nextpage);
+ return request(`${BASE}/podcasts?${params}`);
+}
+
+export function fetchPodcastEpisodes(
+ url: string,
+ nextpage?: string,
+): Promise {
+ const params = new URLSearchParams({ url });
+ if (nextpage) params.set("nextpage", nextpage);
+ return request(`${BASE}/podcasts/episodes?${params}`);
+}
+
export function fetchSuggestions(query: string, service: number): Promise {
const params = new URLSearchParams({ query, service: String(service) });
return request(`${BASE}/suggestions?${params}`);
diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts
index 88d9fa2..fbc10f2 100644
--- a/apps/web/src/types/api.ts
+++ b/apps/web/src/types/api.ts
@@ -139,3 +139,27 @@ export type ChannelResponse = {
videos: VideoItem[];
nextpage: string | null;
};
+
+export type PodcastItem = {
+ id: string;
+ title: string;
+ url: string;
+ thumbnailUrl: string;
+ uploaderName: string;
+ streamCount: number;
+ playlistType: string;
+};
+
+export type PodcastPageResponse = {
+ channelName: string;
+ channelUrl: string;
+ podcasts: PodcastItem[];
+ episodes: VideoItem[];
+ nextpage: string | null;
+};
+
+export type PodcastEpisodesResponse = {
+ podcast: PodcastItem;
+ episodes: VideoItem[];
+ nextpage: string | null;
+};
From b04d083297ec7aa177f8421c772ceb93d6cecff4 Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:30:44 +0200
Subject: [PATCH 02/11] feat: add channel podcast browsing
---
.../components/channel-podcasts-section.tsx | 57 +++++++++++
apps/web/src/components/podcast-card.tsx | 47 +++++++++
apps/web/src/hooks/use-channel.ts | 8 +-
apps/web/src/routes/channel.tsx | 51 ++++++++--
apps/web/src/routes/podcasts.tsx | 95 +++++++++++++++++++
5 files changed, 248 insertions(+), 10 deletions(-)
create mode 100644 apps/web/src/components/channel-podcasts-section.tsx
create mode 100644 apps/web/src/components/podcast-card.tsx
create mode 100644 apps/web/src/routes/podcasts.tsx
diff --git a/apps/web/src/components/channel-podcasts-section.tsx b/apps/web/src/components/channel-podcasts-section.tsx
new file mode 100644
index 0000000..785f893
--- /dev/null
+++ b/apps/web/src/components/channel-podcasts-section.tsx
@@ -0,0 +1,57 @@
+import { usePodcasts } from "../hooks/use-podcasts";
+import { detectProvider } from "../lib/provider";
+import { PodcastCard } from "./podcast-card";
+
+const SKELETON_KEYS = [
+ "podcast-skeleton-1",
+ "podcast-skeleton-2",
+ "podcast-skeleton-3",
+ "podcast-skeleton-4",
+];
+
+type Props = {
+ channelUrl: string;
+ channelAvatar?: string;
+};
+
+export function ChannelPodcastsSection({ channelUrl, channelAvatar }: Props) {
+ const isYoutube = detectProvider(channelUrl) === "youtube";
+ const query = usePodcasts(channelUrl, isYoutube);
+ const podcasts = query.data?.pages.flatMap((page) => page.podcasts) ?? [];
+
+ if (!isYoutube || query.isError || (query.isFetched && podcasts.length === 0)) return null;
+
+ return (
+
+
+
+
Podcasts
+
Podcast playlists from this channel
+
+ {query.hasNextPage && (
+
+ )}
+
+ {query.isLoading ? (
+
+ {SKELETON_KEYS.map((key) => (
+
+ ))}
+
+ ) : (
+
+ {podcasts.map((podcast) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/podcast-card.tsx b/apps/web/src/components/podcast-card.tsx
new file mode 100644
index 0000000..a8cdc35
--- /dev/null
+++ b/apps/web/src/components/podcast-card.tsx
@@ -0,0 +1,47 @@
+import { Link } from "@tanstack/react-router";
+import { proxyImage } from "../lib/proxy";
+import type { PodcastItem } from "../types/api";
+
+type Props = {
+ podcast: PodcastItem;
+ channelAvatar?: string;
+};
+
+export function PodcastCard({ podcast, channelAvatar }: Props) {
+ const thumbnail = proxyImage(podcast.thumbnailUrl);
+ const count = podcast.streamCount === 1 ? "1 episode" : `${podcast.streamCount} episodes`;
+
+ return (
+
+
+

+ {channelAvatar && (
+

+ )}
+
+
+
+ {podcast.title}
+
+
{podcast.uploaderName}
+
{count}
+
+
+ );
+}
diff --git a/apps/web/src/hooks/use-channel.ts b/apps/web/src/hooks/use-channel.ts
index e376584..4cdb958 100644
--- a/apps/web/src/hooks/use-channel.ts
+++ b/apps/web/src/hooks/use-channel.ts
@@ -1,4 +1,5 @@
import { useInfiniteQuery } from "@tanstack/react-query";
+import type { ChannelSort } from "../lib/api";
import { fetchChannel } from "../lib/api";
import { mapVideoItem } from "../lib/mappers";
import { proxyImage } from "../lib/proxy";
@@ -19,11 +20,11 @@ type ChannelPage = {
nextpage: string | null;
};
-export function useChannel(channelUrl: string) {
+export function useChannel(channelUrl: string, sort?: ChannelSort) {
const query = useInfiniteQuery({
- queryKey: ["channel", channelUrl],
+ queryKey: ["channel", channelUrl, sort],
queryFn: async ({ pageParam }): Promise => {
- const res = await fetchChannel(channelUrl, pageParam as string | undefined);
+ const res = await fetchChannel(channelUrl, pageParam as string | undefined, sort);
const isFirstPage = pageParam === undefined;
return {
meta: isFirstPage
@@ -43,6 +44,7 @@ export function useChannel(channelUrl: string) {
initialPageParam: undefined as string | undefined,
getNextPageParam: (last: ChannelPage | undefined) => last?.nextpage ?? undefined,
enabled: channelUrl.length > 0,
+ placeholderData: (previousData) => previousData,
});
const pages = query.data?.pages ?? [];
diff --git a/apps/web/src/routes/channel.tsx b/apps/web/src/routes/channel.tsx
index 00dc7c1..cabb4aa 100644
--- a/apps/web/src/routes/channel.tsx
+++ b/apps/web/src/routes/channel.tsx
@@ -1,5 +1,6 @@
-import { createFileRoute } from "@tanstack/react-router";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { ChannelAvatar } from "../components/channel-avatar";
+import { ChannelPodcastsSection } from "../components/channel-podcasts-section";
import { PageSpinner } from "../components/page-spinner";
import { ScrollSentinel } from "../components/scroll-sentinel";
import { VideoCard } from "../components/video-card";
@@ -7,11 +8,30 @@ import { VerifiedBadgeIcon } from "../components/watch-icons";
import { useBlockedFilter } from "../hooks/use-blocked-filter";
import { useChannel } from "../hooks/use-channel";
import { useSubscriptions } from "../hooks/use-subscriptions";
-import { ApiError } from "../lib/api";
+import { ApiError, type ChannelSort } from "../lib/api";
import { formatViews } from "../lib/format";
+const CHANNEL_SORT_OPTIONS: { value: ChannelSort; label: string }[] = [
+ { value: "latest", label: "Latest" },
+ { value: "popular", label: "Popular" },
+ { value: "oldest", label: "Oldest" },
+];
+
+function toChannelSort(value: unknown): ChannelSort | undefined {
+ if (value === "latest" || value === "popular" || value === "oldest") return value;
+ return undefined;
+}
+
+function validateChannelSearch(search: Record) {
+ const url = typeof search.url === "string" ? search.url : "";
+ const sort = toChannelSort(search.sort);
+ return sort ? { url, sort } : { url };
+}
+
function ChannelPage() {
- const { url } = Route.useSearch();
+ const { url, sort: searchSort } = Route.useSearch();
+ const sort = searchSort ?? "latest";
+ const navigate = useNavigate({ from: "/channel" });
const {
meta,
videos,
@@ -22,7 +42,7 @@ function ChannelPage() {
hasNextPage,
isFetchingNextPage,
fetchNextPage,
- } = useChannel(url);
+ } = useChannel(url, searchSort);
const { add, remove, isSubscribed } = useSubscriptions();
const { filter } = useBlockedFilter();
@@ -37,6 +57,10 @@ function ChannelPage() {
}
}
+ function selectSort(nextSort: ChannelSort) {
+ navigate({ search: { url, sort: nextSort }, replace: true });
+ }
+
if (isLoading) return ;
if (isError) {
const message = error instanceof ApiError ? error.message : "Unable to load channel right now.";
@@ -89,6 +113,21 @@ function ChannelPage() {
)}
+
+
{filter(videos).map((v, index) => (
) => ({
- url: typeof search.url === "string" ? search.url : "",
- }),
+ validateSearch: validateChannelSearch,
component: ChannelPage,
});
diff --git a/apps/web/src/routes/podcasts.tsx b/apps/web/src/routes/podcasts.tsx
new file mode 100644
index 0000000..652d308
--- /dev/null
+++ b/apps/web/src/routes/podcasts.tsx
@@ -0,0 +1,95 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { ChannelAvatar } from "../components/channel-avatar";
+import { PageSpinner } from "../components/page-spinner";
+import { ScrollSentinel } from "../components/scroll-sentinel";
+import { VideoGrid } from "../components/video-grid";
+import { useBlockedFilter } from "../hooks/use-blocked-filter";
+import { usePodcastEpisodes } from "../hooks/use-podcast-episodes";
+import { ApiError } from "../lib/api";
+import { proxyImage } from "../lib/proxy";
+
+function PodcastsPage() {
+ const { url, avatar } = Route.useSearch();
+ const query = usePodcastEpisodes(url);
+ const firstPage = query.data?.pages[0];
+ const podcast = firstPage?.podcast;
+ const { filter } = useBlockedFilter();
+ const rawEpisodes = query.data?.pages.flatMap((page) => page.episodes) ?? [];
+ const channelAvatar =
+ avatar || rawEpisodes.find((episode) => episode.channelAvatar)?.channelAvatar || "";
+ const episodes = filter(
+ rawEpisodes.map((episode) =>
+ episode.channelAvatar || !channelAvatar
+ ? episode
+ : { ...episode, channelAvatar, rawChannelAvatar: channelAvatar },
+ ),
+ );
+
+ if (url.trim().length === 0) {
+ return
No podcast selected.
;
+ }
+
+ if (query.isLoading) return
;
+
+ if (query.isError) {
+ const message =
+ query.error instanceof ApiError ? query.error.message : "Unable to load podcast episodes.";
+ return (
+
+
{message}
+
+
+ );
+ }
+
+ return (
+
+ {podcast && (
+
+ )}
+ {episodes.length === 0 ? (
+
No episodes found.
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export const Route = createFileRoute("/podcasts")({
+ validateSearch: (search: Record
) => ({
+ url: typeof search.url === "string" ? search.url : "",
+ avatar: typeof search.avatar === "string" ? search.avatar : undefined,
+ }),
+ component: PodcastsPage,
+});
From 24054fe0962771b89b999c9240003e01b96d0c62 Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:30:54 +0200
Subject: [PATCH 03/11] feat: use dedicated saved video APIs
---
apps/web/src/components/shorts-actions.tsx | 4 +-
apps/web/src/hooks/use-favorites-playlist.ts | 38 +++++++-----------
.../web/src/hooks/use-watch-later-playlist.ts | 38 +++++++-----------
apps/web/src/lib/api-collections.ts | 40 ++++++++++++++++++-
apps/web/src/lib/authed.ts | 18 +++++++--
apps/web/src/lib/proxy.ts | 13 +++++-
apps/web/src/types/user.ts | 13 ++++++
7 files changed, 110 insertions(+), 54 deletions(-)
diff --git a/apps/web/src/components/shorts-actions.tsx b/apps/web/src/components/shorts-actions.tsx
index 48a6a94..c502c39 100644
--- a/apps/web/src/components/shorts-actions.tsx
+++ b/apps/web/src/components/shorts-actions.tsx
@@ -48,7 +48,7 @@ export function ShortsActions({ stream, onOpenComments, className, compact }: Pr
await addFavorite({
url: stream.id,
title: stream.title,
- thumbnail: stream.thumbnail,
+ thumbnail: stream.rawThumbnail || stream.thumbnail,
duration: stream.duration,
});
}
@@ -62,7 +62,7 @@ export function ShortsActions({ stream, onOpenComments, className, compact }: Pr
await addWatchLater({
url: stream.id,
title: stream.title,
- thumbnail: stream.thumbnail,
+ thumbnail: stream.rawThumbnail || stream.thumbnail,
duration: stream.duration,
});
}
diff --git a/apps/web/src/hooks/use-favorites-playlist.ts b/apps/web/src/hooks/use-favorites-playlist.ts
index e13b441..201b81d 100644
--- a/apps/web/src/hooks/use-favorites-playlist.ts
+++ b/apps/web/src/hooks/use-favorites-playlist.ts
@@ -1,11 +1,9 @@
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
-import { createPlaylist } from "../lib/api-playlists";
-import type { PlaylistItem } from "../types/user";
-import { usePlaylists } from "./use-playlists";
+import { addFavorite, fetchFavorites, removeFavorite } from "../lib/api-collections";
+import { useAuth } from "./use-auth";
-const KEY = ["playlists"];
-const FAVORITES_NAME = "Favorites";
+const KEY = ["favorites"];
type AddPayload = {
url: string;
@@ -17,17 +15,18 @@ type AddPayload = {
type Intent = { url: string; adding: boolean };
export function useFavoritesPlaylist() {
- const { query, addVideo, removeVideo } = usePlaylists();
- const qc = useQueryClient();
+ const { authReady, isAuthed } = useAuth();
const intentRef = useRef(null);
const [intent, setIntent] = useState(null);
-
- const playlists = query.data ?? [];
- const favoritesPlaylist = playlists.find((p) => p.name === FAVORITES_NAME);
+ const query = useQuery({
+ queryKey: KEY,
+ queryFn: fetchFavorites,
+ enabled: authReady && isAuthed,
+ });
function isInFavorites(videoUrl: string): boolean {
if (intentRef.current?.url === videoUrl) return intentRef.current.adding;
- return favoritesPlaylist?.videos.some((v) => v.url === videoUrl) ?? false;
+ return query.data?.some((item) => item.videoUrl === videoUrl) ?? false;
}
function applyIntent(value: Intent | null) {
@@ -35,19 +34,12 @@ export function useFavoritesPlaylist() {
setIntent(value);
}
- async function ensurePlaylist(): Promise {
- if (favoritesPlaylist) return favoritesPlaylist.id;
- const created = await createPlaylist(FAVORITES_NAME);
- qc.setQueryData(KEY, (old) => [...(old ?? []), created]);
- return created.id;
- }
-
async function add(payload: AddPayload): Promise {
if (isInFavorites(payload.url)) return;
applyIntent({ url: payload.url, adding: true });
try {
- const playlistId = await ensurePlaylist();
- await addVideo.mutateAsync({ playlistId, video: payload });
+ await addFavorite(payload.url);
+ await query.refetch();
} catch (e) {
applyIntent(null);
throw e;
@@ -56,10 +48,10 @@ export function useFavoritesPlaylist() {
}
async function remove(videoUrl: string): Promise {
- if (!favoritesPlaylist) return;
applyIntent({ url: videoUrl, adding: false });
try {
- await removeVideo.mutateAsync({ playlistId: favoritesPlaylist.id, videoUrl });
+ await removeFavorite(videoUrl);
+ await query.refetch();
} catch (e) {
applyIntent(null);
throw e;
diff --git a/apps/web/src/hooks/use-watch-later-playlist.ts b/apps/web/src/hooks/use-watch-later-playlist.ts
index 2156299..4898ebf 100644
--- a/apps/web/src/hooks/use-watch-later-playlist.ts
+++ b/apps/web/src/hooks/use-watch-later-playlist.ts
@@ -1,11 +1,9 @@
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
-import { createPlaylist } from "../lib/api-playlists";
-import type { PlaylistItem } from "../types/user";
-import { usePlaylists } from "./use-playlists";
+import { addWatchLater, fetchWatchLater, removeWatchLater } from "../lib/api-collections";
+import { useAuth } from "./use-auth";
-const KEY = ["playlists"];
-const WATCH_LATER_NAME = "Watch Later";
+const KEY = ["watch-later"];
type AddPayload = {
url: string;
@@ -17,17 +15,18 @@ type AddPayload = {
type Intent = { url: string; adding: boolean };
export function useWatchLaterPlaylist() {
- const { query, addVideo, removeVideo } = usePlaylists();
- const qc = useQueryClient();
+ const { authReady, isAuthed } = useAuth();
const intentRef = useRef(null);
const [intent, setIntent] = useState(null);
-
- const playlists = query.data ?? [];
- const watchLaterPlaylist = playlists.find((p) => p.name === WATCH_LATER_NAME);
+ const query = useQuery({
+ queryKey: KEY,
+ queryFn: fetchWatchLater,
+ enabled: authReady && isAuthed,
+ });
function isInWatchLater(videoUrl: string): boolean {
if (intentRef.current?.url === videoUrl) return intentRef.current.adding;
- return watchLaterPlaylist?.videos.some((v) => v.url === videoUrl) ?? false;
+ return query.data?.some((item) => item.url === videoUrl) ?? false;
}
function applyIntent(value: Intent | null) {
@@ -35,19 +34,12 @@ export function useWatchLaterPlaylist() {
setIntent(value);
}
- async function ensurePlaylist(): Promise {
- if (watchLaterPlaylist) return watchLaterPlaylist.id;
- const created = await createPlaylist(WATCH_LATER_NAME);
- qc.setQueryData(KEY, (old) => [...(old ?? []), created]);
- return created.id;
- }
-
async function add(payload: AddPayload): Promise {
if (isInWatchLater(payload.url)) return;
applyIntent({ url: payload.url, adding: true });
try {
- const playlistId = await ensurePlaylist();
- await addVideo.mutateAsync({ playlistId, video: payload });
+ await addWatchLater(payload);
+ await query.refetch();
} catch (e) {
applyIntent(null);
throw e;
@@ -56,10 +48,10 @@ export function useWatchLaterPlaylist() {
}
async function remove(videoUrl: string): Promise {
- if (!watchLaterPlaylist) return;
applyIntent({ url: videoUrl, adding: false });
try {
- await removeVideo.mutateAsync({ playlistId: watchLaterPlaylist.id, videoUrl });
+ await removeWatchLater(videoUrl);
+ await query.refetch();
} catch (e) {
applyIntent(null);
throw e;
diff --git a/apps/web/src/lib/api-collections.ts b/apps/web/src/lib/api-collections.ts
index 5c9b7fa..76070ba 100644
--- a/apps/web/src/lib/api-collections.ts
+++ b/apps/web/src/lib/api-collections.ts
@@ -1,4 +1,4 @@
-import type { BlockedItem, ProgressItem } from "../types/user";
+import type { BlockedItem, FavoriteItem, ProgressItem, WatchLaterItem } from "../types/user";
import { ApiError } from "./api";
import { authed, authedJson } from "./authed";
@@ -11,7 +11,9 @@ async function throwIfFailed(res: Response, fallback: string): Promise {
}
export async function fetchProgress(videoUrl: string): Promise {
- const res = await authed(`${BASE}/progress/${encodeURIComponent(videoUrl)}`);
+ const res = await authed(`${BASE}/progress/${encodeURIComponent(videoUrl)}`, undefined, {
+ silentStatuses: [404],
+ });
if (res.status === 404) return { videoUrl, position: 0, updatedAt: 0 };
const body = await res.json();
if (!res.ok) throw new ApiError((body as { error: string }).error, res.status);
@@ -72,3 +74,37 @@ export async function unblockVideo(url: string): Promise {
});
await throwIfFailed(res, "unblock failed");
}
+
+export function fetchFavorites(): Promise {
+ return authedJson(`${BASE}/favorites`);
+}
+
+export function addFavorite(videoUrl: string): Promise {
+ return authedJson(`${BASE}/favorites/${encodeURIComponent(videoUrl)}`, { method: "POST" });
+}
+
+export async function removeFavorite(videoUrl: string): Promise {
+ const res = await authed(`${BASE}/favorites/${encodeURIComponent(videoUrl)}`, {
+ method: "DELETE",
+ });
+ await throwIfFailed(res, "remove failed");
+}
+
+export function fetchWatchLater(): Promise {
+ return authedJson(`${BASE}/watch-later`);
+}
+
+export function addWatchLater(item: Omit): Promise {
+ return authedJson(`${BASE}/watch-later`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(item),
+ });
+}
+
+export async function removeWatchLater(videoUrl: string): Promise {
+ const res = await authed(`${BASE}/watch-later/${encodeURIComponent(videoUrl)}`, {
+ method: "DELETE",
+ });
+ await throwIfFailed(res, "remove failed");
+}
diff --git a/apps/web/src/lib/authed.ts b/apps/web/src/lib/authed.ts
index 8b0fd55..24d8f18 100644
--- a/apps/web/src/lib/authed.ts
+++ b/apps/web/src/lib/authed.ts
@@ -12,7 +12,19 @@ function withBearer(init: RequestInit | undefined, token: string): RequestInit {
return { ...init, headers };
}
-export async function authed(url: string, init?: RequestInit): Promise {
+type AuthedOptions = {
+ silentStatuses?: number[];
+};
+
+function shouldLogStatus(status: number, options: AuthedOptions | undefined): boolean {
+ return !(options?.silentStatuses ?? []).includes(status);
+}
+
+export async function authed(
+ url: string,
+ init?: RequestInit,
+ options?: AuthedOptions,
+): Promise {
const method = init?.method ?? "GET";
const path = sanitizeRequestPath(url);
let token = useAuthStore.getState().token;
@@ -54,7 +66,7 @@ export async function authed(url: string, init?: RequestInit): Promise
try {
const retryToken = await refreshSession();
const retryRes = await fetch(url, withBearer(init, retryToken));
- if (!retryRes.ok) {
+ if (!retryRes.ok && shouldLogStatus(retryRes.status, options)) {
recordApiError({
endpoint: url,
status: retryRes.status,
@@ -82,7 +94,7 @@ export async function authed(url: string, init?: RequestInit): Promise
throw new ApiError("Session expired", 401);
}
}
- if (!res.ok) {
+ if (!res.ok && shouldLogStatus(res.status, options)) {
recordApiError({
endpoint: url,
status: res.status,
diff --git a/apps/web/src/lib/proxy.ts b/apps/web/src/lib/proxy.ts
index f1a2db6..e814f29 100644
--- a/apps/web/src/lib/proxy.ts
+++ b/apps/web/src/lib/proxy.ts
@@ -15,6 +15,16 @@ function isRemoteUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
+function extractProxyTarget(url: string): string | null {
+ try {
+ const parsed = new URL(url);
+ if (!parsed.pathname.endsWith("/proxy") && !parsed.pathname.endsWith("/api/proxy")) return null;
+ return parsed.searchParams.get("url");
+ } catch {
+ return null;
+ }
+}
+
export function proxyDashManifest(url: string): string {
if (!url) return url;
return isRemoteUrl(url) ? proxyUrl(url) : url;
@@ -33,7 +43,8 @@ function needsProxy(url: string): boolean {
export function proxyImage(url: string): string {
if (!url) return url;
- const normalized = url.startsWith("httpss://") ? `https://${url.slice(9)}` : url;
+ const raw = extractProxyTarget(url) ?? url;
+ const normalized = raw.startsWith("httpss://") ? `https://${raw.slice(9)}` : raw;
if (!needsProxy(normalized)) return normalized;
return proxyUrl(normalized);
}
diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts
index d24d3bd..8bf67d0 100644
--- a/apps/web/src/types/user.ts
+++ b/apps/web/src/types/user.ts
@@ -37,6 +37,19 @@ export type PlaylistItem = {
createdAt: number;
};
+export type FavoriteItem = {
+ videoUrl: string;
+ favoritedAt: number;
+};
+
+export type WatchLaterItem = {
+ url: string;
+ title: string;
+ thumbnail: string;
+ duration: number;
+ addedAt: number;
+};
+
export type ProgressItem = {
videoUrl: string;
position: number;
From ad50e775b13d3cee10ea322f84be50f9a6e37b5a Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:31:08 +0200
Subject: [PATCH 04/11] feat: add saved collection pages
---
.../components/library-collection-card.tsx | 67 +++++++++++++++++++
apps/web/src/hooks/use-favorite-streams.ts | 33 +++++++++
apps/web/src/hooks/use-watch-later-streams.ts | 20 ++++++
apps/web/src/lib/watch-later-mappers.ts | 18 +++++
apps/web/src/routes/favorites.tsx | 30 +++++++++
apps/web/src/routes/playlists.tsx | 19 +++++-
apps/web/src/routes/watch-later.tsx | 30 +++++++++
7 files changed, 216 insertions(+), 1 deletion(-)
create mode 100644 apps/web/src/components/library-collection-card.tsx
create mode 100644 apps/web/src/hooks/use-favorite-streams.ts
create mode 100644 apps/web/src/hooks/use-watch-later-streams.ts
create mode 100644 apps/web/src/lib/watch-later-mappers.ts
create mode 100644 apps/web/src/routes/favorites.tsx
create mode 100644 apps/web/src/routes/watch-later.tsx
diff --git a/apps/web/src/components/library-collection-card.tsx b/apps/web/src/components/library-collection-card.tsx
new file mode 100644
index 0000000..723e2d1
--- /dev/null
+++ b/apps/web/src/components/library-collection-card.tsx
@@ -0,0 +1,67 @@
+import { Link } from "@tanstack/react-router";
+
+type Props = {
+ kind: "favorites" | "watch-later";
+ title: string;
+ count: number;
+ thumbnail?: string;
+};
+
+function EmptyLibraryIcon() {
+ return (
+
+ );
+}
+
+export function LibraryCollectionCard({ kind, title, count, thumbnail }: Props) {
+ const label = `${count} video${count !== 1 ? "s" : ""}`;
+ const body = (
+
+
+ {thumbnail ? (
+

+ ) : (
+
+
+
+ )}
+
+ {label}
+
+
+
+
+ {title}
+
+
{label}
+
+
+ );
+
+ return kind === "favorites" ? (
+ {body}
+ ) : (
+ {body}
+ );
+}
diff --git a/apps/web/src/hooks/use-favorite-streams.ts b/apps/web/src/hooks/use-favorite-streams.ts
new file mode 100644
index 0000000..4989a31
--- /dev/null
+++ b/apps/web/src/hooks/use-favorite-streams.ts
@@ -0,0 +1,33 @@
+import { useQueries, useQuery } from "@tanstack/react-query";
+import { fetchStream } from "../lib/api";
+import { fetchFavorites } from "../lib/api-collections";
+import { mapStreamResponse } from "../lib/mappers";
+import type { VideoStream } from "../types/stream";
+import { useAuth } from "./use-auth";
+
+function isVideoStream(value: VideoStream | undefined): value is VideoStream {
+ return value !== undefined;
+}
+
+export function useFavoriteStreams() {
+ const { authReady, isAuthed } = useAuth();
+ const favorites = useQuery({
+ queryKey: ["favorites"],
+ queryFn: fetchFavorites,
+ enabled: authReady && isAuthed,
+ });
+ const streams = useQueries({
+ queries: (favorites.data ?? []).map((item) => ({
+ queryKey: ["favorite-stream", item.videoUrl],
+ queryFn: () =>
+ fetchStream(item.videoUrl).then((res) => mapStreamResponse(res, item.videoUrl)),
+ enabled: favorites.isSuccess,
+ })),
+ });
+
+ return {
+ count: favorites.data?.length ?? 0,
+ videos: streams.map((query) => query.data).filter(isVideoStream),
+ isLoading: favorites.isLoading || streams.some((query) => query.isLoading),
+ };
+}
diff --git a/apps/web/src/hooks/use-watch-later-streams.ts b/apps/web/src/hooks/use-watch-later-streams.ts
new file mode 100644
index 0000000..88908e3
--- /dev/null
+++ b/apps/web/src/hooks/use-watch-later-streams.ts
@@ -0,0 +1,20 @@
+import { useQuery } from "@tanstack/react-query";
+import { fetchWatchLater } from "../lib/api-collections";
+import { mapWatchLaterItem } from "../lib/watch-later-mappers";
+import { useAuth } from "./use-auth";
+
+export function useWatchLaterStreams() {
+ const { authReady, isAuthed } = useAuth();
+ const query = useQuery({
+ queryKey: ["watch-later"],
+ queryFn: fetchWatchLater,
+ enabled: authReady && isAuthed,
+ });
+ const items = query.data ?? [];
+
+ return {
+ count: items.length,
+ videos: items.map(mapWatchLaterItem),
+ isLoading: query.isLoading,
+ };
+}
diff --git a/apps/web/src/lib/watch-later-mappers.ts b/apps/web/src/lib/watch-later-mappers.ts
new file mode 100644
index 0000000..a825564
--- /dev/null
+++ b/apps/web/src/lib/watch-later-mappers.ts
@@ -0,0 +1,18 @@
+import type { VideoStream } from "../types/stream";
+import type { WatchLaterItem } from "../types/user";
+import { proxyImage } from "./proxy";
+
+export function mapWatchLaterItem(item: WatchLaterItem): VideoStream {
+ return {
+ id: item.url,
+ title: item.title,
+ thumbnail: proxyImage(item.thumbnail),
+ rawThumbnail: item.thumbnail,
+ rawChannelAvatar: "",
+ channelName: "",
+ channelAvatar: "",
+ views: 0,
+ duration: item.duration,
+ publishedAt: item.addedAt > 0 ? item.addedAt : undefined,
+ };
+}
diff --git a/apps/web/src/routes/favorites.tsx b/apps/web/src/routes/favorites.tsx
new file mode 100644
index 0000000..be093de
--- /dev/null
+++ b/apps/web/src/routes/favorites.tsx
@@ -0,0 +1,30 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { PageSpinner } from "../components/page-spinner";
+import { VideoGrid } from "../components/video-grid";
+import { useBlockedFilter } from "../hooks/use-blocked-filter";
+import { useFavoriteStreams } from "../hooks/use-favorite-streams";
+
+function FavoritesPage() {
+ const { videos, count, isLoading } = useFavoriteStreams();
+ const { filter } = useBlockedFilter();
+
+ if (isLoading) return ;
+
+ return (
+
+
+ {videos.length === 0 ? (
+
No favorites yet.
+ ) : (
+
+ )}
+
+ );
+}
+
+export const Route = createFileRoute("/favorites")({ component: FavoritesPage });
diff --git a/apps/web/src/routes/playlists.tsx b/apps/web/src/routes/playlists.tsx
index 6e21f4a..64cad39 100644
--- a/apps/web/src/routes/playlists.tsx
+++ b/apps/web/src/routes/playlists.tsx
@@ -1,10 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { ConfirmModal } from "../components/confirm-modal";
+import { LibraryCollectionCard } from "../components/library-collection-card";
import { PlaylistCard } from "../components/playlist-card";
import { PlaylistCreateModal } from "../components/playlist-create-modal";
import { Toast } from "../components/toast";
+import { useFavoriteStreams } from "../hooks/use-favorite-streams";
import { usePlaylists } from "../hooks/use-playlists";
+import { useWatchLaterStreams } from "../hooks/use-watch-later-streams";
function EmptyState() {
return (
@@ -17,6 +20,8 @@ function EmptyState() {
function PlaylistsPage() {
const { query, create, remove } = usePlaylists();
+ const favorites = useFavoriteStreams();
+ const watchLater = useWatchLaterStreams();
const playlists = query.data ?? [];
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState>(new Set());
@@ -106,10 +111,22 @@ function PlaylistsPage() {
)}
- {playlists.length === 0 ? (
+ {playlists.length === 0 && favorites.count === 0 && watchLater.count === 0 ? (
) : (
+
+
{playlists.map((playlist, index) => (
;
+
+ return (
+
+
+ {videos.length === 0 ? (
+
No videos saved for later.
+ ) : (
+
+ )}
+
+ );
+}
+
+export const Route = createFileRoute("/watch-later")({ component: WatchLaterPage });
From 18ce7643018886531ec579dd3396163181a2329a Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:31:16 +0200
Subject: [PATCH 05/11] fix: stream downloader artifacts directly
---
apps/web/src/components/download-sheet.tsx | 24 ++++++++
.../components/downloader-job-feedback.tsx | 42 +++++++++++++-
apps/web/src/hooks/use-downloader-job.ts | 51 +++++++++++------
apps/web/src/lib/api-downloader.ts | 56 +++++++++++--------
apps/web/src/lib/ios-device.ts | 9 ---
apps/web/src/types/downloader.ts | 10 ++--
6 files changed, 135 insertions(+), 57 deletions(-)
diff --git a/apps/web/src/components/download-sheet.tsx b/apps/web/src/components/download-sheet.tsx
index 45697c6..b707725 100644
--- a/apps/web/src/components/download-sheet.tsx
+++ b/apps/web/src/components/download-sheet.tsx
@@ -3,6 +3,7 @@ import { useArtifactDownloadOnDone } from "../hooks/use-artifact-download-on-don
import { useDownloaderJob } from "../hooks/use-downloader-job";
import { useOverlayLock } from "../hooks/use-overlay-lock";
import { useSmoothDismiss } from "../hooks/use-smooth-dismiss";
+import { deleteDownloaderJob } from "../lib/api-downloader";
import type { VideoStream } from "../types/stream";
import {
buildDownloaderCreatePayload,
@@ -28,13 +29,17 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
isQueued,
isRunning,
errorText,
+ isFailed,
openArtifact,
reset,
start,
canUseIosShareFlow,
+ cancelJob,
+ isCancelling,
} = downloader;
const isBusy = isQueued || isRunning;
const [artifactError, setArtifactError] = useState(null);
+ const [clearPending, setClearPending] = useState(false);
const options = useMemo(() => buildDownloadOptions(stream), [stream]);
const [mode, setMode] = useState("video");
const [selectedId, setSelectedId] = useState(
@@ -69,6 +74,19 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
start(buildDownloaderCreatePayload(stream.id, selected));
}
+ async function clearJob() {
+ if (!jobId) return reset();
+ setClearPending(true);
+ try {
+ await deleteDownloaderJob(jobId);
+ reset();
+ setArtifactError(null);
+ } catch (error) {
+ setArtifactError(error instanceof Error ? error.message : "Failed to clear download job");
+ }
+ setClearPending(false);
+ }
+
return (
diff --git a/apps/web/src/components/downloader-job-feedback.tsx b/apps/web/src/components/downloader-job-feedback.tsx
index c2825b1..daab663 100644
--- a/apps/web/src/components/downloader-job-feedback.tsx
+++ b/apps/web/src/components/downloader-job-feedback.tsx
@@ -23,6 +23,12 @@ type Props = {
errorText: string | null;
immersive?: boolean;
forceWaiting?: boolean;
+ canCancel?: boolean;
+ cancelPending?: boolean;
+ onCancel?: () => void;
+ canClear?: boolean;
+ clearPending?: boolean;
+ onClear?: () => void;
};
function formatResolved(resolved: DownloaderResolvedSelection | null): string | null {
@@ -57,6 +63,12 @@ export function DownloaderJobFeedback({
errorText,
immersive = false,
forceWaiting = false,
+ canCancel = false,
+ cancelPending = false,
+ onCancel,
+ canClear = false,
+ clearPending = false,
+ onClear,
}: Props) {
const resolvedLabel = formatResolved(resolved);
const visibleError =
@@ -69,6 +81,8 @@ export function DownloaderJobFeedback({
const message = downloaderStatusMessage(status, stage, errorCode, visibleError, forceWaiting);
const progress = downloaderProgressValue(status, stage, progressPercent, forceWaiting);
const activeStep = downloaderStageIndex(status, stage);
+ const showCancel = canCancel && !cancelled && !failed && typeof onCancel === "function";
+ const showClear = canClear && typeof onClear === "function";
const showProgress = shouldShowDownloaderProgress(status, forceWaiting) && !failed && !cancelled;
const showPercent = typeof progressPercent === "number" && status === "running";
@@ -87,9 +101,31 @@ export function DownloaderJobFeedback({
{message}
- {showPercent && (
- {Math.round(progress)}%
- )}
+
+ {showPercent && (
+ {Math.round(progress)}%
+ )}
+ {showCancel && (
+
+ )}
+ {showClear && (
+
+ )}
+
{showProgress && (
diff --git a/apps/web/src/hooks/use-downloader-job.ts b/apps/web/src/hooks/use-downloader-job.ts
index 6fae2bf..04316c3 100644
--- a/apps/web/src/hooks/use-downloader-job.ts
+++ b/apps/web/src/hooks/use-downloader-job.ts
@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import {
+ cancelDownloaderJob,
canUseIosShareFlow,
createDownloaderJob,
downloadDownloaderArtifact,
@@ -24,6 +25,11 @@ export function useDownloaderJob() {
mutationFn: (payload: DownloaderCreateJobRequest) => createDownloaderJob(payload),
});
const jobId = create.data?.id;
+ const cancel = useMutation({
+ mutationFn: (id: string) => cancelDownloaderJob(id),
+ onSuccess: (next) =>
+ setEventJob((current) => (current?.id === next.id ? { ...current, ...next } : next)),
+ });
useEffect(() => {
if (!jobId) return;
@@ -52,29 +58,30 @@ export function useDownloaderJob() {
},
});
const job = useMemo(() => {
- if (!eventJob) return query.data;
- if (!query.data || query.data.id !== eventJob.id) return eventJob;
- if (query.data.status === "done" || query.data.status === "failed") {
+ const queryJob = query.data ?? create.data;
+ if (!eventJob) return queryJob;
+ if (!queryJob || queryJob.id !== eventJob.id) return eventJob;
+ if (queryJob.status === "done" || queryJob.status === "failed") {
return {
...eventJob,
- ...query.data,
- resolved: query.data.resolved ?? eventJob.resolved,
- error: query.data.error ?? eventJob.error,
- errorCode: query.data.errorCode ?? eventJob.errorCode,
- tokenFetchMs: query.data.tokenFetchMs ?? eventJob.tokenFetchMs,
- ytdlpMs: query.data.ytdlpMs ?? eventJob.ytdlpMs,
- uploadMs: query.data.uploadMs ?? eventJob.uploadMs,
- totalMs: query.data.totalMs ?? eventJob.totalMs,
+ ...queryJob,
+ resolved: queryJob.resolved ?? eventJob.resolved,
+ error: queryJob.error ?? eventJob.error,
+ errorCode: queryJob.errorCode ?? eventJob.errorCode,
+ tokenFetchMs: queryJob.tokenFetchMs ?? eventJob.tokenFetchMs,
+ ytdlpMs: queryJob.ytdlpMs ?? eventJob.ytdlpMs,
+ uploadMs: queryJob.uploadMs ?? eventJob.uploadMs,
+ totalMs: queryJob.totalMs ?? eventJob.totalMs,
};
}
return {
- ...query.data,
+ ...queryJob,
...eventJob,
- resolved: eventJob.resolved ?? query.data.resolved,
- error: eventJob.error ?? query.data.error,
- errorCode: eventJob.errorCode ?? query.data.errorCode,
+ resolved: eventJob.resolved ?? queryJob.resolved,
+ error: eventJob.error ?? queryJob.error,
+ errorCode: eventJob.errorCode ?? queryJob.errorCode,
};
- }, [eventJob, query.data]);
+ }, [create.data, eventJob, query.data]);
const status: DownloaderJobStatus | null = create.isPending ? "queued" : (job?.status ?? null);
const isQueued = status === "queued";
@@ -92,7 +99,9 @@ export function useDownloaderJob() {
const errorText =
create.error instanceof Error
? create.error.message
- : job?.error || (query.error instanceof Error ? query.error.message : null);
+ : cancel.error instanceof Error
+ ? cancel.error.message
+ : job?.error || (query.error instanceof Error ? query.error.message : null);
function start(payload: DownloaderCreateJobRequest) {
setEventJob(null);
@@ -106,15 +115,22 @@ export function useDownloaderJob() {
return downloadDownloaderArtifact(jobId, options);
}
+ function cancelJob() {
+ if (!jobId || (status !== "queued" && status !== "running")) return;
+ cancel.mutate(jobId);
+ }
+
function reset() {
setEventJob(null);
setSseUnavailable(false);
+ cancel.reset();
create.reset();
}
return {
start,
openArtifact,
+ cancelJob,
reset,
jobId,
status,
@@ -127,6 +143,7 @@ export function useDownloaderJob() {
totalMs,
errorCode,
canUseIosShareFlow: canUseIosShareFlow(),
+ isCancelling: cancel.isPending,
isQueued,
isRunning,
isDone,
diff --git a/apps/web/src/lib/api-downloader.ts b/apps/web/src/lib/api-downloader.ts
index 7629ed0..e306e89 100644
--- a/apps/web/src/lib/api-downloader.ts
+++ b/apps/web/src/lib/api-downloader.ts
@@ -5,7 +5,7 @@ import type {
} from "../types/downloader";
import { ApiError } from "./api";
import { API_BASE as BASE } from "./env";
-import { isIosWebKitBrowser, isMobileDownloadDevice } from "./ios-device";
+import { isIosWebKitBrowser } from "./ios-device";
type ErrorBody = {
error?: string;
@@ -16,6 +16,9 @@ type DownloadArtifactOptions = {
preferShare?: boolean;
};
+const CANCEL_POLL_DELAY_MS = 300;
+const CANCEL_POLL_ATTEMPTS = 8;
+
async function readJson(res: Response): Promise
{
return res.json().catch(() => ({}));
}
@@ -30,6 +33,10 @@ function readErrorMessage(body: unknown, fallback: string): string {
return fallback;
}
+function delay(ms: number): Promise {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
export async function createDownloaderJob(
payload: DownloaderCreateJobRequest,
): Promise {
@@ -54,6 +61,31 @@ export async function fetchDownloaderJob(jobId: string): Promise {
+ const res = await fetch(`${BASE}/downloader/jobs/${encodeURIComponent(jobId)}/cancel`, {
+ method: "POST",
+ });
+ const body = await readJson(res);
+ if (!res.ok) {
+ throw new ApiError(readErrorMessage(body, "Failed to cancel download job"), res.status);
+ }
+ for (let attempt = 0; attempt < CANCEL_POLL_ATTEMPTS; attempt += 1) {
+ const job = await fetchDownloaderJob(jobId);
+ if (job.status !== "queued" && job.status !== "running") return job;
+ await delay(CANCEL_POLL_DELAY_MS);
+ }
+ return fetchDownloaderJob(jobId);
+}
+
+export async function deleteDownloaderJob(jobId: string): Promise {
+ const res = await fetch(`${BASE}/downloader/jobs/${encodeURIComponent(jobId)}`, {
+ method: "DELETE",
+ });
+ if (res.ok || res.status === 404) return;
+ const body = await readJson(res);
+ throw new ApiError(readErrorMessage(body, "Failed to delete download job"), res.status);
+}
+
function extensionFromType(contentType: string | null): string {
const value = contentType ?? "";
if (value.includes("video/mp4")) return "mp4";
@@ -123,25 +155,5 @@ export async function downloadDownloaderArtifact(
await shareDownloaderArtifact(endpoint, jobId);
return;
}
- if (isMobileDownloadDevice()) {
- openArtifactLocation(endpoint);
- return;
- }
- const res = await fetch(endpoint);
- if (!res.ok) {
- const body = await readJson(res);
- throw new ApiError(readErrorMessage(body, "Failed to download artifact"), res.status);
- }
- const blob = await res.blob();
- const fileName = fallbackFileName(jobId, res.headers);
- const objectUrl = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = objectUrl;
- a.download = fileName;
- a.rel = "noopener";
- a.target = "_blank";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
+ openArtifactLocation(endpoint);
}
diff --git a/apps/web/src/lib/ios-device.ts b/apps/web/src/lib/ios-device.ts
index fc94fe5..3da7a5b 100644
--- a/apps/web/src/lib/ios-device.ts
+++ b/apps/web/src/lib/ios-device.ts
@@ -13,11 +13,6 @@ function isWebKitEngine(): boolean {
return hasWebKit && !otherIosBrowser;
}
-function isAndroidDevice(): boolean {
- if (typeof navigator === "undefined") return false;
- return /Android/i.test(navigator.userAgent);
-}
-
export function isIosDevice(): boolean {
if (typeof navigator === "undefined") return false;
return /iPhone|iPad|iPod/.test(navigator.userAgent) || isTouchMac();
@@ -26,7 +21,3 @@ export function isIosDevice(): boolean {
export function isIosWebKitBrowser(): boolean {
return isIosDevice() && isWebKitEngine();
}
-
-export function isMobileDownloadDevice(): boolean {
- return isIosDevice() || isAndroidDevice();
-}
diff --git a/apps/web/src/types/downloader.ts b/apps/web/src/types/downloader.ts
index f774bd6..ed48680 100644
--- a/apps/web/src/types/downloader.ts
+++ b/apps/web/src/types/downloader.ts
@@ -44,11 +44,6 @@ export type DownloaderCreateJobRequest = {
options: DownloaderJobOptions;
};
-export type DownloaderCreateJobResponse = {
- id: string;
- cached: boolean;
-};
-
export type DownloaderResolvedSelection = {
videoItag?: string | null;
audioItag?: string | null;
@@ -70,7 +65,6 @@ export type DownloaderJobResponse = {
totalBytes?: number | null;
etaSeconds?: number | null;
resolved?: DownloaderResolvedSelection | null;
- artifactUrl?: string | null;
errorCode?: string | null;
error?: string | null;
tokenFetchMs?: number | null;
@@ -78,3 +72,7 @@ export type DownloaderJobResponse = {
uploadMs?: number | null;
totalMs?: number | null;
};
+
+export type DownloaderCreateJobResponse = DownloaderJobResponse & {
+ cached?: boolean | null;
+};
From 9bd50f806ed5ae0df084d19a2faf434307d0a4f1 Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:31:24 +0200
Subject: [PATCH 06/11] feat: add responsive search suggestions
---
apps/web/src/components/navbar-search.tsx | 150 ++++++++++++++++++
apps/web/src/components/navbar.tsx | 22 +--
.../src/components/search-overlay-list.tsx | 30 ++--
apps/web/src/components/search-overlay.tsx | 58 ++++---
4 files changed, 204 insertions(+), 56 deletions(-)
create mode 100644 apps/web/src/components/navbar-search.tsx
diff --git a/apps/web/src/components/navbar-search.tsx b/apps/web/src/components/navbar-search.tsx
new file mode 100644
index 0000000..a65a292
--- /dev/null
+++ b/apps/web/src/components/navbar-search.tsx
@@ -0,0 +1,150 @@
+import { useRouterState } from "@tanstack/react-router";
+import { Search } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useDebouncedValue } from "../hooks/use-debounced-value";
+import { useSearchHistory } from "../hooks/use-search-history";
+import { useSearchOverlayNavigation } from "../hooks/use-search-overlay-navigation";
+import { fetchSuggestions } from "../lib/api";
+import { buildSearchOverlayItems } from "../lib/search-overlay-items";
+import { SearchOverlayList } from "./search-overlay-list";
+
+export function NavbarSearch() {
+ const location = useRouterState({ select: (state) => state.location });
+ const currentSearch =
+ location.pathname === "/search" ? (new URLSearchParams(location.searchStr).get("q") ?? "") : "";
+ const [query, setQuery] = useState(currentSearch);
+ const [suggestions, setSuggestions] = useState([]);
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+ const listRef = useRef(null);
+ const { service, navigateAndClose } = useSearchOverlayNavigation({
+ onClose: () => setOpen(false),
+ });
+ const { visibleItems, canLoadMore, loadMore } = useSearchHistory();
+ const debouncedQuery = useDebouncedValue(query, 300);
+ const items = buildSearchOverlayItems(query, visibleItems, suggestions);
+ const showHistory = query.trim().length === 0 && visibleItems.length > 0;
+
+ useEffect(() => {
+ setQuery(currentSearch);
+ }, [currentSearch]);
+
+ useEffect(() => {
+ function onMouseDown(e: MouseEvent) {
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
+ }
+ document.addEventListener("mousedown", onMouseDown);
+ return () => document.removeEventListener("mousedown", onMouseDown);
+ }, []);
+
+ useEffect(() => {
+ if (!debouncedQuery.trim()) {
+ setSuggestions([]);
+ return;
+ }
+ let cancelled = false;
+ fetchSuggestions(debouncedQuery.trim(), service)
+ .then((next) => {
+ if (!cancelled) setSuggestions(next);
+ })
+ .catch(() => {});
+ return () => {
+ cancelled = true;
+ };
+ }, [debouncedQuery, service]);
+
+ useEffect(() => {
+ if (selectedIndex < 0) return;
+ const element = listRef.current?.querySelector(
+ `button[data-item-index="${selectedIndex}"]`,
+ );
+ element?.scrollIntoView({ block: "nearest", behavior: "smooth" });
+ }, [selectedIndex]);
+
+ function submit(e: React.FormEvent) {
+ e.preventDefault();
+ const selected = selectedIndex >= 0 ? items[selectedIndex]?.label : undefined;
+ navigateAndClose(selected ?? query);
+ }
+
+ function selectTerm(term: string) {
+ setQuery(term);
+ navigateAndClose(term);
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Escape") {
+ setOpen(false);
+ return;
+ }
+ if (items.length === 0) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setOpen(true);
+ setSelectedIndex((index) => (index >= items.length - 1 ? 0 : index + 1));
+ return;
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setOpen(true);
+ setSelectedIndex((index) => (index <= 0 ? items.length - 1 : index - 1));
+ return;
+ }
+ if (e.key === "Tab") {
+ const selected = selectedIndex >= 0 ? items[selectedIndex] : items[0];
+ if (!selected) return;
+ e.preventDefault();
+ setQuery(selected.label);
+ setSelectedIndex(-1);
+ }
+ }
+
+ function handleScroll(e: React.UIEvent) {
+ if (!showHistory || !canLoadMore) return;
+ const target = e.currentTarget;
+ const threshold = target.scrollHeight - target.clientHeight - 24;
+ if (target.scrollTop >= threshold) loadMore();
+ }
+
+ return (
+
+
+
+ {open && items.length > 0 && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx
index e7c3700..b5bcfd4 100644
--- a/apps/web/src/components/navbar.tsx
+++ b/apps/web/src/components/navbar.tsx
@@ -10,6 +10,7 @@ import { useUiStore } from "../stores/ui-store";
import { NavbarAccountControls } from "./navbar-account-controls";
import { NavbarLeadingControl } from "./navbar-leading-control";
import { NavbarNotifications } from "./navbar-notifications";
+import { NavbarSearch } from "./navbar-search";
import { Toast } from "./toast";
const SearchOverlay = lazy(() =>
@@ -41,7 +42,7 @@ export function Navbar() {
window.location.assign("/");
}
- useSearchShortcut({ enabled: canOpenSearch, onOpen: () => setSearchOpen(true) });
+ useSearchShortcut({ enabled: canOpenSearch && isMobile, onOpen: () => setSearchOpen(true) });
return (
<>
@@ -72,24 +73,7 @@ export function Navbar() {
)}
- {canOpenSearch && !isMobile && (
-
-
-
- )}
+ {canOpenSearch && !isMobile && }
diff --git a/apps/web/src/components/search-overlay-list.tsx b/apps/web/src/components/search-overlay-list.tsx
index 7650a59..39e9b5e 100644
--- a/apps/web/src/components/search-overlay-list.tsx
+++ b/apps/web/src/components/search-overlay-list.tsx
@@ -7,8 +7,9 @@ type Props = {
selectedIndex: number;
listRef: RefObject
;
onScroll: (e: React.UIEvent) => void;
- onClearAll: () => void;
+ onClearAll?: () => void;
onSelect: (term: string) => void;
+ className?: string;
};
export function SearchOverlayList({
@@ -19,25 +20,28 @@ export function SearchOverlayList({
onScroll,
onClearAll,
onSelect,
+ className,
}: Props) {
if (items.length === 0) return null;
+ const listClass =
+ className ??
+ "mt-1 max-h-[22rem] overflow-y-auto scroll-smooth bg-surface border border-border-strong rounded-lg";
+
return (
-
+
{showHistory && (
-
Recent searches
-
+ {onClearAll && (
+
+ )}
)}
{items.map((item, index) => (
diff --git a/apps/web/src/components/search-overlay.tsx b/apps/web/src/components/search-overlay.tsx
index 938d59a..25e0835 100644
--- a/apps/web/src/components/search-overlay.tsx
+++ b/apps/web/src/components/search-overlay.tsx
@@ -1,4 +1,5 @@
import { useRouterState } from "@tanstack/react-router";
+import { ArrowLeft } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useDebouncedValue } from "../hooks/use-debounced-value";
import { useSearchHistory } from "../hooks/use-search-history";
@@ -94,6 +95,12 @@ export function SearchOverlay({ onClose }: Props) {
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((index) => (index <= 0 ? items.length - 1 : index - 1));
+ } else if (e.key === "Tab") {
+ const selected = selectedIndex >= 0 ? items[selectedIndex] : items[0];
+ if (!selected) return;
+ e.preventDefault();
+ setQuery(selected.label);
+ setSelectedIndex(-1);
}
}
@@ -112,20 +119,20 @@ export function SearchOverlay({ onClose }: Props) {
}
return (
-
-
-
diff --git a/apps/web/src/components/watch-comments-lazy-list.tsx b/apps/web/src/components/watch-comments-lazy-list.tsx
index b302077..719d94b 100644
--- a/apps/web/src/components/watch-comments-lazy-list.tsx
+++ b/apps/web/src/components/watch-comments-lazy-list.tsx
@@ -11,12 +11,17 @@ const FALLBACK_KEYS = Array.from({ length: 3 }, (_, i) => `wcl-${i}`);
type Props = {
comments: Comment[];
videoUrl: string;
+ onSeekTimestamp?: (seconds: number) => void;
};
-export function WatchCommentsLazyList({ comments, videoUrl }: Props) {
+export function WatchCommentsLazyList({ comments, videoUrl, onSeekTimestamp }: Props) {
return (
)}>
-
+
);
}
diff --git a/apps/web/src/components/watch-comments-list.tsx b/apps/web/src/components/watch-comments-list.tsx
index af7b804..31e4e57 100644
--- a/apps/web/src/components/watch-comments-list.tsx
+++ b/apps/web/src/components/watch-comments-list.tsx
@@ -4,10 +4,17 @@ import { WatchCommentRow } from "./watch-comment-row";
type Props = {
comments: Comment[];
videoUrl: string;
+ onSeekTimestamp?: (seconds: number) => void;
};
-export function WatchCommentsList({ comments, videoUrl }: Props) {
+export function WatchCommentsList({ comments, videoUrl, onSeekTimestamp }: Props) {
return comments.map((comment, i) => (
-
+
));
}
diff --git a/apps/web/src/components/watch-comments.tsx b/apps/web/src/components/watch-comments.tsx
index 1f2ce9f..88e7182 100644
--- a/apps/web/src/components/watch-comments.tsx
+++ b/apps/web/src/components/watch-comments.tsx
@@ -9,9 +9,10 @@ const RENDER_STEP = 4;
type Props = {
videoUrl: string;
+ onSeekTimestamp?: (seconds: number) => void;
};
-export function WatchComments({ videoUrl }: Props) {
+export function WatchComments({ videoUrl, onSeekTimestamp }: Props) {
const { data, isFetchingNextPage, hasNextPage, fetchNextPage, isLoading } =
useInfiniteComments(videoUrl);
const [renderCount, setRenderCount] = useState(INITIAL_RENDER_COUNT);
@@ -42,7 +43,11 @@ export function WatchComments({ videoUrl }: Props) {
Comments are disabled for this video.
) : (
-
+
{showSkeletons && SKELETON_KEYS.map((k) =>
)}
{canLoadMore && !isLoading && (
-
+
{reply.likeCount >= 0 && (
{formatLikes(reply.likeCount)} likes
From d4dda82058baab29b178190017acc6b7df936375 Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:32:12 +0200
Subject: [PATCH 08/11] fix: refine player language and layout
---
apps/web/src/components/shorts-video-player.tsx | 4 ++--
apps/web/src/components/watch-layout.tsx | 6 +++---
apps/web/src/hooks/use-settings.ts | 2 +-
apps/web/src/settings/settings-language.tsx | 5 +----
4 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/components/shorts-video-player.tsx b/apps/web/src/components/shorts-video-player.tsx
index 3d4900d..aa96726 100644
--- a/apps/web/src/components/shorts-video-player.tsx
+++ b/apps/web/src/components/shorts-video-player.tsx
@@ -131,11 +131,11 @@ export function ShortsVideoPlayer({
}}
/>
{
- setToast("Original audio unavailable, switched to English");
+ setToast("Original audio unavailable");
}}
originalAudioTrackId={originalAudioTrackId}
preferredDefaultAudioTrackId={preferredDefaultAudioTrackId}
diff --git a/apps/web/src/components/watch-layout.tsx b/apps/web/src/components/watch-layout.tsx
index 9c6f914..0766144 100644
--- a/apps/web/src/components/watch-layout.tsx
+++ b/apps/web/src/components/watch-layout.tsx
@@ -80,11 +80,11 @@ export function WatchLayout({ stream, startTime }: Props) {
{
- setToast("Original audio unavailable, switched to English");
+ setToast("Original audio unavailable");
}}
originalAudioTrackId={originalTrackId}
preferredDefaultAudioTrackId={preferredAudioTrackId}
@@ -102,7 +102,7 @@ export function WatchLayout({ stream, startTime }: Props) {
? "overflow-hidden bg-black"
: "min-w-0 flex-[2] max-w-[133.333vh] flex flex-col gap-4";
const playerBoxClass = cinemaMode
- ? "mx-auto h-[min(calc(100vw*9/16),82svh)] w-[min(100vw,calc(82svh*16/9))]"
+ ? "mx-auto aspect-video w-[min(100%,calc((100svh-4.5rem)*16/9))]"
: "overflow-hidden rounded-lg";
return (
diff --git a/apps/web/src/hooks/use-settings.ts b/apps/web/src/hooks/use-settings.ts
index dfefaa1..03a4ad5 100644
--- a/apps/web/src/hooks/use-settings.ts
+++ b/apps/web/src/hooks/use-settings.ts
@@ -13,7 +13,7 @@ const DEFAULTS: SettingsItem = {
muted: false,
subtitlesEnabled: false,
defaultSubtitleLanguage: "",
- defaultAudioLanguage: "en",
+ defaultAudioLanguage: "",
preferOriginalLanguage: true,
};
diff --git a/apps/web/src/settings/settings-language.tsx b/apps/web/src/settings/settings-language.tsx
index 1f082e1..d27da87 100644
--- a/apps/web/src/settings/settings-language.tsx
+++ b/apps/web/src/settings/settings-language.tsx
@@ -45,15 +45,12 @@ export function SettingsLanguage() {
-
- Subtitle language
-
+ Subtitle language
Preferred subtitle track
update.mutate({ defaultSubtitleLanguage: v })}
- disabled={!settings.subtitlesEnabled}
/>
From a07172141e702c8ad3c2827fff83a96bdcffeeeb Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:32:19 +0200
Subject: [PATCH 09/11] fix: restore nicovideo proxy playback
---
apps/web/src/lib/nico-manifest.ts | 16 +---------------
1 file changed, 1 insertion(+), 15 deletions(-)
diff --git a/apps/web/src/lib/nico-manifest.ts b/apps/web/src/lib/nico-manifest.ts
index 694db11..3d38ff0 100644
--- a/apps/web/src/lib/nico-manifest.ts
+++ b/apps/web/src/lib/nico-manifest.ts
@@ -8,22 +8,8 @@ const RESOLUTION_BANDWIDTH: Record = {
"240p": 300000,
};
-function extractDomandBid(url: string): { cleanUrl: string; domandBid: string | null } {
- const hashIdx = url.indexOf("#");
- if (hashIdx === -1) return { cleanUrl: url, domandBid: null };
- const cleanUrl = url.slice(0, hashIdx);
- const fragment = url.slice(hashIdx + 1);
- const params = new URLSearchParams(fragment);
- const cookie = params.get("cookie");
- if (!cookie) return { cleanUrl, domandBid: null };
- const match = cookie.match(/domand_bid=([^&]+)/);
- return { cleanUrl, domandBid: match ? match[1] : null };
-}
-
function nicoProxyUrl(rawUrl: string, origin: string): string {
- const { cleanUrl, domandBid } = extractDomandBid(rawUrl);
- const base = `${origin}/proxy/nicovideo?url=${encodeURIComponent(cleanUrl)}`;
- return domandBid ? `${base}&domand_bid=${encodeURIComponent(domandBid)}` : base;
+ return `${origin}/proxy/nicovideo?url=${encodeURIComponent(rawUrl)}`;
}
export function buildNicoMasterPlaylist(
From c4fc58de32f5b42d0aac1f75874428908d47bd0e Mon Sep 17 00:00:00 2001
From: Priveetee
Date: Sun, 24 May 2026 11:32:26 +0200
Subject: [PATCH 10/11] chore: remove admin verification toggle
---
apps/web/src/components/admin-settings-panel.tsx | 6 ------
1 file changed, 6 deletions(-)
diff --git a/apps/web/src/components/admin-settings-panel.tsx b/apps/web/src/components/admin-settings-panel.tsx
index cb8ad43..62c71d7 100644
--- a/apps/web/src/components/admin-settings-panel.tsx
+++ b/apps/web/src/components/admin-settings-panel.tsx
@@ -29,12 +29,6 @@ export function AdminSettingsPanel({ settings, pending, onToggle }: Props) {
pending={pending}
onClick={() => onToggle("allowGuest")}
/>
- onToggle("forceEmailVerification")}
- />
Date: Sun, 24 May 2026 11:32:39 +0200
Subject: [PATCH 11/11] chore: update generated route tree
---
apps/web/src/routeTree.gen.ts | 63 +++++++++++++++++++++++++++++++++++
1 file changed, 63 insertions(+)
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index d3008bd..a8dbda6 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as WatchLaterRouteImport } from './routes/watch-later'
import { Route as WatchRouteImport } from './routes/watch'
import { Route as SubscriptionsRouteImport } from './routes/subscriptions'
import { Route as ShortsRouteImport } from './routes/shorts'
@@ -18,10 +19,12 @@ import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as RegisterRouteImport } from './routes/register'
import { Route as ProfileRouteImport } from './routes/profile'
import { Route as PrivacyRouteImport } from './routes/privacy'
+import { Route as PodcastsRouteImport } from './routes/podcasts'
import { Route as PlaylistsRouteImport } from './routes/playlists'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ImportRouteImport } from './routes/import'
import { Route as HistoryRouteImport } from './routes/history'
+import { Route as FavoritesRouteImport } from './routes/favorites'
import { Route as ChannelRouteImport } from './routes/channel'
import { Route as AdminConsoleRouteImport } from './routes/admin-console'
import { Route as IndexRouteImport } from './routes/index'
@@ -30,6 +33,11 @@ import { Route as PlaylistsIdRouteImport } from './routes/playlists_.$id'
import { Route as ImportYoutubeRouteImport } from './routes/import/youtube'
import { Route as ImportPipepipeRouteImport } from './routes/import/pipepipe'
+const WatchLaterRoute = WatchLaterRouteImport.update({
+ id: '/watch-later',
+ path: '/watch-later',
+ getParentRoute: () => rootRouteImport,
+} as any)
const WatchRoute = WatchRouteImport.update({
id: '/watch',
path: '/watch',
@@ -75,6 +83,11 @@ const PrivacyRoute = PrivacyRouteImport.update({
path: '/privacy',
getParentRoute: () => rootRouteImport,
} as any)
+const PodcastsRoute = PodcastsRouteImport.update({
+ id: '/podcasts',
+ path: '/podcasts',
+ getParentRoute: () => rootRouteImport,
+} as any)
const PlaylistsRoute = PlaylistsRouteImport.update({
id: '/playlists',
path: '/playlists',
@@ -95,6 +108,11 @@ const HistoryRoute = HistoryRouteImport.update({
path: '/history',
getParentRoute: () => rootRouteImport,
} as any)
+const FavoritesRoute = FavoritesRouteImport.update({
+ id: '/favorites',
+ path: '/favorites',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ChannelRoute = ChannelRouteImport.update({
id: '/channel',
path: '/channel',
@@ -135,10 +153,12 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin-console': typeof AdminConsoleRoute
'/channel': typeof ChannelRoute
+ '/favorites': typeof FavoritesRoute
'/history': typeof HistoryRoute
'/import': typeof ImportRouteWithChildren
'/login': typeof LoginRoute
'/playlists': typeof PlaylistsRoute
+ '/podcasts': typeof PodcastsRoute
'/privacy': typeof PrivacyRoute
'/profile': typeof ProfileRoute
'/register': typeof RegisterRoute
@@ -148,6 +168,7 @@ export interface FileRoutesByFullPath {
'/shorts': typeof ShortsRoute
'/subscriptions': typeof SubscriptionsRoute
'/watch': typeof WatchRoute
+ '/watch-later': typeof WatchLaterRoute
'/import/pipepipe': typeof ImportPipepipeRoute
'/import/youtube': typeof ImportYoutubeRoute
'/playlists/$id': typeof PlaylistsIdRoute
@@ -157,9 +178,11 @@ export interface FileRoutesByTo {
'/': typeof IndexRoute
'/admin-console': typeof AdminConsoleRoute
'/channel': typeof ChannelRoute
+ '/favorites': typeof FavoritesRoute
'/history': typeof HistoryRoute
'/login': typeof LoginRoute
'/playlists': typeof PlaylistsRoute
+ '/podcasts': typeof PodcastsRoute
'/privacy': typeof PrivacyRoute
'/profile': typeof ProfileRoute
'/register': typeof RegisterRoute
@@ -169,6 +192,7 @@ export interface FileRoutesByTo {
'/shorts': typeof ShortsRoute
'/subscriptions': typeof SubscriptionsRoute
'/watch': typeof WatchRoute
+ '/watch-later': typeof WatchLaterRoute
'/import/pipepipe': typeof ImportPipepipeRoute
'/import/youtube': typeof ImportYoutubeRoute
'/playlists/$id': typeof PlaylistsIdRoute
@@ -179,10 +203,12 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/admin-console': typeof AdminConsoleRoute
'/channel': typeof ChannelRoute
+ '/favorites': typeof FavoritesRoute
'/history': typeof HistoryRoute
'/import': typeof ImportRouteWithChildren
'/login': typeof LoginRoute
'/playlists': typeof PlaylistsRoute
+ '/podcasts': typeof PodcastsRoute
'/privacy': typeof PrivacyRoute
'/profile': typeof ProfileRoute
'/register': typeof RegisterRoute
@@ -192,6 +218,7 @@ export interface FileRoutesById {
'/shorts': typeof ShortsRoute
'/subscriptions': typeof SubscriptionsRoute
'/watch': typeof WatchRoute
+ '/watch-later': typeof WatchLaterRoute
'/import/pipepipe': typeof ImportPipepipeRoute
'/import/youtube': typeof ImportYoutubeRoute
'/playlists_/$id': typeof PlaylistsIdRoute
@@ -203,10 +230,12 @@ export interface FileRouteTypes {
| '/'
| '/admin-console'
| '/channel'
+ | '/favorites'
| '/history'
| '/import'
| '/login'
| '/playlists'
+ | '/podcasts'
| '/privacy'
| '/profile'
| '/register'
@@ -216,6 +245,7 @@ export interface FileRouteTypes {
| '/shorts'
| '/subscriptions'
| '/watch'
+ | '/watch-later'
| '/import/pipepipe'
| '/import/youtube'
| '/playlists/$id'
@@ -225,9 +255,11 @@ export interface FileRouteTypes {
| '/'
| '/admin-console'
| '/channel'
+ | '/favorites'
| '/history'
| '/login'
| '/playlists'
+ | '/podcasts'
| '/privacy'
| '/profile'
| '/register'
@@ -237,6 +269,7 @@ export interface FileRouteTypes {
| '/shorts'
| '/subscriptions'
| '/watch'
+ | '/watch-later'
| '/import/pipepipe'
| '/import/youtube'
| '/playlists/$id'
@@ -246,10 +279,12 @@ export interface FileRouteTypes {
| '/'
| '/admin-console'
| '/channel'
+ | '/favorites'
| '/history'
| '/import'
| '/login'
| '/playlists'
+ | '/podcasts'
| '/privacy'
| '/profile'
| '/register'
@@ -259,6 +294,7 @@ export interface FileRouteTypes {
| '/shorts'
| '/subscriptions'
| '/watch'
+ | '/watch-later'
| '/import/pipepipe'
| '/import/youtube'
| '/playlists_/$id'
@@ -269,10 +305,12 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminConsoleRoute: typeof AdminConsoleRoute
ChannelRoute: typeof ChannelRoute
+ FavoritesRoute: typeof FavoritesRoute
HistoryRoute: typeof HistoryRoute
ImportRoute: typeof ImportRouteWithChildren
LoginRoute: typeof LoginRoute
PlaylistsRoute: typeof PlaylistsRoute
+ PodcastsRoute: typeof PodcastsRoute
PrivacyRoute: typeof PrivacyRoute
ProfileRoute: typeof ProfileRoute
RegisterRoute: typeof RegisterRoute
@@ -282,11 +320,19 @@ export interface RootRouteChildren {
ShortsRoute: typeof ShortsRoute
SubscriptionsRoute: typeof SubscriptionsRoute
WatchRoute: typeof WatchRoute
+ WatchLaterRoute: typeof WatchLaterRoute
PlaylistsIdRoute: typeof PlaylistsIdRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/watch-later': {
+ id: '/watch-later'
+ path: '/watch-later'
+ fullPath: '/watch-later'
+ preLoaderRoute: typeof WatchLaterRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/watch': {
id: '/watch'
path: '/watch'
@@ -350,6 +396,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PrivacyRouteImport
parentRoute: typeof rootRouteImport
}
+ '/podcasts': {
+ id: '/podcasts'
+ path: '/podcasts'
+ fullPath: '/podcasts'
+ preLoaderRoute: typeof PodcastsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/playlists': {
id: '/playlists'
path: '/playlists'
@@ -378,6 +431,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof HistoryRouteImport
parentRoute: typeof rootRouteImport
}
+ '/favorites': {
+ id: '/favorites'
+ path: '/favorites'
+ fullPath: '/favorites'
+ preLoaderRoute: typeof FavoritesRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/channel': {
id: '/channel'
path: '/channel'
@@ -449,10 +509,12 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminConsoleRoute: AdminConsoleRoute,
ChannelRoute: ChannelRoute,
+ FavoritesRoute: FavoritesRoute,
HistoryRoute: HistoryRoute,
ImportRoute: ImportRouteWithChildren,
LoginRoute: LoginRoute,
PlaylistsRoute: PlaylistsRoute,
+ PodcastsRoute: PodcastsRoute,
PrivacyRoute: PrivacyRoute,
ProfileRoute: ProfileRoute,
RegisterRoute: RegisterRoute,
@@ -462,6 +524,7 @@ const rootRouteChildren: RootRouteChildren = {
ShortsRoute: ShortsRoute,
SubscriptionsRoute: SubscriptionsRoute,
WatchRoute: WatchRoute,
+ WatchLaterRoute: WatchLaterRoute,
PlaylistsIdRoute: PlaylistsIdRoute,
}
export const routeTree = rootRouteImport