Files
qbz/src/lib/components/views/AlbumDetailView.svelte
T
vicrodh 84a2164079 refactor(stores): rename store to offlineCacheState
- Create offlineCacheState.ts with compliant terminology
- Update all component imports and type references
- Add compatibility aliases for gradual migration
- Remove deprecated downloadState.ts

cc
2026-01-23 11:57:53 -06:00

530 lines
13 KiB
Svelte

<script lang="ts">
import { onMount, tick } from 'svelte';
import { ArrowLeft, Play, Shuffle, Heart } from 'lucide-svelte';
import TrackRow from '../TrackRow.svelte';
import AlbumMenu from '../AlbumMenu.svelte';
import { getOfflineCacheState, type OfflineCacheStatus, isAlbumFullyCached } from '$lib/stores/offlineCacheState';
import { consumeContextTrackFocus } from '$lib/stores/playbackContextStore';
import {
subscribe as subscribeAlbumFavorites,
isAlbumFavorite,
loadAlbumFavorites,
toggleAlbumFavorite
} from '$lib/stores/albumFavoritesStore';
interface Track {
id: number;
number: number;
title: string;
artist?: string;
duration: string;
durationSeconds: number;
quality?: string;
hires?: boolean;
bitDepth?: number;
samplingRate?: number;
isrc?: string;
}
interface Props {
album: {
id: string;
artwork: string;
title: string;
artist: string;
artistId?: number;
year: string;
label: string;
genre: string;
quality: string;
trackCount: number;
duration: string;
tracks: Track[];
};
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;
onPlayAllNext?: () => void;
onPlayAllLater?: () => void;
onAddTrackToPlaylist?: (trackId: number) => void;
onAddAlbumToPlaylist?: () => void;
onTrackDownload?: (track: Track) => void;
onTrackRemoveDownload?: (trackId: number) => void;
onTrackReDownload?: (track: Track) => void;
getTrackOfflineCacheStatus?: (trackId: number) => { status: OfflineCacheStatus; progress: number };
onDownloadAlbum?: () => void;
onShareAlbumQobuz?: () => void;
onShareAlbumSonglink?: () => void;
downloadStateVersion?: number;
activeTrackId?: number | null;
isPlaybackActive?: boolean;
onOpenAlbumFolder?: () => void;
onReDownloadAlbum?: () => void;
}
let {
album,
onBack,
onArtistClick,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddFavorite,
onTrackShareQobuz,
onTrackShareSonglink,
onTrackGoToAlbum,
onTrackGoToArtist,
onPlayAll,
onShuffleAll,
onPlayAllNext,
onPlayAllLater,
onAddTrackToPlaylist,
onAddAlbumToPlaylist,
onTrackDownload,
onTrackRemoveDownload,
onTrackReDownload,
getTrackOfflineCacheStatus,
onDownloadAlbum,
onShareAlbumQobuz,
onShareAlbumSonglink,
downloadStateVersion,
activeTrackId = null,
isPlaybackActive = false,
onOpenAlbumFolder,
onReDownloadAlbum
}: Props = $props();
let isFavorite = $state(false);
let isFavoriteLoading = $state(false);
let playBtnHovered = $state(false);
let scrollContainer: HTMLDivElement | null = $state(null);
const albumFullyDownloaded = $derived(
isAlbumFullyCached(album.tracks.map(t => t.id))
);
const isVariousArtists = $derived(
album.artist?.trim().toLowerCase() === 'various artists'
);
async function scrollToTrack(trackId: number) {
await tick();
const target = scrollContainer?.querySelector<HTMLElement>(`[data-track-id="${trackId}"]`);
target?.scrollIntoView({ block: 'center' });
}
// Check if album is in favorites on mount
onMount(() => {
let unsubscribe: (() => void) | null = null;
(async () => {
try {
await loadAlbumFavorites();
isFavorite = isAlbumFavorite(album.id);
unsubscribe = subscribeAlbumFavorites(() => {
isFavorite = isAlbumFavorite(album.id);
});
} catch (err) {
console.error('Failed to check album favorite status:', err);
}
})();
return () => {
unsubscribe?.();
};
});
$effect(() => {
if (!album.tracks?.length) return;
const targetId = consumeContextTrackFocus('album', album.id);
if (targetId !== null) {
void scrollToTrack(targetId);
}
});
async function toggleFavorite() {
if (isFavoriteLoading) return;
isFavoriteLoading = true;
try {
isFavorite = await toggleAlbumFavorite(album.id);
} catch (err) {
console.error('Failed to toggle favorite:', err);
} finally {
isFavoriteLoading = false;
}
}
function handleAddAlbumToPlaylist() {
if (!album?.tracks?.length) return;
onAddAlbumToPlaylist?.();
}
</script>
<div class="album-detail" bind:this={scrollContainer}>
<!-- Back Navigation -->
<button class="back-btn" onclick={onBack}>
<ArrowLeft size={16} />
<span>Back</span>
</button>
<!-- Album Header -->
<div class="album-header">
<!-- Album Artwork -->
<div class="artwork">
<img src={album.artwork} alt={album.title} />
</div>
<!-- Album Metadata -->
<div class="metadata">
<h1 class="album-title">{album.title}</h1>
{#if onArtistClick && !isVariousArtists}
<button class="artist-link" onclick={onArtistClick}>
{album.artist}
</button>
{:else}
<div class="artist-name">{album.artist}</div>
{/if}
<div class="album-info">{album.year} • {album.label} • {album.genre}</div>
<div class="album-quality">{album.quality}</div>
<div class="album-stats">{album.trackCount} tracks • {album.duration}</div>
<!-- Action Buttons -->
<div class="actions">
<button
class="play-btn"
style="background-color: {playBtnHovered ? 'var(--accent-hover)' : 'var(--accent-primary)'}"
onmouseenter={() => (playBtnHovered = true)}
onmouseleave={() => (playBtnHovered = false)}
onclick={onPlayAll}
>
<Play size={18} fill="white" color="white" />
<span>Play</span>
</button>
<button class="secondary-btn" onclick={onShuffleAll}>
<Shuffle size={18} />
<span>Shuffle</span>
</button>
<button class="icon-btn" onclick={toggleFavorite} disabled={isFavoriteLoading}>
<Heart
size={20}
color={isFavorite ? 'var(--accent-primary)' : 'white'}
fill={isFavorite ? 'var(--accent-primary)' : 'none'}
/>
</button>
<AlbumMenu
onPlayNext={onPlayAllNext}
onPlayLater={onPlayAllLater}
onAddToPlaylist={onAddAlbumToPlaylist ? handleAddAlbumToPlaylist : undefined}
onShareQobuz={onShareAlbumQobuz}
onShareSonglink={onShareAlbumSonglink}
onDownload={onDownloadAlbum}
isAlbumFullyCached={albumFullyDownloaded}
onOpenContainingFolder={onOpenAlbumFolder}
onReDownloadAlbum={onReDownloadAlbum}
/>
</div>
</div>
</div>
<!-- Divider -->
<div class="divider"></div>
<!-- Track List -->
<div class="track-list">
<!-- Table Header -->
<div class="table-header">
<div class="col-number">#</div>
<div class="col-title">Title</div>
<div class="col-duration">Duration</div>
<div class="col-quality">Quality</div>
</div>
<!-- Track Rows -->
<div class="tracks">
{#each album.tracks as track, trackIndex (`${track.id}-${downloadStateVersion}`)}
{@const downloadInfo = getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 }}
{@const isTrackDownloaded = downloadInfo.status === 'ready'}
<TrackRow
trackId={track.id}
number={track.number}
title={track.title}
artist={track.artist}
duration={track.duration}
quality={track.quality}
isPlaying={activeTrackId === track.id}
downloadStatus={downloadInfo.status}
downloadProgress={downloadInfo.progress}
onPlay={() => {
onTrackPlay?.(track);
}}
onDownload={onTrackDownload ? () => onTrackDownload(track) : undefined}
onRemoveDownload={onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
menuActions={{
onPlayNow: () => {
onTrackPlay?.(track);
},
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : 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,
onDownload: onTrackDownload ? () => onTrackDownload(track) : undefined,
isTrackDownloaded,
onReDownload: isTrackDownloaded && onTrackReDownload ? () => onTrackReDownload(track) : undefined,
onRemoveDownload: isTrackDownloaded && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined
}}
/>
{/each}
</div>
</div>
</div>
<style>
.album-detail {
width: 100%;
height: 100%;
padding: 24px;
padding-right: 8px;
padding-bottom: 100px;
overflow-y: auto;
}
/* Custom scrollbar */
.album-detail::-webkit-scrollbar {
width: 6px;
}
.album-detail::-webkit-scrollbar-track {
background: transparent;
}
.album-detail::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.album-detail::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.back-btn {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-muted);
background: none;
border: none;
cursor: pointer;
margin-bottom: 24px;
transition: color 150ms ease;
}
.back-btn:hover {
color: var(--text-secondary);
}
.album-header {
display: flex;
gap: 32px;
margin-bottom: 32px;
}
.artwork {
flex-shrink: 0;
width: 224px;
height: 224px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.artwork img {
width: 100%;
height: 100%;
object-fit: cover;
}
.metadata {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.album-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 4px;
}
.artist-link {
font-size: 18px;
font-weight: 500;
color: var(--accent-primary);
background: none;
border: none;
cursor: pointer;
text-align: left;
width: fit-content;
margin-bottom: 8px;
}
.artist-name {
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
}
.artist-link:hover {
text-decoration: underline;
}
.album-info {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 4px;
}
.album-quality {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 4px;
}
.album-stats {
font-size: 14px;
color: #666666;
margin-bottom: 24px;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.play-btn {
height: 40px;
padding: 0 24px;
border-radius: 8px;
border: none;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: background-color 150ms ease;
}
.play-btn span {
font-size: 14px;
font-weight: 500;
color: white;
}
.secondary-btn {
height: 40px;
padding: 0 24px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
display: flex;
align-items: center;
gap: 8px;
color: white;
cursor: pointer;
transition: background-color 150ms ease;
}
.secondary-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.secondary-btn span {
font-size: 14px;
font-weight: 500;
}
.icon-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 150ms ease;
}
.icon-btn:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.divider {
height: 1px;
background-color: var(--bg-tertiary);
margin: 32px 0;
}
.table-header {
width: 100%;
height: 40px;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
font-size: 12px;
text-transform: uppercase;
color: #666666;
font-weight: 400;
box-sizing: border-box;
}
.col-number {
width: 48px;
}
.col-title {
flex: 1;
min-width: 0;
}
.col-duration {
width: 80px;
text-align: right;
}
.col-quality {
width: 80px;
text-align: right;
}
.track-list {
display: flex;
flex-direction: column;
width: 100%;
}
.tracks {
display: flex;
flex-direction: column;
width: 100%;
}
</style>