Compare commits

..
3 Commits
Author SHA1 Message Date
Vincent Rozenberg 22e31afc1d 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.
2026-09-04 02:03:46 +02:00
Vincent Rozenberg 35f14ab044 feat: fullscreen carries across videos, and stays out of the way
In fullscreen, Next now keeps you there and shows nothing but the
picture: one video after another, no arrows, no header, no counter.

Three things had to change for that. The player was keyed on the video,
so every Next rebuilt the stage — and the stage is what is fullscreen.
It now survives, resetting its own per-video state instead of being
thrown away to get it.

The cleanup that ran on every change of source called exitFullscreen
outright. It exists for the Picture-in-Picture leak, which does need
handling per video; leaving fullscreen and dropping the source belong
to leaving the player, and are now only done there.

And resolving a stream set the source to nothing first, which unmounted
the element mid-fullscreen. The outgoing video is paused in place
instead, and the source goes straight from one address to the next.

The edge arrows are gone in fullscreen. The transport bar stays — it is
playback, not navigation — and fades on its own as before.

Also: Remove all downloads in the menu bar panel, which asks in the
window rather than in a panel that closes when you look away; and the
JavaScript round-trip logging is gone now that the scrape is settled.

Verified in the running app: three videos in a row without leaving
fullscreen, chrome faded to nothing, and Remove all reaching the
window's own "Delete every download?" with 50 downloads left untouched.
2026-09-04 01:56:08 +02:00
Vincent Rozenberg 41d638b232 feat: read the subscription list off YouTube, and an app-shaped menu bar panel
Takeout is a snapshot you have to go and fetch. This reads the live list
from youtube.com/feed/channels in a browser already signed in.

It runs JavaScript in the page rather than fetching it here, because
the session cannot be borrowed: Chromium encrypts its cookie store with
a per-app Keychain key, and Arc's cookies read with Chrome's key came
back undecryptable — 1346 of 2196, the session cookies among them. The
list is also lazily loaded, so it has to be scrolled to the end, which
only the page can do. Measured against a hand-scrolled count: 208 both
ways.

The browser is brought to the front first. A background tab has its DOM
discarded, so it reports its address while having nothing on it, which
reads exactly like an empty subscription list.

The page gives handles, not channel ids. Most belong to channels already
known here and are matched by name, costing nothing; only the rest are
looked up, four at a time. That mattered more than it sounds: with the
name selector wrong every name came back empty, nothing matched, all 208
were looked up blind, and the result claimed 58 channels should be
deleted. With names read correctly it is 4 lookups and 0 deletions.
Names arriving doubled — "3D OCD 3D OCD", which reads fine and matches
nothing — are collapsed.

osascript prints a string result in source form, quoted with the
newlines escaped, so a list of rows arrived as one line that looked
like no rows at all. It is unquoted before parsing.

The menu bar item is now a window, not an NSMenu: the app's own type,
spacing and colours, hanging from the icon and closing on blur. Adding a
channel is gone from it — the sidebar already does that.

Replacing the subscription list is confirmed in the main window, never
in a panel that closes when you look away, and the confirmation counts
what would go before anything happens.

Verified end to end: 208 read, 4 looked up, 0 removed, 48 downloads
untouched.
2026-09-04 01:34:29 +02:00
14 changed files with 1179 additions and 153 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png"] } tauri = { version = "2", features = ["protocol-asset", "tray-icon", "image-png", "macos-private-api"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+7 -3
View File
@@ -1,9 +1,10 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window and the menu bar panel",
"windows": [ "windows": [
"main" "main",
"tray"
], ],
"permissions": [ "permissions": [
"core:default", "core:default",
@@ -11,6 +12,9 @@
"dialog:default", "dialog:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-is-fullscreen", "core:window:allow-is-fullscreen",
"notification:default" "notification:default",
"core:event:allow-emit-to",
"core:event:allow-emit",
"core:event:allow-listen"
] ]
} }
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen","notification:default"]}} {"default":{"identifier":"default","description":"Capability for the main window and the menu bar panel","local":true,"windows":["main","tray"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging","core:window:allow-is-fullscreen","notification:default","core:event:allow-emit-to","core:event:allow-emit","core:event:allow-listen"]}}
+148
View File
@@ -10,6 +10,7 @@ use crate::models::{
use crate::net; use crate::net;
use crate::playlist_server::PlaylistServer; use crate::playlist_server::PlaylistServer;
use crate::resolve; use crate::resolve;
use crate::subscriptions;
use crate::takeout; use crate::takeout;
use crate::thumbs; use crate::thumbs;
@@ -1613,6 +1614,153 @@ pub async fn set_download_defaults(
Ok(()) Ok(())
} }
/// Turns a scraped list into channels, and says what it could not resolve.
#[derive(Serialize)]
pub struct ScrapeResult {
pub channels: Vec<Channel>,
/// Names whose channel could not be identified, so the user knows the list
/// handed on is short rather than wondering later.
pub unresolved: Vec<String>,
pub looked_up: usize,
}
/// Reads the subscription list out of the browser and resolves it to channels.
///
/// The page gives handles, not channel ids. Most of them belong to channels
/// already known here, and those are matched by name — no request at all.
/// Only genuinely new ones are looked up, a few at a time, because a burst of
/// two hundred requests is how you earn an HTTP 429.
#[tauri::command]
pub async fn scrape_subscriptions(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ScrapeResult, String> {
let rows = crate::tray::scrape_subscriptions(&app).await?;
let known: HashMap<String, ChannelWithCount> = state
.db
.lock()
.await
.list_channels()?
.into_iter()
.map(|c| (subscriptions::name_key(&c.title), c))
.collect();
let mut channels: Vec<Channel> = Vec::new();
let mut to_look_up: Vec<(String, String)> = Vec::new();
for (href, name) in rows {
// A link that already carries the id costs nothing.
if let Some(id) = subscriptions::id_from_href(&href) {
channels.push(Channel { url: resolve::channel_url(&id), id, title: name });
continue;
}
match known.get(&subscriptions::name_key(&name)) {
Some(c) if !name.is_empty() => channels.push(Channel {
id: c.id.clone(),
title: name,
url: c.url.clone(),
}),
_ => to_look_up.push((href, name)),
}
}
let looked_up = to_look_up.len();
let mut unresolved = Vec::new();
// Four at a time: enough to finish a normal import quickly, gentle enough
// that YouTube does not start refusing.
let found = futures::stream::iter(to_look_up.into_iter().map(|(href, name)| {
let http = state.http.clone();
async move {
let url = subscriptions::handle_url(&href);
let page = match http
.get(&url)
.header("Accept-Language", "en-US,en;q=0.9")
.header("Cookie", "CONSENT=YES+1")
.send()
.await
{
Ok(r) => r.text().await.ok(),
Err(_) => None,
};
let resolved = page
.as_deref()
.and_then(resolve::parse_channel)
.map(|(id, parsed)| Channel {
url: resolve::channel_url(&id),
id,
// The page's own name beats a scraped one only when the
// scrape had none.
title: if name.is_empty() { parsed } else { name.clone() },
});
(name, href, resolved)
}
}))
.buffer_unordered(4)
.collect::<Vec<_>>()
.await;
for (name, href, resolved) in found {
match resolved {
Some(c) => channels.push(c),
None => unresolved.push(if name.is_empty() { href } else { name }),
}
}
if channels.is_empty() {
return Err("None of the channels on that page could be identified.".into());
}
Ok(ScrapeResult { channels, unresolved, looked_up })
}
/// What replacing the subscription list with a scraped one would change.
#[tauri::command]
pub async fn preview_scraped_import(
channels: Vec<Channel>,
state: State<'_, AppState>,
) -> Result<ImportPreview, String> {
state.db.lock().await.preview_replace(&channels)
}
/// Replaces the subscription list with a scraped one, exactly as a Takeout
/// import does, files and all.
#[tauri::command]
pub async fn import_scraped(
channels: Vec<Channel>,
state: State<'_, AppState>,
) -> Result<usize, String> {
let dropped = state.db.lock().await.paths_dropped_by_replace(&channels)?;
for p in &dropped {
let _ = tokio::fs::remove_file(p).await;
}
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. /// What deleting one subscription would take with it.
#[derive(Serialize)] #[derive(Serialize)]
pub struct RemovalPreview { pub struct RemovalPreview {
+10 -1
View File
@@ -6,6 +6,7 @@ pub mod models;
pub mod net; pub mod net;
pub mod playlist_server; pub mod playlist_server;
pub mod resolve; pub mod resolve;
pub mod subscriptions;
pub mod takeout; pub mod takeout;
pub mod thumbs; pub mod thumbs;
pub mod tray; pub mod tray;
@@ -85,7 +86,6 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.on_menu_event(tray::on_menu_event)
.setup(|app| { .setup(|app| {
let state = commands::build_state(&app.handle().clone())?; let state = commands::build_state(&app.handle().clone())?;
app.manage(state); app.manage(state);
@@ -137,6 +137,15 @@ pub fn run() {
commands::set_library_path, commands::set_library_path,
commands::open_external, commands::open_external,
commands::add_channel, commands::add_channel,
commands::scrape_subscriptions,
tray::tray_save_video,
tray::show_main_window,
tray::hide_panel,
tray::quit_app,
commands::preview_scraped_import,
commands::import_scraped,
commands::preview_remove_all_subscriptions,
commands::remove_all_subscriptions,
commands::delete_channel, commands::delete_channel,
commands::preview_delete_channel, commands::preview_delete_channel,
commands::set_download_defaults, commands::set_download_defaults,
+207
View File
@@ -0,0 +1,207 @@
//! Reading the subscription list out of a signed-in browser.
//!
//! The Takeout CSV is a snapshot you have to go and fetch. This reads the live
//! list from <https://www.youtube.com/feed/channels> in a browser that is
//! already signed in, which is both current and no trouble to repeat.
//!
//! It runs JavaScript in the page rather than fetching it here, for two
//! reasons. The page is signed-in-only, and the session cookie cannot be
//! borrowed: Chromium encrypts its cookie store with a per-app Keychain key, so
//! Arc's cookies read with Chrome's key come back undecryptable — measured at
//! 1346 of 2196, the session cookies among them. And the list is lazily loaded,
//! so it has to be scrolled to the end, which only the page itself can do.
/// The channels a page carries, as (handle, name).
pub type Scraped = Vec<(String, String)>;
/// Collects what is currently rendered. Handles rather than channel ids: the
/// page carries no ids at all — no `ytInitialData`, and every link is a
/// `/@handle`.
///
/// The name comes from `#text`. `#title` does not exist on this page and
/// `#channel-title` wraps a second copy of the name, which is worth knowing
/// because a wrong name is not a visible failure — it just means nothing
/// matches what is already stored, and every channel gets looked up over the
/// network instead.
pub const EXTRACT_JS: &str = r#"(function(){
var els = document.querySelectorAll('ytd-channel-renderer');
var rows = [];
for (var i = 0; i < els.length; i++) {
var a = els[i].querySelector('a[href^="/@"], a[href^="/channel/"]');
if (!a) continue;
var t = els[i].querySelector('#text, #channel-title');
var name = t ? t.textContent.replace(/\s+/g, ' ').trim() : '';
rows.push(a.getAttribute('href') + '\t' + name);
}
return rows.join('\n');
})()"#;
/// One scroll to the end, so the next batch loads.
pub const SCROLL_JS: &str = "window.scrollTo(0, document.documentElement.scrollHeight); \
document.querySelectorAll('ytd-channel-renderer').length";
/// True for the page this can read.
pub fn is_subscriptions_page(url: &str) -> bool {
let u = url.to_ascii_lowercase();
u.contains("youtube.com/feed/channels")
}
/// Parses what the page handed back.
///
/// A name is allowed to be empty — the handle is what identifies the channel,
/// and a nameless row is still a subscription.
pub fn parse_rows(raw: &str) -> Scraped {
let mut out: Scraped = Vec::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let (href, name) = match line.split_once('\t') {
Some((h, n)) => (h.trim(), n.trim()),
None => (line, ""),
};
if !href.starts_with("/@") && !href.starts_with("/channel/") {
continue;
}
// The same channel can be rendered twice while the list re-flows.
if out.iter().any(|(h, _)| h == href) {
continue;
}
out.push((href.to_string(), collapse_doubled(name)));
}
out
}
/// Collapses a name that arrived twice over.
///
/// YouTube's markup nests the channel name inside an element that also holds
/// it, so a slightly wrong selector yields "3D OCD 3D OCD". That reads fine to
/// a person and matches nothing at all.
pub fn collapse_doubled(name: &str) -> String {
let n = name.split_whitespace().collect::<Vec<_>>().join(" ");
if n.is_empty() {
return n;
}
if n.len() % 2 == 1 {
let mid = n.len() / 2;
if n.is_char_boundary(mid) && n.is_char_boundary(mid + 1) && &n[mid..mid + 1] == " " {
let (a, b) = (&n[..mid], &n[mid + 1..]);
if a == b {
return a.to_string();
}
}
}
n
}
/// The full address for a scraped handle.
pub fn handle_url(href: &str) -> String {
format!("https://www.youtube.com{href}")
}
/// The channel id, when the page gave one outright rather than a handle.
pub fn id_from_href(href: &str) -> Option<String> {
let id = href.strip_prefix("/channel/")?;
let id = id.split('/').next().unwrap_or(id);
(id.starts_with("UC") && id.len() == 24).then(|| id.to_string())
}
/// Names as YouTube renders them and as a CSV stored them differ in case,
/// spacing and stray whitespace; this is what they are compared on.
pub fn name_key(name: &str) -> String {
name.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_subscriptions_page_qualifies() {
assert!(is_subscriptions_page("https://www.youtube.com/feed/channels"));
assert!(is_subscriptions_page("https://youtube.com/feed/channels?flow=grid"));
assert!(!is_subscriptions_page("https://www.youtube.com/feed/subscriptions"));
assert!(!is_subscriptions_page("https://www.youtube.com/watch?v=abc"));
}
#[test]
fn rows_come_back_as_handle_and_name() {
let raw = "/@3DOCD\t3D OCD\n/@mkbhd\tMarques Brownlee\n";
assert_eq!(
parse_rows(raw),
vec![
("/@3DOCD".to_string(), "3D OCD".to_string()),
("/@mkbhd".to_string(), "Marques Brownlee".to_string()),
]
);
}
#[test]
fn junk_rows_are_dropped_and_repeats_collapse() {
// A re-flowing list can render the same channel twice, and anything
// that is not a channel link is not a subscription.
let raw = "/@a\tA\n\n/watch?v=x\tNot a channel\n/@a\tA\n/@b\t\n";
assert_eq!(
parse_rows(raw),
vec![
("/@a".to_string(), "A".to_string()),
// A nameless row is still a subscription; the handle identifies it.
("/@b".to_string(), String::new()),
]
);
}
#[test]
fn a_channel_link_gives_its_id_without_a_lookup() {
assert_eq!(
id_from_href("/channel/UCBJycsmduvYEL83R_U4JriQ").as_deref(),
Some("UCBJycsmduvYEL83R_U4JriQ")
);
assert_eq!(id_from_href("/@mkbhd"), None);
assert_eq!(id_from_href("/channel/UCshort"), None);
}
#[test]
fn a_name_that_arrived_twice_is_collapsed() {
assert_eq!(collapse_doubled("3D OCD 3D OCD"), "3D OCD");
assert_eq!(collapse_doubled(" Linus Tech Tips "), "Linus Tech Tips");
// Not everything that repeats is doubled.
assert_eq!(collapse_doubled("Spanian 2"), "Spanian 2");
assert_eq!(collapse_doubled("Corridor Crew"), "Corridor Crew");
assert_eq!(collapse_doubled(""), "");
}
#[test]
fn doubled_names_survive_parsing() {
assert_eq!(
parse_rows("/@3DOCD\t3D OCD 3D OCD\n"),
vec![("/@3DOCD".to_string(), "3D OCD".to_string())]
);
}
#[test]
fn handles_become_addresses() {
assert_eq!(handle_url("/@mkbhd"), "https://www.youtube.com/@mkbhd");
}
#[test]
fn names_match_across_spacing_and_case() {
// What the page renders vs what a Takeout CSV stored.
assert_eq!(name_key(" Linus Tech Tips "), name_key("linus tech tips"));
assert_ne!(name_key("Corridor Crew"), name_key("Corridor Digital"));
}
#[test]
fn the_extraction_script_asks_for_what_the_page_has() {
// Handles and names, since the page carries no channel ids.
assert!(EXTRACT_JS.contains("ytd-channel-renderer"));
assert!(EXTRACT_JS.contains(r#"a[href^="/@"]"#));
// #text holds the name once; #title does not exist on this page.
assert!(EXTRACT_JS.contains("'#text, #channel-title'"));
assert!(SCROLL_JS.contains("scrollTo"));
}
}
+244 -73
View File
@@ -10,16 +10,14 @@
use crate::commands::{self, AppState}; use crate::commands::{self, AppState};
use crate::resolve; use crate::resolve;
use crate::subscriptions;
use std::io::Write; use std::io::Write;
use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::tray::TrayIconBuilder; use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
pub const SAVE_VIDEO: &str = "tray.save_video"; /// The panel window's label.
pub const ADD_CHANNEL: &str = "tray.add_channel"; pub const PANEL: &str = "tray";
pub const SHOW: &str = "tray.show";
pub const STATUS: &str = "tray.status";
/// A running account of what the menu bar did, next to the database. /// A running account of what the menu bar did, next to the database.
/// ///
@@ -42,21 +40,10 @@ fn log(app: &AppHandle, line: &str) {
} }
} }
/// The menu's own first line, kept so its text can be rewritten. A TrayIcon
/// does not hand its menu back, so the item is held here.
pub struct StatusItem(pub MenuItem<tauri::Wry>);
/// The tray icon itself, held for as long as the app runs. Dropping it takes /// The tray icon itself, held for as long as the app runs. Dropping it takes
/// the icon out of the menu bar. /// the icon out of the menu bar.
pub struct Tray(#[allow(dead_code)] pub tauri::tray::TrayIcon<tauri::Wry>); pub struct Tray(#[allow(dead_code)] pub tauri::tray::TrayIcon<tauri::Wry>);
/// Leaves the answer on the menu itself, which no permission can suppress.
fn set_status(app: &AppHandle, text: &str) {
if let Some(item) = app.try_state::<StatusItem>() {
let _ = item.0.set_text(text);
}
}
/// Browsers worth asking, in the order they are asked. All but Safari answer /// Browsers worth asking, in the order they are asked. All but Safari answer
/// the same Chromium-flavoured AppleScript. /// the same Chromium-flavoured AppleScript.
const CHROMIUM: [&str; 6] = [ const CHROMIUM: [&str; 6] = [
@@ -137,14 +124,123 @@ async fn browser_youtube_url(app: &AppHandle) -> Result<String, String> {
Err("No YouTube page open in a browser.".into()) Err("No YouTube page open in a browser.".into())
} }
/// Runs JavaScript in a browser's active tab.
///
/// Arc allows this out of the box. Chrome and its relatives ship with it off,
/// and say so in the error, which is passed straight back rather than being
/// flattened into "something went wrong".
pub async fn run_js(browser: &str, js: &str) -> Result<String, String> {
let safari = browser == "Safari";
let script = if safari {
format!("tell application \"Safari\" to return (do JavaScript {} in front document)", quote(js))
} else {
format!(
"tell application {} to return (execute front window's active tab javascript {})",
quote(browser),
quote(js)
)
};
let out = tokio::process::Command::new("/usr/bin/osascript")
.arg("-e")
.arg(script)
.output()
.await
.map_err(|e| format!("Could not reach {browser}: {e}"))?;
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("Executing JavaScript through AppleScript is turned off") {
return Err(format!(
"{browser} will not run JavaScript for another app. Turn it on in {browser} under \
View Developer Allow JavaScript from Apple Events, then try again. Arc allows \
it already."
));
}
if !out.status.success() {
return Err(format!("{browser} refused: {}", stderr.trim()));
}
Ok(applescript_unquote(&String::from_utf8_lossy(&out.stdout)))
}
/// AppleScript string literal.
fn quote(s: &str) -> String {
applescript_string(s)
}
/// The browser showing the subscriptions page, if one is.
async fn browser_on_subscriptions(app: &AppHandle) -> Result<String, String> {
for (browser, safari_style) in CHROMIUM
.iter()
.map(|b| (*b, false))
.chain(std::iter::once(("Safari", true)))
{
let Ok(out) = tokio::process::Command::new("/usr/bin/osascript")
.arg("-e")
.arg(url_script(browser, safari_style))
.output()
.await
else {
continue;
};
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if subscriptions::is_subscriptions_page(&url) {
log(app, &format!("subscriptions page open in {browser}"));
return Ok(browser.to_string());
}
}
Err("Open https://www.youtube.com/feed/channels in your browser first, \
then try again."
.into())
}
/// Scrolls the subscriptions page to its end and reads the list off it.
///
/// The list loads a batch at a time, so this scrolls until the count stops
/// growing rather than trusting one read — a first look sees about half.
pub async fn scrape_subscriptions(app: &AppHandle) -> Result<subscriptions::Scraped, String> {
let browser = browser_on_subscriptions(app).await?;
let mut last = 0usize;
let mut settled = 0;
for round in 0..40 {
let n = run_js(&browser, subscriptions::SCROLL_JS)
.await?
.parse::<usize>()
.unwrap_or(0);
// Nothing there yet only means the tab is still waking up.
if n == 0 && round < 8 {
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
continue;
}
if n == last {
settled += 1;
if settled >= 3 {
break;
}
} else {
settled = 0;
}
last = n;
tokio::time::sleep(std::time::Duration::from_millis(900)).await;
}
let raw = run_js(&browser, subscriptions::EXTRACT_JS).await?;
let rows = subscriptions::parse_rows(&raw);
log(app, &format!("scraped {} channels from {browser}", rows.len()));
if rows.is_empty() {
return Err("That page has no channels on it. Is it the subscriptions page, \
and are you signed in?"
.into());
}
Ok(rows)
}
/// The answer, delivered without the window coming forward. /// The answer, delivered without the window coming forward.
/// ///
/// Three ways, because the first two can fail silently: a notification from the /// Two ways, because either can fail silently: a notification from the app
/// app itself, the same through AppleScript if the plugin is unavailable, and /// itself, and the same through AppleScript if the plugin is unavailable.
/// the menu's own first line, which always survives. /// Both are also written to the log beside the database.
async fn notify(app: &AppHandle, text: &str) { async fn notify(app: &AppHandle, text: &str) {
log(app, text); log(app, text);
set_status(app, text);
if app if app
.notification() .notification()
@@ -167,13 +263,42 @@ async fn notify(app: &AppHandle, text: &str) {
.await; .await;
} }
/// Undoes how osascript prints a string result.
///
/// It prints in source form — wrapped in quotes with the newlines and tabs
/// escaped — so a list of rows arrives as one line that looks nothing like
/// rows. Numbers and bare words come back untouched and pass straight through.
fn applescript_unquote(out: &str) -> String {
let out = out.trim();
let Some(inner) = out.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
return out.to_string();
};
let mut s = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
s.push(c);
continue;
}
match chars.next() {
Some('n') => s.push('\n'),
Some('t') => s.push('\t'),
Some('r') => s.push('\r'),
Some(other) => s.push(other),
None => break,
}
}
s
}
/// Quotes a string for AppleScript. Backslashes first, or the escaping of the /// Quotes a string for AppleScript. Backslashes first, or the escaping of the
/// quotes gets undone. /// quotes gets undone.
fn applescript_string(s: &str) -> String { fn applescript_string(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} }
async fn handle_save_video(app: AppHandle) { #[tauri::command]
pub async fn tray_save_video(app: AppHandle) {
let app = &app; let app = &app;
log(app, "menu: save video"); log(app, "menu: save video");
let url = match browser_youtube_url(app).await { let url = match browser_youtube_url(app).await {
@@ -204,24 +329,12 @@ async fn handle_save_video(app: AppHandle) {
}); });
} }
async fn handle_add_channel(app: AppHandle) { /// Brings the window forward, and closes the panel behind it.
let app = &app; #[tauri::command]
log(app, "menu: add channel"); pub fn show_main_window(app: AppHandle) {
let url = match browser_youtube_url(app).await { if let Some(p) = app.get_webview_window(PANEL) {
Ok(u) => u, let _ = p.hide();
Err(e) => return notify(app, &e).await,
};
let state = app.state::<AppState>();
match commands::add_channel(url, app.clone(), state).await {
Ok(title) => {
let _ = app.emit("feed:changed", ());
notify(app, &format!("Subscribed to {title}")).await;
}
Err(e) => notify(app, &e).await,
} }
}
fn show_window(app: &AppHandle) {
if let Some(w) = app.get_webview_window("main") { if let Some(w) = app.get_webview_window("main") {
let _ = w.show(); let _ = w.show();
let _ = w.unminimize(); let _ = w.unminimize();
@@ -229,36 +342,74 @@ fn show_window(app: &AppHandle) {
} }
} }
pub fn on_menu_event(app: &AppHandle, event: MenuEvent) { /// Closes the panel without doing anything else.
let app = app.clone(); #[tauri::command]
match event.id().as_ref() { pub fn hide_panel(app: AppHandle) {
SAVE_VIDEO => { if let Some(p) = app.get_webview_window(PANEL) {
tauri::async_runtime::spawn(handle_save_video(app)); let _ = p.hide();
}
ADD_CHANNEL => {
tauri::async_runtime::spawn(handle_add_channel(app));
}
SHOW => show_window(&app),
_ => {}
} }
} }
#[tauri::command]
pub fn quit_app(app: AppHandle) {
app.exit(0);
}
/// 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 = 252.0;
/// Opens the panel under the menu bar icon.
///
/// A native menu would look like every other menu bar item; this is the app's
/// own window, styled like the rest of it, positioned to hang from the icon.
fn show_panel(app: &AppHandle, icon: tauri::Rect) {
let Some(win) = app.get_webview_window(PANEL) else { return };
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
return;
}
// The icon's rect is in physical pixels; the window is placed in the same
// space, centred under the icon and just below the menu bar.
if let (tauri::Position::Physical(pos), tauri::Size::Physical(size)) =
(icon.position, icon.size)
{
let scale = win.scale_factor().unwrap_or(1.0);
let w = PANEL_W * scale;
let x = pos.x as f64 + size.width as f64 / 2.0 - w / 2.0;
let y = pos.y as f64 + size.height as f64 + 6.0 * scale;
let _ = win.set_position(tauri::PhysicalPosition::new(x.max(8.0), y));
}
let _ = win.show();
let _ = win.set_focus();
}
pub fn build(app: &AppHandle) -> tauri::Result<()> { pub fn build(app: &AppHandle) -> tauri::Result<()> {
// Disabled: it reports, it does not act. // Hidden until the icon is clicked. Undecorated and transparent so the
let status = MenuItem::with_id(app, STATUS, "FlightTube", false, None::<&str>)?; // page can draw its own rounded, shadowed card.
let menu = Menu::with_items( let panel = WebviewWindowBuilder::new(app, PANEL, WebviewUrl::App("index.html#tray".into()))
app, .title("FlightTube")
&[ .inner_size(PANEL_W, PANEL_H)
&status, .decorations(false)
&PredefinedMenuItem::separator(app)?, .transparent(true)
&MenuItem::with_id(app, SAVE_VIDEO, "Download the video I'm watching", true, None::<&str>)?, .resizable(false)
&MenuItem::with_id(app, ADD_CHANNEL, "Add the channel I'm watching", true, None::<&str>)?, .always_on_top(true)
&PredefinedMenuItem::separator(app)?, .skip_taskbar(true)
&MenuItem::with_id(app, SHOW, "Open FlightTube", true, None::<&str>)?, .visible(false)
&PredefinedMenuItem::separator(app)?, .focused(false)
&PredefinedMenuItem::quit(app, Some("Quit FlightTube"))?, .build()?;
],
)?; // Clicking away closes it, as a menu does.
{
let handle = panel.clone();
panel.on_window_event(move |e| {
if let tauri::WindowEvent::Focused(false) = e {
let _ = handle.hide();
}
});
}
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?; let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
@@ -267,17 +418,22 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> {
// A template image takes the menu bar's own colour, light or dark. // A template image takes the menu bar's own colour, light or dark.
.icon_as_template(true) .icon_as_template(true)
.tooltip("FlightTube") .tooltip("FlightTube")
.menu(&menu) // No menu at all: the click opens the panel instead.
// The menu is the whole point; a left click should open it too. .show_menu_on_left_click(false)
.show_menu_on_left_click(true) .on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: MouseButton::Left, button_state, rect, .. } = event
{
if button_state == MouseButtonState::Up {
show_panel(tray.app_handle(), rect);
}
}
})
.build(app)?; .build(app)?;
// TrayIcon is reference-counted and "the icon is removed when the last // TrayIcon is reference-counted and "the icon is removed when the last
// instance is dropped" letting the handle fall out of scope here created // instance is dropped", so letting the handle fall out of scope here would
// the item and destroyed it in the same breath, which is exactly as // create the item and destroy it in the same breath.
// invisible as never creating it.
app.manage(Tray(tray)); app.manage(Tray(tray));
app.manage(StatusItem(status));
Ok(()) Ok(())
} }
@@ -308,6 +464,21 @@ mod tests {
assert!(url_script("Arc", false).contains("URL of active tab of front window")); assert!(url_script("Arc", false).contains("URL of active tab of front window"));
} }
#[test]
fn a_printed_string_result_is_unwrapped_into_real_rows() {
// What osascript actually prints for a multi-line string result: one
// quoted line with the newlines and tabs escaped.
let printed = r#""/@a\tA\n/@b\tB""#;
assert_eq!(applescript_unquote(printed), "/@a\tA\n/@b\tB");
assert_eq!(applescript_unquote(printed).lines().count(), 2);
}
#[test]
fn a_number_result_passes_through_untouched() {
assert_eq!(applescript_unquote("197\n"), "197");
assert_eq!(applescript_unquote(""), "");
}
#[test] #[test]
fn notification_text_survives_quotes_and_backslashes() { fn notification_text_survives_quotes_and_backslashes() {
assert_eq!(applescript_string(r#"a "b" c"#), r#""a \"b\" c""#); assert_eq!(applescript_string(r#"a "b" c"#), r#""a \"b\" c""#);
+5 -2
View File
@@ -32,11 +32,14 @@
"$APPLOCALDATA/**" "$APPLOCALDATA/**"
] ]
} }
} },
"macOSPrivateApi": true
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["app"], "targets": [
"app"
],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+110 -7
View File
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { import {
cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel, cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel,
deleteDownload, downloadVideo, interruptedDownloads, listFeed, openExternal, deleteDownload, downloadVideo, importScraped, interruptedDownloads, listFeed,
previewDeleteChannel, openExternal, previewDeleteChannel, previewScrapedImport,
setDownloadDefaults, type RemovalPreview, setDownloadDefaults, type RemovalPreview, type ScrapeResult,
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
} from "./api"; } from "./api";
import AddChannel from "./components/AddChannel"; import AddChannel from "./components/AddChannel";
@@ -23,7 +23,8 @@ import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
import { import {
BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, DEFAULT_SUB_STYLE, BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, DEFAULT_SUB_STYLE,
QUALITIES, STREAM_QUALITIES, SUB_LANGS, QUALITIES, STREAM_QUALITIES, SUB_LANGS,
type FeedFilter, type FeedItem, type Quality, type RefreshProgress, type SubStyle, type FeedFilter, type FeedItem, type ImportPreview, type Quality,
type RefreshProgress, type SubStyle,
} from "./types"; } from "./types";
const TOAST_MS = 2400; const TOAST_MS = 2400;
@@ -53,6 +54,8 @@ export default function App() {
const [autoplayNext, setAutoplayNext] = useState(() => remembered("autoplayNext")); const [autoplayNext, setAutoplayNext] = useState(() => remembered("autoplayNext"));
const [autoMode, setAutoMode] = useState(() => remembered("autoMode")); const [autoMode, setAutoMode] = useState(() => remembered("autoMode"));
const [confirmAuto, setConfirmAuto] = useState<{ fetch: number; remove: number } | null>(null); const [confirmAuto, setConfirmAuto] = useState<{ fetch: number; remove: number } | null>(null);
// A subscription list read out of the browser, waiting to be confirmed.
const [scraped, setScraped] = useState<(ScrapeResult & { preview: ImportPreview }) | null>(null);
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>(() => {
@@ -315,8 +318,19 @@ export default function App() {
const openIndex = useCallback( const openIndex = useCallback(
(i: number) => { (i: number) => {
if (playableAt(i)) setPlayingIndex(i); const at = playableAt(i);
else setFailure("That video isn't downloaded, and you're offline."); 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], [playableAt],
); );
@@ -356,6 +370,19 @@ export default function App() {
}); });
}, [quality, embedLang]); }, [quality, embedLang]);
// 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(() => {
const un = listen<ScrapeResult>("subs:scraped", (e) => {
previewScrapedImport(e.payload.channels)
.then((preview) => setScraped({ ...e.payload, preview }))
.catch((err) => setFailure(String(err)));
});
return () => {
void un.then((f) => f());
};
}, []);
// Anything saved from the menu bar arrives behind the app's back. // Anything saved from the menu bar arrives behind the app's back.
useEffect(() => { useEffect(() => {
const un = listen("feed:changed", () => reload()); const un = listen("feed:changed", () => reload());
@@ -420,6 +447,26 @@ export default function App() {
.catch((e) => setFailure(String(e))); .catch((e) => setFailure(String(e)));
}, [removing, channelId, reload, say]); }, [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 — // 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 // otherwise auto mode looks asleep for the first ten minutes. Waits for the
// channel list, since knowing which channels are subscriptions is what keeps // channel list, since knowing which channels are subscriptions is what keeps
@@ -726,9 +773,12 @@ export default function App() {
</main> </main>
</div> </div>
{/* The player is deliberately not keyed on the video. Remounting would
build a new stage element, and the stage is what is fullscreen — so
every Next dropped out of fullscreen. It resets its own per-video
state instead. */}
{playing && playingIndex != null && ( {playing && playingIndex != null && (
<Player <Player
key={playing.item.id}
item={playing.item} item={playing.item}
path={playing.path} path={playing.path}
index={playingIndex} index={playingIndex}
@@ -768,6 +818,11 @@ export default function App() {
)} )}
onClose={() => { onClose={() => {
setPlayingIndex(null); setPlayingIndex(null);
try {
localStorage.removeItem("flighttube.playing");
} catch {
/* storage blocked */
}
// Coming back from a video is the natural moment to pick up // Coming back from a video is the natural moment to pick up
// whatever has been posted since. // whatever has been posted since.
if (online && !refreshing) void doRefresh(); if (online && !refreshing) void doRefresh();
@@ -807,6 +862,11 @@ export default function App() {
reload(); reload();
say(`Imported ${n} subscription${n === 1 ? "" : "s"}`); say(`Imported ${n} subscription${n === 1 ? "" : "s"}`);
}} }}
onRemovedAll={(n) => {
setChannelId(null);
reload();
say(`Removed ${n} subscription${n === 1 ? "" : "s"}`);
}}
/> />
)} )}
@@ -826,6 +886,49 @@ export default function App() {
</Dialog> </Dialog>
)} )}
{scraped && (
<Dialog
title="Replace your subscriptions?"
onCancel={() => setScraped(null)}
onConfirm={() => {
const list = scraped.channels;
setScraped(null);
importScraped(list)
.then((n) => {
reload();
say(`Imported ${n} subscriptions`);
void doRefresh();
})
.catch((e) => setFailure(String(e)));
}}
confirmLabel="Replace"
destructive={scraped.preview.removed_channels > 0}
wide
>
<p>
Read <b>{scraped.channels.length}</b> channels off YouTube
{scraped.looked_up > 0 && <> ({scraped.looked_up} looked up by hand)</>}.
</p>
<p className="mt-2">
This replaces the list here. <b>{scraped.preview.removed_channels}</b> channel
{scraped.preview.removed_channels === 1 ? "" : "s"} would go, taking{" "}
<b>{scraped.preview.removed_videos}</b> videos and{" "}
<b>{scraped.preview.removed_downloads}</b> downloaded file
{scraped.preview.removed_downloads === 1 ? "" : "s"} with them.
</p>
{scraped.unresolved.length > 0 && (
<p className="mt-2">
<b>{scraped.unresolved.length}</b> could not be identified and are left out:{" "}
<span className="text-slate-500 dark:text-slate-400">
{scraped.unresolved.slice(0, 8).join(", ")}
{scraped.unresolved.length > 8 && ` and ${scraped.unresolved.length - 8} more`}
</span>
. Add those by hand if you want them.
</p>
)}
</Dialog>
)}
{confirmAuto && ( {confirmAuto && (
<Dialog <Dialog
title="Turn on auto mode?" title="Turn on auto mode?"
+27
View File
@@ -58,6 +58,33 @@ export const checkYtDlpUpdate = () => invoke<UpdateStatus>("check_yt_dlp_update"
export const updateYtDlp = () => invoke<string>("update_yt_dlp"); export const updateYtDlp = () => invoke<string>("update_yt_dlp");
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */ /** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
export interface ScrapeResult {
channels: Array<{ id: string; title: string; url: string }>;
unresolved: string[];
looked_up: number;
}
/** Reads the subscription list out of a signed-in browser page. */
export const scrapeSubscriptions = () => invoke<ScrapeResult>("scrape_subscriptions");
export const previewScrapedImport = (channels: ScrapeResult["channels"]) =>
invoke<ImportPreview>("preview_scraped_import", { 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");
export const hidePanel = () => invoke<void>("hide_panel");
export const quitApp = () => invoke<void>("quit_app");
/** Adds one channel from any YouTube link. Returns its title. */ /** Adds one channel from any YouTube link. Returns its title. */
export const addChannel = (url: string) => invoke<string>("add_channel", { url }); export const addChannel = (url: string) => invoke<string>("add_channel", { url });
+212 -60
View File
@@ -1,9 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream, embeddedSubtitles,
fetchSubtitles,
fileUrl,
listSubtitles,
openExternal,
resolveStream,
savePlayback, savePlayback,
} from "../api"; } from "../api";
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types"; import {
DEFAULT_SUB_LANG,
SUB_FONTS,
SUB_PLACES,
type FeedItem,
type SubStyle,
} from "../types";
import { compactViews, relativeTime, subtitleLabel } from "./format"; import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls"; import PlayerControls from "./PlayerControls";
import { Badge, BTN, Dialog, Spinner } from "./ui"; import { Badge, BTN, Dialog, Spinner } from "./ui";
@@ -39,20 +50,22 @@ interface Props {
} }
/** /**
* Releases a <video> completely. * Ends any Picture-in-Picture session on a <video>.
* *
* Detaching the element is not enough: WebKit keeps a Picture-in-Picture * Detaching the element is not enough: WebKit keeps the session (and its
* session (and its audio) running after the element leaves the DOM, so closing * audio) running after the element leaves the DOM, so stepping to the next
* the player or stepping to the next video would leave the previous one playing * video would leave the previous one playing with no way to stop it.
* with no way to stop it. Every exit path goes through here.
*/ */
function teardown(v: HTMLVideoElement | null) { function releasePiP(v: HTMLVideoElement | null) {
if (!v) return; if (!v) return;
// Safari's PiP is the non-standard presentation-mode API; the spec one is // Safari's PiP is the non-standard presentation-mode API; the spec one is
// tried too, since either may be the live implementation. // tried too, since either may be the live implementation.
const webkit = v as WebkitVideo; const webkit = v as WebkitVideo;
try { try {
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") { if (
webkit.webkitPresentationMode &&
webkit.webkitPresentationMode !== "inline"
) {
webkit.webkitSetPresentationMode?.("inline"); webkit.webkitSetPresentationMode?.("inline");
} }
} catch { } catch {
@@ -68,6 +81,18 @@ function teardown(v: HTMLVideoElement | null) {
} catch { } catch {
/* not supported here */ /* not supported here */
} }
}
/**
* Releases the element for good, on the way out of the player.
*
* Separate from the above because it also leaves fullscreen, and this used to
* run on every change of video: stepping to the next one dropped you out of
* fullscreen every time, which is the opposite of watching one after another.
*/
function teardown(v: HTMLVideoElement | null) {
if (!v) return;
releasePiP(v);
try { try {
if (document.fullscreenElement) void document.exitFullscreen(); if (document.fullscreenElement) void document.exitFullscreen();
} catch { } catch {
@@ -157,9 +182,24 @@ 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,
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext, path,
index, total, titleBarInset, onClose,
onDelete,
onPrev,
onNext,
onDownload,
downloading,
maxHeight,
subLang,
onSubLang,
subStyle,
onSubStyle,
onOpenChannel,
autoplayNext,
index,
total,
titleBarInset,
}: Props) { }: Props) {
const streaming = path === null; const streaming = path === null;
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null); const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
@@ -243,7 +283,8 @@ export default function Player({
// Placement is a WebVTT cue setting, not something CSS can reach, so it is // Placement is a WebVTT cue setting, not something CSS can reach, so it is
// written into the cues themselves. Re-cut whenever the choice changes. // written into the cues themselves. Re-cut whenever the choice changes.
useEffect(() => { useEffect(() => {
const line = SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null; const line =
SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null;
const urls: string[] = []; const urls: string[] = [];
setTracks( setTracks(
rawTracks.map(([lang, text]) => { rawTracks.map(([lang, text]) => {
@@ -259,6 +300,24 @@ export default function Player({
}; };
}, [rawTracks, subStyle.place]); }, [rawTracks, subStyle.place]);
// What a remount used to clear. Kept in one place so a new video starts as
// clean as it would have, without throwing the stage away to get there.
useEffect(() => {
setError(null);
setBuffering(true);
setShowDescription(false);
}, [item.id]);
// Fullscreen belongs to the stage, which now outlives the video. Whether it
// is on decides whether anything to click is shown at all.
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
const sync = () => setFullscreen(!!document.fullscreenElement);
sync();
document.addEventListener("fullscreenchange", sync);
return () => document.removeEventListener("fullscreenchange", sync);
}, []);
// Controls and edge arrows fade away while you are just watching. // Controls and edge arrows fade away while you are just watching.
const [chromeVisible, setChromeVisible] = useState(true); const [chromeVisible, setChromeVisible] = useState(true);
const hideTimer = useRef<number | undefined>(undefined); const hideTimer = useRef<number | undefined>(undefined);
@@ -279,8 +338,11 @@ export default function Player({
return; return;
} }
let cancelled = false; let cancelled = false;
setSrc(null);
setError(null); setError(null);
// Quiet the outgoing video, but leave it in place: clearing the source
// would unmount the element mid-fullscreen.
videoRef.current?.pause();
setBuffering(true);
resolveStream(item.id, maxHeight) resolveStream(item.id, maxHeight)
.then((u) => !cancelled && setSrc(u)) .then((u) => !cancelled && setSrc(u))
.catch((e) => !cancelled && setError(String(e))); .catch((e) => !cancelled && setError(String(e)));
@@ -289,7 +351,6 @@ export default function Player({
}; };
}, [item.id, path, maxHeight]); }, [item.id, path, maxHeight]);
const persist = useCallback(() => { const persist = useCallback(() => {
const v = videoRef.current; const v = videoRef.current;
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return; if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
@@ -305,9 +366,15 @@ export default function Player({
useEffect(() => { useEffect(() => {
const v = videoRef.current; const v = videoRef.current;
return () => teardown(v); return () => releasePiP(v);
}, [src]); }, [src]);
// Leaving the player is the only place the element is released outright.
useEffect(() => {
const v = videoRef.current;
return () => teardown(v);
}, []);
const onTimeUpdate = () => { const onTimeUpdate = () => {
// Frames are flowing, so whatever the media events claimed, we are not // Frames are flowing, so whatever the media events claimed, we are not
// buffering. A resume-seek can fire `waiting` after `playing` and leave the // buffering. A resume-seek can fire `waiting` after `playing` and leave the
@@ -326,7 +393,8 @@ export default function Player({
const v = videoRef.current; const v = videoRef.current;
const at = item.position ?? 0; const at = item.position ?? 0;
if (!v || !Number.isFinite(v.duration)) return; if (!v || !Number.isFinite(v.duration)) return;
if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at; if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S)
v.currentTime = at;
}; };
const leave = useCallback(() => onClose(), [onClose]); const leave = useCallback(() => onClose(), [onClose]);
@@ -340,8 +408,16 @@ export default function Player({
if (e.key === "Escape" && !document.fullscreenElement) leave(); if (e.key === "Escape" && !document.fullscreenElement) leave();
// Moving between videos. Shift with the arrows because the bare ones // Moving between videos. Shift with the arrows because the bare ones
// seek, and shift with N and P because that is what YouTube uses. // seek, and shift with N and P because that is what YouTube uses.
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "P" || e.key === "p")) onPrev?.(); if (
if (e.shiftKey && (e.key === "ArrowRight" || e.key === "N" || e.key === "n")) onNext?.(); e.shiftKey &&
(e.key === "ArrowLeft" || e.key === "P" || e.key === "p")
)
onPrev?.();
if (
e.shiftKey &&
(e.key === "ArrowRight" || e.key === "N" || e.key === "n")
)
onNext?.();
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
@@ -386,17 +462,41 @@ export default function Player({
className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
dark:border-slate-800 dark:bg-slate-900" dark:border-slate-800 dark:bg-slate-900"
> >
<button onClick={leave} title="Back to the feed (Esc)" aria-label="Back" <button
className={navIcon}> onClick={leave}
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> title="Back to the feed (Esc)"
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" /> aria-label="Back"
className={navIcon}
>
<svg
viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 6l-6 6 6 6"
/>
</svg> </svg>
</button> </button>
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}> <button
onClick={onPrev}
disabled={!onPrev}
title="Previous video"
className={navBtn}
>
Prev Prev
</button> </button>
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}> <button
onClick={onNext}
disabled={!onNext}
title="Next video"
className={navBtn}
>
Next Next
</button> </button>
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500"> <span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
@@ -421,7 +521,10 @@ export default function Player({
error ? ( error ? (
<Badge tone="danger">Unavailable</Badge> <Badge tone="danger">Unavailable</Badge>
) : src && !buffering ? ( ) : src && !buffering ? (
<Badge tone="accent" title={`Streaming at ${height || "an unknown"}p`}> <Badge
tone="accent"
title={`Streaming at ${height || "an unknown"}p`}
>
Streaming{height ? ` · ${height}p` : ""} Streaming{height ? ` · ${height}p` : ""}
</Badge> </Badge>
) : ( ) : (
@@ -447,30 +550,39 @@ export default function Player({
ref={stageRef} ref={stageRef}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
onMouseMove={showChrome} onMouseMove={showChrome}
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)} onMouseLeave={() =>
!videoRef.current?.paused && setChromeVisible(false)
}
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`} className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
> >
{/* Edge arrows, the way a player wants them: big targets on the left and {/* Edge arrows, the way a player wants them: big targets on the left and
right of the picture. They fade in on hover so they never sit on top right of the picture. They fade in on hover so they never sit on top
of the video while you are watching it. */} of the video while you are watching it.
<button
onClick={onPrev} Gone entirely in fullscreen. Nothing to navigate with there: it
disabled={!onPrev} plays one video after another and shows only the picture. */}
title="Previous video" {!fullscreen && (
aria-label="Previous video" <>
className={`${edgeBtn} left-3`} <button
> onClick={onPrev}
disabled={!onPrev}
</button> title="Previous video"
<button aria-label="Previous video"
onClick={onNext} className={`${edgeBtn} left-3`}
disabled={!onNext} >
title="Next video"
aria-label="Next video" </button>
className={`${edgeBtn} right-3`} <button
> onClick={onNext}
disabled={!onNext}
</button> title="Next video"
aria-label="Next video"
className={`${edgeBtn} right-3`}
>
</button>
</>
)}
{/* Resolving the stream and buffering it are the same wait as far as {/* Resolving the stream and buffering it are the same wait as far as
you are concerned, so they get the same spinner in the same place. */} you are concerned, so they get the same spinner in the same place. */}
@@ -483,7 +595,6 @@ export default function Player({
{src ? ( {src ? (
<video <video
ref={videoRef} ref={videoRef}
key={src}
src={src} src={src}
autoPlay autoPlay
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
@@ -513,7 +624,13 @@ export default function Player({
className="absolute inset-0 size-full object-contain" className="absolute inset-0 size-full object-contain"
> >
{tracks.map(([lang, url]) => ( {tracks.map(([lang, url]) => (
<track key={url} kind="subtitles" srcLang={lang} label={subtitleLabel(lang)} src={url} /> <track
key={url}
kind="subtitles"
srcLang={lang}
label={subtitleLabel(lang)}
src={url}
/>
))} ))}
</video> </video>
) : ( ) : (
@@ -522,7 +639,9 @@ export default function Player({
<div className="max-w-sm"> <div className="max-w-sm">
<p className="text-[13px] text-red-400">{error}</p> <p className="text-[13px] text-red-400">{error}</p>
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() =>
openExternal(`https://www.youtube.com/watch?v=${item.id}`)
}
className={`${BTN} mt-3 cursor-pointer py-1.5`} className={`${BTN} mt-3 cursor-pointer py-1.5`}
> >
Open on YouTube instead Open on YouTube instead
@@ -592,8 +711,18 @@ export default function Player({
{downloading ? ( {downloading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
) : ( ) : (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" /> viewBox="0 0 24 24"
className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16"
/>
</svg> </svg>
)} )}
</button> </button>
@@ -606,30 +735,53 @@ export default function Player({
className={`${navIcon} hover:border-red-500! hover:text-red-600! className={`${navIcon} hover:border-red-500! hover:text-red-600!
dark:hover:border-red-500! dark:hover:text-red-400!`} dark:hover:border-red-500! dark:hover:text-red-400!`}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" /> className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6"
/>
</svg> </svg>
</button> </button>
)} )}
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() =>
openExternal(`https://www.youtube.com/watch?v=${item.id}`)
}
title="Open on YouTube" title="Open on YouTube"
aria-label="Open on YouTube" aria-label="Open on YouTube"
className={navIcon} className={navIcon}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg
<path strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24"
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5" /> className="size-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"
/>
</svg> </svg>
</button> </button>
</div> </div>
</div> </div>
</footer> </footer>
{showDescription && ( {showDescription && (
<Dialog title={item.title} onCancel={() => setShowDescription(false)} wide> <Dialog
title={item.title}
onCancel={() => setShowDescription(false)}
wide
>
<p className="whitespace-pre-wrap text-[12.5px] leading-relaxed"> <p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
<Linked text={item.description} /> <Linked text={item.description} />
</p> </p>
+44 -2
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
checkPrereqs, checkYtDlpUpdate, importTakeoutCsv, listBrowsers, pickLibraryFolder, checkPrereqs, checkYtDlpUpdate, importTakeoutCsv, listBrowsers, pickLibraryFolder,
pickTakeoutFile, previewTakeoutImport, setCookieSource, testYoutube, updateYtDlp, pickTakeoutFile, previewRemoveAllSubscriptions, previewTakeoutImport,
removeAllSubscriptions, setCookieSource, testYoutube, updateYtDlp,
} from "../api"; } from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance"; import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import { import {
@@ -18,6 +19,7 @@ import {
interface Props { interface Props {
onClose: () => void; onClose: () => void;
onImported: (count: number) => void; onImported: (count: number) => void;
onRemovedAll: (count: number) => void;
appearance: Appearance; appearance: Appearance;
onAppearance: (a: Appearance) => void; onAppearance: (a: Appearance) => void;
quality: Quality; quality: Quality;
@@ -57,7 +59,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
} }
export default function Settings({ export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality, onClose, onImported, onRemovedAll, appearance, onAppearance, quality, onQuality,
bulkLimit, onBulkLimit, bulkLimit, onBulkLimit,
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts, streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
autoplayNext, onAutoplayNext, autoplayNext, onAutoplayNext,
@@ -65,6 +67,8 @@ export default function Settings({
}: Props) { }: Props) {
const [prereqs, setPrereqs] = useState<Prereqs | null>(null); const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | 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 [busy, setBusy] = useState(false);
const [browsers, setBrowsers] = useState<Array<[string, string]>>([]); const [browsers, setBrowsers] = useState<Array<[string, string]>>([]);
const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null); 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`}> <button onClick={() => setGuide(true)} className={`${BTN} cursor-pointer`}>
How do I get the file? How do I get the file?
</button> </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> </div>
</section> </section>
@@ -439,6 +454,33 @@ export default function Settings({
</div> </div>
</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 && ( {guide && (
<Dialog title="Getting your subscriptions" onCancel={() => setGuide(false)} wide> <Dialog title="Getting your subscriptions" onCancel={() => setGuide(false)} wide>
<p className={`mb-3 ${HELP}`}> <p className={`mb-3 ${HELP}`}>
+157
View File
@@ -0,0 +1,157 @@
import { useEffect, useState } from "react";
import { emitTo } from "@tauri-apps/api/event";
import {
hidePanel,
quitApp,
scrapeSubscriptions,
showMainWindow,
traySaveVideo,
} from "../api";
import { Spinner } from "./ui";
/**
* The menu bar panel.
*
* A window rather than a native menu, so it is the app's own type, spacing and
* colours instead of the system's. It hangs from the icon and closes when it
* loses focus, which is what a menu does; everything else about it is ours.
*/
interface RowProps {
label: string;
hint?: string;
onClick: () => void;
busy?: boolean;
icon: React.ReactNode;
}
function Row({ label, hint, onClick, busy, icon }: RowProps) {
return (
<button
onClick={onClick}
disabled={busy}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-left
transition-colors hover:bg-slate-100 disabled:cursor-wait
dark:hover:bg-slate-800"
>
<span className="grid size-6 shrink-0 place-items-center text-slate-400 dark:text-slate-500">
{busy ? <Spinner className="size-3.5" /> : icon}
</span>
<span className="min-w-0">
<span className="block truncate text-[12.5px] font-medium text-slate-700 dark:text-slate-200">
{label}
</span>
{hint && (
<span className="block truncate text-[11px] text-slate-400 dark:text-slate-500">
{hint}
</span>
)}
</span>
</button>
);
}
const ICON = "size-4";
const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.8 } as const;
export default function TrayPanel() {
const [busy, setBusy] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
// The panel is its own window, so it carries no page background of the app's.
useEffect(() => {
document.documentElement.style.background = "transparent";
document.body.style.background = "transparent";
}, []);
const run = (id: string, fn: () => Promise<unknown>, closeAfter = true) => {
setBusy(id);
setNote(null);
fn()
.then(() => {
if (closeAfter) void hidePanel();
})
.catch((e) => setNote(String(e)))
.finally(() => setBusy(null));
};
const importSubscriptions = () =>
run(
"subs",
async () => {
const result = await scrapeSubscriptions();
// The window owns the confirmation: replacing the list is not a thing
// to agree to in a panel that closes when you look away.
await emitTo("main", "subs:scraped", result);
await showMainWindow();
},
false,
);
return (
<div
className="flex h-screen w-screen flex-col rounded-xl border border-slate-300 bg-white/95
p-1.5 shadow-2xl backdrop-blur-xl dark:border-slate-700 dark:bg-slate-900/95"
>
<div className="px-2 pb-1 pt-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-400">
FlightTube
</div>
<Row
label="Download this video"
hint="From the browser it is playing in"
busy={busy === "video"}
onClick={() => run("video", traySaveVideo)}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 4v10.5M7.5 10l4.5 4.5 4.5-4.5M4 18.5v1A2.5 2.5 0 006.5 22h11a2.5 2.5 0 002.5-2.5v-1" />
</svg>
}
/>
<Row
label="Import subscriptions"
hint="Read them off your YouTube page"
busy={busy === "subs"}
onClick={importSubscriptions}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7h9M4 12h9M4 17h5" />
<path d="M17 9v8m0 0l-2.5-2.5M17 17l2.5-2.5" />
</svg>
}
/>
<div className="my-1 h-px bg-slate-200 dark:bg-slate-800" />
<Row
label="Open FlightTube"
onClick={() => run("open", async () => showMainWindow())}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<rect x="3.5" y="5" width="17" height="14" rx="2" />
<path d="M9 5v14" />
</svg>
}
/>
<Row
label="Quit"
onClick={() => void quitApp()}
icon={
<svg viewBox="0 0 24 24" className={ICON} {...stroke} strokeLinecap="round" strokeLinejoin="round">
<path d="M15 5h3a2 2 0 012 2v10a2 2 0 01-2 2h-3M10 12H3m0 0l3.5-3.5M3 12l3.5 3.5" />
</svg>
}
/>
{note && (
<p
className="mt-1 max-h-16 overflow-y-auto rounded-lg bg-red-500/10 px-2 py-1.5 text-[11px]
leading-snug text-red-700 dark:text-red-300"
>
{note}
</p>
)}
</div>
);
}
+6 -3
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import TrayPanel from "./components/TrayPanel";
import "./index.css"; import "./index.css";
// The webview's own context menu offers Reload, Back and Inspect — page // The webview's own context menu offers Reload, Back and Inspect — page
@@ -14,8 +15,10 @@ document.addEventListener("contextmenu", (e) => {
if (!editable) e.preventDefault(); if (!editable) e.preventDefault();
}); });
// The menu bar panel is a second window on the same bundle, told apart by
// the hash it is opened with.
const panel = window.location.hash === "#tray";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>{panel ? <TrayPanel /> : <App />}</React.StrictMode>,
<App />
</React.StrictMode>,
); );