feat(ui): add album card actions and fixed-speed tickers

This commit is contained in:
vicrodh committed 2026-01-14 01:03:14 -06:00
1 parent 7909f5e30d
commit 2d5ababba4
9 files changed
+641 -52

No files matched your search

+169 -21
View File
@@ -1,17 +1,45 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { Play, Disc3 } from 'lucide-svelte';
import { Play, Disc3, Heart } from 'lucide-svelte';
import AlbumMenu from './AlbumMenu.svelte';
import {
subscribe as subscribeAlbumFavorites,
isAlbumFavorite,
loadAlbumFavorites,
toggleAlbumFavorite
} from '$lib/stores/albumFavoritesStore';
interface Props {
albumId?: string;
artwork: string;
title: string;
artist: string;
quality?: string;
size?: 'standard' | 'large';
onclick?: () => void;
onPlay?: () => void;
onPlayNext?: () => void;
onPlayLater?: () => void;
onShareQobuz?: () => void;
onShareSonglink?: () => void;
onDownload?: () => void;
}
let { artwork, title, artist, quality, size = 'standard', onclick }: Props = $props();
let {
albumId,
artwork,
title,
artist,
quality,
size = 'standard',
onclick,
onPlay,
onPlayNext,
onPlayLater,
onShareQobuz,
onShareSonglink,
onDownload
}: Props = $props();
let imageError = $state(false);
const cardSize = $derived(size === 'large' ? 180 : 162);
@@ -23,11 +51,30 @@
let artistOverflow = $state(0);
const titleOffset = $derived(titleOverflow > 0 ? `-${titleOverflow + 16}px` : '0px');
const artistOffset = $derived(artistOverflow > 0 ? `-${artistOverflow + 16}px` : '0px');
const tickerSpeed = 80;
const titleDuration = $derived(titleOverflow > 0 ? `${(titleOverflow + 16) / tickerSpeed}s` : '0s');
const artistDuration = $derived(artistOverflow > 0 ? `${(artistOverflow + 16) / tickerSpeed}s` : '0s');
let favoriteFromStore = $state(false);
const isFavorite = $derived(albumId ? favoriteFromStore : false);
const hasMenu = $derived(!!(onPlayNext || onPlayLater || onShareQobuz || onShareSonglink || onDownload));
const hasOverlay = $derived(!!(albumId || onPlay || hasMenu));
function handleImageError() {
imageError = true;
}
async function handleToggleFavorite(event: MouseEvent) {
event.stopPropagation();
if (!albumId) return;
await toggleAlbumFavorite(albumId);
}
function handlePlay(event: MouseEvent) {
event.stopPropagation();
onPlay?.();
}
function updateOverflow() {
if (titleRef && titleTextRef) {
const overflow = titleTextRef.scrollWidth - titleRef.clientWidth;
@@ -48,6 +95,18 @@
if (artistRef) {
observer.observe(artistRef);
}
if (albumId) {
void loadAlbumFavorites();
favoriteFromStore = isAlbumFavorite(albumId);
const unsubscribe = subscribeAlbumFavorites(() => {
favoriteFromStore = isAlbumFavorite(albumId);
});
return () => {
observer.disconnect();
unsubscribe();
};
}
return () => observer.disconnect();
});
@@ -83,12 +142,40 @@
<img src={artwork} alt={title} loading="lazy" decoding="async" onerror={handleImageError} />
{/if}
<!-- Play Button Overlay (CSS-only hover) -->
<div class="play-overlay">
<div class="play-button">
<Play size={24} fill="white" color="white" />
<!-- Action Overlay -->
{#if hasOverlay}
<div class="action-overlay">
<div class="action-buttons">
{#if albumId}
<button
class="overlay-btn"
class:is-active={isFavorite}
type="button"
onclick={handleToggleFavorite}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart size={18} fill={isFavorite ? 'white' : 'none'} color="white" />
</button>
{/if}
{#if onPlay}
<button class="overlay-btn" type="button" onclick={handlePlay} title="Play">
<Play size={18} fill="white" color="white" />
</button>
{/if}
{#if hasMenu}
<div class="overlay-menu">
<AlbumMenu
onPlayNext={onPlayNext}
onPlayLater={onPlayLater}
onShareQobuz={onShareQobuz}
onShareSonglink={onShareSonglink}
onDownload={onDownload}
/>
</div>
{/if}
</div>
</div>
</div>
{/if}
</div>
<!-- Text Info -->
@@ -96,7 +183,7 @@
<div
class="title"
class:scrollable={titleOverflow > 0}
style="--ticker-offset: {titleOffset};"
style="--ticker-offset: {titleOffset}; --ticker-duration: {titleDuration};"
bind:this={titleRef}
>
<span class="title-text" bind:this={titleTextRef}>{title}</span>
@@ -104,7 +191,7 @@
<div
class="artist"
class:scrollable={artistOverflow > 0}
style="--ticker-offset: {artistOffset};"
style="--ticker-offset: {artistOffset}; --ticker-duration: {artistDuration};"
bind:this={artistRef}
>
<span class="artist-text" bind:this={artistTextRef}>{artist}</span>
@@ -172,19 +259,80 @@
transition: opacity 150ms ease;
}
.album-card:hover .play-overlay {
opacity: 1;
}
.play-button {
width: 48px;
height: 48px;
border-radius: 50%;
.action-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(66, 133, 244, 0.9);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 150ms ease;
background: rgba(10, 10, 10, 0.25);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
pointer-events: none;
}
.album-card:hover .action-overlay {
opacity: 1;
}
.action-buttons {
display: flex;
align-items: center;
gap: 12px;
pointer-events: auto;
}
.overlay-btn {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.85);
background: transparent;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 150ms ease, background-color 150ms ease;
}
.overlay-btn:hover {
transform: scale(1.05);
background-color: rgba(255, 255, 255, 0.1);
}
.overlay-btn.is-active {
background-color: rgba(255, 255, 255, 0.2);
}
.overlay-menu {
display: flex;
align-items: center;
justify-content: center;
}
:global(.album-card .album-menu) {
display: flex;
align-items: center;
}
:global(.album-card .album-menu .menu-trigger) {
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.85);
background: transparent;
color: white;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
:global(.album-card .album-menu .menu-trigger:hover) {
background-color: rgba(255, 255, 255, 0.1);
}
.info {
@@ -212,7 +360,7 @@
}
.album-card:hover .title.scrollable .title-text {
animation: title-ticker 6s linear infinite;
animation: title-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
@@ -241,7 +389,7 @@
}
.album-card:hover .artist.scrollable .artist-text {
animation: title-ticker 6s linear infinite;
animation: title-ticker var(--ticker-duration) linear infinite;
will-change: transform;
}
</style>
+12 -19
View File
@@ -1,10 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { ArrowLeft, Play, Shuffle, Heart } from 'lucide-svelte';
import TrackRow from '../TrackRow.svelte';
import AlbumMenu from '../AlbumMenu.svelte';
import { getDownloadState, type DownloadStatus } from '$lib/stores/downloadState';
import {
subscribe as subscribeAlbumFavorites,
isAlbumFavorite,
loadAlbumFavorites,
toggleAlbumFavorite
} from '$lib/stores/albumFavoritesStore';
interface Track {
id: number;
@@ -96,21 +101,15 @@
album.artist?.trim().toLowerCase() === 'various artists'
);
interface FavoritesResponse {
albums?: { items: Array<{ id: string }>; total: number };
}
// Check if album is in favorites on mount
onMount(async () => {
try {
const response = await invoke<FavoritesResponse>('get_favorites', {
favType: 'albums',
limit: 500,
offset: 0
await loadAlbumFavorites();
isFavorite = isAlbumFavorite(album.id);
const unsubscribe = subscribeAlbumFavorites(() => {
isFavorite = isAlbumFavorite(album.id);
});
if (response.albums?.items) {
isFavorite = response.albums.items.some(item => item.id === album.id);
}
return unsubscribe;
} catch (err) {
console.error('Failed to check album favorite status:', err);
}
@@ -121,13 +120,7 @@
isFavoriteLoading = true;
try {
if (isFavorite) {
await invoke('remove_favorite', { favType: 'album', itemId: album.id });
isFavorite = false;
} else {
await invoke('add_favorite', { favType: 'album', itemId: album.id });
isFavorite = true;
}
isFavorite = await toggleAlbumFavorite(album.id);
} catch (err) {
console.error('Failed to toggle favorite:', err);
} finally {
@@ -46,6 +46,12 @@
artist: ArtistDetail;
onBack: () => void;
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (albumId: string) => void;
onAlbumShareQobuz?: (albumId: string) => void;
onAlbumShareSonglink?: (albumId: string) => void;
onAlbumDownload?: (albumId: string) => void;
onLoadMore?: () => void;
isLoadingMore?: boolean;
onPlaylistClick?: (playlistId: number) => void;
@@ -64,6 +70,12 @@
artist,
onBack,
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onLoadMore,
isLoadingMore = false,
onPlaylistClick,
@@ -552,17 +564,24 @@
{#if artist.albums.length === 0}
<div class="no-albums">No albums found</div>
{:else}
<div class="albums-grid">
{#each artist.albums as album}
<AlbumCard
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
quality={album.quality}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
</div>
<div class="albums-grid">
{#each artist.albums as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
</div>
{#if hasMoreAlbums}
<div class="load-more-container">
@@ -586,10 +605,17 @@
<div class="albums-grid">
{#each artist.epsSingles as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -605,10 +631,17 @@
<div class="albums-grid">
{#each artist.liveAlbums as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -624,10 +657,17 @@
<div class="albums-grid">
{#each artist.compilations as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.year || ''}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -37,6 +37,12 @@
interface Props {
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (albumId: string) => void;
onAlbumShareQobuz?: (albumId: string) => void;
onAlbumShareSonglink?: (albumId: string) => void;
onAlbumDownload?: (albumId: string) => void;
onTrackPlay?: (track: DisplayTrack) => void;
onArtistClick?: (artistId: number) => void;
onTrackPlayNext?: (track: DisplayTrack) => void;
@@ -72,6 +78,12 @@
let {
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onTrackPlay,
onArtistClick,
onTrackPlayNext,
@@ -917,10 +929,17 @@
<div class="album-grid">
{#each group.albums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
quality={album.hires ? 'Hi-Res' : undefined}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -977,10 +996,17 @@
<div class="album-grid">
{#each filteredAlbums as album (album.id)}
<AlbumCard
albumId={album.id}
artwork={album.image?.large || album.image?.thumbnail || ''}
title={album.title}
artist={album.artist.name}
quality={album.hires ? 'Hi-Res' : undefined}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
+67 -1
View File
@@ -47,11 +47,28 @@
interface Props {
userName?: string;
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (albumId: string) => void;
onAlbumShareQobuz?: (albumId: string) => void;
onAlbumShareSonglink?: (albumId: string) => void;
onAlbumDownload?: (albumId: string) => void;
onArtistClick?: (artistId: number) => void;
onTrackPlay?: (track: DisplayTrack) => void;
}
let { userName = 'User', onAlbumClick, onArtistClick, onTrackPlay }: Props = $props();
let {
userName = 'User',
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onArtistClick,
onTrackPlay
}: Props = $props();
// Home settings state
let homeSettings = $state<HomeSettings>(getSettings());
@@ -481,10 +498,17 @@
{#snippet children()}
{#each newReleases as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -498,10 +522,17 @@
{#snippet children()}
{#each pressAwards as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -515,10 +546,17 @@
{#snippet children()}
{#each mostStreamed as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -532,10 +570,17 @@
{#snippet children()}
{#each qobuzissimes as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -549,10 +594,17 @@
{#snippet children()}
{#each editorPicks as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -566,10 +618,17 @@
{#snippet children()}
{#each recentAlbums as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -640,10 +699,17 @@
{#snippet children()}
{#each favoriteAlbums as album}
<AlbumCard
albumId={album.id}
artwork={album.artwork}
title={album.title}
artist={album.artist}
quality={album.quality}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
@@ -401,6 +401,11 @@
}
}
async function handleAlbumPlayFromGrid(album: LocalAlbum) {
await handleAlbumClick(album);
await handlePlayAllAlbum();
}
async function handleTrackPlay(track: LocalTrack) {
try {
await invoke('library_play_track', { trackId: track.id });
@@ -1141,6 +1146,7 @@
title={album.title}
artist={album.artist}
quality={getAlbumQualityBadge(album)}
onPlay={() => handleAlbumPlayFromGrid(album)}
onclick={() => handleAlbumClick(album)}
/>
{/each}
@@ -28,6 +28,12 @@
interface Props {
onAlbumClick?: (albumId: string) => void;
onAlbumPlay?: (albumId: string) => void;
onAlbumPlayNext?: (albumId: string) => void;
onAlbumPlayLater?: (albumId: string) => void;
onAlbumShareQobuz?: (albumId: string) => void;
onAlbumShareSonglink?: (albumId: string) => void;
onAlbumDownload?: (albumId: string) => void;
onTrackPlay?: (track: Track) => void;
onTrackPlayNext?: (track: Track) => void;
onTrackPlayLater?: (track: Track) => void;
@@ -42,6 +48,12 @@
let {
onAlbumClick,
onAlbumPlay,
onAlbumPlayNext,
onAlbumPlayLater,
onAlbumShareQobuz,
onAlbumShareSonglink,
onAlbumDownload,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
@@ -346,10 +358,17 @@
<div class="albums-grid">
{#each albumResults.items as album}
<AlbumCard
albumId={album.id}
artwork={getAlbumArtwork(album)}
title={album.title}
artist={album.artist?.name || 'Unknown Artist'}
quality={getQualityLabel(album)}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onclick={() => onAlbumClick?.(album.id)}
/>
{/each}
+121
View File
@@ -0,0 +1,121 @@
/**
* Album Favorites Store
*
* Centralized store for tracking favorite albums.
*/
import { invoke } from '@tauri-apps/api/core';
import { logRecoEvent } from '$lib/services/recoService';
let favoriteAlbumIds = new Set<string>();
let isLoaded = false;
let isLoading = false;
const listeners = new Set<() => void>();
function notifyListeners(): void {
for (const listener of listeners) {
listener();
}
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function isAlbumFavorite(albumId: string): boolean {
return favoriteAlbumIds.has(albumId);
}
export function getFavoriteAlbumIds(): string[] {
return Array.from(favoriteAlbumIds);
}
export function isFavoritesLoaded(): boolean {
return isLoaded;
}
export async function loadAlbumFavorites(): Promise<void> {
if (isLoading || isLoaded) return;
isLoading = true;
try {
let offset = 0;
const limit = 500;
const collected: string[] = [];
while (true) {
const result = await invoke<{ albums?: { items: Array<{ id: string }>; total?: number } }>('get_favorites', {
favType: 'albums',
limit,
offset
});
const items = result.albums?.items ?? [];
if (!items.length) break;
collected.push(...items.map(item => item.id));
offset += items.length;
if (items.length < limit) break;
if (result.albums?.total && offset >= result.albums.total) break;
}
favoriteAlbumIds = new Set(collected);
isLoaded = true;
notifyListeners();
} catch (err) {
console.error('Failed to load album favorites:', err);
} finally {
isLoading = false;
}
}
export async function toggleAlbumFavorite(albumId: string): Promise<boolean> {
const wasFavorite = favoriteAlbumIds.has(albumId);
const newState = !wasFavorite;
if (newState) {
favoriteAlbumIds.add(albumId);
} else {
favoriteAlbumIds.delete(albumId);
}
notifyListeners();
try {
if (newState) {
await invoke('add_favorite', { favType: 'album', itemId: albumId });
void logRecoEvent({
eventType: 'favorite',
itemType: 'album',
albumId
});
} else {
await invoke('remove_favorite', { favType: 'album', itemId: albumId });
}
return newState;
} catch (err) {
if (newState) {
favoriteAlbumIds.delete(albumId);
} else {
favoriteAlbumIds.add(albumId);
}
notifyListeners();
console.error('Failed to toggle album favorite:', err);
return wasFavorite;
}
}
export function markAsFavorite(albumId: string): void {
if (!favoriteAlbumIds.has(albumId)) {
favoriteAlbumIds.add(albumId);
notifyListeners();
}
}
export function unmarkAsFavorite(albumId: string): void {
if (favoriteAlbumIds.has(albumId)) {
favoriteAlbumIds.delete(albumId);
notifyListeners();
}
}
+170
View File
@@ -390,6 +390,152 @@
);
}
async function fetchAlbumDetail(albumId: string): Promise<AlbumDetail | null> {
try {
const album = await invoke<QobuzAlbum>('get_album', { albumId });
return convertQobuzAlbum(album);
} catch (err) {
console.error('Failed to load album:', err);
showToast('Failed to load album', 'error');
return null;
}
}
async function playAlbumById(albumId: string) {
const album = await fetchAlbumDetail(albumId);
if (!album?.tracks?.length) return;
const artwork = album.artwork || '';
const queueTracks: BackendQueueTrack[] = album.tracks.map(t => ({
id: t.id,
title: t.title,
artist: t.artist || album.artist || 'Unknown Artist',
album: album.title || '',
duration_secs: t.durationSeconds,
artwork_url: artwork || null,
hires: t.hires ?? false,
bit_depth: t.bitDepth ?? null,
sample_rate: t.samplingRate ?? null
}));
await setQueue(queueTracks, 0, true);
const firstTrack = album.tracks[0];
const quality = firstTrack.hires && firstTrack.bitDepth && firstTrack.samplingRate
? `${firstTrack.bitDepth}bit/${firstTrack.samplingRate}kHz`
: firstTrack.hires
? 'Hi-Res'
: '-';
await playTrack({
id: firstTrack.id,
title: firstTrack.title,
artist: firstTrack.artist || album.artist || 'Unknown Artist',
album: album.title || '',
artwork,
duration: firstTrack.durationSeconds,
quality,
bitDepth: firstTrack.bitDepth,
samplingRate: firstTrack.samplingRate,
albumId: album.id,
artistId: firstTrack.artistId
});
}
async function queueAlbumNextById(albumId: string) {
const album = await fetchAlbumDetail(albumId);
if (!album?.tracks?.length) return;
const artwork = album.artwork || '';
for (let i = album.tracks.length - 1; i >= 0; i--) {
const t = album.tracks[i];
queueTrackNext({
id: t.id,
title: t.title,
artist: t.artist || album.artist || 'Unknown Artist',
album: album.title || '',
duration_secs: t.durationSeconds,
artwork_url: artwork || null,
hires: t.hires ?? false,
bit_depth: t.bitDepth ?? null,
sample_rate: t.samplingRate ?? null
});
}
showToast(`Playing ${album.tracks.length} tracks next`, 'success');
}
async function queueAlbumLaterById(albumId: string) {
const album = await fetchAlbumDetail(albumId);
if (!album?.tracks?.length) return;
const artwork = album.artwork || '';
const queueTracks: BackendQueueTrack[] = album.tracks.map(t => ({
id: t.id,
title: t.title,
artist: t.artist || album.artist || 'Unknown Artist',
album: album.title || '',
duration_secs: t.durationSeconds,
artwork_url: artwork || null,
hires: t.hires ?? false,
bit_depth: t.bitDepth ?? null,
sample_rate: t.samplingRate ?? null
}));
const success = await addTracksToQueue(queueTracks);
if (success) {
showToast(`Added ${queueTracks.length} tracks to queue`, 'success');
} else {
showToast('Failed to add to queue', 'error');
}
}
function shareAlbumQobuzLinkById(albumId: string) {
const url = `https://play.qobuz.com/album/${albumId}`;
writeText(url);
showToast('Album link copied to clipboard', 'success');
}
function shareAlbumSonglinkById(albumId: string) {
const qobuzUrl = `https://play.qobuz.com/album/${albumId}`;
const songlinkUrl = `https://song.link/${encodeURIComponent(qobuzUrl)}`;
writeText(songlinkUrl);
showToast('Song.link copied to clipboard', 'success');
}
async function downloadAlbumById(albumId: string) {
const album = await fetchAlbumDetail(albumId);
if (!album) return;
const tracksToDownload = album.tracks.filter(track => {
const status = getDownloadState(track.id).status;
return status === 'none' || status === 'failed';
});
if (tracksToDownload.length === 0) {
showToast('All tracks already downloaded', 'info');
return;
}
showToast(`Downloading ${tracksToDownload.length} tracks from "${album.title}"`, 'info');
for (const track of tracksToDownload) {
try {
await downloadTrack({
id: track.id,
title: track.title,
artist: track.artist || album.artist || 'Unknown',
album: album.title,
albumId: album.id,
durationSecs: track.durationSeconds,
quality: track.quality || '-',
bitDepth: track.bitDepth,
sampleRate: track.samplingRate,
});
} catch (err) {
console.error(`Failed to queue download for "${track.title}":`, err);
}
}
}
// Playback Functions - QobuzTrack from search results
async function handleTrackPlay(track: QobuzTrack) {
console.log('Playing track:', track);
@@ -1531,12 +1677,24 @@
<HomeView
userName={userInfo?.userName}
onAlbumClick={handleAlbumClick}
onAlbumPlay={playAlbumById}
onAlbumPlayNext={queueAlbumNextById}
onAlbumPlayLater={queueAlbumLaterById}
onAlbumShareQobuz={shareAlbumQobuzLinkById}
onAlbumShareSonglink={shareAlbumSonglinkById}
onAlbumDownload={downloadAlbumById}
onArtistClick={handleArtistClick}
onTrackPlay={handleDisplayTrackPlay}
/>
{:else if activeView === 'search'}
<SearchView
onAlbumClick={handleAlbumClick}
onAlbumPlay={playAlbumById}
onAlbumPlayNext={queueAlbumNextById}
onAlbumPlayLater={queueAlbumLaterById}
onAlbumShareQobuz={shareAlbumQobuzLinkById}
onAlbumShareSonglink={shareAlbumSonglinkById}
onAlbumDownload={downloadAlbumById}
onTrackPlay={handleTrackPlay}
onTrackPlayNext={queueQobuzTrackNext}
onTrackPlayLater={queueQobuzTrackLater}
@@ -1588,6 +1746,12 @@
artist={selectedArtist}
onBack={navGoBack}
onAlbumClick={handleAlbumClick}
onAlbumPlay={playAlbumById}
onAlbumPlayNext={queueAlbumNextById}
onAlbumPlayLater={queueAlbumLaterById}
onAlbumShareQobuz={shareAlbumQobuzLinkById}
onAlbumShareSonglink={shareAlbumSonglinkById}
onAlbumDownload={downloadAlbumById}
onLoadMore={loadMoreArtistAlbums}
isLoadingMore={isArtistAlbumsLoading}
onTrackPlay={handleDisplayTrackPlay}
@@ -1637,6 +1801,12 @@
{:else if activeView === 'favorites'}
<FavoritesView
onAlbumClick={handleAlbumClick}
onAlbumPlay={playAlbumById}
onAlbumPlayNext={queueAlbumNextById}
onAlbumPlayLater={queueAlbumLaterById}
onAlbumShareQobuz={shareAlbumQobuzLinkById}
onAlbumShareSonglink={shareAlbumSonglinkById}
onAlbumDownload={downloadAlbumById}
onTrackPlay={handleDisplayTrackPlay}
onArtistClick={handleArtistClick}
onTrackPlayNext={queuePlaylistTrackNext}