Add configuration example, enhance song display with history, and improve UI responsiveness
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m50s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m50s
This commit is contained in:
parent
84f9502b4a
commit
46cc5eb8a1
6 changed files with 223 additions and 62 deletions
8
.env.example
Normal file
8
.env.example
Normal file
|
@ -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
|
10
index.html
10
index.html
|
@ -12,6 +12,11 @@
|
|||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="cmzi.uk" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<!-- Performance hints: preconnect / dns-prefetch for Last.fm API/CDN -->
|
||||
<link rel="preconnect" href="https://lastfm.freetls.fastly.net" crossorigin>
|
||||
<link rel="dns-prefetch" href="//lastfm.freetls.fastly.net">
|
||||
<link rel="preconnect" href="https://lastfm-img2.akamaized.net" crossorigin>
|
||||
<link rel="dns-prefetch" href="//lastfm-img2.akamaized.net">
|
||||
|
||||
<link rel="stylesheet" href="/src/style.css">
|
||||
</head>
|
||||
|
@ -38,8 +43,9 @@
|
|||
|
||||
<!-- Left Column: Profile Picture and Social Links -->
|
||||
<div class="flex-shrink-0 text-center mb-6 md:mb-0">
|
||||
<img src="https://avatars.githubusercontent.com/u/5846077?v=4" alt="Cameron B. R. Redmore"
|
||||
class="rounded-full w-32 h-32 md:w-40 md:h-40 mx-auto border-4 border-white/20 shadow-lg hover-glass">
|
||||
<img src="/avatar.webp" alt="Cameron B. R. Redmore"
|
||||
class="rounded-full w-32 h-32 md:w-40 md:h-40 mx-auto border-4 border-white/20 shadow-lg hover-glass"
|
||||
loading="lazy" decoding="async">
|
||||
<div class="mt-6 flex justify-center space-x-4">
|
||||
<a href="https://github.com/CameronRedmore" target="_blank"
|
||||
class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="GitHub Profile">
|
||||
|
|
BIN
public/avatar.webp
Normal file
BIN
public/avatar.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
108
src/client.ts
108
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);
|
||||
|
@ -118,7 +119,7 @@ function preloadImage(url: string): Promise<HTMLImageElement> {
|
|||
}
|
||||
|
||||
// Function to update the UI with song information
|
||||
async function updateLastSongDisplay(song: LastSong | null, errorMessage?: string): Promise<void> {
|
||||
async function updateLastSongDisplay(song: LastSong | null, errorMessage?: string, history: HistoryEntry[] = []): Promise<void> {
|
||||
console.log('Updating last song display:', song, errorMessage);
|
||||
const containers = [
|
||||
document.getElementById('last-song-container'),
|
||||
|
@ -130,14 +131,15 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
|
|||
|
||||
if (errorMessage || !song) {
|
||||
container.innerHTML = `
|
||||
<div class="text-cyan-300 text-sm">
|
||||
${errorMessage || 'No song data available'}
|
||||
<div class="flex flex-col items-center space-y-3 w-36">
|
||||
<div class="skeleton w-24 h-24 rounded-lg mx-auto"></div>
|
||||
<p class="text-sm text-cyan-300 font-medium w-36 text-center">${errorMessage || 'Loading music data...'}</p>
|
||||
</div>
|
||||
`;
|
||||
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
|
||||
|
@ -155,7 +157,7 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
|
|||
// Show loading state while preloading
|
||||
const loadingElement = `
|
||||
<div class="w-24 h-24 rounded-lg bg-gradient-to-br from-cyan-500/20 to-purple-500/20 flex items-center justify-center mx-auto border border-white/20">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-cyan-300"></div>
|
||||
<div class="skeleton w-6 h-6 rounded-full"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
@ -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 ? `
|
||||
<span class="eq">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
` : '';
|
||||
|
||||
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 = `
|
||||
<div class="flex flex-col items-center space-y-3">
|
||||
<div class="tooltip music-tooltip">
|
||||
<a href="${song.url}" target="_blank" class="block hover:text-cyan-200 transition-colors duration-200 w-36">
|
||||
${imageElement}
|
||||
<div class="text-center w-full">
|
||||
<p class="text-sm text-cyan-300 font-medium w-36">${statusText}</p>
|
||||
<p class="text-sm text-cyan-300 font-medium w-36 flex items-center justify-center gap-1">${eq}${statusText}</p>
|
||||
<div class="font-semibold text-white-900 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.name}</div>
|
||||
<div class="text-sm text-white-200 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.artist}</div>
|
||||
${song.album ? `<div class="text-xs text-white-600 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">${song.album}</div>` : ''}
|
||||
<div class="text-sm text-white-200 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">
|
||||
<a class="underline underline-offset-2 hover:text-cyan-200" href="${artistLink}" target="_blank">${song.artist}</a>
|
||||
</div>
|
||||
${song.album ? `<div class="text-xs text-white-600 text-ellipsis overflow-hidden whitespace-nowrap max-w-full">
|
||||
<a class="underline underline-offset-2 hover:text-cyan-200" href="${albumLink}" target="_blank">${song.album}</a>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</a>
|
||||
<span class="tooltip-text">${tooltipContent}</span>
|
||||
</div>
|
||||
|
||||
<details class="history w-36">
|
||||
<summary class="cursor-pointer text-xs text-cyan-300 hover:text-cyan-200">History</summary>
|
||||
<ul class="mt-2 space-y-1" id="history-list"></ul>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
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
|
||||
// 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 = `
|
||||
<div class="song-name">${h.name}</div>
|
||||
<div class="artist-name">by ${h.artist}</div>
|
||||
${h.album ? `<div class=\"album-name\">from \"${h.album}\"</div>` : ''}
|
||||
${playedAt ? `<div class=\"played-at text-white-400\">played ${playedAt}</div>` : ''}
|
||||
`;
|
||||
return `
|
||||
<li class="text-[11px] text-white-300">
|
||||
<div class="tooltip music-tooltip">
|
||||
<a href="${h.url}" target="_blank" class="block hover:text-cyan-200">
|
||||
<span class="block truncate font-medium">${h.name}</span>
|
||||
<span class="block truncate">${h.artist}</span>
|
||||
${h.album ? `<span class=\"block truncate italic hover:text-cyan-200\">${h.album}</span>` : ''}
|
||||
${playedAt ? `<span class=\"block text-white-500 hover:text-cyan-200\">${playedAt}</span>` : ''}
|
||||
</a>
|
||||
<span class="tooltip-text">${tooltipContent}</span>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Export functions for manual control if needed
|
||||
export { connectMusicWebSocket };
|
|
@ -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<typeof setTimeout> | null = null;
|
||||
|
||||
// Minimal shape we need for a WS connection
|
||||
type WSLike = { send: (data: string | Uint8Array) => void };
|
||||
// WebSocket connections
|
||||
const connections = new Set<any>();
|
||||
const connections = new Set<WSLike>();
|
||||
|
||||
// Function to fetch song from Last.fm
|
||||
async function fetchLastFmSong(): Promise<LastSong | null> {
|
||||
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];
|
||||
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: [] };
|
||||
|
||||
return {
|
||||
name: track.name,
|
||||
artist: track.artist['#text'],
|
||||
album: track.album['#text'],
|
||||
url: track.url,
|
||||
nowPlaying: track['@attr']?.nowplaying === 'true',
|
||||
image: track.image || [],
|
||||
};
|
||||
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<void> {
|
||||
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<void> {
|
|||
// 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<void> {
|
|||
} 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
|
||||
|
@ -127,7 +152,8 @@ const app = new Elysia()
|
|||
if (currentSong) {
|
||||
const message = JSON.stringify({
|
||||
type: 'song-update',
|
||||
data: currentSong
|
||||
data: currentSong,
|
||||
history
|
||||
});
|
||||
ws.send(message);
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue