feat(tracks): add share and queue actions menu

This commit is contained in:
vicrodh committed 2026-01-09 17:15:55 -06:00
1 parent cf584aa1b2
commit e9151b6c55
13 files changed
+852 -56

No files matched your search

+1
View File
@@ -116,6 +116,7 @@ pub struct Track {
pub id: u64,
#[serde(default)]
pub title: String,
pub isrc: Option<String>,
#[serde(default)]
pub duration: u32,
#[serde(default)]
+8
View File
@@ -13,6 +13,14 @@ pub fn add_to_queue(track: QueueTrack, state: State<'_, AppState>) -> Result<(),
Ok(())
}
/// Add a track to play next
#[tauri::command]
pub fn add_to_queue_next(track: QueueTrack, state: State<'_, AppState>) -> Result<(), String> {
log::info!("Command: add_to_queue_next - {} by {}", track.title, track.artist);
state.queue.add_track_next(track);
Ok(())
}
/// Add multiple tracks to the queue
#[tauri::command]
pub fn add_tracks_to_queue(tracks: Vec<QueueTrack>, state: State<'_, AppState>) -> Result<(), String> {
+1
View File
@@ -130,6 +130,7 @@ pub fn run() {
commands::get_audio_devices,
// Queue commands
commands::add_to_queue,
commands::add_to_queue_next,
commands::add_tracks_to_queue,
commands::set_queue,
commands::clear_queue,
+33
View File
@@ -115,6 +115,39 @@ impl QueueManager {
}
}
/// Add a track to play next (after current index if set)
pub fn add_track_next(&self, track: QueueTrack) {
let mut state = self.state.lock().unwrap();
let insert_index = state.current_index.map(|idx| idx + 1).unwrap_or(state.tracks.len());
if insert_index >= state.tracks.len() {
state.tracks.push(track);
} else {
state.tracks.insert(insert_index, track);
}
if state.shuffle {
for idx in state.shuffle_order.iter_mut() {
if *idx >= insert_index {
*idx += 1;
}
}
let new_idx = insert_index;
let next_pos = if state.current_index.is_some() {
state.shuffle_position + 1
} else {
state.shuffle_order.len()
};
if next_pos >= state.shuffle_order.len() {
state.shuffle_order.push(new_idx);
} else {
state.shuffle_order.insert(next_pos, new_idx);
}
}
}
/// Set the entire queue (replaces existing)
pub fn set_queue(&self, new_tracks: Vec<QueueTrack>, start_index: Option<usize>) {
let mut state = self.state.lock().unwrap();
+233
View File
@@ -0,0 +1,233 @@
<script lang="ts">
import { ChevronRight, MoreHorizontal } from 'lucide-svelte';
interface Props {
onPlayNow?: () => void;
onPlayNext?: () => void;
onPlayLater?: () => void;
onAddFavorite?: () => void;
onAddToPlaylist?: () => void;
onShareQobuz?: () => void;
onShareSonglink?: () => void;
onGoToAlbum?: () => void;
onGoToArtist?: () => void;
}
let {
onPlayNow,
onPlayNext,
onPlayLater,
onAddFavorite,
onAddToPlaylist,
onShareQobuz,
onShareSonglink,
onGoToAlbum,
onGoToArtist
}: Props = $props();
let isOpen = $state(false);
let shareOpen = $state(false);
let menuRef: HTMLDivElement | null = null;
const hasPlayback = $derived(!!(onPlayNow || onPlayNext || onPlayLater));
const hasLibrary = $derived(!!(onAddFavorite || onAddToPlaylist));
const hasShare = $derived(!!(onShareQobuz || onShareSonglink));
const hasNav = $derived(!!(onGoToAlbum || onGoToArtist));
const hasMenu = $derived(hasPlayback || hasLibrary || hasShare || hasNav);
function closeMenu() {
isOpen = false;
shareOpen = false;
}
function handleClickOutside(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) {
closeMenu();
}
}
function handleAction(action?: () => void) {
if (!action) return;
action();
closeMenu();
}
$effect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
});
</script>
{#if hasMenu}
<div
class="track-menu"
bind:this={menuRef}
onmousedown={(e) => e.stopPropagation()}
onclick={(e) => e.stopPropagation()}
>
<button
class="menu-trigger"
onclick={(e) => {
e.stopPropagation();
isOpen = !isOpen;
shareOpen = false;
}}
aria-label="Track actions"
>
<MoreHorizontal size={18} />
</button>
{#if isOpen}
<div class="menu">
{#if hasPlayback}
{#if onPlayNow}
<button class="menu-item" onclick={() => handleAction(onPlayNow)}>Play now</button>
{/if}
{#if onPlayNext}
<button class="menu-item" onclick={() => handleAction(onPlayNext)}>Play next</button>
{/if}
{#if onPlayLater}
<button class="menu-item" onclick={() => handleAction(onPlayLater)}>Play later</button>
{/if}
{/if}
{#if hasPlayback && (hasLibrary || hasShare || hasNav)}
<div class="separator"></div>
{/if}
{#if hasLibrary}
{#if onAddFavorite}
<button class="menu-item" onclick={() => handleAction(onAddFavorite)}>Add to favorites</button>
{/if}
{#if onAddToPlaylist}
<button class="menu-item" onclick={() => handleAction(onAddToPlaylist)}>Add to playlist</button>
{/if}
{/if}
{#if hasLibrary && (hasShare || hasNav)}
<div class="separator"></div>
{/if}
{#if hasShare}
<div
class="menu-item submenu-trigger"
onmouseenter={() => (shareOpen = true)}
onmouseleave={() => (shareOpen = false)}
onclick={() => (shareOpen = !shareOpen)}
>
<span>Share</span>
<ChevronRight size={14} />
{#if shareOpen}
<div class="submenu">
{#if onShareQobuz}
<button class="menu-item" onclick={() => handleAction(onShareQobuz)}>Share Qobuz link</button>
{/if}
{#if onShareSonglink}
<button class="menu-item" onclick={() => handleAction(onShareSonglink)}>Share Song.link</button>
{/if}
</div>
{/if}
</div>
{/if}
{#if hasShare && hasNav}
<div class="separator"></div>
{/if}
{#if hasNav}
{#if onGoToAlbum}
<button class="menu-item" onclick={() => handleAction(onGoToAlbum)}>Go to album</button>
{/if}
{#if onGoToArtist}
<button class="menu-item" onclick={() => handleAction(onGoToArtist)}>Go to artist</button>
{/if}
{/if}
</div>
{/if}
</div>
{/if}
<style>
.track-menu {
position: relative;
display: inline-flex;
align-items: center;
}
.menu-trigger {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: background-color 150ms ease, color 150ms ease;
}
.menu-trigger:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 200px;
background-color: var(--bg-tertiary);
border-radius: 10px;
padding: 6px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 60;
}
.menu-item {
width: 100%;
padding: 10px 16px;
background: none;
border: none;
color: var(--text-secondary);
text-align: left;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
transition: background-color 150ms ease, color 150ms ease;
}
.menu-item:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.separator {
height: 1px;
background-color: var(--bg-hover);
margin: 6px 0;
}
.submenu-trigger {
position: relative;
}
.submenu {
position: absolute;
left: 100%;
top: -6px;
margin-left: 6px;
min-width: 200px;
background-color: var(--bg-tertiary);
border-radius: 10px;
padding: 6px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 70;
}
</style>
+35 -38
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Play, Plus, ListPlus, MoreHorizontal } from 'lucide-svelte';
import { Play } from 'lucide-svelte';
import TrackMenu from './TrackMenu.svelte';
interface Props {
number: number;
@@ -9,8 +10,19 @@
quality?: string;
isPlaying?: boolean;
onPlay?: () => void;
menuActions?: TrackMenuActions;
}
interface TrackMenuActions {
onPlayNow?: () => void;
onPlayNext?: () => void;
onPlayLater?: () => void;
onAddFavorite?: () => void;
onAddToPlaylist?: () => void;
onAddToQueue?: () => void;
onShareQobuz?: () => void;
onShareSonglink?: () => void;
onGoToAlbum?: () => void;
onGoToArtist?: () => void;
}
let {
@@ -21,11 +33,11 @@
quality,
isPlaying = false,
onPlay,
onAddToPlaylist,
onAddToQueue
menuActions
}: Props = $props();
let isHovered = $state(false);
const playNowAction = $derived(menuActions?.onPlayNow ?? onPlay);
</script>
<div
@@ -70,29 +82,19 @@
<div class="track-quality">{quality}</div>
{/if}
<!-- Hover Actions -->
{#if isHovered && !isPlaying}
<div class="track-actions">
{#if onAddToQueue}
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); onAddToQueue(); }}
title="Add to queue"
>
<Plus size={18} />
</button>
{/if}
{#if onAddToPlaylist}
<button
class="action-btn"
onclick={(e) => { e.stopPropagation(); onAddToPlaylist(); }}
title="Add to playlist"
>
<ListPlus size={18} />
</button>
{/if}
</div>
{/if}
<div class="track-actions">
<TrackMenu
onPlayNow={playNowAction}
onPlayNext={menuActions?.onPlayNext}
onPlayLater={menuActions?.onPlayLater}
onAddFavorite={menuActions?.onAddFavorite}
onAddToPlaylist={menuActions?.onAddToPlaylist}
onShareQobuz={menuActions?.onShareQobuz}
onShareSonglink={menuActions?.onShareSonglink}
onGoToAlbum={menuActions?.onGoToAlbum}
onGoToArtist={menuActions?.onGoToArtist}
/>
</div>
</div>
<style>
@@ -213,20 +215,15 @@
}
.track-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
opacity: 0.7;
transition: opacity 150ms ease;
}
.action-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: color 150ms ease;
}
.action-btn:hover {
color: var(--text-primary);
.track-row:hover .track-actions,
.track-row.playing .track-actions {
opacity: 1;
}
</style>
@@ -14,6 +14,7 @@
hires?: boolean;
bitDepth?: number;
samplingRate?: number;
isrc?: string;
}
interface Props {
@@ -22,6 +23,7 @@
artwork: string;
title: string;
artist: string;
artistId?: number;
year: string;
label: string;
genre: string;
@@ -33,13 +35,36 @@
onBack: () => void;
onArtistClick?: () => void;
onTrackPlay?: (track: Track) => void;
onTrackPlayNext?: (track: Track) => void;
onTrackPlayLater?: (track: Track) => void;
onTrackAddFavorite?: (trackId: number) => void;
onTrackShareQobuz?: (trackId: number) => void;
onTrackShareSonglink?: (track: Track) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
onPlayAll?: () => void;
onShuffleAll?: () => void;
onAddToQueue?: () => void;
onAddTrackToPlaylist?: (trackId: number) => void;
}
let { album, onBack, onArtistClick, onTrackPlay, onPlayAll, onShuffleAll, onAddToQueue, onAddTrackToPlaylist }: Props = $props();
let {
album,
onBack,
onArtistClick,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist,
onPlayAll,
onShuffleAll,
onAddToQueue,
onAddTrackToPlaylist
}: Props = $props();
let currentTrack = $state<number | null>(null);
let isFavorite = $state(false);
@@ -150,7 +175,20 @@
currentTrack = track.id;
onTrackPlay?.(track);
}}
onAddToPlaylist={onAddTrackToPlaylist ? () => onAddTrackToPlaylist(track.id) : undefined}
menuActions={{
onPlayNow: () => {
currentTrack = track.id;
onTrackPlay?.(track);
},
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined,
onAddFavorite: onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined,
onAddToPlaylist: onAddTrackToPlaylist ? () => onAddTrackToPlaylist(track.id) : undefined,
onShareQobuz: onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined,
onShareSonglink: onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined,
onGoToAlbum: onTrackGoToAlbum ? () => onTrackGoToAlbum(album.id) : undefined,
onGoToArtist: album.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(album.artistId) : undefined
}}
/>
{/each}
</div>
@@ -1,8 +1,9 @@
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Plus, Music } from 'lucide-svelte';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import TrackMenu from '../TrackMenu.svelte';
interface Album {
id: string;
@@ -27,10 +28,11 @@
title: string;
image?: { small?: string; thumbnail?: string; large?: string };
};
performer?: { name: string };
performer?: { id?: number; name: string };
hires_streamable?: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
isrc?: string;
}
interface SearchResults {
@@ -64,9 +66,32 @@
onLoadMore?: () => void;
isLoadingMore?: boolean;
onTrackPlay?: (track: DisplayTrack) => void;
onTrackPlayNext?: (track: Track) => void;
onTrackPlayLater?: (track: Track) => void;
onTrackAddFavorite?: (trackId: number) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onTrackShareQobuz?: (trackId: number) => void;
onTrackShareSonglink?: (track: Track) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
}
let { artist, onBack, onAlbumClick, onLoadMore, isLoadingMore = false, onTrackPlay }: Props = $props();
let {
artist,
onBack,
onAlbumClick,
onLoadMore,
isLoadingMore = false,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackAddToPlaylist,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist
}: Props = $props();
let bioExpanded = $state(false);
let imageError = $state(false);
@@ -233,7 +258,13 @@
{:else}
<div class="tracks-list">
{#each topTracks as track, index}
<button class="track-row" onclick={() => handleTrackPlay(track)}>
<div
class="track-row"
role="button"
tabindex="0"
onclick={() => handleTrackPlay(track)}
onkeydown={(e) => e.key === 'Enter' && handleTrackPlay(track)}
>
<div class="track-number">{index + 1}</div>
<div class="track-artwork">
{#if track.album?.image?.thumbnail || track.album?.image?.small}
@@ -249,7 +280,20 @@
<div class="track-album">{track.album?.title || ''}</div>
</div>
<div class="track-duration">{formatDuration(track.duration)}</div>
</button>
<div class="track-actions">
<TrackMenu
onPlayNow={() => handleTrackPlay(track)}
onPlayNext={onTrackPlayNext ? () => onTrackPlayNext(track) : undefined}
onPlayLater={onTrackPlayLater ? () => onTrackPlayLater(track) : undefined}
onAddFavorite={onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined}
onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined}
onShareQobuz={onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined}
onShareSonglink={onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined}
onGoToAlbum={track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album.id) : undefined}
onGoToArtist={(track.performer?.id || artist.id) && onTrackGoToArtist ? () => onTrackGoToArtist(track.performer?.id || artist.id) : undefined}
/>
</div>
</div>
{/each}
</div>
{/if}
@@ -573,6 +617,18 @@
font-family: var(--font-mono);
}
.track-actions {
display: flex;
align-items: center;
margin-left: 8px;
opacity: 0.7;
transition: opacity 150ms ease;
}
.track-row:hover .track-actions {
opacity: 1;
}
/* Responsive */
@media (max-width: 768px) {
.artist-header {
+85 -2
View File
@@ -19,11 +19,12 @@
title: string;
duration: number;
track_number: number;
performer?: { name: string };
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;
isrc?: string;
}
interface FavoriteArtist {
@@ -37,6 +38,14 @@
onAlbumClick?: (albumId: string) => void;
onTrackPlay?: (track: DisplayTrack) => void;
onArtistClick?: (artistId: number) => void;
onTrackPlayNext?: (track: DisplayTrack) => void;
onTrackPlayLater?: (track: DisplayTrack) => void;
onTrackAddFavorite?: (trackId: number) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onTrackShareQobuz?: (trackId: number) => void;
onTrackShareSonglink?: (track: DisplayTrack) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
}
interface DisplayTrack {
@@ -46,14 +55,29 @@
artist?: string;
album?: string;
albumArt?: string;
albumId?: string;
artistId?: number;
duration: string;
durationSeconds: number;
hires?: boolean;
bitDepth?: number;
samplingRate?: number;
isrc?: string;
}
let { onAlbumClick, onTrackPlay, onArtistClick }: Props = $props();
let {
onAlbumClick,
onTrackPlay,
onArtistClick,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackAddToPlaylist,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist
}: Props = $props();
type TabType = 'tracks' | 'albums' | 'artists';
let activeTab = $state<TabType>('tracks');
@@ -114,11 +138,14 @@
artist: track.performer?.name,
album: track.album?.title,
albumArt: track.album?.image?.thumbnail || track.album?.image?.small,
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,
isrc: track.isrc,
});
}
}
@@ -247,6 +274,62 @@
duration={formatDuration(track.duration)}
quality={track.hires ? 'Hi-Res' : undefined}
onPlay={() => handleTrackClick(track, index)}
menuActions={{
onPlayNow: () => handleTrackClick(track, index),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext({
id: track.id,
number: index + 1,
title: track.title,
artist: track.performer?.name,
album: track.album?.title,
albumArt: track.album?.image?.thumbnail || track.album?.image?.small,
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,
isrc: track.isrc
}) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater({
id: track.id,
number: index + 1,
title: track.title,
artist: track.performer?.name,
album: track.album?.title,
albumArt: track.album?.image?.thumbnail || track.album?.image?.small,
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,
isrc: track.isrc
}) : undefined,
onAddFavorite: onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined,
onAddToPlaylist: onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined,
onShareQobuz: onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined,
onShareSonglink: onTrackShareSonglink ? () => onTrackShareSonglink({
id: track.id,
number: index + 1,
title: track.title,
artist: track.performer?.name,
album: track.album?.title,
albumArt: track.album?.image?.thumbnail || track.album?.image?.small,
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,
isrc: track.isrc
}) : undefined,
onGoToAlbum: track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album.id) : undefined,
onGoToArtist: track.performer?.id && onTrackGoToArtist ? () => onTrackGoToArtist(track.performer.id) : undefined
}}
/>
{/each}
</div>
@@ -74,10 +74,18 @@
interface Props {
onAlbumClick?: (album: LocalAlbum) => void;
onTrackPlay?: (track: LocalTrack) => void;
onTrackPlayNext?: (track: LocalTrack) => void;
onTrackPlayLater?: (track: LocalTrack) => void;
onSetLocalQueue?: (trackIds: number[]) => void;
}
let { onAlbumClick, onTrackPlay, onSetLocalQueue }: Props = $props();
let {
onAlbumClick,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onSetLocalQueue
}: Props = $props();
// View state
type TabType = 'albums' | 'artists' | 'tracks';
@@ -453,6 +461,11 @@
duration={formatDuration(track.duration_secs)}
quality={getQualityBadge(track)}
onPlay={() => handleTrackPlay(track)}
menuActions={{
onPlayNow: () => handleTrackPlay(track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined
}}
/>
{/each}
</div>
@@ -658,6 +671,11 @@
duration={formatDuration(track.duration_secs)}
quality={getQualityBadge(track)}
onPlay={() => handleTrackPlay(track)}
menuActions={{
onPlayNow: () => handleTrackPlay(track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined
}}
/>
{/each}
</div>
@@ -10,11 +10,12 @@
title: string;
duration: number;
track_number: number;
performer?: { name: string };
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;
isrc?: string;
}
interface Playlist {
@@ -36,11 +37,14 @@
artist?: string;
album?: string;
albumArt?: string;
albumId?: string;
artistId?: number;
duration: string;
durationSeconds: number;
hires?: boolean;
bitDepth?: number;
samplingRate?: number;
isrc?: string;
}
interface PlaylistSettings {
@@ -59,9 +63,29 @@
playlistId: number;
onBack: () => void;
onTrackPlay?: (track: DisplayTrack) => void;
onTrackPlayNext?: (track: DisplayTrack) => void;
onTrackPlayLater?: (track: DisplayTrack) => void;
onTrackAddFavorite?: (trackId: number) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onTrackShareQobuz?: (trackId: number) => void;
onTrackShareSonglink?: (track: DisplayTrack) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
}
let { playlistId, onBack, onTrackPlay }: Props = $props();
let {
playlistId,
onBack,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackAddToPlaylist,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist
}: Props = $props();
let playlist = $state<Playlist | null>(null);
let tracks = $state<DisplayTrack[]>([]);
@@ -96,11 +120,14 @@
artist: t.performer?.name,
album: t.album?.title,
albumArt: t.album?.image?.thumbnail || t.album?.image?.small,
albumId: t.album?.id,
artistId: t.performer?.id,
duration: formatDuration(t.duration),
durationSeconds: t.duration,
hires: t.hires,
bitDepth: t.maximum_bit_depth,
samplingRate: t.maximum_sampling_rate,
isrc: t.isrc,
}));
}
} catch (err) {
@@ -453,6 +480,17 @@
duration={track.duration}
quality={track.hires ? 'Hi-Res' : undefined}
onPlay={() => handleTrackClick(track)}
menuActions={{
onPlayNow: () => handleTrackClick(track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined,
onAddFavorite: onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined,
onAddToPlaylist: onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined,
onShareQobuz: onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined,
onShareSonglink: onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined,
onGoToAlbum: track.albumId && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.albumId) : undefined,
onGoToArtist: track.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(track.artistId) : undefined
}}
/>
{/each}
+55 -4
View File
@@ -3,6 +3,7 @@
import { invoke } from '@tauri-apps/api/core';
import { Search, Disc3, Music, Mic2, User, X } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import TrackMenu from '../TrackMenu.svelte';
import { getSearchState, setSearchState, type SearchResults, type SearchTab } from '$lib/stores/searchState';
onMount(() => {
@@ -24,10 +25,30 @@
interface Props {
onAlbumClick?: (albumId: string) => void;
onTrackPlay?: (track: Track) => void;
onTrackPlayNext?: (track: Track) => void;
onTrackPlayLater?: (track: Track) => void;
onTrackAddFavorite?: (trackId: number) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onTrackShareQobuz?: (trackId: number) => void;
onTrackShareSonglink?: (track: Track) => void;
onTrackGoToAlbum?: (albumId: string) => void;
onTrackGoToArtist?: (artistId: number) => void;
onArtistClick?: (artistId: number) => void;
}
let { onAlbumClick, onTrackPlay, onArtistClick }: Props = $props();
let {
onAlbumClick,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackAddToPlaylist,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist,
onArtistClick
}: Props = $props();
interface Album {
id: string;
@@ -45,13 +66,15 @@
title: string;
duration: number;
album?: {
id?: string;
title: string;
image?: { small?: string; thumbnail?: string; large?: string };
};
performer?: { name: string };
performer?: { id?: number; name: string };
hires_streamable?: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
isrc?: string;
}
interface Artist {
@@ -340,9 +363,12 @@
{:else}
<div class="tracks-list">
{#each trackResults.items as track, index}
<button
<div
class="track-row"
role="button"
tabindex="0"
onclick={() => onTrackPlay?.(track)}
onkeydown={(e) => e.key === 'Enter' && onTrackPlay?.(track)}
>
<div class="track-number">{index + 1}</div>
{#if failedTrackImages.has(track.id) || !getTrackArtwork(track)}
@@ -358,7 +384,20 @@
</div>
<div class="track-quality">{getQualityLabel(track)}</div>
<div class="track-duration">{formatDuration(track.duration)}</div>
</button>
<div class="track-actions">
<TrackMenu
onPlayNow={() => onTrackPlay?.(track)}
onPlayNext={onTrackPlayNext ? () => onTrackPlayNext(track) : undefined}
onPlayLater={onTrackPlayLater ? () => onTrackPlayLater(track) : undefined}
onAddFavorite={onTrackAddFavorite ? () => onTrackAddFavorite(track.id) : undefined}
onAddToPlaylist={onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined}
onShareQobuz={onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined}
onShareSonglink={onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined}
onGoToAlbum={track.album?.id && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.album.id) : undefined}
onGoToArtist={track.performer?.id && onTrackGoToArtist ? () => onTrackGoToArtist(track.performer.id) : undefined}
/>
</div>
</div>
{/each}
</div>
{#if hasMoreTracks}
@@ -643,6 +682,18 @@
font-variant-numeric: tabular-nums;
}
.track-actions {
display: flex;
align-items: center;
margin-left: 8px;
opacity: 0.7;
transition: opacity 150ms ease;
}
.track-row:hover .track-actions {
opacity: 1;
}
.artists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+241 -2
View File
@@ -30,13 +30,15 @@
title: string;
duration: number;
album?: {
id?: string;
title: string;
image?: { small?: string; thumbnail?: string; large?: string };
};
performer?: { name: string };
performer?: { id?: number; name: string };
hires_streamable?: boolean;
maximum_bit_depth?: number;
maximum_sampling_rate?: number;
isrc?: string;
}
interface QobuzAlbum {
@@ -104,6 +106,9 @@
hires?: boolean;
bitDepth?: number;
samplingRate?: number;
albumId?: string;
artistId?: number;
isrc?: string;
}
interface AlbumDetail {
@@ -139,6 +144,16 @@
artwork_url: string | null;
}
interface SongLinkResponse {
pageUrl: string;
title?: string;
artist?: string;
thumbnailUrl?: string;
platforms: Record<string, string>;
identifier: string;
contentType: string;
}
interface BackendQueueState {
current_track: BackendQueueTrack | null;
current_index: number | null;
@@ -286,7 +301,10 @@
quality: track.hires_streamable ? 'Hi-Res' : 'CD',
hires: track.hires_streamable,
bitDepth: track.maximum_bit_depth,
samplingRate: track.maximum_sampling_rate
samplingRate: track.maximum_sampling_rate,
albumId: album.id,
artistId: track.performer?.id ?? album.artist?.id,
isrc: track.isrc
})) || []
};
}
@@ -344,6 +362,151 @@
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
async function copyToClipboard(text: string, successMessage: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
showToast(successMessage, 'success');
} catch (err) {
console.error('Failed to copy to clipboard:', err);
showToast('Failed to copy link', 'error');
}
}
function buildQueueTrackFromQobuz(track: QobuzTrack): BackendQueueTrack {
const artwork = track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small || '';
return {
id: track.id,
title: track.title,
artist: track.performer?.name || 'Unknown Artist',
album: track.album?.title || '',
duration_secs: track.duration,
artwork_url: artwork || null
};
}
function buildQueueTrackFromAlbumTrack(track: Track): BackendQueueTrack {
const artwork = selectedAlbum?.artwork || '';
return {
id: track.id,
title: track.title,
artist: track.artist || selectedAlbum?.artist || 'Unknown Artist',
album: selectedAlbum?.title || '',
duration_secs: track.durationSeconds,
artwork_url: artwork || null
};
}
function buildQueueTrackFromDisplayTrack(track: PlaylistTrack): BackendQueueTrack {
return {
id: track.id,
title: track.title,
artist: track.artist || 'Unknown Artist',
album: track.album || 'Playlist',
duration_secs: track.durationSeconds,
artwork_url: track.albumArt || null
};
}
function buildQueueTrackFromLocalTrack(track: LocalLibraryTrack): BackendQueueTrack {
const artwork = track.artwork_path ? `file://${track.artwork_path}` : null;
return {
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
duration_secs: track.duration_secs,
artwork_url: artwork
};
}
async function queueTrackNext(queueTrack: BackendQueueTrack, isLocal = false) {
try {
await invoke('add_to_queue_next', { track: queueTrack });
if (isLocal) {
localTrackIds = new Set([...localTrackIds, queueTrack.id]);
}
await syncQueueState();
showToast('Queued to play next', 'success');
} catch (err) {
console.error('Failed to queue track next:', err);
showToast('Failed to queue track', 'error');
}
}
async function queueTrackLater(queueTrack: BackendQueueTrack, isLocal = false) {
try {
await invoke('add_to_queue', { track: queueTrack });
if (isLocal) {
localTrackIds = new Set([...localTrackIds, queueTrack.id]);
}
await syncQueueState();
showToast('Added to queue', 'success');
} catch (err) {
console.error('Failed to queue track:', err);
showToast('Failed to add to queue', 'error');
}
}
async function addTrackToFavorites(trackId: number) {
try {
await invoke('add_favorite', { favType: 'track', itemId: String(trackId) });
showToast('Added to favorites', 'success');
} catch (err) {
console.error('Failed to add to favorites:', err);
showToast('Failed to add to favorites', 'error');
}
}
async function shareQobuzTrackLink(trackId: number) {
try {
const url = await invoke<string>('get_qobuz_track_url', { trackId });
await copyToClipboard(url, 'Qobuz link copied');
} catch (err) {
console.error('Failed to get Qobuz link:', err);
showToast('Failed to share link', 'error');
}
}
async function resolveTrackIsrc(trackId: number, isrc?: string): Promise<string | null> {
if (isrc && isrc.trim()) {
return isrc;
}
try {
const fullTrack = await invoke<QobuzTrack>('get_track', { trackId });
return fullTrack.isrc || null;
} catch (err) {
console.error('Failed to fetch track ISRC:', err);
return null;
}
}
async function shareSonglinkTrack(trackId: number, isrc?: string) {
const resolvedIsrc = await resolveTrackIsrc(trackId, isrc);
if (!resolvedIsrc) {
showToast('ISRC not available for this track', 'error');
return;
}
try {
const response = await invoke<SongLinkResponse>('share_track_songlink', { isrc: resolvedIsrc });
await copyToClipboard(response.pageUrl, 'Song.link copied');
} catch (err) {
console.error('Failed to get Song.link:', err);
showToast('Failed to share link', 'error');
}
}
// Playback Functions
async function handleTrackPlay(track: QobuzTrack) {
console.log('Playing track:', track);
@@ -771,6 +934,38 @@
}
}
function handleQobuzTrackPlayNext(track: QobuzTrack) {
queueTrackNext(buildQueueTrackFromQobuz(track));
}
function handleQobuzTrackPlayLater(track: QobuzTrack) {
queueTrackLater(buildQueueTrackFromQobuz(track));
}
function handleAlbumTrackPlayNext(track: Track) {
queueTrackNext(buildQueueTrackFromAlbumTrack(track));
}
function handleAlbumTrackPlayLater(track: Track) {
queueTrackLater(buildQueueTrackFromAlbumTrack(track));
}
function handleDisplayTrackPlayNext(track: PlaylistTrack) {
queueTrackNext(buildQueueTrackFromDisplayTrack(track));
}
function handleDisplayTrackPlayLater(track: PlaylistTrack) {
queueTrackLater(buildQueueTrackFromDisplayTrack(track));
}
function handleLocalTrackPlayNext(track: LocalLibraryTrack) {
queueTrackNext(buildQueueTrackFromLocalTrack(track), true);
}
function handleLocalTrackPlayLater(track: LocalLibraryTrack) {
queueTrackLater(buildQueueTrackFromLocalTrack(track), true);
}
// Handle playing a track from playlist view
interface PlaylistTrack {
id: number;
@@ -784,6 +979,9 @@
hires?: boolean;
bitDepth?: number;
samplingRate?: number;
albumId?: string;
artistId?: number;
isrc?: string;
}
async function handlePlaylistTrackPlay(track: PlaylistTrack) {
@@ -1223,6 +1421,14 @@
<SearchView
onAlbumClick={handleAlbumClick}
onTrackPlay={handleTrackPlay}
onTrackPlayNext={handleQobuzTrackPlayNext}
onTrackPlayLater={handleQobuzTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
onArtistClick={handleArtistClick}
/>
{:else if activeView === 'settings'}
@@ -1238,6 +1444,13 @@
onBack={goBack}
onArtistClick={() => selectedAlbum?.artistId && handleArtistClick(selectedAlbum.artistId)}
onTrackPlay={handleAlbumTrackPlay}
onTrackPlayNext={handleAlbumTrackPlayNext}
onTrackPlayLater={handleAlbumTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
onPlayAll={handlePlayAllAlbum}
onShuffleAll={handleShuffleAlbum}
onAddToQueue={handleAddAlbumToQueue}
@@ -1249,10 +1462,20 @@
onBack={goBack}
onAlbumClick={handleAlbumClick}
onTrackPlay={handlePlaylistTrackPlay}
onTrackPlayNext={handleQobuzTrackPlayNext}
onTrackPlayLater={handleQobuzTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
/>
{:else if activeView === 'library'}
<LocalLibraryView
onTrackPlay={handleLocalTrackPlay}
onTrackPlayNext={handleLocalTrackPlayNext}
onTrackPlayLater={handleLocalTrackPlayLater}
onSetLocalQueue={handleSetLocalQueue}
/>
{:else if activeView === 'playlist' && selectedPlaylistId}
@@ -1260,12 +1483,28 @@
playlistId={selectedPlaylistId}
onBack={goBack}
onTrackPlay={handlePlaylistTrackPlay}
onTrackPlayNext={handleDisplayTrackPlayNext}
onTrackPlayLater={handleDisplayTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
/>
{:else if activeView === 'favorites'}
<FavoritesView
onAlbumClick={handleAlbumClick}
onTrackPlay={handlePlaylistTrackPlay}
onArtistClick={handleArtistClick}
onTrackPlayNext={handleDisplayTrackPlayNext}
onTrackPlayLater={handleDisplayTrackPlayLater}
onTrackAddFavorite={addTrackToFavorites}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
onTrackShareQobuz={shareQobuzTrackLink}
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
onTrackGoToAlbum={handleAlbumClick}
onTrackGoToArtist={handleArtistClick}
/>
{/if}
</main>