From 46cc5eb8a1c1f42ca29de52adfc10238a8603135 Mon Sep 17 00:00:00 2001 From: Cameron Redmore Date: Fri, 8 Aug 2025 19:19:37 +0100 Subject: [PATCH] Add configuration example, enhance song display with history, and improve UI responsiveness --- .env.example | 8 +++ index.html | 10 +++- public/avatar.webp | Bin 0 -> 18998 bytes src/client.ts | 142 ++++++++++++++++++++++++++++++++++----------- src/server.ts | 70 +++++++++++++++------- src/style.css | 55 ++++++++++++++++-- 6 files changed, 223 insertions(+), 62 deletions(-) create mode 100644 .env.example create mode 100644 public/avatar.webp 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 0000000000000000000000000000000000000000..15032e37dafd9e63b781482b2ee590fc9536ac29 GIT binary patch literal 18998 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 literal 0 HcmV?d00001 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 */