feat: 4K downloads, watch progress, player controls, new icon
Window chrome: titleBarStyle Overlay (Transparent left the native bar in place, white in dark mode, with a dead strip beneath it). The app now paints that strip itself, so it matches the page background. Player: prev/next through the feed, a full-screen button, a spinner while a stream resolves, Open on YouTube on downloaded videos too, and the description collapsed behind a disclosure. Downloads default to Best, which reaches real 4K — above 1080p YouTube serves VP9/AV1, verified to play natively in WKWebView here. Audio stays pinned to AAC because Opus in MP4 would be silent. A Compatible setting keeps the old 1080p H.264 behaviour. Files are now named '<ISO date> - <title> [<id>].mp4'. Watch progress is recorded and drawn under thumbnails like YouTube's, and reopening a video resumes where it left off. Tiles clamp every text line to a fixed height so they share a baseline, and the search field no longer clips its placeholder. Fixes a Picture-in-Picture leak: WebKit kept a detached video playing after the player closed, so a second video could play over the first with no way to stop it. Every exit path now tears the element down.
This commit is contained in:
+74
-23
@@ -13,7 +13,7 @@ import { useAppearance } from "./hooks/useAppearance";
|
||||
import { useConnectivity } from "./hooks/useConnectivity";
|
||||
import { useDownloads } from "./hooks/useDownloads";
|
||||
import { useFeed } from "./hooks/useFeed";
|
||||
import type { FeedFilter, FeedItem, RefreshProgress } from "./types";
|
||||
import type { FeedFilter, FeedItem, Quality, RefreshProgress } from "./types";
|
||||
|
||||
const TOAST_MS = 2400;
|
||||
|
||||
@@ -30,7 +30,17 @@ export default function App() {
|
||||
}
|
||||
});
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [playing, setPlaying] = useState<{ item: FeedItem; path: string | null } | null>(null);
|
||||
// Index into the current feed, so the player can step through it.
|
||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||
const [quality, setQuality] = useState<Quality>(() => {
|
||||
try {
|
||||
return localStorage.getItem("flighttube.quality") === "compatible"
|
||||
? "compatible"
|
||||
: "best";
|
||||
} catch {
|
||||
return "best";
|
||||
}
|
||||
});
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
@@ -52,10 +62,11 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("flighttube.view", view);
|
||||
localStorage.setItem("flighttube.quality", quality);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
}, [view]);
|
||||
}, [view, quality]);
|
||||
|
||||
// Offline, the only videos that can be played are the ones already on disk,
|
||||
// so the feed collapses to those regardless of the toggle.
|
||||
@@ -116,23 +127,45 @@ export default function App() {
|
||||
}
|
||||
}, [reload, say]);
|
||||
|
||||
const openItem = useCallback(
|
||||
(item: FeedItem) => {
|
||||
// Downloaded plays from disk; anything else streams. Only being offline with
|
||||
// no local copy leaves nothing to play.
|
||||
const playableAt = useCallback(
|
||||
(i: number): { item: FeedItem; path: string | null } | null => {
|
||||
const item = items[i];
|
||||
if (!item) return null;
|
||||
const path = live[item.id]?.path ?? item.path;
|
||||
const done = (live[item.id]?.state ?? item.state) === "done";
|
||||
// Downloaded plays from disk; anything else streams YouTube's embed in
|
||||
// the app. Only being offline with no local copy leaves nothing to play.
|
||||
if (done && path) {
|
||||
setPlaying({ item, path });
|
||||
} else if (online) {
|
||||
setPlaying({ item, path: null });
|
||||
} else {
|
||||
setFailure("That video isn't downloaded, and you're offline.");
|
||||
}
|
||||
if (done && path) return { item, path };
|
||||
return online ? { item, path: null } : null;
|
||||
},
|
||||
[live, online],
|
||||
[items, live, online],
|
||||
);
|
||||
|
||||
const openIndex = useCallback(
|
||||
(i: number) => {
|
||||
if (playableAt(i)) setPlayingIndex(i);
|
||||
else setFailure("That video isn't downloaded, and you're offline.");
|
||||
},
|
||||
[playableAt],
|
||||
);
|
||||
|
||||
/** Next/previous item that can actually be played right now. */
|
||||
const stepFrom = useCallback(
|
||||
(from: number, dir: 1 | -1): number | null => {
|
||||
for (let i = from + dir; i >= 0 && i < items.length; i += dir) {
|
||||
if (playableAt(i)) return i;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[items.length, playableAt],
|
||||
);
|
||||
|
||||
const playing = playingIndex == null ? null : playableAt(playingIndex);
|
||||
// The feed can change under an open player (a refresh, a filter change).
|
||||
useEffect(() => {
|
||||
if (playingIndex != null && !items[playingIndex]) setPlayingIndex(null);
|
||||
}, [items, playingIndex]);
|
||||
|
||||
const emptyMessage = () => {
|
||||
if (loading) return "Loading…";
|
||||
if (channels.length === 0)
|
||||
@@ -200,14 +233,14 @@ export default function App() {
|
||||
: "mx-auto max-w-4xl space-y-1.5"
|
||||
}
|
||||
>
|
||||
{items.map((item) => {
|
||||
{items.map((item, idx) => {
|
||||
const shared = {
|
||||
item,
|
||||
live: live[item.id],
|
||||
online,
|
||||
onOpen: () => openItem(item),
|
||||
onOpen: () => openIndex(idx),
|
||||
onDownload: () =>
|
||||
downloadVideo(item.id).catch((e) => setFailure(String(e))),
|
||||
downloadVideo(item.id, quality).catch((e) => setFailure(String(e))),
|
||||
onCancel: () =>
|
||||
cancelDownload(item.id).catch((e) => setFailure(String(e))),
|
||||
onDelete: () =>
|
||||
@@ -227,16 +260,32 @@ export default function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{playing && (
|
||||
<Player item={playing.item} path={playing.path}
|
||||
onClose={() => setPlaying(null)}
|
||||
{playing && playingIndex != null && (
|
||||
<Player
|
||||
key={playing.item.id}
|
||||
item={playing.item}
|
||||
path={playing.path}
|
||||
index={playingIndex}
|
||||
total={items.length}
|
||||
onPrev={
|
||||
stepFrom(playingIndex, -1) != null
|
||||
? () => setPlayingIndex(stepFrom(playingIndex, -1))
|
||||
: undefined
|
||||
}
|
||||
onNext={
|
||||
stepFrom(playingIndex, 1) != null
|
||||
? () => setPlayingIndex(stepFrom(playingIndex, 1))
|
||||
: undefined
|
||||
}
|
||||
onClose={() => { setPlayingIndex(null); reload(); }}
|
||||
onDelete={async () => {
|
||||
await deleteDownload(playing.item.id);
|
||||
clearLive(playing.item.id);
|
||||
setPlaying(null);
|
||||
setPlayingIndex(null);
|
||||
reload();
|
||||
say("Download deleted");
|
||||
}} />
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
@@ -244,6 +293,8 @@ export default function App() {
|
||||
onClose={() => setShowSettings(false)}
|
||||
appearance={mode}
|
||||
onAppearance={setMode}
|
||||
quality={quality}
|
||||
onQuality={setQuality}
|
||||
onError={setFailure}
|
||||
onImported={(n) => {
|
||||
reload();
|
||||
|
||||
+6
-2
@@ -9,6 +9,7 @@ import type {
|
||||
FeedItem,
|
||||
ImportPreview,
|
||||
Prereqs,
|
||||
Quality,
|
||||
RefreshProgress,
|
||||
RefreshSummary,
|
||||
} from "./types";
|
||||
@@ -22,8 +23,11 @@ export const listFeed = (filter: FeedFilter) =>
|
||||
|
||||
export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
|
||||
|
||||
export const downloadVideo = (videoId: string) =>
|
||||
invoke<void>("download_video", { videoId });
|
||||
export const downloadVideo = (videoId: string, quality: Quality) =>
|
||||
invoke<void>("download_video", { videoId, quality });
|
||||
|
||||
export const savePlayback = (videoId: string, position: number, duration: number) =>
|
||||
invoke<void>("save_playback", { videoId, position, duration });
|
||||
|
||||
export const cancelDownload = (videoId: string) =>
|
||||
invoke<void>("cancel_download", { videoId });
|
||||
|
||||
+170
-25
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fileUrl, openExternal, resolveStream } from "../api";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
|
||||
import type { FeedItem } from "../types";
|
||||
import { compactViews, relativeTime } from "./format";
|
||||
import { BTN, BTN_CHROME, BTN_QUIET } from "./ui";
|
||||
import { BTN, BTN_CHROME, BTN_QUIET, Spinner } from "./ui";
|
||||
|
||||
interface Props {
|
||||
item: FeedItem;
|
||||
@@ -10,8 +10,61 @@ interface Props {
|
||||
path: string | null;
|
||||
onClose: () => void;
|
||||
onDelete: () => void;
|
||||
onPrev?: () => void;
|
||||
onNext?: () => void;
|
||||
/** Position in the current feed, for the "3 of 180" readout. */
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a <video> completely.
|
||||
*
|
||||
* Detaching the element is not enough: WebKit keeps a Picture-in-Picture
|
||||
* session (and its audio) running after the element leaves the DOM, so closing
|
||||
* the player or stepping to the next video would leave the previous one playing
|
||||
* with no way to stop it. Every exit path goes through here.
|
||||
*/
|
||||
function teardown(v: HTMLVideoElement | null) {
|
||||
if (!v) return;
|
||||
// Safari's PiP is the non-standard presentation-mode API; the spec one is
|
||||
// tried too, since either may be the live implementation.
|
||||
try {
|
||||
const webkit = v as HTMLVideoElement & {
|
||||
webkitPresentationMode?: string;
|
||||
webkitSetPresentationMode?: (mode: string) => void;
|
||||
};
|
||||
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
|
||||
webkit.webkitSetPresentationMode?.("inline");
|
||||
}
|
||||
} catch {
|
||||
/* not supported here */
|
||||
}
|
||||
try {
|
||||
if (document.pictureInPictureElement) void document.exitPictureInPicture();
|
||||
} catch {
|
||||
/* not supported here */
|
||||
}
|
||||
try {
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
} catch {
|
||||
/* not supported here */
|
||||
}
|
||||
try {
|
||||
v.pause();
|
||||
// Dropping the source is what actually frees the decoder and the audio.
|
||||
v.removeAttribute("src");
|
||||
v.load();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/** Save at most this often while playing; also saved on close. */
|
||||
const SAVE_EVERY_MS = 5000;
|
||||
/** Ignore a saved position this close to either end — nothing useful to resume. */
|
||||
const RESUME_EDGE_S = 5;
|
||||
|
||||
/**
|
||||
* Two sources, one player element.
|
||||
*
|
||||
@@ -23,10 +76,15 @@ interface Props {
|
||||
* so watching still happens here rather than in a browser. The iframe embed
|
||||
* cannot be used: it rejects a `tauri://` origin with "Error 153".
|
||||
*/
|
||||
export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
export default function Player({
|
||||
item, path, onClose, onDelete, onPrev, onNext, index, total,
|
||||
}: Props) {
|
||||
const streaming = path === null;
|
||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const lastSave = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (path) {
|
||||
@@ -44,6 +102,52 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
};
|
||||
}, [item.id, path]);
|
||||
|
||||
const persist = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
|
||||
savePlayback(item.id, v.currentTime, v.duration).catch(() => {
|
||||
/* a lost position is not worth interrupting playback for */
|
||||
});
|
||||
}, [item.id]);
|
||||
|
||||
// Save on the way out, including when switching to another video. Declared
|
||||
// before the teardown effect so React runs this cleanup first — the position
|
||||
// has to be read off the element before its source is dropped.
|
||||
useEffect(() => () => persist(), [persist]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
return () => teardown(v);
|
||||
}, [src]);
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastSave.current < SAVE_EVERY_MS) return;
|
||||
lastSave.current = now;
|
||||
persist();
|
||||
};
|
||||
|
||||
// Pick up where this video was left off, unless that was right at either end.
|
||||
const onLoadedMetadata = () => {
|
||||
const v = videoRef.current;
|
||||
const at = item.position ?? 0;
|
||||
if (!v || !Number.isFinite(v.duration)) return;
|
||||
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at;
|
||||
};
|
||||
|
||||
const goFullscreen = () => {
|
||||
// The stage rather than the <video>, so our letterboxing travels with it.
|
||||
stageRef.current?.requestFullscreen?.().catch(() => {
|
||||
videoRef.current?.requestFullscreen?.().catch(() => {});
|
||||
});
|
||||
};
|
||||
|
||||
const navBtn =
|
||||
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " +
|
||||
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-30 disabled:cursor-not-allowed " +
|
||||
"disabled:hover:border-slate-300 disabled:hover:text-inherit " +
|
||||
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
@@ -54,12 +158,28 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
<button onClick={onClose} className={`${BTN} cursor-pointer py-1.5`}>
|
||||
← Back
|
||||
</button>
|
||||
|
||||
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
|
||||
‹ Prev
|
||||
</button>
|
||||
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}>
|
||||
Next ›
|
||||
</button>
|
||||
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
||||
{index + 1}/{total}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
||||
{item.channel_title}
|
||||
</span>
|
||||
|
||||
<button onClick={goFullscreen} disabled={!src} title="Full screen" className={navBtn}>
|
||||
⤢ Full screen
|
||||
</button>
|
||||
|
||||
{streaming ? (
|
||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{src ? "Streaming" : error ? "Unavailable" : "Resolving…"}
|
||||
{src ? "Streaming" : error ? "Unavailable" : "Loading"}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
@@ -73,10 +193,19 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
|
||||
{/* Absolute fill + object-contain, so portrait Shorts and landscape
|
||||
videos are both letterboxed to the pane instead of overflowing it. */}
|
||||
<div className="relative min-h-0 flex-1 bg-slate-950">
|
||||
<div ref={stageRef} className="relative min-h-0 flex-1 bg-slate-950">
|
||||
{src ? (
|
||||
<video key={src} src={src} controls autoPlay
|
||||
className="absolute inset-0 size-full object-contain" />
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={src}
|
||||
src={src}
|
||||
controls
|
||||
autoPlay
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMetadata}
|
||||
onPause={persist}
|
||||
className="absolute inset-0 size-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 grid place-items-center px-6 text-center">
|
||||
{error ? (
|
||||
@@ -90,34 +219,50 @@ export default function Player({ item, path, onClose, onDelete }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-slate-400">Finding a stream…</p>
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Spinner />
|
||||
<span className="text-[12px]">Finding a stream…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer
|
||||
className="max-h-52 overflow-y-auto border-t border-slate-200 bg-white px-4 py-4
|
||||
className="max-h-52 shrink-0 overflow-y-auto border-t border-slate-200 bg-white px-4 py-3
|
||||
dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-[15px] font-semibold leading-snug tracking-tight">{item.title}</h2>
|
||||
{streaming && (
|
||||
<button
|
||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||
className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`}
|
||||
>
|
||||
Open on YouTube
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-[15px] font-semibold tracking-tight">{item.title}</h2>
|
||||
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
{[compactViews(item.views), relativeTime(item.published)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||
className={`${BTN_QUIET} shrink-0 cursor-pointer whitespace-nowrap`}
|
||||
>
|
||||
Open on YouTube
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapsed by default — the description is rarely what you came for. */}
|
||||
{item.description && (
|
||||
<p className="mt-3 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{item.description}
|
||||
</p>
|
||||
<details className="group mt-2">
|
||||
<summary
|
||||
className="cursor-pointer list-none text-[11px] font-medium text-slate-500
|
||||
hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400"
|
||||
>
|
||||
<span className="inline-block transition-transform group-open:rotate-90">›</span>{" "}
|
||||
Description
|
||||
</summary>
|
||||
<p className="mt-2 whitespace-pre-wrap text-[12.5px] leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{item.description}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
|
||||
} from "../api";
|
||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||
import type { ImportPreview, Prereqs } from "../types";
|
||||
import type { ImportPreview, Prereqs, Quality } from "../types";
|
||||
import TakeoutGuide from "./TakeoutGuide";
|
||||
import {
|
||||
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL,
|
||||
@@ -14,6 +14,8 @@ interface Props {
|
||||
onImported: (count: number) => void;
|
||||
appearance: Appearance;
|
||||
onAppearance: (a: Appearance) => void;
|
||||
quality: Quality;
|
||||
onQuality: (q: Quality) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -33,7 +35,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
|
||||
}
|
||||
|
||||
export default function Settings({
|
||||
onClose, onImported, appearance, onAppearance, onError,
|
||||
onClose, onImported, appearance, onAppearance, quality, onQuality, onError,
|
||||
}: Props) {
|
||||
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
||||
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
||||
@@ -121,6 +123,27 @@ export default function Settings({
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Download quality</SectionHeading>
|
||||
<p className={`mt-1.5 ${HELP}`}>
|
||||
<b>Best</b> takes the highest resolution available, up to 4K — above 1080p
|
||||
that means VP9 or AV1, which your Mac decodes but older ones may not, and
|
||||
the files are several times larger. <b>Compatible</b> caps at 1080p H.264,
|
||||
which plays anywhere. Audio is AAC either way.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span className={LABEL}>Quality</span>
|
||||
<Segmented
|
||||
value={quality}
|
||||
onChange={onQuality}
|
||||
options={[
|
||||
{ value: "best", label: "Best (4K)" },
|
||||
{ value: "compatible", label: "Compatible" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||
<SectionHeading>Appearance</SectionHeading>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function TopBar({
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search videos and channels"
|
||||
className={`${INPUT} min-w-48 max-w-sm flex-1 py-1.5`}
|
||||
className={`${INPUT} min-w-72 max-w-sm flex-1 py-1.5`}
|
||||
/>
|
||||
|
||||
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { thumbSrc } from "../api";
|
||||
import type { LiveDownload } from "../hooks/useDownloads";
|
||||
import type { FeedItem } from "../types";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import WatchBar from "./WatchBar";
|
||||
import { compactViews, relativeTime } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -48,12 +49,13 @@ export default function VideoRow({
|
||||
)}
|
||||
{downloaded && (
|
||||
<span
|
||||
className="absolute bottom-1 right-1 rounded bg-sky-500 px-1 py-0.5 text-[9px]
|
||||
className="absolute bottom-1.5 right-1 rounded bg-sky-500 px-1 py-0.5 text-[9px]
|
||||
font-bold uppercase tracking-widest leading-none text-white"
|
||||
>
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
<WatchBar item={item} />
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { thumbSrc } from "../api";
|
||||
import type { LiveDownload } from "../hooks/useDownloads";
|
||||
import type { FeedItem } from "../types";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import WatchBar from "./WatchBar";
|
||||
import { compactViews, relativeTime } from "./format";
|
||||
|
||||
interface Props {
|
||||
@@ -45,35 +46,49 @@ export default function VideoTile({
|
||||
)}
|
||||
{downloaded && (
|
||||
<span
|
||||
className="absolute bottom-1.5 right-1.5 rounded bg-sky-500 px-1 py-0.5 text-[9px]
|
||||
className="absolute bottom-2 right-1.5 rounded bg-sky-500 px-1 py-0.5 text-[9px]
|
||||
font-bold uppercase tracking-widest leading-none text-white"
|
||||
>
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
<WatchBar item={item} />
|
||||
</button>
|
||||
|
||||
<div className="mt-2 flex min-w-0 flex-1 flex-col">
|
||||
{/* Every text block is a fixed height and every line is clamped, so tiles
|
||||
stay on a shared baseline no matter how long a title runs. */}
|
||||
<div className="mt-2 flex min-w-0 flex-col">
|
||||
<button onClick={onOpen} className="cursor-pointer text-left">
|
||||
<h3 className="line-clamp-2 text-[13px] font-medium leading-snug">{item.title}</h3>
|
||||
<h3
|
||||
className="line-clamp-2 h-[2.25rem] text-[13px] font-medium leading-snug"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
</button>
|
||||
<div className="mt-1 truncate text-[12px] text-slate-500 dark:text-slate-400">
|
||||
<div
|
||||
className="mt-1 h-4 truncate text-[12px] leading-4 text-slate-500 dark:text-slate-400"
|
||||
title={item.channel_title}
|
||||
>
|
||||
{item.channel_title}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-400 dark:text-slate-500">
|
||||
<div className="mt-0.5 h-4 truncate text-[11px] leading-4 text-slate-400 dark:text-slate-500">
|
||||
{[compactViews(item.views), relativeTime(item.published)].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
|
||||
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
|
||||
<div className="mt-1 line-clamp-2 text-[11px] text-red-600 dark:text-red-400">
|
||||
{live?.error ?? item.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex">
|
||||
<div className="mt-2 flex h-7 items-start">
|
||||
<DownloadButton item={item} live={live} online={online}
|
||||
onDownload={onDownload} onCancel={onCancel} onDelete={onDelete} />
|
||||
</div>
|
||||
|
||||
{(live?.error ?? item.error) && (live?.state ?? item.state) === "failed" && (
|
||||
<div
|
||||
className="mt-1 line-clamp-1 text-[11px] text-red-600 dark:text-red-400"
|
||||
title={live?.error ?? item.error ?? undefined}
|
||||
>
|
||||
{live?.error ?? item.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FeedItem } from "../types";
|
||||
|
||||
/** Fraction of the video watched, or null if it was never opened. */
|
||||
export function watchedFraction(item: FeedItem): number | null {
|
||||
const { position, duration } = item;
|
||||
if (position == null || duration == null || duration <= 0) return null;
|
||||
return Math.min(1, Math.max(0, position / duration));
|
||||
}
|
||||
|
||||
/**
|
||||
* The red sliver across the bottom of a thumbnail, same idea as YouTube's.
|
||||
* Red rather than the sky accent on purpose: the accent means "the action to
|
||||
* take" or "currently selected", and this is neither.
|
||||
*/
|
||||
export default function WatchBar({ item }: { item: FeedItem }) {
|
||||
const f = watchedFraction(item);
|
||||
if (f == null || f < 0.01) return null;
|
||||
const nearlyDone = f > 0.97;
|
||||
return (
|
||||
<span
|
||||
className="absolute inset-x-0 bottom-0 h-[3px] bg-slate-950/45"
|
||||
title={nearlyDone ? "Watched" : `${Math.round(f * 100)}% watched`}
|
||||
>
|
||||
<span
|
||||
className="block h-full bg-red-600"
|
||||
style={{ width: `${Math.max(2, f * 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,16 @@ export const BTN_QUIET =
|
||||
"text-[11px] text-slate-500 underline underline-offset-2 " +
|
||||
"hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400";
|
||||
|
||||
/** The only spinning thing in the app; used where a wait has no known length. */
|
||||
export function Spinner({ className = "size-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
step,
|
||||
children,
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface FeedItem {
|
||||
path: string | null;
|
||||
pct: number | null;
|
||||
error: string | null;
|
||||
/** Seconds watched, and the video's length — drives the feed progress bar. */
|
||||
position: number | null;
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
export interface FeedFilter {
|
||||
@@ -82,3 +85,5 @@ export interface ImportPreview {
|
||||
removed_videos: number;
|
||||
removed_downloads: number;
|
||||
}
|
||||
|
||||
export type Quality = "best" | "compatible";
|
||||
|
||||
Reference in New Issue
Block a user