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) => ( -
    1. +
    2. {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); }