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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user