Realtime now playing
This commit is contained in:
parent
199abb294f
commit
6a0f1d7d3f
5 changed files with 426 additions and 7 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
import { spring } from 'svelte/motion'
|
import { spring } from 'svelte/motion'
|
||||||
import type { Album } from '$lib/types/lastfm'
|
import type { Album } from '$lib/types/lastfm'
|
||||||
import { audioPreview } from '$lib/stores/audio-preview'
|
import { audioPreview } from '$lib/stores/audio-preview'
|
||||||
|
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
||||||
import NowPlaying from './NowPlaying.svelte'
|
import NowPlaying from './NowPlaying.svelte'
|
||||||
|
|
||||||
interface AlbumProps {
|
interface AlbumProps {
|
||||||
|
|
@ -81,16 +82,24 @@
|
||||||
|
|
||||||
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
|
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
|
||||||
|
|
||||||
// Debug log
|
// Subscribe to real-time now playing updates
|
||||||
|
let realtimeNowPlaying = $state<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null>(null)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (album) {
|
if (album) {
|
||||||
console.log(`Album ${album.name}:`, {
|
const unsubscribe = nowPlayingStream.isAlbumPlaying.subscribe(checkAlbum => {
|
||||||
hasAppleMusicData: !!album.appleMusicData,
|
const status = checkAlbum(album.artist.name, album.name)
|
||||||
previewUrl: album.appleMusicData?.previewUrl,
|
if (status !== null) {
|
||||||
hasPreview
|
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)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="album">
|
<div class="album">
|
||||||
|
|
@ -110,8 +119,8 @@
|
||||||
style="transform: scale({$scale})"
|
style="transform: scale({$scale})"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{#if album.isNowPlaying}
|
{#if isNowPlaying}
|
||||||
<NowPlaying trackName={album.nowPlayingTrack !== album.name ? album.nowPlayingTrack : undefined} />
|
<NowPlaying trackName={nowPlayingTrack !== album.name ? nowPlayingTrack : undefined} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasPreview && isHovering}
|
{#if hasPreview && isHovering}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
78
src/lib/components/StreamStatus.svelte
Normal file
78
src/lib/components/StreamStatus.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
|
||||||
|
|
||||||
|
let isConnected = $state(false)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const unsubscribe = nowPlayingStream.subscribe(state => {
|
||||||
|
isConnected = state.connected
|
||||||
|
})
|
||||||
|
return unsubscribe
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isConnected}
|
||||||
|
<div class="stream-status connected" title="Live updates active">
|
||||||
|
<span class="dot"></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.stream-status {
|
||||||
|
position: fixed;
|
||||||
|
bottom: $unit * 2;
|
||||||
|
right: $unit * 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
padding: $unit-half $unit;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
border-radius: $unit * 2;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
|
||||||
|
&.connected {
|
||||||
|
.dot {
|
||||||
|
background: #4caf50;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
.stream-status {
|
||||||
|
bottom: $unit;
|
||||||
|
right: $unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
133
src/lib/stores/now-playing-stream.ts
Normal file
133
src/lib/stores/now-playing-stream.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { writable, derived, get, type Readable } from 'svelte/store'
|
||||||
|
import { browser } from '$app/environment'
|
||||||
|
|
||||||
|
interface NowPlayingUpdate {
|
||||||
|
albumName: string
|
||||||
|
artistName: string
|
||||||
|
isNowPlaying: boolean
|
||||||
|
nowPlayingTrack?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NowPlayingState {
|
||||||
|
connected: boolean
|
||||||
|
updates: Map<string, NowPlayingUpdate>
|
||||||
|
lastUpdate: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNowPlayingStream() {
|
||||||
|
const { subscribe, set, update } = writable<NowPlayingState>({
|
||||||
|
connected: false,
|
||||||
|
updates: new Map(),
|
||||||
|
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
|
||||||
|
|
||||||
|
// Clean up existing connection
|
||||||
|
disconnect()
|
||||||
|
|
||||||
|
eventSource = new EventSource('/api/lastfm/stream')
|
||||||
|
|
||||||
|
eventSource.addEventListener('connected', () => {
|
||||||
|
console.log('Now Playing stream connected')
|
||||||
|
reconnectAttempts = 0
|
||||||
|
update(state => ({ ...state, connected: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('nowplaying', (event) => {
|
||||||
|
try {
|
||||||
|
const updates: NowPlayingUpdate[] = JSON.parse(event.data)
|
||||||
|
update(state => {
|
||||||
|
const newUpdates = new Map(state.updates)
|
||||||
|
|
||||||
|
for (const album of updates) {
|
||||||
|
const key = `${album.artistName}:${album.albumName}`
|
||||||
|
newUpdates.set(key, album)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
updates: newUpdates,
|
||||||
|
lastUpdate: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing now playing update:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('heartbeat', () => {
|
||||||
|
// Heartbeat received, connection is healthy
|
||||||
|
})
|
||||||
|
|
||||||
|
eventSource.addEventListener('error', (error) => {
|
||||||
|
console.error('Now Playing stream error:', error)
|
||||||
|
update(state => ({ ...state, connected: false }))
|
||||||
|
|
||||||
|
// Attempt to 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) {
|
||||||
|
connect()
|
||||||
|
|
||||||
|
// Reconnect on visibility change
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
const currentState = get({ subscribe })
|
||||||
|
if (document.visibilityState === 'visible' && !currentState.connected) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
// Helper to check if a specific album is now playing
|
||||||
|
isAlbumPlaying: derived(
|
||||||
|
{ subscribe },
|
||||||
|
($state) => (artistName: string, albumName: string) => {
|
||||||
|
const key = `${artistName}:${albumName}`
|
||||||
|
const update = $state.updates.get(key)
|
||||||
|
return update ? {
|
||||||
|
isNowPlaying: update.isNowPlaying,
|
||||||
|
nowPlayingTrack: update.nowPlayingTrack
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
) as Readable<(artistName: string, albumName: string) => { isNowPlaying: boolean; nowPlayingTrack?: string } | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nowPlayingStream = createNowPlayingStream()
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
import MentionList from '$components/MentionList.svelte'
|
import MentionList from '$components/MentionList.svelte'
|
||||||
import Page from '$components/Page.svelte'
|
import Page from '$components/Page.svelte'
|
||||||
import RecentAlbums from '$components/RecentAlbums.svelte'
|
import RecentAlbums from '$components/RecentAlbums.svelte'
|
||||||
|
import StreamStatus from '$components/StreamStatus.svelte'
|
||||||
import { generateMetaTags } from '$lib/utils/metadata'
|
import { generateMetaTags } from '$lib/utils/metadata'
|
||||||
import { page } from '$app/stores'
|
import { page } from '$app/stores'
|
||||||
|
|
||||||
|
|
@ -101,6 +102,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
</section> -->
|
</section> -->
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
|
<StreamStatus />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
||||||
196
src/routes/api/lastfm/stream/+server.ts
Normal file
196
src/routes/api/lastfm/stream/+server.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { LastClient } from '@musicorum/lastfm'
|
||||||
|
import type { RequestHandler } from './$types'
|
||||||
|
import type { Album } from '$lib/types/lastfm'
|
||||||
|
import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client'
|
||||||
|
import redis from '../../redis-client'
|
||||||
|
|
||||||
|
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
|
||||||
|
const USERNAME = 'jedmund'
|
||||||
|
const UPDATE_INTERVAL = 30000 // 30 seconds
|
||||||
|
|
||||||
|
interface NowPlayingUpdate {
|
||||||
|
albumName: string
|
||||||
|
artistName: string
|
||||||
|
isNowPlaying: boolean
|
||||||
|
nowPlayingTrack?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store recent tracks for duration-based detection
|
||||||
|
interface TrackPlayInfo {
|
||||||
|
albumName: string
|
||||||
|
trackName: string
|
||||||
|
scrobbleTime: Date
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let recentTracks: TrackPlayInfo[] = []
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
const client = new LastClient(LASTFM_API_KEY || '')
|
||||||
|
let lastNowPlayingState: Map<string, boolean> = new Map()
|
||||||
|
|
||||||
|
// Send initial connection message
|
||||||
|
controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n'))
|
||||||
|
|
||||||
|
const checkForUpdates = async () => {
|
||||||
|
try {
|
||||||
|
const nowPlayingAlbums = await getNowPlayingAlbums(client)
|
||||||
|
const updates: NowPlayingUpdate[] = []
|
||||||
|
|
||||||
|
// Check for changes
|
||||||
|
for (const album of nowPlayingAlbums) {
|
||||||
|
const key = `${album.artistName}:${album.albumName}`
|
||||||
|
const wasPlaying = lastNowPlayingState.get(key) || false
|
||||||
|
|
||||||
|
if (album.isNowPlaying !== wasPlaying) {
|
||||||
|
updates.push(album)
|
||||||
|
lastNowPlayingState.set(key, album.isNowPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send updates if any
|
||||||
|
if (updates.length > 0) {
|
||||||
|
const data = JSON.stringify(updates)
|
||||||
|
controller.enqueue(encoder.encode(`event: nowplaying\ndata: ${data}\n\n`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send heartbeat to keep connection alive
|
||||||
|
controller.enqueue(encoder.encode('event: heartbeat\ndata: {}\n\n'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for updates:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial check
|
||||||
|
await checkForUpdates()
|
||||||
|
|
||||||
|
// Set up interval
|
||||||
|
const intervalId = setInterval(checkForUpdates, UPDATE_INTERVAL)
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
controller.close()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
// Cleanup when stream is cancelled
|
||||||
|
console.log('SSE stream cancelled')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no' // Disable Nginx buffering
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNowPlayingAlbums(client: LastClient): Promise<NowPlayingUpdate[]> {
|
||||||
|
const recentTracksResponse = await client.user.getRecentTracks(USERNAME, { limit: 50, extended: true })
|
||||||
|
const albums: Map<string, NowPlayingUpdate> = new Map()
|
||||||
|
|
||||||
|
// Clear old tracks and collect new track play information
|
||||||
|
recentTracks = []
|
||||||
|
|
||||||
|
for (const track of recentTracksResponse.tracks) {
|
||||||
|
// Store track play information
|
||||||
|
if (track.date) {
|
||||||
|
recentTracks.push({
|
||||||
|
albumName: track.album.name,
|
||||||
|
trackName: track.name,
|
||||||
|
scrobbleTime: track.date
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const albumKey = `${track.artist.name}:${track.album.name}`
|
||||||
|
|
||||||
|
if (!albums.has(albumKey)) {
|
||||||
|
const album: NowPlayingUpdate = {
|
||||||
|
albumName: track.album.name,
|
||||||
|
artistName: track.artist.name,
|
||||||
|
isNowPlaying: track.nowPlaying || false,
|
||||||
|
nowPlayingTrack: track.nowPlaying ? track.name : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not marked as now playing by Last.fm, check with duration-based detection
|
||||||
|
if (!album.isNowPlaying) {
|
||||||
|
const updatedStatus = await checkNowPlayingWithDuration(album.albumName, album.artistName)
|
||||||
|
if (updatedStatus) {
|
||||||
|
album.isNowPlaying = updatedStatus.isNowPlaying
|
||||||
|
album.nowPlayingTrack = updatedStatus.nowPlayingTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albums.set(albumKey, album)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(albums.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkNowPlayingWithDuration(
|
||||||
|
albumName: string,
|
||||||
|
artistName: string
|
||||||
|
): Promise<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null> {
|
||||||
|
try {
|
||||||
|
// Check cache for Apple Music data
|
||||||
|
const cacheKey = `apple:album:${artistName}:${albumName}`
|
||||||
|
const cached = await redis.get(cacheKey)
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
// Try to fetch from Apple Music if not cached
|
||||||
|
const appleMusicAlbum = await findAlbum(artistName, albumName)
|
||||||
|
if (!appleMusicAlbum) return null
|
||||||
|
|
||||||
|
const transformedData = await transformAlbumData(appleMusicAlbum)
|
||||||
|
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
|
||||||
|
return checkWithTracks(albumName, transformedData.tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const appleMusicData = JSON.parse(cached)
|
||||||
|
return checkWithTracks(albumName, appleMusicData.tracks)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking duration for ${albumName}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkWithTracks(
|
||||||
|
albumName: string,
|
||||||
|
tracks?: Array<{ name: string; durationMs?: number }>
|
||||||
|
): { isNowPlaying: boolean; nowPlayingTrack?: string } | null {
|
||||||
|
if (!tracks) return null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes
|
||||||
|
|
||||||
|
for (const trackInfo of recentTracks) {
|
||||||
|
if (trackInfo.albumName !== albumName) continue
|
||||||
|
|
||||||
|
const trackData = tracks.find(t =>
|
||||||
|
t.name.toLowerCase() === trackInfo.trackName.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (trackData?.durationMs) {
|
||||||
|
const trackEndTime = new Date(trackInfo.scrobbleTime.getTime() + trackData.durationMs + SCROBBLE_LAG)
|
||||||
|
|
||||||
|
if (now < trackEndTime) {
|
||||||
|
return {
|
||||||
|
isNowPlaying: true,
|
||||||
|
nowPlayingTrack: trackInfo.trackName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isNowPlaying: false }
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue