From beccaec12899514347522f9acbd8fcfe9fbf9ade Mon Sep 17 00:00:00 2001 From: lullabyX Date: Mon, 6 Jul 2026 02:46:46 +0600 Subject: [PATCH 01/20] feat(video): add video stream + metadata API and playlist items endpoint --- src-tauri/src/cache.rs | 2 +- src-tauri/src/commands/playback.rs | 24 +++- src-tauri/src/lib.rs | 2 + src-tauri/src/tidal_api.rs | 172 ++++++++++++++++++++++++++--- 4 files changed, 182 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/cache.rs b/src-tauri/src/cache.rs index 84c9c1a9..c776c539 100644 --- a/src-tauri/src/cache.rs +++ b/src-tauri/src/cache.rs @@ -187,7 +187,7 @@ impl DiskCacheInner { const MAX_DISK_BYTES: u64 = 2 * 1024 * 1024 * 1024; // 2 GB const EVICT_TARGET: u64 = MAX_DISK_BYTES * 9 / 10; // 1.8 GB -const CURRENT_SCHEMA_VERSION: u8 = 3; +const CURRENT_SCHEMA_VERSION: u8 = 4; pub struct DiskCache { base_dir: PathBuf, diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index 6bc520d0..a4a666fc 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use std::sync::atomic::Ordering; use tauri::State; -use crate::tidal_api::StreamInfo; +use crate::tidal_api::{StreamInfo, TidalVideo, VideoStreamInfo}; use crate::AppState; use crate::SoneError; @@ -212,6 +212,28 @@ pub async fn get_stream_info( Ok(info) } +/// Resolve a music video's HLS master playlist URL. Pure resolver — video +/// plays in the WebView, so this never touches the audio pipeline. +#[tauri::command(rename_all = "camelCase")] +pub async fn get_video_stream_info( + state: State<'_, AppState>, + video_id: u64, + video_quality: Option, +) -> Result { + let quality = video_quality.unwrap_or_else(|| "HIGH".to_string()); + let mut client = state.tidal_client.lock().await; + client.get_video_stream_url(video_id, &quality).await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn get_video_metadata( + state: State<'_, AppState>, + video_id: u64, +) -> Result { + let mut client = state.tidal_client.lock().await; + client.get_video(video_id).await +} + #[tauri::command] pub async fn pause_track(state: State<'_, AppState>) -> Result<(), SoneError> { log::debug!("[pause_track]"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cd6060d5..ebb4ee18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -882,6 +882,8 @@ pub fn run() { commands::playback::set_next_track, commands::playback::clear_next_track, commands::playback::get_stream_info, + commands::playback::get_video_stream_info, + commands::playback::get_video_metadata, commands::playback::pause_track, commands::playback::resume_track, commands::playback::stop_track, diff --git a/src-tauri/src/tidal_api.rs b/src-tauri/src/tidal_api.rs index 42b4eb43..bff381df 100644 --- a/src-tauri/src/tidal_api.rs +++ b/src-tauri/src/tidal_api.rs @@ -110,6 +110,12 @@ pub struct TidalTrack { /// Present on track detail responses — contains mix IDs like `TRACK_MIX`. #[serde(default)] pub mixes: Option, + /// From the `/playlists/{id}/items` wrapper `type` — "track" or "video". + #[serde(default)] + pub item_type: Option, + /// Video thumbnail UUID (videos carry `imageId` instead of `album.cover`). + #[serde(default)] + pub image_id: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -136,6 +142,34 @@ impl TidalTrack { } } +/// Parse `/playlists/{id}/items` wrapper entries (`{ item, type }`) into TidalTracks. +/// A video's inner `item` deserializes cleanly (its missing track-only fields are +/// all `#[serde(default)]`); we stamp `item_type` from the wrapper and copy `imageId`. +fn parse_playlist_items(items: Vec) -> Result, SoneError> { + let mut out = Vec::with_capacity(items.len()); + for entry in items { + let item_type = entry + .get("type") + .and_then(|t| t.as_str()) + .map(|s| s.to_lowercase()); + let Some(inner) = entry.get("item") else { + continue; + }; + let mut track: TidalTrack = serde_json::from_value(inner.clone()) + .map_err(|e| SoneError::Parse(format!("{} - Item: {}", e, inner)))?; + if track.image_id.is_none() { + track.image_id = inner + .get("imageId") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + track.item_type = item_type; + track.backfill_artist(); + out.push(track); + } + Ok(out) +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct TidalAlbumDetail { @@ -501,6 +535,40 @@ pub struct StreamInfo { pub track_peak_amplitude: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct VideoStreamInfo { + pub url: String, + pub video_quality: String, + pub manifest_mime_type: String, + pub video_id: u64, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TidalVideo { + pub id: u64, + pub title: String, + #[serde(default)] + pub duration: Option, + #[serde(default)] + pub image_id: Option, + #[serde(default)] + pub vibrant_color: Option, + #[serde(default)] + pub quality: Option, + #[serde(default, rename = "type")] + pub video_type: Option, + #[serde(default)] + pub explicit: Option, + #[serde(default)] + pub ads_pre_paywall_only: Option, + #[serde(default)] + pub artist: Option, + #[serde(default)] + pub artists: Option>, +} + // ==================== v2 Home Feed MIX types ==================== // These structs document the v2 MIX shape. Not yet consumed by backend code // (home feed items pass through as raw Value), but available for future typed parsing. @@ -1778,12 +1846,14 @@ impl TidalClient { &mut self, playlist_id: &str, ) -> Result, SoneError> { - let path = format!("/playlists/{}/tracks", playlist_id); + // `/items` (not `/tracks`) returns the real entries: tracks AND videos, + // each wrapped as `{ "item": {...}, "type": "track" | "video" }`. + let path = format!("/playlists/{}/items", playlist_id); #[derive(Deserialize)] #[serde(rename_all = "camelCase")] - struct TracksResponse { - items: Vec, + struct ItemsResponse { + items: Vec, total_number_of_items: u32, } @@ -1806,14 +1876,12 @@ impl TidalClient { ) .await?; - let mut data: TracksResponse = serde_json::from_str(&body) + let data: ItemsResponse = serde_json::from_str(&body) .map_err(|e| SoneError::Parse(format!("{} - Body: {}", e, body)))?; let fetched = data.items.len() as u32; - for t in &mut data.items { - t.backfill_artist(); - } - all_tracks.append(&mut data.items); + let mut tracks = parse_playlist_items(data.items)?; + all_tracks.append(&mut tracks); if fetched == 0 || all_tracks.len() as u32 >= data.total_number_of_items { break; @@ -1846,17 +1914,18 @@ impl TidalClient { if let Some(od) = order_direction { params.push(("orderDirection", od)); } + // `/items` returns tracks AND videos, each wrapped as `{ item, type }`. let body = self .api_get_body( - &format!("/playlists/{}/tracks", playlist_id), + &format!("/playlists/{}/items", playlist_id), ¶ms, ) .await?; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] - struct TracksResponse { - items: Vec, + struct ItemsResponse { + items: Vec, total_number_of_items: u32, #[serde(default)] offset: u32, @@ -1864,13 +1933,11 @@ impl TidalClient { limit: u32, } - let mut data: TracksResponse = serde_json::from_str(&body) + let data: ItemsResponse = serde_json::from_str(&body) .map_err(|e| SoneError::Parse(format!("{} - Body: {}", e, body)))?; - for t in &mut data.items { - t.backfill_artist(); - } + let items = parse_playlist_items(data.items)?; Ok(PaginatedTracks { - items: data.items, + items, total_number_of_items: data.total_number_of_items, offset: data.offset, limit: data.limit, @@ -3178,6 +3245,79 @@ impl TidalClient { }) } + pub async fn get_video_stream_url( + &mut self, + video_id: u64, + video_quality: &str, + ) -> Result { + let cc = self.country_code.clone(); + let body = self + .api_get_body( + &format!("/videos/{}/playbackinfopostpaywall", video_id), + &[ + ("countryCode", &cc), + ("videoquality", video_quality), + ("playbackmode", "STREAM"), + ("assetpresentation", "FULL"), + ], + ) + .await?; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct PlaybackInfo { + video_id: u64, + video_quality: String, + manifest_mime_type: String, + manifest: String, + } + + let data = serde_json::from_str::(&body) + .map_err(|e| SoneError::Parse(format!("{} - Body: {}", e, body)))?; + + use base64::Engine; + let manifest_bytes = base64::engine::general_purpose::STANDARD + .decode(&data.manifest) + .map_err(|e| SoneError::Parse(format!("Failed to decode manifest: {}", e)))?; + let manifest_str = String::from_utf8(manifest_bytes) + .map_err(|e| SoneError::Parse(format!("Invalid manifest encoding: {}", e)))?; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct EmuManifest { + urls: Vec, + } + + let manifest_data = serde_json::from_str::(&manifest_str) + .map_err(|e| SoneError::Parse(format!("{} - Manifest: {}", e, manifest_str)))?; + + let url = manifest_data + .urls + .into_iter() + .next() + .ok_or(SoneError::Parse("No URL in video manifest".into()))?; + + Ok(VideoStreamInfo { + url, + video_quality: data.video_quality, + manifest_mime_type: data.manifest_mime_type, + video_id: data.video_id, + }) + } + + pub async fn get_video(&mut self, video_id: u64) -> Result { + let cc = self.country_code.clone(); + self.api_get( + &format!("/videos/{}", video_id), + &[ + ("countryCode", &cc), + ("locale", "en_US"), + ("deviceType", "BROWSER"), + ], + ) + .await + } + pub async fn get_track_lyrics(&mut self, track_id: u64) -> Result { let cc = self.country_code.clone(); self.api_get( From 7c3cc6f99f34101607e14c44543e4336cb33d4a8 Mon Sep 17 00:00:00 2001 From: lullabyX Date: Mon, 6 Jul 2026 02:46:52 +0600 Subject: [PATCH 02/20] feat(video): video playback, queue integration, and player UI --- package.json | 1 + pnpm-lock.yaml | 8 + src/api/tidal.ts | 12 +- src/atoms/video.ts | 10 + src/components/AppInitializer.tsx | 11 +- src/components/ArtistPage.tsx | 33 +- src/components/HomeSection.tsx | 5 + src/components/Layout.tsx | 26 +- src/components/NowPlayingDrawer.tsx | 8 +- src/components/PlayerBar.tsx | 210 ++++++++++- src/components/PlaylistView.tsx | 13 +- src/components/TrackList.tsx | 23 +- src/components/VideoPlayer.tsx | 547 ++++++++++++++++++++++++++++ src/components/ViewAllPage.tsx | 5 + src/components/VolumeSlider.tsx | 29 +- src/hooks/useGaplessPrefetch.ts | 2 + src/hooks/useMediaPlay.ts | 14 + src/hooks/usePlaybackActions.ts | 123 ++++++- src/hooks/useVideoPlayback.ts | 99 +++++ src/lib/gaplessPredict.ts | 1 + src/lib/trackAvailability.ts | 1 + src/lib/videoElement.ts | 5 + src/lib/videoSession.ts | 63 ++++ src/types.ts | 35 +- src/utils/itemHelpers.ts | 33 +- 25 files changed, 1270 insertions(+), 47 deletions(-) create mode 100644 src/atoms/video.ts create mode 100644 src/components/VideoPlayer.tsx create mode 100644 src/hooks/useVideoPlayback.ts create mode 100644 src/lib/videoElement.ts create mode 100644 src/lib/videoSession.ts diff --git a/package.json b/package.json index 9c550d25..72927bb6 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tauri-apps/api": "2.11.0", "@tauri-apps/plugin-deep-link": "2.4.9", "@tauri-apps/plugin-opener": "2.5.4", + "hls.js": "^1.6.16", "jotai": "^2.17.1", "lucide-react": "^0.563.0", "qrcode.react": "^4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c183fd8..fa9a9698 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@tauri-apps/plugin-opener': specifier: 2.5.4 version: 2.5.4 + hls.js: + specifier: ^1.6.16 + version: 1.6.16 jotai: specifier: ^2.17.1 version: 2.17.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.10)(react@19.2.4) @@ -1435,6 +1438,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3274,6 +3280,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hls.js@1.6.16: {} + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.1 diff --git a/src/api/tidal.ts b/src/api/tidal.ts index 07715bc0..ea1d948f 100644 --- a/src/api/tidal.ts +++ b/src/api/tidal.ts @@ -503,6 +503,7 @@ const V2_TYPE_TO_SECTION: Record = { ARTIST: "ARTIST_LIST", PLAYLIST: "PLAYLIST_LIST", MIX: "MIX_LIST", + VIDEO: "VIDEO_LIST", }; function parseArtistPageV2(json: any): ArtistPageData { @@ -527,10 +528,14 @@ function parseArtistPageV2(json: any): ArtistPageData { if (rawItems.length === 0) continue; const firstType = rawItems[0]?.type as string; - if (firstType === "VIDEO" || firstType === "TRACK_CREDITS") continue; + if (firstType === "TRACK_CREDITS") continue; const sectionType = V2_TYPE_TO_SECTION[firstType] || firstType; - const items = rawItems.map((i: any) => i.data || i); + const items = rawItems.map((i: any) => + firstType === "VIDEO" + ? { ...(i.data || i), _itemType: "VIDEO" } + : i.data || i, + ); if (sectionType === "TRACK_LIST" && result.topTracks.length === 0) { result.topTracks = items; @@ -790,6 +795,9 @@ export async function fetchMediaTracks(item: MediaItemType): Promise { case "artist": { return await getArtistTopTracks(item.id); } + case "video": + // Videos are not audio tracks — handled by the video player, not the queue. + return []; } } diff --git a/src/atoms/video.ts b/src/atoms/video.ts new file mode 100644 index 00000000..925d8762 --- /dev/null +++ b/src/atoms/video.ts @@ -0,0 +1,10 @@ +import { atom } from "jotai"; +import type { TidalVideo, VideoStreamInfo } from "../types"; + +/** The video currently open in the takeover player (null = closed). */ +export const currentVideoAtom = atom(null); +export const videoPlayingAtom = atom(false); +export const videoStreamAtom = atom(null); +export const videoFullscreenAtom = atom(false); +/** Overlay visible when true; minimized to the player bar when false. */ +export const videoExpandedAtom = atom(false); diff --git a/src/components/AppInitializer.tsx b/src/components/AppInitializer.tsx index ddb65a7c..f8d4eda9 100644 --- a/src/components/AppInitializer.tsx +++ b/src/components/AppInitializer.tsx @@ -35,6 +35,7 @@ import { favoriteMixIdsAtom, } from "../atoms/favorites"; import { currentViewAtom } from "../atoms/navigation"; +import { currentVideoAtom } from "../atoms/video"; import { isPlayingAtom, currentTrackAtom, @@ -754,8 +755,13 @@ export function AppInitializer() { useEffect(() => { if (!volumeSyncedRef.current) { volumeSyncedRef.current = true; - const vol = store.get(volumeAtom); - invoke("set_volume", { level: vol }).catch(() => {}); + // Never push a persisted volume into a bit-perfect pipeline — it must stay + // at unity. The stored value can be non-unity if the user adjusted volume + // during video playback (video audio is lossy and always controllable). + if (!store.get(bitPerfectAtom)) { + const vol = store.get(volumeAtom); + invoke("set_volume", { level: vol }).catch(() => {}); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -1063,6 +1069,7 @@ export function AppInitializer() { const pushMetadata = () => { const track = store.get(currentTrackAtom); if (!track) return; + if (store.get(currentVideoAtom)) return; // video is current → don't overwrite MPRIS with audio metadata const streamInfo = store.get(streamInfoAtom); const favoriteIds = store.get(favoriteTrackIdsAtom); // Track.album has no artist field; fall back to track's own artist. diff --git a/src/components/ArtistPage.tsx b/src/components/ArtistPage.tsx index eb04e10d..2ab15c4c 100644 --- a/src/components/ArtistPage.tsx +++ b/src/components/ArtistPage.tsx @@ -20,6 +20,7 @@ import { import { useStore } from "jotai"; import { isPlayingAtom, currentTrackAtom } from "../atoms/playback"; import { usePlaybackActions } from "../hooks/usePlaybackActions"; +import { useMediaPlay } from "../hooks/useMediaPlay"; import { useFavorites } from "../hooks/useFavorites"; import { useNavigation } from "../hooks/useNavigation"; import { useToast } from "../contexts/ToastContext"; @@ -100,6 +101,7 @@ export default function ArtistPage({ playFromSource, playAllFromSource, } = usePlaybackActions(); + const playMedia = useMediaPlay(); const { followedArtistIds, followArtist, @@ -439,7 +441,18 @@ export default function ArtistPage({ const handleCardClick = useCallback( (item: any, sectionType: string) => { - if (sectionType === "ALBUM_LIST") { + if (sectionType === "VIDEO_LIST") { + if (item.id != null) { + playMedia({ + type: "video", + id: Number(item.id), + title: item.title || getItemTitle(item), + imageId: item.imageId, + artist: item.artist?.name || item.artists?.[0]?.name, + duration: item.duration, + }).catch(() => {}); + } + } else if (sectionType === "ALBUM_LIST") { navigateToAlbum(item.id, { title: item.title, cover: item.cover, @@ -469,7 +482,13 @@ export default function ArtistPage({ } } }, - [navigateToAlbum, navigateToArtist, navigateToPlaylist, navigateToMix], + [ + navigateToAlbum, + navigateToArtist, + navigateToPlaylist, + navigateToMix, + playMedia, + ], ); const artistPlaying = (() => { @@ -718,9 +737,13 @@ export default function ArtistPage({ } if ( - ["ALBUM_LIST", "ARTIST_LIST", "PLAYLIST_LIST", "MIX_LIST"].includes( - section.type, - ) + [ + "ALBUM_LIST", + "ARTIST_LIST", + "PLAYLIST_LIST", + "MIX_LIST", + "VIDEO_LIST", + ].includes(section.type) ) { return ( isTrackItem(t, section.sectionType), diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 2febe495..e2f15bcd 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -13,6 +13,8 @@ import { maximizedPlayerAtom, } from "../atoms/ui"; import MaximizedPlayer from "./MaximizedPlayer"; +import VideoPlayer from "./VideoPlayer"; +import { currentVideoAtom, videoExpandedAtom } from "../atoms/video"; import { useMiniplayerEmitter } from "../hooks/useMiniplayerEmitter"; interface LayoutProps { @@ -23,6 +25,11 @@ export default function Layout({ children }: LayoutProps) { const scrollRef = useRef(null); const currentView = useAtomValue(currentViewAtom); const maximized = useAtomValue(maximizedPlayerAtom); + const currentVideo = useAtomValue(currentVideoAtom); + const videoExpanded = useAtomValue(videoExpandedAtom); + // Hide the audio chrome only while the overlay is actually showing; when the + // video is minimized the normal view + player bar return (video keeps playing). + const overlayShowing = currentVideo && videoExpanded; // `false` = custom titlebar shown; `true` = native OS chrome (escape hatch) const nativeChrome = useAtomValue(decorationsAtom); const hideTitleBar = useAtomValue(hideTitleBarAtom); @@ -91,7 +98,15 @@ export default function Layout({ children }: LayoutProps) { return (
{!nativeChrome && !hideTitleBar && } -
+ {/* Hide the audio chrome (sidebar + heavy library grids + player bar) while a + fullscreen video overlay is open. An opaque overlay does NOT stop WebKit from + compositing the layer tree beneath it every video frame — at 4K that throttles + the compositor ~3x and makes the video stutter. display:none removes those + layers from compositing entirely. `contents` keeps the wrapper layout-neutral + for the fixed-positioned chrome when no video is playing. */} +
@@ -104,9 +119,12 @@ export default function Layout({ children }: LayoutProps) {
- - {maximized && } - +
+ + {maximized && } + +
+ {currentVideo && } {!nativeChrome && }
); diff --git a/src/components/NowPlayingDrawer.tsx b/src/components/NowPlayingDrawer.tsx index 48b8c354..7437505e 100644 --- a/src/components/NowPlayingDrawer.tsx +++ b/src/components/NowPlayingDrawer.tsx @@ -62,7 +62,7 @@ import TidalVideoCover from "./TidalVideoCover"; import { TiltCover } from "./TiltCover"; import TrackContextMenu from "./TrackContextMenu"; import { TrackArtists, type ArtistInfo } from "./TrackArtists"; -import { getTrackArtistDisplay } from "../utils/itemHelpers"; +import { getTrackArtistDisplay, trackCoverId } from "../utils/itemHelpers"; type TabId = "queue" | "suggested" | "lyrics" | "credits"; @@ -644,7 +644,7 @@ function SuggestedTrackRow({ onClick={handlePlayClick} > @@ -1401,7 +1401,7 @@ function TrackRow({ >
@@ -1633,7 +1633,7 @@ export default function NowPlayingDrawer() { ) : ( diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 56bcb028..c3415d4e 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -12,12 +12,13 @@ import { MoreHorizontal, PictureInPicture2, } from "lucide-react"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { getTidalImageUrl, getTrackDisplayTitle } from "../types"; import ExplicitBadge from "./ExplicitBadge"; import { formatTime } from "../lib/format"; import { isNavigableSource } from "../lib/playbackSource"; import TidalImage from "./TidalImage"; -import { useCallback, useRef, useState, memo } from "react"; +import { useCallback, useRef, useState, useEffect, memo } from "react"; import { useAtomValue, useAtom, useSetAtom } from "jotai"; import { currentTrackAtom, @@ -26,10 +27,17 @@ import { shuffleAtom, playbackSourceAtom, } from "../atoms/playback"; +import { + currentVideoAtom, + videoPlayingAtom, + videoFullscreenAtom, +} from "../atoms/video"; import { favoriteTrackIdsAtom } from "../atoms/favorites"; import { maximizedPlayerAtom } from "../atoms/ui"; import { usePlaybackActions } from "../hooks/usePlaybackActions"; +import { useVideoPlayback } from "../hooks/useVideoPlayback"; import { useProgressScrub } from "../hooks/useProgressScrub"; +import { videoElementRef } from "../lib/videoElement"; import { useFavorites } from "../hooks/useFavorites"; import { useDrawer } from "../hooks/useDrawer"; import { useNavigation } from "../hooks/useNavigation"; @@ -44,8 +52,46 @@ import TrackContextMenu from "./TrackContextMenu"; const TrackInfoSection = memo(function TrackInfoSection() { const currentTrack = useAtomValue(currentTrackAtom); + const currentVideo = useAtomValue(currentVideoAtom); const { toggleDrawer } = useDrawer(); const { navigateToAlbum } = useNavigation(); + const { expandVideo } = useVideoPlayback(); + + // Video takes precedence: while one is active the bar represents the video. + if (currentVideo) { + const videoArtist = + currentVideo.artist?.name || + currentVideo.artists?.map((a) => a.name).join(", ") || + ""; + return ( + <> +
+ +
+
+
+ + {currentVideo.title} + + {currentVideo.explicit && } +
+ {videoArtist && ( + + {videoArtist} + + )} +
+ + ); + } if (!currentTrack) { return
No track playing
; @@ -250,6 +296,123 @@ export const PlayingFromLabel = memo(function PlayingFromLabel() { ); }); +// ─── VideoProgressScrubber ───────────────────────────────────────────────── +// Bound to the shared