feat: uniform control height, chrome auto-hide, streaming quality

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.
This commit is contained in:
vincent
2026-08-29 11:28:05 +02:00
parent 211823f265
commit 9bb7b71225
16 changed files with 503 additions and 92 deletions
+2 -1
View File
@@ -9,6 +9,7 @@
"core:default", "core:default",
"opener:default", "opener:default",
"dialog:default", "dialog:default",
"core:window:allow-start-dragging" "core:window:allow-start-dragging",
"core:window:allow-is-fullscreen"
] ]
} }
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default","dialog:default","core:window:allow-start-dragging"]}} {"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"]}}
+142 -4
View File
@@ -7,6 +7,7 @@ use crate::models::{
Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs, Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs,
}; };
use crate::net; use crate::net;
use crate::playlist_server::PlaylistServer;
use crate::takeout; use crate::takeout;
use crate::thumbs; use crate::thumbs;
@@ -35,6 +36,7 @@ pub struct AppState {
pub app_data: PathBuf, pub app_data: PathBuf,
pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>, pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
pub download_slots: Arc<Semaphore>, pub download_slots: Arc<Semaphore>,
pub playlists: PlaylistServer,
} }
#[derive(Clone, Serialize)] #[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 /// playlist, which lists H.264 + AAC variants up to 1080p with separate audio
/// tracks — exactly the shape AVFoundation plays natively in WKWebView, with /// tracks — exactly the shape AVFoundation plays natively in WKWebView, with
/// adaptive bitrate for free. /// adaptive bitrate for free.
#[derive(Serialize)]
pub struct Stream {
/// Direct URL to hand the player, when no filtering was needed.
pub url: Option<String>,
/// 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<String>,
}
#[tauri::command] #[tauri::command]
pub async fn resolve_stream(video_id: String) -> Result<String, String> { pub async fn resolve_stream(
video_id: String,
max_height: Option<u32>,
state: State<'_, AppState>,
) -> Result<Stream, String> {
let url = format!("https://www.youtube.com/watch?v={video_id}"); let url = format!("https://www.youtube.com/watch?v={video_id}");
// The HLS master playlist. Every m3u8 format shares the same manifest_url, // The HLS master playlist. Every m3u8 format shares the same manifest_url,
// so any one of them yields the master. // 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], &["-f", "bv*[protocol^=m3u8]", "--print", "%(manifest_url)s", &url],
) )
.await .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. // Rare fallback: an old-style progressive muxed MP4.
@@ -198,12 +231,63 @@ pub async fn resolve_stream(video_id: String) -> Result<String, String> {
]) ])
.await .await
{ {
return Ok(u); return Ok(Stream { url: Some(u), playlist: None });
} }
Err("Could not find a playable stream for this video.".into()) 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<String> {
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<u32> {
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. /// Runs yt-dlp and returns its first non-empty stdout line, or None.
async fn yt_dlp_print(args: &[&str]) -> Option<String> { async fn yt_dlp_print(args: &[&str]) -> Option<String> {
let mut cmd = tokio::process::Command::new(bin("yt-dlp")); let mut cmd = tokio::process::Command::new(bin("yt-dlp"));
@@ -642,5 +726,59 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
app_data, app_data,
children: Arc::new(Mutex::new(HashMap::new())), children: Arc::new(Mutex::new(HashMap::new())),
download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)), 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());
}
}
+1
View File
@@ -4,6 +4,7 @@ pub mod downloader;
pub mod feed; pub mod feed;
pub mod models; pub mod models;
pub mod net; pub mod net;
pub mod playlist_server;
pub mod takeout; pub mod takeout;
pub mod thumbs; pub mod thumbs;
+125
View File
@@ -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<Mutex<Vec<(String, String)>>>,
}
impl PlaylistServer {
pub fn start() -> Result<Self, String> {
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<Mutex<Vec<(String, String)>>>,
) -> 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 /<token>.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
}
+24 -3
View File
@@ -13,7 +13,11 @@ import { useAppearance } from "./hooks/useAppearance";
import { useConnectivity } from "./hooks/useConnectivity"; import { useConnectivity } from "./hooks/useConnectivity";
import { useDownloads } from "./hooks/useDownloads"; import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed"; 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; const TOAST_MS = 2400;
/** How often to pull new videos while online, so the feed stays live. */ /** 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); 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 [streamQuality, setStreamQuality] = useState<Quality>(() => {
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<Quality>(() => { const [quality, setQuality] = useState<Quality>(() => {
try { try {
const stored = localStorage.getItem("flighttube.quality"); const stored = localStorage.getItem("flighttube.quality");
@@ -58,6 +70,7 @@ export default function App() {
const [failure, setFailure] = useState<string | null>(null); const [failure, setFailure] = useState<string | null>(null);
const { mode, setMode } = useAppearance(); const { mode, setMode } = useAppearance();
const windowFullscreen = useWindowFullscreen();
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity(); const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
// A toast reports success and fades; a modal reports a failure or asks a // A toast reports success and fades; a modal reports a failure or asks a
@@ -74,13 +87,14 @@ export default function App() {
try { try {
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.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, downloadedOnly, hideShorts, sidebarHidden]); }, [view, quality, streamQuality, 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.
@@ -203,11 +217,15 @@ export default function App() {
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
{/* The webview paints the title bar itself. It sits directly above the {/* 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 && (
<div <div
data-tauri-drag-region data-tauri-drag-region
className="h-9 shrink-0 bg-white dark:bg-slate-900" className="h-9 shrink-0 bg-white dark:bg-slate-900"
/> />
)}
<div className="relative flex min-h-0 flex-1 flex-col lg:flex-row"> <div className="relative flex min-h-0 flex-1 flex-col lg:flex-row">
{/* With the sidebar hidden, a thin strip along the left edge brings it {/* With the sidebar hidden, a thin strip along the left edge brings it
@@ -322,6 +340,7 @@ export default function App() {
path={playing.path} path={playing.path}
index={playingIndex} index={playingIndex}
total={items.length} total={items.length}
maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
onPrev={ onPrev={
stepFrom(playingIndex, -1) != null stepFrom(playingIndex, -1) != null
? () => setPlayingIndex(stepFrom(playingIndex, -1)) ? () => setPlayingIndex(stepFrom(playingIndex, -1))
@@ -361,6 +380,8 @@ export default function App() {
onAppearance={setMode} onAppearance={setMode}
quality={quality} quality={quality}
onQuality={setQuality} onQuality={setQuality}
streamQuality={streamQuality}
onStreamQuality={setStreamQuality}
onError={setFailure} onError={setFailure}
onImported={(n) => { onImported={(n) => {
reload(); reload();
+14 -3
View File
@@ -12,6 +12,7 @@ import type {
Quality, Quality,
RefreshProgress, RefreshProgress,
RefreshSummary, RefreshSummary,
Stream,
} from "./types"; } from "./types";
export const checkPrereqs = () => invoke<Prereqs>("check_prereqs"); export const checkPrereqs = () => invoke<Prereqs>("check_prereqs");
@@ -37,9 +38,19 @@ export const deleteDownload = (videoId: string) =>
export const getConnectivity = () => invoke<boolean>("get_connectivity"); export const getConnectivity = () => invoke<boolean>("get_connectivity");
/** A directly playable URL (HLS master playlist) for an undownloaded video. */ /**
export const resolveStream = (videoId: string) => * A directly playable URL for an undownloaded video. When a quality cap is set
invoke<string>("resolve_stream", { videoId }); * 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<string> {
const s = await invoke<Stream>("resolve_stream", { videoId, maxHeight });
if (!s.url) throw new Error("No playable stream was returned.");
return s.url;
}
export const setLibraryPath = (path: string) => export const setLibraryPath = (path: string) =>
invoke<string>("set_library_path", { path }); invoke<string>("set_library_path", { path });
+13 -14
View File
@@ -1,6 +1,7 @@
import type { LiveDownload } from "../hooks/useDownloads"; import type { LiveDownload } from "../hooks/useDownloads";
import type { FeedItem } from "../types"; import type { FeedItem } from "../types";
import { humanEta } from "./format"; import { humanEta } from "./format";
import { CONTROL_H } from "./ui";
interface Props { interface Props {
item: FeedItem; item: FeedItem;
@@ -11,7 +12,9 @@ interface Props {
onDelete: () => void; 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({ export default function DownloadButton({
item, live, online, onDownload, onCancel, onDelete, item, live, online, onDownload, onCancel, onDelete,
@@ -38,22 +41,18 @@ export default function DownloadButton({
const known = state === "running" && pct != null; const known = state === "running" && pct != null;
return ( return (
<button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"} <button onClick={onCancel} title={humanEta(live?.eta ?? null) || "Cancel download"}
className={`${CHIP} group w-[104px] border border-slate-300 text-slate-500 className={`${CHIP} group relative w-[104px] overflow-hidden border border-slate-300
hover:border-red-500 hover:text-red-600 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`}> dark:border-slate-700 dark:text-slate-400 dark:hover:border-red-500
<span className="hidden group-hover:block">Cancel</span> dark:hover:text-red-400`}>
<span className="block group-hover:hidden"> {/* Progress fills the chip itself, so the control stays one line tall. */}
<span className="mb-1 block font-mono tabular-nums">
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
</span>
<span className="block h-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<span <span
className={`block h-full rounded-full bg-sky-500 transition-[width] duration-100 ${ className="absolute inset-y-0 left-0 bg-sky-500/15 transition-[width] duration-100"
known ? "" : "animate-pulse"
}`}
style={{ width: known ? `${pct}%` : "35%" }} style={{ width: known ? `${pct}%` : "35%" }}
/> />
</span> <span className="relative hidden group-hover:inline">Cancel</span>
<span className="relative inline font-mono tabular-nums group-hover:hidden">
{known ? `${pct!.toFixed(0)}%` : state === "queued" ? "Queued" : "Starting"}
</span> </span>
</button> </button>
); );
+43 -11
View File
@@ -16,6 +16,8 @@ interface Props {
/** Present only while streaming, so the video can be saved from here. */ /** Present only while streaming, so the video can be saved from here. */
onDownload?: () => void; onDownload?: () => void;
downloading?: boolean; 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. */ /** Position in the current feed, for the "3 of 180" readout. */
index: number; index: number;
total: number; total: number;
@@ -92,12 +94,16 @@ const RESUME_EDGE_S = 5;
* cannot be used: it rejects a `tauri://` origin with "Error 153". * cannot be used: it rejects a `tauri://` origin with "Error 153".
*/ */
export default function Player({ 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) { }: 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);
// Controls and edge arrows fade away while you are just watching.
const [chromeVisible, setChromeVisible] = useState(true);
const hideTimer = useRef<number | undefined>(undefined);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const stageRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null);
const lastSave = useRef(0); const lastSave = useRef(0);
@@ -110,13 +116,14 @@ export default function Player({
let cancelled = false; let cancelled = false;
setSrc(null); setSrc(null);
setError(null); setError(null);
resolveStream(item.id) resolveStream(item.id, maxHeight)
.then((u) => !cancelled && setSrc(u)) .then((u) => !cancelled && setSrc(u))
.catch((e) => !cancelled && setError(String(e))); .catch((e) => !cancelled && setError(String(e)));
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [item.id, path]); }, [item.id, path, maxHeight]);
const persist = useCallback(() => { const persist = useCallback(() => {
const v = videoRef.current; const v = videoRef.current;
@@ -176,8 +183,22 @@ export default function Player({
const edgeBtn = const edgeBtn =
"absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " + "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 " + "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 " + "transition-opacity duration-200 hover:bg-slate-950/80 disabled:hidden " +
"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 = const navBtn =
"rounded-lg border border-slate-300 px-2 py-1.5 text-[11px] font-medium cursor-pointer " + "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 className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
dark:border-slate-800 dark:bg-slate-900" dark:border-slate-800 dark:bg-slate-900"
> >
<button onClick={leave} className={`${BTN} cursor-pointer py-1.5`}> <button onClick={leave} title="Back to the feed (Esc)" aria-label="Back"
Back className={`${navBtn} w-[30px] p-0`}>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
</svg>
</button> </button>
<button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}> <button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
@@ -229,7 +253,9 @@ export default function Player({
<div <div
ref={stageRef} ref={stageRef}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
className="group/stage relative min-h-0 flex-1 bg-slate-950" onMouseMove={showChrome}
onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)}
className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
> >
{/* Edge arrows, the way a player wants them: big targets on the left and {/* Edge arrows, the way a player wants them: big targets on the left and
right of the picture. They fade in on hover so they never sit on top right of the picture. They fade in on hover so they never sit on top
@@ -301,7 +327,13 @@ export default function Player({
) )
)} )}
{src && !error && ( {src && !error && (
<PlayerControls videoRef={videoRef} stageRef={stageRef} /> <div
className={`transition-opacity duration-200 ${
chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<PlayerControls videoRef={videoRef} stageRef={stageRef} onActivity={showChrome} />
</div>
)} )}
</div> </div>
@@ -323,14 +355,14 @@ export default function Player({
<button <button
onClick={onDownload} onClick={onDownload}
disabled={downloading} disabled={downloading}
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`} className={`${navBtn} whitespace-nowrap`}
> >
{downloading ? "Downloading…" : "Download"} {downloading ? "Downloading…" : "Download"}
</button> </button>
)} )}
<button <button
onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)} onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
className={`${BTN} cursor-pointer whitespace-nowrap py-1.5`} className={`${navBtn} whitespace-nowrap`}
> >
Open on YouTube Open on YouTube
</button> </button>
+14 -14
View File
@@ -20,7 +20,7 @@ function clock(seconds: number): string {
} }
const btn = 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"; "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
<div <div
// Clicks here must not reach the video's own play/pause handler. // Clicks here must not reach the video's own play/pause handler.
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2 bg-gradient-to-t className="absolute inset-x-0 bottom-0 z-20 flex items-center gap-2.5 bg-gradient-to-t
from-slate-950/85 via-slate-950/60 to-transparent px-4 pb-3 pt-8" from-slate-950/90 via-slate-950/65 to-transparent px-5 pb-4 pt-10"
> >
<button onClick={togglePlay} className={btn} title={playing ? "Pause (space)" : "Play (space)"}> <button onClick={togglePlay} className={btn} title={playing ? "Pause (space)" : "Play (space)"}>
{playing ? ( {playing ? (
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor"> <svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
<rect x="6" y="5" width="4" height="14" rx="1" /> <rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" /> <rect x="14" y="5" width="4" height="14" rx="1" />
</svg> </svg>
) : ( ) : (
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor"> <svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
<path d="M8 5.5v13l11-6.5z" /> <path d="M8 5.5v13l11-6.5z" />
</svg> </svg>
)} )}
@@ -134,7 +134,7 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
className={btn} className={btn}
title={`Back ${SKIP_S}s`} title={`Back ${SKIP_S}s`}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M11 17l-5-5 5-5M18 17l-5-5 5-5" />
</svg> </svg>
</button> </button>
@@ -143,12 +143,12 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
className={btn} className={btn}
title={`Forward ${SKIP_S}s`} title={`Forward ${SKIP_S}s`}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5-5 5M6 7l5 5-5 5" />
</svg> </svg>
</button> </button>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-white/80">{clock(time)}</span> <span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(time)}</span>
<input <input
type="range" type="range"
@@ -162,20 +162,20 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
onActivity?.(); onActivity?.();
}} }}
aria-label="Seek" aria-label="Seek"
className="h-1 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/25 accent-sky-500" className="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/25 accent-sky-500"
style={{ style={{
background: `linear-gradient(to right, var(--color-sky-500) ${pct}%, rgba(255,255,255,0.25) ${pct}%)`, background: `linear-gradient(to right, var(--color-sky-500) ${pct}%, rgba(255,255,255,0.25) ${pct}%)`,
}} }}
/> />
<span className="shrink-0 font-mono text-[11px] tabular-nums text-white/80">{clock(duration)}</span> <span className="shrink-0 font-mono text-[12px] tabular-nums text-white/85">{clock(duration)}</span>
<button <button
onClick={act((v) => (v.muted = !v.muted))} onClick={act((v) => (v.muted = !v.muted))}
className={btn} className={btn}
title={muted || volume === 0 ? "Unmute" : "Mute"} title={muted || volume === 0 ? "Unmute" : "Mute"}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="currentColor"> <svg viewBox="0 0 24 24" className="size-5" fill="currentColor">
<path d="M4 9v6h4l5 4V5L8 9H4z" /> <path d="M4 9v6h4l5 4V5L8 9H4z" />
{muted || volume === 0 ? ( {muted || volume === 0 ? (
<path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" /> <path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" />
@@ -204,7 +204,7 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
onActivity?.(); onActivity?.();
}} }}
aria-label="Volume" aria-label="Volume"
className="h-1 w-20 shrink-0 cursor-pointer appearance-none rounded-full accent-sky-500" className="h-1.5 w-24 shrink-0 cursor-pointer appearance-none rounded-full accent-sky-500"
style={{ style={{
background: `linear-gradient(to right, rgba(255,255,255,0.85) ${ background: `linear-gradient(to right, rgba(255,255,255,0.85) ${
(muted ? 0 : volume) * 100 (muted ? 0 : volume) * 100
@@ -213,14 +213,14 @@ export default function PlayerControls({ videoRef, stageRef, onActivity }: Props
/> />
<button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}> <button onClick={togglePip} className={btn} title={pip ? "Leave Picture in Picture" : "Picture in Picture"}>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="14" rx="2" /> <rect x="3" y="5" width="18" height="14" rx="2" />
<rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" /> <rect x="12" y="11" width="7" height="6" rx="1" fill="currentColor" stroke="none" />
</svg> </svg>
</button> </button>
<button onClick={toggleFull} className={btn} title={full ? "Leave full screen (f)" : "Full screen (f)"}> <button onClick={toggleFull} className={btn} title={full ? "Leave full screen (f)" : "Full screen (f)"}>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2"> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2">
{full ? ( {full ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
) : ( ) : (
+43 -7
View File
@@ -3,10 +3,12 @@ import {
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport, checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
} from "../api"; } from "../api";
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance"; import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
import { QUALITIES, type ImportPreview, type Prereqs, type Quality } from "../types"; import {
QUALITIES, STREAM_QUALITIES, type ImportPreview, type Prereqs, type Quality,
} from "../types";
import TakeoutGuide from "./TakeoutGuide"; import TakeoutGuide from "./TakeoutGuide";
import { import {
BTN_CHROME, BTN_PRIMARY, Dialog, HELP, LABEL, SectionHeading, Segmented, SUBPANEL, BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented, SUBPANEL,
} from "./ui"; } from "./ui";
interface Props { interface Props {
@@ -16,9 +18,15 @@ interface Props {
onAppearance: (a: Appearance) => void; onAppearance: (a: Appearance) => void;
quality: Quality; quality: Quality;
onQuality: (q: Quality) => void; onQuality: (q: Quality) => void;
streamQuality: Quality;
onStreamQuality: (q: Quality) => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
const SELECT =
`w-full ${CONTROL_H} cursor-pointer rounded-lg border border-slate-300 bg-white px-2 ` +
"text-[12px] outline-none dark:border-slate-700 dark:bg-slate-800";
function StatusRow({ label, value }: { label: string; value: string | null }) { function StatusRow({ label, value }: { label: string; value: string | null }) {
return ( return (
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800"> <div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 last:border-b-0 dark:border-slate-800">
@@ -35,7 +43,8 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
} }
export default function Settings({ export default function Settings({
onClose, onImported, appearance, onAppearance, quality, onQuality, onError, onClose, onImported, appearance, onAppearance, quality, onQuality,
streamQuality, onStreamQuality, 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);
@@ -96,7 +105,17 @@ export default function Settings({
> >
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800"> <header className="flex items-center justify-between gap-2 border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<h2 className="text-[15px] font-semibold tracking-tight">Settings</h2> <h2 className="text-[15px] font-semibold tracking-tight">Settings</h2>
<button onClick={onClose} className={`${BTN_CHROME} cursor-pointer`}>Close</button> <button
onClick={onClose}
title="Close settings"
aria-label="Close settings"
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`}
>
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</header> </header>
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
@@ -135,9 +154,7 @@ export default function Settings({
<select <select
value={quality} value={quality}
onChange={(e) => onQuality(e.target.value as Quality)} onChange={(e) => onQuality(e.target.value as Quality)}
className="w-full rounded-lg border border-slate-300 bg-white px-2 py-1.5 className={SELECT}
text-[13px] outline-none cursor-pointer
dark:border-slate-700 dark:bg-slate-800"
> >
{QUALITIES.map((q) => ( {QUALITIES.map((q) => (
<option key={q.value} value={q.value}> <option key={q.value} value={q.value}>
@@ -146,6 +163,25 @@ export default function Settings({
))} ))}
</select> </select>
</label> </label>
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
<span className={LABEL}>Streaming</span>
<select
value={streamQuality}
onChange={(e) => onStreamQuality(e.target.value as Quality)}
className={SELECT}
>
{STREAM_QUALITIES.map((q) => (
<option key={q.value} value={q.value}>
{q.label}
</option>
))}
</select>
</label>
<p className={`mt-2 ${HELP}`}>
Streaming quality applies when you play something you have not downloaded.
Best lets the player adapt to your connection; a fixed height pins it.
</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">
+3 -3
View File
@@ -1,5 +1,5 @@
import type { ChannelWithCount } from "../types"; import type { ChannelWithCount } from "../types";
import { BTN_CHROME, HEADING } from "./ui"; import { HEADING, ICON_BTN } from "./ui";
interface Props { interface Props {
channels: ChannelWithCount[]; channels: ChannelWithCount[];
@@ -49,7 +49,7 @@ export default function Sidebar({
onClick={onOpenSettings} onClick={onOpenSettings}
title="Settings" title="Settings"
aria-label="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`}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8"> <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="12" cy="12" r="3.2" /> <circle cx="12" cy="12" r="3.2" />
@@ -60,7 +60,7 @@ export default function Sidebar({
onClick={onHide} onClick={onHide}
title="Hide subscriptions" title="Hide subscriptions"
aria-label="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`}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8"> <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
+9 -12
View File
@@ -1,4 +1,4 @@
import { INPUT, Segmented } from "./ui"; import { CONTROL_H, ICON_BTN, INPUT, Segmented } from "./ui";
export type ViewMode = "list" | "grid"; export type ViewMode = "list" | "grid";
@@ -39,7 +39,7 @@ function Toggle({
title={title} title={title}
disabled={disabled} disabled={disabled}
className={ 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 " + "font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 " +
(active (active
? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400" ? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400"
@@ -73,10 +73,9 @@ export default function TopBar({
onClick={onShowSidebar} onClick={onShowSidebar}
title="Show subscriptions" title="Show subscriptions"
aria-label="Show subscriptions" aria-label="Show subscriptions"
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg className={`${ICON_BTN} border border-slate-300 text-slate-500 hover:border-sky-500
border border-slate-300 text-slate-500 hover:border-sky-500
hover:text-sky-600 dark:border-slate-700 dark:text-slate-400 hover:text-sky-600 dark:border-slate-700 dark:text-slate-400
dark:hover:border-sky-500 dark:hover:text-sky-400" dark:hover:border-sky-500 dark:hover:text-sky-400`}
> >
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8"> <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
@@ -87,7 +86,7 @@ export default function TopBar({
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder="Search videos and channels" 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`}
/> />
<span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500"> <span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
@@ -125,11 +124,11 @@ export default function TopBar({
? "Offline mode is forced on — click to go back online" ? "Offline mode is forced on — click to go back online"
: "Simulate being offline" : "Simulate being offline"
} }
className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap rounded-lg border className={`inline-flex ${CONTROL_H} cursor-pointer items-center gap-1.5 whitespace-nowrap
border-slate-300 px-2.5 py-1.5 text-[11px] font-medium text-slate-500 rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium text-slate-500
hover:border-sky-500 hover:text-sky-600 hover:border-sky-500 hover:text-sky-600
dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500 dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500
dark:hover:text-sky-400" dark:hover:text-sky-400`}
> >
<span <span
className={`size-1.5 rounded-full ${online ? "bg-sky-500" : "bg-amber-500"}`} className={`size-1.5 rounded-full ${online ? "bg-sky-500" : "bg-amber-500"}`}
@@ -146,9 +145,7 @@ export default function TopBar({
: "Refreshing needs a connection" : "Refreshing needs a connection"
} }
aria-label="Refresh" aria-label="Refresh"
className="grid size-[30px] shrink-0 cursor-pointer place-items-center rounded-lg className={`${ICON_BTN} bg-sky-500 text-white hover:bg-sky-400`}>
bg-sky-500 text-white hover:bg-sky-400 disabled:cursor-not-allowed
disabled:opacity-40">
<svg viewBox="0 0 24 24" className={`size-4 ${refreshing ? "animate-spin" : ""}`} <svg viewBox="0 0 24 24" className={`size-4 ${refreshing ? "animate-spin" : ""}`}
fill="none" stroke="currentColor" strokeWidth="2"> fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" <path strokeLinecap="round" strokeLinejoin="round"
+22 -9
View File
@@ -4,6 +4,17 @@
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
/**
* Every control in the app is this tall. Width still grows with the label —
* only the height is fixed, so a row of mixed buttons lines up.
*/
export const CONTROL_H = "h-[30px]";
/** Square version, for icon-only buttons. */
export const ICON_BTN =
`grid ${CONTROL_H} w-[30px] shrink-0 place-items-center rounded-lg cursor-pointer ` +
"disabled:cursor-not-allowed disabled:opacity-40";
export const HEADING = export const HEADING =
"text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400"; "text-[11px] font-bold uppercase tracking-widest text-slate-500 dark:text-slate-400";
export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400"; export const LABEL = "text-[12px] text-slate-500 dark:text-slate-400";
@@ -14,27 +25,29 @@ export const PANEL =
export const SECTION = export const SECTION =
"border-b border-slate-200 px-4 py-4 dark:border-slate-800"; "border-b border-slate-200 px-4 py-4 dark:border-slate-800";
export const INPUT = export const INPUT =
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-[13px] outline-none " + `w-full ${CONTROL_H} rounded-lg border border-slate-300 bg-white px-3 text-[12px] outline-none ` +
"placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500"; "placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:placeholder:text-slate-500";
export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50"; export const SUBPANEL = "rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50";
const BTN_BASE = "rounded-lg text-[13px] disabled:cursor-not-allowed"; const BTN_BASE =
`inline-flex ${CONTROL_H} items-center justify-center rounded-lg text-[12px] ` +
"disabled:cursor-not-allowed";
export const BTN = export const BTN =
`${BTN_BASE} border border-slate-300 px-3 py-2 font-medium ` + `${BTN_BASE} border border-slate-300 px-2.5 font-medium ` +
"hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " + "hover:border-sky-500 hover:text-sky-600 disabled:opacity-40 " +
"dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400"; "dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";
export const BTN_PRIMARY = export const BTN_PRIMARY =
`${BTN_BASE} bg-sky-500 px-3 py-2 font-semibold text-white ` + `${BTN_BASE} bg-sky-500 px-3 font-semibold text-white ` +
"hover:bg-sky-400 disabled:opacity-40"; "hover:bg-sky-400 disabled:opacity-40";
export const BTN_DANGER = export const BTN_DANGER =
`${BTN_BASE} bg-red-600 px-3 py-1.5 font-semibold text-white hover:bg-red-500`; `${BTN_BASE} bg-red-600 px-3 font-semibold text-white hover:bg-red-500`;
/** Header actions: quieter than a secondary button, still a real target. */ /** Header actions: quieter than a secondary button, still a real target. */
export const BTN_CHROME = export const BTN_CHROME =
"rounded-md px-2 py-1 text-[11px] font-medium text-slate-500 " + `inline-flex ${CONTROL_H} items-center rounded-lg px-2 text-[12px] font-medium text-slate-500 ` +
"hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " + "hover:bg-slate-100 hover:text-slate-900 disabled:opacity-40 disabled:hover:bg-transparent " +
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white"; "dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
@@ -85,7 +98,7 @@ export function Segmented<T extends string>({
return ( return (
<div <div
role="group" role="group"
className="flex rounded-lg border border-slate-300 p-0.5 dark:border-slate-700" className={`flex ${CONTROL_H} items-center rounded-lg border border-slate-300 p-0.5 dark:border-slate-700`}
> >
{options.map((o) => { {options.map((o) => {
const active = o.value === value; const active = o.value === value;
@@ -94,7 +107,7 @@ export function Segmented<T extends string>({
key={o.value} key={o.value}
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
className={ 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 (active
? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!" ? "bg-slate-900! text-white! dark:bg-white! dark:text-slate-900!"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " + : "text-slate-500 hover:bg-slate-100 hover:text-slate-900 " +
@@ -172,7 +185,7 @@ export function Dialog({
{onConfirm && ( {onConfirm && (
<button <button
onClick={onConfirm} onClick={onConfirm}
className={`${destructive ? BTN_DANGER + " py-2" : BTN_PRIMARY} cursor-pointer`} className={`${destructive ? BTN_DANGER : BTN_PRIMARY} cursor-pointer`}
> >
{confirmLabel ?? "Continue"} {confirmLabel ?? "Continue"}
</button> </button>
+24
View File
@@ -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;
}
+13
View File
@@ -89,6 +89,11 @@ export interface ImportPreview {
/** "best" or a maximum height in pixels. */ /** "best" or a maximum height in pixels. */
export type Quality = "best" | "2160" | "1440" | "1080" | "720" | "480"; 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 }> = [ export const QUALITIES: Array<{ value: Quality; label: string }> = [
{ value: "best", label: "Best available (up to 4K)" }, { value: "best", label: "Best available (up to 4K)" },
{ value: "2160", label: "2160p — 4K" }, { value: "2160", label: "2160p — 4K" },
@@ -97,3 +102,11 @@ export const QUALITIES: Array<{ value: Quality; label: string }> = [
{ value: "720", label: "720p" }, { value: "720", label: "720p" },
{ value: "480", label: "480p" }, { 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" },
];