import { api, type DownloadStatus, type QueueJob } from "./api"; import { createStore } from "./store"; // The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them // ("Done · Open") until it is closed, and announces each once (the done notice). // // Watched where the queue is FETCHED, not in a component's effect: every reader of the queue fetches // through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the // previous render's data. /** The statuses the tray lists as under way: waiting, working, or paused by its user. */ export const IN_FLIGHT: ReadonlySet = new Set(["queued", "running", "paused"]); /** The statuses a job leaves on its own, so the queue is worth asking about often. A paused job is * not one: it waits for its user, however long that takes. */ export const MOVING: ReadonlySet = new Set(["queued", "running"]); /** The steps a running job reports that have a name of their own (`downloads.phase.*`). */ export const DOWNLOAD_PHASES: readonly string[] = [ "video", "audio", "editing", "converting", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing", ]; let lastSeen: Map | null = null; const finished = createStore([]); const unannounced = createStore([]); const revealing = createStore(null); // Finishes this tab already said out loud where they happened: an upload that answered done. const announcedHere = new Set(); // This tab's uploads still waiting for their answer, and the uploads the queue saw land done // meanwhile: one of them may be the answer's own job, done before the answer arrived. Whose it is, // only the answer can tell (`uploadEnded`), so until then none of them is announced. let uploading = 0; let held: QueueJob[] = []; /** A job to show in the tray, asked for at `at` (ms). */ export interface Reveal { id: number; at: number; } /** Note a fresh queue: a job that is done now and was not on the last look has just finished. The * first look only learns what is there — a file finished before this tab opened is old news. */ export function noteQueue(jobs: QueueJob[]): void { const before = lastSeen; lastSeen = new Map(jobs.map((j) => [j.id, j.status])); if (!before) return; const done = jobs.filter((j) => j.status === "done" && before.get(j.id) !== "done"); if (done.length === 0) return; const ids = new Set(done.map((j) => j.id)); finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]); // `delete` answers whether it was there: each id is spent on the one look that finds it done. const news = done.filter((j) => !announcedHere.delete(j.id)); const landed = (j: QueueJob) => uploading > 0 && j.source_kind === "upload" && before.get(j.id) === undefined; held = [...held, ...news.filter(landed)]; const now = news.filter((j) => !landed(j)); if (now.length) unannounced.set((list) => [...list, ...now]); } /** An upload from this tab is on its way: what the queue sees land done may be its own job. */ export const uploadStarted = (): void => { uploading += 1; }; /** An upload's answer came: `said` is the job it announced itself (done in that answer — the tray * still keeps it, but its done notice would say it a second time, and as a download), null when it * announced none (still converting, failed, cancelled). In whichever order the queue saw it. The * rest held meanwhile (another device's uploads) are announced once no upload is waiting. */ export function uploadEnded(said: number | null): void { uploading = Math.max(0, uploading - 1); if (said !== null) { const before = held.length; held = held.filter((j) => j.id !== said); if (held.length === before) announcedHere.add(said); } if (uploading > 0 || held.length === 0) return; const release = held; held = []; unannounced.set((list) => [...list, ...release]); } /** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */ export async function fetchQueue(): Promise { const jobs = await api.downloads(); noteQueue(jobs); return jobs; } /** The jobs this tab saw finish, newest first, until the tray is closed. */ export const useFinished = (): QueueJob[] => finished.useValue(); /** The tray was closed: what finished is no longer shown. */ export const clearFinished = (): void => finished.set([]); /** A file was deleted: a "Done · Open" row for it would open and link nothing. */ export const forgetFinished = (id: number): void => finished.set((list) => (list.some((j) => j.id === id) ? list.filter((j) => j.id !== id) : list)); /** The finishes not yet announced — the render subscription that wakes the announcer. */ export const useUnannounced = (): QueueJob[] => unannounced.useValue(); /** Take the finishes to announce, clearing them: each is announced once. */ export function takeUnannounced(): QueueJob[] { const list = unannounced.get(); if (list.length) unannounced.set([]); return list; } /** "View" on a job just started (a download, a clip, an upload still converting): the tray opens on * its row — unfolded, back if it was closed, the keyboard on the row. One the tray no longer lists * because it is done already opens as a file. */ export const showInTray = (id: number): void => revealing.set({ id, at: Date.now() }); /** The pending "View", until the tray has answered it. */ export const useRevealing = (): Reveal | null => revealing.useValue(); /** The tray answered the "View" (or the job is not in the queue at all). */ export const doneRevealing = (): void => revealing.set(null); /** Forget what was watched (a test starts from a tab that has looked at nothing). */ export function resetQueueWatch(): void { lastSeen = null; finished.set([]); unannounced.set([]); announcedHere.clear(); uploading = 0; held = []; revealing.set(null); }