From d6cbd2bc94cb7b2b3cba6a4acacc2684433152f4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 12:53:59 +0200 Subject: [PATCH 01/19] style: improve Shorts player layout --- .../src/components/shorts-action-button.tsx | 12 +++--- .../src/components/shorts-info-overlay.tsx | 2 +- apps/web/src/components/shorts-navigation.tsx | 2 +- .../src/components/shorts-player-layout.tsx | 43 +++++++++++++++++++ .../src/components/shorts-shell-loader.tsx | 2 +- .../src/components/video-player-layout.tsx | 14 ++++++ apps/web/src/components/video-player-types.ts | 1 + apps/web/src/components/video-player.tsx | 2 + apps/web/src/styles/shorts-overrides.css | 41 ++++++++++++++---- 9 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/shorts-player-layout.tsx diff --git a/apps/web/src/components/shorts-action-button.tsx b/apps/web/src/components/shorts-action-button.tsx index 92da127f..80baacc9 100644 --- a/apps/web/src/components/shorts-action-button.tsx +++ b/apps/web/src/components/shorts-action-button.tsx @@ -21,7 +21,11 @@ export function ShortsActionButton({ const iconClass = compact ? "h-4 w-4" : "h-6 w-6"; const rootClass = compact ? "flex flex-col items-center gap-0.5 text-white/90 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-50" - : "flex flex-col items-center gap-1 text-white/90 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-50"; + : "flex flex-col items-center gap-1 text-fg-soft transition-colors hover:text-fg disabled:cursor-not-allowed disabled:opacity-50"; + const idleClass = compact + ? "border-white/20 bg-black/55 hover:border-white/40 hover:bg-black/75" + : "border-border-strong bg-surface hover:bg-surface-strong"; + const activeClass = compact ? "border-white/80 bg-white text-black" : "border-fg bg-fg text-app"; return ( - - - ); -} +import { resolveShortsRouteTarget } from "../lib/shorts-route"; function ShortsPage() { const { v } = Route.useSearch(); + const navigate = Route.useNavigate(); const { settings } = useSettings(); + const publicParam = resolveShortsRouteTarget(v)?.publicParam; + + useEffect(() => { + if (!publicParam || publicParam === v) return; + void navigate({ search: { v: publicParam }, replace: true }); + }, [navigate, publicParam, v]); + if (settings.hideShorts) { return (
-
+

Shorts are hidden

You can re-enable Shorts from video preferences.

- )} -
-
-
- - - ); - } - return (
-
-
- -
-
+
+
!commentsOpen && onWheel(event)} onTouchStart={(event) => !commentsOpen && onTouchStart(event.touches[0]?.clientY ?? null, event.target) @@ -151,25 +148,31 @@ export function ShortsPlayerStage({ onEnded={onAutoNext} /> )} -
- -
- + {current.title && ( +
+ +
+ )} + {current.title && ( + + )} +
+
+ {current.title && ( + + )} +
-
-
- -
{showComments && ( diff --git a/apps/web/src/components/shorts-shell-loader.tsx b/apps/web/src/components/shorts-shell-loader.tsx index 3cf88a80..6fd6809c 100644 --- a/apps/web/src/components/shorts-shell-loader.tsx +++ b/apps/web/src/components/shorts-shell-loader.tsx @@ -7,9 +7,11 @@ type Props = { export function ShortsShellLoader({ sectionClass }: Props) { return (
-
-
- +
+
+
+ +
diff --git a/apps/web/src/lib/shorts-navigation.ts b/apps/web/src/lib/shorts-navigation.ts index 3474b4c3..930555f6 100644 --- a/apps/web/src/lib/shorts-navigation.ts +++ b/apps/web/src/lib/shorts-navigation.ts @@ -1,9 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; -const WHEEL_THRESHOLD = 14; +const WHEEL_THRESHOLD = 48; const WHEEL_RESET_MS = 180; -const WHEEL_LOCK_MS = 70; -const WHEEL_MAX_STEPS = 2; const SWIPE_THRESHOLD = 30; const SWIPE_MIN_DISTANCE = 10; const SWIPE_VELOCITY_THRESHOLD = 0.24; @@ -103,16 +101,16 @@ export function useShortsNavigation( if (now - wheelLastAtRef.current > WHEEL_RESET_MS) wheelAccumRef.current = 0; wheelLastAtRef.current = now; wheelAccumRef.current += deltaY; - if (now < wheelLockedUntilRef.current) return; + if (now < wheelLockedUntilRef.current) { + wheelAccumRef.current = 0; + wheelLockedUntilRef.current = now + WHEEL_RESET_MS; + return; + } if (Math.abs(wheelAccumRef.current) < WHEEL_THRESHOLD) return; - const steps = Math.min( - WHEEL_MAX_STEPS, - Math.max(1, Math.floor(Math.abs(wheelAccumRef.current) / WHEEL_THRESHOLD)), - ); - const moved = moveBy(wheelAccumRef.current > 0 ? steps : -steps, "user"); + const moved = moveBy(wheelAccumRef.current > 0 ? 1 : -1, "user"); wheelAccumRef.current = 0; if (!moved) return; - wheelLockedUntilRef.current = now + WHEEL_LOCK_MS; + wheelLockedUntilRef.current = now + WHEEL_RESET_MS; }, [moveBy], ); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b7905775..42f39319 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -140,11 +140,17 @@ function RootLayout() { } if (shortsPage) { + const shortsMainClass = `transition-[margin] duration-200 ${ + isMobile ? "ml-0" : collapsed ? "ml-14" : "ml-48" + }`; return (
-
+
diff --git a/apps/web/src/styles/shorts-overrides.css b/apps/web/src/styles/shorts-overrides.css index 6f3e75f8..8189c180 100644 --- a/apps/web/src/styles/shorts-overrides.css +++ b/apps/web/src/styles/shorts-overrides.css @@ -1,10 +1,33 @@ -.shorts-frame { +.shorts-viewport { + height: calc(100dvh - 3.5rem - env(safe-area-inset-top, 0px)); + min-height: 0; + overscroll-behavior: none; +} + +.shorts-stage { aspect-ratio: 9 / 16; - width: min(calc(100vw - 1rem), calc((100svh - 4.75rem) * 9 / 16)); + width: min(100%, calc((100dvh - 4.5rem - env(safe-area-inset-top, 0px)) * 9 / 16)); max-width: 100%; max-height: 100%; } +.shorts-frame { + height: 100%; + width: 100%; +} + +@media (min-width: 640px) { + .shorts-stage { + width: min(100%, calc((100dvh - 5rem - env(safe-area-inset-top, 0px)) * 9 / 16)); + } +} + +@media (min-width: 1024px) { + .shorts-stage { + width: min(100%, calc((100dvh - 5.5rem - env(safe-area-inset-top, 0px)) * 9 / 16)); + } +} + .shorts-shell [data-media-player], .shorts-shell [data-media-player] .vds-media-provider, .shorts-shell [data-media-player] .vds-video-layout, From 1a9efcf89209a8fb6a53007cfefc365a0821698a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 15:44:44 +0200 Subject: [PATCH 06/19] fix: normalize blocked content identities --- apps/web/src/hooks/use-blocked-filter.ts | 85 ++++++++------- apps/web/src/hooks/use-blocked.ts | 28 ++--- apps/web/src/hooks/use-stream.ts | 8 +- apps/web/src/lib/blocked-content.ts | 130 +++++++++++++++++++++++ apps/web/src/lib/stream-request.ts | 12 ++- 5 files changed, 205 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/lib/blocked-content.ts diff --git a/apps/web/src/hooks/use-blocked-filter.ts b/apps/web/src/hooks/use-blocked-filter.ts index 9e4568c2..f1e15f65 100644 --- a/apps/web/src/hooks/use-blocked-filter.ts +++ b/apps/web/src/hooks/use-blocked-filter.ts @@ -1,8 +1,11 @@ import { useCallback, useMemo } from "react"; -import { normalizeBlockedKeyword, titleMatchesBlockedKeyword } from "../lib/blocked-keyword-filter"; +import { + type BlockableVideo, + type BlockedChannelIdentity, + createBlockedContentMatcher, +} from "../lib/blocked-content"; import type { ChannelResultItem } from "../types/api"; import type { PublicPlaylistInfo } from "../types/playlist"; -import type { VideoStream } from "../types/stream"; import { useAuth } from "./use-auth"; import { useBlocked } from "./use-blocked"; @@ -10,63 +13,69 @@ export function useBlockedFilter() { const { isAuthed } = useAuth(); const { channels, videos, keywords } = useBlocked(); - const blockedChannelUrls = useMemo( - () => new Set((channels.data ?? []).map((item) => item.url)), - [channels.data], - ); - const blockedVideoUrls = useMemo( - () => new Set((videos.data ?? []).map((item) => item.url)), - [videos.data], - ); - const blockedChannelNames = useMemo( - () => new Set((channels.data ?? []).map((item) => item.name?.toLowerCase()).filter(Boolean)), - [channels.data], - ); - const blockedKeywords = useMemo( + const matcher = useMemo( () => - (keywords.data ?? []) - .map((item) => normalizeBlockedKeyword(item.keyword)) - .filter((keyword) => keyword.length > 0), - [keywords.data], + createBlockedContentMatcher( + isAuthed ? (channels.data ?? []) : [], + isAuthed ? (videos.data ?? []) : [], + isAuthed ? (keywords.data ?? []).map((item) => item.keyword) : [], + ), + [channels.data, isAuthed, keywords.data, videos.data], ); const isBlocked = useCallback( - (stream: VideoStream): boolean => { - if (blockedVideoUrls.has(stream.id)) return true; - if (stream.channelUrl && blockedChannelUrls.has(stream.channelUrl)) return true; - return titleMatchesBlockedKeyword(stream.title, blockedKeywords); - }, - [blockedChannelUrls, blockedKeywords, blockedVideoUrls], + (stream: BlockableVideo): boolean => matcher.isVideoBlocked(stream), + [matcher], ); const filter = useCallback( - (streams: VideoStream[]): VideoStream[] => { - if (!isAuthed) return streams; - return streams.filter((s) => !isBlocked(s)); - }, - [isAuthed, isBlocked], + (streams: T[]): T[] => matcher.filterVideos(streams), + [matcher], + ); + + const isChannelIdentityBlocked = useCallback( + (channel: BlockedChannelIdentity): boolean => matcher.isChannelBlocked(channel), + [matcher], + ); + + const findBlockedChannel = useCallback( + (channel: BlockedChannelIdentity) => matcher.findBlockedChannel(channel), + [matcher], ); const isChannelBlocked = useCallback( - (channel: ChannelResultItem): boolean => blockedChannelUrls.has(channel.url), - [blockedChannelUrls], + (channel: ChannelResultItem): boolean => isChannelIdentityBlocked(channel), + [isChannelIdentityBlocked], + ); + + const isVideoExplicitlyBlocked = useCallback( + (video: Pick): boolean => matcher.isVideoExplicitlyBlocked(video), + [matcher], + ); + + const findBlockedVideo = useCallback( + (video: Pick) => matcher.findBlockedVideo(video), + [matcher], ); const isPlaylistBlocked = useCallback( (playlist: PublicPlaylistInfo): boolean => { - const uploader = playlist.uploaderName.trim().toLowerCase(); - return uploader.length > 0 && blockedChannelNames.has(uploader); + return isChannelIdentityBlocked({ name: playlist.uploaderName }); }, - [blockedChannelNames], + [isChannelIdentityBlocked], ); return { filter, + findBlockedChannel, + findBlockedVideo, isBlocked, isChannelBlocked, + isChannelIdentityBlocked, isPlaylistBlocked, - blockedChannelUrls, - blockedKeywords, - blockedVideoUrls, + isVideoExplicitlyBlocked, + blockedChannelUrls: matcher.channelUrls, + blockedKeywords: matcher.normalizedKeywords, + blockedVideoUrls: matcher.videoUrls, }; } diff --git a/apps/web/src/hooks/use-blocked.ts b/apps/web/src/hooks/use-blocked.ts index da72c686..15edbcaf 100644 --- a/apps/web/src/hooks/use-blocked.ts +++ b/apps/web/src/hooks/use-blocked.ts @@ -12,10 +12,6 @@ import { } from "../lib/api-collections"; import { useAuth } from "./use-auth"; -const CHANNELS_KEY = ["blocked-channels"]; -const KEYWORDS_KEY = ["blocked-keywords"]; -const VIDEOS_KEY = ["blocked-videos"]; - type BlockChannelArgs = { url: string; name?: string; @@ -30,22 +26,26 @@ type BlockVideoArgs = { export function useBlocked() { const qc = useQueryClient(); - const { authReady, isAuthed } = useAuth(); + const { authReady, isAuthed, me } = useAuth(); + const owner = isAuthed ? (me?.id ?? "authenticated") : "signed-out"; + const channelsKey = ["blocked-channels", owner] as const; + const keywordsKey = ["blocked-keywords", owner] as const; + const videosKey = ["blocked-videos", owner] as const; const channels = useQuery({ - queryKey: CHANNELS_KEY, + queryKey: channelsKey, queryFn: fetchBlockedChannels, enabled: authReady && isAuthed, staleTime: 5 * 60 * 1000, }); const videos = useQuery({ - queryKey: VIDEOS_KEY, + queryKey: videosKey, queryFn: fetchBlockedVideos, enabled: authReady && isAuthed, staleTime: 5 * 60 * 1000, }); const keywords = useQuery({ - queryKey: KEYWORDS_KEY, + queryKey: keywordsKey, queryFn: fetchBlockedKeywords, enabled: authReady && isAuthed, staleTime: 5 * 60 * 1000, @@ -54,34 +54,34 @@ export function useBlocked() { const addChannel = useMutation({ mutationFn: ({ url, name, thumbnailUrl, global }: BlockChannelArgs) => isAuthed ? blockChannel(url, name, thumbnailUrl, global) : Promise.resolve(), - onSuccess: () => qc.invalidateQueries({ queryKey: CHANNELS_KEY }), + onSuccess: () => qc.invalidateQueries({ queryKey: channelsKey }), }); const removeChannel = useMutation({ mutationFn: (url: string) => (isAuthed ? unblockChannel(url) : Promise.resolve()), - onSuccess: () => qc.invalidateQueries({ queryKey: CHANNELS_KEY }), + onSuccess: () => qc.invalidateQueries({ queryKey: channelsKey }), }); const addVideo = useMutation({ mutationFn: ({ url, global }: BlockVideoArgs) => isAuthed ? blockVideo(url, global) : Promise.resolve(), - onSuccess: () => qc.invalidateQueries({ queryKey: VIDEOS_KEY }), + onSuccess: () => qc.invalidateQueries({ queryKey: videosKey }), }); const removeVideo = useMutation({ mutationFn: (url: string) => (isAuthed ? unblockVideo(url) : Promise.resolve()), - onSuccess: () => qc.invalidateQueries({ queryKey: VIDEOS_KEY }), + onSuccess: () => qc.invalidateQueries({ queryKey: videosKey }), }); const addKeyword = useMutation({ mutationFn: (keyword: string) => isAuthed ? blockKeyword(keyword).then(() => undefined) : Promise.resolve(), - onSuccess: () => qc.invalidateQueries({ queryKey: KEYWORDS_KEY }), + onSuccess: () => qc.invalidateQueries({ queryKey: keywordsKey }), }); const removeKeyword = useMutation({ mutationFn: (keyword: string) => (isAuthed ? unblockKeyword(keyword) : Promise.resolve()), - onSuccess: () => qc.invalidateQueries({ queryKey: KEYWORDS_KEY }), + onSuccess: () => qc.invalidateQueries({ queryKey: keywordsKey }), }); return { diff --git a/apps/web/src/hooks/use-stream.ts b/apps/web/src/hooks/use-stream.ts index f30572e6..991679d3 100644 --- a/apps/web/src/hooks/use-stream.ts +++ b/apps/web/src/hooks/use-stream.ts @@ -11,10 +11,12 @@ import { sabrBootstrapQueryKey, streamQueryKey, } from "../lib/stream-request"; +import { useAuthStore } from "../stores/auth-store"; export function streamQueryOptions(url: string, useAuthenticatedStream = false, enabled = true) { + const ownerId = useAuthenticatedStream ? useAuthStore.getState().me?.id : null; return queryOptions({ - queryKey: streamQueryKey(url, useAuthenticatedStream), + queryKey: streamQueryKey(url, useAuthenticatedStream, ownerId), queryFn: ({ signal }) => fetchStream( url, @@ -62,8 +64,10 @@ export function useStream(url: string, useAuthenticatedStream = false, enabled = } export function useSabrBootstrap(url: string, useAuthenticatedStream = false, enabled = true) { + const authenticatedOwnerId = useAuthStore((state) => state.me?.id); + const ownerId = useAuthenticatedStream ? authenticatedOwnerId : null; return useQuery({ - queryKey: sabrBootstrapQueryKey(url, useAuthenticatedStream), + queryKey: sabrBootstrapQueryKey(url, useAuthenticatedStream, ownerId), queryFn: ({ signal }) => fetchSabrBootstrap( url, diff --git a/apps/web/src/lib/blocked-content.ts b/apps/web/src/lib/blocked-content.ts new file mode 100644 index 00000000..e17d6084 --- /dev/null +++ b/apps/web/src/lib/blocked-content.ts @@ -0,0 +1,130 @@ +import { normalizeBlockedKeyword, titleMatchesBlockedKeyword } from "./blocked-keyword-filter"; + +export type BlockedChannelIdentity = { + url?: string | null; + name?: string | null; +}; + +export type BlockableVideo = BlockedChannelIdentity & { + id?: string | null; + title?: string | null; + channelUrl?: string | null; + channelName?: string | null; +}; + +type BlockedItem = { + url: string; + name?: string | null; +}; + +const YOUTUBE_HOSTS = new Set([ + "youtube.com", + "www.youtube.com", + "m.youtube.com", + "music.youtube.com", +]); + +function youtubeVideoId(url: URL): string | null { + if (url.hostname.toLowerCase() === "youtu.be") return url.pathname.split("/")[1] || null; + if (!YOUTUBE_HOSTS.has(url.hostname.toLowerCase())) return null; + const queryId = url.searchParams.get("v"); + if (queryId) return queryId; + const [kind, id] = url.pathname.split("/").filter(Boolean); + return kind && ["embed", "live", "shorts"].includes(kind.toLowerCase()) ? (id ?? null) : null; +} + +export function normalizeBlockedContentUrl(value: string | null | undefined): string { + const trimmed = value?.trim() ?? ""; + if (!trimmed) return ""; + try { + const url = new URL(trimmed); + const videoId = youtubeVideoId(url); + if (videoId) return `youtube:video:${videoId}`; + const hostname = YOUTUBE_HOSTS.has(url.hostname.toLowerCase()) + ? "youtube.com" + : url.hostname.toLowerCase(); + const port = url.port ? `:${url.port}` : ""; + const path = url.pathname.replace(/\/+$/, "") || "/"; + return `${hostname}${port}${path}`; + } catch { + return trimmed.replace(/\/+$/, ""); + } +} + +function normalizeName(value: string | null | undefined): string { + return (value ?? "").normalize("NFKC").trim().toLowerCase(); +} + +export function createBlockedContentMatcher( + channels: BlockedItem[], + videos: BlockedItem[], + keywords: string[], +) { + const channelsByUrl = new Map(); + const channelsByName = new Map(); + const videosByUrl = new Map(); + for (const item of channels) { + const url = normalizeBlockedContentUrl(item.url); + const name = normalizeName(item.name); + if (url) channelsByUrl.set(url, item); + if (name) channelsByName.set(name, item); + } + for (const item of videos) { + const url = normalizeBlockedContentUrl(item.url); + if (url) videosByUrl.set(url, item); + } + const channelUrls = new Set(channelsByUrl.keys()); + const videoUrls = new Set(videosByUrl.keys()); + const normalizedKeywords = keywords.map(normalizeBlockedKeyword).filter(Boolean); + + function findBlockedChannel(channel: BlockedChannelIdentity): BlockedItem | undefined { + const url = normalizeBlockedContentUrl(channel.url); + const name = normalizeName(channel.name); + return ( + (url ? channelsByUrl.get(url) : undefined) ?? (name ? channelsByName.get(name) : undefined) + ); + } + + function isChannelBlocked(channel: BlockedChannelIdentity): boolean { + return findBlockedChannel(channel) !== undefined; + } + + function findBlockedVideo(video: Pick): BlockedItem | undefined { + const candidates = new Set( + [video.url, video.id].map(normalizeBlockedContentUrl).filter((url) => url.length > 0), + ); + for (const candidate of candidates) { + const item = videosByUrl.get(candidate); + if (item) return item; + } + return undefined; + } + + function isVideoExplicitlyBlocked(video: Pick): boolean { + return findBlockedVideo(video) !== undefined; + } + + function isVideoBlocked(video: BlockableVideo): boolean { + return ( + isVideoExplicitlyBlocked(video) || + isChannelBlocked({ url: video.channelUrl, name: video.channelName }) || + titleMatchesBlockedKeyword(video.title ?? "", normalizedKeywords) + ); + } + + function filterVideos(items: T[]): T[] { + return items.filter((item) => !isVideoBlocked(item)); + } + + return { + channelUrls, + normalizedKeywords, + videoUrls, + filterVideos, + findBlockedChannel, + findBlockedVideo, + isChannelBlocked, + isVideoBlocked, + isVideoExplicitlyBlocked, + }; +} diff --git a/apps/web/src/lib/stream-request.ts b/apps/web/src/lib/stream-request.ts index 5f541a60..f90b8b1f 100644 --- a/apps/web/src/lib/stream-request.ts +++ b/apps/web/src/lib/stream-request.ts @@ -22,13 +22,17 @@ function providerStreamPath(provider: ReturnType) { export function streamQueryKey( url: string, authenticated: boolean, -): readonly ["stream", string, "auth" | "anon"] { - return ["stream", url, authenticated ? "auth" : "anon"]; + ownerId?: string | null, +): readonly ["stream", string, string] { + const scope = authenticated && ownerId ? `auth:${ownerId}` : authenticated ? "auth" : "anon"; + return ["stream", url, scope]; } export function sabrBootstrapQueryKey( url: string, authenticated: boolean, -): readonly ["stream-bootstrap", string, "auth" | "anon"] { - return ["stream-bootstrap", url, authenticated ? "auth" : "anon"]; + ownerId?: string | null, +): readonly ["stream-bootstrap", string, string] { + const scope = authenticated && ownerId ? `auth:${ownerId}` : authenticated ? "auth" : "anon"; + return ["stream-bootstrap", url, scope]; } From 49a4702aa89a05997b871ba5019cc7fc0a210b6c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 15:44:49 +0200 Subject: [PATCH 07/19] fix: hide blocked content across playback --- .../src/components/channel-page-content.tsx | 10 +++- apps/web/src/components/continue-watching.tsx | 15 +++-- .../src/components/shorts-player-shell.tsx | 5 +- .../components/video-card-feedback-panel.tsx | 19 +++--- apps/web/src/components/watch-layout.tsx | 10 +++- .../web/src/components/watch-more-actions.tsx | 19 +++--- .../src/hooks/use-watch-autoplay-preload.ts | 7 +-- apps/web/src/hooks/use-watch-playback-flow.ts | 3 +- apps/web/src/hooks/use-watch-playlist.tsx | 18 +++++- apps/web/src/routes/embed_.$videoId.tsx | 6 +- apps/web/src/routes/history.tsx | 13 ++-- apps/web/src/routes/playlists.tsx | 31 +++++++--- apps/web/src/routes/playlists_.$id.tsx | 11 +++- .../src/routes/subscriptions_.channels.tsx | 6 +- apps/web/src/routes/watch.tsx | 5 +- apps/web/src/types/playlist.ts | 1 + apps/web/tests/blocked-content.test.ts | 59 +++++++++++++++++++ 17 files changed, 183 insertions(+), 55 deletions(-) create mode 100644 apps/web/tests/blocked-content.test.ts diff --git a/apps/web/src/components/channel-page-content.tsx b/apps/web/src/components/channel-page-content.tsx index cf57e7bc..5877bbd8 100644 --- a/apps/web/src/components/channel-page-content.tsx +++ b/apps/web/src/components/channel-page-content.tsx @@ -41,7 +41,7 @@ export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNaviga fetchNextPage, } = useChannel(sourceUrl, sort, searchQuery, live); const { add, remove, isSubscribed } = useSubscriptions(); - const { filter } = useBlockedFilter(); + const { filter, isChannelIdentityBlocked } = useBlockedFilter(); useDocumentTitle(meta?.name); const subscribed = isSubscribed(sourceUrl); @@ -95,6 +95,14 @@ export function ChannelPageContent({ sourceUrl, sort, searchQuery, tab, onNaviga
); } + if (meta && isChannelIdentityBlocked({ url: sourceUrl, name: meta.name })) { + return ( + + ); + } return (
diff --git a/apps/web/src/components/continue-watching.tsx b/apps/web/src/components/continue-watching.tsx index 0d715b9d..1408a19a 100644 --- a/apps/web/src/components/continue-watching.tsx +++ b/apps/web/src/components/continue-watching.tsx @@ -1,3 +1,5 @@ +import { useMemo } from "react"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useHistory } from "../hooks/use-history"; import { isVideoInProgress } from "../lib/watch-progress"; import { ContinueCard } from "./continue-card"; @@ -6,10 +8,15 @@ const MAX_ITEMS = 12; export function ContinueWatching() { const { items } = useHistory(); - const displayed = items - .filter((h) => isVideoInProgress(h.progress, h.duration)) - .sort((a, b) => b.watchedAt - a.watchedAt) - .slice(0, MAX_ITEMS); + const { filter } = useBlockedFilter(); + const displayed = useMemo( + () => + filter(items) + .filter((h) => isVideoInProgress(h.progress, h.duration)) + .sort((a, b) => b.watchedAt - a.watchedAt) + .slice(0, MAX_ITEMS), + [filter, items], + ); if (displayed.length === 0) return null; return ( diff --git a/apps/web/src/components/shorts-player-shell.tsx b/apps/web/src/components/shorts-player-shell.tsx index 4964a1f3..c8953c23 100644 --- a/apps/web/src/components/shorts-player-shell.tsx +++ b/apps/web/src/components/shorts-player-shell.tsx @@ -25,12 +25,11 @@ export function ShortsPlayerShell({ targetUrl }: Props) { const feed = useShortsFeed(); const shorts = useShortsRouteFeed(feed.shorts, targetUrl); const { authReady, isAuthed } = useAuth(); - const { data: instance, isPending: instancePending } = useInstance(); + const { isPending: instancePending } = useInstance(); const { settings, update, settingsReady } = useSettings(); const playerRef = useRef(null); const [commentsOpen, setCommentsOpen] = useState(false); - const useAuthenticatedStream = - isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); + const useAuthenticatedStream = isAuthed; const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); const handleAutoNext = () => { diff --git a/apps/web/src/components/video-card-feedback-panel.tsx b/apps/web/src/components/video-card-feedback-panel.tsx index 901cf9e0..8859a3fe 100644 --- a/apps/web/src/components/video-card-feedback-panel.tsx +++ b/apps/web/src/components/video-card-feedback-panel.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useAuth } from "../hooks/use-auth"; import { useBlocked } from "../hooks/use-blocked"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useWatchLaterPlaylist } from "../hooks/use-watch-later-playlist"; import { goto } from "../lib/route-redirect"; import { watchLaterResultLabel } from "../lib/watch-later-labels"; @@ -20,11 +21,15 @@ export function VideoCardFeedbackPanel({ stream, anchorEl, onClose, onSaved }: P const { isAuthed } = useAuth(); const [playlistOpen, setPlaylistOpen] = useState(false); const watchLater = useWatchLaterPlaylist(); - const { channels, videos, addChannel, removeChannel, addVideo, removeVideo } = useBlocked(); - const channelBlocked = - !!stream.channelUrl && - (channels.data ?? []).some((blocked) => blocked.url === stream.channelUrl); - const videoBlocked = (videos.data ?? []).some((blocked) => blocked.url === stream.id); + const { addChannel, removeChannel, addVideo, removeVideo } = useBlocked(); + const { findBlockedChannel, findBlockedVideo } = useBlockedFilter(); + const blockedChannel = findBlockedChannel({ + url: stream.channelUrl, + name: stream.channelName, + }); + const blockedVideo = findBlockedVideo(stream); + const channelBlocked = blockedChannel !== undefined; + const videoBlocked = blockedVideo !== undefined; function requireAuth(): boolean { if (isAuthed) return false; @@ -35,7 +40,7 @@ export function VideoCardFeedbackPanel({ stream, anchorEl, onClose, onSaved }: P function toggleVideoBlock() { if (requireAuth()) return; if (videoBlocked) { - removeVideo.mutate(stream.id); + removeVideo.mutate(blockedVideo?.url ?? stream.id); return; } addVideo.mutate({ url: stream.id, global: false }); @@ -44,7 +49,7 @@ export function VideoCardFeedbackPanel({ stream, anchorEl, onClose, onSaved }: P function toggleChannelBlock() { if (!stream.channelUrl || requireAuth()) return; if (channelBlocked) { - removeChannel.mutate(stream.channelUrl); + removeChannel.mutate(blockedChannel?.url ?? stream.channelUrl); return; } addChannel.mutate({ diff --git a/apps/web/src/components/watch-layout.tsx b/apps/web/src/components/watch-layout.tsx index f81a11ea..cbe1f674 100644 --- a/apps/web/src/components/watch-layout.tsx +++ b/apps/web/src/components/watch-layout.tsx @@ -1,4 +1,5 @@ -import { useRef } from "react"; +import { useMemo, useRef } from "react"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useDeArrowBranding } from "../hooks/use-dearrow"; import { useMobile } from "../hooks/use-mobile"; import { usePlayerError } from "../hooks/use-player-error"; @@ -35,6 +36,7 @@ export function WatchLayout({ const isMobile = useMobile(); const save = useSaveProgress(stream.id); const { settings, update, settingsReady } = useSettings(); + const { filter } = useBlockedFilter(); const branding = useDeArrowBranding(stream.id, stream.title, stream.thumbnail, stream.duration); const displayStream = { ...stream, ...branding }; const isLive = stream.streamType === "live_stream" || stream.streamType === "audio_live_stream"; @@ -42,7 +44,10 @@ export function WatchLayout({ const { on: bulletCommentsOn } = useDanmakuStore(); const { isNicoNico, bulletComments } = useWatchBulletComments(stream.id, settings.hideComments); const sponsor = useWatchSponsorBlock(stream, settings); - const relatedStreams = settings.hideRelatedVideos ? [] : (stream.related ?? []); + const relatedStreams = useMemo( + () => (settings.hideRelatedVideos ? [] : filter(stream.related ?? [])), + [filter, settings.hideRelatedVideos, stream.related], + ); const playlist = useWatchPlaylist(list, shuffle, currentParam); const cinemaMode = useWatchLayoutStore((state) => state.cinemaMode); const seekRef = useRef<((seconds: number) => void) | null>(null); @@ -55,6 +60,7 @@ export function WatchLayout({ ); const { autoplay, playerEvents } = useWatchPlaybackFlow({ stream, + related: relatedStreams, settings, settingsReady, isLive, diff --git a/apps/web/src/components/watch-more-actions.tsx b/apps/web/src/components/watch-more-actions.tsx index 1b54fd10..c7355a11 100644 --- a/apps/web/src/components/watch-more-actions.tsx +++ b/apps/web/src/components/watch-more-actions.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; import { useBlocked } from "../hooks/use-blocked"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useWatchLaterPlaylist } from "../hooks/use-watch-later-playlist"; import { goto } from "../lib/route-redirect"; import { watchLaterResultLabel } from "../lib/watch-later-labels"; @@ -19,11 +20,15 @@ export function WatchMoreActions({ stream, isAuthed, onSaved, className }: Props const [menuOpen, setMenuOpen] = useState(false); const menuAnchorRef = useRef(null); const watchLater = useWatchLaterPlaylist(); - const { channels, videos, addChannel, removeChannel, addVideo, removeVideo } = useBlocked(); - const channelBlocked = - !!stream.channelUrl && - (channels.data ?? []).some((blocked) => blocked.url === stream.channelUrl); - const videoBlocked = (videos.data ?? []).some((blocked) => blocked.url === stream.id); + const { addChannel, removeChannel, addVideo, removeVideo } = useBlocked(); + const { findBlockedChannel, findBlockedVideo } = useBlockedFilter(); + const blockedChannel = findBlockedChannel({ + url: stream.channelUrl, + name: stream.channelName, + }); + const blockedVideo = findBlockedVideo(stream); + const channelBlocked = blockedChannel !== undefined; + const videoBlocked = blockedVideo !== undefined; function ensureAuth(): boolean { if (isAuthed) return true; @@ -34,7 +39,7 @@ export function WatchMoreActions({ stream, isAuthed, onSaved, className }: Props function toggleVideoBlock() { if (!ensureAuth()) return; if (videoBlocked) { - removeVideo.mutate(stream.id); + removeVideo.mutate(blockedVideo?.url ?? stream.id); onSaved("Video unblocked"); return; } @@ -45,7 +50,7 @@ export function WatchMoreActions({ stream, isAuthed, onSaved, className }: Props function toggleChannelBlock() { if (!stream.channelUrl || !ensureAuth()) return; if (channelBlocked) { - removeChannel.mutate(stream.channelUrl); + removeChannel.mutate(blockedChannel?.url ?? stream.channelUrl); onSaved("Channel unblocked"); return; } diff --git a/apps/web/src/hooks/use-watch-autoplay-preload.ts b/apps/web/src/hooks/use-watch-autoplay-preload.ts index f05e07d2..58b33ff8 100644 --- a/apps/web/src/hooks/use-watch-autoplay-preload.ts +++ b/apps/web/src/hooks/use-watch-autoplay-preload.ts @@ -21,13 +21,12 @@ export function useWatchAutoplayPreload({ durationMs, enabled, target }: Args) { const queryClient = useQueryClient(); const token = useAuthStore((state) => state.token); const { authReady, isAuthed } = useAuth(); - const { data: instance, isPending: instancePending } = useInstance(); - const { settings, settingsReady } = useSettings(); + const { isPending: instancePending } = useInstance(); + const { settingsReady } = useSettings(); const preloadedRef = useRef(new Set()); const activeRef = useRef(null); const activeTargetIdRef = useRef(""); - const useAuthenticatedStream = - isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); + const useAuthenticatedStream = isAuthed; const ready = authReady && !instancePending && (!isAuthed || settingsReady); const targetId = target?.id ?? ""; diff --git a/apps/web/src/hooks/use-watch-playback-flow.ts b/apps/web/src/hooks/use-watch-playback-flow.ts index c44256a8..8c9ef371 100644 --- a/apps/web/src/hooks/use-watch-playback-flow.ts +++ b/apps/web/src/hooks/use-watch-playback-flow.ts @@ -7,6 +7,7 @@ import { useWatchPlayerEvents } from "./use-watch-player-events"; type Args = { stream: VideoStream; + related: VideoStream[]; settings: SettingsItem; settingsReady: boolean; isLive: boolean; @@ -29,7 +30,7 @@ export function useWatchPlaybackFlow(args: Args) { nextVideo: args.nextVideo, list: args.list, shuffle: args.shuffle, - related: args.stream.related, + related: args.related, }); const preloadAutoplay = useWatchAutoplayPreload({ durationMs: args.stream.duration * 1000, diff --git a/apps/web/src/hooks/use-watch-playlist.tsx b/apps/web/src/hooks/use-watch-playlist.tsx index 96b43d65..a07b35db 100644 --- a/apps/web/src/hooks/use-watch-playlist.tsx +++ b/apps/web/src/hooks/use-watch-playlist.tsx @@ -7,6 +7,7 @@ import { markWatchAutoplayIntent } from "../lib/watch-autoplay-intent"; import { toPublicWatchParam } from "../lib/watch-url"; import { usePlaylistOrderStore } from "../stores/playlist-order-store"; import type { WatchPlaylistItem } from "../types/playlist"; +import { useBlockedFilter } from "./use-blocked-filter"; import { usePlaylist } from "./use-playlist"; import { usePlaylists } from "./use-playlists"; import { usePublicPlaylist } from "./use-public-playlist"; @@ -28,6 +29,7 @@ export function useWatchPlaylist( currentParam: string, ): WatchPlaylist { const navigate = useNavigate(); + const { filter } = useBlockedFilter(); const managedList = list && isManagedPlaylistId(list) ? list : ""; const publicListUrl = list && !isManagedPlaylistId(list) ? `https://www.youtube.com/playlist?list=${list}` : ""; @@ -47,6 +49,7 @@ export function useWatchPlaylist( title: item.title, thumbnail: item.thumbnail, channelName: item.channelName, + channelUrl: item.channelUrl, })) : (publicPlaylist.data?.pages.flatMap((page) => page.streams) ?? []).map((item, index) => ({ key: `${index}-${item.id}`, @@ -54,8 +57,11 @@ export function useWatchPlaylist( title: item.title, thumbnail: item.thumbnail, channelName: item.channelName, + channelUrl: item.channelUrl, })); - const arranged = !isManaged && customOrder ? applyCustomOrder(base, customOrder) : base; + const visibleBase = filter(base); + const arranged = + !isManaged && customOrder ? applyCustomOrder(visibleBase, customOrder) : visibleBase; const videos = shuffle ? shuffleByKey(arranged, shuffle) : arranged; const inPlaylist = Boolean(list) && videos.length > 0; const currentIdx = inPlaylist @@ -121,8 +127,14 @@ export function useWatchPlaylist( }) } onReorder={(items) => { - if (isManaged && list) reorder.mutate({ id: list, order: items.map((v) => v.url) }); - else if (list) + if (isManaged && list) { + const reordered = [...items]; + const visibleKeys = new Set(visibleBase.map((item) => item.key)); + const fullOrder = base.map((item) => + visibleKeys.has(item.key) ? (reordered.shift() ?? item).url : item.url, + ); + reorder.mutate({ id: list, order: fullOrder }); + } else if (list) setOrder( list, items.map((v) => v.key), diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index 76843300..201d765e 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -46,7 +46,7 @@ function EmbedPage() { isGuest, settingsReady: false, }); - const { settings, settingsReady } = useSettings({ + const { settingsReady } = useSettings({ forceAnonymous: !accessWithoutSettings.sessionEnabled, }); const access = resolveEmbedAccess({ @@ -57,9 +57,7 @@ function EmbedPage() { isGuest, settingsReady, }); - const useAuthenticatedStream = - access.sessionEnabled && - (settings.accessMode === "allow_list" || instance?.guestAllowed === false); + const useAuthenticatedStream = access.sessionEnabled; const streamQuery = useStream(sourceUrl, useAuthenticatedStream, access.streamEnabled); const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, access.streamEnabled); const publicParam = toPublicWatchParam(sourceUrl); diff --git a/apps/web/src/routes/history.tsx b/apps/web/src/routes/history.tsx index b46e9582..4f411f67 100644 --- a/apps/web/src/routes/history.tsx +++ b/apps/web/src/routes/history.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ConfirmModal } from "../components/confirm-modal"; import { HistoryCard } from "../components/history-card"; import type { FilterState } from "../components/history-filter"; @@ -8,6 +8,7 @@ import { HistoryFilter } from "../components/history-filter"; import { ScrollSentinel } from "../components/scroll-sentinel"; import { Toast } from "../components/toast"; import { useAuth } from "../hooks/use-auth"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useHistory } from "../hooks/use-history"; import { fetchHistory } from "../lib/api-user"; import type { HistoryItem } from "../types/user"; @@ -53,6 +54,7 @@ function rangeFromFilter(filter: FilterState | null): DateRange { function HistoryPage() { const { isAuthed } = useAuth(); + const { filter: filterBlocked } = useBlockedFilter(); const [searchQuery, setSearchQuery] = useState(""); const [filter, setFilter] = useState(null); const [pendingRemoveItem, setPendingRemoveItem] = useState(null); @@ -81,14 +83,17 @@ function HistoryPage() { staleTime: 30_000, }); - const filtered = filter !== null ? dedupeByUrl(allItemsQuery.data?.items ?? []) : items; - const filteredTotal = filter !== null ? filtered.length : total; + const unblocked = useMemo( + () => filterBlocked(filter !== null ? dedupeByUrl(allItemsQuery.data?.items ?? []) : items), + [allItemsQuery.data?.items, filter, filterBlocked, items], + ); + const filteredTotal = filter !== null ? unblocked.length : total; return (
- {filtered.map((item: HistoryItem) => ( + {unblocked.map((item: HistoryItem) => ( setPendingRemoveItem(item)} /> ))}
diff --git a/apps/web/src/routes/playlists.tsx b/apps/web/src/routes/playlists.tsx index 2ffd9c2f..bd97ce70 100644 --- a/apps/web/src/routes/playlists.tsx +++ b/apps/web/src/routes/playlists.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ConfirmModal } from "../components/confirm-modal"; import { LibraryCollectionCard } from "../components/library-collection-card"; import { PlaylistCard } from "../components/playlist-card"; @@ -8,6 +8,7 @@ import { PlaylistsEmptyState } from "../components/playlists-empty-state"; import { PlaylistsPageHeader } from "../components/playlists-page-header"; import { SavedPlaylistsSection } from "../components/saved-playlists-section"; import { Toast } from "../components/toast"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useFavoriteStreams } from "../hooks/use-favorite-streams"; import { usePlaylists } from "../hooks/use-playlists"; import { useSavedPlaylists } from "../hooks/use-saved-playlists"; @@ -17,10 +18,21 @@ import type { SavedPlaylistItem } from "../types/playlist"; function PlaylistsPage() { const { query, create, remove } = usePlaylists(); const savedPlaylists = useSavedPlaylists(); - const favorites = useFavoriteStreams({ limit: 1 }); + const favorites = useFavoriteStreams(); const watchLater = useWatchLaterStreams(); const playlists = query.data ?? []; - const saved = savedPlaylists.items; + const { filter, isPlaylistBlocked } = useBlockedFilter(); + const visibleFavorites = useMemo(() => filter(favorites.videos), [favorites.videos, filter]); + const visibleWatchLater = useMemo(() => filter(watchLater.videos), [filter, watchLater.videos]); + const visiblePlaylists = useMemo( + () => + playlists.map((playlist) => { + const videos = filter(playlist.videos ?? []); + return { ...playlist, videos, videoCount: videos.length }; + }), + [filter, playlists], + ); + const saved = savedPlaylists.items.filter((playlist) => !isPlaylistBlocked(playlist)); const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [confirmIds, setConfirmIds] = useState(null); @@ -70,7 +82,8 @@ function PlaylistsPage() { : confirmIds.length === 1 ? `Delete "${playlists.find((p) => p.id === confirmIds[0])?.name ?? "this playlist"}"?` : `Delete ${confirmIds.length} playlists?`; - const hasLocalCollections = playlists.length > 0 || favorites.count > 0 || watchLater.count > 0; + const hasLocalCollections = + playlists.length > 0 || visibleFavorites.length > 0 || visibleWatchLater.length > 0; return (
@@ -91,16 +104,16 @@ function PlaylistsPage() { - {playlists.map((playlist, index) => ( + {visiblePlaylists.map((playlist, index) => (
(null); @@ -45,7 +47,8 @@ function PlaylistDetailPage() { ); } - const videos = playlist.videos ?? []; + const allVideos = playlist.videos ?? []; + const videos = filter(allVideos); const count = videos.length; const sortedVideos = sortPlaylistVideos(videos, sortMode); const reorderable = sortMode === "manual"; @@ -116,7 +119,11 @@ function PlaylistDetailPage() {
{count === 0 ? (
-

No videos in this playlist yet.

+

+ {allVideos.length > 0 + ? "All videos in this playlist are blocked." + : "No videos in this playlist yet."} +

Save videos from the watch page using the Save button.

diff --git a/apps/web/src/routes/subscriptions_.channels.tsx b/apps/web/src/routes/subscriptions_.channels.tsx index 74320926..f716eaef 100644 --- a/apps/web/src/routes/subscriptions_.channels.tsx +++ b/apps/web/src/routes/subscriptions_.channels.tsx @@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { SubscriptionChannelList } from "../components/subscription-channel-list"; import { SubscriptionsHeader } from "../components/subscriptions-header"; import { VideoGridSkeleton } from "../components/video-grid-skeleton"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { SUBSCRIPTION_FEED_KEY } from "../hooks/use-subscription-feed"; import { SUBSCRIPTIONS_KEY, useSubscriptions } from "../hooks/use-subscriptions"; import { fetchSubscriptionFeed, fetchSubscriptions } from "../lib/api-user"; @@ -16,7 +17,10 @@ function nextSubscriptionPage(last: Awaited !isChannelIdentityBlocked({ url: item.channelUrl, name: item.name }), + ); function prefetchChannels() { void queryClient.prefetchQuery({ diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index c7ddd6a6..e91ae3fd 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -27,11 +27,10 @@ function WatchPage() { const sourceUrl = toWatchSourceUrl(v); const publicParam = toPublicWatchParam(sourceUrl); const { authReady, isAuthed } = useAuth(); - const { data: instance, isPending: instancePending } = useInstance(); + const { isPending: instancePending } = useInstance(); const { settings, settingsReady } = useSettings(); const navigationSnapshot = useWatchNavigationStore((state) => state.snapshot); - const useAuthenticatedStream = - isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); + const useAuthenticatedStream = isAuthed; const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); const streamQuery = useStream(sourceUrl, useAuthenticatedStream, streamEnabled); const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, streamEnabled); diff --git a/apps/web/src/types/playlist.ts b/apps/web/src/types/playlist.ts index c284ab8e..34772c78 100644 --- a/apps/web/src/types/playlist.ts +++ b/apps/web/src/types/playlist.ts @@ -8,6 +8,7 @@ export type WatchPlaylistItem = { title: string; thumbnail: string; channelName?: string; + channelUrl?: string; }; export type PublicPlaylistInfo = { diff --git a/apps/web/tests/blocked-content.test.ts b/apps/web/tests/blocked-content.test.ts new file mode 100644 index 00000000..2e049047 --- /dev/null +++ b/apps/web/tests/blocked-content.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { + createBlockedContentMatcher, + normalizeBlockedContentUrl, +} from "../src/lib/blocked-content"; + +describe("blocked content matching", () => { + test("treats equivalent YouTube video URLs as the same video", () => { + const matcher = createBlockedContentMatcher( + [], + [{ url: "https://www.youtube.com/watch?v=AbC_123-xyZ" }], + [], + ); + + expect(matcher.isVideoExplicitlyBlocked({ id: "https://youtu.be/AbC_123-xyZ?t=12" })).toBe( + true, + ); + expect( + matcher.isVideoExplicitlyBlocked({ url: "https://m.youtube.com/shorts/AbC_123-xyZ" }), + ).toBe(true); + }); + + test("normalizes YouTube channel hosts and ignores URL decorations", () => { + expect(normalizeBlockedContentUrl("http://www.youtube.com/@Example/?view=0#top")).toBe( + "youtube.com/@Example", + ); + }); + + test("matches blocked channels by canonical URL or normalized name", () => { + const matcher = createBlockedContentMatcher( + [{ url: "https://www.youtube.com/@Example", name: "Test Channel" }], + [], + [], + ); + + expect(matcher.isChannelBlocked({ url: "https://m.youtube.com/@Example/" })).toBe(true); + expect(matcher.isChannelBlocked({ name: "test channel" })).toBe(true); + }); + + test("removes blocked videos, channels, and keywords from ordered candidates", () => { + const matcher = createBlockedContentMatcher( + [{ url: "https://youtube.com/@blocked", name: "Blocked" }], + [{ url: "https://youtube.com/watch?v=blocked-video" }], + ["spoiler"], + ); + const candidates = [ + { id: "https://youtube.com/watch?v=blocked-video", title: "One" }, + { + id: "https://youtube.com/watch?v=blocked-channel", + title: "Two", + channelUrl: "https://www.youtube.com/@blocked", + }, + { id: "https://youtube.com/watch?v=blocked-title", title: "A spoiler inside" }, + { id: "https://youtube.com/watch?v=visible", title: "Visible" }, + ]; + + expect(matcher.filterVideos(candidates).map((item) => item.title)).toEqual(["Visible"]); + }); +}); From bafc10bcb45ee8a4ec35b15faba0e29f1228a44d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 19:04:29 +0200 Subject: [PATCH 08/19] feat: support grouped search filters --- apps/web/src/hooks/use-search-filters.ts | 6 +- apps/web/src/hooks/use-search.ts | 11 ++- apps/web/src/lib/api-discovery.ts | 13 +++- apps/web/src/lib/search-filter-selection.ts | 86 +++++++++++++++++++++ apps/web/src/routes/search.tsx | 48 ++++++++---- apps/web/src/types/api.ts | 11 ++- 6 files changed, 150 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/lib/search-filter-selection.ts diff --git a/apps/web/src/hooks/use-search-filters.ts b/apps/web/src/hooks/use-search-filters.ts index 5d1acc49..a834a9e3 100644 --- a/apps/web/src/hooks/use-search-filters.ts +++ b/apps/web/src/hooks/use-search-filters.ts @@ -1,10 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import { fetchSearchFilters } from "../lib/api-discovery"; -export function useSearchFilters(service: number) { +export function useSearchFilters(service: number, contentFilter?: string) { return useQuery({ - queryKey: ["search-filters", service], - queryFn: () => fetchSearchFilters(service), + queryKey: ["search-filters", service, contentFilter ?? ""], + queryFn: () => fetchSearchFilters(service, contentFilter), staleTime: 60 * 60 * 1000, }); } diff --git a/apps/web/src/hooks/use-search.ts b/apps/web/src/hooks/use-search.ts index a8c3d99d..4899dff8 100644 --- a/apps/web/src/hooks/use-search.ts +++ b/apps/web/src/hooks/use-search.ts @@ -14,11 +14,16 @@ type SearchPage = { isCorrectedSearch: boolean; }; -export function useSearch(q: string, service: number, contentFilter?: string, sortFilter?: string) { +export function useSearch( + q: string, + service: number, + contentFilter?: string, + filters: readonly string[] = [], +) { return useInfiniteQuery({ - queryKey: ["search", q, service, contentFilter ?? "", sortFilter ?? ""], + queryKey: ["search", q, service, contentFilter ?? "", filters], queryFn: async ({ pageParam }: { pageParam: string | undefined }) => { - const response = await fetchSearch(q, service, pageParam, contentFilter, sortFilter); + const response = await fetchSearch(q, service, pageParam, contentFilter, filters); return { streams: response.items.map(mapVideoItem), channels: response.channels ?? [], diff --git a/apps/web/src/lib/api-discovery.ts b/apps/web/src/lib/api-discovery.ts index aaa7f875..ce5a6e2f 100644 --- a/apps/web/src/lib/api-discovery.ts +++ b/apps/web/src/lib/api-discovery.ts @@ -5,8 +5,13 @@ import { optionalBearer } from "./optional-bearer"; export type ChannelSort = "latest" | "popular" | "oldest"; -export function fetchSearchFilters(service: number): Promise { - return request(`${BASE}/search/filters?service=${service}`); +export function fetchSearchFilters( + service: number, + contentFilter?: string, +): Promise { + const params = new URLSearchParams({ service: String(service) }); + if (contentFilter) params.set("contentFilter", contentFilter); + return request(`${BASE}/search/filters?${params}`); } export function fetchSearch( @@ -14,12 +19,12 @@ export function fetchSearch( service: number, nextpage?: string, contentFilter?: string, - sortFilter?: string, + filters: readonly string[] = [], ): Promise { const params = new URLSearchParams({ q, service: String(service) }); if (nextpage) params.set("nextpage", nextpage); if (contentFilter) params.set("contentFilter", contentFilter); - if (sortFilter) params.set("sortFilter", sortFilter); + for (const filter of filters) params.append("filter", filter); return request(`${BASE}/search?${params}`, optionalBearer()); } diff --git a/apps/web/src/lib/search-filter-selection.ts b/apps/web/src/lib/search-filter-selection.ts new file mode 100644 index 00000000..4f10abd8 --- /dev/null +++ b/apps/web/src/lib/search-filter-selection.ts @@ -0,0 +1,86 @@ +import type { SearchFilterGroup, SearchFilterOption, SearchFiltersResponse } from "../types/api"; + +const LABELS: Record = { + sortby: "Sort by", + upload_date: "Upload date", + sort_relevance: "Relevance", + sort_rating: "Rating", + sort_view: "View count", + past_hour: "Past hour", + past_day: "Today", + past_week: "This week", + past_month: "This month", + past_year: "This year", + short_video: "Short", + long_video: "Long", + Ccommons: "Creative Commons", + Hdr: "HDR", + "3d": "3D", + "4k": "4K", +}; + +export function searchFilterLabel(raw: string): string { + const afterColon = raw.includes(":") ? raw.slice(raw.indexOf(":") + 1) : raw; + const value = afterColon.trim(); + if (LABELS[value]) return LABELS[value]; + return value + .split(/[_\s]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +export function searchFilterGroups(filters: SearchFiltersResponse): SearchFilterGroup[] { + if (filters.filterGroups && filters.filterGroups.length > 0) return filters.filterGroups; + if (filters.sortFilters.length === 0) return []; + const hasDefault = filters.sortFilters.some((option) => option.isDefault); + return [ + { + key: "legacy-sort", + label: "Sort by", + multiSelect: false, + options: filters.sortFilters.map((option, index) => ({ + ...option, + isDefault: option.isDefault ?? (!hasDefault && index === 0), + })), + }, + ]; +} + +export function sanitizeSearchFilters( + groups: readonly SearchFilterGroup[], + selected: readonly string[], +): string[] { + const requested = new Set(selected); + return groups.flatMap((group) => { + const matches = group.options.filter( + (option) => requested.has(option.value) && !option.isDefault, + ); + return (group.multiSelect ? matches : matches.slice(0, 1)).map((option) => option.value); + }); +} + +export function toggleSearchFilter( + groups: readonly SearchFilterGroup[], + selected: readonly string[], + groupKey: string, + option: SearchFilterOption, +): string[] { + const group = groups.find((candidate) => candidate.key === groupKey); + if (!group) return sanitizeSearchFilters(groups, selected); + const groupValues = new Set(group.options.map((candidate) => candidate.value)); + const next = selected.filter((value) => !groupValues.has(value)); + if (group.multiSelect) { + next.push(...selected.filter((value) => groupValues.has(value) && value !== option.value)); + } + if (!option.isDefault && !selected.includes(option.value)) next.push(option.value); + return sanitizeSearchFilters(groups, next); +} + +export function activeSearchFilterOptions( + groups: readonly SearchFilterGroup[], + selected: readonly string[], +): SearchFilterOption[] { + const values = new Set(sanitizeSearchFilters(groups, selected)); + return groups.flatMap((group) => group.options.filter((option) => values.has(option.value))); +} diff --git a/apps/web/src/routes/search.tsx b/apps/web/src/routes/search.tsx index 26face6d..29f01eb8 100644 --- a/apps/web/src/routes/search.tsx +++ b/apps/web/src/routes/search.tsx @@ -11,15 +11,24 @@ import { useSearchFilters } from "../hooks/use-search-filters"; import { useSettings } from "../hooks/use-settings"; function SearchPage() { - const { q, service, contentFilter, sortFilter } = Route.useSearch(); + const { + q, + service, + contentFilter, + filters: selectedFilters = [], + sortFilter, + } = Route.useSearch(); const navigate = useNavigate(); - const filters = useSearchFilters(service); + const filters = useSearchFilters(service, contentFilter); + const searchFilters = [...selectedFilters, ...(sortFilter ? [sortFilter] : [])].filter( + (value, index, values) => values.indexOf(value) === index, + ); const { settings } = useSettings(); const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useSearch( q, service, contentFilter, - sortFilter, + searchFilters, ); const { filter, isChannelBlocked, isPlaylistBlocked } = useBlockedFilter(); @@ -59,20 +68,23 @@ function SearchPage() { } function setContentFilter(value: string | undefined) { - navigate({ to: "/search", search: { q, service, contentFilter: value, sortFilter } }); + navigate({ to: "/search", search: { q, service, contentFilter: value } }); } - function setSortFilter(value: string | undefined) { - navigate({ to: "/search", search: { q, service, contentFilter, sortFilter: value } }); + function setSearchFilters(values: string[]) { + navigate({ + to: "/search", + search: { q, service, contentFilter, ...(values.length > 0 ? { filters: values } : {}) }, + }); } const filterBar = filters.data ? ( ) : null; @@ -131,11 +143,19 @@ function SearchPage() { } export const Route = createFileRoute("/search")({ - validateSearch: (search: Record) => ({ - q: typeof search.q === "string" ? search.q : "", - service: typeof search.service === "number" ? search.service : 0, - ...(typeof search.contentFilter === "string" ? { contentFilter: search.contentFilter } : {}), - ...(typeof search.sortFilter === "string" ? { sortFilter: search.sortFilter } : {}), - }), + validateSearch: (search: Record) => { + const filters = Array.isArray(search.filters) + ? search.filters.filter((value): value is string => typeof value === "string") + : typeof search.filters === "string" + ? [search.filters] + : []; + return { + q: typeof search.q === "string" ? search.q : "", + service: typeof search.service === "number" ? search.service : 0, + ...(typeof search.contentFilter === "string" ? { contentFilter: search.contentFilter } : {}), + ...(filters.length > 0 ? { filters } : {}), + ...(typeof search.sortFilter === "string" ? { sortFilter: search.sortFilter } : {}), + }; + }, component: SearchPage, }); diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 31998538..4690ab63 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -103,14 +103,23 @@ export type SearchPageResponse = { isCorrectedSearch: boolean; }; -type SearchFilterOption = { +export type SearchFilterOption = { value: string; label: string; + isDefault?: boolean; +}; + +export type SearchFilterGroup = { + key: string; + label: string; + multiSelect: boolean; + options: SearchFilterOption[]; }; export type SearchFiltersResponse = { contentFilters: SearchFilterOption[]; sortFilters: SearchFilterOption[]; + filterGroups?: SearchFilterGroup[]; }; export type HomeRecommendationsResponse = { From 8a252f933cce2433bca08d85ad3184dadc1717ea Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 19:04:35 +0200 Subject: [PATCH 09/19] feat: add search filter panel --- apps/web/src/components/search-filter-bar.tsx | 222 ++++++++++++++---- 1 file changed, 173 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/search-filter-bar.tsx b/apps/web/src/components/search-filter-bar.tsx index 0cd6d18c..27bbfe1d 100644 --- a/apps/web/src/components/search-filter-bar.tsx +++ b/apps/web/src/components/search-filter-bar.tsx @@ -1,85 +1,209 @@ -import type { SearchFiltersResponse } from "../types/api"; - -function prettifyLabel(raw: string): string { - const afterColon = raw.includes(":") ? raw.slice(raw.indexOf(":") + 1) : raw; - const base = afterColon.trim(); - const stripped = base.startsWith("sort_") ? base.slice(5) : base; - return stripped - .split(/[_\s]+/) - .filter(Boolean) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -} +import { SlidersHorizontal, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + activeSearchFilterOptions, + searchFilterGroups, + searchFilterLabel, + toggleSearchFilter, +} from "../lib/search-filter-selection"; +import type { SearchFilterGroup, SearchFilterOption, SearchFiltersResponse } from "../types/api"; function chipClass(active: boolean): string { const base = - "shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-colors"; + "shrink-0 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors"; return active ? `${base} bg-fg text-app` : `${base} bg-surface-strong text-fg hover:bg-surface-soft`; } +function isOptionSelected( + group: SearchFilterGroup, + option: SearchFilterOption, + selected: readonly string[], +): boolean { + if (!option.isDefault) return selected.includes(option.value); + return !group.options.some( + (candidate) => !candidate.isDefault && selected.includes(candidate.value), + ); +} + type Props = { filters: SearchFiltersResponse; contentFilter: string | undefined; - sortFilter: string | undefined; + selectedFilters: readonly string[]; onContentChange: (value: string | undefined) => void; - onSortChange: (value: string | undefined) => void; + onFiltersChange: (values: string[]) => void; }; export function SearchFilterBar({ filters, contentFilter, - sortFilter, + selectedFilters, onContentChange, - onSortChange, + onFiltersChange, }: Props) { - const contentOptions = filters.contentFilters.filter((option) => option.label !== "all"); - const { sortFilters } = filters; + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const groups = searchFilterGroups(filters); + const activeOptions = activeSearchFilterOptions(groups, selectedFilters); + const contentOptions = filters.contentFilters.filter( + (option) => !option.isDefault && searchFilterLabel(option.label).toLowerCase() !== "all", + ); - if (contentOptions.length === 0 && sortFilters.length === 0) return null; + useEffect(() => { + if (!open) return; + const closeOutside = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + window.addEventListener("mousedown", closeOutside); + window.addEventListener("keydown", closeOnEscape); + return () => { + window.removeEventListener("mousedown", closeOutside); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + + if (contentOptions.length === 0 && groups.length === 0) return null; return ( -
-
- - {contentOptions.map((option) => ( - - ))} -
- {sortFilters.length > 0 && ( -
+
+
+
- {sortFilters.map((option) => ( + {contentOptions.map((option) => ( ))}
+ {groups.length > 0 && ( + + )} +
+ + {activeOptions.length > 0 && ( +
+ {activeOptions.map((option) => { + const group = groups.find((candidate) => + candidate.options.some((candidateOption) => candidateOption.value === option.value), + ); + if (!group) return null; + return ( + + ); + })} +
+ )} + + {open && ( +
+
+

Filters

+
+ {activeOptions.length > 0 && ( + + )} + +
+
+
+ {groups.map((group) => ( +
+ + {searchFilterLabel(group.label)} + +
+ {group.options.map((option) => { + const checked = isOptionSelected(group, option, selectedFilters); + return ( + + ); + })} +
+
+ ))} +
+
)}
); From c3fc5bbcdd3efdfca9cb63897f18b77dee0146ac Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 19:05:27 +0200 Subject: [PATCH 10/19] test: cover search filter selection --- .../web/tests/search-filter-selection.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/web/tests/search-filter-selection.test.ts diff --git a/apps/web/tests/search-filter-selection.test.ts b/apps/web/tests/search-filter-selection.test.ts new file mode 100644 index 00000000..4b433d1f --- /dev/null +++ b/apps/web/tests/search-filter-selection.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { + activeSearchFilterOptions, + sanitizeSearchFilters, + searchFilterGroups, + searchFilterLabel, + toggleSearchFilter, +} from "../src/lib/search-filter-selection"; +import type { SearchFilterGroup } from "../src/types/api"; + +const groups: SearchFilterGroup[] = [ + { + key: "sort", + label: "sortby", + multiSelect: false, + options: [ + { value: "relevance", label: "sort_relevance", isDefault: true }, + { value: "views", label: "sort_view" }, + { value: "rating", label: "sort_rating" }, + ], + }, + { + key: "features", + label: "features", + multiSelect: true, + options: [ + { value: "hd", label: "HD" }, + { value: "captions", label: "Subtitles" }, + ], + }, +]; + +function option(groupKey: string, value: string) { + const match = groups + .find((group) => group.key === groupKey) + ?.options.find((candidate) => candidate.value === value); + if (!match) throw new Error(`Missing ${groupKey} option ${value}`); + return match; +} + +describe("search filter selection", () => { + test("keeps one exclusive value and multiple feature values", () => { + expect(sanitizeSearchFilters(groups, ["views", "rating", "hd", "captions"])).toEqual([ + "views", + "hd", + "captions", + ]); + }); + + test("replaces exclusive filters without changing other groups", () => { + expect(toggleSearchFilter(groups, ["views", "hd"], "sort", option("sort", "rating"))).toEqual([ + "rating", + "hd", + ]); + }); + + test("selecting a default removes the group from the URL", () => { + expect( + toggleSearchFilter(groups, ["views", "hd"], "sort", option("sort", "relevance")), + ).toEqual(["hd"]); + }); + + test("toggles multi-select filters independently", () => { + expect( + toggleSearchFilter(groups, ["views", "hd"], "features", option("features", "captions")), + ).toEqual(["views", "hd", "captions"]); + expect( + toggleSearchFilter(groups, ["views", "hd"], "features", option("features", "hd")), + ).toEqual(["views"]); + }); + + test("falls back to the legacy flat filter response", () => { + expect( + searchFilterGroups({ contentFilters: [], sortFilters: [{ value: "views", label: "Views" }] }), + ).toEqual([ + { + key: "legacy-sort", + label: "Sort by", + multiSelect: false, + options: [{ value: "views", label: "Views", isDefault: true }], + }, + ]); + }); + + test("returns active options and human labels", () => { + expect( + activeSearchFilterOptions(groups, ["views", "hd"]).map((option) => option.value), + ).toEqual(["views", "hd"]); + expect(searchFilterLabel("upload_date")).toBe("Upload date"); + expect(searchFilterLabel("sort_view")).toBe("View count"); + }); +}); From a5662f6921bf746b995c59f781a819dad327d6a9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 00:09:24 +0200 Subject: [PATCH 11/19] fix: load YouTube subtitles through Server --- .../src/components/subtitle-track-utils.ts | 4 +- apps/web/src/lib/proxy.ts | 36 ++++++++++++-- apps/web/tests/subtitle-track-utils.test.ts | 48 +++++++++++++++++-- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/subtitle-track-utils.ts b/apps/web/src/components/subtitle-track-utils.ts index 1f38c0e5..a62c0c29 100644 --- a/apps/web/src/components/subtitle-track-utils.ts +++ b/apps/web/src/components/subtitle-track-utils.ts @@ -1,4 +1,4 @@ -import { toProxiedVttUrl } from "../lib/proxy"; +import { toSubtitleVttUrl } from "../lib/proxy"; import type { SubtitleItem } from "../types/api"; type SafeSubtitleTrack = { @@ -46,7 +46,7 @@ export function buildSafeSubtitleTracks( let src = ""; try { - src = toProxiedVttUrl(rawUrl); + src = toSubtitleVttUrl(rawUrl, normalizeText(item.languageTag), item.isAutoGenerated); } catch { continue; } diff --git a/apps/web/src/lib/proxy.ts b/apps/web/src/lib/proxy.ts index 0158085d..a86d6205 100644 --- a/apps/web/src/lib/proxy.ts +++ b/apps/web/src/lib/proxy.ts @@ -54,12 +54,40 @@ export function proxyImage(url: string): string { return proxyUrl(normalized); } -export function toProxiedVttUrl(url: string): string { +export function toSubtitleVttUrl( + url: string, + languageTag: string, + isAutoGenerated: boolean, +): string { const parsed = new URL(url); - parsed.searchParams.set("fmt", "vtt"); const youtubeTimedText = - (parsed.hostname === "youtube.com" || parsed.hostname === "www.youtube.com") && + (parsed.hostname === "youtube.com" || parsed.hostname.endsWith(".youtube.com")) && parsed.pathname === "/api/timedtext"; - if (youtubeTimedText) return parsed.toString(); + if (youtubeTimedText) return youtubeSubtitleUrl(parsed, languageTag, isAutoGenerated); + parsed.searchParams.set("fmt", "vtt"); return proxyUrl(parsed.toString()); } + +function youtubeSubtitleUrl(timedText: URL, languageTag: string, isAutoGenerated: boolean): string { + const videoId = timedText.searchParams.get("v")?.trim() ?? ""; + const sourceLanguage = timedText.searchParams.get("lang")?.trim() ?? ""; + const translation = timedText.searchParams.get("tlang")?.trim() ?? ""; + const language = languageTag.trim() || translation || sourceLanguage; + if (!/^[A-Za-z0-9_-]{11}$/.test(videoId) || !language) { + throw new Error("Invalid YouTube subtitle track"); + } + const auto = + isAutoGenerated || + timedText.searchParams.get("kind") === "asr" || + timedText.searchParams.get("vssId")?.startsWith("a.") === true; + const params = new URLSearchParams({ + language, + variant: auto ? "auto" : "manual", + format: "vtt", + }); + if (sourceLanguage) params.set("sourceLanguage", sourceLanguage); + if (translation) params.set("translation", translation); + const name = timedText.searchParams.get("name")?.trim(); + if (name) params.set("name", name); + return `${absoluteBase()}/subtitles/youtube/${encodeURIComponent(videoId)}?${params}`; +} diff --git a/apps/web/tests/subtitle-track-utils.test.ts b/apps/web/tests/subtitle-track-utils.test.ts index 2b960980..333faba0 100644 --- a/apps/web/tests/subtitle-track-utils.test.ts +++ b/apps/web/tests/subtitle-track-utils.test.ts @@ -6,14 +6,14 @@ Object.assign(globalThis, { window: { location: { origin: "https://typetype.test test("uses caption variants instead of numeric duplicate language labels", () => { const tracks = buildSafeSubtitleTracks([ { - url: "https://www.youtube.com/api/timedtext?lang=en&name=CC1", + url: "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&name=CC1&expire=123&sig=secret", mimeType: "text/vtt", languageTag: "en", displayLanguageName: "English", isAutoGenerated: false, }, { - url: "https://www.youtube.com/api/timedtext?lang=en&name=DTVCC1", + url: "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&name=DTVCC1&expire=456&sig=secret", mimeType: "text/vtt", languageTag: "en", displayLanguageName: "English", @@ -22,6 +22,46 @@ test("uses caption variants instead of numeric duplicate language labels", () => ]); expect(tracks.map((track) => track.label)).toEqual(["English (CC1)", "English (DTVCC1)"]); - expect(tracks.every((track) => new URL(track.src).searchParams.get("fmt") === "vtt")).toBe(true); - expect(tracks.every((track) => new URL(track.src).hostname === "www.youtube.com")).toBe(true); + expect(tracks.every((track) => new URL(track.src).searchParams.get("format") === "vtt")).toBe( + true, + ); + expect(tracks.every((track) => new URL(track.src).hostname === "typetype.test")).toBe(true); + expect( + tracks.every((track) => new URL(track.src).pathname === "/api/subtitles/youtube/abcdefghijk"), + ).toBe(true); + expect(tracks.every((track) => !track.src.includes("secret"))).toBe(true); +}); + +test("preserves generated and translated YouTube subtitle selection", () => { + const [track] = buildSafeSubtitleTracks([ + { + url: "https://m.youtube.com/api/timedtext?v=abcdefghijk&lang=en&kind=asr&tlang=fr", + mimeType: "application/ttml+xml", + languageTag: "fr", + displayLanguageName: "French", + isAutoGenerated: false, + }, + ]); + + const src = new URL(track?.src ?? ""); + expect(src.searchParams.get("language")).toBe("fr"); + expect(src.searchParams.get("sourceLanguage")).toBe("en"); + expect(src.searchParams.get("translation")).toBe("fr"); + expect(src.searchParams.get("variant")).toBe("auto"); +}); + +test("keeps non YouTube subtitle tracks on the generic proxy", () => { + const [track] = buildSafeSubtitleTracks([ + { + url: "https://subtitles.example.test/captions.ttml", + mimeType: "application/ttml+xml", + languageTag: "en", + displayLanguageName: "English", + isAutoGenerated: false, + }, + ]); + + const src = new URL(track?.src ?? ""); + expect(src.pathname).toBe("/api/proxy"); + expect(src.searchParams.get("url")).toContain("fmt=vtt"); }); From a8bdf1fbe78d76c38136188e1a6b621204f93102 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 5 Aug 2026 08:51:50 +0200 Subject: [PATCH 12/19] fix: handle unavailable reset token clipboard --- apps/web/src/components/reset-token-modal.tsx | 46 +++++++----- apps/web/src/lib/copy-text.ts | 46 ++++++++++++ apps/web/tests/copy-text.test.ts | 70 +++++++++++++++++++ 3 files changed, 143 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/lib/copy-text.ts create mode 100644 apps/web/tests/copy-text.test.ts diff --git a/apps/web/src/components/reset-token-modal.tsx b/apps/web/src/components/reset-token-modal.tsx index ca67284d..fe4913ab 100644 --- a/apps/web/src/components/reset-token-modal.tsx +++ b/apps/web/src/components/reset-token-modal.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { copyText } from "../lib/copy-text"; type ResetTokenModalProps = { email: string; @@ -8,14 +9,7 @@ type ResetTokenModalProps = { }; export function ResetTokenModal({ email, token, onClose, onCopied }: ResetTokenModalProps) { - useEffect(() => { - navigator.clipboard - .writeText(token) - .then(() => { - onCopied(); - }) - .catch(() => {}); - }, [token, onCopied]); + const [copyState, setCopyState] = useState<"idle" | "copied" | "manual">("idle"); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -25,10 +19,13 @@ export function ResetTokenModal({ email, token, onClose, onCopied }: ResetTokenM return () => window.removeEventListener("keydown", handleEscape); }, [onClose]); - const handleCopy = () => { - navigator.clipboard.writeText(token).then(() => { + const handleCopy = async () => { + if (await copyText(token)) { + setCopyState("copied"); onCopied(); - }); + return; + } + setCopyState("manual"); }; return ( @@ -52,18 +49,29 @@ export function ResetTokenModal({ email, token, onClose, onCopied }: ResetTokenM

{email}

-
-

{token}

-
+