diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d5ac2d5 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Copy to .env and fill in your secrets +# These are used by the Bun/Elysia server to fetch your current/last played track from Last.fm +LASTFM_API_KEY= +LASTFM_USERNAME= + +# Optional +PORT=3000 +NODE_ENV=development diff --git a/index.html b/index.html index 9f60b1b..f5a2839 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,11 @@ + + + + + @@ -38,8 +43,9 @@
- Cameron B. R. Redmore + Cameron B. R. Redmore
diff --git a/public/avatar.webp b/public/avatar.webp new file mode 100644 index 0000000..15032e3 Binary files /dev/null and b/public/avatar.webp differ diff --git a/src/client.ts b/src/client.ts index 54ddb4f..4e49320 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,11 +16,13 @@ interface LastSong { url: string; nowPlaying: boolean; image: LastSongImage[]; + playedAt?: number; } // WebSocket connection let musicWebSocket: any = null; let connected = false; +let hasInitialized = false; // Function to connect to WebSocket for real-time updates function connectMusicWebSocket(): void { @@ -36,16 +38,16 @@ function connectMusicWebSocket(): void { }); musicWebSocket.subscribe((evt: any) => { - const {data} = evt; - console.log('Received music update:', data); + const { data } = evt; + if (!data) return; if (data.type === 'song-update') { - updateLastSongDisplay(data.data); + const history: HistoryEntry[] = Array.isArray(data.history) ? data.history : []; + updateLastSongDisplay(data.data, undefined, history); } }); musicWebSocket.on('close', () => { - if(!connected) - { + if (!connected) { return; } console.log('Music WebSocket disconnected, attempting to reconnect...'); @@ -55,8 +57,7 @@ function connectMusicWebSocket(): void { }); musicWebSocket.on('error', (error: any) => { - if(!connected) - { + if (!connected) { return; } console.error('Music WebSocket error:', error); @@ -74,15 +75,15 @@ function connectMusicWebSocket(): void { // Function to select appropriate image size based on screen width function getOptimalImageUrl(images: LastSongImage[]): string { if (!images || images.length === 0) return ''; - + const screenWidth = window.innerWidth; - + // Select image size based on screen width and device pixel ratio const pixelRatio = window.devicePixelRatio || 1; const effectiveWidth = screenWidth * pixelRatio; - + let targetSize: string; - + if (effectiveWidth <= 400) { targetSize = 'small'; // 34px } else if (effectiveWidth <= 800) { @@ -92,18 +93,18 @@ function getOptimalImageUrl(images: LastSongImage[]): string { } else { targetSize = 'extralarge'; // 300px } - + // Find the target size, fallback to largest available const targetImage = images.find(img => img.size === targetSize); if (targetImage) return targetImage['#text']; - + // Fallback: return the largest available image const fallbackOrder = ['extralarge', 'large', 'medium', 'small']; for (const size of fallbackOrder) { const image = images.find(img => img.size === size); if (image) return image['#text']; } - + return ''; } @@ -118,47 +119,48 @@ function preloadImage(url: string): Promise { } // Function to update the UI with song information -async function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): Promise { +async function updateLastSongDisplay(song: LastSong | null, errorMessage?: string, history: HistoryEntry[] = []): Promise { console.log('Updating last song display:', song, errorMessage); const containers = [ document.getElementById('last-song-container'), document.getElementById('last-song-container-mobile') ]; - + for (const container of containers) { if (!container) continue; if (errorMessage || !song) { container.innerHTML = ` -
- ${errorMessage || 'No song data available'} +
+
+

${errorMessage || 'Loading music data...'}

`; continue; } - const statusText = song.nowPlaying ? 'Currently Listening To' : 'Last Played'; + const statusText = song.nowPlaying ? 'Now Playing' : 'Last Played'; const imageUrl = getOptimalImageUrl(song.image); - + // Create tooltip content for music const tooltipContent = `
${song.name}
by ${song.artist}
${song.album ? `
from "${song.album}"
` : ''} `; - + let imageElement: string; - + // If there's an image URL, preload it before updating the display if (imageUrl) { try { // Show loading state while preloading const loadingElement = `
-
+
`; - + // Temporarily show loading state container.innerHTML = `
@@ -176,10 +178,10 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
`; - + // Preload the image await preloadImage(imageUrl); - + // Now set the actual image imageElement = `${song.name}`; } catch (error) { @@ -204,30 +206,74 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin `; } - // Final update with the actual image + // Equalizer animation when live + const eq = song.nowPlaying ? ` + + + + ` : ''; + + const artistLink = `https://www.last.fm/music/${encodeURIComponent(song.artist)}`; + const albumLink = song.album ? `https://www.last.fm/music/${encodeURIComponent(song.artist)}/${encodeURIComponent(song.album)}` : ''; + + // Final update with the actual image and history dropdown container.innerHTML = `
`; + const listEl = container.querySelector('#history-list') as HTMLElement | null; + if (listEl) { + renderHistory(listEl, history); + } } } // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', () => { - // Connect to WebSocket for real-time updates - connectMusicWebSocket(); + // Lazy-load: observe the desktop and mobile containers + const targets = [ + document.getElementById('last-song-container'), + document.getElementById('last-song-container-mobile') + ].filter(Boolean) as HTMLElement[]; + + if (!targets.length) { + connectMusicWebSocket(); + return; + } + + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !hasInitialized) { + hasInitialized = true; + connectMusicWebSocket(); + observer.disconnect(); + break; + } + } + }, { rootMargin: '100px' }); + + targets.forEach((t) => observer.observe(t)); }); // Clean up on page unload @@ -237,5 +283,33 @@ window.addEventListener('beforeunload', () => { } }); +// Hook to receive history from WS subscribe and render it +type HistoryEntry = LastSong; +function renderHistory(listEl: HTMLElement, history: HistoryEntry[]) { + const fmt = (ts: number) => new Date(ts * 1000).toLocaleString(); + listEl.innerHTML = history.slice(0, 5).map((h) => { + const playedAt = h.playedAt ? fmt(h.playedAt) : ''; + const tooltipContent = ` +
${h.name}
+
by ${h.artist}
+ ${h.album ? `
from \"${h.album}\"
` : ''} + ${playedAt ? `
played ${playedAt}
` : ''} + `; + return ` +
  • + +
  • + `; + }).join(''); +} + // Export functions for manual control if needed -export { connectMusicWebSocket }; +export { connectMusicWebSocket }; \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index a47b4f9..ce69554 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,57 +3,77 @@ import { staticPlugin } from '@elysiajs/static'; import path from 'path'; // Song data interface +interface LastSongImage { + size: 'small' | 'medium' | 'large' | 'extralarge'; + '#text': string; +} + interface LastSong { name: string; artist: string; album: string; url: string; nowPlaying: boolean; - image: any[]; + image: LastSongImage[]; + playedAt?: number; // epoch seconds from Last.fm for history entries } // Current song state let currentSong: LastSong | null = null; let lastSongId: string | null = null; +let history: LastSong[] = []; // last 5 tracks (most recent first) let pollTimer: ReturnType | null = null; +// Minimal shape we need for a WS connection +type WSLike = { send: (data: string | Uint8Array) => void }; // WebSocket connections -const connections = new Set(); +const connections = new Set(); // Function to fetch song from Last.fm -async function fetchLastFmSong(): Promise { +async function fetchLastFmSongAndHistory(): Promise<{ current: LastSong | null; recent: LastSong[] }> { const apiKey = Bun.env.LASTFM_API_KEY; const username = Bun.env.LASTFM_USERNAME; if (!apiKey || !username) { console.error('Last.fm API key or username not configured'); - return null; + return { current: null, recent: [] }; } - const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${username}&api_key=${apiKey}&format=json&limit=1`; + const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${username}&api_key=${apiKey}&format=json&limit=5`; try { const response = await fetch(url); - const data = await response.json(); - const track = data.recenttracks.track[0]; - - return { - name: track.name, - artist: track.artist['#text'], - album: track.album['#text'], - url: track.url, - nowPlaying: track['@attr']?.nowplaying === 'true', - image: track.image || [], - }; + if (!response.ok) { + console.error('Last.fm response not OK:', response.status, response.statusText); + return { current: null, recent: [] }; + } + const data = await response.json().catch(() => null as any); + const tracks = Array.isArray(data?.recenttracks?.track) ? data.recenttracks.track : []; + if (!tracks.length) return { current: null, recent: [] }; + + const mapped: LastSong[] = tracks.map((t: any) => ({ + name: t?.name ?? '', + artist: t?.artist?.['#text'] ?? '', + album: t?.album?.['#text'] ?? '', + url: t?.url ?? '', + nowPlaying: t?.['@attr']?.nowplaying === 'true', + image: Array.isArray(t?.image) ? t.image : [], + playedAt: t?.date?.uts ? Number(t.date.uts) : undefined, + })); + + const current = mapped[0] ?? null; + // History is the next items that have a playedAt (exclude the currently playing item) + const recent = mapped.filter((s) => !!s.playedAt).slice(0, 5); + return { current, recent }; } catch (error) { console.error('Error fetching from Last.fm:', error); - return null; + return { current: null, recent: [] }; } } // Function to check for song changes and notify clients async function checkAndUpdateSong(): Promise { - const newSong = await fetchLastFmSong(); + const { current: newSong, recent } = await fetchLastFmSongAndHistory(); // Default to not currently playing if we couldn't fetch anything let nowPlaying = false; @@ -68,10 +88,12 @@ async function checkAndUpdateSong(): Promise { // Update state and notify clients currentSong = newSong; lastSongId = newSongId; + history = recent; const message = JSON.stringify({ type: 'song-update', - data: newSong + data: newSong, + history }); connections.forEach(ws => { @@ -85,6 +107,7 @@ async function checkAndUpdateSong(): Promise { } else { // Keep currentSong updated even if no change to ID (e.g., image/url may vary) currentSong = newSong; + history = recent; } nowPlaying = !!newSong.nowPlaying; @@ -114,6 +137,8 @@ const app = new Elysia() assets: assetsPath, prefix: '' })) + // Simple health check endpoint for uptime checks and Docker + .get('/healthz', () => ({ status: 'ok', now: Date.now() })) .ws('/music', { message(ws, message) { // Handle incoming messages if needed @@ -124,10 +149,11 @@ const app = new Elysia() connections.add(ws); // Send current song to new client immediately - if (currentSong) { + if (currentSong) { const message = JSON.stringify({ - type: 'song-update', - data: currentSong + type: 'song-update', + data: currentSong, + history }); ws.send(message); } diff --git a/src/style.css b/src/style.css index 361f840..4222afc 100644 --- a/src/style.css +++ b/src/style.css @@ -16,6 +16,19 @@ body { padding: 0; } +/* Skeleton shimmer */ +.skeleton { + position: relative; + background: linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.18), rgba(255,255,255,0.08)); + background-size: 200% 100%; + animation: skeleton-loading 1.2s ease-in-out infinite; +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + /* The main card with the glass effect */ .glass-card { background: rgba(255, 255, 255, 0.1); @@ -91,6 +104,40 @@ body { transform: translateY(-2px); } +/* Equalizer animation */ +.eq { + display: inline-flex; + gap: 2px; + align-items: flex-end; + margin-right: 4px; +} +.eq span { + width: 3px; + height: 8px; + background-color: #67e8f9; /* cyan-300 */ + display: inline-block; + animation: eq-bounce 0.9s infinite ease-in-out; +} +.eq span:nth-child(2) { animation-delay: 0.15s; } +.eq span:nth-child(3) { animation-delay: 0.3s; } + +@keyframes eq-bounce { + 0%, 100% { height: 4px; opacity: 0.8; } + 50% { height: 12px; opacity: 1; } +} + +/* History list styling */ +details.history { + width: 100%; +} +details.history[open] summary { + color: #a5f3fc; /* cyan-200 */ +} +details.history ul li { + border-left: 2px solid rgba(255,255,255,0.1); + padding-left: 6px; +} + /* Animated gradient for text */ .animated-gradient { background: linear-gradient(90deg, #4dd0e1, #818cf8, #a5f3fc, #4dd0e1); @@ -131,9 +178,9 @@ body { padding: 8px 12px; position: absolute; z-index: 1000; - bottom: -25%; - left: 50%; - transform: translateX(-50%); + top: 50%; + left: 100%; + transform: translateX(50%) translateY(-50%); font-size: 14px; font-weight: 500; white-space: nowrap; @@ -147,7 +194,7 @@ body { .tooltip:hover .tooltip-text { visibility: visible; opacity: 1; - transform: translateX(-50%) translateY(-5px); + transform: translateX(0%) translateY(-50%); } /* Music tooltip specific styling */