feat: artist top tracks and play button simplification
- Remove blue circle from play/pause, now uses same style as other controls - Add Popular Tracks section to ArtistDetailView with Play All - Search tracks by artist name and filter results - Connect onTrackPlay to play tracks from artist view
This commit is contained in:
1 parent
5d5566102c
commit
fbb0b10be3
3 files changed
+275
-21
No files matched your search
@@ -179,11 +179,11 @@
|
||||
<button class="control-btn primary" onclick={onSkipBack}>
|
||||
<SkipBack size={24} />
|
||||
</button>
|
||||
<button class="play-btn" onclick={onTogglePlay}>
|
||||
<button class="control-btn primary play-pause" onclick={onTogglePlay}>
|
||||
{#if isPlaying}
|
||||
<Pause size={24} color="white" fill="white" />
|
||||
<Pause size={28} />
|
||||
{:else}
|
||||
<Play size={24} color="white" fill="white" />
|
||||
<Play size={28} />
|
||||
{/if}
|
||||
</button>
|
||||
<button class="control-btn primary" onclick={onSkipForward}>
|
||||
@@ -427,22 +427,8 @@
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background-color: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.play-btn:hover {
|
||||
background-color: var(--accent-hover);
|
||||
.control-btn.play-pause {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, User, ChevronDown, ChevronUp } from 'lucide-svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Plus, Music } from 'lucide-svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
|
||||
interface Album {
|
||||
@@ -16,6 +18,37 @@
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
title: string;
|
||||
duration: number;
|
||||
album?: {
|
||||
id: string;
|
||||
title: string;
|
||||
image?: { small?: string; thumbnail?: string; large?: string };
|
||||
};
|
||||
performer?: { name: string };
|
||||
hires_streamable?: boolean;
|
||||
maximum_bit_depth?: number;
|
||||
maximum_sampling_rate?: number;
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
items: Track[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface DisplayTrack {
|
||||
id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
albumArt: string;
|
||||
duration: string;
|
||||
durationSeconds: number;
|
||||
hires?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
artist: {
|
||||
id: number;
|
||||
@@ -30,12 +63,80 @@
|
||||
onAlbumClick?: (albumId: string) => void;
|
||||
onLoadMore?: () => void;
|
||||
isLoadingMore?: boolean;
|
||||
onTrackPlay?: (track: DisplayTrack) => void;
|
||||
}
|
||||
|
||||
let { artist, onBack, onAlbumClick, onLoadMore, isLoadingMore = false }: Props = $props();
|
||||
let { artist, onBack, onAlbumClick, onLoadMore, isLoadingMore = false, onTrackPlay }: Props = $props();
|
||||
|
||||
let bioExpanded = $state(false);
|
||||
let imageError = $state(false);
|
||||
let topTracks = $state<Track[]>([]);
|
||||
let tracksLoading = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
loadTopTracks();
|
||||
});
|
||||
|
||||
async function loadTopTracks() {
|
||||
tracksLoading = true;
|
||||
try {
|
||||
// Search for tracks by artist name
|
||||
const results = await invoke<SearchResults>('search_tracks', {
|
||||
query: artist.name,
|
||||
limit: 10,
|
||||
offset: 0
|
||||
});
|
||||
// Filter to only include tracks by this artist
|
||||
topTracks = results.items.filter(track =>
|
||||
track.performer?.name?.toLowerCase() === artist.name.toLowerCase()
|
||||
).slice(0, 5);
|
||||
} catch (err) {
|
||||
console.error('Failed to load top tracks:', err);
|
||||
} finally {
|
||||
tracksLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function handleTrackPlay(track: Track) {
|
||||
if (onTrackPlay) {
|
||||
onTrackPlay({
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: track.performer?.name || artist.name,
|
||||
album: track.album?.title || '',
|
||||
albumArt: track.album?.image?.large || track.album?.image?.thumbnail || '',
|
||||
duration: formatDuration(track.duration),
|
||||
durationSeconds: track.duration,
|
||||
hires: track.hires_streamable
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlayAllTracks() {
|
||||
if (topTracks.length === 0 || !onTrackPlay) return;
|
||||
|
||||
const queueTracks = topTracks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.performer?.name || artist.name,
|
||||
album: t.album?.title || '',
|
||||
duration_secs: t.duration,
|
||||
artwork_url: t.album?.image?.large || t.album?.image?.thumbnail || '',
|
||||
}));
|
||||
|
||||
try {
|
||||
await invoke('set_queue', { tracks: queueTracks, startIndex: 0 });
|
||||
handleTrackPlay(topTracks[0]);
|
||||
} catch (err) {
|
||||
console.error('Failed to set queue:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
imageError = true;
|
||||
@@ -114,6 +215,49 @@
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Top Tracks Section -->
|
||||
{#if topTracks.length > 0 || tracksLoading}
|
||||
<div class="top-tracks-section">
|
||||
<div class="section-header-row">
|
||||
<h2 class="section-title">Popular Tracks</h2>
|
||||
{#if topTracks.length > 0}
|
||||
<button class="play-all-btn" onclick={handlePlayAllTracks}>
|
||||
<Play size={14} fill="white" color="white" />
|
||||
<span>Play All</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if tracksLoading}
|
||||
<div class="tracks-loading">Loading tracks...</div>
|
||||
{:else}
|
||||
<div class="tracks-list">
|
||||
{#each topTracks as track, index}
|
||||
<button class="track-row" onclick={() => handleTrackPlay(track)}>
|
||||
<div class="track-number">{index + 1}</div>
|
||||
<div class="track-artwork">
|
||||
{#if track.album?.image?.thumbnail || track.album?.image?.small}
|
||||
<img src={track.album?.image?.thumbnail || track.album?.image?.small} alt={track.title} />
|
||||
{:else}
|
||||
<div class="track-artwork-placeholder">
|
||||
<Music size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="track-info">
|
||||
<div class="track-title">{track.title}</div>
|
||||
<div class="track-album">{track.album?.title || ''}</div>
|
||||
</div>
|
||||
<div class="track-duration">{formatDuration(track.duration)}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Discography Section -->
|
||||
<div class="discography">
|
||||
<h2 class="section-title">Discography</h2>
|
||||
@@ -306,6 +450,129 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Top Tracks */
|
||||
.top-tracks-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header-row .section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.play-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--accent-primary);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.play-all-btn:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.tracks-loading {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.track-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.track-row:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.track-number {
|
||||
width: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track-artwork {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-artwork img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-artwork-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-album {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-duration {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.artist-header {
|
||||
|
||||
@@ -1223,6 +1223,7 @@
|
||||
artist={selectedArtist}
|
||||
onBack={goBack}
|
||||
onAlbumClick={handleAlbumClick}
|
||||
onTrackPlay={handlePlaylistTrackPlay}
|
||||
/>
|
||||
{:else if activeView === 'library'}
|
||||
<LocalLibraryView
|
||||
|
||||
Reference in new issue
Block a user