Compare commits
3
Commits
5cc3612b5f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e31afc1d | ||
|
|
35f14ab044 | ||
|
|
41d638b232 |
@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[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"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"description": "Capability for the main window and the menu bar panel",
|
||||
"windows": [
|
||||
"main"
|
||||
"main",
|
||||
"tray"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
@@ -11,6 +12,9 @@
|
||||
"dialog:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"notification:default"
|
||||
"notification:default",
|
||||
"core:event:allow-emit-to",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]}}
|
||||
@@ -10,6 +10,7 @@ use crate::models::{
|
||||
use crate::net;
|
||||
use crate::playlist_server::PlaylistServer;
|
||||
use crate::resolve;
|
||||
use crate::subscriptions;
|
||||
use crate::takeout;
|
||||
use crate::thumbs;
|
||||
|
||||
@@ -1613,6 +1614,153 @@ pub async fn set_download_defaults(
|
||||
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.
|
||||
#[derive(Serialize)]
|
||||
pub struct RemovalPreview {
|
||||
|
||||
+10
-1
@@ -6,6 +6,7 @@ pub mod models;
|
||||
pub mod net;
|
||||
pub mod playlist_server;
|
||||
pub mod resolve;
|
||||
pub mod subscriptions;
|
||||
pub mod takeout;
|
||||
pub mod thumbs;
|
||||
pub mod tray;
|
||||
@@ -85,7 +86,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.on_menu_event(tray::on_menu_event)
|
||||
.setup(|app| {
|
||||
let state = commands::build_state(&app.handle().clone())?;
|
||||
app.manage(state);
|
||||
@@ -137,6 +137,15 @@ pub fn run() {
|
||||
commands::set_library_path,
|
||||
commands::open_external,
|
||||
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::preview_delete_channel,
|
||||
commands::set_download_defaults,
|
||||
|
||||
@@ -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
@@ -10,16 +10,14 @@
|
||||
|
||||
use crate::commands::{self, AppState};
|
||||
use crate::resolve;
|
||||
use crate::subscriptions;
|
||||
use std::io::Write;
|
||||
use tauri::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
pub const SAVE_VIDEO: &str = "tray.save_video";
|
||||
pub const ADD_CHANNEL: &str = "tray.add_channel";
|
||||
pub const SHOW: &str = "tray.show";
|
||||
pub const STATUS: &str = "tray.status";
|
||||
/// The panel window's label.
|
||||
pub const PANEL: &str = "tray";
|
||||
|
||||
/// 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 icon out of the menu bar.
|
||||
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
|
||||
/// the same Chromium-flavoured AppleScript.
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Three ways, because the first two can fail silently: a notification from the
|
||||
/// app itself, the same through AppleScript if the plugin is unavailable, and
|
||||
/// the menu's own first line, which always survives.
|
||||
/// Two ways, because either can fail silently: a notification from the app
|
||||
/// itself, and the same through AppleScript if the plugin is unavailable.
|
||||
/// Both are also written to the log beside the database.
|
||||
async fn notify(app: &AppHandle, text: &str) {
|
||||
log(app, text);
|
||||
set_status(app, text);
|
||||
|
||||
if app
|
||||
.notification()
|
||||
@@ -167,13 +263,42 @@ async fn notify(app: &AppHandle, text: &str) {
|
||||
.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 gets undone.
|
||||
fn applescript_string(s: &str) -> String {
|
||||
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
async fn handle_save_video(app: AppHandle) {
|
||||
#[tauri::command]
|
||||
pub async fn tray_save_video(app: AppHandle) {
|
||||
let app = &app;
|
||||
log(app, "menu: save video");
|
||||
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) {
|
||||
let app = &app;
|
||||
log(app, "menu: add channel");
|
||||
let url = match browser_youtube_url(app).await {
|
||||
Ok(u) => u,
|
||||
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,
|
||||
/// Brings the window forward, and closes the panel behind it.
|
||||
#[tauri::command]
|
||||
pub fn show_main_window(app: AppHandle) {
|
||||
if let Some(p) = app.get_webview_window(PANEL) {
|
||||
let _ = p.hide();
|
||||
}
|
||||
}
|
||||
|
||||
fn show_window(app: &AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
let _ = w.unminimize();
|
||||
@@ -229,36 +342,74 @@ fn show_window(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_menu_event(app: &AppHandle, event: MenuEvent) {
|
||||
let app = app.clone();
|
||||
match event.id().as_ref() {
|
||||
SAVE_VIDEO => {
|
||||
tauri::async_runtime::spawn(handle_save_video(app));
|
||||
}
|
||||
ADD_CHANNEL => {
|
||||
tauri::async_runtime::spawn(handle_add_channel(app));
|
||||
}
|
||||
SHOW => show_window(&app),
|
||||
_ => {}
|
||||
/// Closes the panel without doing anything else.
|
||||
#[tauri::command]
|
||||
pub fn hide_panel(app: AppHandle) {
|
||||
if let Some(p) = app.get_webview_window(PANEL) {
|
||||
let _ = p.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[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<()> {
|
||||
// Disabled: it reports, it does not act.
|
||||
let status = MenuItem::with_id(app, STATUS, "FlightTube", false, None::<&str>)?;
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&status,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&MenuItem::with_id(app, SAVE_VIDEO, "Download the video I'm watching", true, None::<&str>)?,
|
||||
&MenuItem::with_id(app, ADD_CHANNEL, "Add the channel I'm watching", true, None::<&str>)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&MenuItem::with_id(app, SHOW, "Open FlightTube", true, None::<&str>)?,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&PredefinedMenuItem::quit(app, Some("Quit FlightTube"))?,
|
||||
],
|
||||
)?;
|
||||
// Hidden until the icon is clicked. Undecorated and transparent so the
|
||||
// page can draw its own rounded, shadowed card.
|
||||
let panel = WebviewWindowBuilder::new(app, PANEL, WebviewUrl::App("index.html#tray".into()))
|
||||
.title("FlightTube")
|
||||
.inner_size(PANEL_W, PANEL_H)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.resizable(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.visible(false)
|
||||
.focused(false)
|
||||
.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"))?;
|
||||
|
||||
@@ -267,17 +418,22 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> {
|
||||
// A template image takes the menu bar's own colour, light or dark.
|
||||
.icon_as_template(true)
|
||||
.tooltip("FlightTube")
|
||||
.menu(&menu)
|
||||
// The menu is the whole point; a left click should open it too.
|
||||
.show_menu_on_left_click(true)
|
||||
// No menu at all: the click opens the panel instead.
|
||||
.show_menu_on_left_click(false)
|
||||
.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)?;
|
||||
|
||||
// TrayIcon is reference-counted and "the icon is removed when the last
|
||||
// instance is dropped" — letting the handle fall out of scope here created
|
||||
// the item and destroyed it in the same breath, which is exactly as
|
||||
// invisible as never creating it.
|
||||
// instance is dropped", so letting the handle fall out of scope here would
|
||||
// create the item and destroy it in the same breath.
|
||||
app.manage(Tray(tray));
|
||||
app.manage(StatusItem(status));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -308,6 +464,21 @@ mod tests {
|
||||
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]
|
||||
fn notification_text_survives_quotes_and_backslashes() {
|
||||
assert_eq!(applescript_string(r#"a "b" c"#), r#""a \"b\" c""#);
|
||||
|
||||
@@ -32,11 +32,14 @@
|
||||
"$APPLOCALDATA/**"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"macOSPrivateApi": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app"],
|
||||
"targets": [
|
||||
"app"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
+110
-7
@@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel,
|
||||
deleteDownload, downloadVideo, interruptedDownloads, listFeed, openExternal,
|
||||
previewDeleteChannel,
|
||||
setDownloadDefaults, type RemovalPreview,
|
||||
deleteDownload, downloadVideo, importScraped, interruptedDownloads, listFeed,
|
||||
openExternal, previewDeleteChannel, previewScrapedImport,
|
||||
setDownloadDefaults, type RemovalPreview, type ScrapeResult,
|
||||
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
|
||||
} from "./api";
|
||||
import AddChannel from "./components/AddChannel";
|
||||
@@ -23,7 +23,8 @@ import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
|
||||
import {
|
||||
BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, DEFAULT_SUB_STYLE,
|
||||
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";
|
||||
|
||||
const TOAST_MS = 2400;
|
||||
@@ -53,6 +54,8 @@ export default function App() {
|
||||
const [autoplayNext, setAutoplayNext] = useState(() => remembered("autoplayNext"));
|
||||
const [autoMode, setAutoMode] = useState(() => remembered("autoMode"));
|
||||
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 [sidebarPeek, setSidebarPeek] = useState(false);
|
||||
const [view, setView] = useState<ViewMode>(() => {
|
||||
@@ -315,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],
|
||||
);
|
||||
@@ -356,6 +370,19 @@ export default function App() {
|
||||
});
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
const un = listen("feed:changed", () => reload());
|
||||
@@ -420,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
|
||||
@@ -726,9 +773,12 @@ export default function App() {
|
||||
</main>
|
||||
</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 && (
|
||||
<Player
|
||||
key={playing.item.id}
|
||||
item={playing.item}
|
||||
path={playing.path}
|
||||
index={playingIndex}
|
||||
@@ -768,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();
|
||||
@@ -807,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"}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -826,6 +886,49 @@ export default function App() {
|
||||
</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 && (
|
||||
<Dialog
|
||||
title="Turn on auto mode?"
|
||||
|
||||
+27
@@ -58,6 +58,33 @@ export const checkYtDlpUpdate = () => invoke<UpdateStatus>("check_yt_dlp_update"
|
||||
export const updateYtDlp = () => invoke<string>("update_yt_dlp");
|
||||
|
||||
/** 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. */
|
||||
export const addChannel = (url: string) => invoke<string>("add_channel", { url });
|
||||
|
||||
|
||||
+212
-60
@@ -1,9 +1,20 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream,
|
||||
embeddedSubtitles,
|
||||
fetchSubtitles,
|
||||
fileUrl,
|
||||
listSubtitles,
|
||||
openExternal,
|
||||
resolveStream,
|
||||
savePlayback,
|
||||
} 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 PlayerControls from "./PlayerControls";
|
||||
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
|
||||
* session (and its audio) running after the element leaves the DOM, so closing
|
||||
* the player or stepping to the next video would leave the previous one playing
|
||||
* with no way to stop it. Every exit path goes through here.
|
||||
* Detaching the element is not enough: WebKit keeps the session (and its
|
||||
* audio) running after the element leaves the DOM, so stepping to the next
|
||||
* video would leave the previous one playing with no way to stop it.
|
||||
*/
|
||||
function teardown(v: HTMLVideoElement | null) {
|
||||
function releasePiP(v: HTMLVideoElement | null) {
|
||||
if (!v) return;
|
||||
// Safari's PiP is the non-standard presentation-mode API; the spec one is
|
||||
// tried too, since either may be the live implementation.
|
||||
const webkit = v as WebkitVideo;
|
||||
try {
|
||||
if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
|
||||
if (
|
||||
webkit.webkitPresentationMode &&
|
||||
webkit.webkitPresentationMode !== "inline"
|
||||
) {
|
||||
webkit.webkitSetPresentationMode?.("inline");
|
||||
}
|
||||
} catch {
|
||||
@@ -68,6 +81,18 @@ function teardown(v: HTMLVideoElement | null) {
|
||||
} catch {
|
||||
/* 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 {
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
} catch {
|
||||
@@ -157,9 +182,24 @@ function placeCues(vtt: string, line: number | null): string {
|
||||
}
|
||||
|
||||
export default function Player({
|
||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
||||
maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext,
|
||||
index, total, titleBarInset,
|
||||
item,
|
||||
path,
|
||||
onClose,
|
||||
onDelete,
|
||||
onPrev,
|
||||
onNext,
|
||||
onDownload,
|
||||
downloading,
|
||||
maxHeight,
|
||||
subLang,
|
||||
onSubLang,
|
||||
subStyle,
|
||||
onSubStyle,
|
||||
onOpenChannel,
|
||||
autoplayNext,
|
||||
index,
|
||||
total,
|
||||
titleBarInset,
|
||||
}: Props) {
|
||||
const streaming = 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
|
||||
// written into the cues themselves. Re-cut whenever the choice changes.
|
||||
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[] = [];
|
||||
setTracks(
|
||||
rawTracks.map(([lang, text]) => {
|
||||
@@ -259,6 +300,24 @@ export default function Player({
|
||||
};
|
||||
}, [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.
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
const hideTimer = useRef<number | undefined>(undefined);
|
||||
@@ -279,8 +338,11 @@ export default function Player({
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSrc(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)
|
||||
.then((u) => !cancelled && setSrc(u))
|
||||
.catch((e) => !cancelled && setError(String(e)));
|
||||
@@ -289,7 +351,6 @@ export default function Player({
|
||||
};
|
||||
}, [item.id, path, maxHeight]);
|
||||
|
||||
|
||||
const persist = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
|
||||
@@ -305,9 +366,15 @@ export default function Player({
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
return () => teardown(v);
|
||||
return () => releasePiP(v);
|
||||
}, [src]);
|
||||
|
||||
// Leaving the player is the only place the element is released outright.
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
return () => teardown(v);
|
||||
}, []);
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
// Frames are flowing, so whatever the media events claimed, we are not
|
||||
// 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 at = item.position ?? 0;
|
||||
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]);
|
||||
@@ -340,8 +408,16 @@ export default function Player({
|
||||
if (e.key === "Escape" && !document.fullscreenElement) leave();
|
||||
// Moving between videos. Shift with the arrows because the bare ones
|
||||
// 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 (e.shiftKey && (e.key === "ArrowRight" || e.key === "N" || e.key === "n")) onNext?.();
|
||||
if (
|
||||
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);
|
||||
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
|
||||
dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<button onClick={leave} title="Back to the feed (Esc)" 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" />
|
||||
<button
|
||||
onClick={leave}
|
||||
title="Back to the feed (Esc)"
|
||||
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>
|
||||
</button>
|
||||
|
||||
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!onPrev}
|
||||
title="Previous video"
|
||||
className={navBtn}
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
title="Next video"
|
||||
className={navBtn}
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
||||
@@ -421,7 +521,10 @@ export default function Player({
|
||||
error ? (
|
||||
<Badge tone="danger">Unavailable</Badge>
|
||||
) : 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` : ""}
|
||||
</Badge>
|
||||
) : (
|
||||
@@ -447,30 +550,39 @@ export default function Player({
|
||||
ref={stageRef}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
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"}`}
|
||||
>
|
||||
{/* 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
|
||||
of the video while you are watching it. */}
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!onPrev}
|
||||
title="Previous video"
|
||||
aria-label="Previous video"
|
||||
className={`${edgeBtn} left-3`}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
title="Next video"
|
||||
aria-label="Next video"
|
||||
className={`${edgeBtn} right-3`}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
of the video while you are watching it.
|
||||
|
||||
Gone entirely in fullscreen. Nothing to navigate with there: it
|
||||
plays one video after another and shows only the picture. */}
|
||||
{!fullscreen && (
|
||||
<>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={!onPrev}
|
||||
title="Previous video"
|
||||
aria-label="Previous video"
|
||||
className={`${edgeBtn} left-3`}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!onNext}
|
||||
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
|
||||
you are concerned, so they get the same spinner in the same place. */}
|
||||
@@ -483,7 +595,6 @@ export default function Player({
|
||||
{src ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={src}
|
||||
src={src}
|
||||
autoPlay
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
@@ -513,7 +624,13 @@ export default function Player({
|
||||
className="absolute inset-0 size-full object-contain"
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
@@ -522,7 +639,9 @@ export default function Player({
|
||||
<div className="max-w-sm">
|
||||
<p className="text-[13px] text-red-400">{error}</p>
|
||||
<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`}
|
||||
>
|
||||
Open on YouTube instead
|
||||
@@ -592,8 +711,18 @@ export default function Player({
|
||||
{downloading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<svg 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
|
||||
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>
|
||||
)}
|
||||
</button>
|
||||
@@ -606,30 +735,53 @@ export default function Player({
|
||||
className={`${navIcon} hover:border-red-500! hover:text-red-600!
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round"
|
||||
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="size-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6"
|
||||
/>
|
||||
</svg>
|
||||
</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"
|
||||
aria-label="Open on YouTube"
|
||||
className={navIcon}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" 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
|
||||
viewBox="0 0 24 24"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
{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">
|
||||
<Linked text={item.description} />
|
||||
</p>
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import TrayPanel from "./components/TrayPanel";
|
||||
import "./index.css";
|
||||
|
||||
// The webview's own context menu offers Reload, Back and Inspect — page
|
||||
@@ -14,8 +15,10 @@ document.addEventListener("contextmenu", (e) => {
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
<React.StrictMode>{panel ? <TrayPanel /> : <App />}</React.StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user