feat: mark the playing song in lists

This commit is contained in:
DiheChen committed 2026-11-05 12:26:21 +08:00
1 parent f6c925d3bd
commit a4805e957d
11 files changed
+73 -14

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
export type { RoomNotice, RoomState } from './replica'
export { roomStore } from './room-store'
export { sameTrack } from './track'
+17
View File
@@ -0,0 +1,17 @@
import type { Track } from '@/core/realtime'
import { describe, expect, it } from 'vitest'
import { sameTrack } from './track'
describe('sameTrack', () => {
const track: Track = { source: 'qq', id: '1' }
it('音源和 ID 都相同才是同一首歌', () => {
expect(sameTrack(track, { source: 'qq', id: '1' })).toBe(true)
expect(sameTrack(track, { source: 'qq', id: '2' })).toBe(false)
expect(sameTrack(track, { source: 'netease', id: '1' })).toBe(false)
})
it('没有在放的歌时不相同', () => {
expect(sameTrack(track, undefined)).toBe(false)
})
})
+5
View File
@@ -0,0 +1,5 @@
import type { Track } from '@/core/realtime'
export function sameTrack(a: Track, b: Track | undefined): boolean {
return a.source === b?.source && a.id === b.id
}
+10 -6
View File
@@ -1,12 +1,12 @@
import type { PlayRecord } from './records'
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { roomStore } from '@/core/room'
import { roomStore, sameTrack } from '@/core/room'
import { enqueue, sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { formatClock } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, SongArtwork } from '@/shared/ui'
import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, PlayingBars, SongArtwork } from '@/shared/ui'
import { historyStore } from './history-store'
import './history.css'
@@ -22,15 +22,18 @@ function playedOn(ms: number): string {
return new Date(ms).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
function HistoryRow({ record, compact, connected }: { record: PlayRecord, compact: boolean, connected: boolean }) {
function HistoryRow({ record, compact, connected, playing }: { record: PlayRecord, compact: boolean, connected: boolean, playing: boolean }) {
const { title, artist, album, coverUrl, pageUrl } = record.meta
const performer = artist || '未知艺术家'
const source = sourceName(record.track)
return (
<div className="song-row">
<div className="song-row" data-playing={playing || undefined}>
<SongArtwork coverUrl={coverUrl} title={title} href={pageUrl} sourceName={source} />
<div className="song-copy">
<strong className="text-ui-base truncate">{title}</strong>
<span className="song-title">
{playing && <PlayingBars />}
<strong className="text-ui-base truncate">{title}</strong>
</span>
<span className="text-ui-sm muted truncate">{[performer, source, album].filter(Boolean).join(' · ')}</span>
</div>
{!compact && <time className="history-time text-ui-sm muted" dateTime={new Date(record.playedAtMs).toISOString()}>{formatClock(record.playedAtMs)}</time>}
@@ -59,6 +62,7 @@ function HistoryRow({ record, compact, connected }: { record: PlayRecord, compac
export function HistoryPage({ compact = false, filter = '', onFilter }: { compact?: boolean, filter?: string, onFilter?: (filter: string) => void }) {
const plays = useStore(historyStore, s => s.plays)
const connected = useStore(roomStore, s => s.status === 'open')
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
const [limit, setLimit] = useState(batchSize)
const [confirming, setConfirming] = useState(false)
const visible = plays.filter(record => matches(record, filter))
@@ -100,7 +104,7 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
return (
<li key={`${record.track.source}:${record.track.id}`}>
{(index === 0 || playedOn(shown[index - 1].playedAtMs) !== day) && <h2 className="history-date text-ui-sm muted">{day}</h2>}
<HistoryRow record={record} compact={compact} connected={connected} />
<HistoryRow record={record} compact={compact} connected={connected} playing={sameTrack(record.track, playing)} />
</li>
)
})}
+4 -3
View File
@@ -4,7 +4,7 @@ import { Heart, ListMusic, Trash2 } from 'lucide-react'
import { roomStore } from '@/core/room'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { EmptyState, Select, SongArtwork, SongList } from '@/shared/ui'
import { EmptyState, PlayingBars, Select, SongArtwork, SongList } from '@/shared/ui'
import { likeEntry, removeEntry, setPlayMode } from './actions'
import { sourceName } from './source'
import './queue.css'
@@ -26,7 +26,7 @@ function songItem(entry: Entry): SongItem {
}
}
function QueueCard({ label, entry, className = '' }: { label: string, entry: Entry, className?: string }) {
function QueueCard({ label, entry, className = '', playing = false }: { label: string, entry: Entry, className?: string, playing?: boolean }) {
const song = songItem(entry)
return (
<div className={`queue-current ${className}`}>
@@ -36,6 +36,7 @@ function QueueCard({ label, entry, className = '' }: { label: string, entry: Ent
<strong className="text-ui-base truncate">{song.title}</strong>
<span className="text-ui-sm muted truncate">{[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')}</span>
</div>
{playing && <PlayingBars />}
</div>
)
}
@@ -66,7 +67,7 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
]}
/>
</div>
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} />}
{nowPlaying && <QueueCard label="正在播放" entry={nowPlaying.entry} playing />}
{pending && <QueueCard label="下一首" entry={pending} className="queue-pending" />}
{items.length
? (
+9 -4
View File
@@ -3,12 +3,12 @@ import type { PlaylistSummary, TrackSummary } from '@/core/realtime'
import type { SearchCategory, SearchSource } from '@/features/history'
import type { SongItem } from '@/shared/ui'
import { ArrowLeft, ArrowRight, CirclePlus, Clock3, Search, Sparkles } from 'lucide-react'
import { roomStore } from '@/core/room'
import { roomStore, sameTrack } from '@/core/room'
import { historyStore } from '@/features/history'
import { enqueue } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, EmptyState, IconButton, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
import { Artwork, AsyncIconButton, EmptyState, IconButton, PlayingBars, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
import { SearchBox } from './search-box'
import { canOpenPlaylists, keywordSearch, pageSize, recordSearch, searchStore } from './search-store'
import './search.css'
@@ -38,9 +38,10 @@ function songItem({ track, meta }: TrackSummary): SongItem {
}
function TrackRows({ items, connected }: { items: TrackSummary[], connected: boolean }) {
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
return (
<SongList
songs={items.map(songItem)}
songs={items.map(item => ({ ...songItem(item), playing: sameTrack(item.track, playing) }))}
actions={(song, index) => [{
icon: CirclePlus,
label: `点歌${bookTitle(song.title)}`,
@@ -110,6 +111,7 @@ function Overview({ tracks, playlists, connected, onCategory, onOpenPlaylist }:
onOpenPlaylist: (playlist: PlaylistSummary) => void
}) {
const featured = tracks.at(0)
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
return (
<>
{featured && (
@@ -121,7 +123,10 @@ function Overview({ tracks, playlists, connected, onCategory, onOpenPlaylist }:
<h3 className="text-ui-xl truncate" title={featured.meta.title}>{featured.meta.title}</h3>
<p className="text-ui-caption muted truncate">{featured.meta.artist || '未知艺术家'}</p>
<div className="search-feature-footer">
<span className="text-ui-caption">歌曲</span>
<span className="search-feature-kind text-ui-caption">
{sameTrack(featured.track, playing) && <PlayingBars />}
歌曲
</span>
<AsyncIconButton
icon={CirclePlus}
label={`点歌${bookTitle(featured.meta.title)}`}
+1
View File
@@ -39,6 +39,7 @@
.search-featured-song .artwork { width: 88px; background: var(--color-card-hover); }
.search-featured-song h3 { margin-bottom: 4px; }
.search-feature-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 6px; }
.search-feature-kind { display: inline-flex; align-items: center; gap: 8px; }
.search-feature-footer .icon-button { width: 36px; height: 36px; }
.search-feature-footer .icon-button svg { width: 26px; height: 26px; }
.search-preview .song-row { display: grid; grid-template-columns: 42px minmax(0, 1fr) 48px 36px; min-height: 64px; padding: 8px; gap: 12px; }
+1
View File
@@ -9,6 +9,7 @@ export { lazyPage } from './lazy-page'
export { Modal } from './modal'
export { notificationStore } from './notification-store'
export { Notifications } from './notifications'
export { PlayingBars } from './playing-bars'
export { Select } from './select'
export type { SelectOption } from './select'
export { Skeleton } from './skeleton'
+9
View File
@@ -0,0 +1,9 @@
export function PlayingBars() {
return (
<span className="playing-bars" role="img" aria-label="正在播放">
<i />
<i />
<i />
</span>
)
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { LucideIcon } from 'lucide-react'
import { formatDuration } from '@/shared/lib/time'
import { AsyncIconButton } from './icon-button'
import { PlayingBars } from './playing-bars'
import { SongArtwork } from './song-artwork'
export interface SongItem {
@@ -16,6 +17,7 @@ export interface SongItem {
sourceName?: string
note?: string
badge?: string
playing?: boolean
}
export interface SongAction {
@@ -29,11 +31,12 @@ export function SongList({ songs, actions }: { songs: SongItem[], actions: (song
return (
<ol className="song-list">
{songs.map((song, index) => (
<li className="song-row" key={song.key}>
<li className="song-row" data-playing={song.playing || undefined} key={song.key}>
<span className="song-index text-ui-sm muted">{String(index + 1).padStart(2, '0')}</span>
<SongArtwork coverUrl={song.coverUrl} title={song.title} href={song.sourceUrl} sourceName={song.sourceName} />
<div className="song-copy">
<span className="song-title">
{song.playing && <PlayingBars />}
<strong className="text-ui-base truncate">{song.title}</strong>
{song.badge && <span className="song-badge text-ui-xs">{song.badge}</span>}
</span>
+12
View File
@@ -44,6 +44,18 @@
.song-list { list-style: none; padding: 0; margin: 0; }
.song-row { display: flex; align-items: center; gap: 12px; padding: 10px 8px; border-radius: 8px; min-height: 68px; content-visibility: auto; contain-intrinsic-size: auto 68px; }
.song-row:hover { background: var(--color-surface-hover); }
.song-row[data-playing] { background: color-mix(in srgb, var(--color-brand) 10%, transparent); }
.song-row[data-playing]:hover { background: color-mix(in srgb, var(--color-brand) 14%, transparent); }
.song-row[data-playing] strong { color: var(--color-brand); }
.playing-bars { display: inline-flex; align-items: flex-end; flex-shrink: 0; gap: 2px; width: 14px; height: 12px; }
.playing-bars i { flex: 1; height: 100%; border-radius: 1px; background: var(--color-brand); transform: scaleY(.6); transform-origin: bottom; animation: playing-bar 720ms ease-in-out infinite alternate; }
.playing-bars i:nth-child(2) { transform: scaleY(1); animation-delay: -420ms; }
.playing-bars i:nth-child(3) { transform: scaleY(.8); animation-delay: -220ms; }
:root[data-performance='low'] .playing-bars i { animation: none; }
@keyframes playing-bar {
from { transform: scaleY(.3); }
to { transform: scaleY(1); }
}
.song-row .artwork { width: 42px; }
.song-row .artwork > svg { width: 22px; }
.song-action { color: var(--color-foreground-subtle); }