diff --git a/web/src/core/room/index.ts b/web/src/core/room/index.ts
index d3e4db7..9aa2bbd 100644
--- a/web/src/core/room/index.ts
+++ b/web/src/core/room/index.ts
@@ -1,2 +1,3 @@
export type { RoomNotice, RoomState } from './replica'
export { roomStore } from './room-store'
+export { sameTrack } from './track'
diff --git a/web/src/core/room/track.test.ts b/web/src/core/room/track.test.ts
new file mode 100644
index 0000000..ba89945
--- /dev/null
+++ b/web/src/core/room/track.test.ts
@@ -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)
+ })
+})
diff --git a/web/src/core/room/track.ts b/web/src/core/room/track.ts
new file mode 100644
index 0000000..cb17765
--- /dev/null
+++ b/web/src/core/room/track.ts
@@ -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
+}
diff --git a/web/src/features/history/history-page.tsx b/web/src/features/history/history-page.tsx
index 7e637c0..af4d41f 100644
--- a/web/src/features/history/history-page.tsx
+++ b/web/src/features/history/history-page.tsx
@@ -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 (
-
+
-
{title}
+
+ {playing && }
+ {title}
+
{[performer, source, album].filter(Boolean).join(' · ')}
{!compact &&
}
@@ -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 (
{(index === 0 || playedOn(shown[index - 1].playedAtMs) !== day) && {day}
}
-
+
)
})}
diff --git a/web/src/features/queue/queue-page.tsx b/web/src/features/queue/queue-page.tsx
index 173550e..bd2481a 100644
--- a/web/src/features/queue/queue-page.tsx
+++ b/web/src/features/queue/queue-page.tsx
@@ -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 (
@@ -36,6 +36,7 @@ function QueueCard({ label, entry, className = '' }: { label: string, entry: Ent
{song.title}
{[song.artist, song.sourceName, song.note].filter(Boolean).join(' · ')}
+ {playing &&
}
)
}
@@ -66,7 +67,7 @@ export function QueuePage({ compact = false }: { compact?: boolean }) {
]}
/>
- {nowPlaying && }
+ {nowPlaying && }
{pending && }
{items.length
? (
diff --git a/web/src/features/search/search-page.tsx b/web/src/features/search/search-page.tsx
index b72c859..16cfb87 100644
--- a/web/src/features/search/search-page.tsx
+++ b/web/src/features/search/search-page.tsx
@@ -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 (
({ ...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 }:
{featured.meta.title}
{featured.meta.artist || '未知艺术家'}
-
歌曲
+
+ {sameTrack(featured.track, playing) && }
+ 歌曲
+
+
+
+
+
+ )
+}
diff --git a/web/src/shared/ui/song-list.tsx b/web/src/shared/ui/song-list.tsx
index f03e158..160931e 100644
--- a/web/src/shared/ui/song-list.tsx
+++ b/web/src/shared/ui/song-list.tsx
@@ -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 (
{songs.map((song, index) => (
- -
+
-
{String(index + 1).padStart(2, '0')}
+ {song.playing && }
{song.title}
{song.badge && {song.badge}}
diff --git a/web/src/shared/ui/ui.css b/web/src/shared/ui/ui.css
index 0d1bed1..2037b8a 100644
--- a/web/src/shared/ui/ui.css
+++ b/web/src/shared/ui/ui.css
@@ -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); }