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();
|
||||
|
||||
Reference in New Issue
Block a user