diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5962dce..045c08f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: quality: runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.1 - uses: oven-sh/setup-bun@v2.2.0 with: @@ -68,7 +68,7 @@ jobs: needs: quality runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.1 - uses: oven-sh/setup-bun@v2.2.0 with: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 140c75f4..f15f1f21 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -13,7 +13,7 @@ jobs: coverage: runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.1 - uses: oven-sh/setup-bun@v2.2.0 with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9ca8bac9..9f15fa93 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -30,7 +30,7 @@ jobs: metadata-json: ${{ steps.meta.outputs.json }} version: ${{ steps.info.outputs.version }} steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.1 - uses: oven-sh/setup-bun@v2.2.0 with: @@ -58,7 +58,7 @@ jobs: - name: Extract image metadata id: meta - uses: docker/metadata-action@v6.0.0 + uses: docker/metadata-action@v6.2.0 with: images: ${{ steps.info.outputs.image }} tags: | @@ -84,28 +84,28 @@ jobs: - platform: linux/arm64 arch: arm64 steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v7.0.1 - name: Isolate Docker credentials run: echo "DOCKER_CONFIG=$RUNNER_TEMP/docker-config" >> "$GITHUB_ENV" - - uses: docker/login-action@v4.1.0 + - uses: docker/login-action@v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-qemu-action@v4.1.0 + - uses: docker/setup-qemu-action@v4.2.0 if: matrix.arch == 'arm64' with: platforms: arm64 cache-image: false - - uses: docker/setup-buildx-action@v4.0.0 + - uses: docker/setup-buildx-action@v4.2.0 - name: Build and push platform image id: build - uses: docker/build-push-action@v7.1.0 + uses: docker/build-push-action@v7.3.0 with: context: . build-args: | @@ -158,13 +158,13 @@ jobs: pattern: frontend-digest-*-${{ github.run_id }}-${{ github.run_attempt }} merge-multiple: true - - uses: docker/login-action@v4.1.0 + - uses: docker/login-action@v4.5.2 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-buildx-action@v4.0.0 + - uses: docker/setup-buildx-action@v4.2.0 - name: Publish manifest list id: manifest diff --git a/Dockerfile b/Dockerfile index 436035f0..74c1717a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,11 @@ RUN bun install --frozen-lockfile COPY apps/web ./apps/web RUN bun run --cwd apps/web build +RUN find apps/web/dist -type f \ + \( -name '*.css' -o -name '*.html' -o -name '*.js' -o -name '*.json' -o -name '*.svg' \) \ + -exec gzip -9 -k '{}' \; -FROM nginx:1.31.0-alpine AS runner +FROM nginx:1.31.3-alpine AS runner ARG BUILD_VERSION=0.1.0 ARG BUILD_REVISION=development ARG BUILD_TIME=unknown diff --git a/apps/web/package.json b/apps/web/package.json index c11feb22..1a4728d4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@typetype/web", "private": true, - "version": "1.2.4", + "version": "1.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/web/public/import-cooking-chef.gif b/apps/web/public/import-cooking-chef.gif deleted file mode 100644 index 8df21e25..00000000 Binary files a/apps/web/public/import-cooking-chef.gif and /dev/null differ diff --git a/apps/web/public/import-dudu-cooking.gif b/apps/web/public/import-dudu-cooking.gif deleted file mode 100644 index 7d9d873e..00000000 Binary files a/apps/web/public/import-dudu-cooking.gif and /dev/null differ diff --git a/apps/web/public/member-only.gif b/apps/web/public/member-only.gif deleted file mode 100644 index 7bee0d9f..00000000 Binary files a/apps/web/public/member-only.gif and /dev/null differ diff --git a/apps/web/src/components/embed-player-shell.tsx b/apps/web/src/components/embed-player-shell.tsx index 9716ecf8..fed8f05d 100644 --- a/apps/web/src/components/embed-player-shell.tsx +++ b/apps/web/src/components/embed-player-shell.tsx @@ -8,7 +8,6 @@ import { useWatchVttAssets } from "../hooks/use-watch-layout-assets"; import { useWatchSponsorBlock } from "../hooks/use-watch-sponsorblock"; import { getOriginalAudioLocale } from "../lib/audio-track"; import { resolveEmbedAutoplay } from "../lib/embed-playback"; -import type { PlaybackMode } from "../lib/playback-mode"; import { toPublicWatchParam } from "../lib/watch-url"; import type { VideoStream } from "../types/stream"; import { EmbedError, PLAYBACK_FAILED_MESSAGE } from "./embed-error"; @@ -20,7 +19,6 @@ type Props = { startTime: number; autoplay: boolean; sessionEnabled: boolean; - playbackMode: PlaybackMode; }; export function EmbedPlayerShell({ @@ -29,13 +27,12 @@ export function EmbedPlayerShell({ startTime, autoplay, sessionEnabled, - playbackMode, }: Props) { const { settings, settingsReady, update } = useSettings({ forceAnonymous: !sessionEnabled, }); const isLive = stream.streamType === "live_stream" || stream.streamType === "audio_live_stream"; - const player = usePlayerError(stream, isLive, playbackMode); + const player = usePlayerError(stream, isLive); const handleVolumeChange = useVolumeSync(update.mutate); const positionRef = useRef(0); diff --git a/apps/web/src/components/navbar-playback-mode.tsx b/apps/web/src/components/navbar-playback-mode.tsx deleted file mode 100644 index 9516748d..00000000 --- a/apps/web/src/components/navbar-playback-mode.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { AudioWaveform } from "lucide-react"; -import { usePlaybackMode } from "../hooks/use-playback-mode"; - -export function NavbarPlaybackMode() { - const { playbackMode, setMode } = usePlaybackMode(); - const sabrEnabled = playbackMode === "sabr"; - const label = sabrEnabled ? "Use classic playback" : "Use SABR playback"; - - return ( - - ); -} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index 97c45422..05a84882 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -10,7 +10,6 @@ import { useUiStore } from "../stores/ui-store"; import { NavbarAccountControls } from "./navbar-account-controls"; import { NavbarLeadingControl } from "./navbar-leading-control"; import { NavbarNotifications } from "./navbar-notifications"; -import { NavbarPlaybackMode } from "./navbar-playback-mode"; import { NavbarSearch } from "./navbar-search"; import { Toast } from "./toast"; @@ -90,7 +89,6 @@ export function Navbar() { {canOpenSearch && !isMobile && }
- { - if (readPlaybackMode() === "legacy") setOpen(true); - return subscribeClassicPlaybackRequest(() => setOpen(true)); - }, []); - - useEffect(() => { - if (!open) return; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") setOpen(false); - } - document.addEventListener("keydown", onKeyDown); - return () => { - document.body.style.overflow = previousOverflow; - document.removeEventListener("keydown", onKeyDown); - }; - }, [open]); - - if (!open) return null; - - function useSabr() { - setPlaybackMode("sabr"); - setOpen(false); - } - - function continueWithClassic() { - setPlaybackMode("legacy"); - setOpen(false); - } - - return createPortal( - <> - - - -
-

- Alternative clients have relied on classic DASH and HLS extraction for years. YouTube is - restricting that path more often, so a healthy self-hosted instance can still fail in - ways that look like a server problem. -

- -
    - {CLASSIC_FAILURES.map((failure) => ( -
  • - - {failure} -
  • - ))} -
- -
-

- SABR is TypeType's supported and recommended mode. -

-

- It follows YouTube's current delivery path and is the playback path actively - maintained and optimized by TypeType. Classic remains available while it still works, - but it may stop working for individual videos or entire instances. -

-
- -

- SABR should work across supported YouTube videos. If you find an unexpected playback - issue, report the video URL and what happened on GitHub so it can be fixed. -

-
- -
- - - -
-
- , - document.body, - ); -} diff --git a/apps/web/src/components/player-defaults.tsx b/apps/web/src/components/player-defaults.tsx index d94b2355..6a6b4ee5 100644 --- a/apps/web/src/components/player-defaults.tsx +++ b/apps/web/src/components/player-defaults.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef } from "react"; +import { normalizeDefaultPlaybackSpeed } from "../lib/playback-speed"; +import type { SabrPlaybackRatePreference } from "../lib/sabr-playback-rate-preference"; import { useAudioOptions, useMediaPlayer, @@ -11,6 +13,7 @@ const QUALITY_OPTIONS = { sort: "descending" } as const; type PlayerDefaultsProps = { defaultQuality?: string; + defaultPlaybackSpeed?: number; defaultAudioLanguage?: string; preferOriginalLanguage?: boolean; requireOriginalLanguage?: boolean; @@ -22,6 +25,11 @@ type PlayerDefaultsProps = { defaultSubtitleLanguage?: string; }; +type PlaybackSpeedDefaultProps = { + defaultPlaybackSpeed: number; + preference?: SabrPlaybackRatePreference; +}; + function qualityLabelHeight(label: string): number | null { const match = label.match(/(\d+)/); if (!match) return null; @@ -31,6 +39,7 @@ function qualityLabelHeight(label: string): number | null { export function PlayerDefaults({ defaultQuality, + defaultPlaybackSpeed, defaultAudioLanguage, preferOriginalLanguage, requireOriginalLanguage, @@ -150,5 +159,31 @@ export function PlayerDefaults({ } }, [canPlay, textTracks, subtitlesEnabled, defaultSubtitleLanguage]); + if (defaultPlaybackSpeed === undefined) return null; + return ; +} + +export function PlayerPlaybackSpeedDefault({ + defaultPlaybackSpeed, + preference, +}: PlaybackSpeedDefaultProps) { + const canPlay = useMediaState("canPlay"); + const player = useMediaPlayer(); + const applied = useRef(false); + + useEffect(() => { + if (!canPlay || applied.current) return; + const media = player?.el?.querySelector("video,audio"); + if (!media) return; + if (preference) { + preference.apply(media, false); + } else { + const speed = normalizeDefaultPlaybackSpeed(defaultPlaybackSpeed); + media.defaultPlaybackRate = speed; + media.playbackRate = speed; + } + applied.current = true; + }, [canPlay, defaultPlaybackSpeed, player, preference]); + return null; } diff --git a/apps/web/src/components/shorts-player-shell.tsx b/apps/web/src/components/shorts-player-shell.tsx index 9c2fa8a0..1e2ade6f 100644 --- a/apps/web/src/components/shorts-player-shell.tsx +++ b/apps/web/src/components/shorts-player-shell.tsx @@ -108,6 +108,7 @@ export function ShortsPlayerShell({ targetUrl }: Props) { autoplay={settings.autoplay} initialVolume={settings.volume} initialMuted={settings.muted} + defaultPlaybackSpeed={settings.defaultPlaybackSpeed} defaultAudioLanguage={settings.defaultAudioLanguage || undefined} preferOriginalLanguage={settings.preferOriginalLanguage} originalAudioTrackId={originalAudioTrackId} diff --git a/apps/web/src/components/shorts-player-stage.tsx b/apps/web/src/components/shorts-player-stage.tsx index f4d77a8e..c2835be6 100644 --- a/apps/web/src/components/shorts-player-stage.tsx +++ b/apps/web/src/components/shorts-player-stage.tsx @@ -25,6 +25,7 @@ type Props = { autoplay: boolean; initialVolume: number; initialMuted: boolean; + defaultPlaybackSpeed: number; defaultAudioLanguage?: string; preferOriginalLanguage?: boolean; originalAudioTrackId?: string | null; @@ -61,6 +62,7 @@ export function ShortsPlayerStage({ autoplay, initialVolume, initialMuted, + defaultPlaybackSpeed, defaultAudioLanguage, preferOriginalLanguage, originalAudioTrackId, @@ -130,6 +132,7 @@ export function ShortsPlayerStage({ subtitles={stream.subtitles} initialVolume={initialVolume} initialMuted={initialMuted} + defaultPlaybackSpeed={defaultPlaybackSpeed} settingsReady={settingsReady} autoplay={shouldAutoplay} defaultAudioLanguage={defaultAudioLanguage} diff --git a/apps/web/src/components/shorts-video-player.tsx b/apps/web/src/components/shorts-video-player.tsx index 119b749a..f120c2a7 100644 --- a/apps/web/src/components/shorts-video-player.tsx +++ b/apps/web/src/components/shorts-video-player.tsx @@ -28,6 +28,7 @@ type Props = { settingsReady?: boolean; autoplay?: boolean; defaultAudioLanguage?: string; + defaultPlaybackSpeed?: number; preferOriginalLanguage?: boolean; originalAudioTrackId?: string | null; preferredDefaultAudioTrackId?: string | null; @@ -49,6 +50,7 @@ export function ShortsVideoPlayer({ settingsReady = false, autoplay = true, defaultAudioLanguage, + defaultPlaybackSpeed, preferOriginalLanguage, originalAudioTrackId, preferredDefaultAudioTrackId, @@ -133,6 +135,7 @@ export function ShortsVideoPlayer({ }} /> +
{!props.audioOnly && } - {!props.audioOnly && props.autoSkip && props.autoSkipSegments && ( + {shouldRunSponsorBlockAutoSkip(props) && props.autoSkipSegments && ( { + playbackRatePreference.current.setPreferredRate(settings.defaultPlaybackSpeed); + }, [settings.defaultPlaybackSpeed]); return ( + + {props.overlay} + + } captionStyles={settings.captionStyles} onCaptionStylesChange={props.onCaptionStylesChange} onVolumeChange={props.onVolumeChange} diff --git a/apps/web/src/hooks/use-blocked-filter.ts b/apps/web/src/hooks/use-blocked-filter.ts index dcd8ef17..9e4568c2 100644 --- a/apps/web/src/hooks/use-blocked-filter.ts +++ b/apps/web/src/hooks/use-blocked-filter.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import { normalizeBlockedKeyword, titleMatchesBlockedKeyword } from "../lib/blocked-keyword-filter"; import type { ChannelResultItem } from "../types/api"; import type { PublicPlaylistInfo } from "../types/playlist"; import type { VideoStream } from "../types/stream"; @@ -7,7 +8,7 @@ import { useBlocked } from "./use-blocked"; export function useBlockedFilter() { const { isAuthed } = useAuth(); - const { channels, videos } = useBlocked(); + const { channels, videos, keywords } = useBlocked(); const blockedChannelUrls = useMemo( () => new Set((channels.data ?? []).map((item) => item.url)), @@ -21,14 +22,21 @@ export function useBlockedFilter() { () => new Set((channels.data ?? []).map((item) => item.name?.toLowerCase()).filter(Boolean)), [channels.data], ); + const blockedKeywords = useMemo( + () => + (keywords.data ?? []) + .map((item) => normalizeBlockedKeyword(item.keyword)) + .filter((keyword) => keyword.length > 0), + [keywords.data], + ); const isBlocked = useCallback( (stream: VideoStream): boolean => { if (blockedVideoUrls.has(stream.id)) return true; if (stream.channelUrl && blockedChannelUrls.has(stream.channelUrl)) return true; - return false; + return titleMatchesBlockedKeyword(stream.title, blockedKeywords); }, - [blockedChannelUrls, blockedVideoUrls], + [blockedChannelUrls, blockedKeywords, blockedVideoUrls], ); const filter = useCallback( @@ -58,6 +66,7 @@ export function useBlockedFilter() { isChannelBlocked, isPlaylistBlocked, blockedChannelUrls, + blockedKeywords, blockedVideoUrls, }; } diff --git a/apps/web/src/hooks/use-blocked.ts b/apps/web/src/hooks/use-blocked.ts index 1c41f978..da72c686 100644 --- a/apps/web/src/hooks/use-blocked.ts +++ b/apps/web/src/hooks/use-blocked.ts @@ -1,15 +1,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { blockChannel, + blockKeyword, blockVideo, fetchBlockedChannels, + fetchBlockedKeywords, fetchBlockedVideos, unblockChannel, + unblockKeyword, unblockVideo, } 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 = { @@ -40,6 +44,12 @@ export function useBlocked() { enabled: authReady && isAuthed, staleTime: 5 * 60 * 1000, }); + const keywords = useQuery({ + queryKey: KEYWORDS_KEY, + queryFn: fetchBlockedKeywords, + enabled: authReady && isAuthed, + staleTime: 5 * 60 * 1000, + }); const addChannel = useMutation({ mutationFn: ({ url, name, thumbnailUrl, global }: BlockChannelArgs) => @@ -63,5 +73,26 @@ export function useBlocked() { onSuccess: () => qc.invalidateQueries({ queryKey: VIDEOS_KEY }), }); - return { channels, videos, addChannel, removeChannel, addVideo, removeVideo }; + const addKeyword = useMutation({ + mutationFn: (keyword: string) => + isAuthed ? blockKeyword(keyword).then(() => undefined) : Promise.resolve(), + onSuccess: () => qc.invalidateQueries({ queryKey: KEYWORDS_KEY }), + }); + + const removeKeyword = useMutation({ + mutationFn: (keyword: string) => (isAuthed ? unblockKeyword(keyword) : Promise.resolve()), + onSuccess: () => qc.invalidateQueries({ queryKey: KEYWORDS_KEY }), + }); + + return { + channels, + videos, + keywords, + addChannel, + removeChannel, + addVideo, + removeVideo, + addKeyword, + removeKeyword, + }; } diff --git a/apps/web/src/hooks/use-playback-mode.ts b/apps/web/src/hooks/use-playback-mode.ts deleted file mode 100644 index 0ee074ee..00000000 --- a/apps/web/src/hooks/use-playback-mode.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useSyncExternalStore } from "react"; -import { - type PlaybackMode, - readPlaybackMode, - requestPlaybackMode, - subscribePlaybackMode, -} from "../lib/playback-mode"; - -export function usePlaybackMode(): { - playbackMode: PlaybackMode; - setMode: (next: PlaybackMode) => void; -} { - const playbackMode = useSyncExternalStore( - subscribePlaybackMode, - readPlaybackMode, - readPlaybackMode, - ); - - return { - playbackMode, - setMode: requestPlaybackMode, - }; -} diff --git a/apps/web/src/hooks/use-player-error.ts b/apps/web/src/hooks/use-player-error.ts index f057dacc..c943de98 100644 --- a/apps/web/src/hooks/use-player-error.ts +++ b/apps/web/src/hooks/use-player-error.ts @@ -3,19 +3,17 @@ import { bilibiliVariantCount } from "../lib/bilibili-manifest"; import { recordClientEvent } from "../lib/client-debug-log"; import { sanitizeVideoContext } from "../lib/debug-sanitize"; import { isIosDevice } from "../lib/ios-device"; -import type { PlaybackMode } from "../lib/playback-mode"; import { detectProvider } from "../lib/provider"; import { claimAutomaticSabrRecovery, resetAutomaticSabrRecovery } from "../lib/sabr-error-recovery"; import { - hasLegacyDashPair, + directProgressiveStreams, + hasDirectDashPair, hasSabrPlayback, - legacyProgressiveStreams, } from "../lib/stream-delivery"; -import { resolveManifestSrc, shouldUseClassicHls } from "../lib/stream-src"; +import { resolveManifestSrc, shouldUseHls } from "../lib/stream-src"; import type { MediaSrc } from "../lib/vidstack"; import type { VideoStream } from "../types/stream"; import { useInstance } from "./use-instance"; -import { usePlaybackMode } from "./use-playback-mode"; type UsePlayerErrorReturn = { manifestSrc: MediaSrc; @@ -31,29 +29,23 @@ type UsePlayerErrorReturn = { seekStartTime: number | null; }; -export function usePlayerError( - stream: VideoStream, - isLive: boolean, - playbackModeOverride?: PlaybackMode, -): UsePlayerErrorReturn { +export function usePlayerError(stream: VideoStream, isLive: boolean): UsePlayerErrorReturn { const debugVideo = sanitizeVideoContext(stream.id) ?? "unknown"; const provider = detectProvider(stream.id); const iosDevice = isIosDevice(); const { data: instance } = useInstance(); - const { playbackMode: storedPlaybackMode } = usePlaybackMode(); - const playbackMode = playbackModeOverride ?? storedPlaybackMode; - const playbackSourceId = stream.id.length === 0 ? "" : `${stream.id}:${playbackMode}`; + const playbackSourceId = stream.id; const preferServerManifests = instance?.guestAllowed !== false; - const legacyDashPair = hasLegacyDashPair(stream); - const hasLegacyPlaybackFallback = legacyDashPair || legacyProgressiveStreams(stream).length > 0; + const directDashPair = hasDirectDashPair(stream); + const hasDirectPlaybackFallback = directDashPair || directProgressiveStreams(stream).length > 0; const highQualityEnabled = !isLive && !iosDevice && preferServerManifests && !stream.hlsUrl && - legacyDashPair && + directDashPair && provider === "youtube"; - const hlsEnabled = shouldUseClassicHls(stream.hlsUrl, isLive, false, legacyDashPair); + const hlsEnabled = shouldUseHls(stream.hlsUrl, isLive, false, directDashPair); const [hlsFailed, setHlsFailed] = useState(false); const [highQualityFailed, setHighQualityFailed] = useState(false); const [qualityFailed, setQualityFailed] = useState(false); @@ -66,7 +58,7 @@ export function usePlayerError( provider === "bilibili" ? bilibiliVariantCount(stream.videoOnlyStreams ?? [], stream.audioStreams ?? []) : 0; - const sabrSelected = provider === "youtube" && playbackMode === "sabr"; + const sabrSelected = provider === "youtube"; const sabrEnabled = sabrSelected && hasSabrPlayback(stream); const fallbackSrc = resolveManifestSrc(stream, isLive, qualityFailed, { @@ -89,7 +81,7 @@ export function usePlayerError( setPlayerFailed(true); } else if (hlsEnabled && !hlsFailed) { recordClientEvent("player.hls_failed", { video: debugVideo }); - if (!hasLegacyPlaybackFallback) { + if (!hasDirectPlaybackFallback) { setPlayerFailed(true); return; } @@ -103,11 +95,11 @@ export function usePlayerError( recordClientEvent("player.high_quality_failed", { video: debugVideo }); setHighQualityFailed(true); setRetryKey((k) => k + 1); - } else if (legacyDashPair && !qualityFailed) { + } else if (directDashPair && !qualityFailed) { recordClientEvent("player.quality_failed", { video: debugVideo }); setQualityFailed(true); setRetryKey((k) => k + 1); - } else if (!isLive && hasLegacyPlaybackFallback && !compatibilityFallback) { + } else if (!isLive && hasDirectPlaybackFallback && !compatibilityFallback) { recordClientEvent("player.compatibility_fallback", { video: debugVideo }); setCompatibilityFallback(true); setRetryKey((k) => k + 1); @@ -120,13 +112,13 @@ export function usePlayerError( hlsEnabled, hlsFailed, sabrSelected, - hasLegacyPlaybackFallback, + hasDirectPlaybackFallback, provider, bilibiliVariant, bilibiliVariants, highQualityEnabled, highQualityFailed, - legacyDashPair, + directDashPair, qualityFailed, compatibilityFallback, isLive, diff --git a/apps/web/src/hooks/use-settings.ts b/apps/web/src/hooks/use-settings.ts index 0d0a8794..b263a68e 100644 --- a/apps/web/src/hooks/use-settings.ts +++ b/apps/web/src/hooks/use-settings.ts @@ -16,6 +16,7 @@ const DEFAULTS: SettingsItem = { defaultService: 0, defaultLandingPage: "home", defaultQuality: "1080p", + defaultPlaybackSpeed: 1, autoplay: true, autoplayCountdownSeconds: 10, skipPlaylistAutoplayScreen: false, diff --git a/apps/web/src/hooks/use-stream.ts b/apps/web/src/hooks/use-stream.ts index a1029c8b..f30572e6 100644 --- a/apps/web/src/hooks/use-stream.ts +++ b/apps/web/src/hooks/use-stream.ts @@ -6,27 +6,20 @@ import { isMemberOnlyApiError as isMemberOnlyApiResponse, MEMBER_ONLY_MESSAGE, } from "../lib/member-only"; -import { type PlaybackMode, readPlaybackMode } from "../lib/playback-mode"; import { sabrBootstrapEndpoint, sabrBootstrapQueryKey, streamQueryKey, } from "../lib/stream-request"; -export function streamQueryOptions( - url: string, - useAuthenticatedStream = false, - enabled = true, - playbackMode: PlaybackMode = readPlaybackMode(), -) { +export function streamQueryOptions(url: string, useAuthenticatedStream = false, enabled = true) { return queryOptions({ - queryKey: streamQueryKey(url, useAuthenticatedStream, playbackMode), + queryKey: streamQueryKey(url, useAuthenticatedStream), queryFn: ({ signal }) => fetchStream( url, useAuthenticatedStream ? "authenticated_first" : "anonymous_first", signal, - playbackMode, ).then((r) => mapStreamResponse(r, url)), enabled: enabled && url.startsWith("http"), staleTime: 3 * 60 * 1000, @@ -61,24 +54,14 @@ export function isMemberOnlyApiError(error: unknown): boolean { return isMemberOnlyApiResponse(error); } -export function useStream( - url: string, - useAuthenticatedStream = false, - enabled = true, - playbackMode: PlaybackMode = readPlaybackMode(), -) { +export function useStream(url: string, useAuthenticatedStream = false, enabled = true) { return useQuery({ - ...streamQueryOptions(url, useAuthenticatedStream, enabled, playbackMode), + ...streamQueryOptions(url, useAuthenticatedStream, enabled), placeholderData: keepPreviousData, }); } -export function useSabrBootstrap( - url: string, - useAuthenticatedStream = false, - enabled = true, - playbackMode: PlaybackMode = readPlaybackMode(), -) { +export function useSabrBootstrap(url: string, useAuthenticatedStream = false, enabled = true) { return useQuery({ queryKey: sabrBootstrapQueryKey(url, useAuthenticatedStream), queryFn: ({ signal }) => @@ -87,8 +70,7 @@ export function useSabrBootstrap( useAuthenticatedStream ? "authenticated_first" : "anonymous_first", signal, ).then((response) => mapStreamResponse(response, url)), - enabled: - enabled && playbackMode === "sabr" && url.startsWith("http") && !!sabrBootstrapEndpoint(url), + enabled: enabled && url.startsWith("http") && !!sabrBootstrapEndpoint(url), staleTime: 3 * 60 * 1000, gcTime: 30 * 60 * 1000, retry: (count, error) => { diff --git a/apps/web/src/hooks/use-version-info.ts b/apps/web/src/hooks/use-version-info.ts new file mode 100644 index 00000000..7db88eb3 --- /dev/null +++ b/apps/web/src/hooks/use-version-info.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchVersionInfo } from "../lib/api-version"; + +const VERSION_INFO_KEY = ["version-info"]; + +export function useVersionInfo() { + return useQuery({ + queryKey: VERSION_INFO_KEY, + queryFn: fetchVersionInfo, + staleTime: 60_000, + }); +} diff --git a/apps/web/src/hooks/use-watch-autoplay-preload.ts b/apps/web/src/hooks/use-watch-autoplay-preload.ts index 105a83b4..f05e07d2 100644 --- a/apps/web/src/hooks/use-watch-autoplay-preload.ts +++ b/apps/web/src/hooks/use-watch-autoplay-preload.ts @@ -7,7 +7,6 @@ import { toWatchSourceUrl } from "../lib/watch-url"; import { useAuthStore } from "../stores/auth-store"; import { useAuth } from "./use-auth"; import { useInstance } from "./use-instance"; -import { usePlaybackMode } from "./use-playback-mode"; import { useSettings } from "./use-settings"; import { streamQueryOptions } from "./use-stream"; import type { AutoplayTarget } from "./use-watch-ended-navigation"; @@ -23,7 +22,6 @@ export function useWatchAutoplayPreload({ durationMs, enabled, target }: Args) { const token = useAuthStore((state) => state.token); const { authReady, isAuthed } = useAuth(); const { data: instance, isPending: instancePending } = useInstance(); - const { playbackMode } = usePlaybackMode(); const { settings, settingsReady } = useSettings(); const preloadedRef = useRef(new Set()); const activeRef = useRef(null); @@ -52,16 +50,16 @@ export function useWatchAutoplayPreload({ durationMs, enabled, target }: Args) { if (!shouldPreloadAutoplayTarget(positionMs, durationMs, enabled, Boolean(target))) return; if (!target || !ready) return; const sourceUrl = toWatchSourceUrl(target.search.v); - const key = `${sourceUrl}:${useAuthenticatedStream ? "auth" : "anon"}:${playbackMode}`; + const key = `${sourceUrl}:${useAuthenticatedStream ? "auth" : "anon"}`; if (preloadedRef.current.has(key)) return; preloadedRef.current.add(key); const controller = new AbortController(); activeRef.current?.abort(); activeRef.current = controller; void queryClient - .fetchQuery(streamQueryOptions(sourceUrl, useAuthenticatedStream, true, playbackMode)) + .fetchQuery(streamQueryOptions(sourceUrl, useAuthenticatedStream, true)) .then((stream) => { - if (controller.signal.aborted || playbackMode !== "sabr") return; + if (controller.signal.aborted) return; const itag = defaultSabrItag(sabrQualityOptions(stream), "720p"); const config = resolveSabrPlaybackConfig(stream, itag); if (config) return prewarmSabrPlayback(config, token, controller.signal); @@ -73,6 +71,6 @@ export function useWatchAutoplayPreload({ durationMs, enabled, target }: Args) { if (activeRef.current === controller) activeRef.current = null; }); }, - [durationMs, enabled, playbackMode, queryClient, ready, target, token, useAuthenticatedStream], + [durationMs, enabled, queryClient, ready, target, token, useAuthenticatedStream], ); } diff --git a/apps/web/src/lib/api-collections.ts b/apps/web/src/lib/api-collections.ts index d8099bc6..226c22ba 100644 --- a/apps/web/src/lib/api-collections.ts +++ b/apps/web/src/lib/api-collections.ts @@ -1,6 +1,7 @@ import type { AllowedChannelItem, BlockedItem, + BlockedKeywordItem, FavoriteItem, ProgressItem, WatchLaterItem, @@ -86,6 +87,25 @@ export async function unblockVideo(url: string): Promise { await throwIfFailed(res, "unblock failed"); } +export function fetchBlockedKeywords(): Promise { + return authedJson(`${BASE}/blocked/keywords`); +} + +export function blockKeyword(keyword: string): Promise { + return authedJson(`${BASE}/blocked/keywords`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyword }), + }); +} + +export async function unblockKeyword(keyword: string): Promise { + const res = await authed(`${BASE}/blocked/keywords/${encodeURIComponent(keyword)}`, { + method: "DELETE", + }); + await throwIfFailed(res, "unblock failed"); +} + export function fetchAllowedChannels(): Promise { return authedJson(`${BASE}/allowed/channels`); } diff --git a/apps/web/src/lib/api-restore.ts b/apps/web/src/lib/api-restore.ts index 790b77d6..93bc5418 100644 --- a/apps/web/src/lib/api-restore.ts +++ b/apps/web/src/lib/api-restore.ts @@ -3,6 +3,21 @@ import { authed } from "./authed"; import { API_BASE as BASE } from "./env"; export type PipePipeTimeMode = "raw" | "normalized"; +export type TypeTypeBackupCategory = + | "subscriptions" + | "history" + | "playlists" + | "watchLater" + | "favorites" + | "progress" + | "searchHistory" + | "savedPlaylists" + | "settings" + | "contentFilters"; + +export type TypeTypeRestoreSummary = { + restored: Record; +}; export type PipePipeRestoreSummary = { history: number; @@ -27,6 +42,44 @@ function readErrorMessage(payload: unknown, fallback = "Restore failed"): string return fallback; } +function downloadName(response: Response): string { + const disposition = response.headers.get("Content-Disposition") ?? ""; + const match = disposition.match(/filename="?([^";]+)"?/i); + return match?.[1] ?? `typetype-backup-${new Date().toISOString().slice(0, 10)}.json`; +} + +export async function downloadTypeTypeBackup( + categories: readonly TypeTypeBackupCategory[], +): Promise { + const search = new URLSearchParams({ categories: categories.join(",") }); + const response = await authed(`${BASE}/backup/typetype?${search.toString()}`); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new ApiError(readErrorMessage(payload, "Export failed"), response.status); + } + const url = URL.createObjectURL(await response.blob()); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = downloadName(response); + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +export async function restoreTypeType(file: File): Promise { + const body = new FormData(); + body.append("file", file); + const response = await authed(`${BASE}/restore/typetype`, { method: "POST", body }); + const payload = (await response.json().catch(() => ({}))) as + | TypeTypeRestoreSummary + | ErrorPayload; + if (!response.ok) { + throw new ApiError(readErrorMessage(payload), response.status); + } + return payload as TypeTypeRestoreSummary; +} + export async function restorePipePipe( file: File, timeMode: PipePipeTimeMode, diff --git a/apps/web/src/lib/api-stream.ts b/apps/web/src/lib/api-stream.ts index 68454b95..e7cbc463 100644 --- a/apps/web/src/lib/api-stream.ts +++ b/apps/web/src/lib/api-stream.ts @@ -3,7 +3,6 @@ import type { StreamResponse } from "../types/api"; import { ApiError, request } from "./api"; import { recordClientEvent } from "./client-debug-log"; import { sanitizeVideoContext } from "./debug-sanitize"; -import { type PlaybackMode, readPlaybackMode } from "./playback-mode"; import { sabrBootstrapEndpoint, streamEndpoint } from "./stream-request"; type StreamFetchMode = "anonymous_first" | "authenticated_first"; @@ -21,9 +20,8 @@ export async function fetchStream( url: string, mode: StreamFetchMode = "anonymous_first", signal?: AbortSignal, - playbackMode: PlaybackMode = readPlaybackMode(), ): Promise { - const endpoint = streamEndpoint(url, playbackMode); + const endpoint = streamEndpoint(url); return fetchStreamEndpoint(url, endpoint, mode, signal); } diff --git a/apps/web/src/lib/api-version.ts b/apps/web/src/lib/api-version.ts new file mode 100644 index 00000000..1d658623 --- /dev/null +++ b/apps/web/src/lib/api-version.ts @@ -0,0 +1,61 @@ +import { request } from "./api"; +import { API_BASE as BASE } from "./env"; + +const VERSION_COMPONENTS = ["frontend", "server", "token", "downloader"] as const; + +export type VersionComponent = (typeof VERSION_COMPONENTS)[number]; + +export type ComponentVersion = { + service: string; + version: string; + revision: string; + shortRevision: string; + buildTime: string; +}; + +export type VersionInfo = Record; + +const VERSION_PATHS: Record = { + frontend: "web", + server: "server", + token: "token", + downloader: "downloader", +}; + +export function parseComponentVersion(value: unknown): ComponentVersion | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + if ( + typeof candidate.service !== "string" || + typeof candidate.version !== "string" || + typeof candidate.revision !== "string" || + typeof candidate.shortRevision !== "string" || + typeof candidate.buildTime !== "string" + ) { + return null; + } + return { + service: candidate.service, + version: candidate.version, + revision: candidate.revision, + shortRevision: candidate.shortRevision, + buildTime: candidate.buildTime, + }; +} + +async function fetchComponentVersion(component: VersionComponent): Promise { + const payload = await request(`${BASE}/version/${VERSION_PATHS[component]}`); + const version = parseComponentVersion(payload); + if (!version) throw new Error(`Invalid ${component} version payload`); + return version; +} + +export async function fetchVersionInfo(): Promise { + const results = await Promise.allSettled(VERSION_COMPONENTS.map(fetchComponentVersion)); + return Object.fromEntries( + VERSION_COMPONENTS.map((component, index) => [ + component, + results[index].status === "fulfilled" ? results[index].value : null, + ]), + ) as VersionInfo; +} diff --git a/apps/web/src/lib/bilibili-manifest.ts b/apps/web/src/lib/bilibili-manifest.ts index 3d9bd89f..24f9cb37 100644 --- a/apps/web/src/lib/bilibili-manifest.ts +++ b/apps/web/src/lib/bilibili-manifest.ts @@ -1,6 +1,6 @@ import type { AudioStreamItem, VideoStreamItem } from "../types/api"; import { proxyUrl } from "./proxy"; -import { hasPlayableLegacyUrl } from "./stream-delivery"; +import { hasPlayableDirectUrl } from "./stream-delivery"; type VideoCandidate = VideoStreamItem & { codec: string }; type AudioCandidate = AudioStreamItem & { codec: string }; @@ -58,13 +58,13 @@ function isSupportedCodec(mimeType: string, codec: string): boolean { function isVideoCandidate(stream: VideoStreamItem): stream is VideoCandidate { return ( - hasPlayableLegacyUrl(stream) && typeof stream.codec === "string" && stream.codec.length > 0 + hasPlayableDirectUrl(stream) && typeof stream.codec === "string" && stream.codec.length > 0 ); } function isAudioCandidate(stream: AudioStreamItem): stream is AudioCandidate { return ( - hasPlayableLegacyUrl(stream) && typeof stream.codec === "string" && stream.codec.length > 0 + hasPlayableDirectUrl(stream) && typeof stream.codec === "string" && stream.codec.length > 0 ); } diff --git a/apps/web/src/lib/blocked-keyword-filter.ts b/apps/web/src/lib/blocked-keyword-filter.ts new file mode 100644 index 00000000..eb1fee10 --- /dev/null +++ b/apps/web/src/lib/blocked-keyword-filter.ts @@ -0,0 +1,11 @@ +export function normalizeBlockedKeyword(keyword: string): string { + return keyword.normalize("NFKC").trim().toLowerCase(); +} + +export function titleMatchesBlockedKeyword(title: string, keywords: readonly string[]): boolean { + const normalizedTitle = title.normalize("NFKC").toLowerCase(); + return keywords.some((keyword) => { + const normalizedKeyword = normalizeBlockedKeyword(keyword); + return normalizedKeyword.length > 0 && normalizedTitle.includes(normalizedKeyword); + }); +} diff --git a/apps/web/src/lib/dash-manifest.ts b/apps/web/src/lib/dash-manifest.ts index 004e6f8a..c0421bc5 100644 --- a/apps/web/src/lib/dash-manifest.ts +++ b/apps/web/src/lib/dash-manifest.ts @@ -1,6 +1,6 @@ import type { AudioStreamItem, VideoStreamItem } from "../types/api"; import { proxyUrl } from "./proxy"; -import { hasPlayableLegacyUrl } from "./stream-delivery"; +import { hasPlayableDirectUrl } from "./stream-delivery"; const EXCLUDED_VIDEO_PREFIXES = ["av01", "vp9", "vp09"]; @@ -9,7 +9,7 @@ type ValidAudioStream = AudioStreamItem & { codec: string }; function isValidVideo(s: VideoStreamItem): s is ValidVideoStream { const codec = s.codec; - if (!hasPlayableLegacyUrl(s)) return false; + if (!hasPlayableDirectUrl(s)) return false; if (codec === null || codec.length === 0) return false; if (!s.mimeType.includes("video/mp4")) return false; return !EXCLUDED_VIDEO_PREFIXES.some((p) => codec.startsWith(p)); @@ -17,7 +17,7 @@ function isValidVideo(s: VideoStreamItem): s is ValidVideoStream { function isValidAudio(s: AudioStreamItem): s is ValidAudioStream { const codec = s.codec; - if (!hasPlayableLegacyUrl(s)) return false; + if (!hasPlayableDirectUrl(s)) return false; if (codec === null || codec.length === 0) return false; if (!codec.startsWith("mp4a")) return false; return s.mimeType.includes("audio/mp4"); diff --git a/apps/web/src/lib/embed-playback.ts b/apps/web/src/lib/embed-playback.ts index 956488a6..8304b3ad 100644 --- a/apps/web/src/lib/embed-playback.ts +++ b/apps/web/src/lib/embed-playback.ts @@ -1,5 +1,3 @@ -import type { PlaybackMode } from "./playback-mode"; - export function resolveEmbedAutoplay( retryKey: number, playbackIntent: boolean, @@ -7,10 +5,3 @@ export function resolveEmbedAutoplay( ): boolean { return retryKey === 0 ? requestedAutoplay : playbackIntent; } - -export function resolveEmbedPlaybackMode( - framed: boolean, - storedPlaybackMode: PlaybackMode, -): PlaybackMode { - return framed ? "sabr" : storedPlaybackMode; -} diff --git a/apps/web/src/lib/playback-mode.ts b/apps/web/src/lib/playback-mode.ts deleted file mode 100644 index ade4a6f4..00000000 --- a/apps/web/src/lib/playback-mode.ts +++ /dev/null @@ -1,62 +0,0 @@ -export type PlaybackMode = "legacy" | "sabr"; - -const STORAGE_KEY = "typed-playback-mode"; -const CHANGE_EVENT = "typed:playback-mode-change"; -const CLASSIC_REQUEST_EVENT = "typed:classic-playback-request"; - -function canUseStorage(): boolean { - return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; -} - -function isPlaybackMode(value: string | null): value is PlaybackMode { - return value === "legacy" || value === "sabr"; -} - -export function readPlaybackMode(): PlaybackMode { - if (!canUseStorage()) return "legacy"; - return resolveStoredPlaybackMode(window.localStorage.getItem(STORAGE_KEY)); -} - -export function resolveStoredPlaybackMode(stored: string | null): PlaybackMode { - if (stored === "adaptive") return "sabr"; - if (stored === "ios-legacy-compat") return "legacy"; - return isPlaybackMode(stored) ? stored : "legacy"; -} - -export function setPlaybackMode(mode: PlaybackMode): void { - if (!canUseStorage()) return; - window.localStorage.setItem(STORAGE_KEY, mode); - window.dispatchEvent(new Event(CHANGE_EVENT)); -} - -export function requestPlaybackMode(mode: PlaybackMode): void { - if (mode === "legacy" && typeof window !== "undefined") { - window.dispatchEvent(new Event(CLASSIC_REQUEST_EVENT)); - return; - } - setPlaybackMode(mode); -} - -export function subscribeClassicPlaybackRequest(listener: () => void): () => void { - if (typeof window === "undefined") return () => {}; - window.addEventListener(CLASSIC_REQUEST_EVENT, listener); - return () => window.removeEventListener(CLASSIC_REQUEST_EVENT, listener); -} - -export function subscribePlaybackMode(listener: () => void): () => void { - if (typeof window === "undefined") return () => {}; - - const onStorage = (event: StorageEvent) => { - if (event.key !== STORAGE_KEY) return; - listener(); - }; - const onChange = () => listener(); - - window.addEventListener("storage", onStorage); - window.addEventListener(CHANGE_EVENT, onChange); - - return () => { - window.removeEventListener("storage", onStorage); - window.removeEventListener(CHANGE_EVENT, onChange); - }; -} diff --git a/apps/web/src/lib/playback-speed.ts b/apps/web/src/lib/playback-speed.ts new file mode 100644 index 00000000..4db81c36 --- /dev/null +++ b/apps/web/src/lib/playback-speed.ts @@ -0,0 +1,12 @@ +export const DEFAULT_PLAYBACK_SPEED_OPTIONS = [ + 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, +] as const; + +export function normalizeDefaultPlaybackSpeed(value: number): number { + if (!Number.isFinite(value)) return 1; + return Math.min(4, Math.max(0.25, value)); +} + +export function playbackSpeedLabel(value: number): string { + return `${value}x`; +} diff --git a/apps/web/src/lib/sabr-playback-rate-preference.ts b/apps/web/src/lib/sabr-playback-rate-preference.ts index bfbad425..da00120a 100644 --- a/apps/web/src/lib/sabr-playback-rate-preference.ts +++ b/apps/web/src/lib/sabr-playback-rate-preference.ts @@ -8,19 +8,27 @@ function isValidPlaybackRate(rate: number): boolean { export class SabrPlaybackRatePreference { private preferredRate: number | null = null; - initialize(video: HTMLVideoElement): void { + constructor(initialRate?: number) { + if (initialRate !== undefined) this.setPreferredRate(initialRate); + } + + setPreferredRate(rate: number): void { + if (isValidPlaybackRate(rate)) this.preferredRate = rate; + } + + initialize(video: HTMLMediaElement): void { if (this.preferredRate === null && isValidPlaybackRate(video.playbackRate)) { this.preferredRate = video.playbackRate; } } - capture(video: HTMLVideoElement, transient: boolean): void { + capture(video: HTMLMediaElement, transient: boolean): void { if (!transient && isValidPlaybackRate(video.playbackRate)) { this.preferredRate = video.playbackRate; } } - apply(video: HTMLVideoElement, transient: boolean): void { + apply(video: HTMLMediaElement, transient: boolean): void { if (transient || this.preferredRate === null) return; video.defaultPlaybackRate = this.preferredRate; video.playbackRate = this.preferredRate; diff --git a/apps/web/src/lib/settings-section.ts b/apps/web/src/lib/settings-section.ts index 473e1a9f..cea73c26 100644 --- a/apps/web/src/lib/settings-section.ts +++ b/apps/web/src/lib/settings-section.ts @@ -6,7 +6,8 @@ export type SettingsSection = | "service" | "import" | "privacy" - | "blocked"; + | "blocked" + | "about"; const SETTINGS_SECTION_KEY = "typetype-settings-section"; @@ -19,7 +20,8 @@ export function isSettingsSection(value: unknown): value is SettingsSection { value === "service" || value === "import" || value === "privacy" || - value === "blocked" + value === "blocked" || + value === "about" ); } diff --git a/apps/web/src/lib/sponsorblock-playback-tools.ts b/apps/web/src/lib/sponsorblock-playback-tools.ts new file mode 100644 index 00000000..af845eb5 --- /dev/null +++ b/apps/web/src/lib/sponsorblock-playback-tools.ts @@ -0,0 +1,11 @@ +import type { SponsorBlockSegmentItem } from "../types/api"; + +type SponsorBlockPlaybackState = { + audioOnly: boolean; + autoSkip: boolean; + autoSkipSegments?: SponsorBlockSegmentItem[]; +}; + +export function shouldRunSponsorBlockAutoSkip(state: SponsorBlockPlaybackState): boolean { + return state.autoSkip && Boolean(state.autoSkipSegments); +} diff --git a/apps/web/src/lib/stream-compatibility.ts b/apps/web/src/lib/stream-compatibility.ts index 91eb24a9..bfb11f7a 100644 --- a/apps/web/src/lib/stream-compatibility.ts +++ b/apps/web/src/lib/stream-compatibility.ts @@ -1,13 +1,13 @@ import type { VideoStream } from "../types/stream"; import { proxyDashManifest } from "./proxy"; -import { hasPlayableLegacyUrl } from "./stream-delivery"; +import { hasPlayableDirectUrl } from "./stream-delivery"; import type { MediaSrc } from "./vidstack"; export function pickCompatibleProgressiveSrc(stream: VideoStream): MediaSrc | null { const progressive = [...(stream.videoStreams ?? [])] .filter( (candidate) => - hasPlayableLegacyUrl(candidate) && + hasPlayableDirectUrl(candidate) && typeof candidate.codec === "string" && candidate.codec.includes("avc1") && candidate.codec.includes("mp4a") && @@ -27,14 +27,14 @@ export function hasCompatibleMp4(stream: VideoStream): boolean { const audios = stream.audioStreams ?? []; const hasMp4Video = videos.some( (video) => - hasPlayableLegacyUrl(video) && + hasPlayableDirectUrl(video) && typeof video.codec === "string" && video.codec.startsWith("avc1") && (video.mimeType?.includes("video/mp4") ?? true), ); const hasMp4Audio = audios.some( (audio) => - hasPlayableLegacyUrl(audio) && + hasPlayableDirectUrl(audio) && typeof audio.codec === "string" && (audio.codec.startsWith("mp4a") || audio.codec === "mp4a") && (audio.mimeType?.includes("audio/mp4") ?? true), diff --git a/apps/web/src/lib/stream-delivery.ts b/apps/web/src/lib/stream-delivery.ts index 949b76f2..63ce7a64 100644 --- a/apps/web/src/lib/stream-delivery.ts +++ b/apps/web/src/lib/stream-delivery.ts @@ -11,24 +11,24 @@ function isSabrStream(item: { deliveryMethod?: string | null }): boolean { return item.deliveryMethod === "sabr"; } -export function hasPlayableLegacyUrl(item: DeliveredStream): boolean { +export function hasPlayableDirectUrl(item: DeliveredStream): boolean { return !isSabrStream(item) && item.url.length > 0; } -export function legacyVideoOnlyStreams(stream: VideoStream): VideoStreamItem[] { - return (stream.videoOnlyStreams ?? []).filter(hasPlayableLegacyUrl); +export function directVideoOnlyStreams(stream: VideoStream): VideoStreamItem[] { + return (stream.videoOnlyStreams ?? []).filter(hasPlayableDirectUrl); } -export function legacyAudioStreams(stream: VideoStream): AudioStreamItem[] { - return (stream.audioStreams ?? []).filter(hasPlayableLegacyUrl); +export function directAudioStreams(stream: VideoStream): AudioStreamItem[] { + return (stream.audioStreams ?? []).filter(hasPlayableDirectUrl); } -export function legacyProgressiveStreams(stream: VideoStream): VideoStreamItem[] { - return (stream.videoStreams ?? []).filter(hasPlayableLegacyUrl); +export function directProgressiveStreams(stream: VideoStream): VideoStreamItem[] { + return (stream.videoStreams ?? []).filter(hasPlayableDirectUrl); } -export function hasLegacyDashPair(stream: VideoStream): boolean { - return legacyVideoOnlyStreams(stream).length > 0 && legacyAudioStreams(stream).length > 0; +export function hasDirectDashPair(stream: VideoStream): boolean { + return directVideoOnlyStreams(stream).length > 0 && directAudioStreams(stream).length > 0; } export function hasSabrPlayback(stream: VideoStream): boolean { diff --git a/apps/web/src/lib/stream-fallback-src.ts b/apps/web/src/lib/stream-direct-src.ts similarity index 71% rename from apps/web/src/lib/stream-fallback-src.ts rename to apps/web/src/lib/stream-direct-src.ts index f34c31ba..64c3bef9 100644 --- a/apps/web/src/lib/stream-fallback-src.ts +++ b/apps/web/src/lib/stream-direct-src.ts @@ -3,10 +3,10 @@ import { buildDashManifest } from "./dash-manifest"; import { API_BASE as BASE } from "./env"; import { pickCompactAudioTracks } from "./stream-audio-compact"; import { pickCompatibleProgressiveSrc } from "./stream-compatibility"; -import { legacyAudioStreams, legacyVideoOnlyStreams } from "./stream-delivery"; +import { directAudioStreams, directVideoOnlyStreams } from "./stream-delivery"; import type { MediaSrc } from "./vidstack"; -export function resolveLegacyFallbackSrc( +export function resolveDirectSrc( stream: VideoStream, maxHeight: number | undefined, compactAudioTracks: boolean, @@ -14,14 +14,14 @@ export function resolveLegacyFallbackSrc( maxCompactAudioTracks: number, allowServerManifests: boolean, ): MediaSrc { - const legacyVideos = legacyVideoOnlyStreams(stream); - const legacyAudios = legacyAudioStreams(stream); + const directVideos = directVideoOnlyStreams(stream); + const directAudios = directAudioStreams(stream); const audioStreams = compactAudioTracks - ? pickCompactAudioTracks(legacyAudios, preferredAudioLanguage, maxCompactAudioTracks) - : legacyAudios; + ? pickCompactAudioTracks(directAudios, preferredAudioLanguage, maxCompactAudioTracks) + : directAudios; - if (legacyVideos.length && audioStreams.length) { - const built = buildDashManifest(legacyVideos, audioStreams, stream.duration, maxHeight); + if (directVideos.length && audioStreams.length) { + const built = buildDashManifest(directVideos, audioStreams, stream.duration, maxHeight); if (built) return { src: built, type: "application/dash+xml" }; } if (!allowServerManifests) { diff --git a/apps/web/src/lib/stream-request.ts b/apps/web/src/lib/stream-request.ts index f58f043d..5f541a60 100644 --- a/apps/web/src/lib/stream-request.ts +++ b/apps/web/src/lib/stream-request.ts @@ -1,14 +1,9 @@ import { API_BASE as BASE } from "./env"; -import type { PlaybackMode } from "./playback-mode"; import { detectProvider } from "./provider"; -function effectivePlaybackMode(url: string, playbackMode: PlaybackMode): PlaybackMode { - return playbackMode === "sabr" && detectProvider(url) === "youtube" ? "sabr" : "legacy"; -} - -export function streamEndpoint(url: string, playbackMode: PlaybackMode): string { +export function streamEndpoint(url: string): string { const provider = detectProvider(url); - const path = providerStreamPath(provider, effectivePlaybackMode(url, playbackMode)); + const path = providerStreamPath(provider); return `${BASE}${path}?url=${encodeURIComponent(url)}`; } @@ -17,22 +12,18 @@ export function sabrBootstrapEndpoint(url: string): string | null { return `${BASE}/streams/youtube/sabr/bootstrap?url=${encodeURIComponent(url)}`; } -function providerStreamPath( - provider: ReturnType, - playbackMode: PlaybackMode, -) { - if (provider === "youtube") return `/streams/youtube/${playbackMode}`; +function providerStreamPath(provider: ReturnType) { + if (provider === "youtube") return "/streams/youtube/sabr"; if (provider === "nicovideo") return "/streams/niconico"; if (provider === "bilibili") return "/streams/bilibili"; - return "/streams/youtube/legacy"; + throw new Error("Unsupported video provider"); } export function streamQueryKey( url: string, authenticated: boolean, - playbackMode: PlaybackMode, -): readonly ["stream", string, "auth" | "anon", PlaybackMode] { - return ["stream", url, authenticated ? "auth" : "anon", effectivePlaybackMode(url, playbackMode)]; +): readonly ["stream", string, "auth" | "anon"] { + return ["stream", url, authenticated ? "auth" : "anon"]; } export function sabrBootstrapQueryKey( diff --git a/apps/web/src/lib/stream-src.ts b/apps/web/src/lib/stream-src.ts index 18241d38..72374b11 100644 --- a/apps/web/src/lib/stream-src.ts +++ b/apps/web/src/lib/stream-src.ts @@ -5,8 +5,8 @@ import { buildNicoHlsManifest } from "./nico-hls-manifest"; import { detectProvider } from "./provider"; import { proxyDashManifest } from "./proxy"; import { hasCompatibleMp4, pickCompatibleProgressiveSrc } from "./stream-compatibility"; -import { hasLegacyDashPair } from "./stream-delivery"; -import { resolveLegacyFallbackSrc } from "./stream-fallback-src"; +import { hasDirectDashPair } from "./stream-delivery"; +import { resolveDirectSrc } from "./stream-direct-src"; import type { MediaSrc } from "./vidstack"; type ResolveManifestOptions = { @@ -36,14 +36,14 @@ export function resolveHlsManifestUrl(stream: VideoStream): string { return proxyDashManifest(`${BASE}/streams/hls-manifest?url=${encodeURIComponent(stream.id)}`); } -export function shouldUseClassicHls( +export function shouldUseHls( hlsUrl: string | undefined, isLive: boolean, hlsFailed: boolean, - legacyDashPair: boolean, + directDashPair: boolean, ): boolean { return Boolean( - hlsUrl && !hlsFailed && (isLive || isSignedHlsManifestUrl(hlsUrl) || !legacyDashPair), + hlsUrl && !hlsFailed && (isLive || isSignedHlsManifestUrl(hlsUrl) || !directDashPair), ); } @@ -60,10 +60,9 @@ export function resolveManifestSrc( const allowServerManifests = options?.allowServerManifests ?? true; const provider = detectProvider(stream.id); const safeMaxHeight = qualityFailed ? 720 : 1080; - const legacyDashPair = hasLegacyDashPair(stream); - const allowLegacyServerManifests = allowServerManifests; + const directDashPair = hasDirectDashPair(stream); - if (shouldUseClassicHls(stream.hlsUrl, isLive, options?.hlsFailed ?? false, legacyDashPair)) { + if (shouldUseHls(stream.hlsUrl, isLive, options?.hlsFailed ?? false, directDashPair)) { return { src: resolveHlsManifestUrl(stream), type: "application/x-mpegurl", @@ -101,8 +100,8 @@ export function resolveManifestSrc( provider === "youtube" && options?.enableHighQualityPlayback && !options.highQualityFailed && - allowLegacyServerManifests && - legacyDashPair && + allowServerManifests && + directDashPair && !compatibilityMode ) { return { @@ -111,19 +110,19 @@ export function resolveManifestSrc( }; } - if (!hasCompatibleMp4(stream) && allowLegacyServerManifests) { + if (!hasCompatibleMp4(stream) && allowServerManifests) { return { src: proxyDashManifest(`${BASE}/streams/manifest?url=${encodeURIComponent(stream.id)}`), type: "application/dash+xml", }; } - return resolveLegacyFallbackSrc( + return resolveDirectSrc( stream, safeMaxHeight, compactAudioTracks, options?.preferredAudioLanguage, maxCompactAudioTracks, - allowLegacyServerManifests, + allowServerManifests, ); } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 2489d973..b7905775 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -5,7 +5,6 @@ import { AuthBackdrop } from "../components/auth-backdrop"; import { GuestDisabledScreen } from "../components/guest-disabled-screen"; import { MobileTabBar } from "../components/mobile-tab-bar"; import { Navbar } from "../components/navbar"; -import { PlaybackTransitionNotice } from "../components/playback-transition-notice"; import { Sidebar } from "../components/sidebar"; import { useAuth } from "../hooks/use-auth"; import { useInstance } from "../hooks/use-instance"; @@ -144,7 +143,6 @@ function RootLayout() { return (
-
@@ -179,7 +177,6 @@ function RootLayout() {
- {watchCinemaPage ? !isMobile && : }
diff --git a/apps/web/src/routes/embed_.$videoId.tsx b/apps/web/src/routes/embed_.$videoId.tsx index edb96c37..76843300 100644 --- a/apps/web/src/routes/embed_.$videoId.tsx +++ b/apps/web/src/routes/embed_.$videoId.tsx @@ -5,14 +5,12 @@ import { EmbedLoading } from "../components/embed-loading"; import { EmbedPlayerShell } from "../components/embed-player-shell"; import { useAuth } from "../hooks/use-auth"; import { useInstance } from "../hooks/use-instance"; -import { usePlaybackMode } from "../hooks/use-playback-mode"; import { useSettings } from "../hooks/use-settings"; import { isStreamUnavailableError, useSabrBootstrap, useStream } from "../hooks/use-stream"; import { FAMILY_LIST_BLOCKED_MESSAGE, isChannelNotAllowedError } from "../lib/allow-list-error"; import { ApiError } from "../lib/api"; import { isYoutubeSessionReconnectError } from "../lib/api-youtube-session"; import { isEmbeddedFrame, resolveEmbedAccess } from "../lib/embed-access"; -import { resolveEmbedPlaybackMode } from "../lib/embed-playback"; import { parseStartTime } from "../lib/parse-start-time"; import { selectProgressiveWatchStream } from "../lib/progressive-watch-stream"; import { proxyImage } from "../lib/proxy"; @@ -59,28 +57,16 @@ function EmbedPage() { isGuest, settingsReady, }); - const { playbackMode: storedPlaybackMode } = usePlaybackMode(); - const playbackMode = resolveEmbedPlaybackMode(framed, storedPlaybackMode); const useAuthenticatedStream = access.sessionEnabled && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); - const streamQuery = useStream( - sourceUrl, - useAuthenticatedStream, - access.streamEnabled, - playbackMode, - ); - const bootstrap = useSabrBootstrap( - sourceUrl, - useAuthenticatedStream, - access.streamEnabled, - playbackMode, - ); + const streamQuery = useStream(sourceUrl, useAuthenticatedStream, access.streamEnabled); + const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, access.streamEnabled); const publicParam = toPublicWatchParam(sourceUrl); const availabilityPoster = proxyImage(youtubeThumbnailUrl(publicParam) ?? ""); const activeStream = selectProgressiveWatchStream( streamQuery.isPlaceholderData ? undefined : streamQuery.data, - playbackMode === "sabr" ? bootstrap.data : undefined, + bootstrap.data, publicParam, [], ); @@ -158,7 +144,6 @@ function EmbedPage() { startTime={startTime} autoplay={shouldAutoplay} sessionEnabled={access.sessionEnabled} - playbackMode={playbackMode} /> ); } diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 386e248f..5727748a 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,13 +1,14 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import { useSettings } from "../hooks/use-settings"; -import { goto } from "../lib/route-redirect"; import { getStoredSettingsSection, isSettingsSection, rememberSettingsSection, type SettingsSection, } from "../lib/settings-section"; +import { SettingsAbout } from "../settings/settings-about"; +import { SettingsBackup } from "../settings/settings-backup"; import { SettingsBlocked } from "../settings/settings-blocked"; import { SettingsLandingPage } from "../settings/settings-landing-page"; import { SettingsLanguage } from "../settings/settings-language"; @@ -30,6 +31,7 @@ const BASE_ITEMS: Item[] = [ { key: "import", label: "Import" }, { key: "privacy", label: "Privacy" }, { key: "blocked", label: "Blocked" }, + { key: "about", label: "About" }, ]; function settingsItems(showLanguage: boolean): Item[] { @@ -43,27 +45,6 @@ function settingsItems(showLanguage: boolean): Item[] { ]; } -function SettingsImport() { - return ( -
-

Migration

-
-
- Import from YouTube or PipePipe - Open the dedicated import page. -
- -
-
- ); -} - function SettingsPage() { const { settings } = useSettings(); const { section } = Route.useSearch(); @@ -93,9 +74,10 @@ function SettingsPage() { {activeSection === "home" && } {activeSection === "language" && settings.defaultService === 0 && } {activeSection === "service" && } - {activeSection === "import" && } + {activeSection === "import" && } {activeSection === "privacy" && } {activeSection === "blocked" && } + {activeSection === "about" && }
); } diff --git a/apps/web/src/routes/shorts.tsx b/apps/web/src/routes/shorts.tsx index 10a88c3f..e9765d28 100644 --- a/apps/web/src/routes/shorts.tsx +++ b/apps/web/src/routes/shorts.tsx @@ -56,7 +56,7 @@ function ShortsPage() { diff --git a/apps/web/src/routes/watch.tsx b/apps/web/src/routes/watch.tsx index 2208ee62..c7ddd6a6 100644 --- a/apps/web/src/routes/watch.tsx +++ b/apps/web/src/routes/watch.tsx @@ -7,7 +7,6 @@ import { useAuth } from "../hooks/use-auth"; import { useDocumentTitle } from "../hooks/use-document-title"; import { useHistory } from "../hooks/use-history"; import { useInstance } from "../hooks/use-instance"; -import { usePlaybackMode } from "../hooks/use-playback-mode"; import { useProgress } from "../hooks/use-progress"; import { useSettings } from "../hooks/use-settings"; import { useSabrBootstrap, useStream } from "../hooks/use-stream"; @@ -31,17 +30,11 @@ function WatchPage() { const { data: instance, isPending: instancePending } = useInstance(); const { settings, settingsReady } = useSettings(); const navigationSnapshot = useWatchNavigationStore((state) => state.snapshot); - const { playbackMode } = usePlaybackMode(); const useAuthenticatedStream = isAuthed && (settings.accessMode === "allow_list" || instance?.guestAllowed === false); const streamEnabled = authReady && !instancePending && (!isAuthed || settingsReady); - const streamQuery = useStream(sourceUrl, useAuthenticatedStream, streamEnabled, playbackMode); - const bootstrap = useSabrBootstrap( - sourceUrl, - useAuthenticatedStream, - streamEnabled, - playbackMode, - ); + const streamQuery = useStream(sourceUrl, useAuthenticatedStream, streamEnabled); + const bootstrap = useSabrBootstrap(sourceUrl, useAuthenticatedStream, streamEnabled); const { add } = useHistory(); const progressFetch = useProgress(sourceUrl); const previewMatches = @@ -53,7 +46,7 @@ function WatchPage() { ); const activeStream = selectProgressiveWatchStream( streamQuery.isPlaceholderData ? undefined : streamQuery.data, - playbackMode === "sabr" ? bootstrap.data : undefined, + bootstrap.data, publicParam, previewRelated, ); diff --git a/apps/web/src/settings/settings-about.tsx b/apps/web/src/settings/settings-about.tsx new file mode 100644 index 00000000..0995010f --- /dev/null +++ b/apps/web/src/settings/settings-about.tsx @@ -0,0 +1,55 @@ +import { RefreshCw } from "lucide-react"; +import { useVersionInfo } from "../hooks/use-version-info"; +import type { ComponentVersion, VersionComponent } from "../lib/api-version"; + +const COMPONENTS: { key: VersionComponent; label: string }[] = [ + { key: "frontend", label: "Frontend" }, + { key: "server", label: "Server" }, + { key: "token", label: "Token" }, + { key: "downloader", label: "Downloader" }, +]; + +function VersionDetails({ version }: { version: ComponentVersion | null }) { + if (!version) return Unavailable; + return ( +
+

{version.version}

+

{version.revision}

+

{version.buildTime}

+
+ ); +} + +export function SettingsAbout() { + const query = useVersionInfo(); + + return ( +
+
+

Versions

+ +
+
+ {COMPONENTS.map(({ key, label }) => ( +
+ {label} + {query.isPending ? ( + Loading... + ) : ( + + )} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/settings/settings-backup.tsx b/apps/web/src/settings/settings-backup.tsx new file mode 100644 index 00000000..70cc6329 --- /dev/null +++ b/apps/web/src/settings/settings-backup.tsx @@ -0,0 +1,145 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Download, Upload } from "lucide-react"; +import { type ChangeEvent, useEffect, useRef, useState } from "react"; +import { Toast } from "../components/toast"; +import { + downloadTypeTypeBackup, + restoreTypeType, + type TypeTypeBackupCategory, +} from "../lib/api-restore"; +import { goto } from "../lib/route-redirect"; + +const SECTION_LABEL = "px-1 text-xs font-medium uppercase tracking-wider text-fg-soft"; +const CATEGORIES: { value: TypeTypeBackupCategory; label: string }[] = [ + { value: "subscriptions", label: "Subscriptions" }, + { value: "history", label: "Watch history" }, + { value: "playlists", label: "Playlists" }, + { value: "watchLater", label: "Watch later" }, + { value: "favorites", label: "Favorites" }, + { value: "progress", label: "Playback progress" }, + { value: "searchHistory", label: "Search history" }, + { value: "savedPlaylists", label: "Saved playlists" }, + { value: "settings", label: "Settings" }, + { value: "contentFilters", label: "Content filters" }, +]; + +function message(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +export function SettingsBackup() { + const queryClient = useQueryClient(); + const fileInput = useRef(null); + const [categories, setCategories] = useState( + CATEGORIES.map((item) => item.value), + ); + const [toast, setToast] = useState(null); + + useEffect(() => { + if (!toast) return; + const timer = setTimeout(() => setToast(null), 3200); + return () => clearTimeout(timer); + }, [toast]); + + const exportBackup = useMutation({ + mutationFn: () => downloadTypeTypeBackup(categories), + onSuccess: () => setToast("Export completed"), + onError: (error) => setToast(message(error, "Export failed")), + }); + const importBackup = useMutation({ + mutationFn: restoreTypeType, + onSuccess: (summary) => { + const count = Object.values(summary.restored).reduce((total, value) => total + value, 0); + setToast(`Backup restored: ${count} items`); + void queryClient.invalidateQueries(); + }, + onError: (error) => setToast(message(error, "Restore failed")), + }); + + function toggleCategory(category: TypeTypeBackupCategory) { + setCategories((current) => + current.includes(category) + ? current.filter((item) => item !== category) + : [...current, category], + ); + } + + function selectFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) importBackup.mutate(file); + } + + return ( +
+
+

TypeType backup

+
+
+ {CATEGORIES.map((category) => ( + + ))} +
+
+ + + +
+

+ Restoring replaces the categories included in the backup. +

+
+
+
+

Migration

+
+
+ Import from YouTube or PipePipe + Open the dedicated import page. +
+ +
+
+ +
+ ); +} diff --git a/apps/web/src/settings/settings-blocked.tsx b/apps/web/src/settings/settings-blocked.tsx index c9dd6d4a..7f9e0abe 100644 --- a/apps/web/src/settings/settings-blocked.tsx +++ b/apps/web/src/settings/settings-blocked.tsx @@ -1,30 +1,11 @@ -import { type MouseEvent, useState } from "react"; +import { Plus, X } from "lucide-react"; +import { type FormEvent, type MouseEvent, useState } from "react"; import { ChannelAvatar } from "../components/channel-avatar"; import { useBlocked } from "../hooks/use-blocked"; import type { BlockedItem } from "../types/user"; const SECTION_LABEL = "text-xs font-medium text-fg-soft uppercase tracking-wider px-1"; -function XIcon() { - return ( - - ); -} - type ChannelBubbleProps = { item: BlockedItem; onClick: () => void; @@ -53,7 +34,7 @@ function ChannelBubble({ item, onClick, onRemove }: ChannelBubbleProps) { aria-label={`Unblock ${label}`} className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-surface-strong hover:bg-danger border border-border-strong text-fg-muted flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" > - +
); @@ -104,16 +85,74 @@ function BlockedChannelModal({ item, onUnblock, onClose }: ModalProps) { } export function SettingsBlocked() { - const { channels, videos, removeChannel, removeVideo } = useBlocked(); + const { channels, videos, keywords, addKeyword, removeChannel, removeKeyword, removeVideo } = + useBlocked(); const [selected, setSelected] = useState(null); + const [keyword, setKeyword] = useState(""); const channelList = channels.data ?? []; const videoList = videos.data ?? []; + const keywordList = keywords.data ?? []; - if (channelList.length === 0 && videoList.length === 0) return null; + function submitKeyword(event: FormEvent) { + event.preventDefault(); + const value = keyword.trim(); + if (!value) return; + addKeyword.mutate(value, { onSuccess: () => setKeyword("") }); + } return ( <> +
+

Blocked keywords

+
+
+ setKeyword(event.target.value)} + placeholder="Keyword" + aria-label="Keyword to block" + className="h-9 min-w-0 flex-1 rounded-md border border-border-strong bg-app px-3 text-sm text-fg outline-none placeholder:text-fg-soft focus:border-fg-soft" + /> + +
+ {keywordList.length === 0 ? ( +

No blocked keywords

+ ) : ( +
+ {keywordList.map((item) => ( +
+ + {item.keyword} + + +
+ ))} +
+ )} +
+
{channelList.length > 0 && (

Blocked channels

@@ -145,7 +184,7 @@ export function SettingsBlocked() { aria-label={`Unblock ${item.url}`} className="flex-shrink-0 text-fg-soft hover:text-fg transition-colors p-1 rounded hover:bg-surface-strong" > - +
))} diff --git a/apps/web/src/settings/settings-nav.tsx b/apps/web/src/settings/settings-nav.tsx index eed8b609..21195832 100644 --- a/apps/web/src/settings/settings-nav.tsx +++ b/apps/web/src/settings/settings-nav.tsx @@ -14,7 +14,7 @@ type Props = { export function SettingsNav({ items, active, onSelect }: Props) { return (