diff --git a/apps/web/src/components/admin-settings-panel.tsx b/apps/web/src/components/admin-settings-panel.tsx index cb8ad43..62c71d7 100644 --- a/apps/web/src/components/admin-settings-panel.tsx +++ b/apps/web/src/components/admin-settings-panel.tsx @@ -29,12 +29,6 @@ export function AdminSettingsPanel({ settings, pending, onToggle }: Props) { pending={pending} onClick={() => onToggle("allowGuest")} /> - onToggle("forceEmailVerification")} - /> page.podcasts) ?? []; + + if (!isYoutube || query.isError || (query.isFetched && podcasts.length === 0)) return null; + + return ( +
+
+
+

Podcasts

+

Podcast playlists from this channel

+
+ {query.hasNextPage && ( + + )} +
+ {query.isLoading ? ( +
+ {SKELETON_KEYS.map((key) => ( +
+ ))} +
+ ) : ( +
+ {podcasts.map((podcast) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/download-sheet.tsx b/apps/web/src/components/download-sheet.tsx index 45697c6..b707725 100644 --- a/apps/web/src/components/download-sheet.tsx +++ b/apps/web/src/components/download-sheet.tsx @@ -3,6 +3,7 @@ import { useArtifactDownloadOnDone } from "../hooks/use-artifact-download-on-don import { useDownloaderJob } from "../hooks/use-downloader-job"; import { useOverlayLock } from "../hooks/use-overlay-lock"; import { useSmoothDismiss } from "../hooks/use-smooth-dismiss"; +import { deleteDownloaderJob } from "../lib/api-downloader"; import type { VideoStream } from "../types/stream"; import { buildDownloaderCreatePayload, @@ -28,13 +29,17 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) { isQueued, isRunning, errorText, + isFailed, openArtifact, reset, start, canUseIosShareFlow, + cancelJob, + isCancelling, } = downloader; const isBusy = isQueued || isRunning; const [artifactError, setArtifactError] = useState(null); + const [clearPending, setClearPending] = useState(false); const options = useMemo(() => buildDownloadOptions(stream), [stream]); const [mode, setMode] = useState("video"); const [selectedId, setSelectedId] = useState( @@ -69,6 +74,19 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) { start(buildDownloaderCreatePayload(stream.id, selected)); } + async function clearJob() { + if (!jobId) return reset(); + setClearPending(true); + try { + await deleteDownloaderJob(jobId); + reset(); + setArtifactError(null); + } catch (error) { + setArtifactError(error instanceof Error ? error.message : "Failed to clear download job"); + } + setClearPending(false); + } + return (
diff --git a/apps/web/src/components/downloader-job-feedback.tsx b/apps/web/src/components/downloader-job-feedback.tsx index c2825b1..daab663 100644 --- a/apps/web/src/components/downloader-job-feedback.tsx +++ b/apps/web/src/components/downloader-job-feedback.tsx @@ -23,6 +23,12 @@ type Props = { errorText: string | null; immersive?: boolean; forceWaiting?: boolean; + canCancel?: boolean; + cancelPending?: boolean; + onCancel?: () => void; + canClear?: boolean; + clearPending?: boolean; + onClear?: () => void; }; function formatResolved(resolved: DownloaderResolvedSelection | null): string | null { @@ -57,6 +63,12 @@ export function DownloaderJobFeedback({ errorText, immersive = false, forceWaiting = false, + canCancel = false, + cancelPending = false, + onCancel, + canClear = false, + clearPending = false, + onClear, }: Props) { const resolvedLabel = formatResolved(resolved); const visibleError = @@ -69,6 +81,8 @@ export function DownloaderJobFeedback({ const message = downloaderStatusMessage(status, stage, errorCode, visibleError, forceWaiting); const progress = downloaderProgressValue(status, stage, progressPercent, forceWaiting); const activeStep = downloaderStageIndex(status, stage); + const showCancel = canCancel && !cancelled && !failed && typeof onCancel === "function"; + const showClear = canClear && typeof onClear === "function"; const showProgress = shouldShowDownloaderProgress(status, forceWaiting) && !failed && !cancelled; const showPercent = typeof progressPercent === "number" && status === "running"; @@ -87,9 +101,31 @@ export function DownloaderJobFeedback({ {message}

- {showPercent && ( - {Math.round(progress)}% - )} +
+ {showPercent && ( + {Math.round(progress)}% + )} + {showCancel && ( + + )} + {showClear && ( + + )} +
{showProgress && (
diff --git a/apps/web/src/components/library-collection-card.tsx b/apps/web/src/components/library-collection-card.tsx new file mode 100644 index 0000000..723e2d1 --- /dev/null +++ b/apps/web/src/components/library-collection-card.tsx @@ -0,0 +1,67 @@ +import { Link } from "@tanstack/react-router"; + +type Props = { + kind: "favorites" | "watch-later"; + title: string; + count: number; + thumbnail?: string; +}; + +function EmptyLibraryIcon() { + return ( + + + + ); +} + +export function LibraryCollectionCard({ kind, title, count, thumbnail }: Props) { + const label = `${count} video${count !== 1 ? "s" : ""}`; + const body = ( +
+
+ {thumbnail ? ( + {title} + ) : ( +
+ +
+ )} + + {label} + +
+
+

+ {title} +

+

{label}

+
+
+ ); + + return kind === "favorites" ? ( + {body} + ) : ( + {body} + ); +} diff --git a/apps/web/src/components/navbar-search.tsx b/apps/web/src/components/navbar-search.tsx new file mode 100644 index 0000000..a65a292 --- /dev/null +++ b/apps/web/src/components/navbar-search.tsx @@ -0,0 +1,150 @@ +import { useRouterState } from "@tanstack/react-router"; +import { Search } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useDebouncedValue } from "../hooks/use-debounced-value"; +import { useSearchHistory } from "../hooks/use-search-history"; +import { useSearchOverlayNavigation } from "../hooks/use-search-overlay-navigation"; +import { fetchSuggestions } from "../lib/api"; +import { buildSearchOverlayItems } from "../lib/search-overlay-items"; +import { SearchOverlayList } from "./search-overlay-list"; + +export function NavbarSearch() { + const location = useRouterState({ select: (state) => state.location }); + const currentSearch = + location.pathname === "/search" ? (new URLSearchParams(location.searchStr).get("q") ?? "") : ""; + const [query, setQuery] = useState(currentSearch); + const [suggestions, setSuggestions] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const listRef = useRef(null); + const { service, navigateAndClose } = useSearchOverlayNavigation({ + onClose: () => setOpen(false), + }); + const { visibleItems, canLoadMore, loadMore } = useSearchHistory(); + const debouncedQuery = useDebouncedValue(query, 300); + const items = buildSearchOverlayItems(query, visibleItems, suggestions); + const showHistory = query.trim().length === 0 && visibleItems.length > 0; + + useEffect(() => { + setQuery(currentSearch); + }, [currentSearch]); + + useEffect(() => { + function onMouseDown(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, []); + + useEffect(() => { + if (!debouncedQuery.trim()) { + setSuggestions([]); + return; + } + let cancelled = false; + fetchSuggestions(debouncedQuery.trim(), service) + .then((next) => { + if (!cancelled) setSuggestions(next); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [debouncedQuery, service]); + + useEffect(() => { + if (selectedIndex < 0) return; + const element = listRef.current?.querySelector( + `button[data-item-index="${selectedIndex}"]`, + ); + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [selectedIndex]); + + function submit(e: React.FormEvent) { + e.preventDefault(); + const selected = selectedIndex >= 0 ? items[selectedIndex]?.label : undefined; + navigateAndClose(selected ?? query); + } + + function selectTerm(term: string) { + setQuery(term); + navigateAndClose(term); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + return; + } + if (items.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setOpen(true); + setSelectedIndex((index) => (index >= items.length - 1 ? 0 : index + 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setOpen(true); + setSelectedIndex((index) => (index <= 0 ? items.length - 1 : index - 1)); + return; + } + if (e.key === "Tab") { + const selected = selectedIndex >= 0 ? items[selectedIndex] : items[0]; + if (!selected) return; + e.preventDefault(); + setQuery(selected.label); + setSelectedIndex(-1); + } + } + + function handleScroll(e: React.UIEvent) { + if (!showHistory || !canLoadMore) return; + const target = e.currentTarget; + const threshold = target.scrollHeight - target.clientHeight - 24; + if (target.scrollTop >= threshold) loadMore(); + } + + return ( + +
+
+ { + setQuery(e.target.value); + setSelectedIndex(-1); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + placeholder="Search" + className="min-w-0 flex-1 rounded-l-full border border-border-strong bg-app px-4 text-sm text-fg placeholder:text-fg-soft outline-none focus:border-fg" + /> + +
+ {open && items.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/navbar.tsx b/apps/web/src/components/navbar.tsx index e7c3700..b5bcfd4 100644 --- a/apps/web/src/components/navbar.tsx +++ b/apps/web/src/components/navbar.tsx @@ -10,6 +10,7 @@ import { useUiStore } from "../stores/ui-store"; import { NavbarAccountControls } from "./navbar-account-controls"; import { NavbarLeadingControl } from "./navbar-leading-control"; import { NavbarNotifications } from "./navbar-notifications"; +import { NavbarSearch } from "./navbar-search"; import { Toast } from "./toast"; const SearchOverlay = lazy(() => @@ -41,7 +42,7 @@ export function Navbar() { window.location.assign("/"); } - useSearchShortcut({ enabled: canOpenSearch, onOpen: () => setSearchOpen(true) }); + useSearchShortcut({ enabled: canOpenSearch && isMobile, onOpen: () => setSearchOpen(true) }); return ( <> @@ -72,24 +73,7 @@ export function Navbar() { )} - {canOpenSearch && !isMobile && ( -
- -
- )} + {canOpenSearch && !isMobile && }
diff --git a/apps/web/src/components/podcast-card.tsx b/apps/web/src/components/podcast-card.tsx new file mode 100644 index 0000000..a8cdc35 --- /dev/null +++ b/apps/web/src/components/podcast-card.tsx @@ -0,0 +1,47 @@ +import { Link } from "@tanstack/react-router"; +import { proxyImage } from "../lib/proxy"; +import type { PodcastItem } from "../types/api"; + +type Props = { + podcast: PodcastItem; + channelAvatar?: string; +}; + +export function PodcastCard({ podcast, channelAvatar }: Props) { + const thumbnail = proxyImage(podcast.thumbnailUrl); + const count = podcast.streamCount === 1 ? "1 episode" : `${podcast.streamCount} episodes`; + + return ( + +
+ {podcast.title} + {channelAvatar && ( + + )} +
+
+

+ {podcast.title} +

+

{podcast.uploaderName}

+

{count}

+
+ + ); +} diff --git a/apps/web/src/components/search-overlay-list.tsx b/apps/web/src/components/search-overlay-list.tsx index 7650a59..39e9b5e 100644 --- a/apps/web/src/components/search-overlay-list.tsx +++ b/apps/web/src/components/search-overlay-list.tsx @@ -7,8 +7,9 @@ type Props = { selectedIndex: number; listRef: RefObject; onScroll: (e: React.UIEvent) => void; - onClearAll: () => void; + onClearAll?: () => void; onSelect: (term: string) => void; + className?: string; }; export function SearchOverlayList({ @@ -19,25 +20,28 @@ export function SearchOverlayList({ onScroll, onClearAll, onSelect, + className, }: Props) { if (items.length === 0) return null; + const listClass = + className ?? + "mt-1 max-h-[22rem] overflow-y-auto scroll-smooth bg-surface border border-border-strong rounded-lg"; + return ( -
    +
      {showHistory && (
    • Recent searches - + {onClearAll && ( + + )}
    • )} {items.map((item, index) => ( diff --git a/apps/web/src/components/search-overlay.tsx b/apps/web/src/components/search-overlay.tsx index 938d59a..25e0835 100644 --- a/apps/web/src/components/search-overlay.tsx +++ b/apps/web/src/components/search-overlay.tsx @@ -1,4 +1,5 @@ import { useRouterState } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useDebouncedValue } from "../hooks/use-debounced-value"; import { useSearchHistory } from "../hooks/use-search-history"; @@ -94,6 +95,12 @@ export function SearchOverlay({ onClose }: Props) { } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((index) => (index <= 0 ? items.length - 1 : index - 1)); + } else if (e.key === "Tab") { + const selected = selectedIndex >= 0 ? items[selectedIndex] : items[0]; + if (!selected) return; + e.preventDefault(); + setQuery(selected.label); + setSelectedIndex(-1); } } @@ -112,20 +119,20 @@ export function SearchOverlay({ onClose }: Props) { } return ( -
      - - setConfirmClearOpen(true)} - onSelect={submitTerm} - /> +
      + setConfirmClearOpen(true)} + onSelect={submitTerm} + className="max-h-full overflow-y-auto scroll-smooth rounded-xl border border-border bg-surface" + /> +
      {confirmClearOpen && ( { - setToast("Original audio unavailable, switched to English"); + setToast("Original audio unavailable"); }} originalAudioTrackId={originalAudioTrackId} preferredDefaultAudioTrackId={preferredDefaultAudioTrackId} diff --git a/apps/web/src/components/watch-comment-replies.tsx b/apps/web/src/components/watch-comment-replies.tsx index 82c9b81..c8b3403 100644 --- a/apps/web/src/components/watch-comment-replies.tsx +++ b/apps/web/src/components/watch-comment-replies.tsx @@ -9,9 +9,10 @@ type Props = { videoUrl: string; repliesPage: string; locale?: string; + onSeekTimestamp?: (seconds: number) => void; }; -export function WatchCommentReplies({ videoUrl, repliesPage, locale }: Props) { +export function WatchCommentReplies({ videoUrl, repliesPage, locale, onSeekTimestamp }: Props) { const { data, isFetchingNextPage, hasNextPage, fetchNextPage, isLoading } = useCommentReplies( videoUrl, repliesPage, @@ -31,7 +32,7 @@ export function WatchCommentReplies({ videoUrl, repliesPage, locale }: Props) { className="animate-card-pop-in" style={{ animationDelay: `${Math.min(i * 25, 150)}ms` }} > - +
))} {(isLoading || isFetchingNextPage) && diff --git a/apps/web/src/components/watch-comment-row.tsx b/apps/web/src/components/watch-comment-row.tsx index 539fccf..3125a80 100644 --- a/apps/web/src/components/watch-comment-row.tsx +++ b/apps/web/src/components/watch-comment-row.tsx @@ -5,9 +5,10 @@ type Props = { comment: Comment; videoUrl: string; index: number; + onSeekTimestamp?: (seconds: number) => void; }; -export function WatchCommentRow({ comment, videoUrl, index }: Props) { +export function WatchCommentRow({ comment, videoUrl, index, onSeekTimestamp }: Props) { return (
- +
); } diff --git a/apps/web/src/components/watch-comment.tsx b/apps/web/src/components/watch-comment.tsx index 96badb0..77d8c5c 100644 --- a/apps/web/src/components/watch-comment.tsx +++ b/apps/web/src/components/watch-comment.tsx @@ -9,9 +9,10 @@ import { WatchCommentReplies } from "./watch-comment-replies"; type Props = { comment: Comment; videoUrl: string; + onSeekTimestamp?: (seconds: number) => void; }; -export function WatchComment({ comment, videoUrl }: Props) { +export function WatchComment({ comment, videoUrl, onSeekTimestamp }: Props) { const locale = useClientLocale(); const [showReplies, setShowReplies] = useState(false); const [expanded, setExpanded] = useState(false); @@ -66,7 +67,7 @@ export function WatchComment({ comment, videoUrl }: Props) { ref={textRef} className={`text-sm text-fg leading-relaxed whitespace-pre-wrap${expanded ? "" : " line-clamp-5"}`} > - +

{(overflows || expanded) && (
diff --git a/apps/web/src/components/watch-comments-lazy-list.tsx b/apps/web/src/components/watch-comments-lazy-list.tsx index b302077..719d94b 100644 --- a/apps/web/src/components/watch-comments-lazy-list.tsx +++ b/apps/web/src/components/watch-comments-lazy-list.tsx @@ -11,12 +11,17 @@ const FALLBACK_KEYS = Array.from({ length: 3 }, (_, i) => `wcl-${i}`); type Props = { comments: Comment[]; videoUrl: string; + onSeekTimestamp?: (seconds: number) => void; }; -export function WatchCommentsLazyList({ comments, videoUrl }: Props) { +export function WatchCommentsLazyList({ comments, videoUrl, onSeekTimestamp }: Props) { return ( )}> - + ); } diff --git a/apps/web/src/components/watch-comments-list.tsx b/apps/web/src/components/watch-comments-list.tsx index af7b804..31e4e57 100644 --- a/apps/web/src/components/watch-comments-list.tsx +++ b/apps/web/src/components/watch-comments-list.tsx @@ -4,10 +4,17 @@ import { WatchCommentRow } from "./watch-comment-row"; type Props = { comments: Comment[]; videoUrl: string; + onSeekTimestamp?: (seconds: number) => void; }; -export function WatchCommentsList({ comments, videoUrl }: Props) { +export function WatchCommentsList({ comments, videoUrl, onSeekTimestamp }: Props) { return comments.map((comment, i) => ( - + )); } diff --git a/apps/web/src/components/watch-comments.tsx b/apps/web/src/components/watch-comments.tsx index 1f2ce9f..88e7182 100644 --- a/apps/web/src/components/watch-comments.tsx +++ b/apps/web/src/components/watch-comments.tsx @@ -9,9 +9,10 @@ const RENDER_STEP = 4; type Props = { videoUrl: string; + onSeekTimestamp?: (seconds: number) => void; }; -export function WatchComments({ videoUrl }: Props) { +export function WatchComments({ videoUrl, onSeekTimestamp }: Props) { const { data, isFetchingNextPage, hasNextPage, fetchNextPage, isLoading } = useInfiniteComments(videoUrl); const [renderCount, setRenderCount] = useState(INITIAL_RENDER_COUNT); @@ -42,7 +43,11 @@ export function WatchComments({ videoUrl }: Props) {

Comments are disabled for this video.

) : (
- + {showSkeletons && SKELETON_KEYS.map((k) => )} {canLoadMore && !isLoading && ( + +
); } diff --git a/apps/web/src/components/watch-layout.tsx b/apps/web/src/components/watch-layout.tsx index 9c6f914..0766144 100644 --- a/apps/web/src/components/watch-layout.tsx +++ b/apps/web/src/components/watch-layout.tsx @@ -80,11 +80,11 @@ export function WatchLayout({ stream, startTime }: Props) { { - setToast("Original audio unavailable, switched to English"); + setToast("Original audio unavailable"); }} originalAudioTrackId={originalTrackId} preferredDefaultAudioTrackId={preferredAudioTrackId} @@ -102,7 +102,7 @@ export function WatchLayout({ stream, startTime }: Props) { ? "overflow-hidden bg-black" : "min-w-0 flex-[2] max-w-[133.333vh] flex flex-col gap-4"; const playerBoxClass = cinemaMode - ? "mx-auto h-[min(calc(100vw*9/16),82svh)] w-[min(100vw,calc(82svh*16/9))]" + ? "mx-auto aspect-video w-[min(100%,calc((100svh-4.5rem)*16/9))]" : "overflow-hidden rounded-lg"; return ( diff --git a/apps/web/src/components/watch-meta.tsx b/apps/web/src/components/watch-meta.tsx index a439c62..5011013 100644 --- a/apps/web/src/components/watch-meta.tsx +++ b/apps/web/src/components/watch-meta.tsx @@ -18,7 +18,9 @@ export function WatchMeta({ stream, showComments = true, onSeekTimestamp }: Prop {stream.description && ( )} - {showComments && } + {showComments && ( + + )} ); } diff --git a/apps/web/src/components/watch-reply.tsx b/apps/web/src/components/watch-reply.tsx index b871696..6ab4190 100644 --- a/apps/web/src/components/watch-reply.tsx +++ b/apps/web/src/components/watch-reply.tsx @@ -6,9 +6,10 @@ import { RichText } from "./rich-text"; type Props = { reply: Comment; locale?: string; + onSeekTimestamp?: (seconds: number) => void; }; -export function WatchReply({ reply, locale }: Props) { +export function WatchReply({ reply, locale, onSeekTimestamp }: Props) { const publishedTime = formatCommentPublishedTime(reply.publishedAt, reply.publishedTime, locale); return ( @@ -26,7 +27,7 @@ export function WatchReply({ reply, locale }: Props) { {publishedTime && {publishedTime}}

- +

{reply.likeCount >= 0 && ( {formatLikes(reply.likeCount)} likes diff --git a/apps/web/src/hooks/use-channel.ts b/apps/web/src/hooks/use-channel.ts index e376584..4cdb958 100644 --- a/apps/web/src/hooks/use-channel.ts +++ b/apps/web/src/hooks/use-channel.ts @@ -1,4 +1,5 @@ import { useInfiniteQuery } from "@tanstack/react-query"; +import type { ChannelSort } from "../lib/api"; import { fetchChannel } from "../lib/api"; import { mapVideoItem } from "../lib/mappers"; import { proxyImage } from "../lib/proxy"; @@ -19,11 +20,11 @@ type ChannelPage = { nextpage: string | null; }; -export function useChannel(channelUrl: string) { +export function useChannel(channelUrl: string, sort?: ChannelSort) { const query = useInfiniteQuery({ - queryKey: ["channel", channelUrl], + queryKey: ["channel", channelUrl, sort], queryFn: async ({ pageParam }): Promise => { - const res = await fetchChannel(channelUrl, pageParam as string | undefined); + const res = await fetchChannel(channelUrl, pageParam as string | undefined, sort); const isFirstPage = pageParam === undefined; return { meta: isFirstPage @@ -43,6 +44,7 @@ export function useChannel(channelUrl: string) { initialPageParam: undefined as string | undefined, getNextPageParam: (last: ChannelPage | undefined) => last?.nextpage ?? undefined, enabled: channelUrl.length > 0, + placeholderData: (previousData) => previousData, }); const pages = query.data?.pages ?? []; diff --git a/apps/web/src/hooks/use-downloader-job.ts b/apps/web/src/hooks/use-downloader-job.ts index 6fae2bf..04316c3 100644 --- a/apps/web/src/hooks/use-downloader-job.ts +++ b/apps/web/src/hooks/use-downloader-job.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; import { + cancelDownloaderJob, canUseIosShareFlow, createDownloaderJob, downloadDownloaderArtifact, @@ -24,6 +25,11 @@ export function useDownloaderJob() { mutationFn: (payload: DownloaderCreateJobRequest) => createDownloaderJob(payload), }); const jobId = create.data?.id; + const cancel = useMutation({ + mutationFn: (id: string) => cancelDownloaderJob(id), + onSuccess: (next) => + setEventJob((current) => (current?.id === next.id ? { ...current, ...next } : next)), + }); useEffect(() => { if (!jobId) return; @@ -52,29 +58,30 @@ export function useDownloaderJob() { }, }); const job = useMemo(() => { - if (!eventJob) return query.data; - if (!query.data || query.data.id !== eventJob.id) return eventJob; - if (query.data.status === "done" || query.data.status === "failed") { + const queryJob = query.data ?? create.data; + if (!eventJob) return queryJob; + if (!queryJob || queryJob.id !== eventJob.id) return eventJob; + if (queryJob.status === "done" || queryJob.status === "failed") { return { ...eventJob, - ...query.data, - resolved: query.data.resolved ?? eventJob.resolved, - error: query.data.error ?? eventJob.error, - errorCode: query.data.errorCode ?? eventJob.errorCode, - tokenFetchMs: query.data.tokenFetchMs ?? eventJob.tokenFetchMs, - ytdlpMs: query.data.ytdlpMs ?? eventJob.ytdlpMs, - uploadMs: query.data.uploadMs ?? eventJob.uploadMs, - totalMs: query.data.totalMs ?? eventJob.totalMs, + ...queryJob, + resolved: queryJob.resolved ?? eventJob.resolved, + error: queryJob.error ?? eventJob.error, + errorCode: queryJob.errorCode ?? eventJob.errorCode, + tokenFetchMs: queryJob.tokenFetchMs ?? eventJob.tokenFetchMs, + ytdlpMs: queryJob.ytdlpMs ?? eventJob.ytdlpMs, + uploadMs: queryJob.uploadMs ?? eventJob.uploadMs, + totalMs: queryJob.totalMs ?? eventJob.totalMs, }; } return { - ...query.data, + ...queryJob, ...eventJob, - resolved: eventJob.resolved ?? query.data.resolved, - error: eventJob.error ?? query.data.error, - errorCode: eventJob.errorCode ?? query.data.errorCode, + resolved: eventJob.resolved ?? queryJob.resolved, + error: eventJob.error ?? queryJob.error, + errorCode: eventJob.errorCode ?? queryJob.errorCode, }; - }, [eventJob, query.data]); + }, [create.data, eventJob, query.data]); const status: DownloaderJobStatus | null = create.isPending ? "queued" : (job?.status ?? null); const isQueued = status === "queued"; @@ -92,7 +99,9 @@ export function useDownloaderJob() { const errorText = create.error instanceof Error ? create.error.message - : job?.error || (query.error instanceof Error ? query.error.message : null); + : cancel.error instanceof Error + ? cancel.error.message + : job?.error || (query.error instanceof Error ? query.error.message : null); function start(payload: DownloaderCreateJobRequest) { setEventJob(null); @@ -106,15 +115,22 @@ export function useDownloaderJob() { return downloadDownloaderArtifact(jobId, options); } + function cancelJob() { + if (!jobId || (status !== "queued" && status !== "running")) return; + cancel.mutate(jobId); + } + function reset() { setEventJob(null); setSseUnavailable(false); + cancel.reset(); create.reset(); } return { start, openArtifact, + cancelJob, reset, jobId, status, @@ -127,6 +143,7 @@ export function useDownloaderJob() { totalMs, errorCode, canUseIosShareFlow: canUseIosShareFlow(), + isCancelling: cancel.isPending, isQueued, isRunning, isDone, diff --git a/apps/web/src/hooks/use-favorite-streams.ts b/apps/web/src/hooks/use-favorite-streams.ts new file mode 100644 index 0000000..4989a31 --- /dev/null +++ b/apps/web/src/hooks/use-favorite-streams.ts @@ -0,0 +1,33 @@ +import { useQueries, useQuery } from "@tanstack/react-query"; +import { fetchStream } from "../lib/api"; +import { fetchFavorites } from "../lib/api-collections"; +import { mapStreamResponse } from "../lib/mappers"; +import type { VideoStream } from "../types/stream"; +import { useAuth } from "./use-auth"; + +function isVideoStream(value: VideoStream | undefined): value is VideoStream { + return value !== undefined; +} + +export function useFavoriteStreams() { + const { authReady, isAuthed } = useAuth(); + const favorites = useQuery({ + queryKey: ["favorites"], + queryFn: fetchFavorites, + enabled: authReady && isAuthed, + }); + const streams = useQueries({ + queries: (favorites.data ?? []).map((item) => ({ + queryKey: ["favorite-stream", item.videoUrl], + queryFn: () => + fetchStream(item.videoUrl).then((res) => mapStreamResponse(res, item.videoUrl)), + enabled: favorites.isSuccess, + })), + }); + + return { + count: favorites.data?.length ?? 0, + videos: streams.map((query) => query.data).filter(isVideoStream), + isLoading: favorites.isLoading || streams.some((query) => query.isLoading), + }; +} diff --git a/apps/web/src/hooks/use-favorites-playlist.ts b/apps/web/src/hooks/use-favorites-playlist.ts index e13b441..201b81d 100644 --- a/apps/web/src/hooks/use-favorites-playlist.ts +++ b/apps/web/src/hooks/use-favorites-playlist.ts @@ -1,11 +1,9 @@ -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useRef, useState } from "react"; -import { createPlaylist } from "../lib/api-playlists"; -import type { PlaylistItem } from "../types/user"; -import { usePlaylists } from "./use-playlists"; +import { addFavorite, fetchFavorites, removeFavorite } from "../lib/api-collections"; +import { useAuth } from "./use-auth"; -const KEY = ["playlists"]; -const FAVORITES_NAME = "Favorites"; +const KEY = ["favorites"]; type AddPayload = { url: string; @@ -17,17 +15,18 @@ type AddPayload = { type Intent = { url: string; adding: boolean }; export function useFavoritesPlaylist() { - const { query, addVideo, removeVideo } = usePlaylists(); - const qc = useQueryClient(); + const { authReady, isAuthed } = useAuth(); const intentRef = useRef(null); const [intent, setIntent] = useState(null); - - const playlists = query.data ?? []; - const favoritesPlaylist = playlists.find((p) => p.name === FAVORITES_NAME); + const query = useQuery({ + queryKey: KEY, + queryFn: fetchFavorites, + enabled: authReady && isAuthed, + }); function isInFavorites(videoUrl: string): boolean { if (intentRef.current?.url === videoUrl) return intentRef.current.adding; - return favoritesPlaylist?.videos.some((v) => v.url === videoUrl) ?? false; + return query.data?.some((item) => item.videoUrl === videoUrl) ?? false; } function applyIntent(value: Intent | null) { @@ -35,19 +34,12 @@ export function useFavoritesPlaylist() { setIntent(value); } - async function ensurePlaylist(): Promise { - if (favoritesPlaylist) return favoritesPlaylist.id; - const created = await createPlaylist(FAVORITES_NAME); - qc.setQueryData(KEY, (old) => [...(old ?? []), created]); - return created.id; - } - async function add(payload: AddPayload): Promise { if (isInFavorites(payload.url)) return; applyIntent({ url: payload.url, adding: true }); try { - const playlistId = await ensurePlaylist(); - await addVideo.mutateAsync({ playlistId, video: payload }); + await addFavorite(payload.url); + await query.refetch(); } catch (e) { applyIntent(null); throw e; @@ -56,10 +48,10 @@ export function useFavoritesPlaylist() { } async function remove(videoUrl: string): Promise { - if (!favoritesPlaylist) return; applyIntent({ url: videoUrl, adding: false }); try { - await removeVideo.mutateAsync({ playlistId: favoritesPlaylist.id, videoUrl }); + await removeFavorite(videoUrl); + await query.refetch(); } catch (e) { applyIntent(null); throw e; diff --git a/apps/web/src/hooks/use-podcast-episodes.ts b/apps/web/src/hooks/use-podcast-episodes.ts new file mode 100644 index 0000000..5b026ad --- /dev/null +++ b/apps/web/src/hooks/use-podcast-episodes.ts @@ -0,0 +1,28 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { fetchPodcastEpisodes } from "../lib/api"; +import { mapVideoItem } from "../lib/mappers"; +import type { PodcastItem } from "../types/api"; +import type { VideoStream } from "../types/stream"; + +type PodcastEpisodesPage = { + podcast: PodcastItem; + episodes: VideoStream[]; + nextpage: string | null; +}; + +export function usePodcastEpisodes(podcastUrl: string) { + return useInfiniteQuery({ + queryKey: ["podcast-episodes", podcastUrl], + enabled: podcastUrl.trim().length > 0, + queryFn: async ({ pageParam }): Promise => { + const res = await fetchPodcastEpisodes(podcastUrl, pageParam as string | undefined); + return { + podcast: res.podcast, + episodes: res.episodes.map(mapVideoItem), + nextpage: res.nextpage, + }; + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined, + }); +} diff --git a/apps/web/src/hooks/use-podcasts.ts b/apps/web/src/hooks/use-podcasts.ts new file mode 100644 index 0000000..a9fbff5 --- /dev/null +++ b/apps/web/src/hooks/use-podcasts.ts @@ -0,0 +1,32 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { fetchPodcasts } from "../lib/api"; +import { mapVideoItem } from "../lib/mappers"; +import type { PodcastItem } from "../types/api"; +import type { VideoStream } from "../types/stream"; + +type PodcastPage = { + channelName: string; + channelUrl: string; + podcasts: PodcastItem[]; + episodes: VideoStream[]; + nextpage: string | null; +}; + +export function usePodcasts(channelUrl: string, enabled = true) { + return useInfiniteQuery({ + queryKey: ["podcasts", channelUrl], + enabled: enabled && channelUrl.trim().length > 0, + queryFn: async ({ pageParam }): Promise => { + const res = await fetchPodcasts(channelUrl, pageParam as string | undefined); + return { + channelName: res.channelName, + channelUrl: res.channelUrl, + podcasts: res.podcasts, + episodes: res.episodes.map(mapVideoItem), + nextpage: res.nextpage, + }; + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextpage ?? undefined, + }); +} diff --git a/apps/web/src/hooks/use-settings.ts b/apps/web/src/hooks/use-settings.ts index dfefaa1..03a4ad5 100644 --- a/apps/web/src/hooks/use-settings.ts +++ b/apps/web/src/hooks/use-settings.ts @@ -13,7 +13,7 @@ const DEFAULTS: SettingsItem = { muted: false, subtitlesEnabled: false, defaultSubtitleLanguage: "", - defaultAudioLanguage: "en", + defaultAudioLanguage: "", preferOriginalLanguage: true, }; diff --git a/apps/web/src/hooks/use-watch-later-playlist.ts b/apps/web/src/hooks/use-watch-later-playlist.ts index 2156299..4898ebf 100644 --- a/apps/web/src/hooks/use-watch-later-playlist.ts +++ b/apps/web/src/hooks/use-watch-later-playlist.ts @@ -1,11 +1,9 @@ -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useRef, useState } from "react"; -import { createPlaylist } from "../lib/api-playlists"; -import type { PlaylistItem } from "../types/user"; -import { usePlaylists } from "./use-playlists"; +import { addWatchLater, fetchWatchLater, removeWatchLater } from "../lib/api-collections"; +import { useAuth } from "./use-auth"; -const KEY = ["playlists"]; -const WATCH_LATER_NAME = "Watch Later"; +const KEY = ["watch-later"]; type AddPayload = { url: string; @@ -17,17 +15,18 @@ type AddPayload = { type Intent = { url: string; adding: boolean }; export function useWatchLaterPlaylist() { - const { query, addVideo, removeVideo } = usePlaylists(); - const qc = useQueryClient(); + const { authReady, isAuthed } = useAuth(); const intentRef = useRef(null); const [intent, setIntent] = useState(null); - - const playlists = query.data ?? []; - const watchLaterPlaylist = playlists.find((p) => p.name === WATCH_LATER_NAME); + const query = useQuery({ + queryKey: KEY, + queryFn: fetchWatchLater, + enabled: authReady && isAuthed, + }); function isInWatchLater(videoUrl: string): boolean { if (intentRef.current?.url === videoUrl) return intentRef.current.adding; - return watchLaterPlaylist?.videos.some((v) => v.url === videoUrl) ?? false; + return query.data?.some((item) => item.url === videoUrl) ?? false; } function applyIntent(value: Intent | null) { @@ -35,19 +34,12 @@ export function useWatchLaterPlaylist() { setIntent(value); } - async function ensurePlaylist(): Promise { - if (watchLaterPlaylist) return watchLaterPlaylist.id; - const created = await createPlaylist(WATCH_LATER_NAME); - qc.setQueryData(KEY, (old) => [...(old ?? []), created]); - return created.id; - } - async function add(payload: AddPayload): Promise { if (isInWatchLater(payload.url)) return; applyIntent({ url: payload.url, adding: true }); try { - const playlistId = await ensurePlaylist(); - await addVideo.mutateAsync({ playlistId, video: payload }); + await addWatchLater(payload); + await query.refetch(); } catch (e) { applyIntent(null); throw e; @@ -56,10 +48,10 @@ export function useWatchLaterPlaylist() { } async function remove(videoUrl: string): Promise { - if (!watchLaterPlaylist) return; applyIntent({ url: videoUrl, adding: false }); try { - await removeVideo.mutateAsync({ playlistId: watchLaterPlaylist.id, videoUrl }); + await removeWatchLater(videoUrl); + await query.refetch(); } catch (e) { applyIntent(null); throw e; diff --git a/apps/web/src/hooks/use-watch-later-streams.ts b/apps/web/src/hooks/use-watch-later-streams.ts new file mode 100644 index 0000000..88908e3 --- /dev/null +++ b/apps/web/src/hooks/use-watch-later-streams.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchWatchLater } from "../lib/api-collections"; +import { mapWatchLaterItem } from "../lib/watch-later-mappers"; +import { useAuth } from "./use-auth"; + +export function useWatchLaterStreams() { + const { authReady, isAuthed } = useAuth(); + const query = useQuery({ + queryKey: ["watch-later"], + queryFn: fetchWatchLater, + enabled: authReady && isAuthed, + }); + const items = query.data ?? []; + + return { + count: items.length, + videos: items.map(mapWatchLaterItem), + isLoading: query.isLoading, + }; +} diff --git a/apps/web/src/lib/api-collections.ts b/apps/web/src/lib/api-collections.ts index 5c9b7fa..76070ba 100644 --- a/apps/web/src/lib/api-collections.ts +++ b/apps/web/src/lib/api-collections.ts @@ -1,4 +1,4 @@ -import type { BlockedItem, ProgressItem } from "../types/user"; +import type { BlockedItem, FavoriteItem, ProgressItem, WatchLaterItem } from "../types/user"; import { ApiError } from "./api"; import { authed, authedJson } from "./authed"; @@ -11,7 +11,9 @@ async function throwIfFailed(res: Response, fallback: string): Promise { } export async function fetchProgress(videoUrl: string): Promise { - const res = await authed(`${BASE}/progress/${encodeURIComponent(videoUrl)}`); + const res = await authed(`${BASE}/progress/${encodeURIComponent(videoUrl)}`, undefined, { + silentStatuses: [404], + }); if (res.status === 404) return { videoUrl, position: 0, updatedAt: 0 }; const body = await res.json(); if (!res.ok) throw new ApiError((body as { error: string }).error, res.status); @@ -72,3 +74,37 @@ export async function unblockVideo(url: string): Promise { }); await throwIfFailed(res, "unblock failed"); } + +export function fetchFavorites(): Promise { + return authedJson(`${BASE}/favorites`); +} + +export function addFavorite(videoUrl: string): Promise { + return authedJson(`${BASE}/favorites/${encodeURIComponent(videoUrl)}`, { method: "POST" }); +} + +export async function removeFavorite(videoUrl: string): Promise { + const res = await authed(`${BASE}/favorites/${encodeURIComponent(videoUrl)}`, { + method: "DELETE", + }); + await throwIfFailed(res, "remove failed"); +} + +export function fetchWatchLater(): Promise { + return authedJson(`${BASE}/watch-later`); +} + +export function addWatchLater(item: Omit): Promise { + return authedJson(`${BASE}/watch-later`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(item), + }); +} + +export async function removeWatchLater(videoUrl: string): Promise { + const res = await authed(`${BASE}/watch-later/${encodeURIComponent(videoUrl)}`, { + method: "DELETE", + }); + await throwIfFailed(res, "remove failed"); +} diff --git a/apps/web/src/lib/api-downloader.ts b/apps/web/src/lib/api-downloader.ts index 7629ed0..e306e89 100644 --- a/apps/web/src/lib/api-downloader.ts +++ b/apps/web/src/lib/api-downloader.ts @@ -5,7 +5,7 @@ import type { } from "../types/downloader"; import { ApiError } from "./api"; import { API_BASE as BASE } from "./env"; -import { isIosWebKitBrowser, isMobileDownloadDevice } from "./ios-device"; +import { isIosWebKitBrowser } from "./ios-device"; type ErrorBody = { error?: string; @@ -16,6 +16,9 @@ type DownloadArtifactOptions = { preferShare?: boolean; }; +const CANCEL_POLL_DELAY_MS = 300; +const CANCEL_POLL_ATTEMPTS = 8; + async function readJson(res: Response): Promise { return res.json().catch(() => ({})); } @@ -30,6 +33,10 @@ function readErrorMessage(body: unknown, fallback: string): string { return fallback; } +function delay(ms: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + export async function createDownloaderJob( payload: DownloaderCreateJobRequest, ): Promise { @@ -54,6 +61,31 @@ export async function fetchDownloaderJob(jobId: string): Promise { + const res = await fetch(`${BASE}/downloader/jobs/${encodeURIComponent(jobId)}/cancel`, { + method: "POST", + }); + const body = await readJson(res); + if (!res.ok) { + throw new ApiError(readErrorMessage(body, "Failed to cancel download job"), res.status); + } + for (let attempt = 0; attempt < CANCEL_POLL_ATTEMPTS; attempt += 1) { + const job = await fetchDownloaderJob(jobId); + if (job.status !== "queued" && job.status !== "running") return job; + await delay(CANCEL_POLL_DELAY_MS); + } + return fetchDownloaderJob(jobId); +} + +export async function deleteDownloaderJob(jobId: string): Promise { + const res = await fetch(`${BASE}/downloader/jobs/${encodeURIComponent(jobId)}`, { + method: "DELETE", + }); + if (res.ok || res.status === 404) return; + const body = await readJson(res); + throw new ApiError(readErrorMessage(body, "Failed to delete download job"), res.status); +} + function extensionFromType(contentType: string | null): string { const value = contentType ?? ""; if (value.includes("video/mp4")) return "mp4"; @@ -123,25 +155,5 @@ export async function downloadDownloaderArtifact( await shareDownloaderArtifact(endpoint, jobId); return; } - if (isMobileDownloadDevice()) { - openArtifactLocation(endpoint); - return; - } - const res = await fetch(endpoint); - if (!res.ok) { - const body = await readJson(res); - throw new ApiError(readErrorMessage(body, "Failed to download artifact"), res.status); - } - const blob = await res.blob(); - const fileName = fallbackFileName(jobId, res.headers); - const objectUrl = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = objectUrl; - a.download = fileName; - a.rel = "noopener"; - a.target = "_blank"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000); + openArtifactLocation(endpoint); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 76691d0..d69eb2a 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -2,6 +2,8 @@ import type { BulletCommentsPageResponse, ChannelResponse, CommentsPageResponse, + PodcastEpisodesResponse, + PodcastPageResponse, SearchPageResponse, StreamResponse, } from "../types/api"; @@ -20,6 +22,8 @@ export class ApiError extends Error { } } +export type ChannelSort = "latest" | "popular" | "oldest"; + type ErrorLikeBody = { error?: string; message?: string; @@ -125,12 +129,32 @@ export function fetchCommentReplies( return request(`${BASE}/comments/replies?${params}`); } -export function fetchChannel(url: string, nextpage?: string): Promise { +export function fetchChannel( + url: string, + nextpage?: string, + sort?: ChannelSort, +): Promise { const params = new URLSearchParams({ url }); + if (sort) params.set("sort", sort); if (nextpage) params.set("nextpage", nextpage); return request(`${BASE}/channel?${params}`); } +export function fetchPodcasts(url: string, nextpage?: string): Promise { + const params = new URLSearchParams({ url }); + if (nextpage) params.set("nextpage", nextpage); + return request(`${BASE}/podcasts?${params}`); +} + +export function fetchPodcastEpisodes( + url: string, + nextpage?: string, +): Promise { + const params = new URLSearchParams({ url }); + if (nextpage) params.set("nextpage", nextpage); + return request(`${BASE}/podcasts/episodes?${params}`); +} + export function fetchSuggestions(query: string, service: number): Promise { const params = new URLSearchParams({ query, service: String(service) }); return request(`${BASE}/suggestions?${params}`); diff --git a/apps/web/src/lib/authed.ts b/apps/web/src/lib/authed.ts index 8b0fd55..24d8f18 100644 --- a/apps/web/src/lib/authed.ts +++ b/apps/web/src/lib/authed.ts @@ -12,7 +12,19 @@ function withBearer(init: RequestInit | undefined, token: string): RequestInit { return { ...init, headers }; } -export async function authed(url: string, init?: RequestInit): Promise { +type AuthedOptions = { + silentStatuses?: number[]; +}; + +function shouldLogStatus(status: number, options: AuthedOptions | undefined): boolean { + return !(options?.silentStatuses ?? []).includes(status); +} + +export async function authed( + url: string, + init?: RequestInit, + options?: AuthedOptions, +): Promise { const method = init?.method ?? "GET"; const path = sanitizeRequestPath(url); let token = useAuthStore.getState().token; @@ -54,7 +66,7 @@ export async function authed(url: string, init?: RequestInit): Promise try { const retryToken = await refreshSession(); const retryRes = await fetch(url, withBearer(init, retryToken)); - if (!retryRes.ok) { + if (!retryRes.ok && shouldLogStatus(retryRes.status, options)) { recordApiError({ endpoint: url, status: retryRes.status, @@ -82,7 +94,7 @@ export async function authed(url: string, init?: RequestInit): Promise throw new ApiError("Session expired", 401); } } - if (!res.ok) { + if (!res.ok && shouldLogStatus(res.status, options)) { recordApiError({ endpoint: url, status: res.status, diff --git a/apps/web/src/lib/ios-device.ts b/apps/web/src/lib/ios-device.ts index fc94fe5..3da7a5b 100644 --- a/apps/web/src/lib/ios-device.ts +++ b/apps/web/src/lib/ios-device.ts @@ -13,11 +13,6 @@ function isWebKitEngine(): boolean { return hasWebKit && !otherIosBrowser; } -function isAndroidDevice(): boolean { - if (typeof navigator === "undefined") return false; - return /Android/i.test(navigator.userAgent); -} - export function isIosDevice(): boolean { if (typeof navigator === "undefined") return false; return /iPhone|iPad|iPod/.test(navigator.userAgent) || isTouchMac(); @@ -26,7 +21,3 @@ export function isIosDevice(): boolean { export function isIosWebKitBrowser(): boolean { return isIosDevice() && isWebKitEngine(); } - -export function isMobileDownloadDevice(): boolean { - return isIosDevice() || isAndroidDevice(); -} diff --git a/apps/web/src/lib/nico-manifest.ts b/apps/web/src/lib/nico-manifest.ts index 694db11..3d38ff0 100644 --- a/apps/web/src/lib/nico-manifest.ts +++ b/apps/web/src/lib/nico-manifest.ts @@ -8,22 +8,8 @@ const RESOLUTION_BANDWIDTH: Record = { "240p": 300000, }; -function extractDomandBid(url: string): { cleanUrl: string; domandBid: string | null } { - const hashIdx = url.indexOf("#"); - if (hashIdx === -1) return { cleanUrl: url, domandBid: null }; - const cleanUrl = url.slice(0, hashIdx); - const fragment = url.slice(hashIdx + 1); - const params = new URLSearchParams(fragment); - const cookie = params.get("cookie"); - if (!cookie) return { cleanUrl, domandBid: null }; - const match = cookie.match(/domand_bid=([^&]+)/); - return { cleanUrl, domandBid: match ? match[1] : null }; -} - function nicoProxyUrl(rawUrl: string, origin: string): string { - const { cleanUrl, domandBid } = extractDomandBid(rawUrl); - const base = `${origin}/proxy/nicovideo?url=${encodeURIComponent(cleanUrl)}`; - return domandBid ? `${base}&domand_bid=${encodeURIComponent(domandBid)}` : base; + return `${origin}/proxy/nicovideo?url=${encodeURIComponent(rawUrl)}`; } export function buildNicoMasterPlaylist( diff --git a/apps/web/src/lib/proxy.ts b/apps/web/src/lib/proxy.ts index f1a2db6..e814f29 100644 --- a/apps/web/src/lib/proxy.ts +++ b/apps/web/src/lib/proxy.ts @@ -15,6 +15,16 @@ function isRemoteUrl(url: string): boolean { return url.startsWith("http://") || url.startsWith("https://"); } +function extractProxyTarget(url: string): string | null { + try { + const parsed = new URL(url); + if (!parsed.pathname.endsWith("/proxy") && !parsed.pathname.endsWith("/api/proxy")) return null; + return parsed.searchParams.get("url"); + } catch { + return null; + } +} + export function proxyDashManifest(url: string): string { if (!url) return url; return isRemoteUrl(url) ? proxyUrl(url) : url; @@ -33,7 +43,8 @@ function needsProxy(url: string): boolean { export function proxyImage(url: string): string { if (!url) return url; - const normalized = url.startsWith("httpss://") ? `https://${url.slice(9)}` : url; + const raw = extractProxyTarget(url) ?? url; + const normalized = raw.startsWith("httpss://") ? `https://${raw.slice(9)}` : raw; if (!needsProxy(normalized)) return normalized; return proxyUrl(normalized); } diff --git a/apps/web/src/lib/watch-later-mappers.ts b/apps/web/src/lib/watch-later-mappers.ts new file mode 100644 index 0000000..a825564 --- /dev/null +++ b/apps/web/src/lib/watch-later-mappers.ts @@ -0,0 +1,18 @@ +import type { VideoStream } from "../types/stream"; +import type { WatchLaterItem } from "../types/user"; +import { proxyImage } from "./proxy"; + +export function mapWatchLaterItem(item: WatchLaterItem): VideoStream { + return { + id: item.url, + title: item.title, + thumbnail: proxyImage(item.thumbnail), + rawThumbnail: item.thumbnail, + rawChannelAvatar: "", + channelName: "", + channelAvatar: "", + views: 0, + duration: item.duration, + publishedAt: item.addedAt > 0 ? item.addedAt : undefined, + }; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index d3008bd..a8dbda6 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WatchLaterRouteImport } from './routes/watch-later' import { Route as WatchRouteImport } from './routes/watch' import { Route as SubscriptionsRouteImport } from './routes/subscriptions' import { Route as ShortsRouteImport } from './routes/shorts' @@ -18,10 +19,12 @@ import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as RegisterRouteImport } from './routes/register' import { Route as ProfileRouteImport } from './routes/profile' import { Route as PrivacyRouteImport } from './routes/privacy' +import { Route as PodcastsRouteImport } from './routes/podcasts' import { Route as PlaylistsRouteImport } from './routes/playlists' import { Route as LoginRouteImport } from './routes/login' import { Route as ImportRouteImport } from './routes/import' import { Route as HistoryRouteImport } from './routes/history' +import { Route as FavoritesRouteImport } from './routes/favorites' import { Route as ChannelRouteImport } from './routes/channel' import { Route as AdminConsoleRouteImport } from './routes/admin-console' import { Route as IndexRouteImport } from './routes/index' @@ -30,6 +33,11 @@ import { Route as PlaylistsIdRouteImport } from './routes/playlists_.$id' import { Route as ImportYoutubeRouteImport } from './routes/import/youtube' import { Route as ImportPipepipeRouteImport } from './routes/import/pipepipe' +const WatchLaterRoute = WatchLaterRouteImport.update({ + id: '/watch-later', + path: '/watch-later', + getParentRoute: () => rootRouteImport, +} as any) const WatchRoute = WatchRouteImport.update({ id: '/watch', path: '/watch', @@ -75,6 +83,11 @@ const PrivacyRoute = PrivacyRouteImport.update({ path: '/privacy', getParentRoute: () => rootRouteImport, } as any) +const PodcastsRoute = PodcastsRouteImport.update({ + id: '/podcasts', + path: '/podcasts', + getParentRoute: () => rootRouteImport, +} as any) const PlaylistsRoute = PlaylistsRouteImport.update({ id: '/playlists', path: '/playlists', @@ -95,6 +108,11 @@ const HistoryRoute = HistoryRouteImport.update({ path: '/history', getParentRoute: () => rootRouteImport, } as any) +const FavoritesRoute = FavoritesRouteImport.update({ + id: '/favorites', + path: '/favorites', + getParentRoute: () => rootRouteImport, +} as any) const ChannelRoute = ChannelRouteImport.update({ id: '/channel', path: '/channel', @@ -135,10 +153,12 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/admin-console': typeof AdminConsoleRoute '/channel': typeof ChannelRoute + '/favorites': typeof FavoritesRoute '/history': typeof HistoryRoute '/import': typeof ImportRouteWithChildren '/login': typeof LoginRoute '/playlists': typeof PlaylistsRoute + '/podcasts': typeof PodcastsRoute '/privacy': typeof PrivacyRoute '/profile': typeof ProfileRoute '/register': typeof RegisterRoute @@ -148,6 +168,7 @@ export interface FileRoutesByFullPath { '/shorts': typeof ShortsRoute '/subscriptions': typeof SubscriptionsRoute '/watch': typeof WatchRoute + '/watch-later': typeof WatchLaterRoute '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists/$id': typeof PlaylistsIdRoute @@ -157,9 +178,11 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/admin-console': typeof AdminConsoleRoute '/channel': typeof ChannelRoute + '/favorites': typeof FavoritesRoute '/history': typeof HistoryRoute '/login': typeof LoginRoute '/playlists': typeof PlaylistsRoute + '/podcasts': typeof PodcastsRoute '/privacy': typeof PrivacyRoute '/profile': typeof ProfileRoute '/register': typeof RegisterRoute @@ -169,6 +192,7 @@ export interface FileRoutesByTo { '/shorts': typeof ShortsRoute '/subscriptions': typeof SubscriptionsRoute '/watch': typeof WatchRoute + '/watch-later': typeof WatchLaterRoute '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists/$id': typeof PlaylistsIdRoute @@ -179,10 +203,12 @@ export interface FileRoutesById { '/': typeof IndexRoute '/admin-console': typeof AdminConsoleRoute '/channel': typeof ChannelRoute + '/favorites': typeof FavoritesRoute '/history': typeof HistoryRoute '/import': typeof ImportRouteWithChildren '/login': typeof LoginRoute '/playlists': typeof PlaylistsRoute + '/podcasts': typeof PodcastsRoute '/privacy': typeof PrivacyRoute '/profile': typeof ProfileRoute '/register': typeof RegisterRoute @@ -192,6 +218,7 @@ export interface FileRoutesById { '/shorts': typeof ShortsRoute '/subscriptions': typeof SubscriptionsRoute '/watch': typeof WatchRoute + '/watch-later': typeof WatchLaterRoute '/import/pipepipe': typeof ImportPipepipeRoute '/import/youtube': typeof ImportYoutubeRoute '/playlists_/$id': typeof PlaylistsIdRoute @@ -203,10 +230,12 @@ export interface FileRouteTypes { | '/' | '/admin-console' | '/channel' + | '/favorites' | '/history' | '/import' | '/login' | '/playlists' + | '/podcasts' | '/privacy' | '/profile' | '/register' @@ -216,6 +245,7 @@ export interface FileRouteTypes { | '/shorts' | '/subscriptions' | '/watch' + | '/watch-later' | '/import/pipepipe' | '/import/youtube' | '/playlists/$id' @@ -225,9 +255,11 @@ export interface FileRouteTypes { | '/' | '/admin-console' | '/channel' + | '/favorites' | '/history' | '/login' | '/playlists' + | '/podcasts' | '/privacy' | '/profile' | '/register' @@ -237,6 +269,7 @@ export interface FileRouteTypes { | '/shorts' | '/subscriptions' | '/watch' + | '/watch-later' | '/import/pipepipe' | '/import/youtube' | '/playlists/$id' @@ -246,10 +279,12 @@ export interface FileRouteTypes { | '/' | '/admin-console' | '/channel' + | '/favorites' | '/history' | '/import' | '/login' | '/playlists' + | '/podcasts' | '/privacy' | '/profile' | '/register' @@ -259,6 +294,7 @@ export interface FileRouteTypes { | '/shorts' | '/subscriptions' | '/watch' + | '/watch-later' | '/import/pipepipe' | '/import/youtube' | '/playlists_/$id' @@ -269,10 +305,12 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AdminConsoleRoute: typeof AdminConsoleRoute ChannelRoute: typeof ChannelRoute + FavoritesRoute: typeof FavoritesRoute HistoryRoute: typeof HistoryRoute ImportRoute: typeof ImportRouteWithChildren LoginRoute: typeof LoginRoute PlaylistsRoute: typeof PlaylistsRoute + PodcastsRoute: typeof PodcastsRoute PrivacyRoute: typeof PrivacyRoute ProfileRoute: typeof ProfileRoute RegisterRoute: typeof RegisterRoute @@ -282,11 +320,19 @@ export interface RootRouteChildren { ShortsRoute: typeof ShortsRoute SubscriptionsRoute: typeof SubscriptionsRoute WatchRoute: typeof WatchRoute + WatchLaterRoute: typeof WatchLaterRoute PlaylistsIdRoute: typeof PlaylistsIdRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/watch-later': { + id: '/watch-later' + path: '/watch-later' + fullPath: '/watch-later' + preLoaderRoute: typeof WatchLaterRouteImport + parentRoute: typeof rootRouteImport + } '/watch': { id: '/watch' path: '/watch' @@ -350,6 +396,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PrivacyRouteImport parentRoute: typeof rootRouteImport } + '/podcasts': { + id: '/podcasts' + path: '/podcasts' + fullPath: '/podcasts' + preLoaderRoute: typeof PodcastsRouteImport + parentRoute: typeof rootRouteImport + } '/playlists': { id: '/playlists' path: '/playlists' @@ -378,6 +431,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HistoryRouteImport parentRoute: typeof rootRouteImport } + '/favorites': { + id: '/favorites' + path: '/favorites' + fullPath: '/favorites' + preLoaderRoute: typeof FavoritesRouteImport + parentRoute: typeof rootRouteImport + } '/channel': { id: '/channel' path: '/channel' @@ -449,10 +509,12 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AdminConsoleRoute: AdminConsoleRoute, ChannelRoute: ChannelRoute, + FavoritesRoute: FavoritesRoute, HistoryRoute: HistoryRoute, ImportRoute: ImportRouteWithChildren, LoginRoute: LoginRoute, PlaylistsRoute: PlaylistsRoute, + PodcastsRoute: PodcastsRoute, PrivacyRoute: PrivacyRoute, ProfileRoute: ProfileRoute, RegisterRoute: RegisterRoute, @@ -462,6 +524,7 @@ const rootRouteChildren: RootRouteChildren = { ShortsRoute: ShortsRoute, SubscriptionsRoute: SubscriptionsRoute, WatchRoute: WatchRoute, + WatchLaterRoute: WatchLaterRoute, PlaylistsIdRoute: PlaylistsIdRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/channel.tsx b/apps/web/src/routes/channel.tsx index 00dc7c1..cabb4aa 100644 --- a/apps/web/src/routes/channel.tsx +++ b/apps/web/src/routes/channel.tsx @@ -1,5 +1,6 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { ChannelAvatar } from "../components/channel-avatar"; +import { ChannelPodcastsSection } from "../components/channel-podcasts-section"; import { PageSpinner } from "../components/page-spinner"; import { ScrollSentinel } from "../components/scroll-sentinel"; import { VideoCard } from "../components/video-card"; @@ -7,11 +8,30 @@ import { VerifiedBadgeIcon } from "../components/watch-icons"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useChannel } from "../hooks/use-channel"; import { useSubscriptions } from "../hooks/use-subscriptions"; -import { ApiError } from "../lib/api"; +import { ApiError, type ChannelSort } from "../lib/api"; import { formatViews } from "../lib/format"; +const CHANNEL_SORT_OPTIONS: { value: ChannelSort; label: string }[] = [ + { value: "latest", label: "Latest" }, + { value: "popular", label: "Popular" }, + { value: "oldest", label: "Oldest" }, +]; + +function toChannelSort(value: unknown): ChannelSort | undefined { + if (value === "latest" || value === "popular" || value === "oldest") return value; + return undefined; +} + +function validateChannelSearch(search: Record) { + const url = typeof search.url === "string" ? search.url : ""; + const sort = toChannelSort(search.sort); + return sort ? { url, sort } : { url }; +} + function ChannelPage() { - const { url } = Route.useSearch(); + const { url, sort: searchSort } = Route.useSearch(); + const sort = searchSort ?? "latest"; + const navigate = useNavigate({ from: "/channel" }); const { meta, videos, @@ -22,7 +42,7 @@ function ChannelPage() { hasNextPage, isFetchingNextPage, fetchNextPage, - } = useChannel(url); + } = useChannel(url, searchSort); const { add, remove, isSubscribed } = useSubscriptions(); const { filter } = useBlockedFilter(); @@ -37,6 +57,10 @@ function ChannelPage() { } } + function selectSort(nextSort: ChannelSort) { + navigate({ search: { url, sort: nextSort }, replace: true }); + } + if (isLoading) return ; if (isError) { const message = error instanceof ApiError ? error.message : "Unable to load channel right now."; @@ -89,6 +113,21 @@ function ChannelPage() { )} + +
{filter(videos).map((v, index) => (
) => ({ - url: typeof search.url === "string" ? search.url : "", - }), + validateSearch: validateChannelSearch, component: ChannelPage, }); diff --git a/apps/web/src/routes/favorites.tsx b/apps/web/src/routes/favorites.tsx new file mode 100644 index 0000000..be093de --- /dev/null +++ b/apps/web/src/routes/favorites.tsx @@ -0,0 +1,30 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PageSpinner } from "../components/page-spinner"; +import { VideoGrid } from "../components/video-grid"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; +import { useFavoriteStreams } from "../hooks/use-favorite-streams"; + +function FavoritesPage() { + const { videos, count, isLoading } = useFavoriteStreams(); + const { filter } = useBlockedFilter(); + + if (isLoading) return ; + + return ( +
+
+

Favorites

+

+ {count} video{count !== 1 ? "s" : ""} +

+
+ {videos.length === 0 ? ( +

No favorites yet.

+ ) : ( + + )} +
+ ); +} + +export const Route = createFileRoute("/favorites")({ component: FavoritesPage }); diff --git a/apps/web/src/routes/playlists.tsx b/apps/web/src/routes/playlists.tsx index 6e21f4a..64cad39 100644 --- a/apps/web/src/routes/playlists.tsx +++ b/apps/web/src/routes/playlists.tsx @@ -1,10 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { ConfirmModal } from "../components/confirm-modal"; +import { LibraryCollectionCard } from "../components/library-collection-card"; import { PlaylistCard } from "../components/playlist-card"; import { PlaylistCreateModal } from "../components/playlist-create-modal"; import { Toast } from "../components/toast"; +import { useFavoriteStreams } from "../hooks/use-favorite-streams"; import { usePlaylists } from "../hooks/use-playlists"; +import { useWatchLaterStreams } from "../hooks/use-watch-later-streams"; function EmptyState() { return ( @@ -17,6 +20,8 @@ function EmptyState() { function PlaylistsPage() { const { query, create, remove } = usePlaylists(); + const favorites = useFavoriteStreams(); + const watchLater = useWatchLaterStreams(); const playlists = query.data ?? []; const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -106,10 +111,22 @@ function PlaylistsPage() { )}
- {playlists.length === 0 ? ( + {playlists.length === 0 && favorites.count === 0 && watchLater.count === 0 ? ( ) : (
+ + {playlists.map((playlist, index) => (
page.episodes) ?? []; + const channelAvatar = + avatar || rawEpisodes.find((episode) => episode.channelAvatar)?.channelAvatar || ""; + const episodes = filter( + rawEpisodes.map((episode) => + episode.channelAvatar || !channelAvatar + ? episode + : { ...episode, channelAvatar, rawChannelAvatar: channelAvatar }, + ), + ); + + if (url.trim().length === 0) { + return

No podcast selected.

; + } + + if (query.isLoading) return ; + + if (query.isError) { + const message = + query.error instanceof ApiError ? query.error.message : "Unable to load podcast episodes."; + return ( +
+

{message}

+ +
+ ); + } + + return ( +
+ {podcast && ( +
+ +
+

+ {channelAvatar && ( + + )} + Podcast +

+

{podcast.title}

+

{podcast.uploaderName}

+
+
+ )} + {episodes.length === 0 ? ( +

No episodes found.

+ ) : ( + + )} + +
+ ); +} + +export const Route = createFileRoute("/podcasts")({ + validateSearch: (search: Record) => ({ + url: typeof search.url === "string" ? search.url : "", + avatar: typeof search.avatar === "string" ? search.avatar : undefined, + }), + component: PodcastsPage, +}); diff --git a/apps/web/src/routes/watch-later.tsx b/apps/web/src/routes/watch-later.tsx new file mode 100644 index 0000000..7724242 --- /dev/null +++ b/apps/web/src/routes/watch-later.tsx @@ -0,0 +1,30 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PageSpinner } from "../components/page-spinner"; +import { VideoGrid } from "../components/video-grid"; +import { useBlockedFilter } from "../hooks/use-blocked-filter"; +import { useWatchLaterStreams } from "../hooks/use-watch-later-streams"; + +function WatchLaterPage() { + const { videos, count, isLoading } = useWatchLaterStreams(); + const { filter } = useBlockedFilter(); + + if (isLoading) return ; + + return ( +
+
+

Watch later

+

+ {count} video{count !== 1 ? "s" : ""} +

+
+ {videos.length === 0 ? ( +

No videos saved for later.

+ ) : ( + + )} +
+ ); +} + +export const Route = createFileRoute("/watch-later")({ component: WatchLaterPage }); diff --git a/apps/web/src/settings/settings-language.tsx b/apps/web/src/settings/settings-language.tsx index 1f082e1..d27da87 100644 --- a/apps/web/src/settings/settings-language.tsx +++ b/apps/web/src/settings/settings-language.tsx @@ -45,15 +45,12 @@ export function SettingsLanguage() {
- - Subtitle language - + Subtitle language Preferred subtitle track
update.mutate({ defaultSubtitleLanguage: v })} - disabled={!settings.subtitlesEnabled} />
diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 88d9fa2..fbc10f2 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -139,3 +139,27 @@ export type ChannelResponse = { videos: VideoItem[]; nextpage: string | null; }; + +export type PodcastItem = { + id: string; + title: string; + url: string; + thumbnailUrl: string; + uploaderName: string; + streamCount: number; + playlistType: string; +}; + +export type PodcastPageResponse = { + channelName: string; + channelUrl: string; + podcasts: PodcastItem[]; + episodes: VideoItem[]; + nextpage: string | null; +}; + +export type PodcastEpisodesResponse = { + podcast: PodcastItem; + episodes: VideoItem[]; + nextpage: string | null; +}; diff --git a/apps/web/src/types/downloader.ts b/apps/web/src/types/downloader.ts index f774bd6..ed48680 100644 --- a/apps/web/src/types/downloader.ts +++ b/apps/web/src/types/downloader.ts @@ -44,11 +44,6 @@ export type DownloaderCreateJobRequest = { options: DownloaderJobOptions; }; -export type DownloaderCreateJobResponse = { - id: string; - cached: boolean; -}; - export type DownloaderResolvedSelection = { videoItag?: string | null; audioItag?: string | null; @@ -70,7 +65,6 @@ export type DownloaderJobResponse = { totalBytes?: number | null; etaSeconds?: number | null; resolved?: DownloaderResolvedSelection | null; - artifactUrl?: string | null; errorCode?: string | null; error?: string | null; tokenFetchMs?: number | null; @@ -78,3 +72,7 @@ export type DownloaderJobResponse = { uploadMs?: number | null; totalMs?: number | null; }; + +export type DownloaderCreateJobResponse = DownloaderJobResponse & { + cached?: boolean | null; +}; diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index d24d3bd..8bf67d0 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -37,6 +37,19 @@ export type PlaylistItem = { createdAt: number; }; +export type FavoriteItem = { + videoUrl: string; + favoritedAt: number; +}; + +export type WatchLaterItem = { + url: string; + title: string; + thumbnail: string; + duration: number; + addedAt: number; +}; + export type ProgressItem = { videoUrl: string; position: number;