"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { channelCachedPages, downloadChannelOffline, evictChannelOffline, isPinned, swSupported, } from "../lib/offlineCache"; import { buildChannelIndex, dropChannelIndex, searchChannel, workerSupported, type IndexHit, } from "yt-dlp-transcript-common/components/searchIndexWorkerClient"; import { inTrackLabel } from "yt-dlp-transcript-common/lib/captionTracks"; export type OfflineChannel = { slug: string; name: string }; type ChannelState = { download: "idle" | "downloading" | "cached" | "error"; done: number; total: number; // Worker FlexSearch index state. index: "none" | "building" | "ready" | "error"; idxDone: number; idxTotal: number; docCount: number; }; const DEFAULT: ChannelState = { download: "idle", done: 0, total: 0, index: "none", idxDone: 0, idxTotal: 0, docCount: 0, }; function useOnline(): boolean { const [online, setOnline] = useState(true); useEffect(() => { const set = () => setOnline(navigator.onLine); set(); window.addEventListener("online", set); window.addEventListener("offline", set); return () => { window.removeEventListener("online", set); window.removeEventListener("offline", set); }; }, []); return online; } export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { const online = useOnline(); const [supported, setSupported] = useState(true); const [hasWorker, setHasWorker] = useState(false); const [states, setStates] = useState>({}); const setState = useCallback((slug: string, patch: Partial) => { setStates((prev) => { const base = prev[slug] ?? DEFAULT; return { ...prev, [slug]: { ...base, ...patch } }; }); }, []); useEffect(() => { if (!swSupported()) { setSupported(false); return; } setHasWorker(workerSupported()); let cancelled = false; (async () => { for (const c of channels) { if (!isPinned("", c.slug)) continue; const cached = await channelCachedPages("", c.slug); if (!cancelled && cached > 0) { setState(c.slug, { download: "cached", done: cached, total: cached }); } } })(); return () => { cancelled = true; }; }, [channels, setState]); async function download(slug: string) { setState(slug, { download: "downloading", done: 0, total: 0 }); const ok = await downloadChannelOffline("", slug, ({ done, total }) => setState(slug, { download: "downloading", done, total }), ).catch(() => false); setState(slug, { download: ok ? "cached" : "error" }); } async function remove(slug: string) { await evictChannelOffline("", slug); await dropChannelIndex(slug).catch(() => {}); setState(slug, { ...DEFAULT }); } async function buildIndex(slug: string) { setState(slug, { index: "building", idxDone: 0, idxTotal: 0 }); try { const docCount = await buildChannelIndex(slug, (done, total) => setState(slug, { index: "building", idxDone: done, idxTotal: total }), ); setState(slug, { index: "ready", docCount }); } catch { setState(slug, { index: "error" }); } } const indexedSlugs = useMemo( () => channels .filter((c) => states[c.slug]?.index === "ready") .map((c) => c.slug), [channels, states], ); const nameBySlug = useMemo(() => { const m = new Map(); for (const c of channels) m.set(c.slug, c.name); return m; }, [channels]); if (!supported) { return (
Offline downloads aren't available in this browser (no service worker support). Search still works while you're online.
); } return (
{hasWorker && indexedSlugs.length > 0 && ( )} {channels.length === 0 ? (

This site has no channels.

) : (
    {channels.map((c) => { const st = states[c.slug] ?? DEFAULT; return (
  • {c.name} {st.download === "cached" && ( Available offline )} {st.index === "ready" && ( Indexed · {st.docCount.toLocaleString()} )} {(st.download === "error" || st.index === "error") && ( {st.index === "error" ? "Index failed" : "Download failed"} )} {st.download === "downloading" && ( {st.total > 0 ? `${Math.round((st.done / st.total) * 100)}%` : "starting…"} )} {st.index === "building" && ( indexing{" "} {st.idxTotal > 0 ? `${Math.round((st.idxDone / st.idxTotal) * 100)}%` : "…"} )} {hasWorker && st.index !== "ready" && ( )} {st.download === "cached" ? ( ) : ( )}
  • ); })}
)}
); } // Instant word-search across the channels whose FlexSearch index has been built // in the worker. Runs entirely off the main thread and works offline once the // channel's shards are cached. function OfflineSearch({ indexedSlugs, nameBySlug, }: { indexedSlugs: string[]; nameBySlug: Map; }) { const [term, setTerm] = useState(""); const [hits, setHits] = useState<{ channel: string; hit: IndexHit }[]>([]); const [searching, setSearching] = useState(false); const runId = useRef(0); useEffect(() => { const q = term.trim(); if (q.length < 2) { setHits([]); return; } const id = ++runId.current; setSearching(true); const timer = setTimeout(async () => { const perChannel = await Promise.all( indexedSlugs.map((slug) => searchChannel(slug, q, 20) .then((hs) => hs.map((hit) => ({ channel: slug, hit }))) .catch(() => []), ), ); if (id !== runId.current) return; // a newer query superseded this one setHits(perChannel.flat().slice(0, 40)); setSearching(false); }, 180); return () => clearTimeout(timer); }, [term, indexedSlugs]); return (
{term.trim().length >= 2 && (
{searching && hits.length === 0 ? (

Searching…

) : hits.length === 0 ? (

No matches.

) : (
    {hits.map(({ channel, hit }, i) => (
  • {hit.text} {nameBySlug.get(channel) ?? channel} · {hit.slug} {typeof hit.start === "number" ? ` · ${formatTime(hit.start)}` : ""} {hit.track ? ` · ${inTrackLabel(hit.track)}` : ""}
  • ))}
)}
)}
); } function formatTime(sec: number): string { const s = Math.max(0, Math.floor(sec)); const m = Math.floor(s / 60); const r = s % 60; return `${m}:${String(r).padStart(2, "0")}`; }