feat: mark the playing song in lists
This commit is contained in:
1 parent
f6c925d3bd
commit
a4805e957d
11 files changed
+73
-14
No files matched your search
@@ -1,2 +1,3 @@
|
||||
export type { RoomNotice, RoomState } from './replica'
|
||||
export { roomStore } from './room-store'
|
||||
export { sameTrack } from './track'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
? (
|
||||
|
||||
@@ -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)}`}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function PlayingBars() {
|
||||
return (
|
||||
<span className="playing-bars" role="img" aria-label="正在播放">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user