feat(player): disable metadata actions for ephemeral tracks

Library-bound actions (favorite, add-to-playlist, track info) require a
DB row to operate; ephemeral tracks live only in memory and would 404
on use. Surface them disabled rather than removed so the affordance
stays discoverable in the chrome — users still see the icon, the
tooltip explains why it's inert.

Surfaces wired
- NowPlayingBar: + and ♥ disabled when active track id >= 2^48.
- QueuePanel: now-playing ♥ + per-track Add to Playlist + Track Info
  in both queue and history menus disabled per-row by id range.
- ImmersiveControls + PlayerControlsCompact: ♥ disabled.
- ImmersivePlayer: passthrough prop to PlayerControlsCompact.
- MiniPlayerFooter / MiniPlayerWindowControls: no metadata actions in
  these surfaces, nothing to gate.

Detection is by id range (>= EPHEMERAL_ID_FLOOR), not by source string.
The setQueueForLocalTracks mapping rewrites `source: 'ephemeral'` to
`'local'` before the track reaches the queue, so a string-equality
check would never fire — id range matches the rest of the ephemeral
codebase (gapless carve-out, CUE boundary, seek offset, queue/now-
playing wipe).

Tooltip on the disabled state uses the new
`actions.unavailableForEphemeral` key, translated for en, es, de, fr, pt.

cc
This commit is contained in:
vicrodh committed 2026-05-09 09:24:37 -06:00
1 parent 0fe19dd460
commit 2d33d7cb42
11 files changed
+112 -29

No files matched your search

+21 -10
View File
@@ -95,6 +95,14 @@
normalizationGain?: number | null; normalizationGain?: number | null;
onToggleNormalization?: () => void; onToggleNormalization?: () => void;
controlsDisabled?: boolean; controlsDisabled?: boolean;
/**
* Hide the favorite + add-to-playlist buttons when the active track
* can't be persisted to library structures (today: ephemeral folder
* playback). Playback controls stay enabled — the user can still
* play / pause / skip / seek / volume; only the metadata-bound
* actions disappear.
*/
metadataActionsDisabled?: boolean;
explicit?: boolean; explicit?: boolean;
qconnectSessionSnapshot?: QconnectSessionSnapshot | null; qconnectSessionSnapshot?: QconnectSessionSnapshot | null;
onToggleQconnectConnection?: () => void | Promise<void>; onToggleQconnectConnection?: () => void | Promise<void>;
@@ -150,6 +158,7 @@
normalizationGain = null, normalizationGain = null,
onToggleNormalization, onToggleNormalization,
controlsDisabled = false, controlsDisabled = false,
metadataActionsDisabled = false,
explicit = false, explicit = false,
qconnectSessionSnapshot = null, qconnectSessionSnapshot = null,
onToggleQconnectConnection, onToggleQconnectConnection,
@@ -491,23 +500,25 @@
<button <button
class="control-btn" class="control-btn"
class:disabled={controlsDisabled} class:disabled={controlsDisabled || metadataActionsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled || metadataActionsDisabled}
onclick={controlsDisabled ? undefined : onAddToPlaylist} onclick={controlsDisabled || metadataActionsDisabled ? undefined : onAddToPlaylist}
title={$translateStore('actions.addToPlaylist')} title={metadataActionsDisabled ? $translateStore('actions.unavailableForEphemeral') : $translateStore('actions.addToPlaylist')}
> >
<Plus size={16} /> <Plus size={16} />
</button> </button>
<button <button
class="control-btn" class="control-btn"
class:active={isFavorite && !controlsDisabled} class:active={isFavorite && !controlsDisabled && !metadataActionsDisabled}
class:disabled={controlsDisabled} class:disabled={controlsDisabled || metadataActionsDisabled}
disabled={controlsDisabled} disabled={controlsDisabled || metadataActionsDisabled}
onclick={controlsDisabled ? undefined : onToggleFavorite} onclick={controlsDisabled || metadataActionsDisabled ? undefined : onToggleFavorite}
title={isFavorite ? $translateStore('actions.removeFromFavorites') : $translateStore('actions.addToFavorites')} title={metadataActionsDisabled
? $translateStore('actions.unavailableForEphemeral')
: (isFavorite ? $translateStore('actions.removeFromFavorites') : $translateStore('actions.addToFavorites'))}
> >
<Heart size={16} fill={isFavorite ? 'currentColor' : 'none'} /> <Heart size={16} fill={isFavorite && !metadataActionsDisabled ? 'currentColor' : 'none'} />
</button> </button>
</div> </div>
+48 -8
View File
@@ -29,6 +29,18 @@
parental_warning?: boolean; parental_warning?: boolean;
} }
// Mirrors EPHEMERAL_ID_FLOOR (1 << 48) in src-tauri/src/ephemeral_library.
// Track ids at or above this threshold come from the in-memory ephemeral
// cache, not the library DB. Favorites / playlists / track-info actions
// require a DB row and don't make sense for ephemeral tracks; the
// helper below gates the corresponding UI off so users don't get
// dead-end clicks.
const EPHEMERAL_ID_FLOOR = 1 << 48;
function isEphemeralQueueTrack(id: string | number | null | undefined): boolean {
if (id == null) return false;
return Number(id) >= EPHEMERAL_ID_FLOOR;
}
interface IndexedQueueTrack { interface IndexedQueueTrack {
track: QueueTrack; track: QueueTrack;
originalIndex: number; originalIndex: number;
@@ -349,11 +361,15 @@
</div> </div>
<button <button
class="np-favorite" class="np-favorite"
class:active={currentTrackFavorite} class:active={currentTrackFavorite && !isEphemeralQueueTrack(currentTrack.id)}
onclick={toggleCurrentTrackFavorite} class:disabled={isEphemeralQueueTrack(currentTrack.id)}
title={currentTrackFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')} disabled={isEphemeralQueueTrack(currentTrack.id)}
onclick={isEphemeralQueueTrack(currentTrack.id) ? undefined : toggleCurrentTrackFavorite}
title={isEphemeralQueueTrack(currentTrack.id)
? $t('actions.unavailableForEphemeral')
: (currentTrackFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites'))}
> >
<Heart size={18} fill={currentTrackFavorite ? 'currentColor' : 'none'} /> <Heart size={18} fill={currentTrackFavorite && !isEphemeralQueueTrack(currentTrack.id) ? 'currentColor' : 'none'} />
</button> </button>
</div> </div>
</div> </div>
@@ -438,11 +454,23 @@
</button> </button>
{/if} {/if}
<button class="menu-item" onclick={(e) => handleAddToPlaylist(e, queueTrack.id)}> <button
class="menu-item"
class:disabled={isEphemeralQueueTrack(queueTrack.id)}
disabled={isEphemeralQueueTrack(queueTrack.id)}
onclick={(e) => { if (!isEphemeralQueueTrack(queueTrack.id)) handleAddToPlaylist(e, queueTrack.id); }}
title={isEphemeralQueueTrack(queueTrack.id) ? $t('actions.unavailableForEphemeral') : ''}
>
<ListPlus size={14} /> <ListPlus size={14} />
<span>{$t('actions.addToPlaylist')}</span> <span>{$t('actions.addToPlaylist')}</span>
</button> </button>
<button class="menu-item" onclick={(e) => handleShowTrackInfo(e, queueTrack.id)}> <button
class="menu-item"
class:disabled={isEphemeralQueueTrack(queueTrack.id)}
disabled={isEphemeralQueueTrack(queueTrack.id)}
onclick={(e) => { if (!isEphemeralQueueTrack(queueTrack.id)) handleShowTrackInfo(e, queueTrack.id); }}
title={isEphemeralQueueTrack(queueTrack.id) ? $t('actions.unavailableForEphemeral') : ''}
>
<Info size={14} /> <Info size={14} />
<span>{$t('player.trackInfo')}</span> <span>{$t('player.trackInfo')}</span>
</button> </button>
@@ -497,11 +525,23 @@
</button> </button>
{#if openHistoryMenuId === track.id} {#if openHistoryMenuId === track.id}
<div class="track-context-menu"> <div class="track-context-menu">
<button class="menu-item" onclick={(e) => handleAddToPlaylist(e, track.id)}> <button
class="menu-item"
class:disabled={isEphemeralQueueTrack(track.id)}
disabled={isEphemeralQueueTrack(track.id)}
onclick={(e) => { if (!isEphemeralQueueTrack(track.id)) handleAddToPlaylist(e, track.id); }}
title={isEphemeralQueueTrack(track.id) ? $t('actions.unavailableForEphemeral') : ''}
>
<ListPlus size={14} /> <ListPlus size={14} />
<span>{$t('actions.addToPlaylist')}</span> <span>{$t('actions.addToPlaylist')}</span>
</button> </button>
<button class="menu-item" onclick={(e) => handleShowTrackInfo(e, track.id)}> <button
class="menu-item"
class:disabled={isEphemeralQueueTrack(track.id)}
disabled={isEphemeralQueueTrack(track.id)}
onclick={(e) => { if (!isEphemeralQueueTrack(track.id)) handleShowTrackInfo(e, track.id); }}
title={isEphemeralQueueTrack(track.id) ? $t('actions.unavailableForEphemeral') : ''}
>
<Info size={14} /> <Info size={14} />
<span>{$t('player.trackInfo')}</span> <span>{$t('player.trackInfo')}</span>
</button> </button>
@@ -30,6 +30,11 @@
onToggleFavorite: () => void; onToggleFavorite: () => void;
onVolumeChange: (volume: number) => void; onVolumeChange: (volume: number) => void;
volumeLocked?: boolean; volumeLocked?: boolean;
/** Disable the favorite button when the active track lives outside
* the library (today: ephemeral folder playback). Heart stays visible
* but inert so the user understands the action exists; it just isn't
* meaningful for transient tracks. */
metadataActionsDisabled?: boolean;
} }
let { let {
@@ -52,7 +57,8 @@
onToggleRepeat, onToggleRepeat,
onToggleFavorite, onToggleFavorite,
onVolumeChange, onVolumeChange,
volumeLocked = false volumeLocked = false,
metadataActionsDisabled = false
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -121,13 +127,17 @@
<div class="right-section"> <div class="right-section">
<button <button
class="action-btn" class="action-btn"
class:active={isFavorite} class:active={isFavorite && !metadataActionsDisabled}
onclick={onToggleFavorite} class:disabled={metadataActionsDisabled}
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')} disabled={metadataActionsDisabled}
onclick={metadataActionsDisabled ? undefined : onToggleFavorite}
title={metadataActionsDisabled
? $t('actions.unavailableForEphemeral')
: (isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites'))}
> >
<Heart <Heart
size={18} size={18}
fill={isFavorite ? 'currentColor' : 'none'} fill={isFavorite && !metadataActionsDisabled ? 'currentColor' : 'none'}
/> />
</button> </button>
@@ -100,6 +100,10 @@
onPlayHistoryTrack?: (trackId: string) => void; onPlayHistoryTrack?: (trackId: string) => void;
// Content flags // Content flags
explicit?: boolean; explicit?: boolean;
/** Disable metadata-bound actions (favorite, etc) when the active
* track lives outside the library. Passed down to ImmersiveControls
* and PlayerControlsCompact. */
metadataActionsDisabled?: boolean;
} }
let { let {
@@ -150,7 +154,8 @@
onQueueClear, onQueueClear,
historyTracks = [], historyTracks = [],
onPlayHistoryTrack, onPlayHistoryTrack,
explicit = false explicit = false,
metadataActionsDisabled = false
}: Props = $props(); }: Props = $props();
// UI State // UI State
@@ -778,6 +783,7 @@
{onToggleShuffle} {onToggleShuffle}
{onToggleRepeat} {onToggleRepeat}
{onToggleFavorite} {onToggleFavorite}
{metadataActionsDisabled}
{isInfinitePlay} {isInfinitePlay}
{onToggleInfinitePlay} {onToggleInfinitePlay}
{onVolumeChange} {onVolumeChange}
@@ -48,6 +48,10 @@
onMinimize?: () => void; onMinimize?: () => void;
onToggleFullscreen?: () => void; onToggleFullscreen?: () => void;
onToggleMaximize?: () => void; onToggleMaximize?: () => void;
/** Disable favorite when active track lives outside the library
* (today: ephemeral folder playback). Heart stays visible but
* inert. */
metadataActionsDisabled?: boolean;
} }
let { let {
@@ -76,7 +80,8 @@
onClose, onClose,
onMinimize, onMinimize,
onToggleFullscreen, onToggleFullscreen,
onToggleMaximize onToggleMaximize,
metadataActionsDisabled = false
}: Props = $props(); }: Props = $props();
let progressRef: HTMLDivElement | null = $state(null); let progressRef: HTMLDivElement | null = $state(null);
@@ -220,11 +225,15 @@
<div class="controls-group left"> <div class="controls-group left">
<button <button
class="control-btn" class="control-btn"
class:active={isFavorite} class:active={isFavorite && !metadataActionsDisabled}
onclick={onToggleFavorite} class:disabled={metadataActionsDisabled}
title={isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites')} disabled={metadataActionsDisabled}
onclick={metadataActionsDisabled ? undefined : onToggleFavorite}
title={metadataActionsDisabled
? $t('actions.unavailableForEphemeral')
: (isFavorite ? $t('actions.removeFromFavorites') : $t('actions.addToFavorites'))}
> >
<Heart size={12} fill={isFavorite ? 'currentColor' : 'none'} /> <Heart size={12} fill={isFavorite && !metadataActionsDisabled ? 'currentColor' : 'none'} />
</button> </button>
<button <button
class="control-btn" class="control-btn"
+1
View File
@@ -39,6 +39,7 @@
"addToPlaylist": "Zur Playlist hinzufügen", "addToPlaylist": "Zur Playlist hinzufügen",
"addToFavorites": "Zur Bibliothek hinzufügen", "addToFavorites": "Zur Bibliothek hinzufügen",
"removeFromFavorites": "Aus Bibliothek entfernen", "removeFromFavorites": "Aus Bibliothek entfernen",
"unavailableForEphemeral": "Nicht verfügbar für temporäre Ordner",
"removeFromPlaylist": "Aus Playlist entfernen", "removeFromPlaylist": "Aus Playlist entfernen",
"selectedTracks": "{count, plural, one {# Titel ausgewählt} other {# Titel ausgewählt}}", "selectedTracks": "{count, plural, one {# Titel ausgewählt} other {# Titel ausgewählt}}",
"select": "Auswählen", "select": "Auswählen",
+1
View File
@@ -40,6 +40,7 @@
"addToPlaylist": "Add to Playlist", "addToPlaylist": "Add to Playlist",
"addToFavorites": "Add to Library", "addToFavorites": "Add to Library",
"removeFromFavorites": "Remove from Library", "removeFromFavorites": "Remove from Library",
"unavailableForEphemeral": "Not available for ephemeral folders",
"removeFromPlaylist": "Remove from Playlist", "removeFromPlaylist": "Remove from Playlist",
"selectedTracks": "{count, plural, one {# track selected} other {# tracks selected}}", "selectedTracks": "{count, plural, one {# track selected} other {# tracks selected}}",
"select": "Select", "select": "Select",
+1
View File
@@ -39,6 +39,7 @@
"addToPlaylist": "Agregar a Lista", "addToPlaylist": "Agregar a Lista",
"addToFavorites": "Agregar a Biblioteca", "addToFavorites": "Agregar a Biblioteca",
"removeFromFavorites": "Quitar de Biblioteca", "removeFromFavorites": "Quitar de Biblioteca",
"unavailableForEphemeral": "No disponible para carpetas efímeras",
"removeFromPlaylist": "Quitar del Playlist", "removeFromPlaylist": "Quitar del Playlist",
"selectedTracks": "{count, plural, one {# canción seleccionada} other {# canciones seleccionadas}}", "selectedTracks": "{count, plural, one {# canción seleccionada} other {# canciones seleccionadas}}",
"select": "Seleccionar", "select": "Seleccionar",
+1
View File
@@ -39,6 +39,7 @@
"addToPlaylist": "Ajouter à la playlist", "addToPlaylist": "Ajouter à la playlist",
"addToFavorites": "Ajouter à la bibliothèque", "addToFavorites": "Ajouter à la bibliothèque",
"removeFromFavorites": "Retirer de la bibliothèque", "removeFromFavorites": "Retirer de la bibliothèque",
"unavailableForEphemeral": "Non disponible pour les dossiers éphémères",
"removeFromPlaylist": "Retirer de la playlist", "removeFromPlaylist": "Retirer de la playlist",
"selectedTracks": "{count, plural, one {# titre sélectionné} other {# titres sélectionnés}}", "selectedTracks": "{count, plural, one {# titre sélectionné} other {# titres sélectionnés}}",
"select": "Sélectionner", "select": "Sélectionner",
+1
View File
@@ -39,6 +39,7 @@
"addToPlaylist": "Adicionar à Playlist", "addToPlaylist": "Adicionar à Playlist",
"addToFavorites": "Adicionar à biblioteca", "addToFavorites": "Adicionar à biblioteca",
"removeFromFavorites": "Remover da biblioteca", "removeFromFavorites": "Remover da biblioteca",
"unavailableForEphemeral": "Indisponível para pastas efêmeras",
"removeFromPlaylist": "Remover da Playlist", "removeFromPlaylist": "Remover da Playlist",
"selectedTracks": "{count, plural, one {# faixa selecionada} other {# faixas selecionadas}}", "selectedTracks": "{count, plural, one {# faixa selecionada} other {# faixas selecionadas}}",
"select": "Selecionar", "select": "Selecionar",
+2
View File
@@ -6727,6 +6727,7 @@
{isFavorite} {isFavorite}
onToggleFavorite={toggleFavorite} onToggleFavorite={toggleFavorite}
onAddToPlaylist={openAddToPlaylistModal} onAddToPlaylist={openAddToPlaylistModal}
metadataActionsDisabled={currentTrack != null && currentTrack.id >= (1 << 48)}
onOpenQueue={toggleQueue} onOpenQueue={toggleQueue}
onOpenMiniPlayer={() => { onOpenMiniPlayer={() => {
void enterMiniplayerMode(); void enterMiniplayerMode();
@@ -6832,6 +6833,7 @@
onToggleRepeat={toggleRepeat} onToggleRepeat={toggleRepeat}
{isFavorite} {isFavorite}
onToggleFavorite={toggleFavorite} onToggleFavorite={toggleFavorite}
metadataActionsDisabled={currentTrack != null && currentTrack.id >= (1 << 48)}
lyricsLines={lyricsLines} lyricsLines={lyricsLines}
lyricsActiveIndex={lyricsActiveIndex} lyricsActiveIndex={lyricsActiveIndex}
lyricsActiveProgress={lyricsActiveProgress} lyricsActiveProgress={lyricsActiveProgress}