PageSourceSearch

https://dreamshire.com/music/

html dreamshire.com collected 2026-09-25 01:19:08 UTC 50,659 bytes, 1,104 lines download raw bytes

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">&times;</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">&#9668;&#9668; Prev</button>
170            <div id="current-song">No song playing</div>
171            <button id="pause-button" class="control-button" aria-label="Pause">&#9646;&#9646; Pause</button>
172            <button id="next-button" class="nav-button" aria-label="Next Track">Next &#9658;&#9658;</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">&#9632; 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">&#128266;</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">&times;</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                &#11123; Download Lyrics
202            </a>
203        </div>
204    </div>
205
206    <!-- ═══════════════════════════════════════════════════
207         FOOTER
208    ════════════════════════════════════════════════════ -->
209    <footer>
210        <p>&copy; 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">&#9654;</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 = '&#9646;&#9646; Pause';
871        } else {
872            audioPlayer.pause();
873            pauseButton.innerHTML = '&#9654; Resume';
874        }
875    });
876
877    // Reset pause button label when a new track starts
878    audioPlayer.addEventListener('play', function() {
879        pauseButton.innerHTML = '&#9646;&#9646; 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&#039;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&#039;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.