fix: improve now playing detection reliability

- Always fetch fresh data from Last.fm for now playing detection
- Add confidence-based detection with progress tracking
- Implement dynamic polling intervals (10s when playing, 30s idle)
- Add marquee animation for now playing text in header
- Fix release date formatting to show only year
- Add gradient fade effects to marquee edges
- Enhanced logging for debugging detection issues

🤖 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 01:25:31 -07:00
parent e21d5eab9d
commit 103c69664b
5 changed files with 301 additions and 114 deletions

View file

@ -2,6 +2,7 @@
import Avatar from './Avatar.svelte' import Avatar from './Avatar.svelte'
import SegmentedController from './SegmentedController.svelte' import SegmentedController from './SegmentedController.svelte'
import NavDropdown from './NavDropdown.svelte' import NavDropdown from './NavDropdown.svelte'
import NowPlayingBar from './NowPlayingBar.svelte'
import { albumStream } from '$lib/stores/album-stream' import { albumStream } from '$lib/stores/album-stream'
import { nowPlayingStream } from '$lib/stores/now-playing-stream' import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import type { Album } from '$lib/types/lastfm' import type { Album } from '$lib/types/lastfm'
@ -23,6 +24,15 @@
const nowPlaying = state.albums.find((album) => album.isNowPlaying) const nowPlaying = state.albums.find((album) => album.isNowPlaying)
currentlyPlayingAlbum = nowPlaying || null currentlyPlayingAlbum = nowPlaying || null
isPlayingMusic = !!nowPlaying isPlayingMusic = !!nowPlaying
// Debug logging
if (nowPlaying) {
console.log('Header: Now playing detected:', {
artist: nowPlaying.artist.name,
album: nowPlaying.name,
track: nowPlaying.nowPlayingTrack
})
}
}) })
return unsubscribe return unsubscribe
@ -32,7 +42,12 @@
$effect(() => { $effect(() => {
const unsubscribe = nowPlayingStream.subscribe((state) => { const unsubscribe = nowPlayingStream.subscribe((state) => {
const hasNowPlaying = Array.from(state.updates.values()).some((update) => update.isNowPlaying) const hasNowPlaying = Array.from(state.updates.values()).some((update) => update.isNowPlaying)
if (!hasNowPlaying && currentlyPlayingAlbum) { 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 // Music stopped
currentlyPlayingAlbum = null currentlyPlayingAlbum = null
isPlayingMusic = false isPlayingMusic = false
@ -82,31 +97,25 @@
style="--gradient-opacity: {gradientOpacity}; --padding-progress: {paddingProgress}" style="--gradient-opacity: {gradientOpacity}; --padding-progress: {paddingProgress}"
> >
<div class="header-content"> <div class="header-content">
<a <a
href="/about" href="/about"
class="header-link" class="header-link"
aria-label="@jedmund" aria-label="@jedmund"
onmouseenter={() => isHoveringAvatar = true} onmouseenter={() => {
onmouseleave={() => isHoveringAvatar = false} isHoveringAvatar = true
console.log('Header: Hovering avatar, showing now playing?', {
isHoveringAvatar: true,
isPlayingMusic,
currentlyPlayingAlbum: currentlyPlayingAlbum?.name
})
}}
onmouseleave={() => (isHoveringAvatar = false)}
> >
<Avatar /> <Avatar />
</a> </a>
<div class="nav-desktop"> <div class="nav-desktop">
{#if isHoveringAvatar && isPlayingMusic && currentlyPlayingAlbum} {#if isHoveringAvatar && isPlayingMusic && currentlyPlayingAlbum}
<div class="now-playing"> <NowPlayingBar album={currentlyPlayingAlbum} {getAlbumArtwork} />
<span class="music-note"></span>
{#if getAlbumArtwork(currentlyPlayingAlbum)}
<img
src={getAlbumArtwork(currentlyPlayingAlbum)}
alt="{currentlyPlayingAlbum.name} album cover"
class="album-thumbnail"
/>
{/if}
<span class="track-info">
{currentlyPlayingAlbum.artist.name} - {currentlyPlayingAlbum.nowPlayingTrack || currentlyPlayingAlbum.name}
</span>
<span class="music-note"></span>
</div>
{:else} {:else}
<SegmentedController /> <SegmentedController />
{/if} {/if}
@ -199,51 +208,4 @@
display: block; display: block;
} }
} }
.now-playing {
display: flex;
align-items: center;
gap: $unit-2x;
padding: $unit-2x $unit-3x;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: $corner-radius-3xl;
border: 1px solid rgba(255, 255, 255, 0.2);
min-width: 300px;
height: 52px;
transition: all $transition-fast ease;
.music-note {
font-size: $font-size-md;
opacity: 0.8;
animation: pulse 2s ease-in-out infinite;
}
.album-thumbnail {
width: 28px;
height: 28px;
border-radius: $corner-radius-sm;
object-fit: cover;
}
.track-info {
flex: 1;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
}
@keyframes pulse {
0%, 100% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
}
</style> </style>

View file

@ -0,0 +1,144 @@
<script lang="ts">
import type { Album } from '$lib/types/lastfm'
interface Props {
album: Album
getAlbumArtwork: (album: Album) => string
}
let { album, getAlbumArtwork }: Props = $props()
const trackText = $derived(`${album.artist.name} — ${album.name}${
album.appleMusicData?.releaseDate
? ` (${new Date(album.appleMusicData.releaseDate).getFullYear()})`
: ''
} — ${album.nowPlayingTrack || album.name}`)
</script>
<nav class="now-playing-bar">
<div class="now-playing-content">
{#if getAlbumArtwork(album)}
<img src={getAlbumArtwork(album)} alt="{album.name} album cover" class="album-thumbnail" />
{/if}
<span class="track-info">
<span class="now-playing-label">Now playing</span>
<div class="marquee-container">
<span class="now-playing-title">{trackText}</span>
<span class="now-playing-title" aria-hidden="true">{trackText}</span>
</div>
</span>
</div>
</nav>
<style lang="scss">
.now-playing-bar {
display: flex;
align-items: center;
gap: 4px;
background: $gray-100;
padding: calc($unit-half - 1px) $unit-half;
border-radius: 100px;
box-shadow: 0 1px 3px $shadow-light;
position: relative;
overflow: hidden;
box-sizing: border-box;
min-height: 58.4px;
width: 404px;
}
.now-playing-label {
font-size: $font-size-extra-small;
font-weight: $font-weight-med;
color: $gray-50;
}
.now-playing-content {
display: grid;
grid-template-columns: 32px 1fr;
align-items: center;
gap: $unit-2x;
width: 100%;
padding: $unit $unit-2x;
color: $text-color-subdued;
}
.music-note {
font-size: $font-size-small;
opacity: 0.8;
animation: pulse 2s ease-in-out infinite;
}
.album-thumbnail {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.track-info {
font-size: $font-size-small;
font-weight: $font-weight-med;
text-align: center;
padding-right: 32px; // Balance out the image on the left
display: flex;
flex-direction: column;
overflow: hidden;
}
.marquee-container {
overflow: hidden;
position: relative;
width: 100%;
display: flex;
gap: 50px; // Space between repeated text
// Gradient overlays
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 30px;
z-index: 1;
pointer-events: none;
}
&::before {
left: 0;
background: linear-gradient(to right, $gray-100, transparent);
}
&::after {
right: 0;
background: linear-gradient(to left, $gray-100, transparent);
}
}
.now-playing-title {
display: inline-block;
white-space: nowrap;
animation: marquee 15s linear infinite;
flex-shrink: 0;
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - 50px)); // Include gap in animation
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
}
</style>

View file

@ -38,11 +38,14 @@ export class LastfmStreamManager {
*/ */
async checkForUpdates(): Promise<StreamUpdate> { async checkForUpdates(): Promise<StreamUpdate> {
try { try {
// Always fetch fresh data for now playing detection
const freshData = await this.fetchFreshRecentTracks()
// Fetch recent albums // Fetch recent albums
const albums = await this.getRecentAlbums(4) const albums = await this.getRecentAlbums(4, freshData)
// Process now playing status // Process now playing status
await this.updateNowPlayingStatus(albums) await this.updateNowPlayingStatus(albums, freshData)
// Enrich albums with additional data // Enrich albums with additional data
const enrichedAlbums = await this.enrichAlbums(albums) const enrichedAlbums = await this.enrichAlbums(albums)
@ -60,7 +63,7 @@ export class LastfmStreamManager {
} }
// Check for now playing updates for non-recent albums // Check for now playing updates for non-recent albums
const nowPlayingUpdates = await this.getNowPlayingUpdatesForNonRecentAlbums(enrichedAlbums) const nowPlayingUpdates = await this.getNowPlayingUpdatesForNonRecentAlbums(enrichedAlbums, freshData)
if (nowPlayingUpdates.length > 0) { if (nowPlayingUpdates.length > 0) {
update.nowPlayingUpdates = nowPlayingUpdates update.nowPlayingUpdates = nowPlayingUpdates
} }
@ -72,25 +75,27 @@ export class LastfmStreamManager {
} }
} }
/**
* Fetch fresh recent tracks from Last.fm (no cache)
*/
private async fetchFreshRecentTracks(): Promise<any> {
logger.music('debug', 'Fetching fresh Last.fm recent tracks for now playing detection')
const recentTracksResponse = await this.client.user.getRecentTracks(this.username, {
limit: 50,
extended: true
})
// Still cache it for other uses, but always fetch fresh for now playing
await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse)
return recentTracksResponse
}
/** /**
* Get recent albums from Last.fm * Get recent albums from Last.fm
*/ */
private async getRecentAlbums(limit: number): Promise<Album[]> { private async getRecentAlbums(limit: number, recentTracksResponse?: any): Promise<Album[]> {
// Try cache first // Use provided fresh data or fetch new
const cached = await this.albumEnricher.getCachedRecentTracks(this.username) if (!recentTracksResponse) {
recentTracksResponse = await this.fetchFreshRecentTracks()
let recentTracksResponse
if (cached) {
logger.music('debug', 'Using cached Last.fm recent tracks for album stream')
recentTracksResponse = cached
} else {
logger.music('debug', 'Fetching fresh Last.fm recent tracks for album stream')
recentTracksResponse = await this.client.user.getRecentTracks(this.username, {
limit: 50,
extended: true
})
// Cache the response
await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse)
} }
// Convert tracks to unique albums // Convert tracks to unique albums
@ -119,19 +124,10 @@ export class LastfmStreamManager {
/** /**
* Update now playing status using the detector * Update now playing status using the detector
*/ */
private async updateNowPlayingStatus(albums: Album[]): Promise<void> { private async updateNowPlayingStatus(albums: Album[], recentTracksResponse?: any): Promise<void> {
// Get recent tracks for now playing detection // Use provided fresh data or fetch new
const cached = await this.albumEnricher.getCachedRecentTracks(this.username) if (!recentTracksResponse) {
recentTracksResponse = await this.fetchFreshRecentTracks()
let recentTracksResponse
if (cached) {
recentTracksResponse = cached
} else {
recentTracksResponse = await this.client.user.getRecentTracks(this.username, {
limit: 50,
extended: true
})
await this.albumEnricher.cacheRecentTracks(this.username, recentTracksResponse)
} }
// Process now playing detection // Process now playing detection
@ -240,18 +236,15 @@ export class LastfmStreamManager {
* Get now playing updates for albums not in the recent list * Get now playing updates for albums not in the recent list
*/ */
private async getNowPlayingUpdatesForNonRecentAlbums( private async getNowPlayingUpdatesForNonRecentAlbums(
recentAlbums: Album[] recentAlbums: Album[],
recentTracksResponse?: any
): Promise<NowPlayingUpdate[]> { ): Promise<NowPlayingUpdate[]> {
const updates: NowPlayingUpdate[] = [] const updates: NowPlayingUpdate[] = []
// Get all now playing albums // Use provided fresh data or fetch new
const cached = await this.albumEnricher.getCachedRecentTracks(this.username) if (!recentTracksResponse) {
const recentTracksResponse = recentTracksResponse = await this.fetchFreshRecentTracks()
cached || }
(await this.client.user.getRecentTracks(this.username, {
limit: 50,
extended: true
}))
const nowPlayingMap = await this.nowPlayingDetector.processNowPlayingTracks( const nowPlayingMap = await this.nowPlayingDetector.processNowPlayingTracks(
recentTracksResponse, recentTracksResponse,

View file

@ -24,6 +24,11 @@ export interface NowPlayingResult {
const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes to account for Last.fm scrobble delay const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes to account for Last.fm scrobble delay
const TRACK_HISTORY_WINDOW = 60 * 60 * 1000 // Keep 1 hour of track history const TRACK_HISTORY_WINDOW = 60 * 60 * 1000 // Keep 1 hour of track history
// Confidence thresholds
const CONFIDENCE_HIGH = 0.9
const CONFIDENCE_MEDIUM = 0.6
const CONFIDENCE_LOW = 0.3
export class NowPlayingDetector { export class NowPlayingDetector {
private recentTracks: TrackPlayInfo[] = [] private recentTracks: TrackPlayInfo[] = []
@ -45,6 +50,36 @@ export class NowPlayingDetector {
this.recentTracks = this.recentTracks.filter((track) => track.scrobbleTime > cutoffTime) this.recentTracks = this.recentTracks.filter((track) => track.scrobbleTime > cutoffTime)
} }
/**
* Calculate confidence score for now playing detection
*/
private calculateConfidence(
scrobbleTime: Date,
durationMs: number,
now: Date = new Date()
): { confidence: number; reason: string } {
const elapsed = now.getTime() - scrobbleTime.getTime()
const progress = elapsed / durationMs
// Very confident if within normal playback window
if (progress >= 0 && progress <= 1.0) {
return { confidence: CONFIDENCE_HIGH, reason: 'within normal playback' }
}
// Medium confidence if slightly over (accounting for buffering/pauses)
if (progress > 1.0 && progress <= 1.2) {
return { confidence: CONFIDENCE_MEDIUM, reason: 'slightly over duration (buffering/pauses)' }
}
// Low confidence if significantly over but within lag window
if (progress > 1.2 && elapsed <= durationMs + SCROBBLE_LAG) {
return { confidence: CONFIDENCE_LOW, reason: 'significantly over but within lag window' }
}
// No confidence if too far past expected end
return { confidence: 0, reason: 'track ended' }
}
/** /**
* Check if an album is currently playing based on track duration * Check if an album is currently playing based on track duration
*/ */
@ -74,19 +109,27 @@ export class NowPlayingDetector {
) )
if (trackData?.durationMs) { if (trackData?.durationMs) {
const trackEndTime = new Date( const { confidence, reason } = this.calculateConfidence(
mostRecentTrack.scrobbleTime.getTime() + trackData.durationMs + SCROBBLE_LAG mostRecentTrack.scrobbleTime,
trackData.durationMs,
now
) )
if (now < trackEndTime) { // Only consider it playing if confidence is above threshold
if (confidence >= CONFIDENCE_LOW) {
logger.music( logger.music(
'debug', 'debug',
`Track "${mostRecentTrack.trackName}" is still playing (ends at ${trackEndTime.toLocaleTimeString()})` `Track "${mostRecentTrack.trackName}" detected as playing (confidence: ${confidence}, ${reason})`
) )
return { return {
isNowPlaying: true, isNowPlaying: true,
nowPlayingTrack: mostRecentTrack.trackName nowPlayingTrack: mostRecentTrack.trackName
} }
} else {
logger.music(
'debug',
`Track "${mostRecentTrack.trackName}" confidence too low: ${confidence} (${reason})`
)
} }
} }
@ -120,10 +163,15 @@ export class NowPlayingDetector {
for (const track of tracks) { for (const track of tracks) {
if (track.nowPlaying) { if (track.nowPlaying) {
hasOfficialNowPlaying = true hasOfficialNowPlaying = true
logger.music('debug', `Last.fm reports "${track.name}" by ${track.artist.name} as now playing`)
break break
} }
} }
if (!hasOfficialNowPlaying) {
logger.music('debug', 'No official now playing from Last.fm, will use duration-based detection')
}
// Process all tracks // Process all tracks
for (const track of tracks) { for (const track of tracks) {
// Store track play information // Store track play information
@ -150,10 +198,18 @@ export class NowPlayingDetector {
try { try {
const appleMusicData = await appleMusicDataLookup(album.artistName, album.albumName) const appleMusicData = await appleMusicDataLookup(album.artistName, album.albumName)
if (appleMusicData?.tracks) { if (appleMusicData?.tracks) {
logger.music(
'debug',
`Checking duration-based detection for "${album.albumName}" (${appleMusicData.tracks.length} tracks)`
)
const result = this.checkAlbumNowPlaying(album.albumName, appleMusicData.tracks) const result = this.checkAlbumNowPlaying(album.albumName, appleMusicData.tracks)
if (result?.isNowPlaying) { if (result?.isNowPlaying) {
album.isNowPlaying = true album.isNowPlaying = true
album.nowPlayingTrack = result.nowPlayingTrack album.nowPlayingTrack = result.nowPlayingTrack
logger.music(
'debug',
`Duration-based detection: "${album.nowPlayingTrack}" from "${album.albumName}" is now playing`
)
} }
} }
} catch (error) { } catch (error) {
@ -173,6 +229,10 @@ export class NowPlayingDetector {
// Update recent tracks // Update recent tracks
this.updateRecentTracks(newRecentTracks) this.updateRecentTracks(newRecentTracks)
// Log summary
const nowPlayingCount = Array.from(albums.values()).filter(a => a.isNowPlaying).length
logger.music('debug', `Detected ${nowPlayingCount} album(s) as now playing out of ${albums.size} recent albums`)
// Ensure only one album is marked as now playing // Ensure only one album is marked as now playing
return this.ensureSingleNowPlaying(albums, newRecentTracks) return this.ensureSingleNowPlaying(albums, newRecentTracks)
} }

View file

@ -5,7 +5,8 @@ import { logger } from '$lib/server/logger'
const LASTFM_API_KEY = process.env.LASTFM_API_KEY const LASTFM_API_KEY = process.env.LASTFM_API_KEY
const USERNAME = 'jedmund' const USERNAME = 'jedmund'
const UPDATE_INTERVAL = 30000 // 30 seconds to reduce API load const UPDATE_INTERVAL = 30000 // 30 seconds default
const FAST_UPDATE_INTERVAL = 10000 // 10 seconds when music is playing
export const GET: RequestHandler = async ({ request }) => { export const GET: RequestHandler = async ({ request }) => {
const encoder = new TextEncoder() const encoder = new TextEncoder()
@ -16,6 +17,8 @@ export const GET: RequestHandler = async ({ request }) => {
const streamManager = new LastfmStreamManager(client, USERNAME) const streamManager = new LastfmStreamManager(client, USERNAME)
let intervalId: NodeJS.Timeout | null = null let intervalId: NodeJS.Timeout | null = null
let isClosed = false let isClosed = false
let currentInterval = UPDATE_INTERVAL
let isPlaying = false
// Send initial connection message // Send initial connection message
try { try {
@ -36,6 +39,9 @@ export const GET: RequestHandler = async ({ request }) => {
try { try {
const update = await streamManager.checkForUpdates() const update = await streamManager.checkForUpdates()
// Check if music is playing
let musicIsPlaying = false
// Send album updates if any // Send album updates if any
if (update.albums && !isClosed) { if (update.albums && !isClosed) {
try { try {
@ -43,6 +49,8 @@ export const GET: RequestHandler = async ({ request }) => {
controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`)) controller.enqueue(encoder.encode(`event: albums\ndata: ${data}\n\n`))
const nowPlayingAlbum = update.albums.find((a) => a.isNowPlaying) const nowPlayingAlbum = update.albums.find((a) => a.isNowPlaying)
musicIsPlaying = !!nowPlayingAlbum
logger.music('debug', 'Sent album update with now playing status:', { logger.music('debug', 'Sent album update with now playing status:', {
totalAlbums: update.albums.length, totalAlbums: update.albums.length,
nowPlayingAlbum: nowPlayingAlbum nowPlayingAlbum: nowPlayingAlbum
@ -59,11 +67,31 @@ export const GET: RequestHandler = async ({ request }) => {
try { try {
const data = JSON.stringify(update.nowPlayingUpdates) const data = JSON.stringify(update.nowPlayingUpdates)
controller.enqueue(encoder.encode(`event: nowplaying\ndata: ${data}\n\n`)) controller.enqueue(encoder.encode(`event: nowplaying\ndata: ${data}\n\n`))
// Check if any of the updates indicate music is playing
musicIsPlaying = musicIsPlaying || update.nowPlayingUpdates.some(u => u.isNowPlaying)
} catch (e) { } catch (e) {
isClosed = true isClosed = true
} }
} }
// Adjust polling interval based on playing state
if (musicIsPlaying !== isPlaying) {
isPlaying = musicIsPlaying
const newInterval = isPlaying ? FAST_UPDATE_INTERVAL : UPDATE_INTERVAL
if (newInterval !== currentInterval) {
currentInterval = newInterval
logger.music('debug', `Adjusting polling interval to ${currentInterval}ms (playing: ${isPlaying})`)
// Reset interval with new timing
if (intervalId) {
clearInterval(intervalId)
intervalId = setInterval(checkForUpdates, currentInterval)
}
}
}
// Send heartbeat to keep connection alive // Send heartbeat to keep connection alive
if (!isClosed) { if (!isClosed) {
try { try {
@ -82,7 +110,7 @@ export const GET: RequestHandler = async ({ request }) => {
await checkForUpdates() await checkForUpdates()
// Set up interval // Set up interval
intervalId = setInterval(checkForUpdates, UPDATE_INTERVAL) intervalId = setInterval(checkForUpdates, currentInterval)
// Handle client disconnect // Handle client disconnect
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {