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:
vincent
2026-08-29 04:03:45 +02:00
parent d0bad64d7c
commit 5b09acd28d
69 changed files with 582 additions and 89 deletions
+74 -23
View File
@@ -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();