1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> 6 <meta name="description" content="Stream our artists original music collections â browse albums, EPs, and singles across multiple genres with built-in player, lyrics, and karaoke tracks."> 7 <title>Michael Molony's Music Collection</title> 8 9 <!-- Bootstrap 5.3.2 --> 10 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"> 11 12 <!-- Custom player styles --> 13 <link rel="stylesheet" href="player.style.css"> 14 15 <!-- Share button + toast styles (self-contained, no dependency on player.style.css) --> 16 <style> 17 .share-btn { 18 background: transparent; 19 border: none; 20 cursor: pointer; 21 padding: 6px; 22 line-height: 0; 23 border-radius: 6px; 24 color: #b3b3b3; 25 transition: color 0.2s, background 0.2s; 26 } 27 .share-btn:hover { color: #1DB954; background: rgba(255,255,255,0.08); } 28 .share-btn svg { display: block; width: 20px; height: 20px; } 29 30 /* Album-description title row: share icon sits beside the title; a 31 matching invisible spacer on the left keeps the title dead-center. */ 32 #album-description-title-row { 33 display: flex; 34 align-items: center; 35 justify-content: center; 36 gap: 8px; 37 } 38 #album-description-title-row #album-description-title { margin: 0; } 39 /* Spacer mirrors the share button's box (svg 18px + 6px padding each 40 side = 30px). Keep in sync if the button's size/padding changes. */ 41 #album-share-spacer { width: 30px; flex: 0 0 auto; } 42 #share-album-button { flex: 0 0 auto; } 43 #share-album-button svg { width: 18px; height: 18px; } 44 45 /* Per-track share icon (More column) â gray, greens on hover */ 46 .share-icon { color: #b3b3b3; cursor: pointer; transition: color 0.2s; } 47 .share-icon:hover { color: #1DB954; } 48 49 /* Toast for clipboard-copy fallback */ 50 #share-toast { 51 position: fixed; 52 left: 50%; 53 bottom: 90px; 54 transform: translateX(-50%) translateY(20px); 55 background: #1DB954; 56 color: #000; 57 font-weight: 600; 58 font-size: 14px; 59 padding: 10px 18px; 60 border-radius: 24px; 61 box-shadow: 0 4px 12px rgba(0,0,0,0.4); 62 opacity: 0; 63 pointer-events: none; 64 transition: opacity 0.25s, transform 0.25s; 65 z-index: 2000; 66 } 67 #share-toast.show { 68 opacity: 1; 69 transform: translateX(-50%) translateY(0); 70 } 71 /* Album-tile action row: icon Play + Buy side by side */ 72 .album .album-actions { display:flex; gap:6px; margin-top:7px; } 73 .album .album-actions .play-album-button { margin-top:0; } /* row controls spacing */ 74 .album .album-actions .play-icon { width:auto; flex:0 0 auto; padding-left:11px; padding-right:11px; } 75 .album .album-actions .buy-btn { flex:1 1 auto; } 76 .album .buy-btn[data-bp-loading] { opacity:.6; } 77 </style> 78</head> 79<body> 80 81 <!-- Loading overlay (removed once data is fetched) --> 82 <div id="loading-overlay">Loading Libraryâ¦</div> 83 84 <!-- âââââââââââââââââââââââââââââââââââââââââââââââââââ 85 HEADER 86 ââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 87 <header> 88 <h1>Michael Molony's Music</h1> 89 <button id="artist-profile-button" aria-label="View Artist Profile"> 90 <svg width="13" height="15" viewBox="0 0 448 512" fill="white" aria-hidden="true"> 91 <path d="M224 256a128 128 0 1 0 0-256 128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3 0 498.7 13.3 512 29.7 512h388.6c16.4 0 29.7-13.3 29.7-29.7 0-98.5-79.8-178.3-178.3-178.3h-91.4z"/> 92 </svg>
93 <span>Artist's Profile</span> 94 </button> 95 </header> 96 97 <!-- âââââââââââââââââââââââââââââââââââââââââââââââââââ 98 ARTIST SELECTOR 99 ââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 100 <div id="artist-selector-container"> 101 <label for="artist-selector">Artist:</label> 102 <select id="artist-selector"> 103 <option value="">â Loading â</option> 104 </select> 105 </div> 106 107 <!-- âââââââââââââââââââââââââââââââââââââââââââââââââââ 108 MAIN CONTENT 109 ââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 110 <main> 111 112 <!-- Search Bar --> 113 <div id="search-bar-container"> 114 <input type="text" id="search-bar" placeholder="Search for a song or albumâ¦" autocomplete="off" /> 115 <button id="clear-search" aria-label="Clear search">×</button> 116 </div> 117 118 <!-- Albums Section --> 119 <section id="albums-section"> 120 <h2>Albums / EPs / Singles</h2> 121 <div id="albums-grid"></div> 122 </section> 123 124 <!-- Album Description Panel --> 125 <div id="album-description-section"> 126 <div id="album-description-title-row"> 127 <span id="album-share-spacer" aria-hidden="true"></span> 128 <h3 id="album-description-title"></h3> 129 <button id="share-album-button" class="share-btn" aria-label="Share this album" title="Share this album"> 130 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg> 131 </button> 132 </div> 133 <p id="album-description-text"></p> 134 </div> 135 136 <!-- Song Table --> 137 <div class="table-responsive"> 138 <table id="song-table"> 139 <thead> 140 <tr> 141 <th>Track</th> 142 <th id="th-artist">Artist</th> 143 <th>Song Name</th> 144 <th>Time</th> 145 <th>Play</th> 146 <th>More</th> 147 </tr> 148 </thead> 149 <tbody></tbody> 150 </table> 151 </div> 152 153 </main> 154 155 <!-- âââââââââââââââââââââââââââââââââââââââââââââââââââ 156 FIXED AUDIO PLAYER 157 JS toggles: audioPlayerContainer.style.display = 'flex' | 'none' 158 flex-direction: column is set in CSS, so two rows stack vertically. 159 ââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 160 <div id="audio-player-container" style="display: none;"> 161 162 <!-- Functional audio element â native UI hidden, JS controls it fully --> 163 <audio id="audio-player"> 164 Your browser does not support the audio element. 165 </audio> 166 167 <!-- Row 1: Prev | Now Playing | Pause | Next --> 168 <div class="player-row player-top"> 169 <button id="prev-button" class="nav-button" aria-label="Previous Track">◄◄ Prev</button> 170 <div id="current-song">No song playing</div> 171 <button id="pause-button" class="control-button" aria-label="Pause">▮▮ Pause</button> 172 <button id="next-button" class="nav-button" aria-label="Next Track">Next ►►</button> 173 <button id="share-song-button" class="share-btn" aria-label="Share this song" title="Share this song"> 174 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg> 175 </button> 176 </div> 177 178 <!-- Row 2: Stop | Progress Bar | Time | Volume --> 179 <div class="player-row" id="audio-controls"> 180 <button id="stop-button" class="control-button">■ Stop</button> 181 <div id="progress-container">
182 <input type="range" id="progress-bar" value="0" min="0" max="100" step="any"> 183 <div id="time-display">0:00 / 0:00</div> 184 </div> 185 <div id="volume-container"> 186 <label for="volume-control">🔊</label> 187 <input type="range" id="volume-control" min="0" max="1" step="0.01" value="1" /> 188 </div> 189 </div> 190 191 </div> 192 193 <!-- âââââââââââââââââââââââââââââââââââââââââââââââââââ 194 LYRICS MODAL 195 ââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 196 <div id="lyrics-modal" class="modal"> 197 <div class="modal-content"> 198 <button class="close" aria-label="Close">×</button> 199 <pre id="lyrics-content" style="white-space: pre-wrap;"></pre> 200 <a id="download-lyrics" href="#" download class="control-button" style="margin-top: 12px;"> 201 ⭳ Download Lyrics 202 </a> 203 </div> 204 </div> 205 206 <!-- âââââââââââââââââââââââââââââââââââââââââââââââââââ 207 FOOTER 208 ââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 209 <footer> 210 <p>© 2025 Michael Molony</p> 211 </footer> 212 213 <!-- Toast for share link copy fallback --> 214 <div id="share-toast"></div> 215 216 <!-- Bootstrap JS bundle --> 217
217<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
217 218 219 <!-- âââââââââââââââââââââââââââââââââââââââââââââââââââ 220 APPLICATION SCRIPT â UNCHANGED FROM ORIGINAL 221 ââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 222
222<script> 223 document.addEventListener('DOMContentLoaded', async function() { 224 225 // ===================================================================== 226 // CONSTANTS 227 // ===================================================================== 228 229 // Placeholder for missing album covers (inline SVG â no external file needed) 230 const PLACEHOLDER_COVER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300' viewBox='0 0 300 300'%3E%3Crect width='300' height='300' fill='%23282828'/%3E%3Ctext x='150' y='160' text-anchor='middle' fill='%23555' font-size='48'%3E%E2%99%AB%3C/text%3E%3C/svg%3E"; 231 232 // ===================================================================== 233 // DOM REFERENCES 234 // ===================================================================== 235 236 const audioPlayer = document.getElementById('audio-player'); 237 const audioPlayerContainer = document.getElementById('audio-player-container'); 238 const volumeControl = document.getElementById('volume-control'); 239 const stopButton = document.getElementById('stop-button'); 240 const pauseButton = document.getElementById('pause-button'); 241 const prevButton = document.getElementById('prev-button'); 242 const nextButton = document.getElementById('next-button'); 243 const progressBar = document.getElementById('progress-bar'); 244 const timeDisplay = document.getElementById('time-display'); 245 const currentSongDisplay = document.getElementById('current-song'); 246 const lyricsModal = document.getElementById('lyrics-modal'); 247 const closeModal = document.querySelector('.close'); 248 const lyricsContent = document.getElementById('lyrics-content'); 249 const downloadLyrics = document.getElementById('download-lyrics'); 250 const artistSelector = document.getElementById('artist-selector'); 251 const searchBar = document.getElementById('search-bar'); 252 const clearSearchButton = document.getElementById('clear-search'); 253 254 // ===================================================================== 255 // ANALYTICS 256 // ===================================================================== 257 258 // Persist anonymous session ID for the browser session (no PII) 259 let _sessionId = sessionStorage.getItem('mm_session'); 260 if (!_sessionId) { 261 _sessionId = crypto.randomUUID 262 ? crypto.randomUUID() 263 : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { 264 const r = Math.random() * 16 | 0; 265 return (c === 'x' ? r : r & 0x3 | 0x8).toString(16); 266 }); 267 sessionStorage.setItem('mm_session', _sessionId); 268 } 269 270 let _activePlayId = null; // UUID of the in-progress play event 271 let _playStartTime = null; // Date.now() when track started 272 let _trackDuration = 0; // seconds, set when metadata loads 273 let _pendingSource = null; // set by caller before playTrack() 274 275 276 async function trackStart(song, source) { 277 const playId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36); 278 _activePlayId = playId; 279 _playStartTime = Date.now(); 280 _pendingSource = null; 281 try { 282 await fetch('track.php', { 283 method: 'POST', 284 headers: { 'Content-Type': 'application/json' }, 285 body: JSON.stringify({ 286 action: 'start', 287 play_id: playId, 288 serial: song.id, 289 album_id: song.album || '', 290 artist: song.artist, 291 title: song.title, 292 duration: _trackDuration || Math.round(audioPlayer.duration) || 0, 293 play_source: source || 'unknown', 294 session_id: _sessionId, 295 referrer: document.referrer || '', 296 }) 297 }); 298 } catch (e) { console.warn('Analytics start failed:', e); } 299 } 300 301 async function trackEnd(completed = false) {
302 if (!_activePlayId) return; 303 const playId = _activePlayId; 304 const listenedSec = _playStartTime ? Math.round((Date.now() - _playStartTime) / 1000) : 0; 305 _activePlayId = null; 306 _playStartTime = null; 307 try { 308 await fetch('track.php', { 309 method: 'POST', 310 headers: { 'Content-Type': 'application/json' }, 311 body: JSON.stringify({ 312 action: 'end', 313 play_id: playId, 314 listened_sec: listenedSec, 315 completed: completed, 316 }) 317 }); 318 } catch (e) { console.warn('Analytics end failed:', e); } 319 } 320 321 // Fire trackEnd on page unload (catches tab close / navigation) 322 window.addEventListener('beforeunload', () => { if (_activePlayId) trackEnd(false); }); 323 324 // ===================================================================== 325 // VOLUME CONTROL 326 // ===================================================================== 327 328 volumeControl.addEventListener('input', function() { 329 audioPlayer.volume = parseFloat(this.value); 330 }); 331 332 // ===================================================================== 333 // DATA LAYER â fetched from Library via library.php 334 // ===================================================================== 335 336 let allArtists = []; // raw API response: [{name, slug, albums:[â¦]}] 337 let albums = []; // [{id, name, cover, songs:[serial,â¦]}] 338 let songs = []; // [{id, track, artist, title, file, album, duration, ...}] 339 let currentArtistSlug = ''; // slug of the currently loaded artist 340 341 // Album description state 342 let _descriptionCache = {}; // albumId â description string (or '') 343 let _descriptionRequest = null; // albumId of in-flight fetch (race guard) 344 345 /** Clear cached descriptions â call when switching artists. */ 346 function clearDescriptionCache() { _descriptionCache = {}; } 347 348 // ===================================================================== 349 // UTILITY FUNCTIONS 350 // ===================================================================== 351 352 /** Convert seconds to "m:ss" display string */ 353 function formatTime(s) { 354 const sec = Math.round(s); 355 return `${(sec / 60 | 0)}:${('0' + (sec % 60)).slice(-2)}`; 356 } 357
358 /** Convert "M:SS" or "H:MM:SS" string (or plain number) to integer seconds */ 359 function durationToSec(d) { 360 if (!d) return 0; 361 if (typeof d === 'number') return Math.round(d); 362 const parts = String(d).split(':').map(Number); 363 return parts.length === 2 ? parts[0]*60 + parts[1] : parts[0]*3600 + parts[1]*60 + (parts[2]||0); 364 } 365 366 /** Get all songs belonging to an album */ 367 function getSongsByAlbum(albumId) { 368 return songs.filter(song => song.album === albumId); 369 } 370 371 // ===================================================================== 372 // SHARING 373 // ===================================================================== 374 375 // Canonical player base â matches the URL used by the email mailer. 376 // Built from the live host so it works on any environment. 377 const PLAYER_BASE = location.origin + '/music/'; 378 379 let _toastTimer = null; 380 function showToast(msg) { 381 const t = document.getElementById('share-toast'); 382 if (!t) return; 383 t.textContent = msg; 384 t.classList.add('show'); 385 clearTimeout(_toastTimer); 386 _toastTimer = setTimeout(() => t.classList.remove('show'), 2500); 387 } 388 389 /** 390 * Build a shareable link and offer it via the native share sheet 391 * (mobile / supported browsers) or fall back to clipboard copy. 392 * kind: 'song' â ?sid=⦠| 'album' â ?aid=⦠393 */ 394 async function shareLink(kind, id, label) { 395 if (!id) return; 396 const param = kind === 'song' ? 'sid' : 'aid'; 397 const url = `${PLAYER_BASE}?${param}=${encodeURIComponent(id)}&src=share`; 398 const text = kind === 'song' 399 ? `Listen to "${label}"` 400 : `Listen to the album "${label}"`; 401 402 if (navigator.share) { 403 try { 404 await navigator.share({ title: document.title, text, url }); 405 return; 406 } catch (err) { 407 // User dismissed the share sheet â do nothing further. 408 if (err && err.name === 'AbortError') return; 409 // Any other failure falls through to clipboard copy. 410 } 411 } 412 413 try { 414 await navigator.clipboard.writeText(url); 415 showToast('Link copied to clipboard'); 416 } catch { 417 window.prompt('Copy this link:', url); 418 } 419 } 420 421 // ===================================================================== 422 // ARTIST / LIBRARY LOADING 423 // ===================================================================== 424 425 /** 426 * Load an artist's data into the internal albums[] / songs[] arrays. 427 * Called on initial load and when the artist dropdown changes. 428 */ 429 function loadArtist(artistData) { 430 albums = []; 431 songs = []; 432 currentArtistSlug = artistData.slug; 433 clearDescriptionCache(); 434 document.querySelector('header h1').textContent = artistData.name + "'s Music"; 435 document.title = artistData.name + "'s Music Collection"; 436
437 artistData.albums.forEach(albumInfo => { 438 const albumId = albumInfo.album_id; 439 440 albums.push({ 441 id: albumId, 442 name: albumInfo.album, 443 cover: albumInfo.cover || PLACEHOLDER_COVER, 444 songs: albumInfo.tracks.map(t => t.serial), 445 path: albumInfo.path || '', 446 }); 447 448 albumInfo.tracks.forEach(track => { 449 songs.push({ 450 id: track.serial, 451 track: track.tracknumber, 452 artist: albumInfo.artist, 453 title: track.title, 454 file: track.mp3_url, 455 album: albumId, 456 duration: formatTime(track.duration), 457 hasLyrics: track.has_lyrics, 458 lyricsPath: `${albumInfo.path}/mp3/${track.filename}`, 459 hasKaraoke: !!(track.has_karaoke === true || track.has_karaoke === 'true' || track.has_karaoke === 1), 460 karaokeUrl: track.karaoke_url || null, 461 hasVideo: !!(track.has_video === true || track.has_video === 'true' || track.has_video === 1), 462 videoUrl: track.video_url || null, 463 }); 464 }); 465 }); 466 467 songs.sort((a, b) => parseInt(a.track, 10) - parseInt(b.track, 10)); 468 updateProfileLink(); 469 } 470 471 /** Update the Artist Profile button to point to the current artist's profile page. */ 472 function updateProfileLink() { 473 document.getElementById('artist-profile-button').onclick = function() { 474 window.location.href = `/music/${encodeURIComponent(currentArtistSlug)}/profile`; 475 }; 476 } 477 478 /** 479 * Search ALL artists for a song by serial (BETA-nnnnnn). 480 * Used for ?sid= URL parameter which must work across artists. 481 */ 482 function findSongGlobally(serial) { 483 for (const artist of allArtists) { 484 for (const albumInfo of artist.albums) { 485 const track = albumInfo.tracks.find(t => t.serial === serial); 486 if (track) return { artistData: artist, albumInfo, track }; 487 } 488 } 489 return null; 490 } 491 492 /** 493 * Search ALL artists for an album by album_id (ALB-nnnn). 494 * Used for ?aid= URL parameter which must work across artists. 495 */ 496 function findAlbumGlobally(albumId) { 497 for (const artist of allArtists) { 498 const albumInfo = artist.albums.find(a => a.album_id === albumId); 499 if (albumInfo) return { artistData: artist, albumInfo }; 500 } 501 return null; 502 } 503 504 /** Find an artist by slug (directory name). Used for ?artist= URL parameter. */ 505 function findArtistBySlug(slug) { 506 return allArtists.find(a => a.slug.toLowerCase() === slug.toLowerCase()) || null; 507 } 508 509 // ââ Fetch the library data ââââââââââââââââââââââââââââââââââââââââââ 510 try { 511 const resp = await fetch('library.php'); 512 if (!resp.ok) throw new Error(`HTTP ${resp.status}`); 513 const data = await resp.json(); 514 allArtists = data.artists || []; 515 } catch (err) { 516 console.error('Failed to load library:', err); 517 document.getElementById('loading-overlay').textContent = 518 'Error loading music library. Check that library.php is accessible.'; 519 return; 520 } 521 522 // ââ Populate artist dropdown ââââââââââââââââââââââââââââââââââââââââ 523 artistSelector.innerHTML = '';
524 allArtists.forEach(a => { 525 const opt = document.createElement('option'); 526 opt.value = a.slug; 527 opt.textContent = a.name; 528 artistSelector.appendChild(opt); 529 }); 530 531 // ââ Process URL parameters BEFORE choosing default artist âââââââââââ 532 const urlParams = new URLSearchParams(window.location.search); 533 const paramSid = urlParams.get('sid'); 534 const paramAid = urlParams.get('aid'); 535 const paramSrc = urlParams.get('src'); 536 const pathMatch = window.location.pathname.match(/\/music\/([a-zA-Z0-9_-]+)\/?/); 537 const paramArtist = urlParams.get('artist') || (pathMatch ? pathMatch[1] : null); 538 539 // Canonicalize URL â replace ?artist=slug with clean /music/slug in browser history 540 if (paramArtist && urlParams.get('artist')) { 541 history.replaceState(null, '', `/music/${paramArtist}`); 542 } 543 544 let startupArtist = null; 545 let startupAlbum = null; 546 let startupSong = null; 547 548 // ?sid= takes priority â find the song, determine its artist and album 549 if (paramSid) { 550 const found = findSongGlobally(paramSid); 551 if (found) { 552 startupArtist = found.artistData; 553 startupAlbum = found.albumInfo.album_id; 554 startupSong = found; 555 } 556 } 557 558 // ?aid= â find the album, determine its artist 559 if (!startupArtist && paramAid) { 560 const found = findAlbumGlobally(paramAid); 561 if (found) { 562 startupArtist = found.artistData; 563 startupAlbum = found.albumInfo.album_id; 564 } 565 } 566 567 // ?artist= â find the artist by slug 568 if (!startupArtist && paramArtist) { 569 startupArtist = findArtistBySlug(paramArtist); 570 } 571 572 // Fall back to Michael Molony or first available 573 if (!startupArtist) { 574 startupArtist = allArtists.find(a => /michael molony/i.test(a.name)) || allArtists[0]; 575 } 576 577 // Default to first album if none specified 578 if (!startupAlbum && startupArtist && startupArtist.albums.length > 0) { 579 startupAlbum = startupArtist.albums[0].album_id; 580 } 581 582 // Load the determined artist 583 if (startupArtist) { 584 artistSelector.value = startupArtist.slug; 585 loadArtist(startupArtist); 586 } 587 588 // Remove loading overlay 589 document.getElementById('loading-overlay').remove(); 590 591 // ===================================================================== 592 // PLAYLIST / PLAYBACK STATE 593 // ===================================================================== 594 595 let playlist = []; 596 let currentTrackIndex = 0; 597 let currentSongId = null; 598 599 // ===================================================================== 600 // DISPLAY FUNCTIONS 601 // ===================================================================== 602 603 function populateAlbums(albumList) { 604 const grid = document.getElementById('albums-grid'); 605 grid.innerHTML = ''; 606 albumList.forEach(a => { 607 grid.insertAdjacentHTML('beforeend', ` 608 <div class="album" data-album-id="${a.id}"> 609 <img src="${a.cover}" alt="${a.name} Cover" 610 onerror="this.onerror=null;this.src='${PLACEHOLDER_COVER}'"> 611 <div class="album-name">${a.name}</div> 612 <div class="album-actions"> 613 <button class="play-album-button play-icon" 614 data-album-id="${a.id}" 615 aria-label="Play album ${a.name}" title="Play">▶</button> 616 <button class="play-album-button buy-btn bp-buy" 617 data-album="${a.id}" 618 aria-label="Buy album ${a.name}">Buy</button> 619 </div> 620 </div> 621 `); 622 }); 623 } 624 625 function populateTable(songList) { 626 const tableBody = document.querySelector('#song-table tbody'); 627 tableBody.innerHTML = ''; 628 629 tableBody.insertAdjacentHTML('beforeend', songList.map(song => { 630 const album = song.album ? albums.find(a => a.id === song.album) : null; 631 const cover = album?.cover || PLACEHOLDER_COVER; 632 633 return `<tr> 634 <td> 635 <img class="album-thumbnail" src="${cover}" alt="${album?.name || 'Single'}" 636 onerror="this.onerror=null;this.src='${PLACEHOLDER_COVER}'" 637 style="width:48px;height:48px"> 638 ${String(song.track).padStart(2, '0')} 639 </td> 640 <td>${song.artist}</td> 641 <td> 642 <a class="song-name" href="#" data-src="${song.file}" 643 data-song-id="${song.id}">${song.title}</a> 644 </td> 645 <td class="duration-cell">${song.duration || '--:--'}</td> 646 <td> 647 <button class="play-button" data-src="${song.file}" 648 data-song-id="${song.id}">Play</button> 649 </td> 650 <td class="more-cell"> 651 ${song.hasLyrics 652 ? `<span title="Lyrics" style="display:inline-block;vertical-align:middle"><svg class="lyrics-icon" data-lyrics="${song.lyricsPath}" xmlns="http://www.w3.org/2000/svg" width="27" height="27" viewB
652ox="0 0 24 24" fill="none" stroke="#1a6dff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;display:block"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg></span>` 653 : `<span title="Instrumental" style="display:inline-block;vertical-align:middle"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="display:block"><path d="M9 18V5l12-2v13" fill="none" stroke="#b8860b" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><circle cx="6" cy="18" r="3" fill="#FFD700" stroke="#b8860b" stroke-width="1.5"/><circle cx="18" cy="16" r="3" fill="#FFD700" stroke="#b8860b" stroke-width="1.5"/></svg></span>`} 654 ${song.hasKaraoke && song.karaokeUrl 655 ? `<span title="Karaoke" style="display:inline-block;vertical-align:middle;margin-left:5px"><svg class="karaoke-icon" data-url="${song.karaokeUrl}" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="cursor:pointer;display:block"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg></span>` 656 : ''} 657 ${song.hasVideo && song.videoUrl 658 ? `<span title="Video" style="display:inline-block;vertical-align:middle;margin-left:5px"><svg class="video-icon" data-url="${song.videoUrl}" xmlns="http://www.w3.org/2000/svg" width="29" height="22" viewBox="0 0 26 18" style="cursor:pointer;display:block"><rect x="1" y="1" width="24" height="16" rx="3" ry="3" fill="#ef4444" stroke="#c00" stroke-width="1"/><polygon points="10,5 10,13 19,9" fill="white"/></svg></span>` 659 : ''} 660 <span title="Share this song" style="display:inline-block;vertical-align:middle;margin-left:5px"><svg class="share-icon" data-song-id="${song.id}" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg></span> 661 </td> 662 </tr>`; 663 }).join('')); 664 } 665 666 function highlightCurrentSong(src) { 667 document.querySelectorAll('#song-table tbody tr').forEach(row => { 668 const link = row.querySelector('.song-name'); 669 row.classList.toggle('playing', link.getAttribute('data-src') === src); 670 }); 671 } 672 673 function highlightActivePlayButton(songId) { 674 document.querySelectorAll('.play-button').forEach(btn => { 675 const isActive = btn.getAttribute('data-song-id') === songId; 676 btn.classList.toggle('active', isActive); 677 btn.textContent = isActive ? 'Stop' : 'Play'; 678 }); 679 } 680 681 function highlightSelectedAlbum(albumId) { 682 document.querySelectorAll('.album').forEach(el => { 683 el.classList.toggle('selected', el.getAttribute('data-album-id') === albumId); 684 }); 685 showAlbumDescription(albumId); 686 } 687 688// ââ Album description fetching & display ââââââââââââââââââââââââââââ 689async function showAlbumDescription(albumId) { 690 const descSection = document.getElementById('album-description-section'); 691 const descTitle = document.getElementById('album-description-title'); 692 const descText = document.getElementById('album-description-text'); 693 694 const album = albumId ? albums.find(a => a.id === albumId) : null; 695 if (!album || (!album.path && (!album.cover || album.cover.startsWith('data:')))) { 696 _descriptionRequest = null; 697 descSection.style.display = 'none'; 698 return; 699 } 700 701 // Point the album-share button at this album 702 const shareAlbumBtn = document.getElementById('share-album-button'); 703 if (shareAlbumBtn) { 704 shareAlbumBtn.dataset.albumId = album.id; 705 shareAlbumBtn.dataset.albumName = album.name; 706 } 707 708 if (!(albumId in _descriptionCache)) { 709 descSection.style.display = 'none'; 710 _descriptionRequest = albumId; 711 try { 712 const basePath = (album.cover && !album.cover.startsWith('data:')) 713 ? album.cover.substring(0, album.cover.lastIndexOf('/')) 714 : album.path; 715 const resp = await fetch(basePath + '/album.json'); 716 if (!resp.ok) throw new Error('HTTP ' + resp.status); 717 const json = await resp.json(); 718 _descriptionCache[albumId] = (typeof json.description === 'string' ? json.description.trim() : ''); 719 } catch { 720 _descriptionCache[albumId] = ''; 721 } 722 if (_descriptionRequest !== albumId) return; 723 _descriptionRequest = null; 724 } 725 726 descTitle.textContent = album.name; 727 descText.textContent = _descriptionCache[albumId]; 728 descSection.style.display = 'block'; 729} 730 // ===================================================================== 731 // PLAYBACK FUNCTIONS 732 // ===================================================================== 733 734 function loadPlaylist(songIds, startIndex = 0) {
735 playlist = songIds.map(id => songs.find(s => s.id === id)).filter(Boolean); 736 currentTrackIndex = startIndex; 737 if (playlist.length > 0) { 738 playTrack(currentTrackIndex); 739 } 740 } 741 742 function playTrack(index) { 743 if (index < 0 || index >= playlist.length) { 744 console.error('Track index out of bounds'); 745 return; 746 } 747 trackEnd(false); // end tracking for previous track 748 749 const song = playlist[index]; 750 audioPlayer.src = song.file; 751 audioPlayer.play(); 752 currentSongDisplay.textContent = `Playing: ${song.title}`; 753 highlightCurrentSong(song.file); 754 highlightActivePlayButton(song.id); 755 audioPlayerContainer.style.display = 'flex'; 756 currentSongId = song.id; 757 758 // Start analytics â source was stored by the caller 759 _trackDuration = durationToSec(song.duration); 760 trackStart(song, _pendingSource || 'unknown'); 761 } 762 763 /** Reset the player to its idle state. Used by Stop button and song toggle-off. */ 764 function resetPlayer() { 765 trackEnd(false); 766 audioPlayer.pause(); 767 audioPlayer.currentTime = 0; 768 progressBar.value = 0; 769 timeDisplay.textContent = '0:00 / 0:00'; 770 currentSongDisplay.textContent = 'No song playing'; 771 audioPlayerContainer.style.display = 'none'; 772 773 playlist = []; 774 currentTrackIndex = 0; 775 currentSongId = null; 776 777 highlightSelectedAlbum(null); 778 document.querySelectorAll('#song-table tr, .play-button').forEach(el => { 779 el.classList.remove('playing', 'active'); 780 if (el.classList.contains('play-button')) el.textContent = 'Play'; 781 }); 782 } 783 784 // ===================================================================== 785 // EVENT LISTENERS 786 // ===================================================================== 787 788 // Artist dropdown change 789 artistSelector.addEventListener('change', function() { 790 const artist = allArtists.find(a => a.slug === this.value); 791 if (!artist) return; 792 793 resetPlayer(); 794 loadArtist(artist); 795 populateAlbums(albums); 796 history.pushState(null, '', `/music/${artist.slug}`); 797 798 const firstAlbumId = albums.length > 0 ? albums[0].id : null; 799 if (firstAlbumId) { 800 populateTable(getSongsByAlbum(firstAlbumId)); 801 highlightSelectedAlbum(firstAlbumId); 802 } else { 803 populateTable([]); 804 } 805 }); 806 807 // Album grid â select album to show its songs 808 document.getElementById('albums-grid').addEventListener('click', function(event) { 809 if (event.target.closest('.bp-buy')) return; // Buy is handled by buy.js, not here 810 const albumDiv = event.target.closest('.album'); 811 if (!albumDiv) return; 812 813 const albumId = albumDiv.getAttribute('data-album-id'); 814 815 // Always show songs and highlight first (so table exists before playback highlights) 816 populateTable(getSongsByAlbum(albumId)); 817 highlightSelectedAlbum(albumId); 818 819 // "Play Album" button starts playback (after table is built) 820 if (event.target.classList.contains('play-album-button')) { 821 _pendingSource = 'album-play'; 822 loadPlaylist(getSongsByAlbum(albumId).map(s => s.id), 0); 823 } 824 }); 825 826 // Song table â play/stop individual songs, or click song name 827 document.getElementById('song-table').addEventListener('click', function(e) { 828 e.preventDefault(); 829 const target = e.target.closest('.play-button, .song-name'); 830 if (!target) return; 831 832 const src = target.dataset.src; 833 const songId = target.dataset.songId; 834 if (!src) return; 835 836 const song = songs.find(s => s.file === src); 837 if (!song) return; 838 839 // Toggle off if already playing this song 840 if (song.id === currentSongId) { 841 resetPlayer(); 842 } else { 843 _pendingSource = target.classList.contains('play-button') ? 'play-btn' : 'song-link'; 844 const album = song.album ? albums.find(a => a.id === song.album) : null; 845 const songPos = album?.songs.indexOf(song.id) ?? 0; 846 loadPlaylist(album?.songs || [song.id], songPos); 847 highlightSelectedAlbum(album?.id || null); 848 } 849 }); 850 851 // Stop button 852 stopButton.addEventListener('click', resetPlayer); 853 854 // Share â current song (now-playing bar) 855 document.getElementById('share-song-button').addEventListener('click', function() { 856 if (!currentSongId) return; 857 const song = songs.find(s => s.id === currentSongId); 858 shareLink('song', currentSongId, song ? song.title : ''); 859 }); 860 861 // Share â selected album (description panel) 862 document.getElementById('share-album-button').addEventListener('click', function() { 863 shareLink('album', this.dataset.albumId, this.dataset.albumName || ''); 864 }); 865 866 // Pause / Resume button 867 pauseButton.addEventListener('click', function() { 868 if (audioPlayer.paused) { 869 audioPlayer.play(); 870 pauseButton.innerHTML = '▮▮ Pause'; 871 } else { 872 audioPlayer.pause(); 873 pauseButton.innerHTML = '▶ Resume'; 874 } 875 }); 876 877 // Reset pause button label when a new track starts 878 audioPlayer.addEventListener('play', function() { 879 pauseButton.innerHTML = '▮▮ Pause'; 880 }); 881 882 // Next track 883 nextButton.addEventListener('click', function() { 884 if (playlist.length === 0) return; 885 _pendingSource = 'next'; 886 currentTrackIndex = (currentTrackIndex + 1) % playlist.length; 887 playTrack(currentTrackIndex); 888 }); 889 890 // Previous track 891 prevButton.addEventListener('click', function() { 892 if (playlist.length === 0) return; 893 _pendingSource = 'prev'; 894 currentTrackIndex = (currentTrackIndex - 1 + playlist.length) % playlist.length; 895 playTrack(currentTrackIndex); 896 }); 897 898 // Auto-advance when track ends 899 audioPlayer.addEventListener('ended', function() { 900 trackEnd(true); 901 if (playlist.length === 0) return; 902 _pendingSource = 'auto'; 903 currentTrackIndex = (currentTrackIndex + 1) % playlist.length; 904 playTrack(currentTrackIndex); 905 }); 906 907 // raf handle for progress bar throttle â prevents stutter on mobile 908 let _progressRaf = null; 909 910 // Progress bar updates 911 audioPlayer.addEventListener('timeupdate', function() { 912 if (_progressRaf) return; 913 _progressRaf = requestAnimationFrame(() => { 914 _progressRaf = null; 915 if (audioPlayer.duration) { 916 progressBar.value = (audioPlayer.currentTime / audioPlayer.duration) * 100; 917 timeDisplay.textContent = `${formatTime(audioPlayer.currentTime)} / ${formatTime(audioPlayer.duration)}`; 918 } 919 }); 920 }); 921 922 audioPlayer.addEventListener('loadedmetadata', function() { 923 timeDisplay.textContent = `0:00 / ${formatTime(audioPlayer.duration)}`; 924 }); 925 926 // Seek 927 progressBar.addEventListener('input', function() { 928 if (audioPlayer.duration) { 929 audioPlayer.currentTime = (progressBar.value / 100) * audioPlayer.duration; 930 } 931 }); 932 933 // More column clicks (lyrics, karaoke, video icons) 934 document.getElementById('song-table').addEventListener('click', function(event) { 935 const lyricsIcon = event.target.closest('.lyrics-icon'); 936 if (lyricsIcon) { 937 const lyricsPath = lyricsIcon.getAttribute('data-lyrics'); 938 fetch(`lyrics.php?file=${encodeURIComponent(lyricsPath)}`) 939 .then(response => { 940 if (!response.ok) throw new Error('Lyrics not found.'); 941 return response.text(); 942 }) 943 .then(data => { 944 lyricsContent.textContent = data; 945 const blob = new Blob([data], { type: 'text/plain' }); 946 downloadLyrics.href = URL.createObjectURL(blob); 947 downloadLyrics.download = lyricsPath.split('/').pop().replace('.mp3', '-lyrics.txt'); 948 downloadLyrics.style.display = 'inline-block'; 949 lyricsModal.style.display = 'block'; 950 }) 951 .catch(error => { 952 lyricsContent.textContent = 'Lyrics not available.'; 953 downloadLyrics.style.display = 'none'; 954 lyricsModal.style.display = 'block'; 955 console.error(error); 956 }); 957 return; 958 } 959
960 const karaokeIcon = event.target.closest('.karaoke-icon'); 961 if (karaokeIcon) { 962 window.open(karaokeIcon.getAttribute('data-url'), '_blank'); 963 return; 964 } 965 966 const videoIcon = event.target.closest('.video-icon'); 967 if (videoIcon) { 968 window.open(videoIcon.getAttribute('data-url'), '_blank'); 969 return; 970 } 971 972 const shareIcon = event.target.closest('.share-icon'); 973 if (shareIcon) { 974 const songId = shareIcon.getAttribute('data-song-id'); 975 const song = songs.find(s => s.id === songId); 976 shareLink('song', songId, song ? song.title : ''); 977 return; 978 } 979 }); 980 981 // Lyrics modal â close 982 closeModal.addEventListener('click', function() { 983 lyricsModal.style.display = 'none'; 984 downloadLyrics.style.display = 'inline-block'; 985 }); 986 987 window.addEventListener('click', function(event) { 988 if (event.target === lyricsModal) { 989 lyricsModal.style.display = 'none'; 990 downloadLyrics.style.display = 'inline-block'; 991 } 992 }); 993 994 // ===================================================================== 995 // INITIAL SETUP 996 // ===================================================================== 997 998 populateAlbums(albums); 999 1000 // Handle ?sid= play prompt (security: user must confirm playback) 1001 if (startupSong) { 1002 const song = songs.find(s => s.id === paramSid); 1003 if (song) { 1004 const playPrompt = document.createElement('div'); 1005 Object.assign(playPrompt.style, { 1006 position: 'fixed', top: '0', left: '0', width: '100%', height: '80%', 1007 display: 'flex', justifyContent: 'center', alignItems: 'center', 1008 padding: '15px', backgroundColor: 'rgba(0, 0, 0, 0.5)', 1009 color: 'white', borderRadius: '5px', zIndex: '1000' 1010 }); 1011 1012 playPrompt.innerHTML = ` 1013 <div style="background: #222; padding: 20px; border-radius: 10px; text-align: center; box-shadow: 0 4px 8px rgba(0,0,0,0.3);"> 1014 <p style="font-size: 18px; margin-bottom: 15px;">Play "${song.title}"?</p> 1015 <button id="confirm-play" style="background: #1DB954; color: white; border: none; padding: 8px 20px; margin: 0 10px; border-radius: 20px; cursor: pointer; font-weight: bold; transition: all 0.2s;">Yes</button> 1016 <button id="cancel-play" style="background: #333; color: white; border: 1px solid #555; padding: 8px 20px; border-radius: 20px; cursor: pointer; transition: all 0.2s;">No</button> 1017 </div> 1018 `; 1019 document.body.appendChild(playPrompt); 1020 1021 document.getElementById('confirm-play').onclick = () => { 1022 _pendingSource = paramSrc || 'sid-url'; 1023 if (song.album) { 1024 const album = albums.find(a => a.id === song.album); 1025 if (album) { 1026 loadPlaylist(album.songs, album.songs.indexOf(song.id)); 1027 } 1028 } else { 1029 loadPlaylist([song.id], 0); 1030 } 1031 document.body.removeChild(playPrompt); 1032 }; 1033 1034 document.getElementById('cancel-play').onclick = () => { 1035 document.body.removeChild(playPrompt); 1036 }; 1037 } 1038 } 1039 1040 // Display songs for the startup album and highlight it 1041 populateTable(getSongsByAlbum(startupAlbum)); 1042 highlightSelectedAlbum(startupAlbum); 1043 1044 // ===================================================================== 1045 // SEARCH 1046 // ===================================================================== 1047 1048 searchBar.addEventListener('input', function() { 1049 const query = this.value.toLowerCase(); 1050 clearSearchButton.style.display = query ? 'block' : 'none'; 1051 1052 let filteredSongs = []; 1053 1054 if (!query) { 1055 const firstAlbumId = albums.length > 0 ? albums[0].id : null; 1056 if (firstAlbumId) { 1057 filteredSongs = getSongsByAlbum(firstAlbumId); 1058 highlightSelectedAlbum(firstAlbumId); 1059 } 1060 } else { 1061 const matchedAlbums = albums.filter(a => a.name.toLowerCase().includes(query)); 1062 1063 filteredSongs = matchedAlbums.length 1064 ? matchedAlbums.flatMap(a => getSongsByAlbum(a.id)) 1065 : songs.filter(s => 1066 ['track', 'artist', 'title'].some(p => s[p].toLowerCase().includes(query)) 1067 ); 1068 1069 highlightSelectedAlbum(null); 1070 } 1071 1072 populateTable(filteredSongs); 1073 }); 1074 1075 clearSearchButton.addEventListener('click', function() { 1076 searchBar.value = ''; 1077 clearSearchButton.style.display = 'none'; 1078 const firstAlbumId = albums.length > 0 ? albums[0].id : null; 1079 if (firstAlbumId) { 1080 populateTable(getSongsByAlbum(firstAlbumId)); 1081 highlightSelectedAlbum(firstAlbumId); 1082 } 1083 }); 1084 1085 }); // end DOMContentLoaded 1086 </script>
1086 1087 1088<nav aria-hidden="true" style="display:none"><a href="/music/Library/james-stewart-evans/">James Stewart Evans</a> 1089<a href="/music/Library/james-stewart-evans/2025-barstool-vacation/">Barstool Vacation</a> 1090<a href="/music/Library/james-stewart-evans/2025-love-don-t-end-with-goodbye/">Love Don't End With Goodbye</a> 1091<a href="/music/Library/james-stewart-evans/2025-no-expiration-date-on-my-butt/">No Expiration Date, On My Butt</a> 1092<a href="/music/Library/james-stewart-evans/2025-the-golden-years-lie/">The Golden Years Lie</a> 1093<a href="/music/Library/michael-molony/">Michael Molony</a> 1094<a href="/music/Library/michael-molony/2025-clockmaker-s-collection-volume-i/">Clockmaker's Collection (Volume I)</a> 1095<a href="/music/Library/michael-molony/2025-dinner-for-eight/">Dinner for Eight</a> 1096<a href="/music/Library/michael-molony/2025-footsteps-through-the-celtic-mist/">Footsteps Through the Celtic Mist</a> 1097<a href="/music/Library/michael-molony/2025-raga-shreen/">Raga Shreen</a> 1098<a href="/music/Library/michael-molony/2025-ripples-in-memories/">Ripples in Memories</a> 1099<a href="/music/Library/michael-molony/2026-trade-winds-and-twilight/">Trade Winds and Twilight</a> 1100<a href="/music/Library/sherry-winger/">Sherry Winger</a> 1101<a href="/music/Library/sherry-winger/2025-10-more-chances/">10 More Chances</a> 1102</nav>
1102<script src="/stripexx/buy.js" defer></script>
1102 1103</body> 1104</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.