Merge pre-release into feature/immersive-player
Resolved conflicts: - src-tauri/src/api/client.rs: Keep TrackUnavailable error handling - i18n files: Combined immersive player keys with trackUnavailable key
This commit is contained in:
commit
011ee02ec9
13 files changed
+1005
-28
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "qbz-nix",
|
"name": "qbz-nix",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "qbz-nix",
|
"name": "qbz-nix",
|
||||||
"version": "1.1.6",
|
"version": "1.1.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
|||||||
@@ -590,11 +590,13 @@ impl QobuzClient {
|
|||||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Validate that we got an actual URL
|
// Validate that we got an actual URL (track may be unavailable)
|
||||||
let url = json["url"].as_str().unwrap_or("").to_string();
|
let url = json["url"].as_str().unwrap_or("").to_string();
|
||||||
if url.is_empty() {
|
if url.is_empty() {
|
||||||
log::error!("Stream URL response missing 'url' field. Response: {:?}", json);
|
// Log the restriction codes for debugging
|
||||||
return Err(ApiError::ApiResponse("Stream URL response missing 'url' field".to_string()));
|
let restriction_codes: Vec<&str> = restrictions.iter().map(|r| r.code.as_str()).collect();
|
||||||
|
log::warn!("Stream URL missing for track {} - restrictions: {:?}", track_id, restriction_codes);
|
||||||
|
return Err(ApiError::TrackUnavailable(track_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(StreamUrl {
|
Ok(StreamUrl {
|
||||||
@@ -622,6 +624,8 @@ impl QobuzClient {
|
|||||||
let qualities = Quality::fallback_order();
|
let qualities = Quality::fallback_order();
|
||||||
let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
|
let start_idx = qualities.iter().position(|q| *q == preferred).unwrap_or(0);
|
||||||
|
|
||||||
|
let mut track_unavailable = false;
|
||||||
|
|
||||||
for quality in &qualities[start_idx..] {
|
for quality in &qualities[start_idx..] {
|
||||||
log::info!("Trying quality: {:?}", quality);
|
log::info!("Trying quality: {:?}", quality);
|
||||||
match self.get_stream_url(track_id, *quality).await {
|
match self.get_stream_url(track_id, *quality).await {
|
||||||
@@ -637,6 +641,11 @@ impl QobuzClient {
|
|||||||
log::error!("Invalid app secret");
|
log::error!("Invalid app secret");
|
||||||
return Err(ApiError::InvalidAppSecret);
|
return Err(ApiError::InvalidAppSecret);
|
||||||
},
|
},
|
||||||
|
Err(ApiError::TrackUnavailable(_)) => {
|
||||||
|
// Track is completely unavailable on Qobuz
|
||||||
|
track_unavailable = true;
|
||||||
|
continue;
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Quality {:?} failed: {}, trying next", quality, e);
|
log::warn!("Quality {:?} failed: {}, trying next", quality, e);
|
||||||
continue;
|
continue;
|
||||||
@@ -644,6 +653,12 @@ impl QobuzClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If all quality levels reported track unavailable, return that specific error
|
||||||
|
if track_unavailable {
|
||||||
|
log::error!("Track {} is no longer available on Qobuz", track_id);
|
||||||
|
return Err(ApiError::TrackUnavailable(track_id));
|
||||||
|
}
|
||||||
|
|
||||||
log::error!("No quality available for track {}", track_id);
|
log::error!("No quality available for track {}", track_id);
|
||||||
Err(ApiError::NoQualityAvailable)
|
Err(ApiError::NoQualityAvailable)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ pub enum ApiError {
|
|||||||
#[error("No valid quality available for this track")]
|
#[error("No valid quality available for this track")]
|
||||||
NoQualityAvailable,
|
NoQualityAvailable,
|
||||||
|
|
||||||
|
#[error("Track {0} is no longer available on Qobuz")]
|
||||||
|
TrackUnavailable(u64),
|
||||||
|
|
||||||
#[error("Network error: {0}")]
|
#[error("Network error: {0}")]
|
||||||
NetworkError(#[from] reqwest::Error),
|
NetworkError(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,11 @@ pub struct StreamUrl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StreamUrl {
|
impl StreamUrl {
|
||||||
|
/// Check if the stream has restrictions that prevent playback
|
||||||
pub fn has_restrictions(&self) -> bool {
|
pub fn has_restrictions(&self) -> bool {
|
||||||
self.restrictions.iter().any(|r| {
|
self.restrictions.iter().any(|r| {
|
||||||
r.code == "FormatRestrictedByFormatAvailability"
|
r.code == "FormatRestrictedByFormatAvailability"
|
||||||
|
|| r.code == "SampleRestrictedByRightHolders"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { goBack, goForward } from '$lib/stores/navigationStore';
|
|||||||
import { loadToastsPreference } from '$lib/stores/toastStore';
|
import { loadToastsPreference } from '$lib/stores/toastStore';
|
||||||
import { loadSystemNotificationsPreference, flushScrobbleQueue } from '$lib/services/playbackService';
|
import { loadSystemNotificationsPreference, flushScrobbleQueue } from '$lib/services/playbackService';
|
||||||
import { initOfflineStore, cleanupOfflineStore, onOnlineTransition, syncPendingPlaylists } from '$lib/stores/offlineStore';
|
import { initOfflineStore, cleanupOfflineStore, onOnlineTransition, syncPendingPlaylists } from '$lib/stores/offlineStore';
|
||||||
|
import { loadUnavailableTracks } from '$lib/stores/unavailableTracksStore';
|
||||||
import { getNextZoomLevel } from '$lib/utils/zoom';
|
import { getNextZoomLevel } from '$lib/utils/zoom';
|
||||||
import { getZoom, setZoom } from '$lib/stores/zoomStore';
|
import { getZoom, setZoom } from '$lib/stores/zoomStore';
|
||||||
|
|
||||||
@@ -141,6 +142,9 @@ export function bootstrapApp(): BootstrapResult {
|
|||||||
// Initialize offline store (async, fire-and-forget)
|
// Initialize offline store (async, fire-and-forget)
|
||||||
initOfflineStore();
|
initOfflineStore();
|
||||||
|
|
||||||
|
// Load unavailable tracks from localStorage
|
||||||
|
loadUnavailableTracks();
|
||||||
|
|
||||||
// Register callback to flush scrobble queue and sync playlists when transitioning to online
|
// Register callback to flush scrobble queue and sync playlists when transitioning to online
|
||||||
onOnlineTransition(() => {
|
onOnlineTransition(() => {
|
||||||
console.log('[Bootstrap] Online transition detected - flushing scrobble queue and syncing playlists');
|
console.log('[Bootstrap] Online transition detected - flushing scrobble queue and syncing playlists');
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
CloudDownload,
|
CloudDownload,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Radio,
|
Radio,
|
||||||
Info
|
Info,
|
||||||
|
Search
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { shouldHidePlaylistFeatures } from '$lib/utils/offlineHelpers';
|
import { shouldHidePlaylistFeatures } from '$lib/utils/offlineHelpers';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
onAddFavorite?: () => void;
|
onAddFavorite?: () => void;
|
||||||
onAddToPlaylist?: () => void;
|
onAddToPlaylist?: () => void;
|
||||||
onRemoveFromPlaylist?: () => void;
|
onRemoveFromPlaylist?: () => void;
|
||||||
|
onFindReplacement?: () => void;
|
||||||
onShareQobuz?: () => void;
|
onShareQobuz?: () => void;
|
||||||
onShareSonglink?: () => void;
|
onShareSonglink?: () => void;
|
||||||
onGoToAlbum?: () => void;
|
onGoToAlbum?: () => void;
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
onAddFavorite,
|
onAddFavorite,
|
||||||
onAddToPlaylist,
|
onAddToPlaylist,
|
||||||
onRemoveFromPlaylist,
|
onRemoveFromPlaylist,
|
||||||
|
onFindReplacement,
|
||||||
onShareQobuz,
|
onShareQobuz,
|
||||||
onShareSonglink,
|
onShareSonglink,
|
||||||
onGoToAlbum,
|
onGoToAlbum,
|
||||||
@@ -100,7 +103,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPlayback = $derived(!!(onPlayNow || onPlayTrackOnly || onPlayFromHere || onPlayNext || onPlayLater || onCreateRadio));
|
const hasPlayback = $derived(!!(onPlayNow || onPlayTrackOnly || onPlayFromHere || onPlayNext || onPlayLater || onCreateRadio));
|
||||||
const hasLibrary = $derived(!!(onAddFavorite || onAddToPlaylist || onRemoveFromPlaylist));
|
const hasLibrary = $derived(!!(onAddFavorite || onAddToPlaylist || onRemoveFromPlaylist || onFindReplacement));
|
||||||
const hasShare = $derived(!!(onShareQobuz || onShareSonglink));
|
const hasShare = $derived(!!(onShareQobuz || onShareSonglink));
|
||||||
const hasDownload = $derived(!!onDownload || isTrackDownloaded);
|
const hasDownload = $derived(!!onDownload || isTrackDownloaded);
|
||||||
const hasNav = $derived(!!(onGoToAlbum || onGoToArtist || onShowInfo));
|
const hasNav = $derived(!!(onGoToAlbum || onGoToArtist || onShowInfo));
|
||||||
@@ -414,6 +417,12 @@
|
|||||||
<span>Remove from playlist</span>
|
<span>Remove from playlist</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if onFindReplacement}
|
||||||
|
<button class="menu-item" onclick={() => handleAction(onFindReplacement)}>
|
||||||
|
<Search size={14} />
|
||||||
|
<span>Find available version</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasLibrary && (hasShare || hasNav)}
|
{#if hasLibrary && (hasShare || hasNav)}
|
||||||
|
|||||||
@@ -0,0 +1,588 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { X, Search, Play, Check, AlertCircle, Loader2 } from 'lucide-svelte';
|
||||||
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
|
interface SearchTrack {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
performer?: { id?: number; name: string };
|
||||||
|
album?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image?: { small?: string; thumbnail?: string; large?: string };
|
||||||
|
};
|
||||||
|
hires: boolean;
|
||||||
|
maximum_bit_depth?: number;
|
||||||
|
maximum_sampling_rate?: number;
|
||||||
|
streamable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResultsPage {
|
||||||
|
items: SearchTrack[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
trackTitle: string;
|
||||||
|
trackArtist?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (track: SearchTrack) => void;
|
||||||
|
onPreview?: (track: SearchTrack) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
isOpen,
|
||||||
|
trackTitle,
|
||||||
|
trackArtist,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
onPreview
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let results = $state<SearchTrack[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let searched = $state(false);
|
||||||
|
let selectedTrackId = $state<number | null>(null);
|
||||||
|
|
||||||
|
// Initialize search query when modal opens
|
||||||
|
$effect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Build initial search query from track info
|
||||||
|
const query = trackArtist
|
||||||
|
? `${trackTitle} ${trackArtist}`
|
||||||
|
: trackTitle;
|
||||||
|
searchQuery = query;
|
||||||
|
selectedTrackId = null;
|
||||||
|
searched = false;
|
||||||
|
results = [];
|
||||||
|
// Auto-search on open
|
||||||
|
doSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
searched = true;
|
||||||
|
try {
|
||||||
|
const response = await invoke<SearchResultsPage>('search_tracks', {
|
||||||
|
query: searchQuery.trim(),
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
// Filter out non-streamable tracks
|
||||||
|
results = response.items.filter(t => t.streamable !== false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Search failed:', err);
|
||||||
|
results = [];
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
doSearch();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuality(track: SearchTrack): string {
|
||||||
|
if (track.maximum_bit_depth && track.maximum_sampling_rate) {
|
||||||
|
return `${track.maximum_bit_depth}bit/${track.maximum_sampling_rate}kHz`;
|
||||||
|
}
|
||||||
|
if (track.hires) return 'Hi-Res';
|
||||||
|
return 'CD';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(track: SearchTrack) {
|
||||||
|
selectedTrackId = track.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
const selected = results.find(t => t.id === selectedTrackId);
|
||||||
|
if (selected) {
|
||||||
|
onSelect(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreview(e: MouseEvent, track: SearchTrack) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPreview?.(track);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isOpen}
|
||||||
|
<div class="modal-overlay" onclick={onClose} onkeydown={handleKeydown} role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-content" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{$t('playlist.findReplacement')}</h2>
|
||||||
|
<button class="close-btn" onclick={onClose}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-section">
|
||||||
|
<div class="search-info">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>{$t('playlist.searchingFor')}: <strong>{trackTitle}</strong>{trackArtist ? ` - ${trackArtist}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="search-input-container">
|
||||||
|
<Search size={16} class="search-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder={$t('search.placeholder')}
|
||||||
|
class="search-input"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
/>
|
||||||
|
<button class="search-btn" onclick={doSearch} disabled={loading}>
|
||||||
|
{#if loading}
|
||||||
|
<Loader2 size={16} class="spinner" />
|
||||||
|
{:else}
|
||||||
|
{$t('playlist.searchButton')}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-section">
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading-state">
|
||||||
|
<Loader2 size={24} class="spinner" />
|
||||||
|
<span>{$t('playlist.loading')}</span>
|
||||||
|
</div>
|
||||||
|
{:else if searched && results.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<AlertCircle size={32} />
|
||||||
|
<p>{$t('playlist.noReplacementsFound')}</p>
|
||||||
|
<p class="hint">{$t('playlist.tryDifferentSearch')}</p>
|
||||||
|
</div>
|
||||||
|
{:else if results.length > 0}
|
||||||
|
<div class="results-list">
|
||||||
|
{#each results as track (track.id)}
|
||||||
|
<button
|
||||||
|
class="result-item"
|
||||||
|
class:selected={selectedTrackId === track.id}
|
||||||
|
onclick={() => handleSelect(track)}
|
||||||
|
ondblclick={() => { handleSelect(track); handleConfirm(); }}
|
||||||
|
>
|
||||||
|
<div class="track-artwork">
|
||||||
|
{#if track.album?.image?.thumbnail}
|
||||||
|
<img src={track.album.image.thumbnail} alt="" />
|
||||||
|
{:else}
|
||||||
|
<div class="artwork-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="track-info">
|
||||||
|
<div class="track-title">{track.title}</div>
|
||||||
|
<div class="track-meta">
|
||||||
|
<span class="artist">{track.performer?.name ?? 'Unknown'}</span>
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<span class="album">{track.album?.title ?? 'Unknown Album'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="track-quality" class:hires={track.hires}>
|
||||||
|
{formatQuality(track)}
|
||||||
|
</div>
|
||||||
|
<div class="track-duration">{formatDuration(track.duration)}</div>
|
||||||
|
{#if onPreview}
|
||||||
|
<button
|
||||||
|
class="preview-btn"
|
||||||
|
onclick={(e) => handlePreview(e, track)}
|
||||||
|
title={$t('playlist.preview')}
|
||||||
|
>
|
||||||
|
<Play size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if selectedTrackId === track.id}
|
||||||
|
<div class="selected-indicator">
|
||||||
|
<Check size={16} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty-state initial">
|
||||||
|
<Search size={32} />
|
||||||
|
<p>{$t('playlist.searchToFindReplacement')}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="cancel-btn" onclick={onClose}>
|
||||||
|
{$t('playlist.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="confirm-btn"
|
||||||
|
onclick={handleConfirm}
|
||||||
|
disabled={selectedTrackId === null}
|
||||||
|
>
|
||||||
|
{$t('playlist.replaceTrack')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-section {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-container :global(.search-icon) {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .hint {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state.initial {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item.selected {
|
||||||
|
background: var(--accent-primary-alpha, rgba(99, 102, 241, 0.15));
|
||||||
|
outline: 1px solid var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-artwork img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artwork-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta .separator {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta .artist,
|
||||||
|
.track-meta .album {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta .album {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-quality {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-quality.hires {
|
||||||
|
color: var(--accent-primary);
|
||||||
|
background: var(--accent-primary-alpha, rgba(99, 102, 241, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-duration {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
width: 45px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item:hover .preview-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-btn:hover {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-indicator {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn,
|
||||||
|
.confirm-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Play, Pause, Heart, HardDrive } from 'lucide-svelte';
|
import { Play, Pause, Heart, HardDrive, AlertCircle } from 'lucide-svelte';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import DownloadButton from './DownloadButton.svelte';
|
import DownloadButton from './DownloadButton.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
quality?: string;
|
quality?: string;
|
||||||
isPlaying?: boolean;
|
isPlaying?: boolean;
|
||||||
isLocal?: boolean; // Whether this is a local library track
|
isLocal?: boolean; // Whether this is a local library track
|
||||||
|
isUnavailable?: boolean; // Track removed from Qobuz or otherwise unavailable
|
||||||
|
unavailableTooltip?: string; // Tooltip for unavailable indicator
|
||||||
isFavoriteOverride?: boolean; // Optional override for favorite state
|
isFavoriteOverride?: boolean; // Optional override for favorite state
|
||||||
downloadStatus?: OfflineCacheStatus;
|
downloadStatus?: OfflineCacheStatus;
|
||||||
downloadProgress?: number;
|
downloadProgress?: number;
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
onPlayLater?: () => void;
|
onPlayLater?: () => void;
|
||||||
onAddToPlaylist?: () => void;
|
onAddToPlaylist?: () => void;
|
||||||
onRemoveFromPlaylist?: () => void;
|
onRemoveFromPlaylist?: () => void;
|
||||||
|
onFindReplacement?: () => void;
|
||||||
onShareQobuz?: () => void;
|
onShareQobuz?: () => void;
|
||||||
onShareSonglink?: () => void;
|
onShareSonglink?: () => void;
|
||||||
onGoToAlbum?: () => void;
|
onGoToAlbum?: () => void;
|
||||||
@@ -67,6 +70,8 @@
|
|||||||
quality,
|
quality,
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
isLocal = false,
|
isLocal = false,
|
||||||
|
isUnavailable = false,
|
||||||
|
unavailableTooltip,
|
||||||
isFavoriteOverride,
|
isFavoriteOverride,
|
||||||
downloadStatus = 'none',
|
downloadStatus = 'none',
|
||||||
downloadProgress = 0,
|
downloadProgress = 0,
|
||||||
@@ -141,9 +146,13 @@
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
onkeydown={(e) => e.key === 'Enter' && onPlay?.()}
|
onkeydown={(e) => e.key === 'Enter' && onPlay?.()}
|
||||||
>
|
>
|
||||||
<!-- Track Number / Play Button -->
|
<!-- Track Number / Play Button / Unavailable Indicator -->
|
||||||
<div class="track-number">
|
<div class="track-number" class:unavailable={isUnavailable}>
|
||||||
{#if isPlaying}
|
{#if isUnavailable}
|
||||||
|
<span class="unavailable-icon" title={unavailableTooltip}>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
</span>
|
||||||
|
{:else if isPlaying}
|
||||||
{#if isHovered}
|
{#if isHovered}
|
||||||
<button class="pause-btn" type="button" onclick={handlePauseClick} aria-label="Pause">
|
<button class="pause-btn" type="button" onclick={handlePauseClick} aria-label="Pause">
|
||||||
<Pause size={16} class="pause-icon" />
|
<Pause size={16} class="pause-icon" />
|
||||||
@@ -204,10 +213,10 @@
|
|||||||
<!-- Quality (always render to maintain column alignment) -->
|
<!-- Quality (always render to maintain column alignment) -->
|
||||||
<div class="track-quality">{quality ?? ''}</div>
|
<div class="track-quality">{quality ?? ''}</div>
|
||||||
|
|
||||||
<!-- Favorite Button (placeholder for local tracks to maintain column width) -->
|
<!-- Favorite Button (placeholder for local tracks or hidden to maintain column width) -->
|
||||||
{#if isLocal}
|
{#if isLocal || hideFavorite}
|
||||||
<div class="favorite-placeholder"></div>
|
<div class="favorite-placeholder"></div>
|
||||||
{:else if !hideFavorite}
|
{:else}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="favorite-btn"
|
class="favorite-btn"
|
||||||
@@ -225,12 +234,14 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Download Indicator / Local Indicator -->
|
<!-- Download Indicator / Local Indicator (placeholder when hidden to maintain column width) -->
|
||||||
{#if isLocal}
|
{#if isLocal}
|
||||||
<div class="local-indicator" title="Local track - only available on this device">
|
<div class="local-indicator" title="Local track - only available on this device">
|
||||||
<HardDrive size={14} />
|
<HardDrive size={14} />
|
||||||
</div>
|
</div>
|
||||||
{:else if !hideDownload}
|
{:else if hideDownload}
|
||||||
|
<div class="download-placeholder"></div>
|
||||||
|
{:else}
|
||||||
<div class="download-indicator" class:has-download={downloadStatus !== 'none'}>
|
<div class="download-indicator" class:has-download={downloadStatus !== 'none'}>
|
||||||
<DownloadButton
|
<DownloadButton
|
||||||
status={downloadStatus}
|
status={downloadStatus}
|
||||||
@@ -252,6 +263,7 @@
|
|||||||
onAddFavorite={trackId !== undefined ? () => toggleTrackFavorite(trackId) : undefined}
|
onAddFavorite={trackId !== undefined ? () => toggleTrackFavorite(trackId) : undefined}
|
||||||
onAddToPlaylist={menuActions?.onAddToPlaylist}
|
onAddToPlaylist={menuActions?.onAddToPlaylist}
|
||||||
onRemoveFromPlaylist={menuActions?.onRemoveFromPlaylist}
|
onRemoveFromPlaylist={menuActions?.onRemoveFromPlaylist}
|
||||||
|
onFindReplacement={menuActions?.onFindReplacement}
|
||||||
onShareQobuz={menuActions?.onShareQobuz}
|
onShareQobuz={menuActions?.onShareQobuz}
|
||||||
onShareSonglink={menuActions?.onShareSonglink}
|
onShareSonglink={menuActions?.onShareSonglink}
|
||||||
onGoToAlbum={menuActions?.onGoToAlbum}
|
onGoToAlbum={menuActions?.onGoToAlbum}
|
||||||
@@ -310,6 +322,18 @@
|
|||||||
color: #666666;
|
color: #666666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.track-number.unavailable {
|
||||||
|
color: var(--error-color, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unavailable-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--error-color, #ef4444);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.track-number :global(.play-icon) {
|
.track-number :global(.play-icon) {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -468,6 +492,11 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-placeholder {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.favorite-btn {
|
.favorite-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import AlbumMenu from '../AlbumMenu.svelte';
|
import AlbumMenu from '../AlbumMenu.svelte';
|
||||||
import PlaylistCollage from '../PlaylistCollage.svelte';
|
import PlaylistCollage from '../PlaylistCollage.svelte';
|
||||||
import PlaylistModal from '../PlaylistModal.svelte';
|
import PlaylistModal from '../PlaylistModal.svelte';
|
||||||
|
import TrackReplacementModal from '../TrackReplacementModal.svelte';
|
||||||
import ViewTransition from '../ViewTransition.svelte';
|
import ViewTransition from '../ViewTransition.svelte';
|
||||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -17,6 +18,8 @@
|
|||||||
type OfflineStatus
|
type OfflineStatus
|
||||||
} from '$lib/stores/offlineStore';
|
} from '$lib/stores/offlineStore';
|
||||||
import { consumeContextTrackFocus, setPlaybackContext } from '$lib/stores/playbackContextStore';
|
import { consumeContextTrackFocus, setPlaybackContext } from '$lib/stores/playbackContextStore';
|
||||||
|
import { isTrackUnavailable, clearTrackUnavailable, subscribe as subscribeUnavailable } from '$lib/stores/unavailableTracksStore';
|
||||||
|
import { showToast } from '$lib/stores/toastStore';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@
|
|||||||
maximum_sampling_rate?: number;
|
maximum_sampling_rate?: number;
|
||||||
isrc?: string;
|
isrc?: string;
|
||||||
playlist_track_id?: number; // Qobuz playlist-specific ID for removal
|
playlist_track_id?: number; // Qobuz playlist-specific ID for removal
|
||||||
|
streamable?: boolean; // Whether track is available on Qobuz (false = removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Playlist {
|
interface Playlist {
|
||||||
@@ -73,6 +77,7 @@
|
|||||||
label?: string; // Record label name from Qobuz
|
label?: string; // Record label name from Qobuz
|
||||||
addedIndex?: number; // Original position in playlist (proxy for date added)
|
addedIndex?: number; // Original position in playlist (proxy for date added)
|
||||||
customPosition?: number; // User-defined position for custom arrange mode
|
customPosition?: number; // User-defined position for custom arrange mode
|
||||||
|
streamable?: boolean; // Whether track is available on Qobuz (false = removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local library track from backend
|
// Local library track from backend
|
||||||
@@ -214,6 +219,9 @@
|
|||||||
let offlineStatus = $state<OfflineStatus>(getOfflineStatus());
|
let offlineStatus = $state<OfflineStatus>(getOfflineStatus());
|
||||||
let tracksWithLocalCopies = $state<Set<number>>(new Set());
|
let tracksWithLocalCopies = $state<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Track unavailable state version (increments to force re-render)
|
||||||
|
let unavailableVersion = $state(0);
|
||||||
|
|
||||||
// Local settings state
|
// Local settings state
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let sortBy = $state<SortField>('default');
|
let sortBy = $state<SortField>('default');
|
||||||
@@ -244,6 +252,10 @@
|
|||||||
let isCopying = $state(false);
|
let isCopying = $state(false);
|
||||||
let isCopied = $state(false);
|
let isCopied = $state(false);
|
||||||
|
|
||||||
|
// Track replacement modal state
|
||||||
|
let replacementModalOpen = $state(false);
|
||||||
|
let trackToReplace = $state<DisplayTrack | null>(null);
|
||||||
|
|
||||||
// Track copied playlists in localStorage
|
// Track copied playlists in localStorage
|
||||||
const COPIED_PLAYLISTS_KEY = 'qbz_copied_playlists';
|
const COPIED_PLAYLISTS_KEY = 'qbz_copied_playlists';
|
||||||
|
|
||||||
@@ -285,14 +297,23 @@
|
|||||||
console.warn('Failed to get current user ID:', err);
|
console.warn('Failed to get current user ID:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribe = subscribeOffline(() => {
|
const unsubscribeOffline = subscribeOffline(() => {
|
||||||
offlineStatus = getOfflineStatus();
|
offlineStatus = getOfflineStatus();
|
||||||
// Re-check local copies when offline status changes
|
// Re-check local copies when offline status changes
|
||||||
if (offlineStatus.isOffline && tracks.length > 0) {
|
if (offlineStatus.isOffline && tracks.length > 0) {
|
||||||
checkTracksLocalStatus();
|
checkTracksLocalStatus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
|
||||||
|
// Subscribe to unavailable tracks store
|
||||||
|
const unsubscribeUnavailable = subscribeUnavailable(() => {
|
||||||
|
unavailableVersion++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeOffline();
|
||||||
|
unsubscribeUnavailable();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if this playlist was already copied when playlistId changes
|
// Check if this playlist was already copied when playlistId changes
|
||||||
@@ -308,10 +329,26 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if a track is available (has local copy when offline, always available when online)
|
// Check if a track was removed from Qobuz (streamable: false or marked in unavailable store)
|
||||||
|
function isTrackRemovedFromQobuz(track: DisplayTrack): boolean {
|
||||||
|
if (track.isLocal) return false;
|
||||||
|
// Check API streamable flag
|
||||||
|
if (track.streamable === false) return true;
|
||||||
|
// Check local unavailable store (marked during playback errors)
|
||||||
|
// Reference unavailableVersion to trigger reactivity
|
||||||
|
void unavailableVersion;
|
||||||
|
return isTrackUnavailable(track.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a track is available (has local copy when offline, always available when online, unless removed from Qobuz)
|
||||||
function isTrackAvailable(track: DisplayTrack): boolean {
|
function isTrackAvailable(track: DisplayTrack): boolean {
|
||||||
|
// Tracks removed from Qobuz are never available
|
||||||
|
if (isTrackRemovedFromQobuz(track)) return false;
|
||||||
|
// When online, Qobuz tracks are available
|
||||||
if (!offlineStatus.isOffline) return true;
|
if (!offlineStatus.isOffline) return true;
|
||||||
if (track.isLocal) return true; // Local tracks are always available
|
// Local tracks are always available
|
||||||
|
if (track.isLocal) return true;
|
||||||
|
// When offline, check if we have a local copy
|
||||||
return tracksWithLocalCopies.has(track.id);
|
return tracksWithLocalCopies.has(track.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +531,7 @@
|
|||||||
playlistTrackId: t.playlist_track_id,
|
playlistTrackId: t.playlist_track_id,
|
||||||
label: t.album?.label?.name,
|
label: t.album?.label?.name,
|
||||||
addedIndex: idx,
|
addedIndex: idx,
|
||||||
|
streamable: t.streamable,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1203,6 +1241,95 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open replacement modal for an unavailable track
|
||||||
|
function openReplacementModal(track: DisplayTrack) {
|
||||||
|
trackToReplace = track;
|
||||||
|
replacementModalOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle track replacement selection
|
||||||
|
interface ReplacementTrack {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
duration: number;
|
||||||
|
performer?: { id?: number; name: string };
|
||||||
|
album?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
image?: { small?: string; thumbnail?: string; large?: string };
|
||||||
|
};
|
||||||
|
hires: boolean;
|
||||||
|
maximum_bit_depth?: number;
|
||||||
|
maximum_sampling_rate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTrackReplacement(newTrack: ReplacementTrack) {
|
||||||
|
if (!trackToReplace || !trackToReplace.playlistTrackId) {
|
||||||
|
console.error('No track to replace or missing playlist_track_id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the current position of the track being replaced
|
||||||
|
const currentIndex = displayTracks.findIndex(t => t.id === trackToReplace!.id);
|
||||||
|
|
||||||
|
// Remove the old track
|
||||||
|
await invoke('remove_tracks_from_playlist', {
|
||||||
|
playlistId,
|
||||||
|
playlistTrackIds: [trackToReplace.playlistTrackId]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add the new track
|
||||||
|
await invoke('add_tracks_to_playlist', {
|
||||||
|
playlistId,
|
||||||
|
trackIds: [newTrack.id]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the unavailable status for the old track (if it was in the store)
|
||||||
|
clearTrackUnavailable(trackToReplace.id);
|
||||||
|
|
||||||
|
// Reload the playlist to get updated data
|
||||||
|
await loadPlaylist();
|
||||||
|
notifyParentOfCounts();
|
||||||
|
onPlaylistUpdated?.();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showToast($t('playlist.trackReplaced'), 'success');
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
replacementModalOpen = false;
|
||||||
|
trackToReplace = null;
|
||||||
|
|
||||||
|
console.log(`[Playlist] Track replaced: ${trackToReplace?.title} -> ${newTrack.title} at position ${currentIndex}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to replace track:', err);
|
||||||
|
showToast($t('playlist.trackReplaceFailed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview a replacement track
|
||||||
|
function handlePreviewReplacement(track: ReplacementTrack) {
|
||||||
|
if (!onTrackPlay) return;
|
||||||
|
|
||||||
|
const displayTrack: DisplayTrack = {
|
||||||
|
id: track.id,
|
||||||
|
number: 0,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.performer?.name,
|
||||||
|
album: track.album?.title,
|
||||||
|
albumArt: track.album?.image?.large || track.album?.image?.thumbnail,
|
||||||
|
albumId: track.album?.id,
|
||||||
|
artistId: track.performer?.id,
|
||||||
|
duration: formatDuration(track.duration),
|
||||||
|
durationSeconds: track.duration,
|
||||||
|
hires: track.hires,
|
||||||
|
bitDepth: track.maximum_bit_depth,
|
||||||
|
samplingRate: track.maximum_sampling_rate
|
||||||
|
};
|
||||||
|
|
||||||
|
onTrackPlay(displayTrack);
|
||||||
|
}
|
||||||
|
|
||||||
// Add a suggested track to the playlist
|
// Add a suggested track to the playlist
|
||||||
async function handleAddSuggestedTrack(suggestedTrack: import('$lib/services/playlistSuggestionsService').SuggestedTrack) {
|
async function handleAddSuggestedTrack(suggestedTrack: import('$lib/services/playlistSuggestionsService').SuggestedTrack) {
|
||||||
try {
|
try {
|
||||||
@@ -1635,14 +1762,16 @@
|
|||||||
)}
|
)}
|
||||||
{@const isTrackPlaying = isActiveTrack && isPlaybackActive}
|
{@const isTrackPlaying = isActiveTrack && isPlaybackActive}
|
||||||
{@const available = isTrackAvailable(track)}
|
{@const available = isTrackAvailable(track)}
|
||||||
|
{@const removedFromQobuz = isTrackRemovedFromQobuz(track)}
|
||||||
<div
|
<div
|
||||||
class="track-row-wrapper"
|
class="track-row-wrapper"
|
||||||
class:unavailable={!available}
|
class:unavailable={!available}
|
||||||
|
class:removed-from-qobuz={removedFromQobuz}
|
||||||
class:custom-order-mode={isCustomOrderMode}
|
class:custom-order-mode={isCustomOrderMode}
|
||||||
class:dragging={draggedTrackIdx === idx}
|
class:dragging={draggedTrackIdx === idx}
|
||||||
class:drag-over={dragOverIdx === idx && draggedTrackIdx !== idx}
|
class:drag-over={dragOverIdx === idx && draggedTrackIdx !== idx}
|
||||||
title={!available ? $t('offline.trackNotAvailable') : undefined}
|
title={removedFromQobuz ? $t('player.trackUnavailable') : (!available ? $t('offline.trackNotAvailable') : undefined)}
|
||||||
draggable={isCustomOrderMode}
|
draggable={isCustomOrderMode && !removedFromQobuz}
|
||||||
ondragstart={(e) => handleDragStart(e, idx)}
|
ondragstart={(e) => handleDragStart(e, idx)}
|
||||||
ondragover={(e) => handleDragOver(e, idx)}
|
ondragover={(e) => handleDragOver(e, idx)}
|
||||||
ondragleave={handleDragLeave}
|
ondragleave={handleDragLeave}
|
||||||
@@ -1694,14 +1823,20 @@
|
|||||||
: '-'}
|
: '-'}
|
||||||
isPlaying={isTrackPlaying}
|
isPlaying={isTrackPlaying}
|
||||||
isLocal={track.isLocal}
|
isLocal={track.isLocal}
|
||||||
hideFavorite={track.isLocal}
|
isUnavailable={removedFromQobuz && isOwnPlaylist}
|
||||||
hideDownload={track.isLocal}
|
unavailableTooltip={removedFromQobuz ? $t('player.trackUnavailable') : undefined}
|
||||||
|
hideFavorite={track.isLocal || removedFromQobuz}
|
||||||
|
hideDownload={track.isLocal || removedFromQobuz}
|
||||||
downloadStatus={downloadInfo.status}
|
downloadStatus={downloadInfo.status}
|
||||||
downloadProgress={downloadInfo.progress}
|
downloadProgress={downloadInfo.progress}
|
||||||
onPlay={available ? () => handleTrackClick(track, idx) : undefined}
|
onPlay={available ? () => handleTrackClick(track, idx) : undefined}
|
||||||
onDownload={available && !track.isLocal && onTrackDownload ? () => onTrackDownload(track) : undefined}
|
onDownload={available && !track.isLocal && onTrackDownload ? () => onTrackDownload(track) : undefined}
|
||||||
onRemoveDownload={available && !track.isLocal && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
|
onRemoveDownload={available && !track.isLocal && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
|
||||||
menuActions={available ? {
|
menuActions={removedFromQobuz ? (isOwnPlaylist ? {
|
||||||
|
// Only allow remove from playlist and find replacement for tracks removed from Qobuz (owned playlists only)
|
||||||
|
onRemoveFromPlaylist: () => removeTrackFromPlaylist(track),
|
||||||
|
onFindReplacement: () => openReplacementModal(track)
|
||||||
|
} : {}) : available ? {
|
||||||
onPlayNow: () => handleTrackClick(track, idx),
|
onPlayNow: () => handleTrackClick(track, idx),
|
||||||
onPlayNext: track.isLocal ? () => handleTrackPlayNext(track) : (onTrackPlayNext ? () => onTrackPlayNext(track) : undefined),
|
onPlayNext: track.isLocal ? () => handleTrackPlayNext(track) : (onTrackPlayNext ? () => onTrackPlayNext(track) : undefined),
|
||||||
onPlayLater: track.isLocal ? () => handleTrackPlayLater(track) : (onTrackPlayLater ? () => onTrackPlayLater(track) : undefined),
|
onPlayLater: track.isLocal ? () => handleTrackPlayLater(track) : (onTrackPlayLater ? () => onTrackPlayLater(track) : undefined),
|
||||||
@@ -1761,6 +1896,16 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Track Replacement Modal -->
|
||||||
|
<TrackReplacementModal
|
||||||
|
isOpen={replacementModalOpen}
|
||||||
|
trackTitle={trackToReplace?.title ?? ''}
|
||||||
|
trackArtist={trackToReplace?.artist}
|
||||||
|
onClose={() => { replacementModalOpen = false; trackToReplace = null; }}
|
||||||
|
onSelect={handleTrackReplacement}
|
||||||
|
onPreview={handlePreviewReplacement}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.playlist-detail {
|
.playlist-detail {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
@@ -2312,6 +2457,23 @@
|
|||||||
filter: grayscale(100%);
|
filter: grayscale(100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Track removed from Qobuz - allow limited interactions (remove from playlist) */
|
||||||
|
.track-row-wrapper.removed-from-qobuz {
|
||||||
|
opacity: 0.5;
|
||||||
|
/* Keep wrapper interactive for context menu */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row-wrapper.removed-from-qobuz :global(.track-row) {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable play hover effect for removed tracks */
|
||||||
|
.track-row-wrapper.removed-from-qobuz :global(.track-row .track-number),
|
||||||
|
.track-row-wrapper.removed-from-qobuz :global(.track-row .play-button) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom order mode */
|
/* Custom order mode */
|
||||||
.track-row-wrapper.custom-order-mode {
|
.track-row-wrapper.custom-order-mode {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
|||||||
@@ -194,7 +194,20 @@
|
|||||||
"private": "Private",
|
"private": "Private",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"manage": "Manage playlists",
|
"manage": "Manage playlists",
|
||||||
"allHidden": "All playlists are hidden"
|
"allHidden": "All playlists are hidden",
|
||||||
|
"findReplacement": "Find Available Version",
|
||||||
|
"searchingFor": "Searching for",
|
||||||
|
"noReplacementsFound": "No available versions found",
|
||||||
|
"tryDifferentSearch": "Try a different search query",
|
||||||
|
"searchToFindReplacement": "Search to find a replacement track",
|
||||||
|
"replaceTrack": "Replace Track",
|
||||||
|
"lookForAvailableVersion": "Look for available version",
|
||||||
|
"trackReplaced": "Track replaced successfully",
|
||||||
|
"trackReplaceFailed": "Failed to replace track",
|
||||||
|
"searchButton": "Search",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"loading": "Searching...",
|
||||||
|
"preview": "Preview"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"nameAZ": "Name (A-Z)",
|
"nameAZ": "Name (A-Z)",
|
||||||
@@ -253,6 +266,7 @@
|
|||||||
"lyrics": "Lyrics",
|
"lyrics": "Lyrics",
|
||||||
"noLyrics": "No lyrics available",
|
"noLyrics": "No lyrics available",
|
||||||
"fetchingLyrics": "Fetching lyrics...",
|
"fetchingLyrics": "Fetching lyrics...",
|
||||||
|
"trackUnavailable": "Track no longer available in Qobuz library",
|
||||||
"credits": "Credits",
|
"credits": "Credits",
|
||||||
"trackInfo": "Track Info",
|
"trackInfo": "Track Info",
|
||||||
"suggestions": "Suggestions",
|
"suggestions": "Suggestions",
|
||||||
|
|||||||
@@ -194,7 +194,20 @@
|
|||||||
"private": "Privada",
|
"private": "Privada",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"manage": "Administrar listas",
|
"manage": "Administrar listas",
|
||||||
"allHidden": "Todas las listas están ocultas"
|
"allHidden": "Todas las listas están ocultas",
|
||||||
|
"findReplacement": "Buscar Versión Disponible",
|
||||||
|
"searchingFor": "Buscando",
|
||||||
|
"noReplacementsFound": "No se encontraron versiones disponibles",
|
||||||
|
"tryDifferentSearch": "Intenta con otra búsqueda",
|
||||||
|
"searchToFindReplacement": "Busca para encontrar un reemplazo",
|
||||||
|
"replaceTrack": "Reemplazar Pista",
|
||||||
|
"lookForAvailableVersion": "Buscar versión disponible",
|
||||||
|
"trackReplaced": "Pista reemplazada exitosamente",
|
||||||
|
"trackReplaceFailed": "Error al reemplazar pista",
|
||||||
|
"searchButton": "Buscar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"loading": "Buscando...",
|
||||||
|
"preview": "Vista previa"
|
||||||
},
|
},
|
||||||
"sort": {
|
"sort": {
|
||||||
"nameAZ": "Nombre (A-Z)",
|
"nameAZ": "Nombre (A-Z)",
|
||||||
@@ -253,6 +266,7 @@
|
|||||||
"lyrics": "Letras",
|
"lyrics": "Letras",
|
||||||
"noLyrics": "No hay letras disponibles",
|
"noLyrics": "No hay letras disponibles",
|
||||||
"fetchingLyrics": "Buscando letras...",
|
"fetchingLyrics": "Buscando letras...",
|
||||||
|
"trackUnavailable": "Pista ya no disponible en la biblioteca de Qobuz",
|
||||||
"credits": "Créditos",
|
"credits": "Créditos",
|
||||||
"trackInfo": "Info de Pista",
|
"trackInfo": "Info de Pista",
|
||||||
"suggestions": "Sugerencias",
|
"suggestions": "Sugerencias",
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
setIsFavorite,
|
setIsFavorite,
|
||||||
type PlayingTrack
|
type PlayingTrack
|
||||||
} from '$lib/stores/playerStore';
|
} from '$lib/stores/playerStore';
|
||||||
import { syncQueueState } from '$lib/stores/queueStore';
|
import { syncQueueState, nextTrack } from '$lib/stores/queueStore';
|
||||||
|
import { markTrackUnavailable } from '$lib/stores/unavailableTracksStore';
|
||||||
import { logRecoEvent } from '$lib/services/recoService';
|
import { logRecoEvent } from '$lib/services/recoService';
|
||||||
import {
|
import {
|
||||||
isCasting,
|
isCasting,
|
||||||
@@ -199,6 +200,39 @@ export async function playTrack(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to play track:', err);
|
console.error('Failed to play track:', err);
|
||||||
dismissBuffering();
|
dismissBuffering();
|
||||||
|
|
||||||
|
// Check if track is unavailable on Qobuz
|
||||||
|
const errorStr = String(err);
|
||||||
|
if (errorStr.includes('no longer available') || errorStr.includes('TrackUnavailable')) {
|
||||||
|
// Mark track as unavailable for future reference
|
||||||
|
markTrackUnavailable(track.id);
|
||||||
|
showToast(`"${track.title}" is no longer available`, 'error');
|
||||||
|
|
||||||
|
// Auto-skip to next track
|
||||||
|
const next = await nextTrack();
|
||||||
|
if (next) {
|
||||||
|
console.log('Auto-skipping to next track:', next.title);
|
||||||
|
// Small delay to let the toast be visible
|
||||||
|
setTimeout(() => {
|
||||||
|
playTrack({
|
||||||
|
id: next.id,
|
||||||
|
title: next.title,
|
||||||
|
artist: next.artist,
|
||||||
|
album: next.album,
|
||||||
|
duration: next.duration_secs,
|
||||||
|
artwork: next.artwork_url || undefined,
|
||||||
|
albumId: next.album_id || undefined,
|
||||||
|
artistId: next.artist_id || undefined,
|
||||||
|
bitDepth: next.bit_depth || undefined,
|
||||||
|
samplingRate: next.sample_rate || undefined
|
||||||
|
}, { showLoadingToast: true, showSuccessToast: true });
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
showToast(`Playback error: ${err}`, 'error');
|
showToast(`Playback error: ${err}`, 'error');
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Unavailable Tracks Store
|
||||||
|
*
|
||||||
|
* Tracks which tracks have been detected as unavailable on Qobuz.
|
||||||
|
* Persists to localStorage so tracks remain marked after app restart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'qbz-unavailable-tracks';
|
||||||
|
|
||||||
|
// In-memory set of unavailable track IDs
|
||||||
|
let unavailableTrackIds: Set<number> = new Set();
|
||||||
|
|
||||||
|
// Subscribers for reactivity
|
||||||
|
type Subscriber = () => void;
|
||||||
|
const subscribers: Set<Subscriber> = new Set();
|
||||||
|
|
||||||
|
function notifySubscribers() {
|
||||||
|
subscribers.forEach(fn => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load unavailable tracks from localStorage
|
||||||
|
*/
|
||||||
|
export function loadUnavailableTracks(): void {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const ids = JSON.parse(stored) as number[];
|
||||||
|
unavailableTrackIds = new Set(ids);
|
||||||
|
console.log(`[UnavailableTracks] Loaded ${unavailableTrackIds.size} unavailable tracks from storage`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UnavailableTracks] Failed to load from storage:', err);
|
||||||
|
unavailableTrackIds = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save unavailable tracks to localStorage
|
||||||
|
*/
|
||||||
|
function saveToStorage(): void {
|
||||||
|
try {
|
||||||
|
const ids = Array.from(unavailableTrackIds);
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UnavailableTracks] Failed to save to storage:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a track as unavailable
|
||||||
|
*/
|
||||||
|
export function markTrackUnavailable(trackId: number): void {
|
||||||
|
if (!unavailableTrackIds.has(trackId)) {
|
||||||
|
unavailableTrackIds.add(trackId);
|
||||||
|
saveToStorage();
|
||||||
|
notifySubscribers();
|
||||||
|
console.log(`[UnavailableTracks] Marked track ${trackId} as unavailable`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a track is known to be unavailable
|
||||||
|
*/
|
||||||
|
export function isTrackUnavailable(trackId: number): boolean {
|
||||||
|
return unavailableTrackIds.has(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a track from the unavailable list (e.g., when removed from library)
|
||||||
|
*/
|
||||||
|
export function clearTrackUnavailable(trackId: number): void {
|
||||||
|
if (unavailableTrackIds.has(trackId)) {
|
||||||
|
unavailableTrackIds.delete(trackId);
|
||||||
|
saveToStorage();
|
||||||
|
notifySubscribers();
|
||||||
|
console.log(`[UnavailableTracks] Cleared unavailable status for track ${trackId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all unavailable track IDs
|
||||||
|
*/
|
||||||
|
export function getUnavailableTrackIds(): Set<number> {
|
||||||
|
return new Set(unavailableTrackIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to changes in unavailable tracks
|
||||||
|
*/
|
||||||
|
export function subscribe(fn: Subscriber): () => void {
|
||||||
|
subscribers.add(fn);
|
||||||
|
return () => subscribers.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all unavailable tracks (for testing/reset)
|
||||||
|
*/
|
||||||
|
export function clearAll(): void {
|
||||||
|
unavailableTrackIds.clear();
|
||||||
|
saveToStorage();
|
||||||
|
notifySubscribers();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user