From c4d105123854bfb47c02acbfdfbb0011db40bd22 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 16:45:46 +0200 Subject: [PATCH 1/3] feat: add channel video search --- .../web/src/components/channel-filter-bar.tsx | 104 ++++++++++++++++++ .../src/components/video-grid-skeleton.tsx | 19 ++++ apps/web/src/hooks/use-channel.ts | 30 +++-- apps/web/src/lib/channel-search-url.ts | 54 +++++++++ apps/web/src/lib/channel-sort.ts | 18 +++ apps/web/src/routes/channel.tsx | 93 +++++++++------- 6 files changed, 270 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/channel-filter-bar.tsx create mode 100644 apps/web/src/components/video-grid-skeleton.tsx create mode 100644 apps/web/src/lib/channel-search-url.ts create mode 100644 apps/web/src/lib/channel-sort.ts diff --git a/apps/web/src/components/channel-filter-bar.tsx b/apps/web/src/components/channel-filter-bar.tsx new file mode 100644 index 0000000..a95a438 --- /dev/null +++ b/apps/web/src/components/channel-filter-bar.tsx @@ -0,0 +1,104 @@ +import type { FormEvent } from "react"; +import { useEffect, useState } from "react"; +import type { ChannelSort } from "../lib/api"; +import { CHANNEL_SORT_OPTIONS, channelSortOrDefault } from "../lib/channel-sort"; + +type Props = { + sort: ChannelSort; + query: string; + searchAvailable: boolean; + onSearch: (query: string) => void; + onSortChange: (sort: ChannelSort) => void; +}; + +export function ChannelFilterBar({ sort, query, searchAvailable, onSearch, onSortChange }: Props) { + const [input, setInput] = useState(query); + const trimmedInput = input.trim(); + const isSearching = query.length > 0; + + useEffect(() => { + setInput(query); + }, [query]); + + function submitSearch(event: FormEvent) { + event.preventDefault(); + if (searchAvailable) onSearch(trimmedInput); + } + + function clearSearch() { + setInput(""); + onSearch(""); + } + + return ( +
+
+ {searchAvailable && ( +
+
+ + Channel + + setInput(event.target.value)} + placeholder="Search this channel" + className="h-9 min-w-0 flex-1 border-border-strong border-b bg-transparent text-sm text-fg outline-none transition-colors placeholder:text-fg-soft focus:border-fg" + /> + {input.length > 0 && ( + + )} + +
+
+ )} + {!isSearching && ( +
+ {CHANNEL_SORT_OPTIONS.map((option) => { + const selected = option.value === sort; + return ( + + ); + })} +
+ )} +
+ {isSearching && ( +
+ + Search results for {query}, ranked by YouTube. + + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/video-grid-skeleton.tsx b/apps/web/src/components/video-grid-skeleton.tsx new file mode 100644 index 0000000..659adf6 --- /dev/null +++ b/apps/web/src/components/video-grid-skeleton.tsx @@ -0,0 +1,19 @@ +import { VideoCardSkeleton } from "./video-card-skeleton"; + +const DEFAULT_COUNT = 12; + +type Props = { + count?: number; + idPrefix?: string; +}; + +export function VideoGridSkeleton({ count = DEFAULT_COUNT, idPrefix = "video-grid" }: Props) { + const keys = Array.from({ length: count }, (_, index) => `${idPrefix}-${index}`); + return ( +
+ {keys.map((key) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/hooks/use-channel.ts b/apps/web/src/hooks/use-channel.ts index 4cdb958..aaa720b 100644 --- a/apps/web/src/hooks/use-channel.ts +++ b/apps/web/src/hooks/use-channel.ts @@ -1,6 +1,8 @@ import { useInfiniteQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; import type { ChannelSort } from "../lib/api"; import { fetchChannel } from "../lib/api"; +import { buildChannelRequestUrl } from "../lib/channel-search-url"; import { mapVideoItem } from "../lib/mappers"; import { proxyImage } from "../lib/proxy"; import type { VideoStream } from "../types/stream"; @@ -20,11 +22,18 @@ type ChannelPage = { nextpage: string | null; }; -export function useChannel(channelUrl: string, sort?: ChannelSort) { - const query = useInfiniteQuery({ - queryKey: ["channel", channelUrl, sort], +type CachedChannelMeta = { + channelUrl: string; + meta: ChannelMeta; +}; + +export function useChannel(channelUrl: string, sort: ChannelSort, searchQuery: string) { + const lastMeta = useRef(null); + const requestUrl = buildChannelRequestUrl(channelUrl, searchQuery); + const channelQuery = useInfiniteQuery({ + queryKey: ["channel", channelUrl, sort, searchQuery], queryFn: async ({ pageParam }): Promise => { - const res = await fetchChannel(channelUrl, pageParam as string | undefined, sort); + const res = await fetchChannel(requestUrl, pageParam as string | undefined, sort); const isFirstPage = pageParam === undefined; return { meta: isFirstPage @@ -44,15 +53,20 @@ export function useChannel(channelUrl: string, sort?: ChannelSort) { initialPageParam: undefined as string | undefined, getNextPageParam: (last: ChannelPage | undefined) => last?.nextpage ?? undefined, enabled: channelUrl.length > 0, - placeholderData: (previousData) => previousData, }); - const pages = query.data?.pages ?? []; - const meta = pages.find((p) => p.meta !== null)?.meta ?? null; + const pages = channelQuery.data?.pages ?? []; + const currentMeta = pages.find((p) => p.meta !== null)?.meta ?? null; + const cachedMeta = lastMeta.current?.channelUrl === channelUrl ? lastMeta.current.meta : null; + const meta = currentMeta ?? cachedMeta; const avatarUrl = meta?.avatarUrl ?? ""; const videos = pages.flatMap((p) => p.videos.map((v) => (v.channelAvatar || !avatarUrl ? v : { ...v, channelAvatar: avatarUrl })), ); - return { ...query, meta, videos }; + useEffect(() => { + if (currentMeta) lastMeta.current = { channelUrl, meta: currentMeta }; + }, [channelUrl, currentMeta]); + + return { ...channelQuery, meta, videos }; } diff --git a/apps/web/src/lib/channel-search-url.ts b/apps/web/src/lib/channel-search-url.ts new file mode 100644 index 0000000..8f54c42 --- /dev/null +++ b/apps/web/src/lib/channel-search-url.ts @@ -0,0 +1,54 @@ +type ChannelSearchUrl = { + channelUrl: string; + query: string; +}; + +function trimTrailingSlash(value: string): string { + const trimmed = value.trim(); + return trimmed.endsWith("/") ? trimmed.replace(/\/+$/, "") : trimmed; +} + +function isYoutubeHost(hostname: string): boolean { + return hostname === "youtube.com" || hostname.endsWith(".youtube.com"); +} + +function toCleanChannelUrl(parsed: URL, pathSegments: string[]): string { + parsed.pathname = `/${pathSegments.join("/")}`; + parsed.search = ""; + parsed.hash = ""; + return trimTrailingSlash(parsed.toString()); +} + +export function splitChannelSearchUrl(url: string): ChannelSearchUrl { + const fallback = trimTrailingSlash(url); + try { + const parsed = new URL(fallback); + const segments = parsed.pathname.split("/").filter(Boolean); + const searchIndex = segments.lastIndexOf("search"); + const isSearchUrl = isYoutubeHost(parsed.hostname) && searchIndex > 0; + const channelSegments = isSearchUrl ? segments.slice(0, searchIndex) : segments; + const query = parsed.searchParams.get("query")?.trim() ?? ""; + return { + channelUrl: isSearchUrl ? toCleanChannelUrl(parsed, channelSegments) : fallback, + query: isSearchUrl ? query : "", + }; + } catch { + return { channelUrl: fallback, query: "" }; + } +} + +export function buildChannelRequestUrl(channelUrl: string, query: string): string { + const trimmedQuery = query.trim(); + if (trimmedQuery.length === 0) return channelUrl; + try { + const parsed = new URL(splitChannelSearchUrl(channelUrl).channelUrl); + if (!isYoutubeHost(parsed.hostname)) return channelUrl; + parsed.pathname = `${parsed.pathname.replace(/\/+$/, "")}/search`; + parsed.search = ""; + parsed.searchParams.set("query", trimmedQuery); + parsed.hash = ""; + return parsed.toString(); + } catch { + return channelUrl; + } +} diff --git a/apps/web/src/lib/channel-sort.ts b/apps/web/src/lib/channel-sort.ts new file mode 100644 index 0000000..187b30a --- /dev/null +++ b/apps/web/src/lib/channel-sort.ts @@ -0,0 +1,18 @@ +import type { ChannelSort } from "./api"; + +export const CHANNEL_SORT_OPTIONS: { value: ChannelSort; label: string }[] = [ + { value: "latest", label: "Newest" }, + { value: "popular", label: "Popular" }, + { value: "oldest", label: "Oldest" }, +]; + +function toChannelSort(value: unknown): ChannelSort | undefined { + if (value === "latest" || value === "newest") return "latest"; + if (value === "popular") return "popular"; + if (value === "oldest" || value === "old") return "oldest"; + return undefined; +} + +export function channelSortOrDefault(value: unknown): ChannelSort { + return toChannelSort(value) ?? "latest"; +} diff --git a/apps/web/src/routes/channel.tsx b/apps/web/src/routes/channel.tsx index cabb4aa..69bd763 100644 --- a/apps/web/src/routes/channel.tsx +++ b/apps/web/src/routes/channel.tsx @@ -1,36 +1,38 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { ChannelAvatar } from "../components/channel-avatar"; +import { ChannelFilterBar } from "../components/channel-filter-bar"; 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"; +import { VideoGridSkeleton } from "../components/video-grid-skeleton"; 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, type ChannelSort } from "../lib/api"; +import { splitChannelSearchUrl } from "../lib/channel-search-url"; +import { channelSortOrDefault } from "../lib/channel-sort"; import { formatViews } from "../lib/format"; +import { detectProvider } from "../lib/provider"; -const CHANNEL_SORT_OPTIONS: { value: ChannelSort; label: string }[] = [ - { value: "latest", label: "Latest" }, - { value: "popular", label: "Popular" }, - { value: "oldest", label: "Oldest" }, -]; +type ChannelRouteSearch = { url: string; sort?: ChannelSort; q?: string }; -function toChannelSort(value: unknown): ChannelSort | undefined { - if (value === "latest" || value === "popular" || value === "oldest") return value; - return undefined; +function channelRouteSearch(url: string, sort: ChannelSort, query: string): ChannelRouteSearch { + const q = query.trim(); + return q.length > 0 ? { url, sort, q } : { url, sort }; } function validateChannelSearch(search: Record) { - const url = typeof search.url === "string" ? search.url : ""; - const sort = toChannelSort(search.sort); - return sort ? { url, sort } : { url }; + const parsed = splitChannelSearchUrl(typeof search.url === "string" ? search.url : ""); + const query = typeof search.q === "string" ? search.q.trim() : parsed.query; + return channelRouteSearch(parsed.channelUrl, channelSortOrDefault(search.sort), query); } function ChannelPage() { - const { url, sort: searchSort } = Route.useSearch(); + const { url, sort: searchSort, q } = Route.useSearch(); const sort = searchSort ?? "latest"; + const searchQuery = q ?? ""; const navigate = useNavigate({ from: "/channel" }); const { meta, @@ -40,13 +42,18 @@ function ChannelPage() { error, refetch, hasNextPage, + isFetching, isFetchingNextPage, fetchNextPage, - } = useChannel(url, searchSort); + } = useChannel(url, sort, searchQuery); const { add, remove, isSubscribed } = useSubscriptions(); const { filter } = useBlockedFilter(); const subscribed = isSubscribed(url); + const searchAvailable = detectProvider(url) === "youtube"; + const visibleVideos = filter(videos); + const isInitialLoading = isLoading && !meta; + const isReplacingVideos = isFetching && !isFetchingNextPage && visibleVideos.length === 0; function handleSubscribe() { if (!meta) return; @@ -58,10 +65,15 @@ function ChannelPage() { } function selectSort(nextSort: ChannelSort) { - navigate({ search: { url, sort: nextSort }, replace: true }); + navigate({ search: channelRouteSearch(url, nextSort, searchQuery), replace: true }); } - if (isLoading) return ; + function searchChannel(nextQuery: string) { + const query = searchAvailable ? nextQuery : ""; + navigate({ search: channelRouteSearch(url, sort, query), replace: true }); + } + + if (isInitialLoading) return ; if (isError) { const message = error instanceof ApiError ? error.message : "Unable to load channel right now."; return ( @@ -114,32 +126,33 @@ function ChannelPage() { )} - -
- {filter(videos).map((v, index) => ( -
- -
- ))} -
- + + )} + {isFetchingNextPage && } + ); } From 77f078256535d003077d1a04b1b2f386aa059fbb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 16:46:15 +0200 Subject: [PATCH 2/3] refactor: share video grid loading states --- .../src/components/home-fallback-section.tsx | 19 +++--------- .../home-recommendations-section.tsx | 17 ++--------- apps/web/src/routes/favorites.tsx | 10 +++---- apps/web/src/routes/search.tsx | 30 ++++--------------- apps/web/src/routes/subscriptions.tsx | 18 ++++------- apps/web/src/routes/watch-later.tsx | 10 +++---- 6 files changed, 27 insertions(+), 77 deletions(-) diff --git a/apps/web/src/components/home-fallback-section.tsx b/apps/web/src/components/home-fallback-section.tsx index d3893fc..b1e5ec1 100644 --- a/apps/web/src/components/home-fallback-section.tsx +++ b/apps/web/src/components/home-fallback-section.tsx @@ -2,29 +2,18 @@ import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useSubscriptionFeed } from "../hooks/use-subscription-feed"; import { useSubscriptions } from "../hooks/use-subscriptions"; import { ScrollSentinel } from "./scroll-sentinel"; -import { VideoCardSkeleton } from "./video-card-skeleton"; import { VideoGrid } from "./video-grid"; - -const SKELETON_KEYS = Array.from({ length: 12 }, (_, i) => `hfs-${i}`); - -function SkeletonGrid() { - return ( -
- {SKELETON_KEYS.map((k) => ( - - ))} -
- ); -} +import { VideoGridSkeleton } from "./video-grid-skeleton"; function FeedSection() { const { streams, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useSubscriptionFeed(); const { filter } = useBlockedFilter(); - if (isLoading) return ; + if (isLoading) return ; return ( <> + {isFetchingNextPage && } ); @@ -33,7 +22,7 @@ function FeedSection() { export function HomeFallbackSection() { const { query } = useSubscriptions(); const hasSubs = (query.data ?? []).length > 0; - if (query.isLoading) return ; + if (query.isLoading) return ; if (hasSubs) return ; return (
diff --git a/apps/web/src/components/home-recommendations-section.tsx b/apps/web/src/components/home-recommendations-section.tsx index fd6fc74..2c21d73 100644 --- a/apps/web/src/components/home-recommendations-section.tsx +++ b/apps/web/src/components/home-recommendations-section.tsx @@ -2,20 +2,8 @@ import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useHomeRecommendations } from "../hooks/use-home-recommendations"; import { HomeFallbackSection } from "./home-fallback-section"; import { ScrollSentinel } from "./scroll-sentinel"; -import { VideoCardSkeleton } from "./video-card-skeleton"; import { VideoGrid } from "./video-grid"; - -const SKELETON_KEYS = Array.from({ length: 12 }, (_, i) => `hrs-${i}`); - -function SkeletonGrid() { - return ( -
- {SKELETON_KEYS.map((k) => ( - - ))} -
- ); -} +import { VideoGridSkeleton } from "./video-grid-skeleton"; export function HomeRecommendationsSection() { const { streams, isLoading, isError, hasNextPage, isFetchingNextPage, fetchNextPage } = @@ -23,11 +11,12 @@ export function HomeRecommendationsSection() { const { filter } = useBlockedFilter(); const filtered = filter(streams); - if (isLoading) return ; + if (isLoading) return ; if (isError || filtered.length === 0) return ; return ( <> + {isFetchingNextPage && } ); diff --git a/apps/web/src/routes/favorites.tsx b/apps/web/src/routes/favorites.tsx index be093de..a799ff9 100644 --- a/apps/web/src/routes/favorites.tsx +++ b/apps/web/src/routes/favorites.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { PageSpinner } from "../components/page-spinner"; import { VideoGrid } from "../components/video-grid"; +import { VideoGridSkeleton } from "../components/video-grid-skeleton"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useFavoriteStreams } from "../hooks/use-favorite-streams"; @@ -8,17 +8,17 @@ function FavoritesPage() { const { videos, count, isLoading } = useFavoriteStreams(); const { filter } = useBlockedFilter(); - if (isLoading) return ; - return (

Favorites

- {count} video{count !== 1 ? "s" : ""} + {isLoading ? "Loading videos" : `${count} video${count !== 1 ? "s" : ""}`}

- {videos.length === 0 ? ( + {isLoading ? ( + + ) : videos.length === 0 ? (

No favorites yet.

) : ( diff --git a/apps/web/src/routes/search.tsx b/apps/web/src/routes/search.tsx index f689c37..6084dae 100644 --- a/apps/web/src/routes/search.tsx +++ b/apps/web/src/routes/search.tsx @@ -1,13 +1,11 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; import { ScrollSentinel } from "../components/scroll-sentinel"; -import { VideoCard } from "../components/video-card"; -import { VideoCardSkeleton } from "../components/video-card-skeleton"; +import { VideoGrid } from "../components/video-grid"; +import { VideoGridSkeleton } from "../components/video-grid-skeleton"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useSearch } from "../hooks/use-search"; -const SKELETON_KEYS = Array.from({ length: 12 }, (_, i) => `skeleton-${i}`); - function SearchPage() { const { q, service } = Route.useSearch(); const navigate = useNavigate(); @@ -28,15 +26,7 @@ function SearchPage() { navigate({ to: "/search", search: { q: suggestion, service } }); } - if (isLoading) { - return ( -
- {SKELETON_KEYS.map((k) => ( - - ))} -
- ); - } + if (isLoading) return ; return (
@@ -68,19 +58,9 @@ function SearchPage() { {streams.length === 0 ? (

No results for “{q}”

) : ( -
- {streams.map((stream, index) => ( -
- -
- ))} - {isFetchingNextPage && SKELETON_KEYS.map((k) => )} -
+ )} + {isFetchingNextPage && }
); diff --git a/apps/web/src/routes/subscriptions.tsx b/apps/web/src/routes/subscriptions.tsx index d9cbe74..3e01939 100644 --- a/apps/web/src/routes/subscriptions.tsx +++ b/apps/web/src/routes/subscriptions.tsx @@ -2,17 +2,14 @@ import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { ScrollSentinel } from "../components/scroll-sentinel"; -import { VideoCardSkeleton } from "../components/video-card-skeleton"; import { VideoGrid } from "../components/video-grid"; +import { VideoGridSkeleton } from "../components/video-grid-skeleton"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { streamQueryOptions } from "../hooks/use-stream"; import { useSubscriptionFeed } from "../hooks/use-subscription-feed"; import { useSubscriptions } from "../hooks/use-subscriptions"; import { ApiError } from "../lib/api"; -const SKELETON_COUNT = 12; -const SKELETON_KEYS = Array.from({ length: SKELETON_COUNT }, (_, i) => `subs-sk-${i}`); - function SubscriptionsPage() { const queryClient = useQueryClient(); const prefetchedIdsRef = useRef(new Set()); @@ -35,6 +32,8 @@ function SubscriptionsPage() { } }, [streams, queryClient]); + if (query.isLoading) return ; + if (subscriptions.length === 0) { return (
@@ -43,19 +42,12 @@ function SubscriptionsPage() { ); } - if (isLoading) { - return ( -
- {SKELETON_KEYS.map((key) => ( - - ))} -
- ); - } + if (isLoading) return ; return ( <> + {isFetchingNextPage && } ); diff --git a/apps/web/src/routes/watch-later.tsx b/apps/web/src/routes/watch-later.tsx index 7724242..6906427 100644 --- a/apps/web/src/routes/watch-later.tsx +++ b/apps/web/src/routes/watch-later.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { PageSpinner } from "../components/page-spinner"; import { VideoGrid } from "../components/video-grid"; +import { VideoGridSkeleton } from "../components/video-grid-skeleton"; import { useBlockedFilter } from "../hooks/use-blocked-filter"; import { useWatchLaterStreams } from "../hooks/use-watch-later-streams"; @@ -8,17 +8,17 @@ function WatchLaterPage() { const { videos, count, isLoading } = useWatchLaterStreams(); const { filter } = useBlockedFilter(); - if (isLoading) return ; - return (

Watch later

- {count} video{count !== 1 ? "s" : ""} + {isLoading ? "Loading videos" : `${count} video${count !== 1 ? "s" : ""}`}

- {videos.length === 0 ? ( + {isLoading ? ( + + ) : videos.length === 0 ? (

No videos saved for later.

) : ( From d7c522b5898a5bdaf8bd65257db4781233c75227 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 6 Jun 2026 16:46:48 +0200 Subject: [PATCH 3/3] feat: enrich continue watching cards --- apps/web/src/components/continue-card.tsx | 93 +++++++++++++++++------ apps/web/src/lib/history-enrichment.ts | 64 +++++++++++----- apps/web/src/types/user.ts | 1 + 3 files changed, 115 insertions(+), 43 deletions(-) diff --git a/apps/web/src/components/continue-card.tsx b/apps/web/src/components/continue-card.tsx index 89eb502..79f0ada 100644 --- a/apps/web/src/components/continue-card.tsx +++ b/apps/web/src/components/continue-card.tsx @@ -1,8 +1,13 @@ import { Link } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; import { useWatchPrefetch } from "../hooks/use-watch-prefetch"; import { formatDuration } from "../lib/format"; +import { resolveHistoryChannelMeta } from "../lib/history-enrichment"; +import { proxyImage } from "../lib/proxy"; import type { HistoryItem } from "../types/user"; +import { HistoryChannelAvatar } from "./history-channel-avatar"; import { VideoProgressBar } from "./video-progress-bar"; +import { VerifiedBadgeIcon } from "./watch-icons"; type ContinueCardProps = { item: HistoryItem; @@ -10,34 +15,74 @@ type ContinueCardProps = { export function ContinueCard({ item }: ContinueCardProps) { const prefetch = useWatchPrefetch(item.url); + const [uploaderVerified, setUploaderVerified] = useState(item.uploaderVerified ?? false); + + useEffect(() => { + let active = true; + setUploaderVerified(item.uploaderVerified ?? false); + if (item.uploaderVerified !== undefined) { + return () => { + active = false; + }; + } + resolveHistoryChannelMeta(item).then((meta) => { + if (active) setUploaderVerified(meta.uploaderVerified); + }); + return () => { + active = false; + }; + }, [item]); return ( - -
- {item.title} - - {formatDuration(item.duration)} - - -
-
- +
+ +
+ {item.title} + + {formatDuration(item.duration)} + + +
+ {item.title} - {item.channelName} + +
+ {item.channelUrl ? ( + + + + ) : ( + + )} + {item.channelUrl ? ( + + {item.channelName} + {uploaderVerified && } + + ) : ( + + {item.channelName} + {uploaderVerified && } + + )}
- +
); } diff --git a/apps/web/src/lib/history-enrichment.ts b/apps/web/src/lib/history-enrichment.ts index bd98711..aa6479c 100644 --- a/apps/web/src/lib/history-enrichment.ts +++ b/apps/web/src/lib/history-enrichment.ts @@ -7,48 +7,70 @@ const CACHE_MS = 30 * 60 * 1000; type CacheEntry = { updatedAt: number; avatarUrl: string; + uploaderVerified: boolean; +}; + +type HistoryChannelMeta = { + avatarUrl: string | null; + uploaderVerified: boolean; }; const avatarCache = new Map(); -const pendingByChannel = new Map>(); +const pendingByChannel = new Map>(); -function fromStream(stream: StreamResponse): string | null { - return stream.uploaderAvatarUrl && stream.uploaderAvatarUrl.length > 0 - ? stream.uploaderAvatarUrl - : null; +function fromStream(stream: StreamResponse): HistoryChannelMeta { + return { + avatarUrl: + stream.uploaderAvatarUrl && stream.uploaderAvatarUrl.length > 0 + ? stream.uploaderAvatarUrl + : null, + uploaderVerified: stream.uploaderVerified, + }; } -function cached(channelUrl: string): string | null { +function cached(channelUrl: string): HistoryChannelMeta | null { const hit = avatarCache.get(channelUrl); if (!hit) return null; if (Date.now() - hit.updatedAt > CACHE_MS) { avatarCache.delete(channelUrl); return null; } - return hit.avatarUrl; + return { avatarUrl: hit.avatarUrl || null, uploaderVerified: hit.uploaderVerified }; } -function setCache(channelUrl: string, avatarUrl: string): void { - avatarCache.set(channelUrl, { avatarUrl, updatedAt: Date.now() }); +function setCache(channelUrl: string, meta: HistoryChannelMeta): void { + avatarCache.set(channelUrl, { + avatarUrl: meta.avatarUrl ?? "", + uploaderVerified: meta.uploaderVerified, + updatedAt: Date.now(), + }); } -export async function resolveHistoryAvatar(item: HistoryItem): Promise { - if (item.channelAvatar) return item.channelAvatar; +export async function resolveHistoryChannelMeta(item: HistoryItem): Promise { + const current = { + avatarUrl: item.channelAvatar ?? null, + uploaderVerified: item.uploaderVerified ?? false, + }; + if (item.channelAvatar && item.uploaderVerified !== undefined) return current; const channelUrl = item.channelUrl; - if (!channelUrl) return null; + if (!channelUrl) return current; const hit = cached(channelUrl); - if (hit) return hit; + if (hit) + return { + avatarUrl: current.avatarUrl ?? hit.avatarUrl, + uploaderVerified: hit.uploaderVerified, + }; const pending = pendingByChannel.get(channelUrl); - if (pending) return pending; + if (pending) + return pending.then((meta) => ({ ...meta, avatarUrl: current.avatarUrl ?? meta.avatarUrl })); const task = (async () => { try { const stream = await fetchStream(item.url); - const avatarUrl = fromStream(stream); - if (!avatarUrl) return null; - setCache(channelUrl, avatarUrl); - return avatarUrl; + const meta = fromStream(stream); + setCache(channelUrl, meta); + return meta; } catch { - return null; + return current; } finally { pendingByChannel.delete(channelUrl); } @@ -56,3 +78,7 @@ export async function resolveHistoryAvatar(item: HistoryItem): Promise { + return resolveHistoryChannelMeta(item).then((meta) => meta.avatarUrl); +} diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index 41f100d..c26ed1a 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -8,6 +8,7 @@ export type HistoryItem = { channelName: string; channelUrl: string; channelAvatar?: string; + uploaderVerified?: boolean; duration: number; progress: number; watchedAt: number;