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.
This commit is contained in:
@@ -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,129 @@ 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 deleting one subscription would take with it.
|
||||
#[derive(Serialize)]
|
||||
pub struct RemovalPreview {
|
||||
|
||||
@@ -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,13 @@ 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::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"));
|
||||
}
|
||||
}
|
||||
+253
-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,132 @@ 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_logged(app: &AppHandle, browser: &str, js: &str) -> Result<String, String> {
|
||||
let r = run_js(browser, js).await;
|
||||
match &r {
|
||||
Ok(out) => log(app, &format!("js ok, {} chars back", out.len())),
|
||||
Err(e) => log(app, &format!("js failed: {e}")),
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
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_logged(app, &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_logged(app, &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 +272,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 +338,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 +351,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 +427,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 +473,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",
|
||||
|
||||
+63
-4
@@ -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>(() => {
|
||||
@@ -356,6 +359,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());
|
||||
@@ -826,6 +842,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?"
|
||||
|
||||
+21
@@ -58,6 +58,27 @@ 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 });
|
||||
|
||||
/** 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 });
|
||||
|
||||
|
||||
@@ -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