From 9bb7b7122590be70e4ff90863cbd2da09e15ce02 Mon Sep 17 00:00:00 2001 From: vincent Date: Sat, 29 Aug 2026 11:28:05 +0200 Subject: [PATCH] feat: uniform control height, chrome auto-hide, streaming quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every control in the app now shares one height (CONTROL_H), with width still growing to fit its label. The player transport is larger, Back is an icon, and the footer buttons match the prev/next size. Player chrome — transport and edge arrows — fades after 2.6s of inactivity and never hides while paused. The title-bar strip collapses in macOS window fullscreen, where the traffic lights are gone and it was just a blank white bar. Streaming quality is now selectable alongside download quality. A cap is applied by rewriting YouTube's HLS master playlist down to the best variant at or below the chosen height, keeping its audio group. That playlist is served from a small loopback HTTP server: Safari's native HLS is backed by AVFoundation, which cannot read blob: or custom-scheme URLs, so a Blob URL silently fails to play. Settings closes with an icon button and its selects match the shared control height. --- src-tauri/capabilities/default.json | 3 +- src-tauri/gen/schemas/capabilities.json | 2 +- src-tauri/src/commands.rs | 146 +++++++++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/playlist_server.rs | 125 ++++++++++++++++++++ src/App.tsx | 35 ++++-- src/api.ts | 17 ++- src/components/DownloadButton.tsx | 33 +++--- src/components/Player.tsx | 54 +++++++-- src/components/PlayerControls.tsx | 28 ++--- src/components/Settings.tsx | 50 ++++++-- src/components/Sidebar.tsx | 6 +- src/components/TopBar.tsx | 27 ++--- src/components/ui.tsx | 31 +++-- src/hooks/useWindowFullscreen.ts | 24 ++++ src/types.ts | 13 +++ 16 files changed, 503 insertions(+), 92 deletions(-) create mode 100644 src-tauri/src/playlist_server.rs create mode 100644 src/hooks/useWindowFullscreen.ts diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6f15304..a578be3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "core:default", "opener:default", "dialog:default", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "core:window:allow-is-fullscreen" ] } diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index b07666d..cf3684a 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b289933..4136d54 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -7,6 +7,7 @@ use crate::models::{ Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs, }; use crate::net; +use crate::playlist_server::PlaylistServer; use crate::takeout; use crate::thumbs; @@ -35,6 +36,7 @@ pub struct AppState { pub app_data: PathBuf, pub children: Arc>>, pub download_slots: Arc, + pub playlists: PlaylistServer, } #[derive(Clone, Serialize)] @@ -174,18 +176,49 @@ pub async fn import_takeout_csv( /// playlist, which lists H.264 + AAC variants up to 1080p with separate audio /// tracks — exactly the shape AVFoundation plays natively in WKWebView, with /// adaptive bitrate for free. +#[derive(Serialize)] +pub struct Stream { + /// Direct URL to hand the player, when no filtering was needed. + pub url: Option, + /// A rewritten HLS master playlist, when a quality cap was applied. The + /// frontend turns this into a Blob URL — every URL inside is absolute, so + /// the playlist works from anywhere. + pub playlist: Option, +} + #[tauri::command] -pub async fn resolve_stream(video_id: String) -> Result { +pub async fn resolve_stream( + video_id: String, + max_height: Option, + state: State<'_, AppState>, +) -> Result { let url = format!("https://www.youtube.com/watch?v={video_id}"); // The HLS master playlist. Every m3u8 format shares the same manifest_url, // so any one of them yields the master. - if let Some(u) = yt_dlp_print( + if let Some(master) = yt_dlp_print( &["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url], ) .await { - return Ok(u); + let Some(cap) = max_height else { + return Ok(Stream { url: Some(master), playlist: None }); + }; + // Fetch and cut it down. If anything about that fails, the adaptive + // master still plays — a quality preference is not worth an error. + return match state.http.get(&master).send().await { + Ok(resp) => match resp.text().await { + Ok(body) => Ok(match filter_master_playlist(&body, cap) { + Some(filtered) => Stream { + url: Some(state.playlists.publish(filtered).await), + playlist: None, + }, + None => Stream { url: Some(master), playlist: None }, + }), + Err(_) => Ok(Stream { url: Some(master), playlist: None }), + }, + Err(_) => Ok(Stream { url: Some(master), playlist: None }), + }; } // Rare fallback: an old-style progressive muxed MP4. @@ -198,12 +231,63 @@ pub async fn resolve_stream(video_id: String) -> Result { ]) .await { - return Ok(u); + return Ok(Stream { url: Some(u), playlist: None }); } Err("Could not find a playable stream for this video.".into()) } +/// Keeps only the highest video variant at or below `max_height`, along with +/// every `EXT-X-MEDIA` line (the audio and subtitle groups it references). +/// +/// Returns `None` if nothing matched, so the caller can fall back to adaptive +/// rather than hand the player an empty playlist. +pub fn filter_master_playlist(body: &str, max_height: u32) -> Option { + let lines: Vec<&str> = body.lines().collect(); + let mut media = Vec::new(); + // (height, stream-inf line, url line) + let mut variants: Vec<(u32, &str, &str)> = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + if line.starts_with("#EXT-X-MEDIA:") { + media.push(*line); + } else if line.starts_with("#EXT-X-STREAM-INF:") { + let Some(url) = lines.get(i + 1) else { continue }; + if url.starts_with('#') || url.trim().is_empty() { + continue; + } + if let Some(h) = resolution_height(line) { + if h <= max_height { + variants.push((h, *line, *url)); + } + } + } + } + + let best = variants.iter().max_by_key(|(h, _, _)| *h)?; + + let mut out = String::from("#EXTM3U +#EXT-X-INDEPENDENT-SEGMENTS +"); + for m in media { + out.push_str(m); + out.push('\n'); + } + out.push_str(best.1); + out.push('\n'); + out.push_str(best.2); + out.push('\n'); + Some(out) +} + +/// Pulls the vertical size out of a `RESOLUTION=1920x1080` attribute. +fn resolution_height(stream_inf: &str) -> Option { + let at = stream_inf.find("RESOLUTION=")? + "RESOLUTION=".len(); + let rest = &stream_inf[at..]; + let value = rest.split(&[',', ' '][..]).next()?; + value.split(&['x', 'X'][..]).nth(1)?.parse().ok() +} + /// Runs yt-dlp and returns its first non-empty stdout line, or None. async fn yt_dlp_print(args: &[&str]) -> Option { let mut cmd = tokio::process::Command::new(bin("yt-dlp")); @@ -642,5 +726,59 @@ pub fn build_state(app: &AppHandle) -> Result { app_data, children: Arc::new(Mutex::new(HashMap::new())), download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)), + playlists: PlaylistServer::start()?, }) } + +#[cfg(test)] +mod tests { + use super::filter_master_playlist; + + const MASTER: &str = concat!( + "#EXTM3U\n", + "#EXT-X-INDEPENDENT-SEGMENTS\n", + "#EXT-X-MEDIA:URI=\"https://a/audio.m3u8\",TYPE=AUDIO,GROUP-ID=\"234\",DEFAULT=YES\n", + "#EXT-X-STREAM-INF:BANDWIDTH=756324,CODECS=\"avc1,mp4a\",RESOLUTION=640x360,AUDIO=\"234\"\n", + "https://a/360.m3u8\n", + "#EXT-X-STREAM-INF:BANDWIDTH=3878958,CODECS=\"avc1,mp4a\",RESOLUTION=1280x720,AUDIO=\"234\"\n", + "https://a/720.m3u8\n", + "#EXT-X-STREAM-INF:BANDWIDTH=6039686,CODECS=\"avc1,mp4a\",RESOLUTION=1920x1080,AUDIO=\"234\"\n", + "https://a/1080.m3u8\n", + ); + + #[test] + fn keeps_the_best_variant_at_or_below_the_cap() { + let out = filter_master_playlist(MASTER, 720).unwrap(); + assert!(out.contains("https://a/720.m3u8")); + assert!(!out.contains("https://a/1080.m3u8")); + assert!(!out.contains("https://a/360.m3u8")); + } + + #[test] + fn always_carries_the_audio_group_across() { + // A video-only variant would play silently, so EXT-X-MEDIA must survive. + let out = filter_master_playlist(MASTER, 360).unwrap(); + assert!(out.contains("TYPE=AUDIO")); + assert!(out.contains("https://a/audio.m3u8")); + assert!(out.starts_with("#EXTM3U")); + } + + #[test] + fn an_exact_match_is_included_not_excluded() { + let out = filter_master_playlist(MASTER, 1080).unwrap(); + assert!(out.contains("https://a/1080.m3u8")); + } + + #[test] + fn nothing_below_the_cap_yields_none_so_the_caller_can_fall_back() { + assert!(filter_master_playlist(MASTER, 144).is_none()); + assert!(filter_master_playlist("#EXTM3U\n", 1080).is_none()); + assert!(filter_master_playlist("", 1080).is_none()); + } + + #[test] + fn a_stream_inf_with_no_following_url_is_skipped() { + let truncated = "#EXTM3U\n#EXT-X-STREAM-INF:RESOLUTION=1280x720\n"; + assert!(filter_master_playlist(truncated, 1080).is_none()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1cdc1af..59e4d1d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod downloader; pub mod feed; pub mod models; pub mod net; +pub mod playlist_server; pub mod takeout; pub mod thumbs; diff --git a/src-tauri/src/playlist_server.rs b/src-tauri/src/playlist_server.rs new file mode 100644 index 0000000..f38aac4 --- /dev/null +++ b/src-tauri/src/playlist_server.rs @@ -0,0 +1,125 @@ +//! A minimal loopback HTTP server for rewritten HLS playlists. +//! +//! When a streaming quality cap is set we hand the player a filtered master +//! playlist rather than YouTube's. That playlist has to be reachable over +//! `http://`: Safari's native HLS is backed by AVFoundation, which cannot read +//! `blob:` or custom-scheme URLs — only a real HTTP one. Hence ~80 lines of +//! server rather than a one-line Blob URL. +//! +//! It binds to 127.0.0.1 on an ephemeral port and only ever serves playlists it +//! was explicitly handed, addressed by an unguessable token. + +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +/// Playlists are small; keeping the last few is plenty, and it stops a long +/// session from growing the map without bound. +const MAX_ENTRIES: usize = 8; + +#[derive(Clone)] +pub struct PlaylistServer { + port: u16, + entries: Arc>>, +} + +impl PlaylistServer { + pub fn start() -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| format!("Cannot bind playlist server: {e}"))?; + listener + .set_nonblocking(true) + .map_err(|e| format!("Cannot configure playlist server: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("Cannot read playlist server port: {e}"))? + .port(); + + let server = PlaylistServer { + port, + entries: Arc::new(Mutex::new(Vec::new())), + }; + + let entries = server.entries.clone(); + tauri::async_runtime::spawn(async move { + let Ok(listener) = TcpListener::from_std(listener) else { + return; + }; + loop { + let Ok((stream, _)) = listener.accept().await else { + continue; + }; + let entries = entries.clone(); + tauri::async_runtime::spawn(async move { + let _ = serve(stream, entries).await; + }); + } + }); + + Ok(server) + } + + /// Publishes a playlist and returns the URL to hand the player. + pub async fn publish(&self, body: String) -> String { + let token = token(); + let mut entries = self.entries.lock().await; + entries.push((token.clone(), body)); + if entries.len() > MAX_ENTRIES { + entries.remove(0); + } + format!("http://127.0.0.1:{}/{token}.m3u8", self.port) + } +} + +/// Not cryptographic, just unguessable enough that another local process +/// cannot stumble onto a playlist by iterating short paths. +fn token() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let addr = Box::into_raw(Box::new(0u8)) as usize; + // Reclaim the allocation used purely as an address source. + unsafe { drop(Box::from_raw(addr as *mut u8)) }; + format!("{nanos:x}{addr:x}") +} + +async fn serve( + mut stream: tokio::net::TcpStream, + entries: Arc>>, +) -> std::io::Result<()> { + let mut buf = vec![0u8; 2048]; + let n = stream.read(&mut buf).await?; + let request = String::from_utf8_lossy(&buf[..n]); + + // "GET /.m3u8 HTTP/1.1" + let path = request + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .unwrap_or(""); + let wanted = path.trim_start_matches('/').trim_end_matches(".m3u8"); + + let body = { + let entries = entries.lock().await; + entries + .iter() + .find(|(t, _)| t == wanted) + .map(|(_, b)| b.clone()) + }; + + let response = match body { + Some(body) => format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/vnd.apple.mpegurl\r\n\ + Content-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ), + None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(), + }; + + stream.write_all(response.as_bytes()).await?; + stream.flush().await +} diff --git a/src/App.tsx b/src/App.tsx index 7a178d4..6a6d8b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,11 @@ import { useAppearance } from "./hooks/useAppearance"; import { useConnectivity } from "./hooks/useConnectivity"; import { useDownloads } from "./hooks/useDownloads"; import { useFeed } from "./hooks/useFeed"; -import { QUALITIES, type FeedFilter, type FeedItem, type Quality, type RefreshProgress } from "./types"; +import { useWindowFullscreen } from "./hooks/useWindowFullscreen"; +import { + QUALITIES, STREAM_QUALITIES, + type FeedFilter, type FeedItem, type Quality, type RefreshProgress, +} from "./types"; const TOAST_MS = 2400; /** How often to pull new videos while online, so the feed stays live. */ @@ -44,6 +48,14 @@ export default function App() { const [showSettings, setShowSettings] = useState(false); // Index into the current feed, so the player can step through it. const [playingIndex, setPlayingIndex] = useState(null); + const [streamQuality, setStreamQuality] = useState(() => { + try { + const stored = localStorage.getItem("flighttube.streamQuality"); + return STREAM_QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best"; + } catch { + return "best"; + } + }); const [quality, setQuality] = useState(() => { try { const stored = localStorage.getItem("flighttube.quality"); @@ -58,6 +70,7 @@ export default function App() { const [failure, setFailure] = useState(null); const { mode, setMode } = useAppearance(); + const windowFullscreen = useWindowFullscreen(); const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity(); // A toast reports success and fades; a modal reports a failure or asks a @@ -74,13 +87,14 @@ export default function App() { try { localStorage.setItem("flighttube.view", view); localStorage.setItem("flighttube.quality", quality); + localStorage.setItem("flighttube.streamQuality", streamQuality); localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0"); localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0"); localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0"); } catch { /* storage blocked */ } - }, [view, quality, downloadedOnly, hideShorts, sidebarHidden]); + }, [view, quality, streamQuality, downloadedOnly, hideShorts, sidebarHidden]); // Offline, the only videos that can be played are the ones already on disk, // so the feed collapses to those regardless of the toggle. @@ -203,11 +217,15 @@ export default function App() { return (
{/* The webview paints the title bar itself. It sits directly above the - sidebar and top bar, so it takes the panel colour, not the page's. */} -
+ sidebar and top bar, so it takes the panel colour, not the page's. + In macOS window fullscreen the traffic lights are gone, so the strip + would just be a blank bar — it collapses instead. */} + {!windowFullscreen && ( +
+ )}
{/* With the sidebar hidden, a thin strip along the left edge brings it @@ -322,6 +340,7 @@ export default function App() { path={playing.path} index={playingIndex} total={items.length} + maxHeight={streamQuality === "best" ? null : Number(streamQuality)} onPrev={ stepFrom(playingIndex, -1) != null ? () => setPlayingIndex(stepFrom(playingIndex, -1)) @@ -361,6 +380,8 @@ export default function App() { onAppearance={setMode} quality={quality} onQuality={setQuality} + streamQuality={streamQuality} + onStreamQuality={setStreamQuality} onError={setFailure} onImported={(n) => { reload(); diff --git a/src/api.ts b/src/api.ts index c714ccc..7e9b7b2 100644 --- a/src/api.ts +++ b/src/api.ts @@ -12,6 +12,7 @@ import type { Quality, RefreshProgress, RefreshSummary, + Stream, } from "./types"; export const checkPrereqs = () => invoke("check_prereqs"); @@ -37,9 +38,19 @@ export const deleteDownload = (videoId: string) => export const getConnectivity = () => invoke("get_connectivity"); -/** A directly playable URL (HLS master playlist) for an undownloaded video. */ -export const resolveStream = (videoId: string) => - invoke("resolve_stream", { videoId }); +/** + * A directly playable URL for an undownloaded video. When a quality cap is set + * the backend serves a rewritten playlist from its own loopback server, so this + * is always a plain URL either way. + */ +export async function resolveStream( + videoId: string, + maxHeight: number | null, +): Promise { + const s = await invoke("resolve_stream", { videoId, maxHeight }); + if (!s.url) throw new Error("No playable stream was returned."); + return s.url; +} export const setLibraryPath = (path: string) => invoke("set_library_path", { path }); diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx index a3857ac..31eed66 100644 --- a/src/components/DownloadButton.tsx +++ b/src/components/DownloadButton.tsx @@ -1,6 +1,7 @@ import type { LiveDownload } from "../hooks/useDownloads"; import type { FeedItem } from "../types"; import { humanEta } from "./format"; +import { CONTROL_H } from "./ui"; interface Props { item: FeedItem; @@ -11,7 +12,9 @@ interface Props { onDelete: () => void; } -const CHIP = "rounded-lg px-2.5 py-1.5 text-[11px] font-medium shrink-0 cursor-pointer"; +const CHIP = + `inline-flex ${CONTROL_H} shrink-0 cursor-pointer items-center justify-center rounded-lg ` + + "px-2.5 text-[12px] font-medium"; export default function DownloadButton({ item, live, online, onDownload, onCancel, onDelete, @@ -38,22 +41,18 @@ export default function DownloadButton({ const known = state === "running" && pct != null; return ( ); diff --git a/src/components/Player.tsx b/src/components/Player.tsx index 24f2e7d..51e6ec5 100644 --- a/src/components/Player.tsx +++ b/src/components/Player.tsx @@ -16,6 +16,8 @@ interface Props { /** Present only while streaming, so the video can be saved from here. */ onDownload?: () => void; downloading?: boolean; + /** Max height for streaming, or null to let the player adapt. */ + maxHeight: number | null; /** Position in the current feed, for the "3 of 180" readout. */ index: number; total: number; @@ -92,12 +94,16 @@ const RESUME_EDGE_S = 5; * cannot be used: it rejects a `tauri://` origin with "Error 153". */ export default function Player({ - item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, index, total, + item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading, + maxHeight, index, total, }: Props) { const streaming = path === null; const [src, setSrc] = useState(path ? fileUrl(path) : null); const [error, setError] = useState(null); const [buffering, setBuffering] = useState(true); + // Controls and edge arrows fade away while you are just watching. + const [chromeVisible, setChromeVisible] = useState(true); + const hideTimer = useRef(undefined); const videoRef = useRef(null); const stageRef = useRef(null); const lastSave = useRef(0); @@ -110,13 +116,14 @@ export default function Player({ let cancelled = false; setSrc(null); setError(null); - resolveStream(item.id) + resolveStream(item.id, maxHeight) .then((u) => !cancelled && setSrc(u)) .catch((e) => !cancelled && setError(String(e))); return () => { cancelled = true; }; - }, [item.id, path]); + }, [item.id, path, maxHeight]); + const persist = useCallback(() => { const v = videoRef.current; @@ -176,8 +183,22 @@ export default function Player({ const edgeBtn = "absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " + "bg-slate-950/55 text-2xl leading-none text-white backdrop-blur cursor-pointer " + - "opacity-0 transition-opacity group-hover/stage:opacity-100 focus-visible:opacity-100 " + - "hover:bg-slate-950/80 disabled:hidden"; + "transition-opacity duration-200 hover:bg-slate-950/80 disabled:hidden " + + (chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"); + + const showChrome = useCallback(() => { + setChromeVisible(true); + window.clearTimeout(hideTimer.current); + hideTimer.current = window.setTimeout(() => { + // Never hide while paused — there would be no way back to play. + if (!videoRef.current?.paused) setChromeVisible(false); + }, 2600); + }, []); + + useEffect(() => { + showChrome(); + return () => window.clearTimeout(hideTimer.current); + }, [showChrome, src]); const navBtn = "rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " + @@ -192,8 +213,11 @@ export default function Player({ className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900" > - )} diff --git a/src/components/PlayerControls.tsx b/src/components/PlayerControls.tsx index 9ee2d2d..8a6ffe7 100644 --- a/src/components/PlayerControls.tsx +++ b/src/components/PlayerControls.tsx @@ -20,7 +20,7 @@ function clock(seconds: number): string { } const btn = - "grid size-8 shrink-0 place-items-center rounded-md text-white/90 cursor-pointer " + + "grid size-10 shrink-0 place-items-center rounded-lg text-white/90 cursor-pointer " + "hover:bg-white/15 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"; /** @@ -113,17 +113,17 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
e.stopPropagation()} - className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2 bg-gradient-to-t - from-slate-950/85 via-slate-950/60 to-transparent px-4 pb-3 pt-8" + className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2.5 bg-gradient-to-t + from-slate-950/90 via-slate-950/65 to-transparent px-5 pb-4 pt-10" > @@ -143,12 +143,12 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props className={btn} title={`Forward ${SKIP_S}s`} > - + - {clock(time)} + {clock(time)} - {clock(duration)} + {clock(duration)} +
@@ -135,9 +154,7 @@ export default function Settings({ + + +

+ Streaming quality applies when you play something you have not downloaded. + Best lets the player adapt to your connection; a fixed height pins it. +

diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8d3a0a4..20fdcb5 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import type { ChannelWithCount } from "../types"; -import { BTN_CHROME, HEADING } from "./ui"; +import { HEADING, ICON_BTN } from "./ui"; interface Props { channels: ChannelWithCount[]; @@ -49,7 +49,7 @@ export default function Sidebar({ onClick={onOpenSettings} title="Settings" aria-label="Settings" - className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`} + className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`} > @@ -60,7 +60,7 @@ export default function Sidebar({ onClick={onHide} title="Hide subscriptions" aria-label="Hide subscriptions" - className={`${BTN_CHROME} grid size-7 cursor-pointer place-items-center`} + className={`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white`} > diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index bcc49f0..d10666b 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,4 +1,4 @@ -import { INPUT, Segmented } from "./ui"; +import { CONTROL_H, ICON_BTN, INPUT, Segmented } from "./ui"; export type ViewMode = "list" | "grid"; @@ -39,7 +39,7 @@ function Toggle({ title={title} disabled={disabled} className={ - "cursor-pointer whitespace-nowrap rounded-lg border px-2.5 py-1.5 text-[11px] " + + `inline-flex ${CONTROL_H} cursor-pointer items-center whitespace-nowrap rounded-lg border px-2.5 text-[12px] ` + "font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 " + (active ? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400" @@ -73,10 +73,9 @@ export default function TopBar({ onClick={onShowSidebar} title="Show subscriptions" aria-label="Show subscriptions" - className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg - border border-slate-300 text-slate-500 hover:border-sky-500 - hover:text-sky-600 dark:border-slate-700 dark:text-slate-400 - dark:hover:border-sky-500 dark:hover:text-sky-400" + className={`${ICON_BTN} border border-slate-300 text-slate-500 hover:border-sky-500 + hover:text-sky-600 dark:border-slate-700 dark:text-slate-400 + dark:hover:border-sky-500 dark:hover:text-sky-400`} > @@ -87,7 +86,7 @@ export default function TopBar({ value={search} onChange={(e) => onSearch(e.target.value)} placeholder="Search videos and channels" - className={`${INPUT} min-w-72 max-w-sm flex-1 py-1.5`} + className={`${INPUT} min-w-72 max-w-sm flex-1`} /> @@ -125,11 +124,11 @@ export default function TopBar({ ? "Offline mode is forced on — click to go back online" : "Simulate being offline" } - className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border - border-slate-300 px-2.5 py-1.5 text-[11px] font-medium text-slate-500 - hover:border-sky-500 hover:text-sky-600 - dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500 - dark:hover:text-sky-400" + className={`inline-flex ${CONTROL_H} cursor-pointer items-center gap-1.5 whitespace-nowrap + rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium text-slate-500 + hover:border-sky-500 hover:text-sky-600 + dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500 + dark:hover:text-sky-400`} > + className={`${ICON_BTN} bg-sky-500 text-white hover:bg-sky-400`}> ({ return (
{options.map((o) => { const active = o.value === value; @@ -94,7 +107,7 @@ export function Segmented({ key={o.value} onClick={() => onChange(o.value)} className={ - "rounded-md px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer " + + "h-full rounded-md px-2.5 text-[12px] font-medium transition-colors cursor-pointer " + (active ? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!" : "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " + @@ -172,7 +185,7 @@ export function Dialog({ {onConfirm && ( diff --git a/src/hooks/useWindowFullscreen.ts b/src/hooks/useWindowFullscreen.ts new file mode 100644 index 0000000..63b7c46 --- /dev/null +++ b/src/hooks/useWindowFullscreen.ts @@ -0,0 +1,24 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useEffect, useState } from "react"; + +/** + * True while the macOS *window* is fullscreen (the green button), as distinct + * from the video being fullscreen. In that state the traffic lights are gone, + * so the title-bar strip the app paints is pure dead space and must collapse. + */ +export function useWindowFullscreen(): boolean { + const [full, setFull] = useState(false); + + useEffect(() => { + const win = getCurrentWindow(); + let unlisten: (() => void) | undefined; + const check = () => { + win.isFullscreen().then(setFull).catch(() => {}); + }; + check(); + win.onResized(check).then((u) => (unlisten = u)); + return () => unlisten?.(); + }, []); + + return full; +} diff --git a/src/types.ts b/src/types.ts index e554a9c..dda8f16 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,6 +89,11 @@ export interface ImportPreview { /** "best" or a maximum height in pixels. */ export type Quality = "best" | "2160" | "1440" | "1080" | "720" | "480"; +export interface Stream { + url: string | null; + playlist: string | null; +} + export const QUALITIES: Array<{ value: Quality; label: string }> = [ { value: "best", label: "Best available (up to 4K)" }, { value: "2160", label: "2160p — 4K" }, @@ -97,3 +102,11 @@ export const QUALITIES: Array<{ value: Quality; label: string }> = [ { value: "720", label: "720p" }, { value: "480", label: "480p" }, ]; + +/** Streaming tops out at 1080p — YouTube's HLS carries nothing higher. */ +export const STREAM_QUALITIES: Array<{ value: Quality; label: string }> = [ + { value: "best", label: "Best available (adaptive)" }, + { value: "1080", label: "1080p" }, + { value: "720", label: "720p" }, + { value: "480", label: "480p" }, +];