feat: play the next video automatically when one ends

A toggle under Playback in Settings, off by default and remembered.

Verified offline with forced offline mode on: a downloaded video ran to
its end and the next one started by itself, 6/51 to 7/51, with no
network.

It follows the same list the Next button does, which already steps to
the next *playable* video — offline that is the next download, so it
plays through what is on the Mac one after another. Online it will
stream the next video, and the Settings text says so: a toggle that
quietly does nothing outside one hidden condition is how the subtitle
preference went wrong three times.

The last video in the list simply stops; there is nowhere to go and
Next is already absent there.
This commit is contained in:
vincent
2026-09-01 18:57:07 +02:00
parent ad29f57e65
commit b9e8a9db84
3 changed files with 36 additions and 2 deletions
+6 -1
View File
@@ -49,6 +49,7 @@ export default function App() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly")); const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly"));
const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts")); const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts"));
const [autoplayNext, setAutoplayNext] = useState(() => remembered("autoplayNext"));
const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden")); const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden"));
const [sidebarPeek, setSidebarPeek] = useState(false); const [sidebarPeek, setSidebarPeek] = useState(false);
const [view, setView] = useState<ViewMode>(() => { const [view, setView] = useState<ViewMode>(() => {
@@ -161,12 +162,13 @@ export default function App() {
localStorage.setItem("flighttube.browser", browser); localStorage.setItem("flighttube.browser", browser);
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0"); localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0"); localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
localStorage.setItem("flighttube.autoplayNext", autoplayNext ? "1" : "0");
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0"); localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
} catch { } catch {
/* storage blocked */ /* storage blocked */
} }
}, [view, quality, bulkLimit, streamQuality, subLang, subStyle, browser, downloadedOnly, }, [view, quality, bulkLimit, streamQuality, subLang, subStyle, browser, downloadedOnly,
hideShorts, sidebarHidden]); hideShorts, autoplayNext, sidebarHidden]);
// The language a download embeds. Like the player's fetch, this does not // The language a download embeds. Like the player's fetch, this does not
// depend on the on/off preference: that says what is shown, and a file // depend on the on/off preference: that says what is shown, and a file
@@ -624,6 +626,7 @@ export default function App() {
onSubLang={setSubLang} onSubLang={setSubLang}
subStyle={subStyle} subStyle={subStyle}
onSubStyle={setSubStyle} onSubStyle={setSubStyle}
autoplayNext={autoplayNext}
onOpenChannel={() => { onOpenChannel={() => {
setChannelId(playing.item.channel_id); setChannelId(playing.item.channel_id);
setSearch(""); setSearch("");
@@ -683,6 +686,8 @@ export default function App() {
onSubLang={setSubLang} onSubLang={setSubLang}
hideShorts={hideShorts} hideShorts={hideShorts}
onHideShorts={setHideShorts} onHideShorts={setHideShorts}
autoplayNext={autoplayNext}
onAutoplayNext={setAutoplayNext}
browser={browser} browser={browser}
onBrowser={setBrowser} onBrowser={setBrowser}
onError={setFailure} onError={setFailure}
+9 -1
View File
@@ -27,6 +27,8 @@ interface Props {
onSubStyle: (s: SubStyle) => void; onSubStyle: (s: SubStyle) => void;
/** Leaves the player for this video's channel. */ /** Leaves the player for this video's channel. */
onOpenChannel: () => void; onOpenChannel: () => void;
/** Roll straight on to the next video when this one ends. */
autoplayNext: boolean;
/** Persists a subtitle choice made from the transport bar. */ /** Persists a subtitle choice made from the transport bar. */
onSubLang: (l: string) => void; onSubLang: (l: string) => void;
/** Position in the current feed, for the "3 of 180" readout. */ /** Position in the current feed, for the "3 of 180" readout. */
@@ -122,7 +124,7 @@ function placeCues(vtt: string, line: number | null): string {
export default function Player({ export default function Player({
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext,
index, total, titleBarInset, index, total, titleBarInset,
}: Props) { }: Props) {
const streaming = path === null; const streaming = path === null;
@@ -457,6 +459,12 @@ export default function Player({
onTimeUpdate={onTimeUpdate} onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMetadata} onLoadedMetadata={onLoadedMetadata}
onPause={persist} onPause={persist}
onEnded={() => {
persist();
// Only where there is somewhere to go: the last video in the
// list simply stops.
if (autoplayNext) onNext?.();
}}
onLoadStart={() => setBuffering(true)} onLoadStart={() => setBuffering(true)}
onWaiting={() => setBuffering(true)} onWaiting={() => setBuffering(true)}
onStalled={() => setBuffering(true)} onStalled={() => setBuffering(true)}
+21
View File
@@ -30,6 +30,8 @@ interface Props {
onSubLang: (l: string) => void; onSubLang: (l: string) => void;
hideShorts: boolean; hideShorts: boolean;
onHideShorts: (v: boolean) => void; onHideShorts: (v: boolean) => void;
autoplayNext: boolean;
onAutoplayNext: (v: boolean) => void;
browser: string; browser: string;
onBrowser: (b: string) => void; onBrowser: (b: string) => void;
onError: (message: string) => void; onError: (message: string) => void;
@@ -58,6 +60,7 @@ export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality, onClose, onImported, appearance, onAppearance, quality, onQuality,
bulkLimit, onBulkLimit, bulkLimit, onBulkLimit,
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts, streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
autoplayNext, onAutoplayNext,
browser, onBrowser, onError, browser, onBrowser, onError,
}: Props) { }: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null); const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
@@ -301,6 +304,24 @@ export default function Settings({
</p> </p>
</section> </section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Playback</SectionHeading>
<label className="mt-2 flex cursor-pointer items-center justify-between gap-3">
<span className={LABEL}>Play next automatically</span>
<input
type="checkbox"
checked={autoplayNext}
onChange={(e) => onAutoplayNext(e.target.checked)}
className="size-4 cursor-pointer accent-sky-500"
/>
</label>
<p className={`mt-2 ${HELP}`}>
When a video ends, the next one in the list starts on its own. Meant for
offline viewing, where it plays through your downloads one after another;
online it will stream the next video, and it stops at the end of the list.
</p>
</section>
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800"> <section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<SectionHeading>Sign in to YouTube</SectionHeading> <SectionHeading>Sign in to YouTube</SectionHeading>
<p className={`mt-1.5 ${HELP}`}> <p className={`mt-1.5 ${HELP}`}>