feat: subtitles, delete-all, auto refresh, and menu cleanup
Subtitles: a language preference in Settings drives what is shown and what is downloaded. yt-dlp saves WebVTT sidecars next to each download, including YouTube's auto-generated track, and the player attaches them as <track> elements so they work offline. Sidecars are removed with their video, and by Delete all. WebVTT rather than muxed subtitle streams because WebKit reads a <track> reliably and largely ignores subtitle tracks inside an MP4. Downloads in progress now appear under the downloaded-only filter, so a download you just started does not vanish from the list you are watching it in. That view also gains a Delete all, behind a confirmation naming what goes. The feed refreshes on launch and whenever the player closes, so the Refresh button is only for staleness. Player: Delete moved to the footer and shortened, Open on YouTube is now an external-link icon. Removes the Edit and Help menus. Cut/Copy/Paste move to the app menu, without which their shortcuts would stop working in the search field. The webview context menu is suppressed outside text fields — its Reload and Back items act on a page the app does not present as one. Settings now warns that 4K AV1 plays back with artefacts: the files decode cleanly in ffmpeg, so it is the built-in decoder, not the download.
This commit is contained in:
@@ -690,6 +690,7 @@ async fn cache_thumbnails(state: &State<'_, AppState>) {
|
|||||||
pub async fn download_video(
|
pub async fn download_video(
|
||||||
video_id: String,
|
video_id: String,
|
||||||
quality: String,
|
quality: String,
|
||||||
|
sub_langs: String,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -723,7 +724,7 @@ pub async fn download_video(
|
|||||||
.join(downloader::OUTPUT_TEMPLATE)
|
.join(downloader::OUTPUT_TEMPLATE)
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
let mut args = downloader::build_args(&video_id, &out_template, &quality);
|
let mut args = downloader::build_args(&video_id, &out_template, &quality, &sub_langs);
|
||||||
// Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no
|
// Without this yt-dlp looks for ffmpeg on PATH, which a bundled app has no
|
||||||
// reason to have. Merging video and audio would fail on a clean machine.
|
// reason to have. Merging video and audio would fail on a clean machine.
|
||||||
args.push("--ffmpeg-location".into());
|
args.push("--ffmpeg-location".into());
|
||||||
@@ -922,6 +923,55 @@ async fn cleanup_partials(library: PathBuf, video_id: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WebVTT files yt-dlp wrote beside a download, as (language, path) pairs.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_subtitles(
|
||||||
|
video_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<(String, String)>, String> {
|
||||||
|
let library = state.library.lock().await.clone();
|
||||||
|
let Ok(mut entries) = tokio::fs::read_dir(&library).await else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if !name.contains(&video_id) || !name.ends_with(".vtt") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// yt-dlp names them "<base>.<lang>.vtt".
|
||||||
|
let lang = name
|
||||||
|
.trim_end_matches(".vtt")
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
out.push((lang, entry.path().to_string_lossy().to_string()));
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every download and the files behind them, including subtitles.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_all_downloads(state: State<'_, AppState>) -> Result<usize, String> {
|
||||||
|
let paths = state.db.lock().await.all_download_paths()?;
|
||||||
|
for p in &paths {
|
||||||
|
let _ = tokio::fs::remove_file(p).await;
|
||||||
|
}
|
||||||
|
// Subtitle sidecars are not tracked in the database, so sweep them here.
|
||||||
|
let library = state.library.lock().await.clone();
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(&library).await {
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.ends_with(".vtt") || name.contains(".part") || name.ends_with(".ytdl") {
|
||||||
|
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.db.lock().await.clear_all_downloads()
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_download(
|
pub async fn delete_download(
|
||||||
video_id: String,
|
video_id: String,
|
||||||
@@ -931,7 +981,17 @@ pub async fn delete_download(
|
|||||||
if let Some(p) = path {
|
if let Some(p) = path {
|
||||||
let _ = tokio::fs::remove_file(&p).await;
|
let _ = tokio::fs::remove_file(&p).await;
|
||||||
}
|
}
|
||||||
cleanup_partials(state.library.lock().await.clone(), &video_id).await;
|
let library = state.library.lock().await.clone();
|
||||||
|
cleanup_partials(library.clone(), &video_id).await;
|
||||||
|
// The .vtt sidecars belong to the video, so they go with it.
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(&library).await {
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.contains(&video_id) && name.ends_with(".vtt") {
|
||||||
|
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
state.db.lock().await.clear_download(&video_id)
|
state.db.lock().await.clear_download(&video_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-1
@@ -369,7 +369,9 @@ impl Db {
|
|||||||
args.push(Box::new(pat));
|
args.push(Box::new(pat));
|
||||||
}
|
}
|
||||||
if f.downloaded_only {
|
if f.downloaded_only {
|
||||||
sql.push_str(" AND d.state = 'done'");
|
// Queued and running count: a download you started should not
|
||||||
|
// vanish from the very list you are watching it in.
|
||||||
|
sql.push_str(" AND d.state IN ('done','queued','running')");
|
||||||
}
|
}
|
||||||
if f.hide_shorts {
|
if f.hide_shorts {
|
||||||
sql.push_str(" AND v.is_short = 0");
|
sql.push_str(" AND v.is_short = 0");
|
||||||
@@ -498,6 +500,26 @@ impl Db {
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Paths of every completed download, for deleting them all at once.
|
||||||
|
pub fn all_download_paths(&self) -> Result<Vec<String>, String> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT path FROM downloads WHERE path IS NOT NULL")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| r.get::<_, String>(0))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_all_downloads(&self) -> Result<usize, String> {
|
||||||
|
let n = self
|
||||||
|
.conn
|
||||||
|
.execute("DELETE FROM downloads", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn clear_download(&self, video_id: &str) -> Result<(), String> {
|
pub fn clear_download(&self, video_id: &str) -> Result<(), String> {
|
||||||
self.conn
|
self.conn
|
||||||
.execute("DELETE FROM downloads WHERE video_id = ?1", params![video_id])
|
.execute("DELETE FROM downloads WHERE video_id = ?1", params![video_id])
|
||||||
@@ -737,6 +759,36 @@ mod tests {
|
|||||||
assert_eq!(feed[0].id, "b");
|
assert_eq!(feed[0].id, "b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn downloads_in_progress_still_show_in_the_downloaded_filter() {
|
||||||
|
let db = seeded();
|
||||||
|
db.set_download_state("a", DownloadState::Running, None).unwrap();
|
||||||
|
db.set_download_state("c", DownloadState::Queued, None).unwrap();
|
||||||
|
db.set_download_state("b", DownloadState::Failed, Some("x")).unwrap();
|
||||||
|
|
||||||
|
let feed = db
|
||||||
|
.list_feed(&FeedFilter { downloaded_only: true, ..Default::default() })
|
||||||
|
.unwrap();
|
||||||
|
let ids: Vec<&str> = feed.iter().map(|f| f.id.as_str()).collect();
|
||||||
|
assert!(ids.contains(&"a"), "running should be listed");
|
||||||
|
assert!(ids.contains(&"c"), "queued should be listed");
|
||||||
|
assert!(!ids.contains(&"b"), "failed should not be");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clearing_all_downloads_empties_the_filter() {
|
||||||
|
let db = seeded();
|
||||||
|
db.set_download_state("a", DownloadState::Done, None).unwrap();
|
||||||
|
db.set_download_path("a", "/movies/a.mp4").unwrap();
|
||||||
|
db.set_download_state("b", DownloadState::Done, None).unwrap();
|
||||||
|
assert_eq!(db.all_download_paths().unwrap(), vec!["/movies/a.mp4".to_string()]);
|
||||||
|
db.clear_all_downloads().unwrap();
|
||||||
|
assert!(db
|
||||||
|
.list_feed(&FeedFilter { downloaded_only: true, ..Default::default() })
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hide_shorts_filter_excludes_shorts() {
|
fn hide_shorts_filter_excludes_shorts() {
|
||||||
let db = seeded();
|
let db = seeded();
|
||||||
|
|||||||
@@ -79,7 +79,12 @@ pub fn parse_progress_line(line: &str) -> Option<Progress> {
|
|||||||
|
|
||||||
/// Arguments for downloading one video. Kept separate from process spawning so
|
/// Arguments for downloading one video. Kept separate from process spawning so
|
||||||
/// the argument construction is assertable in tests.
|
/// the argument construction is assertable in tests.
|
||||||
pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec<String> {
|
pub fn build_args(
|
||||||
|
video_id: &str,
|
||||||
|
out_template: &str,
|
||||||
|
quality: &str,
|
||||||
|
sub_langs: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
vec![
|
vec![
|
||||||
"-f".into(),
|
"-f".into(),
|
||||||
format_selector(quality),
|
format_selector(quality),
|
||||||
@@ -94,6 +99,18 @@ pub fn build_args(video_id: &str, out_template: &str, quality: &str) -> Vec<Stri
|
|||||||
"180".into(),
|
"180".into(),
|
||||||
"--progress-template".into(),
|
"--progress-template".into(),
|
||||||
PROGRESS_TEMPLATE.into(),
|
PROGRESS_TEMPLATE.into(),
|
||||||
|
// Subtitles come along for offline use, including YouTube's
|
||||||
|
// auto-generated ones. They are written beside the video as WebVTT
|
||||||
|
// rather than muxed in: WebKit reads a <track> reliably, whereas
|
||||||
|
// subtitle streams inside an MP4 it largely ignores.
|
||||||
|
"--write-subs".into(),
|
||||||
|
"--write-auto-subs".into(),
|
||||||
|
"--sub-format".into(),
|
||||||
|
"vtt".into(),
|
||||||
|
"--convert-subs".into(),
|
||||||
|
"vtt".into(),
|
||||||
|
"--sub-langs".into(),
|
||||||
|
sub_langs.into(),
|
||||||
"--print".into(),
|
"--print".into(),
|
||||||
"after_move:FTPATH %(filepath)s".into(),
|
"after_move:FTPATH %(filepath)s".into(),
|
||||||
"-o".into(),
|
"-o".into(),
|
||||||
@@ -175,9 +192,20 @@ mod tests {
|
|||||||
assert_eq!(p.pct(), None);
|
assert_eq!(p.pct(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtitles_are_requested_including_auto_generated() {
|
||||||
|
let args = build_args("abc", "/tmp/o.%(ext)s", "best", "en.*,nl.*");
|
||||||
|
assert!(args.contains(&"--write-subs".to_string()));
|
||||||
|
// The auto-generated track is the only one many videos have.
|
||||||
|
assert!(args.contains(&"--write-auto-subs".to_string()));
|
||||||
|
assert!(args.contains(&"en.*,nl.*".to_string()));
|
||||||
|
// WebVTT, because that is what a <track> element can load.
|
||||||
|
assert!(args.contains(&"vtt".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_quality_takes_the_highest_available() {
|
fn best_quality_takes_the_highest_available() {
|
||||||
let args = build_args("abc123", "/tmp/out.%(ext)s", "best");
|
let args = build_args("abc123", "/tmp/out.%(ext)s", "best", "en.*");
|
||||||
assert!(args.contains(&FORMAT_BEST.to_string()));
|
assert!(args.contains(&FORMAT_BEST.to_string()));
|
||||||
assert!(args.contains(&"mp4".to_string()));
|
assert!(args.contains(&"mp4".to_string()));
|
||||||
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
|
assert!(args.contains(&"https://www.youtube.com/watch?v=abc123".to_string()));
|
||||||
@@ -197,7 +225,7 @@ mod tests {
|
|||||||
let sel = format_selector("1080");
|
let sel = format_selector("1080");
|
||||||
assert!(sel.contains("height<=1080"));
|
assert!(sel.contains("height<=1080"));
|
||||||
assert!(!sel.contains("height<=2160"));
|
assert!(!sel.contains("height<=2160"));
|
||||||
assert!(build_args("x", "o", "1080").contains(&sel));
|
assert!(build_args("x", "o", "1080", "en.*").contains(&sel));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -8,8 +8,52 @@ pub mod playlist_server;
|
|||||||
pub mod takeout;
|
pub mod takeout;
|
||||||
pub mod thumbs;
|
pub mod thumbs;
|
||||||
|
|
||||||
|
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
/// The macOS menu bar, minus Edit and Help.
|
||||||
|
///
|
||||||
|
/// Tauri's default adds both; neither has anything to offer here. Edit is kept
|
||||||
|
/// as a hidden-in-spirit necessity though — its Cut/Copy/Paste items are what
|
||||||
|
/// make those shortcuts work in the search field, so they live under the app
|
||||||
|
/// menu instead of their own top-level entry.
|
||||||
|
fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
||||||
|
let app_menu = Submenu::with_items(
|
||||||
|
app,
|
||||||
|
"FlightTube",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::about(app, None, Some(AboutMetadata::default()))?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::hide(app, None)?,
|
||||||
|
&PredefinedMenuItem::hide_others(app, None)?,
|
||||||
|
&PredefinedMenuItem::show_all(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
// Keeps ⌘X/⌘C/⌘V working in text fields without an Edit menu.
|
||||||
|
&PredefinedMenuItem::cut(app, None)?,
|
||||||
|
&PredefinedMenuItem::copy(app, None)?,
|
||||||
|
&PredefinedMenuItem::paste(app, None)?,
|
||||||
|
&PredefinedMenuItem::select_all(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::quit(app, None)?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let window_menu = Submenu::with_items(
|
||||||
|
app,
|
||||||
|
"Window",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::minimize(app, None)?,
|
||||||
|
&PredefinedMenuItem::maximize(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::close_window(app, None)?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Menu::with_items(app, &[&app_menu, &window_menu])
|
||||||
|
}
|
||||||
|
|
||||||
/// Turns on WKWebView's element fullscreen.
|
/// Turns on WKWebView's element fullscreen.
|
||||||
///
|
///
|
||||||
/// It is off by default in a Tauri window, which is why the native player has
|
/// It is off by default in a Tauri window, which is why the native player has
|
||||||
@@ -35,6 +79,7 @@ fn enable_element_fullscreen(window: &tauri::WebviewWindow) {
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.menu(build_menu)
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -50,7 +95,51 @@ pub fn run() {
|
|||||||
// the background rather than the first time Settings is opened.
|
// the background rather than the first time Settings is opened.
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
|
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
|
/// The macOS menu bar, minus Edit and Help.
|
||||||
|
///
|
||||||
|
/// Tauri's default adds both; neither has anything to offer here. Edit is kept
|
||||||
|
/// as a hidden-in-spirit necessity though — its Cut/Copy/Paste items are what
|
||||||
|
/// make those shortcuts work in the search field, so they live under the app
|
||||||
|
/// menu instead of their own top-level entry.
|
||||||
|
fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
||||||
|
let app_menu = Submenu::with_items(
|
||||||
|
app,
|
||||||
|
"FlightTube",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::about(app, None, Some(AboutMetadata::default()))?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::hide(app, None)?,
|
||||||
|
&PredefinedMenuItem::hide_others(app, None)?,
|
||||||
|
&PredefinedMenuItem::show_all(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
// Keeps ⌘X/⌘C/⌘V working in text fields without an Edit menu.
|
||||||
|
&PredefinedMenuItem::cut(app, None)?,
|
||||||
|
&PredefinedMenuItem::copy(app, None)?,
|
||||||
|
&PredefinedMenuItem::paste(app, None)?,
|
||||||
|
&PredefinedMenuItem::select_all(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::quit(app, None)?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let window_menu = Submenu::with_items(
|
||||||
|
app,
|
||||||
|
"Window",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::minimize(app, None)?,
|
||||||
|
&PredefinedMenuItem::maximize(app, None)?,
|
||||||
|
&PredefinedMenuItem::separator(app)?,
|
||||||
|
&PredefinedMenuItem::close_window(app, None)?,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Menu::with_items(app, &[&app_menu, &window_menu])
|
||||||
|
}
|
||||||
let state = handle.state::<commands::AppState>();
|
let state = handle.state::<commands::AppState>();
|
||||||
let _ = commands::check_prereqs(state).await;
|
let _ = commands::check_prereqs(state).await;
|
||||||
});
|
});
|
||||||
@@ -69,6 +158,8 @@ pub fn run() {
|
|||||||
commands::download_video,
|
commands::download_video,
|
||||||
commands::cancel_download,
|
commands::cancel_download,
|
||||||
commands::delete_download,
|
commands::delete_download,
|
||||||
|
commands::delete_all_downloads,
|
||||||
|
commands::list_subtitles,
|
||||||
commands::get_connectivity,
|
commands::get_connectivity,
|
||||||
commands::set_library_path,
|
commands::set_library_path,
|
||||||
commands::open_external,
|
commands::open_external,
|
||||||
|
|||||||
+69
-6
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
cancelDownload, deleteDownload, downloadVideo, onRefreshProgress, refreshFeeds,
|
cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
|
||||||
|
onRefreshProgress, refreshFeeds,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import Player from "./components/Player";
|
import Player from "./components/Player";
|
||||||
import Settings from "./components/Settings";
|
import Settings from "./components/Settings";
|
||||||
@@ -15,7 +16,7 @@ import { useDownloads } from "./hooks/useDownloads";
|
|||||||
import { useFeed } from "./hooks/useFeed";
|
import { useFeed } from "./hooks/useFeed";
|
||||||
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
|
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
|
||||||
import {
|
import {
|
||||||
QUALITIES, STREAM_QUALITIES,
|
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||||
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
|
type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -48,6 +49,14 @@ export default function App() {
|
|||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Index into the current feed, so the player can step through it.
|
// Index into the current feed, so the player can step through it.
|
||||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||||
|
const [subLang, setSubLang] = useState(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("flighttube.subLang");
|
||||||
|
return SUB_LANGS.some((l) => l.value === stored) ? stored! : "off";
|
||||||
|
} catch {
|
||||||
|
return "off";
|
||||||
|
}
|
||||||
|
});
|
||||||
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
|
const [streamQuality, setStreamQuality] = useState<Quality>(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("flighttube.streamQuality");
|
const stored = localStorage.getItem("flighttube.streamQuality");
|
||||||
@@ -88,13 +97,14 @@ export default function App() {
|
|||||||
localStorage.setItem("flighttube.view", view);
|
localStorage.setItem("flighttube.view", view);
|
||||||
localStorage.setItem("flighttube.quality", quality);
|
localStorage.setItem("flighttube.quality", quality);
|
||||||
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
||||||
|
localStorage.setItem("flighttube.subLang", subLang);
|
||||||
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
||||||
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
||||||
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
|
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
|
||||||
} catch {
|
} catch {
|
||||||
/* storage blocked */
|
/* storage blocked */
|
||||||
}
|
}
|
||||||
}, [view, quality, streamQuality, downloadedOnly, hideShorts, sidebarHidden]);
|
}, [view, quality, streamQuality, subLang, downloadedOnly, hideShorts, sidebarHidden]);
|
||||||
|
|
||||||
// Offline, the only videos that can be played are the ones already on disk,
|
// Offline, the only videos that can be played are the ones already on disk,
|
||||||
// so the feed collapses to those regardless of the toggle.
|
// so the feed collapses to those regardless of the toggle.
|
||||||
@@ -136,6 +146,10 @@ export default function App() {
|
|||||||
[channels],
|
[channels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// yt-dlp wants a pattern; "en.*" catches "en" and "en-orig" and the
|
||||||
|
// auto-generated "en" track alike. English is always fetched as a fallback.
|
||||||
|
const subLangArg = subLang === "off" ? "en.*" : `${subLang}.*,en.*`;
|
||||||
|
|
||||||
const doRefresh = useCallback(async () => {
|
const doRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
@@ -204,6 +218,29 @@ export default function App() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [online, refreshing, playingIndex, doRefresh]);
|
}, [online, refreshing, playingIndex, doRefresh]);
|
||||||
|
|
||||||
|
// Refresh once on launch, as soon as there is a connection and something to
|
||||||
|
// refresh, so the feed is current without anyone pressing anything.
|
||||||
|
const launched = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (launched.current || !online || channels.length === 0) return;
|
||||||
|
launched.current = true;
|
||||||
|
void doRefresh();
|
||||||
|
}, [online, channels.length, doRefresh]);
|
||||||
|
|
||||||
|
const [confirmWipe, setConfirmWipe] = useState(false);
|
||||||
|
|
||||||
|
const wipeDownloads = useCallback(async () => {
|
||||||
|
setConfirmWipe(false);
|
||||||
|
try {
|
||||||
|
const n = await deleteAllDownloads();
|
||||||
|
items.forEach((i) => clearLive(i.id));
|
||||||
|
await reload();
|
||||||
|
say(`Deleted ${n} download${n === 1 ? "" : "s"}`);
|
||||||
|
} catch (e) {
|
||||||
|
setFailure(String(e));
|
||||||
|
}
|
||||||
|
}, [items, clearLive, reload, say]);
|
||||||
|
|
||||||
const emptyMessage = () => {
|
const emptyMessage = () => {
|
||||||
if (loading) return "Loading…";
|
if (loading) return "Loading…";
|
||||||
if (channels.length === 0)
|
if (channels.length === 0)
|
||||||
@@ -274,6 +311,7 @@ export default function App() {
|
|||||||
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
|
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
|
||||||
resultCount={items.length}
|
resultCount={items.length}
|
||||||
view={view} onView={setView}
|
view={view} onView={setView}
|
||||||
|
onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined}
|
||||||
sidebarHidden={sidebarHidden}
|
sidebarHidden={sidebarHidden}
|
||||||
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
|
onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
|
||||||
titleBarInset={titleBarInset}
|
titleBarInset={titleBarInset}
|
||||||
@@ -311,7 +349,7 @@ export default function App() {
|
|||||||
online,
|
online,
|
||||||
onOpen: () => openIndex(idx),
|
onOpen: () => openIndex(idx),
|
||||||
onDownload: () =>
|
onDownload: () =>
|
||||||
downloadVideo(item.id, quality).catch((e) => setFailure(String(e))),
|
downloadVideo(item.id, quality, subLangArg).catch((e) => setFailure(String(e))),
|
||||||
onCancel: () =>
|
onCancel: () =>
|
||||||
cancelDownload(item.id).catch((e) => setFailure(String(e))),
|
cancelDownload(item.id).catch((e) => setFailure(String(e))),
|
||||||
onDelete: () =>
|
onDelete: () =>
|
||||||
@@ -339,6 +377,7 @@ export default function App() {
|
|||||||
index={playingIndex}
|
index={playingIndex}
|
||||||
total={items.length}
|
total={items.length}
|
||||||
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
|
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
|
||||||
|
subLang={subLang}
|
||||||
titleBarInset={titleBarInset}
|
titleBarInset={titleBarInset}
|
||||||
onPrev={
|
onPrev={
|
||||||
stepFrom(playingIndex, -1) != null
|
stepFrom(playingIndex, -1) != null
|
||||||
@@ -353,7 +392,7 @@ export default function App() {
|
|||||||
onDownload={
|
onDownload={
|
||||||
playing.path === null
|
playing.path === null
|
||||||
? () => {
|
? () => {
|
||||||
downloadVideo(playing.item.id, quality).catch((e) => setFailure(String(e)));
|
downloadVideo(playing.item.id, quality, subLangArg).catch((e) => setFailure(String(e)));
|
||||||
say("Download started");
|
say("Download started");
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@@ -361,7 +400,13 @@ export default function App() {
|
|||||||
downloading={["queued", "running"].includes(
|
downloading={["queued", "running"].includes(
|
||||||
live[playing.item.id]?.state ?? playing.item.state ?? "",
|
live[playing.item.id]?.state ?? playing.item.state ?? "",
|
||||||
)}
|
)}
|
||||||
onClose={() => { setPlayingIndex(null); reload(); }}
|
onClose={() => {
|
||||||
|
setPlayingIndex(null);
|
||||||
|
// Coming back from a video is the natural moment to pick up
|
||||||
|
// whatever has been posted since.
|
||||||
|
if (online && !refreshing) void doRefresh();
|
||||||
|
else void reload();
|
||||||
|
}}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
await deleteDownload(playing.item.id);
|
await deleteDownload(playing.item.id);
|
||||||
clearLive(playing.item.id);
|
clearLive(playing.item.id);
|
||||||
@@ -381,6 +426,8 @@ export default function App() {
|
|||||||
onQuality={setQuality}
|
onQuality={setQuality}
|
||||||
streamQuality={streamQuality}
|
streamQuality={streamQuality}
|
||||||
onStreamQuality={setStreamQuality}
|
onStreamQuality={setStreamQuality}
|
||||||
|
subLang={subLang}
|
||||||
|
onSubLang={setSubLang}
|
||||||
onError={setFailure}
|
onError={setFailure}
|
||||||
onImported={(n) => {
|
onImported={(n) => {
|
||||||
reload();
|
reload();
|
||||||
@@ -389,6 +436,22 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmWipe && (
|
||||||
|
<Dialog
|
||||||
|
title="Delete every download?"
|
||||||
|
onCancel={() => setConfirmWipe(false)}
|
||||||
|
onConfirm={wipeDownloads}
|
||||||
|
confirmLabel="Delete all"
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
All <b>{totals.downloaded}</b> downloaded video
|
||||||
|
{totals.downloaded === 1 ? "" : "s"} and their subtitles will be removed from
|
||||||
|
disk. Your subscriptions and the feed are untouched.
|
||||||
|
</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
{failure && (
|
{failure && (
|
||||||
<Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
|
<Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
|
||||||
<p className="break-words">{failure}</p>
|
<p className="break-words">{failure}</p>
|
||||||
|
|||||||
+8
-2
@@ -24,8 +24,14 @@ export const listFeed = (filter: FeedFilter) =>
|
|||||||
|
|
||||||
export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
|
export const refreshFeeds = () => invoke<RefreshSummary>("refresh_feeds");
|
||||||
|
|
||||||
export const downloadVideo = (videoId: string, quality: Quality) =>
|
export const downloadVideo = (videoId: string, quality: Quality, subLangs: string) =>
|
||||||
invoke<void>("download_video", { videoId, quality });
|
invoke<void>("download_video", { videoId, quality, subLangs });
|
||||||
|
|
||||||
|
export const deleteAllDownloads = () => invoke<number>("delete_all_downloads");
|
||||||
|
|
||||||
|
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
|
||||||
|
export const listSubtitles = (videoId: string) =>
|
||||||
|
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
||||||
|
|
||||||
export const savePlayback = (videoId: string, position: number, duration: number) =>
|
export const savePlayback = (videoId: string, position: number, duration: number) =>
|
||||||
invoke<void>("save_playback", { videoId, position, duration });
|
invoke<void>("save_playback", { videoId, position, duration });
|
||||||
|
|||||||
+55
-14
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { fileUrl, openExternal, resolveStream, savePlayback } from "../api";
|
import { fileUrl, listSubtitles, openExternal, resolveStream, savePlayback } from "../api";
|
||||||
import type { FeedItem } from "../types";
|
import type { FeedItem } from "../types";
|
||||||
import { compactViews, relativeTime } from "./format";
|
import { compactViews, relativeTime } from "./format";
|
||||||
import PlayerControls from "./PlayerControls";
|
import PlayerControls from "./PlayerControls";
|
||||||
import { BTN, BTN_CHROME, Spinner } from "./ui";
|
import { BTN, Spinner } from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: FeedItem;
|
item: FeedItem;
|
||||||
@@ -18,6 +18,8 @@ interface Props {
|
|||||||
downloading?: boolean;
|
downloading?: boolean;
|
||||||
/** Max height for streaming, or null to let the player adapt. */
|
/** Max height for streaming, or null to let the player adapt. */
|
||||||
maxHeight: number | null;
|
maxHeight: number | null;
|
||||||
|
/** Preferred subtitle language, or "off". */
|
||||||
|
subLang: string;
|
||||||
/** Position in the current feed, for the "3 of 180" readout. */
|
/** Position in the current feed, for the "3 of 180" readout. */
|
||||||
index: number;
|
index: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -97,12 +99,28 @@ const RESUME_EDGE_S = 5;
|
|||||||
*/
|
*/
|
||||||
export default function Player({
|
export default function Player({
|
||||||
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
|
||||||
maxHeight, index, total, titleBarInset,
|
maxHeight, subLang, index, total, titleBarInset,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const streaming = path === null;
|
const streaming = path === null;
|
||||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [buffering, setBuffering] = useState(true);
|
const [buffering, setBuffering] = useState(true);
|
||||||
|
// WebVTT files yt-dlp saved next to a download, so subtitles work offline.
|
||||||
|
const [sidecars, setSidecars] = useState<Array<[string, string]>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!path) {
|
||||||
|
setSidecars([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
listSubtitles(item.id)
|
||||||
|
.then((s) => !cancelled && setSidecars(s))
|
||||||
|
.catch(() => !cancelled && setSidecars([]));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [item.id, path]);
|
||||||
// Controls and edge arrows fade away while you are just watching.
|
// Controls and edge arrows fade away while you are just watching.
|
||||||
const [chromeVisible, setChromeVisible] = useState(true);
|
const [chromeVisible, setChromeVisible] = useState(true);
|
||||||
const hideTimer = useRef<number | undefined>(undefined);
|
const hideTimer = useRef<number | undefined>(undefined);
|
||||||
@@ -236,17 +254,10 @@ export default function Player({
|
|||||||
{item.channel_title}
|
{item.channel_title}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{streaming ? (
|
{streaming && (
|
||||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||||
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"}
|
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={onDelete}
|
|
||||||
className={`${BTN_CHROME} cursor-pointer hover:bg-red-500/10! hover:text-red-600! dark:hover:text-red-400!`}
|
|
||||||
>
|
|
||||||
Delete download
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -312,7 +323,17 @@ export default function Player({
|
|||||||
onPlaying={() => setBuffering(false)}
|
onPlaying={() => setBuffering(false)}
|
||||||
onSeeked={() => setBuffering(false)}
|
onSeeked={() => setBuffering(false)}
|
||||||
className="absolute inset-0 size-full object-contain"
|
className="absolute inset-0 size-full object-contain"
|
||||||
|
>
|
||||||
|
{sidecars.map(([lang, file]) => (
|
||||||
|
<track
|
||||||
|
key={file}
|
||||||
|
kind="subtitles"
|
||||||
|
srcLang={lang}
|
||||||
|
label={lang}
|
||||||
|
src={fileUrl(file)}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</video>
|
||||||
) : (
|
) : (
|
||||||
error && (
|
error && (
|
||||||
<div className="absolute inset-0 grid place-items-center px-6 text-center">
|
<div className="absolute inset-0 grid place-items-center px-6 text-center">
|
||||||
@@ -334,7 +355,12 @@ export default function Player({
|
|||||||
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
|
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<PlayerControls videoRef={videoRef} stageRef={stageRef} onActivity={showChrome} />
|
<PlayerControls
|
||||||
|
videoRef={videoRef}
|
||||||
|
stageRef={stageRef}
|
||||||
|
onActivity={showChrome}
|
||||||
|
subLang={subLang}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -362,11 +388,26 @@ export default function Player({
|
|||||||
{downloading ? "Downloading…" : "Download"}
|
{downloading ? "Downloading…" : "Download"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!streaming && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
title="Delete this download"
|
||||||
|
className={`${navBtn} whitespace-nowrap hover:border-red-500! hover:text-red-600!
|
||||||
|
dark:hover:border-red-500! dark:hover:text-red-400!`}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
|
||||||
className={`${navBtn} whitespace-nowrap`}
|
title="Open on YouTube"
|
||||||
|
aria-label="Open on YouTube"
|
||||||
|
className={`${navBtn} w-[30px] p-0`}
|
||||||
>
|
>
|
||||||
Open on YouTube
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface Props {
|
|||||||
stageRef: React.RefObject<HTMLDivElement | null>;
|
stageRef: React.RefObject<HTMLDivElement | null>;
|
||||||
/** Nudges the auto-hide timer whenever the user does something. */
|
/** Nudges the auto-hide timer whenever the user does something. */
|
||||||
onActivity?: () => void;
|
onActivity?: () => void;
|
||||||
|
/** Preferred subtitle language, or "off" to start with none. */
|
||||||
|
subLang: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SKIP_S = 10;
|
const SKIP_S = 10;
|
||||||
@@ -59,7 +61,7 @@ const btn =
|
|||||||
* Owning the bar is the only way to get every control into one strip along the
|
* Owning the bar is the only way to get every control into one strip along the
|
||||||
* bottom, so the native ones are switched off entirely.
|
* bottom, so the native ones are switched off entirely.
|
||||||
*/
|
*/
|
||||||
export default function PlayerControls({ videoRef, stageRef, onActivity }: Props) {
|
export default function PlayerControls({ videoRef, stageRef, onActivity, subLang }: Props) {
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [time, setTime] = useState(0);
|
const [time, setTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
@@ -83,15 +85,24 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
|
|
||||||
// Nothing translated gets forced on. YouTube marks its subtitle track
|
// Subtitles follow the language chosen in Settings and nothing else.
|
||||||
// AUTOSELECT=YES and WebKit will switch it on when it matches the system
|
// WebKit will otherwise switch on whatever matches the system language,
|
||||||
// language; that is the same unwanted auto-selection as a dubbed audio
|
// which is the same unwanted auto-selection as a dubbed audio track.
|
||||||
// track, so subtitles start off and stay a deliberate choice.
|
const applyPreference = () => {
|
||||||
const silenceSubs = () => {
|
const tracks = Array.from(v.textTracks);
|
||||||
for (const t of Array.from(v.textTracks)) t.mode = "disabled";
|
const wanted =
|
||||||
|
subLang === "off"
|
||||||
|
? undefined
|
||||||
|
: tracks.find((t) =>
|
||||||
|
(t.language || "").toLowerCase().startsWith(subLang.toLowerCase()),
|
||||||
|
);
|
||||||
|
for (const t of tracks) t.mode = t === wanted ? "showing" : "disabled";
|
||||||
read();
|
read();
|
||||||
};
|
};
|
||||||
v.addEventListener("loadedmetadata", silenceSubs);
|
applyPreference();
|
||||||
|
v.addEventListener("loadedmetadata", applyPreference);
|
||||||
|
// HLS subtitle renditions arrive after metadata, so re-apply as they land.
|
||||||
|
v.textTracks.addEventListener?.("addtrack", applyPreference);
|
||||||
|
|
||||||
v.addEventListener("loadedmetadata", read);
|
v.addEventListener("loadedmetadata", read);
|
||||||
const at = (v as VideoWithTracks).audioTracks;
|
const at = (v as VideoWithTracks).audioTracks;
|
||||||
@@ -101,14 +112,15 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
|
|||||||
const id = setInterval(read, 1000);
|
const id = setInterval(read, 1000);
|
||||||
const stop = setTimeout(() => clearInterval(id), 8000);
|
const stop = setTimeout(() => clearInterval(id), 8000);
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener("loadedmetadata", silenceSubs);
|
v.removeEventListener("loadedmetadata", applyPreference);
|
||||||
|
v.textTracks.removeEventListener?.("addtrack", applyPreference);
|
||||||
v.removeEventListener("loadedmetadata", read);
|
v.removeEventListener("loadedmetadata", read);
|
||||||
at?.removeEventListener?.("addtrack", read);
|
at?.removeEventListener?.("addtrack", read);
|
||||||
v.textTracks.removeEventListener?.("addtrack", read);
|
v.textTracks.removeEventListener?.("addtrack", read);
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
clearTimeout(stop);
|
clearTimeout(stop);
|
||||||
};
|
};
|
||||||
}, [videoRef]);
|
}, [videoRef, subLang]);
|
||||||
|
|
||||||
// Close the menu on any outside click.
|
// Close the menu on any outside click.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||||
import {
|
import {
|
||||||
QUALITIES, STREAM_QUALITIES, type ImportPreview, type Prereqs, type Quality,
|
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||||
|
type ImportPreview, type Prereqs, type Quality,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import TakeoutGuide from "./TakeoutGuide";
|
import TakeoutGuide from "./TakeoutGuide";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,8 @@ interface Props {
|
|||||||
onQuality: (q: Quality) => void;
|
onQuality: (q: Quality) => void;
|
||||||
streamQuality: Quality;
|
streamQuality: Quality;
|
||||||
onStreamQuality: (q: Quality) => void;
|
onStreamQuality: (q: Quality) => void;
|
||||||
|
subLang: string;
|
||||||
|
onSubLang: (l: string) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +47,7 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
|
|||||||
|
|
||||||
export default function Settings({
|
export default function Settings({
|
||||||
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
||||||
streamQuality, onStreamQuality, onError,
|
streamQuality, onStreamQuality, subLang, onSubLang, onError,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
||||||
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
||||||
@@ -182,6 +185,26 @@ export default function Settings({
|
|||||||
Streaming quality applies when you play something you have not downloaded.
|
Streaming quality applies when you play something you have not downloaded.
|
||||||
Best lets the player adapt to your connection; a fixed height pins it.
|
Best lets the player adapt to your connection; a fixed height pins it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label className="mt-3 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||||
|
<span className={LABEL}>Subtitles</span>
|
||||||
|
<select
|
||||||
|
value={subLang}
|
||||||
|
onChange={(e) => onSubLang(e.target.value)}
|
||||||
|
className={SELECT}
|
||||||
|
>
|
||||||
|
{SUB_LANGS.map((l) => (
|
||||||
|
<option key={l.value} value={l.value}>
|
||||||
|
{l.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className={`mt-2 ${HELP}`}>
|
||||||
|
Shown automatically when a video has subtitles in this language, including
|
||||||
|
YouTube's auto-generated ones, and saved alongside anything you download so
|
||||||
|
they work offline. You can still switch tracks from the player.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface Props {
|
|||||||
onView: (v: ViewMode) => void;
|
onView: (v: ViewMode) => void;
|
||||||
sidebarHidden: boolean;
|
sidebarHidden: boolean;
|
||||||
onShowSidebar: () => void;
|
onShowSidebar: () => void;
|
||||||
|
/** Present only when there is something to delete. */
|
||||||
|
onDeleteAll?: () => void;
|
||||||
/** Matches the sidebar's inset so the two headers share a baseline. */
|
/** Matches the sidebar's inset so the two headers share a baseline. */
|
||||||
titleBarInset: boolean;
|
titleBarInset: boolean;
|
||||||
}
|
}
|
||||||
@@ -58,7 +60,7 @@ export default function TopBar({
|
|||||||
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
|
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
|
||||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||||
sidebarHidden, onShowSidebar, titleBarInset,
|
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const pct = refreshProgress && refreshProgress.total > 0
|
const pct = refreshProgress && refreshProgress.total > 0
|
||||||
? (refreshProgress.done / refreshProgress.total) * 100
|
? (refreshProgress.done / refreshProgress.total) * 100
|
||||||
@@ -105,6 +107,20 @@ export default function TopBar({
|
|||||||
Downloaded only
|
Downloaded only
|
||||||
</Toggle>
|
</Toggle>
|
||||||
|
|
||||||
|
{downloadedOnly && onDeleteAll && (
|
||||||
|
<button
|
||||||
|
onClick={onDeleteAll}
|
||||||
|
title="Delete every download"
|
||||||
|
className={`inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap
|
||||||
|
rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium
|
||||||
|
text-slate-500 hover:border-red-500 hover:text-red-600
|
||||||
|
dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500
|
||||||
|
dark:hover:text-red-400`}
|
||||||
|
>
|
||||||
|
Delete all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
|
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
|
||||||
title="Hide Shorts from the feed">
|
title="Hide Shorts from the feed">
|
||||||
Hide Shorts
|
Hide Shorts
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import ReactDOM from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
// The webview's own context menu offers Reload, Back and Inspect — page
|
||||||
|
// actions in something that is not meant to read as a page. Text fields keep
|
||||||
|
// theirs so copy and paste stay reachable.
|
||||||
|
document.addEventListener("contextmenu", (e) => {
|
||||||
|
const el = e.target as HTMLElement | null;
|
||||||
|
const editable =
|
||||||
|
el?.closest("input, textarea, [contenteditable='true']") !== null &&
|
||||||
|
el?.closest("input, textarea, [contenteditable='true']") !== undefined;
|
||||||
|
if (!editable) e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -110,3 +110,15 @@ export const STREAM_QUALITIES: Array<{ value: Quality; label: string }> = [
|
|||||||
{ value: "720", label: "720p" },
|
{ value: "720", label: "720p" },
|
||||||
{ value: "480", label: "480p" },
|
{ value: "480", label: "480p" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Preferred subtitle language: shown when available, and downloaded. */
|
||||||
|
export const SUB_LANGS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: "off", label: "None" },
|
||||||
|
{ value: "en", label: "English" },
|
||||||
|
{ value: "nl", label: "Nederlands" },
|
||||||
|
{ value: "de", label: "Deutsch" },
|
||||||
|
{ value: "fr", label: "Français" },
|
||||||
|
{ value: "es", label: "Español" },
|
||||||
|
{ value: "it", label: "Italiano" },
|
||||||
|
{ value: "pt", label: "Português" },
|
||||||
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user