From d47f0f70b78b59c0ab902d2cebde69f104857e65 Mon Sep 17 00:00:00 2001 From: Faizan Asad Date: Sun, 27 Sep 2026 20:12:44 +0500 Subject: [PATCH] feat: robust offline mode and offline artwork for downloads - Save download covers to disk and use them wherever the song shows, incl. OS media controls - Stay signed in with the cached library when launching offline - Offline Home lists downloads; search searches downloads - Skip undownloaded songs on next/previous; dim them in track lists - Pause the download queue offline and resume on reconnect - Queue Last.fm scrobbles made offline - Friendly offline messages instead of load errors - Never cache failed YouTube clients or the IFrame API, which made songs skip after reconnecting --- src-tauri/src/lib.rs | 83 ++++++++- src-tauri/src/linux_media.rs | 5 + src-tauri/src/macos_media.rs | 11 +- src-tauri/src/windows_media.rs | 27 ++- .../youtube/YouTubeMusicDataSource.ts | 40 ++++- src/datasource/youtube/artwork.check.ts | 24 ++- src/datasource/youtube/artwork.ts | 14 ++ src/internal/connectivity.ts | 41 +++++ src/player/AudioEngine.ts | 7 +- src/player/LastFm.ts | 57 ++++++ src/player/LibraryController.ts | 6 +- src/player/PlayerController.ts | 54 +++++- src/player/SearchController.ts | 8 +- src/player/offlineStore.check.ts | 17 +- src/player/offlineStore.ts | 162 ++++++++++++++++-- src/player/playableIndex.check.ts | 24 +++ src/player/playableIndex.ts | 16 ++ src/player/useMediaSession.ts | 7 +- src/ui/components/TrackArtwork.tsx | 26 ++- src/ui/components/TrackRow.tsx | 4 + src/ui/components/player/PlayerBar.tsx | 5 +- src/ui/pages/AlbumView.tsx | 3 +- src/ui/pages/ArtistView.tsx | 3 +- src/ui/pages/BrowsePage.tsx | 3 +- src/ui/pages/HomePage.tsx | 100 ++++++++++- src/ui/pages/PlaylistView.tsx | 7 +- 26 files changed, 683 insertions(+), 71 deletions(-) create mode 100644 src/internal/connectivity.ts create mode 100644 src/player/playableIndex.check.ts create mode 100644 src/player/playableIndex.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe06c522..fdd2b9c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -730,13 +730,8 @@ fn read_image_file(path: String) -> Result { return Err(cache_error("that image is too large to embed")); } - let mime_type = match data.as_slice() { - [0xff, 0xd8, 0xff, ..] => "image/jpeg", - [0x89, b'P', b'N', b'G', ..] => "image/png", - [b'G', b'I', b'F', b'8', ..] => "image/gif", - [b'B', b'M', ..] => "image/bmp", - [b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => "image/webp", - _ => return Err(cache_error("that file is not a PNG, JPEG, GIF, BMP or WebP image")), + let Some(mime_type) = sniff_image_mime(&data) else { + return Err(cache_error("that file is not a PNG, JPEG, GIF, BMP or WebP image")); }; Ok(LocalArtwork { @@ -745,6 +740,17 @@ fn read_image_file(path: String) -> Result { }) } +fn sniff_image_mime(data: &[u8]) -> Option<&'static str> { + match data { + [0xff, 0xd8, 0xff, ..] => Some("image/jpeg"), + [0x89, b'P', b'N', b'G', ..] => Some("image/png"), + [b'G', b'I', b'F', b'8', ..] => Some("image/gif"), + [b'B', b'M', ..] => Some("image/bmp"), + [b'R', b'I', b'F', b'F', _, _, _, _, b'W', b'E', b'B', b'P', ..] => Some("image/webp"), + _ => None, + } +} + /// The first embedded picture, for the one file the UI is about to show. #[tauri::command] fn local_audio_artwork(path: String) -> Result, CommandError> { @@ -3183,6 +3189,7 @@ fn offline_entry_path(app: &tauri::AppHandle, track_id: &str) -> Result Result Result<(), CommandError> { let path = offline_entry_path(&app, &track_id)?; + let _ = fs::remove_file(path.with_extension("art")); if path.exists() { fs::remove_file(&path) .map_err(|error| cache_error(format!("offline delete failed: {error}")))?; @@ -3682,6 +3690,63 @@ fn offline_audio_remove(app: tauri::AppHandle, track_id: String) -> Result<(), C Ok(()) } +/// The saved cover behind an `offline-artwork:` URL, for the OS media controls. +pub(crate) fn offline_artwork_file(app: &tauri::AppHandle, url: &str) -> Option { + let track_id = url.strip_prefix("offline-artwork:")?; + let path = offline_entry_path(app, track_id).ok()?.with_extension("art"); + path.is_file().then_some(path) +} + +/// Saves a downloaded track's cover next to its audio, so it still shows with no network. +#[tauri::command] +async fn offline_artwork_save( + app: tauri::AppHandle, + track_id: String, + url: String, +) -> Result<(), CommandError> { + let path = offline_entry_path(&app, &track_id)?.with_extension("art"); + if !url.starts_with("https://") { + return Err(cache_error("artwork URL must be https")); + } + let client = http_client_builder() + .timeout(Duration::from_secs(20)) + .build() + .map_err(|error| cache_error(format!("artwork client creation failed: {error}")))?; + let bytes = client + .get(&url) + .send() + .await + .and_then(|response| response.error_for_status()) + .map_err(|error| cache_error(format!("artwork request failed: {error}")))? + .bytes() + .await + .map_err(|error| cache_error(format!("artwork read failed: {error}")))?; + if bytes.len() > MAX_ARTWORK_BYTES || sniff_image_mime(&bytes).is_none() { + return Err(cache_error("artwork response is not a usable image")); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| cache_error(format!("offline directory creation failed: {error}")))?; + } + fs::write(&path, &bytes).map_err(|error| cache_error(format!("artwork save failed: {error}"))) +} + +#[tauri::command] +fn offline_artwork(app: tauri::AppHandle, track_id: String) -> Result, CommandError> { + use base64::Engine; + + let path = offline_entry_path(&app, &track_id)?.with_extension("art"); + let data = match fs::read(&path) { + Ok(data) => data, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(cache_error(format!("artwork read failed: {error}"))), + }; + Ok(sniff_image_mime(&data).map(|mime_type| LocalArtwork { + mime_type: mime_type.to_string(), + data_base64: base64::engine::general_purpose::STANDARD.encode(&data), + })) +} + /// The source of truth for what is downloaded. The frontend keeps its own manifest for track /// metadata, but this is what reconciles it when the two disagree. #[tauri::command] @@ -3705,6 +3770,7 @@ fn offline_audio_list(app: tauri::AppHandle) -> Result, Co entries.push(OfflineEntryInfo { track_id: track_id.to_string(), byte_length: entry.metadata().map(|meta| meta.len()).unwrap_or(0), + has_artwork: path.with_extension("art").exists(), }); } Ok(entries) @@ -3756,6 +3822,7 @@ fn offline_audio_prune(app: tauri::AppHandle, max_bytes: u64) -> Result, update: MediaSessionUpdate, ) -> Result<(), String> { + // MPRIS takes `file://` covers, which is how a downloaded song keeps its artwork offline. + let mut update = update; + if let Some(path) = update.artwork_url.as_deref().and_then(|url| crate::offline_artwork_file(&app, url)) { + update.artwork_url = Some(format!("file://{}", path.display())); + } state.update(&app, update) } diff --git a/src-tauri/src/macos_media.rs b/src-tauri/src/macos_media.rs index 370d89d9..8759c45d 100644 --- a/src-tauri/src/macos_media.rs +++ b/src-tauri/src/macos_media.rs @@ -130,7 +130,7 @@ impl MacosMediaSession { fn spawn_artwork_fetch(app: &AppHandle, url: String) { let app = app.clone(); tauri::async_runtime::spawn(async move { - let bytes = load_artwork_bytes(&url).await.unwrap_or_default(); + let bytes = load_artwork_bytes(&app, &url).await.unwrap_or_default(); let Some(session) = app.try_state::() else { return; }; @@ -140,9 +140,14 @@ fn spawn_artwork_fetch(app: &AppHandle, url: String) { }); } -async fn load_artwork_bytes(url: &str) -> Option> { +async fn load_artwork_bytes(app: &AppHandle, url: &str) -> Option> { use base64::Engine; + // A downloaded song's saved cover, so Now Playing keeps its artwork offline. + if let Some(path) = crate::offline_artwork_file(app, url) { + return std::fs::read(path).ok(); + } + // Embedded cover of a local file. The tag read blocks, but once per track, not per update. if let Some(path) = url.strip_prefix("local-art:") { let artwork = crate::local_audio_artwork(path.to_string()).ok()??; @@ -151,7 +156,7 @@ async fn load_artwork_bytes(url: &str) -> Option> { .ok(); } - let response = reqwest::get(url).await.ok()?; + let response = crate::http_client_builder().build().ok()?.get(url).send().await.ok()?; if !response.status().is_success() { return None; } diff --git a/src-tauri/src/windows_media.rs b/src-tauri/src/windows_media.rs index 927a4022..e31e5559 100644 --- a/src-tauri/src/windows_media.rs +++ b/src-tauri/src/windows_media.rs @@ -15,7 +15,7 @@ use windows::{ MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControls, SystemMediaTransportControlsTimelineProperties, }, - Storage::Streams::RandomAccessStreamReference, + Storage::{StorageFile, Streams::RandomAccessStreamReference}, Win32::{ Foundation::{HWND, LPARAM, LRESULT, RPC_E_CHANGED_MODE, WPARAM}, System::Com::{ @@ -248,14 +248,10 @@ impl NativeMediaSession { }) .map_err(|error| error.to_string())?; - if let Some(artwork_url) = metadata.artwork_url.as_ref() { - if let Ok(uri) = Uri::CreateUri(&HSTRING::from(artwork_url.as_str())) { - if let Ok(thumbnail) = RandomAccessStreamReference::CreateFromUri(&uri) { - updater - .SetThumbnail(&thumbnail) - .map_err(|error| error.to_string())?; - } - } + if let Some(thumbnail) = metadata.artwork_url.as_deref().and_then(artwork_stream) { + updater + .SetThumbnail(&thumbnail) + .map_err(|error| error.to_string())?; } } @@ -315,6 +311,15 @@ impl MediaMetadata { } } +/// A downloaded song's saved cover is a `file://` path; anything else is a URL the shell fetches. +fn artwork_stream(url: &str) -> Option { + if let Some(path) = url.strip_prefix("file://") { + let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path)).ok()?.get().ok()?; + return RandomAccessStreamReference::CreateFromFile(&file).ok(); + } + RandomAccessStreamReference::CreateFromUri(&Uri::CreateUri(&HSTRING::from(url)).ok()?).ok() +} + fn duration_to_timespan(seconds: f64) -> windows::Foundation::TimeSpan { windows::Foundation::TimeSpan { Duration: (seconds * 10_000_000.0).round() as i64, @@ -603,5 +608,9 @@ pub fn update_windows_media_session( state: tauri::State<'_, WindowsMediaSession>, update: MediaSessionUpdate, ) -> Result<(), String> { + let mut update = update; + if let Some(path) = update.artwork_url.as_deref().and_then(|url| crate::offline_artwork_file(&app, url)) { + update.artwork_url = Some(format!("file://{}", path.display())); + } state.with_session(&app, |session| session.update(update)) } diff --git a/src/datasource/youtube/YouTubeMusicDataSource.ts b/src/datasource/youtube/YouTubeMusicDataSource.ts index 4d990875..dcb32ab7 100644 --- a/src/datasource/youtube/YouTubeMusicDataSource.ts +++ b/src/datasource/youtube/YouTubeMusicDataSource.ts @@ -536,7 +536,12 @@ export class YouTubeMusicDataSource extends DataSource { private getMusicClient(): Promise { if (!this.musicClientPromise) { logInternalInfo("YouTubeMusicDataSource.getMusicClient creating client"); - this.musicClientPromise = this.createMusicClient(true); + const promise = this.createMusicClient(true); + // A failure (offline at launch) must not be cached, or every call fails until restart. + promise.catch(() => { + if (this.musicClientPromise === promise) this.musicClientPromise = null; + }); + this.musicClientPromise = promise; } return this.musicClientPromise; @@ -547,10 +552,14 @@ export class YouTubeMusicDataSource extends DataSource { logInternalInfo("YouTubeMusicDataSource.getWebClient creating client"); // No player needed: this client only enumerates accounts and resolves like endpoints, // neither of which touches stream URLs, and retrieving it downloads the player script. - this.webClientPromise = Innertube.create({ + const promise = Innertube.create({ ...this.getSessionOptions(false), client_type: ClientType.WEB, }); + promise.catch(() => { + if (this.webClientPromise === promise) this.webClientPromise = null; + }); + this.webClientPromise = promise; } return this.webClientPromise; @@ -579,7 +588,7 @@ export class YouTubeMusicDataSource extends DataSource { private getDownloadClient(): Promise { if (!this.downloadClientPromise) { logInternalInfo("YouTubeMusicDataSource.getDownloadClient creating client"); - this.downloadClientPromise = (async () => { + const promise = (async () => { const bootstrap = await Innertube.create({ fetch: tauriFetch, retrieve_player: false, @@ -596,6 +605,15 @@ export class YouTubeMusicDataSource extends DataSource { client_type: ClientType.MUSIC, }); })(); + /* + * Never cache a failure. Built offline at launch, it used to stay rejected for the whole + * session, so every stream fell back to the music client, whose URLs googlevideo refuses + * after the first chunk — songs cut out after a few seconds and were skipped. + */ + promise.catch(() => { + if (this.downloadClientPromise === promise) this.downloadClientPromise = null; + }); + this.downloadClientPromise = promise; } return this.downloadClientPromise; @@ -2979,7 +2997,16 @@ export class YouTubeMusicDataSource extends DataSource { * fields are already the values it assigns. */ this.resetMusicClients(); - await this.getMusicClient(); + /* + * Offline, the client cannot be built, but the stored credential is still the session: + * failing here used to fall through to "signed out" and hide the cached library and + * downloads. The client is built on first use once the network is back. + */ + await this.getMusicClient().catch((error) => { + logInternalWarn("YouTubeMusicDataSource.restoreSession client deferred", { + error: error instanceof Error ? error.message : String(error), + }); + }); logInternalInfo("YouTubeMusicDataSource.restoreSession success"); return true; } catch (error) { @@ -5726,7 +5753,10 @@ export class YouTubeMusicDataSource extends DataSource { logInternalWarn("YouTubeMusicDataSource.getStreamData client failed", { trackId: track.id, client: label, - error: error instanceof Error ? error.message : String(error), + // tauriFetch rejects with a plain `{ message }`, which String() turns into "[object Object]". + error: error instanceof Error + ? error.message + : (error as { message?: string } | null)?.message ?? String(error), }); } } diff --git a/src/datasource/youtube/artwork.check.ts b/src/datasource/youtube/artwork.check.ts index 0fe17e4e..e282c836 100644 --- a/src/datasource/youtube/artwork.check.ts +++ b/src/datasource/youtube/artwork.check.ts @@ -10,7 +10,12 @@ */ export {}; -import { getArtworkSizeBucket, getArtworkUrlCandidates, selectArtworkUrl } from "./artwork"; +import { + artworkIdentity, + getArtworkSizeBucket, + getArtworkUrlCandidates, + selectArtworkUrl, +} from "./artwork"; function check(condition: boolean, message: string): void { if (!condition) throw new Error(`FAILED: ${message}`); @@ -95,4 +100,21 @@ check( "a 2x slot needing more than 544 still keeps the original", ); +// A saved download cover has to match the same song at every size it is shown at. +check( + artworkIdentity("https://lh3.googleusercontent.com/abc=w120-h120-l90-rj") + === artworkIdentity("//lh3.googleusercontent.com/abc=w544-h544-l90-rj"), + "size variants of one cover share an identity", +); +check( + artworkIdentity("https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg") + === artworkIdentity("https://i9.ytimg.com/vi_webp/dQw4w9WgXcQ/mqdefault.webp"), + "every thumbnail of one video shares an identity", +); +check( + artworkIdentity("https://lh3.googleusercontent.com/abc=w120") + !== artworkIdentity("https://lh3.googleusercontent.com/xyz=w120"), + "different covers stay different", +); + console.log("artwork.check.ts OK"); diff --git a/src/datasource/youtube/artwork.ts b/src/datasource/youtube/artwork.ts index ede65e18..bd732974 100644 --- a/src/datasource/youtube/artwork.ts +++ b/src/datasource/youtube/artwork.ts @@ -130,6 +130,20 @@ export function getArtworkUrlCandidates(url?: string, size?: number | null): str }); } +/** + * One key per image whatever size variant a URL asks for, so a download's saved cover matches + * the same song wherever it shows — a queue row's `=w120` and a header's `=w544` alike. + */ +export function artworkIdentity(url: string): string { + const normalized = normalizeArtworkUrl(url); + const videoThumbnail = normalized.match(/^https:\/\/i\d?\.ytimg\.com\/vi(?:_webp)?\/([^/]+)\//); + if (videoThumbnail) return `ytimg:${videoThumbnail[1]}`; + if (/googleusercontent\.com|ggpht\.com/.test(normalized) && !/[?&]/.test(normalized)) { + return normalized.replace(/=[^=/]+$/, ""); + } + return normalized; +} + export function getVideoArtworkFallback(videoId: string): string | undefined { return /^[A-Za-z0-9_-]{11}$/.test(videoId) ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` diff --git a/src/internal/connectivity.ts b/src/internal/connectivity.ts new file mode 100644 index 00000000..39b5282e --- /dev/null +++ b/src/internal/connectivity.ts @@ -0,0 +1,41 @@ +import { useSyncExternalStore } from "react"; + +/** + * Whether the app can reach the network, as last measured by PlayerBar's probe. + * + * A module rather than PlayerBar state because the player needs it too: offline, it has to + * step over songs that are not downloaded instead of failing on each one. + */ +let online = typeof navigator === "undefined" ? true : navigator.onLine; +const listeners = new Set<() => void>(); + +export function isOnline(): boolean { + return online; +} + +export function setOnline(next: boolean): void { + if (next === online) return; + online = next; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useOnline(): boolean { + return useSyncExternalStore(subscribe, isOnline, () => true); +} + +/** Runs `callback` each time the app comes back online. */ +export function onReconnect(callback: () => void): () => void { + return subscribe(() => { + if (online) callback(); + }); +} + +/** A failed load's message, saying so plainly when the real cause is being offline. */ +export function loadFailedMessage(message: string): string { + return online ? message : "You're offline. Reconnect and try again."; +} diff --git a/src/player/AudioEngine.ts b/src/player/AudioEngine.ts index e3bd32eb..184713a9 100644 --- a/src/player/AudioEngine.ts +++ b/src/player/AudioEngine.ts @@ -210,7 +210,12 @@ function loadYouTubeIframeApi(): Promise { const script = document.createElement("script"); script.src = "https://www.youtube.com/iframe_api"; script.async = true; - script.onerror = () => reject(new Error("Unable to load the YouTube player API.")); + script.onerror = () => { + // Not cached: loaded offline at launch, it used to stay broken for the whole session. + script.remove(); + iframeApiPromise = null; + reject(new Error("Unable to load the YouTube player API.")); + }; document.head.appendChild(script); }); diff --git a/src/player/LastFm.ts b/src/player/LastFm.ts index 61696c4b..34871ba4 100644 --- a/src/player/LastFm.ts +++ b/src/player/LastFm.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; import type { Track } from "../datasource/types"; import { logInternalDebug, logInternalWarn } from "../internal/logging"; +import { isOnline, onReconnect } from "../internal/connectivity"; export interface LastFmAuthStart { token: string; @@ -78,6 +79,30 @@ function trackKey(track: Track, payload: LastFmTrackPayload) { ].join(":"); } +/* + * Scrobbles made offline, sent when the connection is back. Last.fm accepts timestamps up to + * two weeks old, so an offline listen still counts instead of being dropped. + */ +const PENDING_SCROBBLES_KEY = "zuno.lastfm-pending-scrobbles.v1"; +const MAX_PENDING_SCROBBLES = 500; + +function readPendingScrobbles(): LastFmScrobblePayload[] { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(PENDING_SCROBBLES_KEY) ?? "[]"); + return Array.isArray(parsed) ? parsed as LastFmScrobblePayload[] : []; + } catch { + return []; + } +} + +function writePendingScrobbles(pending: LastFmScrobblePayload[]): void { + try { + localStorage.setItem(PENDING_SCROBBLES_KEY, JSON.stringify(pending.slice(-MAX_PENDING_SCROBBLES))); + } catch { + // Quota or a locked profile: those scrobbles are lost, as they always were before. + } +} + function scrobbleThreshold(duration?: number): number | null { if (!duration || duration < MIN_SCROBBLE_DURATION_SEC) return null; return Math.min(MAX_THRESHOLD_SEC, duration / 2); @@ -87,6 +112,7 @@ export class LastFmService { private static tracked: TrackedTrackState | null = null; private static sessionChecked = false; private static hasSession = false; + private static flushingPending = false; static async startAuth(): Promise { const auth = await invoke("lastfm_auth_token"); @@ -198,16 +224,47 @@ export class LastFmService { private static async scrobble(payload: LastFmScrobblePayload): Promise { if (!await this.ensureSession()) return; + if (!isOnline()) { + writePendingScrobbles([...readPendingScrobbles(), payload]); + return; + } try { await invoke("lastfm_scrobble", { input: payload }); logInternalDebug("LastFm.scrobble.success", { artist: payload.artist, track: payload.track, }); + void this.flushPendingScrobbles(); } catch (error) { + if (!isOnline()) writePendingScrobbles([...readPendingScrobbles(), payload]); logInternalWarn("LastFm.scrobble.failed", { error: error instanceof Error ? error.message : String(error), }); } } + + /** Sends scrobbles saved while offline, oldest first, stopping at the first failure. */ + static async flushPendingScrobbles(): Promise { + if (this.flushingPending || !isOnline() || readPendingScrobbles().length === 0) return; + if (!await this.ensureSession()) return; + this.flushingPending = true; + let sent = 0; + try { + for (const payload of readPendingScrobbles()) { + await invoke("lastfm_scrobble", { input: payload }); + sent += 1; + } + } catch (error) { + logInternalWarn("LastFm.pendingScrobbles.failed", { + sent, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + // Sliced from the front: anything saved while this ran was appended behind. + writePendingScrobbles(readPendingScrobbles().slice(sent)); + this.flushingPending = false; + } + } } + +onReconnect(() => void LastFmService.flushPendingScrobbles()); diff --git a/src/player/LibraryController.ts b/src/player/LibraryController.ts index cb7fea59..3006257a 100644 --- a/src/player/LibraryController.ts +++ b/src/player/LibraryController.ts @@ -24,6 +24,7 @@ import type { TrackRating, } from "../datasource/types"; import { logInternalError, logInternalInfo } from "../internal/logging"; +import { isOnline } from "../internal/connectivity"; import { getAppSetting, setAppSetting } from "../internal/appSettings"; import { forgetTrackInPlaylist, rememberTrackInPlaylist } from "./playlistMembership"; import { @@ -1102,7 +1103,10 @@ export class LibraryController { const detail = error instanceof Error ? error.message : String(error); this.setState({ status: "error", - error: detail && detail !== "[object Object]" ? `${message}\n\n${detail}` : message, + // recoverConnection retries on reconnect, so offline this is a wait, not a failure. + error: !isOnline() + ? "You're offline. Zuno will retry when you reconnect." + : detail && detail !== "[object Object]" ? `${message}\n\n${detail}` : message, authPrompt: null, }); } diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index d384dcbf..8a5b6090 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -7,7 +7,9 @@ import { Queue } from "./Queue"; import { NavigationCoalescer } from "./navigationCoalescer"; import { recordPlay } from "./playHistory"; import { computeQueueWindow } from "./queueWindow"; -import { getOfflineTrack, isTrackDownloaded } from "./offlineStore"; +import { getOfflineTrack, isPlayableOffline, isTrackDownloaded } from "./offlineStore"; +import { isOnline } from "../internal/connectivity"; +import { findPlayableIndex } from "./playableIndex"; import { hasPreloadDeck } from "./preloadDeck"; import { getAudioEngineMode } from "../ui/settings/audioEngine"; import { DiscordRpcService } from "./DiscordRPC"; @@ -828,7 +830,8 @@ export class PlayerController { private async skipToNextNow(): Promise { const shouldResume = this.shouldResumeAfterNavigation(); - const nextTrack = this.queue.next(false); + const nextTrack = this.nextPlayableTrack(); + if (!isOnline() && (!nextTrack || nextTrack.id === this.state.currentTrack?.id)) return; if ( (!nextTrack || nextTrack.id === this.state.currentTrack?.id) && this.state.currentTrack @@ -987,7 +990,7 @@ export class PlayerController { return; } - const nextTrack = this.queue.next(false); + const nextTrack = this.nextPlayableTrack(); if (nextTrack && nextTrack.id !== this.state.currentTrack?.id) { this.refillAutomaticQueue(); @@ -995,6 +998,12 @@ export class PlayerController { return; } + // Looping and recommendations both need the network; offline, the queue just ends. + if (!isOnline()) { + this.setState({ status: "paused" }); + return; + } + /* * Looping is decided here rather than by passing `wrap` to Queue.next, because it only * applies at the *end* of the collection — everywhere else next() already advances @@ -1221,6 +1230,10 @@ export class PlayerController { }); return; } + // Fails fast rather than waiting out network timeouts; setError explains it's offline. + if (!isOnline() && !isPlayableOffline(track)) { + throw new Error("Not available offline."); + } try { const startedAt = performance.now(); @@ -1442,10 +1455,30 @@ export class PlayerController { * crossfade into. Repeat-one is excluded because the "next" track is the current one, and * handing the deck to itself is not a transition. */ + /** + * The queue's next track, stepping over songs that cannot play offline. + * + * Without this a partly downloaded playlist stopped at its first gap with a network error. + */ + private nextPlayableTrack(): Track | null { + return this.stepToPlayable(1); + } + + /** One step through the queue; offline, past songs that cannot play, and nowhere if none can. */ + private stepToPlayable(direction: 1 | -1): Track | null { + if (isOnline()) return direction === 1 ? this.queue.next(false) : this.queue.prev(false); + const index = findPlayableIndex(this.queue.all, this.queue.currentIndex, direction, isPlayableOffline); + return index === -1 ? null : this.queue.select(index); + } + private peekNextTrack(): Track | null { if (this.playbackOrderMode === "repeat-one") return null; - const next = this.queue.all[this.queue.currentIndex + 1] ?? null; + // Offline, gapless preloading targets the song that will actually play next. + const index = isOnline() + ? this.queue.currentIndex + 1 + : findPlayableIndex(this.queue.all, this.queue.currentIndex, 1, isPlayableOffline); + const next = this.queue.all[index] ?? null; if (!next || next.id === this.state.currentTrack?.id) return null; return next; } @@ -1655,12 +1688,16 @@ export class PlayerController { trackId: this.state.currentTrack?.id, }); const detail = getErrorMessage(error); + const track = this.state.currentTrack; + const offlineMiss = !isOnline() && track && !isPlayableOffline(track); this.setState({ status: "error", - error: detail && detail !== "[object Object]" - ? `Playback failed. ${detail}` - : "Playback failed. Check internal logs for details.", + error: offlineMiss + ? "You're offline. Only downloaded songs can play until you reconnect." + : detail && detail !== "[object Object]" + ? `Playback failed. ${detail}` + : "Playback failed. Check internal logs for details.", // Preserve currentTrack to prevent reversion to hardcoded track currentTrack: this.state.currentTrack, }); @@ -1806,7 +1843,7 @@ export class PlayerController { private async skipToPreviousNow(): Promise { const shouldResume = this.shouldResumeAfterNavigation(); - let previousTrack = this.queue.prev(false); + let previousTrack = this.stepToPlayable(-1); if (!previousTrack || previousTrack.id === this.state.currentTrack?.id) { let currentHistoryIndex = -1; for (let index = this.state.history.length - 1; index >= 0; index -= 1) { @@ -1818,6 +1855,7 @@ export class PlayerController { previousTrack = currentHistoryIndex > 0 ? this.state.history[currentHistoryIndex - 1] : null; + if (previousTrack && !isOnline() && !isPlayableOffline(previousTrack)) previousTrack = null; } logInternalInfo("PlayerController.skipToPrevious", { currentTrackId: this.state.currentTrack?.id ?? null, diff --git a/src/player/SearchController.ts b/src/player/SearchController.ts index 3cff66f4..681638f1 100644 --- a/src/player/SearchController.ts +++ b/src/player/SearchController.ts @@ -1,5 +1,7 @@ import type { DataSource } from "../datasource/DataSource"; import type { SearchResults, Track } from "../datasource/types"; +import { isOnline } from "../internal/connectivity"; +import { searchDownloads } from "./offlineStore"; export class SearchController { constructor(private readonly dataSource: DataSource) {} @@ -12,6 +14,9 @@ export class SearchController { if (!normalizedQuery) { return { artists: [], tracks: [], albums: [], playlists: [] }; } + if (!isOnline()) { + return { artists: [], tracks: searchDownloads(normalizedQuery), albums: [], playlists: [] }; + } if (this.dataSource.search) { return this.dataSource.search(normalizedQuery, onUpdate); } @@ -24,6 +29,7 @@ export class SearchController { async searchTracks(query: string, onUpdate?: (tracks: Track[]) => void): Promise { const normalizedQuery = query.trim(); if (!normalizedQuery || !this.dataSource.searchTracks) return []; + if (!isOnline()) return searchDownloads(normalizedQuery); return this.dataSource.searchTracks(normalizedQuery, onUpdate); } @@ -32,7 +38,7 @@ export class SearchController { onUpdate?: (suggestions: string[]) => void, ): Promise { const normalizedQuery = query.trim(); - if (!normalizedQuery || !this.dataSource.getSearchSuggestions) return []; + if (!normalizedQuery || !this.dataSource.getSearchSuggestions || !isOnline()) return []; return this.dataSource.getSearchSuggestions(normalizedQuery, onUpdate); } } diff --git a/src/player/offlineStore.check.ts b/src/player/offlineStore.check.ts index 0d2764e1..9db3ca91 100644 --- a/src/player/offlineStore.check.ts +++ b/src/player/offlineStore.check.ts @@ -10,7 +10,7 @@ */ export {}; -import { reconcileManifest, type OfflineEntry } from "./offlineStore"; +import { matchesDownloadQuery, reconcileManifest, type OfflineEntry } from "./offlineStore"; function check(condition: boolean, message: string): void { if (!condition) throw new Error(`FAILED: ${message}`); @@ -61,4 +61,19 @@ const lostManifest = reconcileManifest({}, [ equal(lostManifest.entries, {}, "no manifest means nothing is playable yet"); equal(lostManifest.orphans, [], "but the files are left alone to be recovered"); +// Disk also decides which downloads have a saved cover, so artwork is never offered for a missing file. +const withArtwork = reconcileManifest(manifest, [ + { trackId: "a", byteLength: 100, hasArtwork: true }, + { trackId: "b", byteLength: 200 }, +]); +equal(withArtwork.entries.a.hasArtwork, true, "a cover on disk is recorded"); +equal(withArtwork.entries.b.hasArtwork, false, "a missing cover is not"); + +// Offline search runs over downloads, word by word, in any order and any case. +const song = { id: "x", title: "El baile de los que sobran", artist: "Los Prisioneros", album: "Pateando piedras" } as OfflineEntry["track"]; +check(matchesDownloadQuery(song, "prisioneros BAILE"), "words match across title and artist in any order"); +check(matchesDownloadQuery(song, "pateando"), "the album counts"); +check(!matchesDownloadQuery(song, "baile soda"), "every word has to match"); +check(!matchesDownloadQuery(song, " "), "a blank query matches nothing"); + console.log("offlineStore.check passed"); diff --git a/src/player/offlineStore.ts b/src/player/offlineStore.ts index ddea84f8..688391a9 100644 --- a/src/player/offlineStore.ts +++ b/src/player/offlineStore.ts @@ -1,10 +1,12 @@ -import { useSyncExternalStore } from "react"; +import { useMemo, useSyncExternalStore } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import type { Track } from "../datasource/types"; import { logInternalError, logInternalInfo, logInternalWarn } from "../internal/logging"; import { getAppSetting, setAppSetting } from "../internal/appSettings"; import { getDownloadQuality, type AudioQuality } from "../internal/audioQuality"; +import { artworkIdentity, getArtworkUrlCandidates } from "../datasource/youtube/artwork"; +import { isOnline, onReconnect, useOnline } from "../internal/connectivity"; const MANIFEST_KEY = "zuno.offline-manifest.v1"; const MAX_BYTES_KEY = "zuno.offline-max-bytes.v1"; @@ -26,8 +28,15 @@ export interface OfflineEntry { track: Track; byteLength: number; downloadedAt: number; + /** A cover saved beside the audio, so the song still shows its artwork offline. */ + hasArtwork?: boolean; } +type OnDiskEntry = { trackId: string; byteLength: number; hasArtwork?: boolean }; + +/** `artworkUrl` value that TrackArtwork reads from disk through `offline_artwork`. */ +export const OFFLINE_ARTWORK_PREFIX = "offline-artwork:"; + export interface OfflineState { entries: Record; /** 0-100 for the track currently downloading. Absent when the size is unknown. */ @@ -108,7 +117,15 @@ function setState(next: Partial): void { emit(); } +/** Artwork identity → downloaded track holding that cover on disk. */ +let artworkIndex = new Map(); + function commitEntries(entries: Record): void { + artworkIndex = new Map( + Object.values(entries) + .filter((entry) => entry.hasArtwork && entry.track.artworkUrl) + .map((entry) => [artworkIdentity(entry.track.artworkUrl!), entry.track.id]), + ); writeManifest(entries); setState({ entries, @@ -137,15 +154,15 @@ export function setOfflineMaxBytes(maxBytes: number): void { */ export function reconcileManifest( manifest: Record, - onDisk: ReadonlyArray<{ trackId: string; byteLength: number }>, + onDisk: ReadonlyArray, ): { entries: Record; orphans: string[] } { - const byId = new Map(onDisk.map((entry) => [entry.trackId, entry.byteLength])); + const byId = new Map(onDisk.map((entry) => [entry.trackId, entry])); const entries: Record = {}; for (const [trackId, entry] of Object.entries(manifest)) { - const byteLength = byId.get(trackId); - if (byteLength === undefined) continue; - entries[trackId] = { ...entry, byteLength }; + const file = byId.get(trackId); + if (!file) continue; + entries[trackId] = { ...entry, byteLength: file.byteLength, hasArtwork: Boolean(file.hasArtwork) }; } const orphans = Object.keys(manifest).length === 0 @@ -168,9 +185,7 @@ export async function hydrateOfflineStore(): Promise { // the durable file is what is left when local storage is cleared out from underneath us. const manifest = { ...(await readDurableManifest()), ...readManifest() }; try { - const onDisk = await invoke>( - "offline_audio_list", - ); + const onDisk = await invoke("offline_audio_list"); const { entries, orphans } = reconcileManifest(manifest, onDisk); for (const trackId of orphans) { void invoke("offline_audio_remove", { trackId }).catch(() => {}); @@ -178,6 +193,7 @@ export async function hydrateOfflineStore(): Promise { commitEntries(entries); logInternalInfo("offlineStore.hydrate", { count: Object.keys(entries).length }); + void backfillArtwork(); } catch (error) { logInternalWarn("offlineStore.hydrate failed", { error: error instanceof Error ? error.message : String(error), @@ -222,6 +238,103 @@ export function getOfflineTrack(trackId: string): Track | undefined { return state.entries[trackId]?.track; } +/** Offline-playable: a local file, or a download whose audio is on disk. */ +export function isPlayableOffline(track: Track): boolean { + return track.source === "local" || Boolean(state.entries[track.id]); +} + +/** Every word of `query` appears in the title, artist or album, ignoring case. */ +export function matchesDownloadQuery(track: Track, query: string): boolean { + const haystack = `${track.title} ${track.artist} ${track.album ?? ""}`.toLocaleLowerCase(); + const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean); + return words.length > 0 && words.every((word) => haystack.includes(word)); +} + +/** Offline search: downloads are the only songs that can be found and played. */ +export function searchDownloads(query: string): Track[] { + return Object.values(state.entries) + .map((entry) => entry.track) + .filter((track) => matchesDownloadQuery(track, query)); +} + +/** True while offline for a song that needs the network to play. */ +export function useUnavailableOffline(track: Track): boolean { + const online = useOnline(); + const playable = useSyncExternalStore(subscribe, () => isPlayableOffline(track), () => true); + return !online && !playable; +} + +/** + * The saved cover for any artwork URL that belongs to a download, or undefined. + * + * Matched on the image rather than the track, so it applies wherever that cover shows — + * queue, playlists, the player bar — not only in the Downloads list. + */ +export function useOfflineArtworkUrl(artworkUrl: string | undefined): string | undefined { + const identity = useMemo(() => (artworkUrl ? artworkIdentity(artworkUrl) : null), [artworkUrl]); + const trackId = useSyncExternalStore( + subscribe, + () => (identity ? artworkIndex.get(identity) : undefined), + () => undefined, + ); + return trackId ? `${OFFLINE_ARTWORK_PREFIX}${trackId}` : undefined; +} + +async function saveArtwork(track: Track): Promise { + const url = getArtworkUrlCandidates(track.artworkUrl, 544)[0]; + if (!url) return false; + try { + await invoke("offline_artwork_save", { trackId: track.id, url }); + return true; + } catch (error) { + logInternalWarn("offlineStore.saveArtwork failed", { + trackId: track.id, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +let backfilling = false; + +/** + * Saves covers for downloads made before covers were saved, one at a time. + * + * Runs at launch and on every reconnect; three misses in a row (probably offline) end a pass. + */ +async function backfillArtwork(): Promise { + if (backfilling || !isOnline()) return; + backfilling = true; + try { + await backfillMissingArtwork(); + } finally { + backfilling = false; + } +} + +async function backfillMissingArtwork(): Promise { + const missing = Object.values(state.entries).filter( + (entry) => !entry.hasArtwork && entry.track.artworkUrl, + ); + const saved = new Set(); + let misses = 0; + for (const entry of missing) { + if (await saveArtwork(entry.track)) { + saved.add(entry.track.id); + misses = 0; + } else if (++misses >= 3) { + break; + } + } + if (saved.size === 0) return; + commitEntries(Object.fromEntries( + Object.entries(state.entries).map(([trackId, entry]) => [ + trackId, + saved.has(trackId) ? { ...entry, hasArtwork: true } : entry, + ]), + )); +} + /** Resolves the stream URL for a track. Callers pass this in so the store stays data-source agnostic. */ type StreamUrlResolver = ( track: Track, @@ -302,6 +415,8 @@ async function pump(): Promise { if (pumping || state.downloadingId !== null) return; if (state.queued.length === 0) return; + // Offline, every download would fail and drop out of the queue; it waits for onReconnect instead. + if (!isOnline()) return; if (!resolveStreamUrl) { logInternalWarn("offlineStore.pump has no stream resolver"); return; @@ -309,7 +424,7 @@ async function pump(): Promise { pumping = true; try { - while (state.queued.length > 0) { + while (state.queued.length > 0 && isOnline()) { const [trackId, ...rest] = state.queued; const track = pendingTracks.get(trackId); setState({ queued: rest, downloadingId: trackId, progress: null }); @@ -324,6 +439,7 @@ async function pump(): Promise { const { url, mimeType, cookie } = await resolveStreamUrl(track, getDownloadQuality()); const byteLength = await invoke("offline_audio_save", { url, trackId, cookie }); logInternalInfo("offlineStore.download complete", { trackId, byteLength }); + const hasArtwork = await saveArtwork(track); pendingTracks.delete(trackId); { const { [trackId]: _done, ...pending } = state.pending; @@ -331,11 +447,21 @@ async function pump(): Promise { } commitEntries({ ...state.entries, - [trackId]: { track: { ...track, mimeType }, byteLength, downloadedAt: Date.now() }, + [trackId]: { + track: { ...track, mimeType }, + byteLength, + downloadedAt: Date.now(), + hasArtwork, + }, }); setState({ downloadingId: null, progress: null }); await prune(); } catch (error) { + // Lost the connection mid-download: back to the front of the queue, not a failure. + if (!isOnline()) { + setState({ queued: [trackId, ...state.queued], downloadingId: null, progress: null }); + break; + } pendingTracks.delete(trackId); const { [trackId]: _failed, ...pending } = state.pending; const message = error instanceof Error ? error.message : String(error); @@ -362,9 +488,7 @@ async function prune(): Promise { try { await invoke("offline_audio_prune", { maxBytes }); - const onDisk = await invoke>( - "offline_audio_list", - ); + const onDisk = await invoke("offline_audio_list"); const kept = new Set(onDisk.map((entry) => entry.trackId)); const entries = Object.fromEntries( Object.entries(state.entries).filter(([trackId]) => kept.has(trackId)), @@ -377,6 +501,11 @@ async function prune(): Promise { } } +onReconnect(() => { + void pump(); + void backfillArtwork(); +}); + function subscribe(listener: Listener): () => void { listeners.add(listener); return () => listeners.delete(listener); @@ -386,6 +515,11 @@ export function getOfflineState(): OfflineState { return state; } +/** Downloaded entries only, so a list of them does not re-render on every progress tick. */ +export function useOfflineEntries(): Record { + return useSyncExternalStore(subscribe, () => state.entries, () => state.entries); +} + export function useOfflineState(): OfflineState { return useSyncExternalStore(subscribe, getOfflineState, getOfflineState); } diff --git a/src/player/playableIndex.check.ts b/src/player/playableIndex.check.ts new file mode 100644 index 00000000..91ee9f8f --- /dev/null +++ b/src/player/playableIndex.check.ts @@ -0,0 +1,24 @@ +/** + * Self-check for offline queue stepping. Run with the whole suite: + * + * npm run check + */ +export {}; + +import { findPlayableIndex } from "./playableIndex"; + +function check(condition: boolean, message: string): void { + if (!condition) throw new Error(`FAILED: ${message}`); +} + +// d = downloaded, s = streaming only +const queue = ["d", "s", "s", "d", "s"]; +const downloaded = (item: string) => item === "d"; + +check(findPlayableIndex(queue, 0, 1, downloaded) === 3, "next skips over streaming-only songs"); +check(findPlayableIndex(queue, 3, -1, downloaded) === 0, "previous skips back the same way"); +check(findPlayableIndex(queue, 3, 1, downloaded) === -1, "nothing playable ahead is -1, not a wrap"); +check(findPlayableIndex(queue, 0, -1, downloaded) === -1, "nothing before the start is -1"); +check(findPlayableIndex([], 0, 1, downloaded) === -1, "an empty queue has nothing to play"); + +console.log("playableIndex.check passed"); diff --git a/src/player/playableIndex.ts b/src/player/playableIndex.ts new file mode 100644 index 00000000..8b7cffed --- /dev/null +++ b/src/player/playableIndex.ts @@ -0,0 +1,16 @@ +/** + * The nearest position from `from` in `direction` that `isPlayable` accepts, or -1. + * + * Offline, next and previous step over songs that are not downloaded instead of failing on them. + */ +export function findPlayableIndex( + items: readonly T[], + from: number, + direction: 1 | -1, + isPlayable: (item: T) => boolean, +): number { + for (let index = from + direction; index >= 0 && index < items.length; index += direction) { + if (isPlayable(items[index])) return index; + } + return -1; +} diff --git a/src/player/useMediaSession.ts b/src/player/useMediaSession.ts index 2b37d7ed..eef8824c 100644 --- a/src/player/useMediaSession.ts +++ b/src/player/useMediaSession.ts @@ -5,6 +5,7 @@ import type { PlayerState } from "./PlayerController"; import type { PlayerControllerActions } from "./playerStore"; import { logInternalWarn } from "../internal/logging"; import { useLinuxMediaSession } from "../ui/settings/mediaSession"; +import { useOfflineArtworkUrl } from "./offlineStore"; type NativeMediaAction = | "play" @@ -94,6 +95,8 @@ export function useMediaSession( () => getNativeMediaControlEvent(linuxMediaSession), [linuxMediaSession], ); + // A downloaded song's saved cover, so the OS media controls keep their artwork offline. + const offlineArtworkUrl = useOfflineArtworkUrl(state.currentTrack?.artworkUrl); const sendNativeMediaUpdate = useCallback((context: string, forceMetadata = false) => { if (!nativeMediaCommand) return; @@ -103,7 +106,7 @@ export function useMediaSession( update: { title: track?.title ?? null, artist: track?.artist ?? null, - artworkUrl: track?.artworkUrl ?? null, + artworkUrl: offlineArtworkUrl ?? track?.artworkUrl ?? null, status: state.status, durationSec: duration || null, positionSec: getClampedPosition(duration, controller.getCurrentTime()), @@ -114,7 +117,7 @@ export function useMediaSession( error: String(error), }); }); - }, [controller, nativeMediaCommand, state.currentTrack, state.status]); + }, [controller, nativeMediaCommand, offlineArtworkUrl, state.currentTrack, state.status]); useEffect(() => { if (!nativeMediaControlEvent) return; diff --git a/src/ui/components/TrackArtwork.tsx b/src/ui/components/TrackArtwork.tsx index f0302429..a99e276e 100644 --- a/src/ui/components/TrackArtwork.tsx +++ b/src/ui/components/TrackArtwork.tsx @@ -13,6 +13,7 @@ import { import { logInternalDebug } from "../../internal/logging"; import { tauriFetch } from "../../datasource/youtube/tauriFetch"; import { LOCAL_ARTWORK_PREFIX, LOCAL_IMAGE_PREFIX } from "../../player/localPlaylists"; +import { OFFLINE_ARTWORK_PREFIX, useOfflineArtworkUrl } from "../../player/offlineStore"; const ARTWORK_RETRY_DELAYS_MS = [500, 1500]; @@ -61,7 +62,7 @@ interface TrackArtworkProps { } export function TrackArtwork({ - artworkUrl, + artworkUrl: sourceArtworkUrl, className, iconSize = 24, loading = "lazy", @@ -70,6 +71,9 @@ export function TrackArtwork({ size, variant = "track", }: TrackArtworkProps) { + // A downloaded song's cover is read from disk, so it shows offline and skips the network online. + const offlineArtworkUrl = useOfflineArtworkUrl(sourceArtworkUrl); + const artworkUrl = offlineArtworkUrl ?? sourceArtworkUrl; const sizeBucket = size == null ? null : getArtworkSizeBucket(size); /* * Resolutions are cached per source *and* per requested size. @@ -88,7 +92,8 @@ export function TrackArtwork({ // proxy effect below reads them through Rust instead. const isEmbeddedArtwork = Boolean(artworkUrl?.startsWith(LOCAL_ARTWORK_PREFIX)); const isLocalImage = Boolean(artworkUrl?.startsWith(LOCAL_IMAGE_PREFIX)); - const isLocalArtwork = isEmbeddedArtwork || isLocalImage; + const isOfflineArtwork = Boolean(offlineArtworkUrl); + const isLocalArtwork = isEmbeddedArtwork || isLocalImage || isOfflineArtwork; const artworkCandidates = useMemo(() => { if (!artworkUrl?.trim()) return []; const cached = cacheKey ? getResolvedArtworkUrl(cacheKey) : undefined; @@ -191,13 +196,17 @@ export function TrackArtwork({ // Embedded cover: read it out of the file's tags. Same cache, same object-URL budget, // same request sharing — only where the bytes come from differs. if (isLocalArtwork) { - const artwork = isLocalImage - ? await invoke<{ mimeType: string; dataBase64: string }>("read_image_file", { - path: artworkUrl.slice(LOCAL_IMAGE_PREFIX.length), + const artwork = isOfflineArtwork + ? await invoke<{ mimeType: string; dataBase64: string } | null>("offline_artwork", { + trackId: artworkUrl.slice(OFFLINE_ARTWORK_PREFIX.length), }) - : await invoke<{ mimeType: string; dataBase64: string } | null>("local_audio_artwork", { - path: artworkUrl.slice(LOCAL_ARTWORK_PREFIX.length), - }); + : isLocalImage + ? await invoke<{ mimeType: string; dataBase64: string }>("read_image_file", { + path: artworkUrl.slice(LOCAL_IMAGE_PREFIX.length), + }) + : await invoke<{ mimeType: string; dataBase64: string } | null>("local_audio_artwork", { + path: artworkUrl.slice(LOCAL_ARTWORK_PREFIX.length), + }); if (!artwork) throw new Error("This file carries no embedded artwork."); const bytes = Uint8Array.from(atob(artwork.dataBase64), (char) => char.charCodeAt(0)); return new Blob([bytes], { type: artwork.mimeType }); @@ -224,6 +233,7 @@ export function TrackArtwork({ cacheKey, isLocalArtwork, isLocalImage, + isOfflineArtwork, preferProxy, sizeBucket, proxiedArtworkUrl, diff --git a/src/ui/components/TrackRow.tsx b/src/ui/components/TrackRow.tsx index 1bad62fb..f5e27b29 100644 --- a/src/ui/components/TrackRow.tsx +++ b/src/ui/components/TrackRow.tsx @@ -13,6 +13,7 @@ import { CheckActiveIcon, CheckIcon, DislikeActiveIcon, DislikeIcon, DownloadIco import { Loader, MusicVisualizer } from "@/components/motion/loader"; import { getOfflineStatus, + useUnavailableOffline, queueDownload, removeDownload, useOfflineState, @@ -413,6 +414,7 @@ export const TrackRow = memo(function TrackRow({ /* Whether this list does multi-select at all. Browse and search rows do not pass a handler, and must keep their play-on-hover glyph rather than gain a checkbox that does nothing. */ const canSelect = Boolean(onToggleSelected); + const unavailableOffline = useUnavailableOffline(track); return ( + )} + +
+ {downloads.slice(0, OFFLINE_HOME_LIMIT).map((track, index) => ( + onPlay(track)} + onContextMenu={(event) => onContextMenu(event, track)} + /> + ))} +
+ + )} + + ); +} diff --git a/src/ui/pages/PlaylistView.tsx b/src/ui/pages/PlaylistView.tsx index 2e21d08f..0ef83f69 100644 --- a/src/ui/pages/PlaylistView.tsx +++ b/src/ui/pages/PlaylistView.tsx @@ -24,6 +24,7 @@ import { useNowPlaying } from "../hooks/useNowPlaying"; import { useKeyboardShortcuts } from "../settings/keyboardShortcuts"; import { shouldStartPageSearch } from "./pageSearchKeyboard"; import { collectTrackPages } from "./collectTrackPages"; +import { loadFailedMessage } from "../../internal/connectivity"; /* * Collapsed search affordance that widens on hover/focus or while it holds a query — @@ -328,7 +329,7 @@ export function PlaylistView({ playlist, playerController, libraryController }: showPage(page); }) .catch(() => { - if (active && !showedPage) setError("Unable to load this playlist."); + if (active && !showedPage) setError(loadFailedMessage("Unable to load this playlist.")); }) .finally(() => { if (active) setIsLoading(false); @@ -385,7 +386,7 @@ export function PlaylistView({ playlist, playerController, libraryController }: setNextPageKey(page.nextPageKey); } catch { if (playlistIdRef.current === loadingPlaylistId) { - setLoadMoreError("Could not load more songs."); + setLoadMoreError(loadFailedMessage("Could not load more songs.")); } } finally { if (playlistIdRef.current === loadingPlaylistId) { @@ -434,7 +435,7 @@ export function PlaylistView({ playlist, playerController, libraryController }: }); } catch { if (playlistIdRef.current === loadingPlaylistId) { - setLoadMoreError("Could not load the rest of this playlist."); + setLoadMoreError(loadFailedMessage("Could not load the rest of this playlist.")); } return tracksRef.current; } finally {