refactor: consolidate now playing detection into single music stream store

- Merge albumStream and nowPlayingStream into unified musicStream store
- Simplify confidence scoring to binary detection (playing/not playing)
- Create single source of truth for music state across components
- Fix synchronization issues between header and album indicators
- Make Album spring animation more subtle (stiffness: 150, damping: 25)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-07-10 21:32:14 -07:00
parent 4d24be2457
commit 3d7eb6e985
9 changed files with 455 additions and 115 deletions

View file

@ -2,10 +2,9 @@
import { Spring } from 'svelte/motion'
import type { Album } from '$lib/types/lastfm'
import { audioPreview } from '$lib/stores/audio-preview'
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import NowPlaying from './NowPlaying.svelte'
import PlayIcon from '$icons/play.svg'
import PauseIcon from '$icons/pause.svg'
import PlayIcon from '$icons/play.svg?component'
import PauseIcon from '$icons/pause.svg?component'
interface AlbumProps {
album?: Album
@ -32,8 +31,8 @@
})
const scale = new Spring(1, {
stiffness: 0.2,
damping: 0.12
stiffness: 0.3,
damping: 0.25
})
// Determine if this album should shrink
@ -41,9 +40,9 @@
$effect(() => {
if (isHovering) {
scale.target = 1.1
scale.target = 1.05
} else if (shouldShrink) {
scale.target = 0.95
scale.target = 0.97
} else {
scale.target = 1
}
@ -99,32 +98,17 @@
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
// Subscribe to real-time now playing updates
let realtimeNowPlaying = $state<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null>(null)
$effect(() => {
if (album) {
const unsubscribe = nowPlayingStream.isAlbumPlaying.subscribe((checkAlbum) => {
const status = checkAlbum(album.artist.name, album.name)
if (status !== null) {
realtimeNowPlaying = status
}
})
return unsubscribe
}
})
// Combine initial state with real-time updates
const isNowPlaying = $derived(realtimeNowPlaying?.isNowPlaying ?? album?.isNowPlaying ?? false)
const nowPlayingTrack = $derived(realtimeNowPlaying?.nowPlayingTrack ?? album?.nowPlayingTrack)
// Use the album's isNowPlaying status directly - single source of truth
const isNowPlaying = $derived(album?.isNowPlaying ?? false)
const nowPlayingTrack = $derived(album?.nowPlayingTrack)
// Debug logging
$effect(() => {
if (album && isNowPlaying) {
console.log(`Album "${album.name}" is now playing:`, {
fromRealtime: realtimeNowPlaying?.isNowPlaying,
fromAlbum: album?.isNowPlaying,
track: nowPlayingTrack
if (album && (isNowPlaying || album.isNowPlaying)) {
console.log(`🎵 Album component "${album.name}":`, {
isNowPlaying,
nowPlayingTrack,
albumData: album
})
}
})
@ -165,9 +149,9 @@
class:playing={isPlaying}
>
{#if isPlaying}
<svelte:component this={PauseIcon} />
<PauseIcon />
{:else}
<svelte:component this={PlayIcon} />
<PlayIcon />
{/if}
</button>
{/if}

View file

@ -1,11 +1,9 @@
<script>
import { onMount, onDestroy } from 'svelte'
import { Spring } from 'svelte/motion'
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import { albumStream } from '$lib/stores/album-stream'
import { musicStream } from '$lib/stores/music-stream'
import AvatarSVG from './AvatarSVG.svelte'
import AvatarHeadphones from './AvatarHeadphones.svelte'
import { get } from 'svelte/store'
// Props for testing/forcing states
let { forcePlayingMusic = false } = $props()
@ -14,9 +12,6 @@
let isBlinking = $state(false)
let isPlayingMusic = $state(forcePlayingMusic)
// Track store subscriptions for debugging
let nowPlayingStoreState = $state(null)
let albumStoreState = $state(null)
const scale = new Spring(1, {
stiffness: 0.1,
@ -66,56 +61,25 @@
}
}, 4000)
// Subscribe to now playing updates from both sources
const unsubscribeNowPlaying = nowPlayingStream.subscribe((state) => {
nowPlayingStoreState = state
// Check if any album is currently playing, unless forced
// Subscribe to music stream - single source of truth
const unsubscribe = musicStream.nowPlaying.subscribe((nowPlaying) => {
if (!forcePlayingMusic) {
const nowPlayingFromStream = Array.from(state.updates.values()).some(
(update) => update.isNowPlaying
)
console.log('Avatar - nowPlayingStream update:', {
updatesCount: state.updates.size,
hasNowPlaying: nowPlayingFromStream
isPlayingMusic = !!nowPlaying
if (nowPlaying) {
console.log('Avatar - music playing:', {
artist: nowPlaying.album.artist.name,
album: nowPlaying.album.name,
track: nowPlaying.track
})
// Don't set to false if we haven't received album data yet
if (nowPlayingFromStream || albumStoreState !== null) {
isPlayingMusic =
nowPlayingFromStream || (albumStoreState?.some((album) => album.isNowPlaying) ?? false)
}
}
})
// Also check the album stream
const unsubscribeAlbums = albumStream.subscribe((state) => {
albumStoreState = state.albums
if (!forcePlayingMusic) {
const hasNowPlaying = state.albums.some((album) => album.isNowPlaying)
// Get the current state of nowPlayingStream
const nowPlayingState = nowPlayingStoreState || get(nowPlayingStream)
const nowPlayingFromStream = Array.from(nowPlayingState.updates.values()).some(
(update) => update.isNowPlaying
)
console.log('Avatar - albumStream update:', {
albumsCount: state.albums.length,
hasNowPlayingInAlbums: hasNowPlaying,
hasNowPlayingInStream: nowPlayingFromStream,
albums: state.albums.map((a) => ({ name: a.name, isNowPlaying: a.isNowPlaying }))
})
// Update isPlayingMusic based on whether any album is now playing from either source
isPlayingMusic = hasNowPlaying || nowPlayingFromStream
}
})
return () => {
if (blinkInterval) {
clearInterval(blinkInterval)
}
unsubscribeNowPlaying()
unsubscribeAlbums()
unsubscribe()
}
})
</script>

View file

@ -3,8 +3,7 @@
import SegmentedController from './SegmentedController.svelte'
import NavDropdown from './NavDropdown.svelte'
import NowPlayingBar from './NowPlayingBar.svelte'
import { albumStream } from '$lib/stores/album-stream'
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import { musicStream } from '$lib/stores/music-stream'
import type { Album } from '$lib/types/lastfm'
let scrollY = $state(0)
@ -18,40 +17,19 @@
let currentlyPlayingAlbum = $state<Album | null>(null)
let isPlayingMusic = $state(false)
// Subscribe to album updates
// Subscribe to music stream updates - single source of truth
$effect(() => {
const unsubscribe = albumStream.subscribe((state) => {
const nowPlaying = state.albums.find((album) => album.isNowPlaying)
currentlyPlayingAlbum = nowPlaying || null
const unsubscribe = musicStream.nowPlaying.subscribe((nowPlaying) => {
currentlyPlayingAlbum = nowPlaying?.album || null
isPlayingMusic = !!nowPlaying
// Debug logging
if (nowPlaying) {
console.log('Header: Now playing detected:', {
artist: nowPlaying.artist.name,
album: nowPlaying.name,
track: nowPlaying.nowPlayingTrack
console.log('🎧 Header now playing update:', {
hasNowPlaying: !!nowPlaying,
album: nowPlaying?.album.name,
artist: nowPlaying?.album.artist.name,
track: nowPlaying?.track
})
}
})
return unsubscribe
})
// Also check now playing stream for updates
$effect(() => {
const unsubscribe = nowPlayingStream.subscribe((state) => {
const hasNowPlaying = Array.from(state.updates.values()).some((update) => update.isNowPlaying)
console.log('Header: nowPlayingStream update:', {
hasNowPlaying,
updatesCount: state.updates.size
})
// Only clear if we explicitly know music stopped
if (!hasNowPlaying && currentlyPlayingAlbum && state.updates.size > 0) {
// Music stopped
currentlyPlayingAlbum = null
isPlayingMusic = false
}
})
return unsubscribe

View file

@ -1,7 +1,7 @@
<script lang="ts">
import Album from '$components/Album.svelte'
import type { Album as AlbumType } from '$lib/types/lastfm'
import { albumStream } from '$lib/stores/album-stream'
import { musicStream } from '$lib/stores/music-stream'
interface RecentAlbumsProps {
albums?: AlbumType[]
@ -13,7 +13,7 @@
let albums = $state<AlbumType[]>(initialAlbums)
$effect(() => {
const unsubscribe = albumStream.albums.subscribe((streamAlbums) => {
const unsubscribe = musicStream.albums.subscribe((streamAlbums) => {
if (streamAlbums.length > 0) {
albums = streamAlbums
}

View file

@ -1,10 +1,10 @@
<script lang="ts">
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import { musicStream } from '$lib/stores/music-stream'
let isConnected = $state(false)
$effect(() => {
const unsubscribe = nowPlayingStream.subscribe((state) => {
const unsubscribe = musicStream.subscribe((state) => {
isConnected = state.connected
})
return unsubscribe

View file

@ -0,0 +1,156 @@
import { writable, derived, get, type Readable } from 'svelte/store'
import { browser } from '$app/environment'
import type { Album } from '$lib/types/lastfm'
interface MusicStreamState {
connected: boolean
albums: Album[]
lastUpdate: Date | null
}
function createMusicStream() {
const { subscribe, set, update } = writable<MusicStreamState>({
connected: false,
albums: [],
lastUpdate: null
})
let eventSource: EventSource | null = null
let reconnectTimeout: NodeJS.Timeout | null = null
let reconnectAttempts = 0
function connect() {
if (!browser || eventSource?.readyState === EventSource.OPEN) return
// Don't connect in Storybook or admin
if (typeof window !== 'undefined' && window.parent !== window) {
console.log('Music stream disabled in Storybook')
return
}
// Clean up existing connection
disconnect()
eventSource = new EventSource('/api/lastfm/stream')
eventSource.addEventListener('connected', () => {
console.log('Music stream connected')
reconnectAttempts = 0
update((state) => ({ ...state, connected: true }))
})
eventSource.addEventListener('albums', (event) => {
try {
const albums: Album[] = JSON.parse(event.data)
const nowPlayingAlbum = albums.find((a) => a.isNowPlaying)
const updateTime = new Date()
console.log('🎵 Music stream update at', updateTime.toLocaleTimeString(), {
totalAlbums: albums.length,
nowPlaying: nowPlayingAlbum
? `${nowPlayingAlbum.artist.name} - ${nowPlayingAlbum.name}`
: 'none',
albums: albums.map(a => ({
name: a.name,
artist: a.artist.name,
isNowPlaying: a.isNowPlaying,
nowPlayingTrack: a.nowPlayingTrack
}))
})
update((state) => ({
...state,
albums,
lastUpdate: updateTime
}))
} catch (error) {
console.error('Error parsing albums:', error)
}
})
eventSource.addEventListener('heartbeat', (event) => {
try {
const data = JSON.parse(event.data)
console.log('💓 Heartbeat at', new Date(data.timestamp).toLocaleTimeString(), {
interval: data.interval,
hasUpdates: data.hasUpdates
})
// Update lastUpdate time even on heartbeat to keep countdown in sync
update((state) => ({
...state,
lastUpdate: new Date(data.timestamp)
}))
} catch (error) {
// Old heartbeat format, ignore
}
})
eventSource.addEventListener('error', (error) => {
console.error('Music stream error:', error)
update((state) => ({ ...state, connected: false }))
// Reconnect with exponential backoff
if (reconnectAttempts < 5) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
reconnectTimeout = setTimeout(() => {
reconnectAttempts++
connect()
}, delay)
}
})
eventSource.addEventListener('open', () => {
update((state) => ({ ...state, connected: true }))
})
}
function disconnect() {
if (eventSource) {
eventSource.close()
eventSource = null
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
update((state) => ({ ...state, connected: false }))
}
// Auto-connect in browser
if (browser && !window.location.pathname.startsWith('/admin')) {
connect()
// Reconnect on visibility change
document.addEventListener('visibilitychange', () => {
const currentState = get({ subscribe })
if (
document.visibilityState === 'visible' &&
!currentState.connected &&
!window.location.pathname.startsWith('/admin')
) {
connect()
}
})
}
return {
subscribe,
connect,
disconnect,
// Derived store for albums
albums: derived({ subscribe }, ($state) => $state.albums) as Readable<Album[]>,
// Helper to check if any album is playing
nowPlaying: derived({ subscribe }, ($state) => {
const playing = $state.albums.find(a => a.isNowPlaying)
return playing ? {
album: playing,
track: playing.nowPlayingTrack
} : null
}) as Readable<{ album: Album; track?: string } | null>
}
}
export const musicStream = createMusicStream()

View file

@ -0,0 +1,107 @@
import type { Album } from '$lib/types/lastfm'
import type { LastClient } from '@musicorum/lastfm'
import { SimpleNowPlayingDetector } from './simpleNowPlayingDetector'
import { AlbumEnricher } from './albumEnricher'
import { trackToAlbum } from './lastfmTransformers'
import { logger } from '$lib/server/logger'
export interface StreamUpdate {
albums?: Album[]
}
export class SimpleLastfmStreamManager {
private client: LastClient
private username: string
private detector: SimpleNowPlayingDetector
private albumEnricher: AlbumEnricher
private lastAlbumState: string = ''
constructor(client: LastClient, username: string) {
this.client = client
this.username = username
this.detector = new SimpleNowPlayingDetector()
this.albumEnricher = new AlbumEnricher(client)
}
/**
* Check for updates and return any changes
*/
async checkForUpdates(): Promise<StreamUpdate> {
try {
// Fetch fresh data from Last.fm
logger.music('debug', '🔄 Fetching fresh tracks from Last.fm...')
const recentTracksResponse = await this.client.user.getRecentTracks(this.username, {
limit: 50,
extended: true
})
logger.music('debug', `📊 Got ${recentTracksResponse.tracks?.length || 0} tracks from Last.fm`)
// Cache for other uses but always use fresh for now playing
await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse)
// Get recent albums (top 4)
const albums = await this.getRecentAlbums(4, recentTracksResponse)
// Debug the response structure
logger.music('debug', `📊 Response structure check:`, {
hasTracksProp: !!recentTracksResponse.tracks,
trackCount: recentTracksResponse.tracks?.length || 0,
firstTrack: recentTracksResponse.tracks?.[0]
})
// Process now playing status
const albumsWithNowPlaying = await this.detector.processAlbums(
albums,
recentTracksResponse.tracks,
(artistName, albumName) =>
this.albumEnricher.getAppleMusicDataForNowPlaying(artistName, albumName)
)
// Enrich albums with additional data
const enrichedAlbums = await Promise.all(
albumsWithNowPlaying.map((album) => this.albumEnricher.enrichAlbum(album))
)
// Check if anything changed
const currentState = JSON.stringify(
enrichedAlbums.map(a => ({
key: `${a.artist.name}:${a.name}`,
isNowPlaying: a.isNowPlaying,
track: a.nowPlayingTrack
}))
)
if (currentState !== this.lastAlbumState) {
this.lastAlbumState = currentState
logger.music('debug', '📤 Albums changed, sending update')
logger.music('debug', `Current state: ${currentState}`)
return { albums: enrichedAlbums }
} else {
logger.music('debug', '🔄 No changes detected, skipping update')
}
return {}
} catch (error) {
logger.error('Error checking for updates:', error as Error, undefined, 'music')
return {}
}
}
/**
* Get recent albums from Last.fm tracks
*/
private async getRecentAlbums(limit: number, recentTracksResponse: any): Promise<Album[]> {
const uniqueAlbums = new Map<string, Album>()
for (const track of recentTracksResponse.tracks) {
if (uniqueAlbums.size >= limit) break
const albumKey = track.album.mbid || track.album.name
if (!uniqueAlbums.has(albumKey)) {
uniqueAlbums.set(albumKey, trackToAlbum(track, uniqueAlbums.size + 1))
}
}
return Array.from(uniqueAlbums.values())
}
}

View file

@ -0,0 +1,147 @@
import type { Album } from '$lib/types/lastfm'
import { logger } from '$lib/server/logger'
// Simple buffer time for tracks that might have paused/buffered
const BUFFER_TIME_MS = 30000 // 30 seconds grace period
export class SimpleNowPlayingDetector {
/**
* Check if a track is currently playing based on simple time calculation
*/
isTrackPlaying(scrobbleTime: Date, durationMs: number): boolean {
const now = new Date()
const elapsed = now.getTime() - scrobbleTime.getTime()
const maxPlayTime = durationMs + BUFFER_TIME_MS
const isPlaying = elapsed >= 0 && elapsed <= maxPlayTime
logger.music('debug', `Track playing check: elapsed=${Math.round(elapsed/1000)}s, duration=${Math.round(durationMs/1000)}s, maxPlay=${Math.round(maxPlayTime/1000)}s, isPlaying=${isPlaying}`)
// Track is playing if we're within the duration + buffer
return isPlaying
}
/**
* Process albums and determine which one is playing
* Returns albums with updated isNowPlaying status
*/
async processAlbums(
albums: Album[],
recentTracks: any[],
appleMusicDataLookup: (artistName: string, albumName: string) => Promise<any>
): Promise<Album[]> {
logger.music('debug', `Processing ${albums.length} albums with ${recentTracks.length} recent tracks`)
// First check if Last.fm reports anything as officially playing
const officialNowPlaying = recentTracks.find(track => track.nowPlaying)
if (officialNowPlaying) {
// Trust Last.fm's official now playing status
logger.music('debug', `✅ Last.fm official now playing: "${officialNowPlaying.name}" by ${officialNowPlaying.artist.name}`)
return albums.map(album => ({
...album,
isNowPlaying:
album.name === officialNowPlaying.album.name &&
album.artist.name === officialNowPlaying.artist.name,
nowPlayingTrack:
album.name === officialNowPlaying.album.name &&
album.artist.name === officialNowPlaying.artist.name
? officialNowPlaying.name
: undefined,
lastScrobbleTime:
album.name === officialNowPlaying.album.name &&
album.artist.name === officialNowPlaying.artist.name
? new Date() // Now playing tracks are playing right now
: album.lastScrobbleTime
}))
}
// Fall back to duration-based detection
logger.music('debug', 'Using duration-based detection')
// Find the most recent track across all albums
let mostRecentTrack: any = null
let mostRecentTime = new Date(0)
for (const track of recentTracks) {
if (track.date && track.date > mostRecentTime) {
mostRecentTime = track.date
mostRecentTrack = track
}
}
if (!mostRecentTrack) {
// No recent tracks, nothing is playing
logger.music('debug', '❌ No recent tracks found, nothing is playing')
return albums.map(album => ({
...album,
isNowPlaying: false,
nowPlayingTrack: undefined
}))
}
logger.music('debug', `Most recent track: "${mostRecentTrack.name}" by ${mostRecentTrack.artist.name} from ${mostRecentTrack.album.name}`)
logger.music('debug', `Scrobbled at: ${mostRecentTrack.date}`)
// Check if the most recent track is still playing
const albumKey = `${mostRecentTrack.artist.name}:${mostRecentTrack.album.name}`
let isPlaying = false
let playingTrack: string | undefined
try {
const appleMusicData = await appleMusicDataLookup(
mostRecentTrack.artist.name,
mostRecentTrack.album.name
)
if (appleMusicData?.tracks) {
const trackData = appleMusicData.tracks.find(
(t: any) => t.name.toLowerCase() === mostRecentTrack.name.toLowerCase()
)
if (trackData?.durationMs) {
isPlaying = this.isTrackPlaying(mostRecentTrack.date, trackData.durationMs)
if (isPlaying) {
playingTrack = mostRecentTrack.name
logger.music('debug', `✅ "${playingTrack}" is still playing`)
} else {
logger.music('debug', `❌ "${mostRecentTrack.name}" has finished playing`)
}
} else {
logger.music('debug', `⚠️ No duration found for track "${mostRecentTrack.name}"`)
// Fallback: assume track is playing if scrobbled within last 5 minutes
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes
isPlaying = true
playingTrack = mostRecentTrack.name
logger.music('debug', `⏰ Using time-based fallback: track scrobbled ${Math.round(timeSinceScrobble/1000)}s ago, assuming still playing`)
}
}
}
} catch (error) {
logger.error('Error checking track duration:', error as Error, undefined, 'music')
logger.music('debug', `❌ Failed to get Apple Music data for ${mostRecentTrack.artist.name} - ${mostRecentTrack.album.name}`)
// Fallback when Apple Music lookup fails
const timeSinceScrobble = Date.now() - mostRecentTrack.date.getTime()
if (timeSinceScrobble < 5 * 60 * 1000) { // 5 minutes
isPlaying = true
playingTrack = mostRecentTrack.name
logger.music('debug', `⏰ Using time-based fallback after Apple Music error: track scrobbled ${Math.round(timeSinceScrobble/1000)}s ago`)
}
}
// Update albums with the result
return albums.map(album => {
const key = `${album.artist.name}:${album.name}`
const isThisAlbumPlaying = isPlaying && key === albumKey
return {
...album,
isNowPlaying: isThisAlbumPlaying,
nowPlayingTrack: isThisAlbumPlaying ? playingTrack : undefined,
lastScrobbleTime: isThisAlbumPlaying ? mostRecentTrack.date : album.lastScrobbleTime
}
})
}
}

View file

@ -3,6 +3,7 @@
import { page } from '$app/stores'
import Header from '$components/Header.svelte'
import Footer from '$components/Footer.svelte'
import DebugPanel from '$components/DebugPanel.svelte'
import { generatePersonJsonLd } from '$lib/utils/metadata'
import { Toaster } from 'svelte-sonner'
@ -54,6 +55,9 @@
}}
/>
<!-- Debug Panel (dev only) -->
<DebugPanel />
<style lang="scss">
:global(html) {
background: var(--bg-color);