Merge branch 'bugfix/unavailable-track-row-dim' into pre-release

This commit is contained in:
vicrodh committed 2026-05-11 16:01:52 -06:00
commit 43a35647fe
7 files changed
+139 -6

No files matched your search

+19 -6
View File
@@ -255,24 +255,25 @@
<div
class="track-row"
class:playing={isActiveTrack || isPlaying}
class:hovered={isHovered && !isActiveTrack && !isPlaying && !isBlacklisted}
class:hovered={isHovered && !isActiveTrack && !isPlaying && !isBlacklisted && !isUnavailable}
class:compact
class:blacklisted={isBlacklisted}
class:unavailable={isUnavailable}
class:selected
data-track-id={trackId ?? undefined}
draggable={!!trackId && !isBlacklisted}
draggable={!!trackId && !isBlacklisted && !isUnavailable}
ondragstart={handleDragStart}
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
onclick={selectable ? onToggleSelect : (isBlacklisted ? undefined : onPlay)}
onclick={selectable ? onToggleSelect : (isBlacklisted || isUnavailable ? undefined : onPlay)}
oncontextmenu={(e) => {
if (isBlacklisted || selectable) return;
if (isBlacklisted || isUnavailable || selectable) return;
e.preventDefault();
contextMenuPos = { x: e.clientX, y: e.clientY };
}}
role="button"
tabindex={isBlacklisted ? -1 : 0}
onkeydown={(e) => e.key === 'Enter' && !isBlacklisted && (selectable ? onToggleSelect?.(e as unknown as MouseEvent) : onPlay?.())}
tabindex={isBlacklisted || isUnavailable ? -1 : 0}
onkeydown={(e) => e.key === 'Enter' && !isBlacklisted && !isUnavailable && (selectable ? onToggleSelect?.(e as unknown as MouseEvent) : onPlay?.())}
>
<!-- Checkbox (select mode) — always rendered, animates in/out so the
column doesn't pop when select mode toggles. -->
@@ -652,6 +653,18 @@
color: var(--text-muted);
}
/* Unavailable track styles — mirrors blacklisted: dim the whole row, kill
hover background, kill pointer cursor. The track-number cell still shows
the red CircleAlert icon (handled by .track-number.unavailable). */
.track-row.unavailable {
opacity: 0.4;
cursor: not-allowed;
}
.track-row.unavailable:hover {
background: transparent;
}
.track-number :global(.play-icon) {
color: white;
}
@@ -1,8 +1,13 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import TrackRow from './TrackRow.svelte';
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import {
isTrackRemovedFromQobuz,
subscribe as subscribeUnavailable
} from '$lib/stores/unavailableTracksStore';
import { restoreScrollOnBackForward } from '$lib/utils/scrollRestore';
import { formatTrackTitle } from '$lib/utils/trackTitle';
@@ -341,6 +346,19 @@
}
let resizeObserver: ResizeObserver | null = null;
// Counter incremented when the unavailable-tracks store mutates so reactive
// reads in {@const} blocks re-evaluate. Skipped for `isLocal` lists since
// local files don't carry the streamable concept.
let unavailableVersion = $state(0);
let unsubscribeUnavailable: (() => void) | null = null;
// Reading `unavailableVersion` here registers the dependency so the {@const}
// calling this re-evaluates when the store changes. Local lists short-circuit
// to false since streamable doesn't apply to local files.
function checkTrackUnavailable(track: { id: number; streamable?: boolean }): boolean {
if (isLocal) return false;
void unavailableVersion;
return isTrackRemovedFromQobuz(track);
}
onMount(() => {
if (containerEl) {
@@ -355,10 +373,17 @@
}
restoreScrollOnBackForward(containerEl, (v) => scrollTop = v);
if (!isLocal) {
unsubscribeUnavailable = subscribeUnavailable(() => {
unavailableVersion++;
});
}
});
onDestroy(() => {
resizeObserver?.disconnect();
unsubscribeUnavailable?.();
});
// Public method to scroll to a group
@@ -404,6 +429,7 @@
{@const downloadInfo = getOfflineCacheStatus?.(trackId) ?? { status: 'none' as const, progress: 0 }}
{@const isTrackDownloaded = downloadInfo.status === 'ready'}
{@const trackBlacklisted = !isLocal && artistId ? isArtistBlacklisted(artistId) : false}
{@const trackUnavailable = checkTrackUnavailable(item.track)}
<TrackRow
trackId={trackId}
number={getTrackNumber(item.track, item.index)}
@@ -418,6 +444,8 @@
isPlaying={isPlaybackActive && activeTrackId === trackId}
isActiveTrack={activeTrackId === trackId}
isBlacklisted={trackBlacklisted}
isUnavailable={trackUnavailable}
unavailableTooltip={trackUnavailable ? $t('player.trackUnavailable') : undefined}
selectable={selectable}
selected={selectedIds?.has(trackId) ?? false}
dragTrackIds={selectable && selectedIds?.has(trackId) && selectedIds.size > 1 ? [...selectedIds] : undefined}
@@ -31,6 +31,10 @@
toggleAlbumFavorite
} from '$lib/stores/albumFavoritesStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import {
isTrackRemovedFromQobuz,
subscribe as subscribeUnavailable
} from '$lib/stores/unavailableTracksStore';
import ImageLightbox from '../ImageLightbox.svelte';
import BookletViewer from '../BookletViewer.svelte';
import type { QobuzGoody } from '$lib/types';
@@ -59,6 +63,8 @@
samplingRate?: number;
isrc?: string;
parental_warning?: boolean;
/** API streamable flag — false when the track has been removed from Qobuz. */
streamable?: boolean;
}
interface ArtistAlbum {
@@ -203,6 +209,15 @@
let isFavorite = $state(false);
let isFavoriteLoading = $state(false);
// Counter incremented when the unavailable-tracks store mutates so reactive
// reads in {@const} blocks re-evaluate.
let unavailableVersion = $state(0);
// Reading `unavailableVersion` here registers the dependency so the {@const}
// calling this re-evaluates when the store changes.
function checkTrackUnavailable(track: { id: number; streamable?: boolean }): boolean {
void unavailableVersion;
return isTrackRemovedFromQobuz(track);
}
let lightboxOpen = $state(false);
let bookletOpen = $state(false);
let descriptionExpanded = $state(false);
@@ -563,9 +578,14 @@
gradientEnabled = isAlbumHeaderGradientEnabled();
});
const unsubscribeUnavailable = subscribeUnavailable(() => {
unavailableVersion++;
});
return () => {
unsubscribe?.();
unsubscribeAppearance();
unsubscribeUnavailable();
};
});
@@ -866,6 +886,7 @@
{@const isTrackDownloaded = downloadInfo.status === 'ready'}
{@const trackArtistId = track.artistId ?? album.artistId}
{@const trackBlacklisted = trackArtistId ? isArtistBlacklisted(trackArtistId) : false}
{@const trackUnavailable = checkTrackUnavailable(track)}
<TrackRow
trackId={track.id}
number={track.number}
@@ -877,6 +898,8 @@
isPlaying={isPlaybackActive && activeTrackId === track.id}
isActiveTrack={activeTrackId === track.id}
isBlacklisted={trackBlacklisted}
isUnavailable={trackUnavailable}
unavailableTooltip={trackUnavailable ? $t('player.trackUnavailable') : undefined}
selectable={multiSelectMode}
selected={multiSelectedIds.has(track.id)}
dragTrackIds={multiSelectMode && multiSelectedIds.has(track.id) ? [...multiSelectedIds] : undefined}
@@ -15,6 +15,10 @@
import { showToast } from '$lib/stores/toastStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import {
isTrackRemovedFromQobuz,
subscribe as subscribeUnavailable
} from '$lib/stores/unavailableTracksStore';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
@@ -477,16 +481,28 @@
return `${mins}m`;
});
// Unavailable-tracks reactivity
let unavailableVersion = $state(0);
let unsubscribeUnavailable: (() => void) | null = null;
function checkTrackUnavailable(track: { id: number; streamable?: boolean }): boolean {
void unavailableVersion;
return isTrackRemovedFromQobuz(track);
}
onMount(() => {
if (autoRunDone) return;
autoRunDone = true;
void generateDailyQ('read-write');
document.addEventListener('click', handleGlobalClick);
unsubscribeUnavailable = subscribeUnavailable(() => {
unavailableVersion++;
});
});
onDestroy(() => {
dismissAlgoTooltip();
document.removeEventListener('click', handleGlobalClick);
unsubscribeUnavailable?.();
});
</script>
@@ -603,6 +619,7 @@
{#each filteredTracks as track, index}
{@const trackBlacklisted = track.performer?.id ? isArtistBlacklisted(track.performer.id) : false}
{@const trackUnavailable = checkTrackUnavailable(track)}
{@const cacheStatus = getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 }}
{@const isTrackDownloaded = cacheStatus.status === 'ready'}
{@const displayTrack = toDisplayTrack(track)}
@@ -619,6 +636,8 @@
isPlaying={isPlaybackActive && activeTrackId === track.id}
isActiveTrack={activeTrackId === track.id}
isBlacklisted={trackBlacklisted}
isUnavailable={trackUnavailable}
unavailableTooltip={trackUnavailable ? $t('player.trackUnavailable') : undefined}
selectable={multiSelectMode}
selected={multiSelectedIds.has(track.id)}
dragTrackIds={multiSelectMode && multiSelectedIds.has(track.id) ? [...multiSelectedIds] : undefined}
+20
View File
@@ -14,6 +14,10 @@
import { showToast } from '$lib/stores/toastStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import {
isTrackRemovedFromQobuz,
subscribe as subscribeUnavailable
} from '$lib/stores/unavailableTracksStore';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
@@ -532,16 +536,29 @@
return `${mins} min`;
});
// Unavailable-tracks reactivity: bumped on store mutations so {@const}
// reads in the track list re-evaluate the dim state.
let unavailableVersion = $state(0);
let unsubscribeUnavailable: (() => void) | null = null;
function checkTrackUnavailable(track: { id: number; streamable?: boolean }): boolean {
void unavailableVersion;
return isTrackRemovedFromQobuz(track);
}
onMount(() => {
if (autoRunDone) return;
autoRunDone = true;
void generateTopQ('read-write');
document.addEventListener('click', handleGlobalClick);
unsubscribeUnavailable = subscribeUnavailable(() => {
unavailableVersion++;
});
});
onDestroy(() => {
dismissAlgoTooltip();
document.removeEventListener('click', handleGlobalClick);
unsubscribeUnavailable?.();
});
</script>
@@ -659,6 +676,7 @@
{#each filteredTracks as track, index}
{@const trackBlacklisted = track.performer?.id ? isArtistBlacklisted(track.performer.id) : false}
{@const trackUnavailable = checkTrackUnavailable(track)}
{@const cacheStatus = getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 }}
{@const isTrackDownloaded = cacheStatus.status === 'ready'}
{@const displayTrack = toDisplayTrack(track)}
@@ -675,6 +693,8 @@
isPlaying={isPlaybackActive && activeTrackId === track.id}
isActiveTrack={activeTrackId === track.id}
isBlacklisted={trackBlacklisted}
isUnavailable={trackUnavailable}
unavailableTooltip={trackUnavailable ? $t('player.trackUnavailable') : undefined}
selectable={multiSelectMode}
selected={multiSelectedIds.has(track.id)}
dragTrackIds={multiSelectMode && multiSelectedIds.has(track.id) ? [...multiSelectedIds] : undefined}
@@ -15,6 +15,10 @@
import { showToast } from '$lib/stores/toastStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
import {
isTrackRemovedFromQobuz,
subscribe as subscribeUnavailable
} from '$lib/stores/unavailableTracksStore';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
import type { OfflineCacheStatus } from '$lib/stores/offlineCacheState';
@@ -475,16 +479,28 @@
return `${mins} ` + $t('time.minutes');
});
// Unavailable-tracks reactivity
let unavailableVersion = $state(0);
let unsubscribeUnavailable: (() => void) | null = null;
function checkTrackUnavailable(track: { id: number; streamable?: boolean }): boolean {
void unavailableVersion;
return isTrackRemovedFromQobuz(track);
}
onMount(() => {
if (autoRunDone) return;
autoRunDone = true;
void generateDailyQ('read-write');
document.addEventListener('click', handleGlobalClick);
unsubscribeUnavailable = subscribeUnavailable(() => {
unavailableVersion++;
});
});
onDestroy(() => {
dismissAlgoTooltip();
document.removeEventListener('click', handleGlobalClick);
unsubscribeUnavailable?.();
});
</script>
@@ -598,6 +614,7 @@
{#each filteredTracks as track, index}
{@const trackBlacklisted = track.performer?.id ? isArtistBlacklisted(track.performer.id) : false}
{@const trackUnavailable = checkTrackUnavailable(track)}
{@const cacheStatus = getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 }}
{@const isTrackDownloaded = cacheStatus.status === 'ready'}
{@const displayTrack = toDisplayTrack(track)}
@@ -614,6 +631,8 @@
isPlaying={isPlaybackActive && activeTrackId === track.id}
isActiveTrack={activeTrackId === track.id}
isBlacklisted={trackBlacklisted}
isUnavailable={trackUnavailable}
unavailableTooltip={trackUnavailable ? $t('player.trackUnavailable') : undefined}
selectable={multiSelectMode}
selected={multiSelectedIds.has(track.id)}
dragTrackIds={multiSelectMode && multiSelectedIds.has(track.id) ? [...multiSelectedIds] : undefined}
+11
View File
@@ -85,6 +85,17 @@ export function getUnavailableTrackIds(): Set<number> {
return new Set(unavailableTrackIds);
}
/**
* Combined check: a track counts as removed-from-Qobuz when either the API
* marks it unstreamable or playback hit a TrackUnavailable error this session
* (cached in the store). Consumer views pass the result to TrackRow's
* `isUnavailable` prop. Subscribe to the store separately for reactivity.
*/
export function isTrackRemovedFromQobuz(track: { id: number; streamable?: boolean }): boolean {
if (track.streamable === false) return true;
return isTrackUnavailable(track.id);
}
/**
* Subscribe to changes in unavailable tracks
*/