feat: remove every subscription from Settings, and reopen the last video

Remove all sits beside Import in Settings, since emptying the list is
the other half of replacing it. It asks first and counts what goes:
210 channels, 3339 videos, 50 downloaded files, in those words. Nothing
changes on YouTube, and the confirmation says so — importing or reading
the list again brings it all back.

It takes the same path an import does when nothing survives, so a video
saved on its own from the menu bar is left alone: its channel was never
a subscription, and this is about subscriptions.

The app also reopens whatever was playing when it last closed, if that
video is still in the feed and still playable — offline, one that was
streaming is not. The note is made while the player is open and dropped
when it is closed, so a restart only reopens something you were in the
middle of, never something you had deliberately finished with. Verified
both ways: quitting mid-video came back to it, closing the player first
came back to the feed.

The panel's Remove all downloads is gone; it was a misreading of the
request.
This commit is contained in:
Vincent Rozenberg
2026-09-04 02:03:46 +02:00
parent 35f14ab044
commit 22e31afc1d
7 changed files with 120 additions and 37 deletions
+24
View File
@@ -1737,6 +1737,30 @@ pub async fn import_scraped(
state.db.lock().await.replace_channels(&channels)
}
/// What clearing the subscription list would take with it.
#[tauri::command]
pub async fn preview_remove_all_subscriptions(
state: State<'_, AppState>,
) -> Result<ImportPreview, String> {
state.db.lock().await.preview_replace(&[])
}
/// Empties the subscription list, and the videos and files hanging off it.
///
/// The same path an import takes when nothing survives it, so a video saved on
/// its own from the menu bar is left alone here too: its channel was never a
/// subscription, and this is about subscriptions.
#[tauri::command]
pub async fn remove_all_subscriptions(state: State<'_, AppState>) -> Result<usize, String> {
let dropped = state.db.lock().await.paths_dropped_by_replace(&[])?;
for path in &dropped {
let _ = tokio::fs::remove_file(path).await;
}
let before = state.db.lock().await.list_channels()?.len();
state.db.lock().await.replace_channels(&[])?;
Ok(before)
}
/// What deleting one subscription would take with it.
#[derive(Serialize)]
pub struct RemovalPreview {
+2
View File
@@ -144,6 +144,8 @@ pub fn run() {
tray::quit_app,
commands::preview_scraped_import,
commands::import_scraped,
commands::preview_remove_all_subscriptions,
commands::remove_all_subscriptions,
commands::delete_channel,
commands::preview_delete_channel,
commands::set_download_defaults,
+1 -1
View File
@@ -357,7 +357,7 @@ pub fn quit_app(app: AppHandle) {
/// The panel's own size. Fixed, because it is a menu: it does not resize.
const PANEL_W: f64 = 304.0;
const PANEL_H: f64 = 296.0;
const PANEL_H: f64 = 252.0;
/// Opens the panel under the menu bar icon.
///
+43 -11
View File
@@ -318,8 +318,19 @@ export default function App() {
const openIndex = useCallback(
(i: number) => {
if (playableAt(i)) setPlayingIndex(i);
else setFailure("That video isn't downloaded, and you're offline.");
const at = playableAt(i);
if (!at) {
setFailure("That video isn't downloaded, and you're offline.");
return;
}
setPlayingIndex(i);
// Noted while it is open and forgotten when it is closed, so a restart
// only reopens something you were actually in the middle of.
try {
localStorage.setItem("flighttube.playing", at.item.id);
} catch {
/* storage blocked */
}
},
[playableAt],
);
@@ -359,15 +370,6 @@ export default function App() {
});
}, [quality, embedLang]);
// The menu bar can ask to clear the downloads; the answering is done here,
// by the same confirmation the toolbar uses.
useEffect(() => {
const un = listen("downloads:wipe-request", () => setConfirmWipe(true));
return () => {
void un.then((f) => f());
};
}, []);
// The menu bar reads the subscription list, but replacing what is here is
// not a thing to agree to in a panel that closes when you look away.
useEffect(() => {
@@ -445,6 +447,26 @@ export default function App() {
.catch((e) => setFailure(String(e)));
}, [removing, channelId, reload, say]);
// Reopen whatever was playing when the app last closed, once the feed has
// loaded and only if that video is still in it and still playable — offline,
// a video that was streaming is not.
const resumedOnce = useRef(false);
useEffect(() => {
if (resumedOnce.current || items.length === 0 || playingIndex != null) return;
resumedOnce.current = true;
let id: string | null = null;
try {
id = localStorage.getItem("flighttube.playing");
} catch {
/* storage blocked */
}
if (!id) return;
const at = items.findIndex((i) => i.id === id);
if (at >= 0 && playableAt(at)) setPlayingIndex(at);
// playableAt changes with every render; the guard above runs this once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items]);
// On launch, bring the library in line before the first ten-minute check —
// otherwise auto mode looks asleep for the first ten minutes. Waits for the
// channel list, since knowing which channels are subscriptions is what keeps
@@ -796,6 +818,11 @@ export default function App() {
)}
onClose={() => {
setPlayingIndex(null);
try {
localStorage.removeItem("flighttube.playing");
} catch {
/* storage blocked */
}
// Coming back from a video is the natural moment to pick up
// whatever has been posted since.
if (online && !refreshing) void doRefresh();
@@ -835,6 +862,11 @@ export default function App() {
reload();
say(`Imported ${n} subscription${n === 1 ? "" : "s"}`);
}}
onRemovedAll={(n) => {
setChannelId(null);
reload();
say(`Removed ${n} subscription${n === 1 ? "" : "s"}`);
}}
/>
)}
+6
View File
@@ -73,6 +73,12 @@ export const previewScrapedImport = (channels: ScrapeResult["channels"]) =>
export const importScraped = (channels: ScrapeResult["channels"]) =>
invoke<number>("import_scraped", { channels });
/** What emptying the subscription list would take with it. */
export const previewRemoveAllSubscriptions = () =>
invoke<ImportPreview>("preview_remove_all_subscriptions");
export const removeAllSubscriptions = () => invoke<number>("remove_all_subscriptions");
/** The menu bar panel's own actions. */
export const traySaveVideo = () => invoke<void>("tray_save_video");
export const showMainWindow = () => invoke<void>("show_main_window");
+44 -2
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import {
checkPrereqs, checkYtDlpUpdate, importTakeoutCsv, listBrowsers, pickLibraryFolder,
pickTakeoutFile, previewTakeoutImport, setCookieSource, testYoutube, updateYtDlp,
pickTakeoutFile, previewRemoveAllSubscriptions, previewTakeoutImport,
removeAllSubscriptions, setCookieSource, testYoutube, updateYtDlp,
} from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import {
@@ -18,6 +19,7 @@ import {
interface Props {
onClose: () => void;
onImported: (count: number) => void;
onRemovedAll: (count: number) => void;
appearance: Appearance;
onAppearance: (a: Appearance) => void;
quality: Quality;
@@ -57,7 +59,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
}
export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality,
onClose, onImported, onRemovedAll, appearance, onAppearance, quality, onQuality,
bulkLimit, onBulkLimit,
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
autoplayNext, onAutoplayNext,
@@ -65,6 +67,8 @@ export default function Settings({
}: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
// Emptying the list is confirmed against what it would actually remove.
const [wipe, setWipe] = useState<ImportPreview | null>(null);
const [busy, setBusy] = useState(false);
const [browsers, setBrowsers] = useState<Array<[string, string]>>([]);
const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null);
@@ -199,6 +203,17 @@ export default function Settings({
<button onClick={() => setGuide(true)} className={`${BTN} cursor-pointer`}>
How do I get the file?
</button>
<button
onClick={() =>
previewRemoveAllSubscriptions()
.then(setWipe)
.catch((e) => onError(String(e)))
}
className={`${BTN} cursor-pointer hover:border-red-500! hover:text-red-600!
dark:hover:border-red-500! dark:hover:text-red-400!`}
>
Remove all
</button>
</div>
</section>
@@ -439,6 +454,33 @@ export default function Settings({
</div>
</div>
{wipe && (
<Dialog
title="Remove every subscription?"
onCancel={() => setWipe(null)}
onConfirm={() => {
setWipe(null);
setBusy(true);
removeAllSubscriptions()
.then(onRemovedAll)
.catch((e) => onError(String(e)))
.finally(() => setBusy(false));
}}
confirmLabel="Remove all"
destructive
>
<p>
All <b>{wipe.removed_channels}</b> channels leave FlightTube, taking{" "}
<b>{wipe.removed_videos}</b> videos and <b>{wipe.removed_downloads}</b> downloaded
file{wipe.removed_downloads === 1 ? "" : "s"} with them.
</p>
<p className="mt-2">
Nothing changes on YouTube you stay subscribed there, and importing or reading
the list again brings everything back.
</p>
</Dialog>
)}
{guide && (
<Dialog title="Getting your subscriptions" onCancel={() => setGuide(false)} wide>
<p className={`mb-3 ${HELP}`}>
-23
View File
@@ -122,29 +122,6 @@ export default function TrayPanel() {
}
/>
<Row
label="Remove all downloads"
hint="Asks in the window first"
busy={busy === "wipe"}
onClick={() =>
run(
"wipe",
async () => {
await emitTo("main", "downloads:wipe-request", null);
await showMainWindow();
},
false,
)
}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h16M10 11v6M14 11v6" />
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
</svg>
}
/>
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
<Row