feat(ui): add album card actions and fixed-speed tickers
This commit is contained in:
1 parent
7909f5e30d
commit
2d5ababba4
9 files changed
+641
-52
No files matched your search
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user