diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 09605245..54a74120 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,7 +19,8 @@ { "url": "https://itunes.apple.com/*" }, { "url": "https://apic-desktop.musixmatch.com/*" }, { "url": "https://genius.com/*" }, - { "url": "https://lrclib.net/*" } + { "url": "https://lrclib.net/*" }, + { "url": "https://sponsor.ajay.app/*" } ] }, "store:default", @@ -35,6 +36,12 @@ "core:window:allow-start-resize-dragging", "core:window:allow-outer-position", "core:window:allow-outer-size", - "core:window:allow-set-always-on-top" + "core:window:allow-set-always-on-top", + "core:window:allow-maximize", + "core:window:allow-current-monitor", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-set-resizable", + "core:window:allow-set-shadow" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 98667253..29d8a873 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,7 +11,7 @@ use tokio::process::Command as TokioCommand; use tokio::sync::{Mutex, Notify}; use axum::{ - extract::{Path, Request, State as AxumState}, + extract::{Path, Query, Request, State as AxumState}, http::StatusCode, response::{IntoResponse, Response}, routing::get, @@ -3228,6 +3228,8 @@ struct StreamServer { /// in-flight independently for the two modes. ephemeral_dir: PathBuf, cover_dir: PathBuf, + /// Backdrop videos. Session-only, wiped on startup. + video_dir: PathBuf, downloads: DownloadMap, /// Expected location of the managed yt-dlp copy. Resolution to an /// actual program (managed vs PATH fallback) happens per-spawn via @@ -3954,12 +3956,411 @@ fn generate_stream_token() -> String { out } +const VIDEO_FORMAT_LO: &str = "bestvideo[vcodec^=avc1][height<=1440][protocol^=http]/bestvideo[height<=1440][protocol^=http]/bestvideo[protocol^=http]/best[protocol^=http]"; +const VIDEO_FORMAT_HI: &str = "bestvideo[vcodec^=vp9][height<=2160][protocol^=http]/bestvideo[vcodec^=av01][height<=2160][protocol^=http]/bestvideo[vcodec^=avc1][height<=1440][protocol^=http]/bestvideo[height<=1440][protocol^=http]/bestvideo[protocol^=http]/best[protocol^=http]"; + +fn spawn_video_downloader( + video_id: String, + tier: String, + target_dir: PathBuf, + map_key: String, + srv: StreamServer, + state: Arc, +) { + let downloads = srv.downloads.clone(); + tokio::spawn(async move { + let url = format!("https://www.youtube.com/watch?v={video_id}"); + let part_path = target_dir.join(format!("{video_id}.{tier}.video.part")); + let final_path = target_dir.join(format!("{video_id}.{tier}.video.webm")); + let _ = tokio::fs::create_dir_all(&target_dir).await; + let _ = tokio::fs::remove_file(&part_path).await; + + let format = if tier == "hi" { VIDEO_FORMAT_HI } else { VIDEO_FORMAT_LO }; + let mut cmd = TokioCommand::new(ytdlp::program(&srv.ytdlp_bin)); + cmd.args([ + "-f", + format, + "--no-playlist", + "--no-warnings", + "--no-part", + "-q", + "--retries", + "5", + "--extractor-retries", + "3", + "--socket-timeout", + "15", + "-o", + "-", + ]); + cmd.arg(&url); + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); + let mut child = match cmd.stdout(Stdio::piped()).stderr(Stdio::inherit()).spawn() { + Ok(c) => c, + Err(e) => { + eprintln!("[stream-video] spawn {video_id}: {e}"); + state.complete.store(true, Ordering::Release); + state.notify.notify_waiters(); + downloads.lock().await.remove(&map_key); + return; + } + }; + + let mut stdout = child.stdout.take().unwrap(); + let mut file = tokio::fs::File::create(&part_path).await.ok(); + let mut buf = vec![0u8; 64 * 1024]; + let mut ok = true; + const READ_TIMEOUT: Duration = Duration::from_secs(60); + loop { + match tokio::time::timeout(READ_TIMEOUT, stdout.read(&mut buf)).await { + Err(_) => { + eprintln!("[stream-video] read timeout for {video_id}; killing yt-dlp"); + let _ = child.start_kill(); + ok = false; + break; + } + Ok(Ok(0)) => break, + Ok(Ok(n)) => { + let chunk = &buf[..n]; + if let Some(ref mut f) = file { + if let Err(e) = f.write_all(chunk).await { + eprintln!("[stream-video] write .part: {e}"); + file = None; + ok = false; + } + } + state.notify.notify_waiters(); + } + Ok(Err(e)) => { + eprintln!("[stream-video] read stdout: {e}"); + ok = false; + break; + } + } + } + if let Some(mut f) = file.take() { + let _ = f.flush().await; + drop(f); + } + let status = child.wait().await; + let success = ok && status.map(|s| s.success()).unwrap_or(false); + + const MIN_VIDEO_BYTES: u64 = 32 * 1024; + let part_size = tokio::fs::metadata(&part_path) + .await + .map(|m| m.len()) + .unwrap_or(0); + if success && part_size >= MIN_VIDEO_BYTES { + if let Err(e) = tokio::fs::rename(&part_path, &final_path).await { + eprintln!("[stream-video] rename: {e}"); + let _ = tokio::fs::remove_file(&part_path).await; + } else { + eprintln!("[stream-video] cached {video_id} ({part_size} bytes)"); + } + } else { + if success { + eprintln!( + "[stream-video] download too small for {video_id}: {part_size} bytes (min {MIN_VIDEO_BYTES})" + ); + } else { + eprintln!("[stream-video] download failed {video_id}"); + } + let _ = tokio::fs::remove_file(&part_path).await; + } + + state.complete.store(true, Ordering::Release); + state.notify.notify_waiters(); + + if success { + let downloads_evict = downloads.clone(); + let key = map_key.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(60)).await; + downloads_evict.lock().await.remove(&key); + }); + } else { + downloads.lock().await.remove(&map_key); + } + }); +} + +async fn sniff_video_mime(path: &std::path::Path) -> &'static str { + let mut buf = [0u8; 16]; + if let Ok(mut f) = tokio::fs::File::open(path).await { + let _ = f.read(&mut buf).await; + } + if &buf[4..8] == b"ftyp" { + "video/mp4" + } else { + "video/webm" + } +} + +async fn video_handler( + AxumState(srv): AxumState, + Path(video_id): Path, + Query(params): Query>, + req: Request, +) -> Response { + if !sanitize_video_id(&video_id) { + return (StatusCode::BAD_REQUEST, "invalid videoId").into_response(); + } + + let tier = match params.get("q").map(|s| s.as_str()) { + Some("hi") => "hi", + _ => "lo", + }; + let target_dir = srv.video_dir.clone(); + let map_key = format!("v:{tier}:{video_id}"); + let final_path = target_dir.join(format!("{video_id}.{tier}.video.webm")); + + let t0 = std::time::Instant::now(); + let range_hdr = req + .headers() + .get(axum::http::header::RANGE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + eprintln!( + "[stream-video] GET /video/{video_id} range={range_hdr:?} cached={}", + final_path.exists() + ); + + if !final_path.exists() { + let state = { + let mut map = srv.downloads.lock().await; + if let Some(s) = map.get(&map_key) { + s.clone() + } else { + let s = Arc::new(DownloadState { + complete: Arc::new(AtomicBool::new(false)), + notify: Arc::new(Notify::new()), + }); + map.insert(map_key.clone(), s.clone()); + drop(map); + spawn_video_downloader( + video_id.clone(), + tier.to_string(), + target_dir.clone(), + map_key.clone(), + srv.clone(), + s.clone(), + ); + s + } + }; + + let deadline = tokio::time::Instant::now() + Duration::from_secs(180); + while !state.complete.load(Ordering::Acquire) { + if tokio::time::Instant::now() >= deadline { + eprintln!("[stream-video] {video_id}: TIMEOUT after 180s"); + return (StatusCode::GATEWAY_TIMEOUT, "download timeout").into_response(); + } + let notified = state.notify.notified(); + tokio::pin!(notified); + let _ = tokio::time::timeout(Duration::from_secs(5), notified).await; + } + + if !final_path.exists() { + eprintln!( + "[stream-video] {video_id}: BAD_GATEWAY, complete but no file (elapsed {:.2}s)", + t0.elapsed().as_secs_f32() + ); + return (StatusCode::BAD_GATEWAY, "download failed").into_response(); + } + eprintln!( + "[stream-video] {video_id}: download finished in {:.2}s", + t0.elapsed().as_secs_f32() + ); + } + + let sniffed_ct = sniff_video_mime(&final_path).await; + let mut resp = ServeFile::new(&final_path) + .oneshot(req) + .await + .map(|r| r.into_response()) + .unwrap_or_else(|e| { + (StatusCode::INTERNAL_SERVER_ERROR, format!("serve: {e}")).into_response() + }); + if resp.status().is_success() || resp.status() == StatusCode::PARTIAL_CONTENT { + resp.headers_mut().insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static(sniffed_ct), + ); + } + eprintln!( + "[stream-video] {video_id}: responding {} ({:.2}s total) ct={:?} len={:?}", + resp.status(), + t0.elapsed().as_secs_f32(), + resp.headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + resp.headers() + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()), + ); + resp +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct VideoMeta { + title: Option, + channel: Option, + channel_id: Option, + channel_is_verified: Option, + categories: Vec, + view_count: Option, + duration: Option, +} + +/// Title and uploader from oembed: one small GET, no process spawn. +#[derive(serde::Serialize)] +struct VideoBrief { + id: String, + title: Option, + channel: Option, +} + +#[tauri::command] +async fn probe_video_briefs(video_ids: Vec) -> Result, String> { + const MAX_BRIEFS: usize = 40; + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .map_err(|e| format!("build brief client: {e}"))?; + let mut handles = Vec::new(); + for id in video_ids + .into_iter() + .filter(|v| sanitize_video_id(v)) + .take(MAX_BRIEFS) + { + let client = client.clone(); + handles.push(tokio::spawn(async move { + let url = format!( + "https://www.youtube.com/oembed?url=https%3A//www.youtube.com/watch%3Fv%3D{id}&format=json" + ); + let mut brief = VideoBrief { + id, + title: None, + channel: None, + }; + let Ok(res) = client.get(&url).send().await else { + return brief; + }; + if !res.status().is_success() { + return brief; + } + let Ok(body) = res.text().await else { + return brief; + }; + let Ok(json) = serde_json::from_str::(&body) else { + return brief; + }; + brief.title = json.get("title").and_then(|v| v.as_str()).map(String::from); + brief.channel = json + .get("author_name") + .and_then(|v| v.as_str()) + .map(String::from); + brief + })); + } + let mut out = Vec::new(); + for h in handles { + if let Ok(brief) = h.await { + out.push(brief); + } + } + Ok(out) +} + +#[tauri::command] +async fn probe_video_meta(app: tauri::AppHandle, video_id: String) -> Result { + if !sanitize_video_id(&video_id) { + return Err(format!("invalid videoId: {video_id}")); + } + let url = format!("https://www.youtube.com/watch?v={video_id}"); + let mut command = TokioCommand::new(ytdlp::program(&ytdlp::managed_path(&app))); + command.args([ + "-j", + "--skip-download", + "--no-playlist", + "--no-warnings", + "--socket-timeout", + "10", + &url, + ]); + #[cfg(windows)] + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + let output = command + .output() + .await + .map_err(|e| format!("spawn yt-dlp: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "yt-dlp exit {}: {}", + output.status, + stderr.chars().take(400).collect::() + )); + } + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).map_err(|e| format!("parse: {e}"))?; + Ok(VideoMeta { + title: json.get("title").and_then(|v| v.as_str()).map(String::from), + channel: json + .get("channel") + .and_then(|v| v.as_str()) + .map(String::from), + channel_id: json + .get("channel_id") + .and_then(|v| v.as_str()) + .map(String::from), + channel_is_verified: json.get("channel_is_verified").and_then(|v| v.as_bool()), + categories: json + .get("categories") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + view_count: json.get("view_count").and_then(|v| v.as_u64()), + duration: json.get("duration").and_then(|v| v.as_f64()), + }) +} + +async fn wipe_dir_files(dir: &std::path::Path, label: &str) { + if let Ok(mut rd) = tokio::fs::read_dir(dir).await { + let mut wiped: u64 = 0; + while let Ok(Some(entry)) = rd.next_entry().await { + if let Ok(meta) = entry.metadata().await { + if meta.is_file() { + wiped += meta.len(); + let _ = tokio::fs::remove_file(entry.path()).await; + } + } + } + if wiped > 0 { + eprintln!("[stream-server] wiped {wiped} bytes from {label} dir"); + } + } +} + +#[tauri::command] +async fn diag_log(tag: String, text: String) { + eprintln!("[{tag}] {text}"); +} + async fn start_stream_server( port_state: Arc>>, token_state: Arc>>, cache_dir: PathBuf, ephemeral_dir: PathBuf, cover_dir: PathBuf, + video_dir: PathBuf, ytdlp_bin: PathBuf, ) { if let Err(e) = tokio::fs::create_dir_all(&cache_dir).await { @@ -3971,6 +4372,10 @@ async fn start_stream_server( if let Err(e) = tokio::fs::create_dir_all(&cover_dir).await { eprintln!("[stream-server] mkdir {cover_dir:?}: {e}"); } + if let Err(e) = tokio::fs::create_dir_all(&video_dir).await { + eprintln!("[stream-server] mkdir {video_dir:?}: {e}"); + } + wipe_dir_files(&video_dir, "video").await; // Wipe whatever a previous (anonymous / Free) session left behind. // Persisting tracks across restarts is a Premium-only feature; if a @@ -3995,6 +4400,7 @@ async fn start_stream_server( cache_dir, ephemeral_dir, cover_dir, + video_dir, downloads: Arc::new(Mutex::new(HashMap::new())), ytdlp_bin, }; @@ -4008,6 +4414,7 @@ async fn start_stream_server( let routes = Router::new() .route("/stream/:video_id", get(stream_handler)) + .route("/video/:video_id", get(video_handler)) .route("/prefetch/:video_id", get(prefetch_handler)) .route("/cover/:filename", get(cover_serve_handler)) .with_state(server); @@ -4185,6 +4592,9 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ ensure_ytdlp, resolve_stream_ytdlp, + probe_video_meta, + probe_video_briefs, + diag_log, get_stream_base_url, start_login, get_cookie_header, @@ -4296,6 +4706,7 @@ pub fn run() { let cache_dir = cache_root.join("stream"); let ephemeral_dir = cache_root.join("stream-ephemeral"); let cover_dir = cache_root.join("covers"); + let video_dir = cache_root.join("stream-video"); let handle = app.handle().clone(); eprintln!("[stream-server] cache dir: {cache_dir:?}"); eprintln!("[stream-server] ephemeral dir: {ephemeral_dir:?}"); @@ -4308,7 +4719,15 @@ pub fn run() { // email-based dedup before the UI reads the list. dedup_accounts_by_identity(&handle).await; cleanup_login_artifacts(&handle).await; - start_stream_server(port, token, cache_dir, ephemeral_dir, cover_dir, ytdlp_bin) + start_stream_server( + port, + token, + cache_dir, + ephemeral_dir, + cover_dir, + video_dir, + ytdlp_bin, + ) .await; }); // Subscribe to resume-from-sleep before the loop starts, so a diff --git a/src/components/layout/floating-player-app.tsx b/src/components/layout/floating-player-app.tsx index ef149428..f40a4f6e 100644 --- a/src/components/layout/floating-player-app.tsx +++ b/src/components/layout/floating-player-app.tsx @@ -29,6 +29,8 @@ import { PERSIST_MAX_AGE, persister, queryClient } from "@/lib/query-client"; initFloatingPlaybackBridge(); initFloatingTrackSourceBridge(); +const FLOATING_DIMMED_OPACITY = 0.35; + /** * Frontend entrypoint when the same bundle is loaded in the standalone * player window (`?floating-player=1`). We deliberately skip @@ -42,6 +44,26 @@ export default function FloatingPlayerApp() { // cross-window `storage` listener in the settings store keeps it // live when toggled over there. const background = useSettingsStore((s) => s.background); + const fadeWhenUnfocused = useSettingsStore((s) => s.floatingFadeWhenUnfocused); + // Hover wins over focus, so the card is readable before the click lands. + const [windowFocused, setWindowFocused] = useState(true); + const [pointerOver, setPointerOver] = useState(false); + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + void getCurrentWindow() + .onFocusChanged(({ payload }) => setWindowFocused(payload)) + .then((off) => { + if (cancelled) off(); + else unlisten = off; + }) + .catch(() => {}); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + const dimmed = fadeWhenUnfocused && !windowFocused && !pointerOver; // Read the main window's persisted query cache once, so the liked // list (7+ continuation round-trips when fetched cold) is there // before the heart first renders. Restore only: subscribing would @@ -78,7 +100,15 @@ export default function FloatingPlayerApp() { is hydrated from disk once above, read-only. */} -
+
setPointerOver(true)} + onMouseLeave={() => setPointerOver(false)} + > {background === "ambient" && } diff --git a/src/components/layout/fullscreen-player.tsx b/src/components/layout/fullscreen-player.tsx index 643ff226..e9457858 100644 --- a/src/components/layout/fullscreen-player.tsx +++ b/src/components/layout/fullscreen-player.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { HeartIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react"; import { toast } from "sonner"; @@ -48,6 +48,19 @@ import { useFullscreenStore } from "@/lib/store/fullscreen"; import { usePlaybackStore, currentTrack } from "@/lib/store/playback"; import { useScrubStore } from "@/lib/store/scrub"; import { useSettingsStore, type FullscreenLayout } from "@/lib/store/settings"; +import { useTrackSourceStore } from "@/lib/store/track-source"; +import { videoStreamUrlFor } from "@/lib/stream"; +import { getVideoQualityTier, reportPlaybackStutter } from "@/lib/video-quality"; +import { useVideoGateStore } from "@/lib/store/video-gate"; +import { getAudioClock } from "@/lib/audio-clock"; + +const VIDEO_WAIT_CEILING_MS = 45_000; +/** Under this the picture reads as in step, so leave the rate alone. */ +const SYNC_TOLERANCE = 0.04; +/** Past this it's a seek or a stall, not drift, so jump rather than ease. */ +const SYNC_SEEK_THRESHOLD = 0.5; +const SYNC_MAX_RATE_TRIM = 0.1; +const IDLE_DELAY_MS = 2800; import type { QueueTrack } from "@/lib/store/playback"; /** @@ -102,6 +115,97 @@ const sideButton = (on: boolean) => /* The view */ /* ------------------------------------------------------------------ */ +function VideoBackdrop({ + streamId, + onError, + onReady, +}: { + streamId: string; + onError: () => void; + onReady: () => void; +}) { + const [src, setSrc] = useState(null); + const videoRef = useRef(null); + + useEffect(() => { + let cancelled = false; + setSrc(null); + (async () => { + const tier = await getVideoQualityTier(); + const url = await videoStreamUrlFor(streamId, tier); + if (!cancelled) setSrc(url); + })(); + return () => { + cancelled = true; + }; + }, [streamId]); + + useEffect(() => { + if (!src) return; + const check = setTimeout(() => { + const el = videoRef.current; + if (!el || typeof el.getVideoPlaybackQuality !== "function") return; + const q = el.getVideoPlaybackQuality(); + if (q.totalVideoFrames > 60 && q.droppedVideoFrames / q.totalVideoFrames > 0.1) { + reportPlaybackStutter(); + } + }, 4000); + return () => clearTimeout(check); + }, [src]); + + useEffect(() => { + if (!src) return; + let raf: number; + const tick = () => { + const el = videoRef.current; + if (el) { + const { position, playing } = usePlaybackStore.getState(); + if (playing && el.paused) void el.play().catch(() => {}); + if (!playing && !el.paused) el.pause(); + const target = getAudioClock() ?? position; + const seekable = !el.duration || target <= el.duration; + if (el.readyState >= 2 && seekable) { + const drift = el.currentTime - target; + const off = Math.abs(drift); + if (off > SYNC_SEEK_THRESHOLD || !playing) { + if (off > SYNC_TOLERANCE) el.currentTime = target; + if (el.playbackRate !== 1) el.playbackRate = 1; + } else if (off > SYNC_TOLERANCE) { + // A seek this small costs a visible hitch; a rate trim does not. + const trim = Math.max( + -SYNC_MAX_RATE_TRIM, + Math.min(SYNC_MAX_RATE_TRIM, drift), + ); + el.playbackRate = 1 - trim; + } else if (el.playbackRate !== 1) { + el.playbackRate = 1; + } + } + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [src]); + + if (!src) return null; + + return ( +
- {immersive ? ( + {showVideo && sourceRecord?.video ? ( +
+ setVideoErrored(true)} + onReady={() => setVideoReady(true)} + /> +
+ ) : null} + {immersive && !showVideo ? ( <> {/* Blur bands: strongest at the edges, gone by mid-height. */}
+
{/* Top row: layout switch, Exit. */}
a.name).join(" ") ?? ""; - const query = `${track.title} ${artistsLine}`.trim(); - const altId = await findAlternateVideoId(query, track.videoId, target); + const artistNames = trackArtistNames(track); + const query = `${track.title} ${artistNames.join(" ")}`.trim(); + const altId = await findAlternateVideoId( + query, + track.videoId, + target, + artistNames, + track.title, + trackArtistIds(track), + track.duration, + ); if (!altId) { toast.error( target === "video" @@ -298,11 +308,33 @@ export function ProgressSlider({ seek(v); setScrub(null); }} + marks={} />
); } +// Handed to the slider as `marks`, so the fill and thumb stay on top. +function SponsorMarks({ duration }: { duration: number }) { + const segments = useSponsorSegments((s) => s.segments); + if (duration <= 0 || segments.length === 0) return null; + return ( +
+ {segments.map((seg) => { + const from = Math.max(0, Math.min(100, (seg.start / duration) * 100)); + const to = Math.max(0, Math.min(100, (seg.end / duration) * 100)); + return ( +
+ ); + })} +
+ ); +} + export function VolumeControl({ direction = "horizontal", compact = false, diff --git a/src/components/layout/player-more-menu.tsx b/src/components/layout/player-more-menu.tsx index 75795ede..782104c6 100644 --- a/src/components/layout/player-more-menu.tsx +++ b/src/components/layout/player-more-menu.tsx @@ -26,6 +26,7 @@ import { useTrackMenuController, } from "@/components/shared/track-context-menu"; import { findAlternateVideoId } from "@/lib/innertube/alternate-source"; +import { trackArtistIds, trackArtistNames } from "@/lib/track-meta"; import { useTrackSourceStore, type SourceKind } from "@/lib/store/track-source"; import type { QueueTrack } from "@/lib/store/playback"; import type { ShelfItem } from "@/lib/innertube/types"; @@ -156,9 +157,17 @@ function SourceMenuItems({ track }: { track: QueueTrack }) { } setBusy(target); try { - const artistsLine = track.artists?.map((a) => a.name).join(" ") ?? ""; - const query = `${track.title} ${artistsLine}`.trim(); - const altId = await findAlternateVideoId(query, track.videoId, target); + const artistNames = trackArtistNames(track); + const query = `${track.title} ${artistNames.join(" ")}`.trim(); + const altId = await findAlternateVideoId( + query, + track.videoId, + target, + artistNames, + track.title, + trackArtistIds(track), + track.duration, + ); if (!altId) { toast.error( target === "video" diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 5af3dd1f..1af6f85c 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -1,11 +1,13 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "@tanstack/react-router"; -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { currentMonitor, getCurrentWindow } from "@tauri-apps/api/window"; +import type { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi"; import { invoke } from "@tauri-apps/api/core"; import { getVersion } from "@tauri-apps/api/app"; import { openUrl } from "@tauri-apps/plugin-opener"; import { ArrowLeftIcon, ArrowRightIcon, MoreHorizontalIcon } from "lucide-react"; import { + IconArrowsMinimize, IconBugFilled, IconDeviceDesktopFilled, IconExternalLink, @@ -87,6 +89,89 @@ export function TopBar() { const [maximized, setMaximized] = useState(false); const [reportOpen, setReportOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); + const [trueFullscreen, setTrueFullscreen] = useState(false); + const [barRevealed, setBarRevealed] = useState(true); + const restoreBoundsRef = useRef<{ + position: PhysicalPosition; + size: PhysicalSize; + wasMaximized: boolean; + } | null>(null); + const hideTimerRef = useRef(null); + + useEffect(() => { + if (!trueFullscreen) return; + const REVEAL_ZONE = 8; + const HOLD_ZONE = 40; + const onMove = (e: MouseEvent) => { + const zone = barRevealed ? HOLD_ZONE : REVEAL_ZONE; + if (e.clientY <= zone) { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + if (!barRevealed) setBarRevealed(true); + } else if (hideTimerRef.current === null) { + hideTimerRef.current = window.setTimeout(() => { + hideTimerRef.current = null; + setBarRevealed(false); + }, 900); + } + }; + window.addEventListener("mousemove", onMove); + return () => { + window.removeEventListener("mousemove", onMove); + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }; + }, [trueFullscreen, barRevealed]); + + const toggleTrueFullscreen = async () => { + const w = getCurrentWindow(); + if (!trueFullscreen) { + const monitor = await currentMonitor(); + if (!monitor) return; + restoreBoundsRef.current = { + position: await w.outerPosition(), + size: await w.outerSize(), + wasMaximized: await w.isMaximized(), + }; + await w.setAlwaysOnTop(true); + await w.setResizable(false); + await w.setShadow(false); + await w.setPosition(monitor.position); + await w.setSize(monitor.size); + setTrueFullscreen(true); + setBarRevealed(false); + } else { + await w.setAlwaysOnTop(false); + await w.setResizable(true); + await w.setShadow(true); + const bounds = restoreBoundsRef.current; + if (bounds?.wasMaximized) { + await w.maximize(); + } else if (bounds) { + await w.setSize(bounds.size); + await w.setPosition(bounds.position); + } + setTrueFullscreen(false); + setBarRevealed(true); + } + }; + + const barHidden = trueFullscreen && !barRevealed; + + useEffect(() => { + if (!IS_TAURI) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "F11") return; + e.preventDefault(); + void toggleTrueFullscreen(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }); useEffect(() => { // macOS has no custom maximize glyph to keep in sync; native traffic @@ -128,8 +213,9 @@ export function TopBar() { // reachable there; the bar itself is transparent, so the blurred // cover runs up behind it. className={cn( - "relative flex h-9 shrink-0 select-none items-center", + "relative flex h-9 shrink-0 select-none items-center transition-transform duration-300", fullscreen ? "z-[45]" : "z-30", + barHidden ? "-translate-y-full pointer-events-none" : "translate-y-0", )} >
+ {IS_TAURI && trueFullscreen ? ( + + ) : null} + {/* No page and no sidebar to navigate while the full-screen player is up, so the three go with it. */} {fullscreen ? null : ( @@ -243,7 +341,12 @@ export function TopBar() { {!IS_MAC && (
+ } + /> + + ); +} diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx index de203b7c..0e182ab2 100644 --- a/src/components/ui/slider.tsx +++ b/src/components/ui/slider.tsx @@ -26,8 +26,12 @@ function Slider({ value, min = 0, max = 100, + marks, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + // Painted over the inactive pill, under the active one. + marks?: React.ReactNode +}) { const _values = React.useMemo( () => Array.isArray(value) @@ -76,16 +80,6 @@ function Slider({ "relative grow data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5" )} > - {/* Active (filled) pill — grows from the start to the thumb gap. */} -
{/* Inactive pill — starts after the thumb gap, runs to the end. Keeps the `slider-track` data-slot so a consumer can still retint the visible inactive bar. */} @@ -102,6 +96,17 @@ function Slider({ : { left: inactiveStart } } /> + {marks} + {/* Active (filled) pill — grows from the start to the thumb gap. */} +
{Array.from({ length: _values.length }, (_, index) => ( (null); // Guard against stale stream resolutions when the user skips mid-fetch. const resolveTokenRef = useRef(0); + // Skipped once, or scrubbed back into on purpose. Never jumped again. + const sparedSegmentsRef = useRef>(new Set()); // Counts how many tracks have failed in a row without a successful // play in between. Reset to 0 on `playing`. Used to short-circuit // auto-skip after a few consecutive failures so we don't burn through @@ -122,6 +139,7 @@ export function useAudioEngine() { const a = make(); const b = make(); audioRef.current = a; + setAudioClockSource(a); standbyRef.current = b; { const s = usePlaybackStore.getState(); @@ -144,6 +162,7 @@ export function useAudioEngine() { el.src = ""; } audioRef.current = null; + setAudioClockSource(null); standbyRef.current = null; }; }, []); @@ -278,6 +297,7 @@ export function useAudioEngine() { // the active one, then tell the store, whose track change the // resolve effect below recognises via adoptRef and leaves alone. audioRef.current = to; + setAudioClockSource(to); standbyRef.current = from; adoptRef.current = { index: next.index, @@ -304,6 +324,16 @@ export function useAudioEngine() { const onTimeUpdate = (e: Event) => { if (!isActive(e)) return; const el = e.currentTarget as HTMLAudioElement; + const segments = useSponsorSegments.getState().segments; + const seg = segments.length ? segmentAt(el.currentTime, segments) : null; + // Seeking a trailing stretch would race the crossfade. + const endsTrack = el.duration > 0 && seg && seg.end >= el.duration - 1; + if (seg && !endsTrack && !sparedSegmentsRef.current.has(segmentKey(seg))) { + sparedSegmentsRef.current.add(segmentKey(seg)); + el.currentTime = seg.end; + store().setPosition(seg.end); + return; + } store().setPosition(el.currentTime); maybeCrossfade(el); }; @@ -398,12 +428,20 @@ export function useAudioEngine() { retriedTrackRef.current = null; store().setStatus("ready"); }; + // The auto-skip lands outside a segment, so landing inside one is deliberate. + const onSeeked = (e: Event) => { + if (!isActive(e)) return; + const el = e.currentTarget as HTMLAudioElement; + const seg = segmentAt(el.currentTime, useSponsorSegments.getState().segments); + if (seg) sparedSegmentsRef.current.add(segmentKey(seg)); + }; const onWaiting = () => { // buffering — keep status as ready; don't flip to loading on every gap. }; for (const el of [a, b]) { el.addEventListener("timeupdate", onTimeUpdate); + el.addEventListener("seeked", onSeeked); el.addEventListener("durationchange", onDurationChange); el.addEventListener("ended", onEnded); el.addEventListener("error", onError); @@ -413,6 +451,7 @@ export function useAudioEngine() { return () => { for (const el of [a, b]) { el.removeEventListener("timeupdate", onTimeUpdate); + el.removeEventListener("seeked", onSeeked); el.removeEventListener("durationchange", onDurationChange); el.removeEventListener("ended", onEnded); el.removeEventListener("error", onError); @@ -438,6 +477,25 @@ export function useAudioEngine() { videoId ? resolveStreamId(videoId, s.byVideoId) : undefined, ); + const skipNonMusic = usePlaybackSettings((s) => s.skipNonMusic); + const onVideoSource = useTrackSourceStore( + (s) => !!videoId && s.byVideoId[videoId]?.selected === "video", + ); + useEffect(() => { + const publish = useSponsorSegments.getState().setSegments; + publish([]); + sparedSegmentsRef.current = new Set(); + if (!skipNonMusic || !onVideoSource || !streamVideoId) return; + let cancelled = false; + void fetchNonMusicSegments(streamVideoId).then((segments) => { + if (!cancelled) publish(segments); + }); + return () => { + cancelled = true; + publish([]); + }; + }, [skipNonMusic, onVideoSource, streamVideoId]); + // Reactive Premium check for the gate below. Subscribing (rather than // calling isPremium() inside the effect) makes the resolve effect // re-run when the status lands after sign-in / the launch-time probe. @@ -590,6 +648,7 @@ export function useAudioEngine() { // Play / pause follow store. const playing = usePlaybackStore((s) => s.playing); + const videoGated = useVideoGateStore((s) => s.waiting); useEffect(() => { const el = audioRef.current; if (!el) return; @@ -603,6 +662,11 @@ export function useAudioEngine() { } if (!el.src) return; if (playing) { + if (videoGated) { + cancelFade(); + el.pause(); + return; + } graphRef.current?.resume(); void el.play().catch((e) => { if (e?.name === "AbortError") return; @@ -614,7 +678,7 @@ export function useAudioEngine() { cancelFade(); el.pause(); } - }, [playing, premiumOk]); + }, [playing, premiumOk, videoGated]); // Volume / mute follow store. const volume = usePlaybackStore((s) => s.volume); @@ -861,6 +925,56 @@ export function useAudioEngine() { ); }, [status, nextStreamVideoId]); + // Both gated on Video being chosen: a resolve costs a search, a file costs MBs. + const followVideoMode = usePlaybackSettings((s) => s.followVideoMode); + const warmNextVideo = usePlaybackSettings((s) => s.warmNextVideo); + const preferVideo = + useTrackSourceStore((s) => s.preferred === "video") && followVideoMode; + const currentVideoId = usePlaybackStore((s) => + s.index >= 0 ? s.queue[s.index]?.videoId : undefined, + ); + useEffect(() => { + if (!preferVideo || !currentVideoId) return; + let cancelled = false; + void (async () => { + const st = usePlaybackStore.getState(); + const track = st.index >= 0 ? st.queue[st.index] : undefined; + if (!track || track.videoId !== currentVideoId) return; + const rec = useTrackSourceStore.getState().byVideoId[currentVideoId]; + if (rec?.selected === "video") return; + const altId = await resolveVideoFor(track); + if (cancelled || !altId) return; + const live = usePlaybackStore.getState(); + if (live.queue[live.index]?.videoId !== currentVideoId) return; + useTrackSourceStore.getState().setSelected(currentVideoId, "video"); + })(); + return () => { + cancelled = true; + }; + }, [preferVideo, currentVideoId]); + + useEffect(() => { + if (!preferVideo || status !== "ready" || !nextVideoId) return; + let cancelled = false; + void (async () => { + const st = usePlaybackStore.getState(); + const upcoming = + st.index >= 0 && st.index + 1 < st.queue.length + ? st.queue[st.index + 1] + : undefined; + if (!upcoming || upcoming.videoId !== nextVideoId) return; + const altId = await resolveVideoFor(upcoming); + if (cancelled || !altId) return; + // Flipping mid-track would swap the source out and restart the song. + useTrackSourceStore.getState().setSelected(nextVideoId, "video"); + if (!warmNextVideo) return; + await prefetchVideo(altId, await getVideoQualityTier()); + })(); + return () => { + cancelled = true; + }; + }, [preferVideo, warmNextVideo, status, nextVideoId]); + // Auto-extend the queue with radio tracks when we're near the end, so // playback continues past the explicit queue. const autoRadio = usePlaybackStore((s) => s.autoRadio); diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts new file mode 100644 index 00000000..af41336b --- /dev/null +++ b/src/lib/diagnostics.ts @@ -0,0 +1,6 @@ +import { invoke } from "@tauri-apps/api/core"; + +// A wrong pick is only diagnosable from the candidates that were weighed. +export function diagLog(tag: string, text: string): void { + void invoke("diag_log", { tag, text }).catch(() => {}); +} diff --git a/src/lib/innertube/alternate-source.ts b/src/lib/innertube/alternate-source.ts index 3f43e0fa..b5e24e65 100644 --- a/src/lib/innertube/alternate-source.ts +++ b/src/lib/innertube/alternate-source.ts @@ -1,33 +1,471 @@ import { fetchSearch } from "./search"; +import { fetchArtist } from "./artist"; +import { fetchCounterpartVideoId } from "./counterpart"; import type { SourceKind } from "@/lib/store/track-source"; +import type { ShelfItem } from "./types"; +import { diagLog } from "@/lib/diagnostics"; +import { getVideoBriefs, getVideoMeta } from "./video-meta"; /** * Find the alternate-source videoId for a track. Given a song's videoId * (and the title / artist line we've got in metadata), search YT Music * with the opposite kind filter and pick the first result that isn't - * the input id. Title/artist match is implicit in YT's relevance - * ranking — we don't try to fuzzy-match because YT already does that - * better than we could. + * the input id and is actually credited to (or at least names) one of + * the expected artists. * * Used to play the uncensored / original audio when YT Music's "song" * version is the censored one (common for Russian artists working * around the local lyric ban — switching to the music-video source * gets you the real recording). */ + +function normalizeArtist(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +const ARTIST_SPLIT = /\s*(?:,|&|、|\/|\bfeat\.?\b|\bft\.?\b)\s*/i; + +// Keeps the combined string as well, so nothing that matched before stops. +function expandArtistNames(names: string[]): string[] { + const out = new Set(); + for (const name of names) { + const whole = name.trim(); + if (whole) out.add(whole); + for (const part of name.split(ARTIST_SPLIT)) { + const piece = part.trim(); + if (piece.length > 1) out.add(piece); + } + } + return [...out]; +} + +function matchesArtist(item: ShelfItem, expected: string[]): boolean { + if (expected.length === 0) return true; + const itemArtists = (item.artists ?? []) + .map((a) => normalizeArtist(a.name)) + .filter(Boolean); + const normTitle = normalizeArtist(item.title); + return expected.some((name) => { + const norm = normalizeArtist(name); + if (!norm) return false; + if (itemArtists.some((a) => a.includes(norm) || norm.includes(a))) return true; + return norm.length > 2 && normTitle.includes(norm); + }); +} + +function hasUnexpectedArtist(item: ShelfItem, expected: string[]): boolean { + if (expected.length === 0) return false; + const itemArtists = (item.artists ?? []) + .map((a) => normalizeArtist(a.name)) + .filter(Boolean); + if (itemArtists.length === 0) return false; + const normExpected = expected.map(normalizeArtist).filter(Boolean); + return itemArtists.some( + (a) => !normExpected.some((e) => a.includes(e) || e.includes(a)), + ); +} + +const OFFICIAL_HINT = /\bofficial\b/i; +const NON_OFFICIAL_HINTS = [ + /\bcover\b/i, + /karaoke/i, + /reaction/i, + /\blive\b/i, + /instrumental/i, + /sped up/i, + /slowed/i, + /nightcore/i, + /\bloop(ed)?\b/i, + /\bextended\b/i, + /\bedit\b/i, + /mashup/i, + /\bamv\b/i, + /best part/i, + /play\s*along/i, + /isolated/i, + /a\s*cap+ella/i, + /\bremix\b/i, + /vocals?\s*only/i, + /no\s+(guitar|vocals?|drums?|bass)\b/i, + /backing\s*track/i, + /full\s*album/i, + /\d+d\s*audio/i, + /bass\s*boost(ed)?/i, + /\breverb\b/i, + /tutorial/i, + /\blesson\b/i, + /how\s*to\s*play/i, + /\b(bass|guitar|drum)\s*tabs?\b/i, + /notation/i, + /faded\s*ending/i, + /\balbum\s*version\b/i, + /\bradio\s*edit\b/i, +]; + +const SOFT_HINTS = [ + /\blyrics?\b/i, + /visuali[sz]er/i, + /\blirik\b/i, + /terjemahan/i, + /traduç[aã]o/i, + /traducci[oó]n/i, + /legendado/i, + /subtitulado/i, + /\bsub\.?\s*espa[ñn]ol\b/i, + /가사/, + /해석/, + /歌詞|歌词/, +]; + +function tierOf(title: string): number { + if (OFFICIAL_HINT.test(title)) return 0; + if (NON_OFFICIAL_HINTS.some((hint) => hint.test(title))) return 2; + return 1; +} + +function hasStructuredArtistMatch(item: ShelfItem, expected: string[]): boolean { + if (expected.length === 0) return false; + const itemArtists = (item.artists ?? []) + .map((a) => normalizeArtist(a.name)) + .filter(Boolean); + const normExpected = expected.map(normalizeArtist).filter(Boolean); + return itemArtists.some((a) => + normExpected.some((e) => a.includes(e) || e.includes(a)), + ); +} + +function coreTitle(title: string): string { + return title.replace(/[([][^)\]]*[)\]]/g, " "); +} + +function titleTokens(title: string): string[] { + return coreTitle(title) + .toLowerCase() + .normalize("NFD") + .replace(/\p{M}+/gu, "") + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); +} + +// Everything past the first break is furniture many uploads share verbatim. +function titleHead(title: string): string { + const lead = title.split(/\s*[|:–—]\s*|\s+-\s+/, 1)[0]; + return lead && titleTokens(lead).length > 0 ? lead : title; +} + +function titleCoverage( + item: ShelfItem, + trackTitle: string, +): { coverage: number; headCoverage: number; extra: number } { + const trackTok = titleTokens(trackTitle); + if (trackTok.length === 0) + return { coverage: 1, headCoverage: 1, extra: 0 }; + const itemTok = titleTokens(item.title); + const overlap = trackTok.filter((t) => itemTok.includes(t)).length; + const headTok = titleTokens(titleHead(trackTitle)); + const headHit = headTok.filter((t) => itemTok.includes(t)).length; + return { + coverage: overlap / trackTok.length, + headCoverage: headTok.length > 0 ? headHit / headTok.length : 1, + extra: itemTok.length - trackTok.length, + }; +} + +const MIN_TITLE_COVERAGE = 0.6; +const MAX_EXTRA_TITLE_TOKENS = 20; +const BRIEF_LIMIT = 24; +const VERIFY_BATCH = 10; +const VERIFY_MAX = 12; +const WORST_ACCEPTABLE_TIER = 1; +const DURATION_MIN_RATIO = 0.5; +const DURATION_MAX_RATIO = 2.5; + +const VIDEO_HINT = /\b(music\s*video|official\s*video|\bmv\b)/i; +const AUDIO_HINT = /\baudio\b/i; + +// Binary on purpose: grading the loser needs English, so views decide there. +function formatRank(title: string, targetKind: SourceKind): number { + const looksVideo = VIDEO_HINT.test(title); + const looksAudio = AUDIO_HINT.test(title); + if (targetKind === "video") return looksVideo ? 0 : 1; + return looksAudio && !looksVideo ? 0 : 1; +} + +function channelMatchesArtist(channel: string | null, expected: string[]): boolean { + if (!channel || expected.length === 0) return false; + const norm = normalizeArtist(channel); + return expected.some((name) => { + const e = normalizeArtist(name); + return e.length > 2 && (norm.includes(e) || e.includes(norm)); + }); +} + +function isMusicCategory(categories: string[]): boolean { + return categories.some((c) => c.toLowerCase() === "music"); +} + export async function findAlternateVideoId( query: string, currentVideoId: string, targetKind: SourceKind, + expectedArtists: string[] = [], + trackTitle: string = "", + artistIds: string[] = [], + trackDuration: number = 0, ): Promise { if (!query.trim()) return null; + + const counterpart = await fetchCounterpartVideoId(currentVideoId); + if (counterpart) { + diagLog( + "alt-video", + `counterpart: YT Music pairs ${currentVideoId} with ${counterpart} for its own song/video switch, taking it`, + ); + return counterpart; + } + + expectedArtists = expandArtistNames(expectedArtists); const filter = targetKind === "video" ? "videos" : "songs"; - const results = await fetchSearch(query, filter); + const [results, artistPages] = await Promise.all([ + fetchSearch(query, filter), + Promise.all(artistIds.map((id) => fetchArtist(id).catch(() => null))), + ]); + const artistChannelIds = new Set( + artistPages + .map((a) => a?.channelId) + .filter((id): id is string => Boolean(id)), + ); + const raw: ShelfItem[] = []; + const candidates: ShelfItem[] = []; for (const shelf of results.shelves) { for (const item of shelf.items) { if (item.kind !== "song" && item.kind !== "video") continue; if (item.id === currentVideoId) continue; - return item.id; + raw.push(item); + if (!matchesArtist(item, expectedArtists)) continue; + if (hasUnexpectedArtist(item, expectedArtists)) continue; + if (trackTitle) { + const { coverage, headCoverage, extra } = titleCoverage( + item, + trackTitle, + ); + if ( + coverage < MIN_TITLE_COVERAGE || + headCoverage < MIN_TITLE_COVERAGE || + extra > MAX_EXTRA_TITLE_TOKENS + ) { + continue; + } + } + candidates.push(item); + } + } + diagLog( + "alt-video", + `query="${query}" expected=[${expectedArtists.join(", ")}] trackTitle="${trackTitle}" raw(${raw.length})=${raw + .slice(0, 20) + .map( + (c) => + `"${c.title}" by [${(c.artists ?? []).map((a) => a.name).join(", ")}]`, + ) + .join(" | ")} candidates=${candidates.length}`, + ); + if (candidates.length === 0) return null; + + const briefPool = candidates.slice(0, BRIEF_LIMIT); + const briefs = await getVideoBriefs(briefPool.map((c) => c.id)); + const realTitleOf = (item: ShelfItem) => briefs.get(item.id)?.title ?? item.title; + const realChannelOf = (item: ShelfItem) => briefs.get(item.id)?.channel ?? null; + + const screened: ShelfItem[] = []; + for (const item of briefPool) { + const realTitle = realTitleOf(item); + if (tierOf(realTitle) === 2) { + diagLog( + "alt-video", + `dropped "${item.title}" (${item.id}) - real title "${realTitle}" is a cover/karaoke/remix/etc.`, + ); + continue; } + screened.push(item); } - return null; + diagLog( + "alt-video", + `oembed resolved ${briefs.size}/${briefPool.length} real titles, ${screened.length} still standing`, + ); + if (screened.length === 0) return null; + + const shortlistKey = (item: ShelfItem) => { + const realTitle = realTitleOf(item); + return [ + formatRank(realTitle, targetKind), + SOFT_HINTS.some((h) => h.test(realTitle)) ? 1 : 0, + channelMatchesArtist(realChannelOf(item), expectedArtists) ? 0 : 1, + hasStructuredArtistMatch(item, expectedArtists) ? 0 : 1, + ]; + }; + const shortlist = [...screened].sort((a, b) => { + const ka = shortlistKey(a); + const kb = shortlistKey(b); + for (let i = 0; i < ka.length; i++) { + if (ka[i] !== kb[i]) return ka[i] - kb[i]; + } + return 0; + }); + + const hydratedIds = new Set(); + const rejected = new Set(); + const confirmedArtistChannel = new Set(); + const verifiedChannel = new Set(); + const nameMatchOnly = new Set(); + const softTagIds = new Set(); + const realTier = new Map(); + const viewsById = new Map(); + + const hydrateBatch = async (items: ShelfItem[]) => { + const hydrated = await Promise.all( + items.map(async (item) => ({ item, meta: await getVideoMeta(item.id) })), + ); + for (const { item, meta } of hydrated) { + hydratedIds.add(item.id); + if (!meta) continue; + if (meta.title) realTier.set(item.id, tierOf(meta.title)); + if (meta.viewCount) viewsById.set(item.id, meta.viewCount); + const onArtistChannel = !!meta.channelId && artistChannelIds.has(meta.channelId); + const nameMatch = channelMatchesArtist(meta.channel, expectedArtists); + const music = isMusicCategory(meta.categories); + const realTitle = meta.title ?? realTitleOf(item); + const badTag = tierOf(realTitle) === 2; + const softTag = SOFT_HINTS.some((h) => h.test(realTitle)); + const trustedChannel = onArtistChannel || !!meta.channelIsVerified; + const offDuration = + !!trackDuration && + !!meta.duration && + (meta.duration < trackDuration * DURATION_MIN_RATIO || + meta.duration > trackDuration * DURATION_MAX_RATIO); + if (softTag) softTagIds.add(item.id); + if (offDuration) { + rejected.add(item.id); + diagLog( + "alt-video", + `rejected "${item.title}" (${item.id}) - runs ${Math.round(meta.duration ?? 0)}s against a ${Math.round(trackDuration)}s track`, + ); + } else if (badTag) { + rejected.add(item.id); + diagLog( + "alt-video", + `rejected "${item.title}" (${item.id}) - real title "${meta.title}" carries a lyrics/cover/karaoke/etc. tag the search result hid`, + ); + } else if (softTag && !trustedChannel) { + rejected.add(item.id); + diagLog( + "alt-video", + `rejected "${item.title}" (${item.id}) - real title "${meta.title}" is a lyrics/visualizer video from an unverified channel`, + ); + } else if (onArtistChannel) { + confirmedArtistChannel.add(item.id); + diagLog( + "alt-video", + `confirmed "${item.title}" (${item.id}) - posted on the artist's own channel`, + ); + } else if (meta.channelIsVerified) { + verifiedChannel.add(item.id); + diagLog( + "alt-video", + `trusted "${item.title}" (${item.id}) - verified channel "${meta.channel ?? "unknown"}"${nameMatch ? " names an expected artist" : " hosting content outside the artist's own channel"}`, + ); + } else if (nameMatch) { + nameMatchOnly.add(item.id); + diagLog( + "alt-video", + `matched "${item.title}" (${item.id}) - real channel "${meta.channel ?? "unknown"}" names an expected artist`, + ); + } else if (!music) { + rejected.add(item.id); + diagLog( + "alt-video", + `rejected "${item.title}" (${item.id}) - real channel "${meta.channel ?? "unknown"}" categories=[${meta.categories.join(", ")}] doesn't match ${expectedArtists.join("/")}`, + ); + } else if (formatRank(realTitle, targetKind) === 0) { + diagLog( + "alt-video", + `unverified "${item.title}" (${item.id}) - real channel "${meta.channel ?? "unknown"}" isn't verified, but the upload presents itself as the official one; keeping as a last resort`, + ); + } else { + rejected.add(item.id); + diagLog( + "alt-video", + `rejected "${item.title}" (${item.id}) - real channel "${meta.channel ?? "unknown"}" is an unverified reupload that doesn't name ${expectedArtists.join("/")}`, + ); + } + } + }; + + let verifiedCount = 0; + while (verifiedCount < shortlist.length && verifiedCount < VERIFY_MAX) { + const batch = shortlist.slice(verifiedCount, verifiedCount + VERIFY_BATCH); + await hydrateBatch(batch); + verifiedCount += batch.length; + if (batch.some((c) => !rejected.has(c.id))) break; + diagLog( + "alt-video", + `all ${verifiedCount} verified so far were rejected, reaching further down the shortlist`, + ); + } + + const survivors = shortlist.filter( + (c) => hydratedIds.has(c.id) && !rejected.has(c.id), + ); + if (survivors.length === 0) { + diagLog("alt-video", "every verified candidate failed, aborting"); + return null; + } + const effectiveTier = (c: ShelfItem) => realTier.get(c.id) ?? tierOf(realTitleOf(c)); + const bestSurvivorTier = Math.min(...survivors.map(effectiveTier)); + if (bestSurvivorTier > WORST_ACCEPTABLE_TIER) { + diagLog( + "alt-video", + `best surviving candidate is only tier ${bestSurvivorTier} (lyrics/slowed/loop/etc.) after verification, aborting rather than settling`, + ); + return null; + } + const priorityOf = (c: ShelfItem) => { + const soft = softTagIds.has(c.id) ? 1 : 0; + if (confirmedArtistChannel.has(c.id)) return soft; + if (verifiedChannel.has(c.id)) return 2 + soft; + if (nameMatchOnly.has(c.id)) return 4; + return 5; + }; + const unidentifiedArtist = expectedArtists.length === 0; + if (unidentifiedArtist) { + diagLog( + "alt-video", + "no expected artist on this track, so a verified channel proves nothing about identity; ranking on reach instead", + ); + } + const finalKey = (c: ShelfItem) => + unidentifiedArtist + ? [formatRank(realTitleOf(c), targetKind), -(viewsById.get(c.id) ?? 0)] + : [ + priorityOf(c), + formatRank(realTitleOf(c), targetKind), + -(viewsById.get(c.id) ?? 0), + ]; + survivors.sort((a, b) => { + const ka = finalKey(a); + const kb = finalKey(b); + for (let i = 0; i < ka.length; i++) { + if (ka[i] !== kb[i]) return ka[i] - kb[i]; + } + return 0; + }); + + const best = survivors[0]; + diagLog( + "alt-video", + `picked "${realTitleOf(best)}" (${best.id}) after verifying ${verifiedCount} of ${shortlist.length}`, + ); + return best.id; } diff --git a/src/lib/innertube/counterpart.ts b/src/lib/innertube/counterpart.ts new file mode 100644 index 00000000..932dfc04 --- /dev/null +++ b/src/lib/innertube/counterpart.ts @@ -0,0 +1,32 @@ +import { rawNext, type YtNode } from "./shared"; + +/** YT Music's own song/video pairing, when it exposes one. */ +export async function fetchCounterpartVideoId( + videoId: string, +): Promise { + try { + const json = await rawNext({ + videoId, + playlistId: `RDAMVM${videoId}`, + isAudioOnly: false, + }); + const panel: YtNode | undefined = + json?.contents?.singleColumnMusicWatchNextResultsRenderer?.tabbedRenderer + ?.watchNextTabbedResultsRenderer?.tabs?.[0]?.tabRenderer?.content + ?.musicQueueRenderer?.content?.playlistPanelRenderer; + for (const row of (panel?.contents as YtNode[] | undefined) ?? []) { + const wrapper = row.playlistPanelVideoWrapperRenderer; + if (!wrapper) continue; + const primaryId = + wrapper.primaryRenderer?.playlistPanelVideoRenderer?.videoId; + if (primaryId && primaryId !== videoId) continue; + for (const alt of (wrapper.counterpart as YtNode[] | undefined) ?? []) { + const id = alt.counterpartRenderer?.playlistPanelVideoRenderer?.videoId; + if (typeof id === "string" && id && id !== videoId) return id; + } + } + return null; + } catch { + return null; + } +} diff --git a/src/lib/innertube/video-meta.ts b/src/lib/innertube/video-meta.ts new file mode 100644 index 00000000..6fed50f9 --- /dev/null +++ b/src/lib/innertube/video-meta.ts @@ -0,0 +1,41 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type VideoMeta = { + title: string | null; + channel: string | null; + channelId: string | null; + channelIsVerified: boolean | null; + categories: string[]; + viewCount: number | null; + duration: number | null; +}; + +export type VideoBrief = { + id: string; + title: string | null; + channel: string | null; +}; + +export async function getVideoMeta(videoId: string): Promise { + try { + return await invoke("probe_video_meta", { videoId }); + } catch { + return null; + } +} + +export async function getVideoBriefs( + videoIds: string[], +): Promise> { + const byId = new Map(); + if (videoIds.length === 0) return byId; + try { + const briefs = await invoke("probe_video_briefs", { videoIds }); + for (const brief of briefs) { + if (brief.title || brief.channel) byId.set(brief.id, brief); + } + } catch { + return byId; + } + return byId; +} diff --git a/src/lib/source-follow.ts b/src/lib/source-follow.ts new file mode 100644 index 00000000..e7036733 --- /dev/null +++ b/src/lib/source-follow.ts @@ -0,0 +1,52 @@ +import { findAlternateVideoId } from "@/lib/innertube/alternate-source"; +import { trackArtistIds, trackArtistNames } from "@/lib/track-meta"; +import { useTrackSourceStore } from "@/lib/store/track-source"; +import { diagLog } from "@/lib/diagnostics"; +import type { QueueTrack } from "@/lib/store/playback"; + +const inFlight = new Map>(); +const noVideo = new Set(); + +/** Cached or freshly resolved music-video id, null when there isn't one. */ +export function resolveVideoFor(track: QueueTrack): Promise { + const known = useTrackSourceStore.getState().byVideoId[track.videoId]; + if (known?.video) return Promise.resolve(known.video); + if (noVideo.has(track.videoId)) return Promise.resolve(null); + // Share an in-flight lookup; answering null would read as "no video". + const running = inFlight.get(track.videoId); + if (running) return running; + + const job = (async () => { + try { + const artistNames = trackArtistNames(track); + const query = `${track.title} ${artistNames.join(" ")}`.trim(); + const altId = await findAlternateVideoId( + query, + track.videoId, + "video", + artistNames, + track.title, + trackArtistIds(track), + track.duration, + ); + if (altId) { + useTrackSourceStore + .getState() + .setAlternate(track.videoId, "video", altId); + } else { + noVideo.add(track.videoId); + diagLog( + "alt-video", + `no video worth switching to for "${track.title}" (${track.videoId}), staying on the song`, + ); + } + return altId; + } catch { + return null; + } finally { + inFlight.delete(track.videoId); + } + })(); + inFlight.set(track.videoId, job); + return job; +} diff --git a/src/lib/sponsorblock.ts b/src/lib/sponsorblock.ts new file mode 100644 index 00000000..374034fc --- /dev/null +++ b/src/lib/sponsorblock.ts @@ -0,0 +1,91 @@ +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { diagLog } from "@/lib/diagnostics"; + +export type NonMusicSegment = { start: number; end: number }; + +const SPONSORBLOCK_API = "https://sponsor.ajay.app/api/skipSegments"; +const CATEGORY = "music_offtopic"; +const REQUEST_TIMEOUT_MS = 5000; + +// Privacy endpoint: only a hash prefix is sent, and we filter locally. +async function sha256HexPrefix(input: string, len: number): Promise { + const data = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + .slice(0, len); +} + +const cache = new Map(); +const inflight = new Map>(); + +export async function fetchNonMusicSegments( + videoId: string, +): Promise { + const cached = cache.get(videoId); + if (cached) return cached; + const existing = inflight.get(videoId); + if (existing) return existing; + + const promise = (async () => { + try { + const prefix = await sha256HexPrefix(videoId, 4); + const url = `${SPONSORBLOCK_API}/${prefix}?category=${CATEGORY}&service=YouTube`; + const started = performance.now(); + const res = await tauriFetch(url, { + method: "GET", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + diagLog( + "network", + `sponsor.ajay.app GET ${res.status} ${Math.round(performance.now() - started)}ms`, + ); + if (res.status === 404 || !res.ok) { + cache.set(videoId, []); + return []; + } + const json = (await res.json()) as { + videoID: string; + segments: { segment: [number, number]; category: string }[]; + }[]; + const match = json.find((v) => v.videoID === videoId); + const segments = (match?.segments ?? []) + .filter((s) => s.category === CATEGORY) + .map((s): NonMusicSegment => ({ start: s.segment[0], end: s.segment[1] })) + .sort((a, b) => a.start - b.start); + cache.set(videoId, segments); + diagLog( + "sponsorblock", + `${videoId}: ${segments.length} non-music segment(s) ${segments + .map((s) => `${Math.round(s.start)}-${Math.round(s.end)}s`) + .join(", ")}`, + ); + return segments; + } catch (e) { + diagLog("sponsorblock", `${videoId}: lookup failed (${String(e)})`); + return []; + } finally { + inflight.delete(videoId); + } + })(); + + inflight.set(videoId, promise); + return promise; +} + +/** The non-music stretch `position` is sitting in, if any. */ +export function segmentAt( + position: number, + segments: NonMusicSegment[], +): NonMusicSegment | null { + for (const seg of segments) { + if (position < seg.start) break; + if (position < seg.end) return seg; + } + return null; +} + +export function segmentKey(seg: NonMusicSegment): string { + return `${seg.start}-${seg.end}`; +} diff --git a/src/lib/store/playback-settings.ts b/src/lib/store/playback-settings.ts index 6ff75d65..77c8a7d3 100644 --- a/src/lib/store/playback-settings.ts +++ b/src/lib/store/playback-settings.ts @@ -6,6 +6,8 @@ export type EqPreset = "flat" | "bass" | "vocal" | "treble" | "late" | "custom"; export type BackButtonMode = "previous" | "restart" | "smart"; +export type VideoQualityChoice = "auto" | "hi" | "lo"; + /** Centre frequencies of the nine bands, in Hz. */ export const EQ_BANDS = [62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000]; @@ -42,6 +44,20 @@ type State = { * The queue and track already survive a restart; the position does * not, and this is the switch for it. */ resumePlayback: boolean; + /** Ceiling for the music-video backdrop. "auto" probes what the GPU can + * decode smoothly and steps down if frames start dropping; an explicit + * choice is left alone. */ + videoQuality: VideoQualityChoice; + /** Carry a switch to Video onto the tracks that follow it. Each new + * track costs a search and a round of yt-dlp verification. */ + followVideoMode: boolean; + /** Fetch the next track's video while the current one plays. Removes + * the wait at the cost of a whole video file per track. */ + warmNextVideo: boolean; + /** Jump past the intros, outros and talking in a music video, using + * SponsorBlock's music_offtopic segments. Off by default: it queries a + * third party, albeit only with a hash prefix of the video id. */ + skipNonMusic: boolean; setCrossfadeSec: (v: number) => void; setNormalizeVolume: (v: boolean) => void; setEqEnabled: (v: boolean) => void; @@ -52,6 +68,10 @@ type State = { setBackButton: (v: BackButtonMode) => void; setSmartBackSeconds: (v: number) => void; setResumePlayback: (v: boolean) => void; + setVideoQuality: (v: VideoQualityChoice) => void; + setFollowVideoMode: (v: boolean) => void; + setWarmNextVideo: (v: boolean) => void; + setSkipNonMusic: (v: boolean) => void; }; /** The gains currently in force, whichever way they were chosen. */ @@ -87,6 +107,10 @@ export const usePlaybackSettings = create()( backButton: "smart", smartBackSeconds: 3, resumePlayback: true, + videoQuality: "auto", + followVideoMode: true, + warmNextVideo: true, + skipNonMusic: false, setCrossfadeSec: (crossfadeSec) => set({ crossfadeSec: Math.min(12, Math.max(0, Math.round(crossfadeSec))), @@ -109,6 +133,10 @@ export const usePlaybackSettings = create()( setBackButton: (backButton) => set({ backButton }), setSmartBackSeconds: (smartBackSeconds) => set({ smartBackSeconds }), setResumePlayback: (resumePlayback) => set({ resumePlayback }), + setVideoQuality: (videoQuality) => set({ videoQuality }), + setFollowVideoMode: (followVideoMode) => set({ followVideoMode }), + setWarmNextVideo: (warmNextVideo) => set({ warmNextVideo }), + setSkipNonMusic: (skipNonMusic) => set({ skipNonMusic }), }), { name: "ytm-playback-settings", diff --git a/src/lib/store/settings.ts b/src/lib/store/settings.ts index 4d5cec14..544e8bd4 100644 --- a/src/lib/store/settings.ts +++ b/src/lib/store/settings.ts @@ -33,6 +33,11 @@ type State = { /** Body of the full-screen player: cover centred, cover with the * lyrics beside it, or the art itself behind the title. */ fullscreenLayout: FullscreenLayout; + /** Let the full-screen controls recede once the mouse settles. */ + fullscreenIdleFade: boolean; + /** Dim the floating player while it sits in the background, so it + * stays out of the way of whatever is in front of it. */ + floatingFadeWhenUnfocused: boolean; /** System toast on track change while the app is in the background * (see `lib/playback-notifications.ts`). */ playbackNotifications: boolean; @@ -65,6 +70,8 @@ type State = { setInterfaceFont: (v: InterfaceFont) => void; setRatingButtons: (v: RatingButtons) => void; setFullscreenLayout: (v: FullscreenLayout) => void; + setFullscreenIdleFade: (v: boolean) => void; + setFloatingFadeWhenUnfocused: (v: boolean) => void; setPlaybackNotifications: (v: boolean) => void; setDiscordRichPresence: (v: boolean) => void; setLastfmEnabled: (v: boolean) => void; @@ -92,6 +99,8 @@ export const useSettingsStore = create()( interfaceFont: "system", ratingButtons: "heart", fullscreenLayout: "cover", + fullscreenIdleFade: true, + floatingFadeWhenUnfocused: false, playbackNotifications: false, discordRichPresence: false, lastfmEnabled: false, @@ -106,6 +115,9 @@ export const useSettingsStore = create()( setInterfaceFont: (interfaceFont) => set({ interfaceFont }), setRatingButtons: (ratingButtons) => set({ ratingButtons }), setFullscreenLayout: (fullscreenLayout) => set({ fullscreenLayout }), + setFullscreenIdleFade: (fullscreenIdleFade) => set({ fullscreenIdleFade }), + setFloatingFadeWhenUnfocused: (floatingFadeWhenUnfocused) => + set({ floatingFadeWhenUnfocused }), setPlaybackNotifications: (playbackNotifications) => set({ playbackNotifications }), setDiscordRichPresence: (discordRichPresence) => diff --git a/src/lib/store/sponsor-segments.ts b/src/lib/store/sponsor-segments.ts new file mode 100644 index 00000000..2d9e3dc7 --- /dev/null +++ b/src/lib/store/sponsor-segments.ts @@ -0,0 +1,12 @@ +import { create } from "zustand"; +import type { NonMusicSegment } from "@/lib/sponsorblock"; + +type State = { + segments: NonMusicSegment[]; + setSegments: (segments: NonMusicSegment[]) => void; +}; + +export const useSponsorSegments = create()((set) => ({ + segments: [], + setSegments: (segments) => set({ segments }), +})); diff --git a/src/lib/store/track-source.ts b/src/lib/store/track-source.ts index 6c5a3deb..eaf77cb6 100644 --- a/src/lib/store/track-source.ts +++ b/src/lib/store/track-source.ts @@ -23,6 +23,12 @@ type State = { * pair stays consistent. */ byVideoId: Record; + /** + * The kind the user last chose by hand, carried to tracks they haven't + * touched. Picking Video is a mode, not a one-off: without this every + * new track silently reverts to the song. + */ + preferred: SourceKind; /** Cache an alternate id we resolved. `kind` is the kind of `altId`. */ setAlternate: (knownId: string, kind: SourceKind, altId: string) => void; /** Flip the active source for a track. */ @@ -53,6 +59,7 @@ export const useTrackSourceStore = create()( persist( (set) => ({ byVideoId: {}, + preferred: "song", setAlternate: (knownId, kind, altId) => set((s) => { const existing = s.byVideoId[knownId]; @@ -79,12 +86,15 @@ export const useTrackSourceStore = create()( // No record yet — synthesize a stub so the choice is sticky // even before we've resolved the alternate. const fresh: TrackSources = { song: id, selected }; - return { byVideoId: capByVideoId({ ...s.byVideoId, [id]: fresh }) }; + return { + preferred: selected, + byVideoId: capByVideoId({ ...s.byVideoId, [id]: fresh }), + }; } const updated = { ...existing, selected }; const next = { ...s.byVideoId, [existing.song]: updated }; if (existing.video) next[existing.video] = updated; - return { byVideoId: next }; + return { preferred: selected, byVideoId: next }; }), }), { diff --git a/src/lib/store/video-gate.ts b/src/lib/store/video-gate.ts new file mode 100644 index 00000000..cc344d2e --- /dev/null +++ b/src/lib/store/video-gate.ts @@ -0,0 +1,12 @@ +import { create } from "zustand"; + +type State = { + /** Holds the audio element paused without disturbing `playing`. */ + waiting: boolean; + setWaiting: (waiting: boolean) => void; +}; + +export const useVideoGateStore = create()((set) => ({ + waiting: false, + setWaiting: (waiting) => set({ waiting }), +})); diff --git a/src/lib/stream.ts b/src/lib/stream.ts index bcb8f505..120fc913 100644 --- a/src/lib/stream.ts +++ b/src/lib/stream.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { isPremium } from "@/lib/store/premium"; import type { QueueTrack } from "@/lib/store/playback"; +import type { VideoQualityTier } from "@/lib/video-quality"; /** * The Rust side runs a tiny axum server on a random localhost port that @@ -48,6 +49,36 @@ export async function streamUrlFor(videoId: string): Promise { return `${base}/stream/${encodeURIComponent(videoId)}${ephemeralSuffix()}`; } +export async function videoStreamUrlFor( + videoId: string, + tier: VideoQualityTier, +): Promise { + const base = await getStreamBaseUrl(); + return `${base}/video/${encodeURIComponent(videoId)}?q=${tier}`; +} + +const prefetchedVideos = new Set(); + +// One byte: the server still downloads it all, the page carries none of it. +export async function prefetchVideo( + videoId: string, + tier: VideoQualityTier, +): Promise { + const key = `${videoId}:${tier}`; + if (prefetchedVideos.has(key)) return; + prefetchedVideos.add(key); + try { + const base = await getStreamBaseUrl(); + const res = await fetch( + `${base}/video/${encodeURIComponent(videoId)}?q=${tier}`, + { headers: { Range: "bytes=0-0" } }, + ); + if (!res.ok && res.status !== 206) prefetchedVideos.delete(key); + } catch { + prefetchedVideos.delete(key); + } +} + const prefetched = new Set(); /** @@ -118,5 +149,6 @@ export async function saveTrackMeta( */ export function clearPrefetchMemo(): void { prefetched.clear(); + prefetchedVideos.clear(); metaWritten.clear(); } diff --git a/src/lib/track-meta.ts b/src/lib/track-meta.ts index 9b96ca71..51e1fa34 100644 --- a/src/lib/track-meta.ts +++ b/src/lib/track-meta.ts @@ -174,6 +174,25 @@ export function artistFromSubtitle( return undefined; } +export function trackArtistNames(track: { + artists?: { name: string }[]; + subtitle?: string; +}): string[] { + if (track.artists?.length) { + return track.artists.map((a) => stripTopicSuffix(a.name)).filter(Boolean); + } + const fallback = artistFromSubtitle(track.subtitle); + return fallback ? [fallback] : []; +} + +export function trackArtistIds(track: { + artists?: { id?: string }[]; +}): string[] { + return (track.artists ?? []) + .map((a) => a.id) + .filter((id): id is string => Boolean(id)); +} + /** The structured artist list, joined and de-Topic'd, or undefined. */ export function artistsFromList( artists: { name: string }[] | undefined, diff --git a/src/lib/video-quality.ts b/src/lib/video-quality.ts new file mode 100644 index 00000000..20a95dff --- /dev/null +++ b/src/lib/video-quality.ts @@ -0,0 +1,59 @@ +import { diagLog } from "@/lib/diagnostics"; +import { usePlaybackSettings } from "@/lib/store/playback-settings"; + +export type VideoQualityTier = "hi" | "lo"; + +let tier: VideoQualityTier | null = null; +let tierPromise: Promise | null = null; + +async function smoothDecode(contentType: string): Promise { + const mc = navigator.mediaCapabilities; + if (!mc?.decodingInfo) return false; + try { + const info = await mc.decodingInfo({ + type: "file", + video: { + contentType, + width: 3840, + height: 2160, + bitrate: 15_000_000, + framerate: 30, + }, + }); + return info.supported && info.smooth; + } catch { + return false; + } +} + +async function probe(): Promise { + const vp9 = await smoothDecode('video/webm; codecs="vp09.00.50.08"'); + const av1 = vp9 ? false : await smoothDecode('video/mp4; codecs="av01.0.12M.08"'); + const picked: VideoQualityTier = vp9 || av1 ? "hi" : "lo"; + diagLog( + "video-quality", + `capability probe: vp9=${vp9} av1=${av1} tier=${picked}`, + ); + return picked; +} + +export function getVideoQualityTier(): Promise { + const choice = usePlaybackSettings.getState().videoQuality; + if (choice !== "auto") return Promise.resolve(choice); + if (tier) return Promise.resolve(tier); + if (!tierPromise) { + tierPromise = probe().then((t) => { + tier = t; + return t; + }); + } + return tierPromise; +} + +export function reportPlaybackStutter(): void { + if (usePlaybackSettings.getState().videoQuality !== "auto") return; + if (tier === "lo") return; + tier = "lo"; + tierPromise = Promise.resolve("lo"); + diagLog("video-quality", "dropped-frame ratio too high, downgrading to lo"); +}