player for the songs works now
This commit is contained in:
@@ -312,6 +312,16 @@ body {
|
|||||||
.term-track:first-child { border-top: 0; }
|
.term-track:first-child { border-top: 0; }
|
||||||
.term-track .btn { flex: none; }
|
.term-track .btn { flex: none; }
|
||||||
.term-track-name { font-size: 0.9rem; }
|
.term-track-name { font-size: 0.9rem; }
|
||||||
|
/* "play album" row sitting above the per-track list */
|
||||||
|
.term-track-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding-bottom: 0.65rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
border-bottom: 1px solid oklch(var(--b3));
|
||||||
|
}
|
||||||
|
.term-track-bar .btn { flex: none; }
|
||||||
|
|
||||||
/* --- persistent audio player bar --------------------------- */
|
/* --- persistent audio player bar --------------------------- */
|
||||||
/* Hidden until the first song plays; shown by adding `uw-playing`
|
/* Hidden until the first song plays; shown by adding `uw-playing`
|
||||||
@@ -375,11 +385,119 @@ body {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.uw-player-close:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
|
.uw-player-close:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
|
||||||
|
/* transport + playlist toggle buttons in the player bar */
|
||||||
|
.uw-player-btn {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-width: 2.85rem;
|
||||||
|
height: 2.85rem;
|
||||||
|
padding: 0 0.6rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid oklch(var(--b3));
|
||||||
|
color: oklch(var(--bc) / 0.78);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.uw-player-btn:hover { color: oklch(var(--p)); border-color: oklch(var(--p)); }
|
||||||
|
.uw-queue-badge {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
background: oklch(var(--p));
|
||||||
|
color: oklch(var(--pc));
|
||||||
|
}
|
||||||
|
#uw-player:not(.uw-has-queue) .uw-queue-badge { display: none; }
|
||||||
|
|
||||||
|
/* --- the SoundCloud-style playlist panel ------------------- */
|
||||||
|
.uw-queue {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 72rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-bottom: 1px solid oklch(var(--b3));
|
||||||
|
}
|
||||||
|
.uw-queue[hidden] { display: none; }
|
||||||
|
.uw-queue-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
border-bottom: 1px solid oklch(var(--b3));
|
||||||
|
}
|
||||||
|
.uw-queue-title { font-weight: 700; font-size: 0.95rem; color: oklch(var(--p)); }
|
||||||
|
.uw-queue-meta { font-size: 0.8rem; color: oklch(var(--bc) / 0.6); }
|
||||||
|
.uw-queue-clear {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid oklch(var(--b3));
|
||||||
|
color: oklch(var(--bc) / 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.uw-queue-clear:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
|
||||||
|
.uw-queue-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
max-height: 15rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.uw-queue-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.4rem 1.5rem;
|
||||||
|
}
|
||||||
|
.uw-queue-item:hover { background: oklch(var(--b3) / 0.5); }
|
||||||
|
.uw-queue-item.is-current { background: oklch(var(--p) / 0.12); }
|
||||||
|
.uw-queue-jump {
|
||||||
|
flex: none;
|
||||||
|
width: 1.85rem;
|
||||||
|
height: 1.85rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid oklch(var(--b3));
|
||||||
|
color: oklch(var(--bc) / 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.uw-queue-item.is-current .uw-queue-jump { color: oklch(var(--p)); border-color: oklch(var(--p)); }
|
||||||
|
.uw-queue-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.uw-queue-item.is-current .uw-queue-name { color: oklch(var(--p)); font-weight: 600; }
|
||||||
|
.uw-queue-remove {
|
||||||
|
flex: none;
|
||||||
|
width: 1.85rem;
|
||||||
|
height: 1.85rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: oklch(var(--bc) / 0.5);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.uw-queue-remove:hover { color: oklch(var(--er)); border-color: oklch(var(--er)); }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.uw-player-tag { display: none; }
|
.uw-player-tag { display: none; }
|
||||||
.uw-player-title { max-width: 9rem; font-size: 0.95rem; }
|
.uw-player-title { max-width: 7rem; font-size: 0.95rem; }
|
||||||
.uw-player-inner { padding: 0.75rem 0.95rem; gap: 0.75rem; }
|
.uw-player-inner { padding: 0.75rem 0.95rem; gap: 0.6rem; }
|
||||||
.uw-playing body { padding-bottom: 5.75rem; }
|
.uw-playing body { padding-bottom: 5.75rem; }
|
||||||
|
.uw-player-btn { min-width: 2.4rem; height: 2.4rem; padding: 0 0.4rem; font-size: 0.95rem; }
|
||||||
|
.uw-player-close { width: 2.4rem; height: 2.4rem; }
|
||||||
|
#uw-audio { height: 2.7rem; min-width: 7rem; }
|
||||||
|
.uw-queue-head, .uw-queue-item { padding-left: 0.95rem; padding-right: 0.95rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- vim-style statusline (the footer) --------------------- */
|
/* --- vim-style statusline (the footer) --------------------- */
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="term-cmd-actions">
|
<div class="term-cmd-actions">
|
||||||
|
{% if tracks | length > 0 %}
|
||||||
|
<button type="button" class="uw-play-album btn btn-primary btn-sm"
|
||||||
|
data-tracks-from="#uw-album-tracks">▶ play album</button>
|
||||||
|
{% endif %}
|
||||||
<a href="/audio/albums" class="btn btn-outline btn-sm">[ cd .. ]</a>
|
<a href="/audio/albums" class="btn btn-outline btn-sm">[ cd .. ]</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -56,8 +60,13 @@
|
|||||||
<span class="term-head-name">~/audio/{{ album.slug }}/tracklist</span>
|
<span class="term-head-name">~/audio/{{ album.slug }}/tracklist</span>
|
||||||
<span class="term-head-meta term-tag is-green">{{ tracks | length }} tracks</span>
|
<span class="term-head-meta term-tag is-green">{{ tracks | length }} tracks</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body" id="uw-album-tracks">
|
||||||
{% if tracks | length > 0 %}
|
{% if tracks | length > 0 %}
|
||||||
|
<div class="term-track-bar">
|
||||||
|
<button type="button" class="uw-play-album btn btn-primary btn-sm"
|
||||||
|
data-tracks-from="#uw-album-tracks">▶ play album</button>
|
||||||
|
<span class="term-track-name t-dim">// queue all {{ tracks | length }} tracks</span>
|
||||||
|
</div>
|
||||||
{% for track in tracks %}
|
{% for track in tracks %}
|
||||||
<div class="term-track">
|
<div class="term-track">
|
||||||
<button type="button" class="uw-play btn btn-primary btn-sm"
|
<button type="button" class="uw-play btn btn-primary btn-sm"
|
||||||
|
|||||||
@@ -40,8 +40,10 @@
|
|||||||
{% if album.description %}
|
{% if album.description %}
|
||||||
<p class="term-prose text-sm opacity-80">{{ album.description }}</p>
|
<p class="term-prose text-sm opacity-80">{{ album.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="pt-2">
|
<div class="flex flex-wrap gap-2 pt-2">
|
||||||
<a href="/audio/albums/{{ album.slug }}" class="btn btn-primary btn-sm">[ open → ]</a>
|
<button type="button" class="uw-play-album-remote btn btn-primary btn-sm"
|
||||||
|
data-album-tracks-url="/audio/albums/{{ album.slug }}/tracks">▶ play</button>
|
||||||
|
<a href="/audio/albums/{{ album.slug }}" class="btn btn-outline btn-sm">[ open → ]</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
<p class="term-sub">// {{ tracks | length }} track(s) across every album.</p>
|
<p class="term-sub">// {{ tracks | length }} track(s) across every album.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="term-cmd-actions">
|
<div class="term-cmd-actions">
|
||||||
|
{% if tracks | length > 0 %}
|
||||||
|
<button type="button" class="uw-play-album btn btn-primary btn-sm"
|
||||||
|
data-tracks-from="#uw-songs-list">▶ play all</button>
|
||||||
|
{% endif %}
|
||||||
<a href="/audio/albums" class="btn btn-outline btn-sm">[ albums ]</a>
|
<a href="/audio/albums" class="btn btn-outline btn-sm">[ albums ]</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -26,7 +30,7 @@
|
|||||||
<span class="term-head-name">~/audio/playlist.m3u</span>
|
<span class="term-head-name">~/audio/playlist.m3u</span>
|
||||||
<span class="term-head-meta term-tag is-green">{{ tracks | length }} tracks</span>
|
<span class="term-head-meta term-tag is-green">{{ tracks | length }} tracks</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body" id="uw-songs-list">
|
||||||
{% if tracks | length > 0 %}
|
{% if tracks | length > 0 %}
|
||||||
{% for track in tracks %}
|
{% for track in tracks %}
|
||||||
<div class="term-track">
|
<div class="term-track">
|
||||||
|
|||||||
@@ -38,32 +38,186 @@
|
|||||||
highlightTheme(localStorage.getItem('theme') || 'dark');
|
highlightTheme(localStorage.getItem('theme') || 'dark');
|
||||||
markActiveNav();
|
markActiveNav();
|
||||||
}
|
}
|
||||||
// --- persistent audio player (survives boosted page navigation) ---
|
// --- persistent audio player with playlist queue ----------
|
||||||
function uwPlay(src, title) {
|
// Survives htmx-boosted navigation: window state persists and
|
||||||
|
// #uw-player carries hx-preserve so <audio> keeps playing.
|
||||||
|
var uwQueue = []; // [{ src, title }]
|
||||||
|
var uwIndex = -1; // index of the current track, -1 when empty
|
||||||
|
|
||||||
|
function uwSave() {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('uwQueue', JSON.stringify({ q: uwQueue, i: uwIndex }));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function uwRestore() {
|
||||||
|
try {
|
||||||
|
var d = JSON.parse(sessionStorage.getItem('uwQueue') || 'null');
|
||||||
|
if (d && d.q) { uwQueue = d.q; uwIndex = (typeof d.i === 'number' ? d.i : -1); }
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function uwRenderQueue() {
|
||||||
|
var player = document.getElementById('uw-player');
|
||||||
|
if (player) player.classList.toggle('uw-has-queue', uwQueue.length > 0);
|
||||||
|
var badge = document.getElementById('uw-queue-badge');
|
||||||
|
if (badge) badge.textContent = uwQueue.length;
|
||||||
|
var count = document.getElementById('uw-queue-count');
|
||||||
|
if (count) count.textContent = uwQueue.length + (uwQueue.length === 1 ? ' track' : ' tracks');
|
||||||
|
var list = document.getElementById('uw-queue-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
uwQueue.forEach(function (t, idx) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'uw-queue-item' + (idx === uwIndex ? ' is-current' : '');
|
||||||
|
var jump = document.createElement('button');
|
||||||
|
jump.type = 'button';
|
||||||
|
jump.className = 'uw-queue-jump';
|
||||||
|
jump.setAttribute('data-uw-jump', idx);
|
||||||
|
jump.textContent = (idx === uwIndex ? '▸' : (idx + 1));
|
||||||
|
var name = document.createElement('span');
|
||||||
|
name.className = 'uw-queue-name';
|
||||||
|
name.setAttribute('data-uw-jump', idx);
|
||||||
|
name.textContent = t.title || 'unknown track';
|
||||||
|
var rm = document.createElement('button');
|
||||||
|
rm.type = 'button';
|
||||||
|
rm.className = 'uw-queue-remove';
|
||||||
|
rm.setAttribute('data-uw-remove', idx);
|
||||||
|
rm.setAttribute('aria-label', 'Remove from playlist');
|
||||||
|
rm.textContent = '✕';
|
||||||
|
li.appendChild(jump);
|
||||||
|
li.appendChild(name);
|
||||||
|
li.appendChild(rm);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Point <audio> at the current queue entry; play it when asked.
|
||||||
|
function uwLoad(autoplay) {
|
||||||
var audio = document.getElementById('uw-audio');
|
var audio = document.getElementById('uw-audio');
|
||||||
if (!audio) return;
|
|
||||||
var now = document.getElementById('uw-now');
|
var now = document.getElementById('uw-now');
|
||||||
if (now) now.textContent = title || 'unknown track';
|
if (!audio) return;
|
||||||
if (audio.getAttribute('src') !== src) audio.setAttribute('src', src);
|
var t = uwQueue[uwIndex];
|
||||||
|
if (!t) {
|
||||||
|
if (now) now.textContent = '—';
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
document.documentElement.classList.remove('uw-playing');
|
||||||
|
uwRenderQueue();
|
||||||
|
uwSave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
document.documentElement.classList.add('uw-playing');
|
document.documentElement.classList.add('uw-playing');
|
||||||
var played = audio.play();
|
if (now) now.textContent = t.title || 'unknown track';
|
||||||
if (played && played.catch) played.catch(function () {});
|
if (audio.getAttribute('src') !== t.src) {
|
||||||
|
audio.setAttribute('src', t.src);
|
||||||
|
audio.load();
|
||||||
|
}
|
||||||
|
if (autoplay) {
|
||||||
|
var p = audio.play();
|
||||||
|
if (p && p.catch) p.catch(function () {});
|
||||||
|
}
|
||||||
|
uwRenderQueue();
|
||||||
|
uwSave();
|
||||||
}
|
}
|
||||||
function uwStop() {
|
// Replace the whole queue with a fresh set of tracks and play.
|
||||||
|
function uwPlayList(tracks) {
|
||||||
|
if (!tracks || !tracks.length) return;
|
||||||
|
uwQueue = tracks.slice();
|
||||||
|
uwIndex = 0;
|
||||||
|
uwLoad(true);
|
||||||
|
}
|
||||||
|
// Add one track: play it now if idle, otherwise queue it.
|
||||||
|
function uwAdd(src, title) {
|
||||||
var audio = document.getElementById('uw-audio');
|
var audio = document.getElementById('uw-audio');
|
||||||
if (audio) audio.pause();
|
var idle = uwIndex < 0 || !audio || audio.ended || !audio.getAttribute('src');
|
||||||
document.documentElement.classList.remove('uw-playing');
|
uwQueue.push({ src: src, title: title });
|
||||||
|
if (idle) { uwIndex = uwQueue.length - 1; uwLoad(true); }
|
||||||
|
else { uwRenderQueue(); uwSave(); }
|
||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', initPage);
|
function uwNext() {
|
||||||
|
if (uwIndex >= 0 && uwIndex < uwQueue.length - 1) { uwIndex++; uwLoad(true); }
|
||||||
|
}
|
||||||
|
function uwPrev() {
|
||||||
|
var audio = document.getElementById('uw-audio');
|
||||||
|
if (audio && audio.currentTime > 3) { audio.currentTime = 0; return; }
|
||||||
|
if (uwIndex > 0) { uwIndex--; uwLoad(true); }
|
||||||
|
else if (audio) audio.currentTime = 0;
|
||||||
|
}
|
||||||
|
function uwJump(idx) {
|
||||||
|
if (idx >= 0 && idx < uwQueue.length) { uwIndex = idx; uwLoad(true); }
|
||||||
|
}
|
||||||
|
function uwRemove(idx) {
|
||||||
|
if (idx < 0 || idx >= uwQueue.length) return;
|
||||||
|
var playing = document.documentElement.classList.contains('uw-playing');
|
||||||
|
uwQueue.splice(idx, 1);
|
||||||
|
if (idx < uwIndex) { uwIndex--; uwRenderQueue(); uwSave(); }
|
||||||
|
else if (idx > uwIndex) { uwRenderQueue(); uwSave(); }
|
||||||
|
else {
|
||||||
|
if (uwIndex >= uwQueue.length) uwIndex = uwQueue.length - 1;
|
||||||
|
uwLoad(playing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function uwClear() {
|
||||||
|
uwQueue = [];
|
||||||
|
uwIndex = -1;
|
||||||
|
uwLoad(false);
|
||||||
|
var panel = document.getElementById('uw-queue');
|
||||||
|
if (panel) panel.hidden = true;
|
||||||
|
}
|
||||||
|
function uwInit() {
|
||||||
|
var audio = document.getElementById('uw-audio');
|
||||||
|
if (!audio || audio.dataset.uwBound) return;
|
||||||
|
audio.dataset.uwBound = '1';
|
||||||
|
uwRestore();
|
||||||
|
audio.addEventListener('ended', uwNext);
|
||||||
|
uwRenderQueue();
|
||||||
|
if (uwIndex >= 0 && uwQueue[uwIndex]) uwLoad(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { initPage(); uwInit(); });
|
||||||
document.addEventListener('htmx:afterSwap', initPage);
|
document.addEventListener('htmx:afterSwap', initPage);
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (!e.target.closest) return;
|
if (!e.target.closest) return;
|
||||||
var play = e.target.closest('.uw-play');
|
var albumBtn = e.target.closest('.uw-play-album');
|
||||||
if (play) {
|
if (albumBtn) {
|
||||||
uwPlay(play.getAttribute('data-src'), play.getAttribute('data-title'));
|
var sel = albumBtn.getAttribute('data-tracks-from');
|
||||||
} else if (e.target.closest('#uw-close')) {
|
var scope = (sel && document.querySelector(sel)) || document;
|
||||||
uwStop();
|
var tracks = [];
|
||||||
|
scope.querySelectorAll('.uw-play').forEach(function (b) {
|
||||||
|
tracks.push({ src: b.getAttribute('data-src'), title: b.getAttribute('data-title') });
|
||||||
|
});
|
||||||
|
uwPlayList(tracks);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Play an album straight from the listing: fetch its tracks first.
|
||||||
|
var remoteBtn = e.target.closest('.uw-play-album-remote');
|
||||||
|
if (remoteBtn) {
|
||||||
|
var rurl = remoteBtn.getAttribute('data-album-tracks-url');
|
||||||
|
if (rurl) {
|
||||||
|
remoteBtn.disabled = true;
|
||||||
|
fetch(rurl, { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) { if (d && d.tracks) uwPlayList(d.tracks); })
|
||||||
|
.catch(function () {})
|
||||||
|
.then(function () { remoteBtn.disabled = false; });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var playBtn = e.target.closest('.uw-play');
|
||||||
|
if (playBtn) {
|
||||||
|
uwAdd(playBtn.getAttribute('data-src'), playBtn.getAttribute('data-title'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var jumpEl = e.target.closest('[data-uw-jump]');
|
||||||
|
if (jumpEl) { uwJump(parseInt(jumpEl.getAttribute('data-uw-jump'), 10)); return; }
|
||||||
|
var rmEl = e.target.closest('[data-uw-remove]');
|
||||||
|
if (rmEl) { uwRemove(parseInt(rmEl.getAttribute('data-uw-remove'), 10)); return; }
|
||||||
|
if (e.target.closest('#uw-next')) { uwNext(); return; }
|
||||||
|
if (e.target.closest('#uw-prev')) { uwPrev(); return; }
|
||||||
|
if (e.target.closest('#uw-queue-clear')) { uwClear(); return; }
|
||||||
|
if (e.target.closest('#uw-queue-toggle')) {
|
||||||
|
var panel = document.getElementById('uw-queue');
|
||||||
|
if (panel) panel.hidden = !panel.hidden;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest('#uw-close')) { uwClear(); return; }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
|
<link href="/static/css/app.css" rel="stylesheet" type="text/css">
|
||||||
@@ -179,10 +333,21 @@
|
|||||||
<span class="term-seg is-mode">100%</span>
|
<span class="term-seg is-mode">100%</span>
|
||||||
</footer>
|
</footer>
|
||||||
<div id="uw-player" hx-preserve="true">
|
<div id="uw-player" hx-preserve="true">
|
||||||
|
<div id="uw-queue" class="uw-queue" hidden>
|
||||||
|
<div class="uw-queue-head">
|
||||||
|
<span class="uw-queue-title">☰ playlist</span>
|
||||||
|
<span id="uw-queue-count" class="uw-queue-meta">0 tracks</span>
|
||||||
|
<button type="button" id="uw-queue-clear" class="uw-queue-clear">clear</button>
|
||||||
|
</div>
|
||||||
|
<ol id="uw-queue-list" class="uw-queue-list"></ol>
|
||||||
|
</div>
|
||||||
<div class="uw-player-inner">
|
<div class="uw-player-inner">
|
||||||
<span class="uw-player-tag">▶ now playing</span>
|
<span class="uw-player-tag">▶ now playing</span>
|
||||||
<span id="uw-now" class="uw-player-title">—</span>
|
<span id="uw-now" class="uw-player-title">—</span>
|
||||||
|
<button type="button" id="uw-prev" class="uw-player-btn" aria-label="Previous track" title="Previous">⏮</button>
|
||||||
<audio id="uw-audio" controls preload="none"></audio>
|
<audio id="uw-audio" controls preload="none"></audio>
|
||||||
|
<button type="button" id="uw-next" class="uw-player-btn" aria-label="Next track" title="Next">⏭</button>
|
||||||
|
<button type="button" id="uw-queue-toggle" class="uw-player-btn" aria-label="Toggle playlist" title="Playlist">☰<span id="uw-queue-badge" class="uw-queue-badge">0</span></button>
|
||||||
<button type="button" id="uw-close" class="uw-player-close" aria-label="Stop playback" title="Stop">✕</button>
|
<button type="button" id="uw-close" class="uw-player-close" aria-label="Stop playback" title="Stop">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -548,6 +548,41 @@ async fn public_album(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Published tracks of an album as JSON, so the audio listing page can
|
||||||
|
/// queue a whole album without navigating to its detail page.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn public_album_tracks(
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let album = audio_albums::Entity::find()
|
||||||
|
.filter(audio_albums::Column::Slug.eq(slug))
|
||||||
|
.filter(audio_albums::Column::Published.eq(true))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
let tracks = audio_tracks::Entity::find()
|
||||||
|
.filter(audio_tracks::Column::AlbumId.eq(album.id))
|
||||||
|
.filter(audio_tracks::Column::Published.eq(true))
|
||||||
|
.order_by_asc(audio_tracks::Column::TrackNumber)
|
||||||
|
.order_by_asc(audio_tracks::Column::Title)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let items: Vec<serde_json::Value> = tracks
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| {
|
||||||
|
json!({
|
||||||
|
"src": format!("/audio/tracks/{}/stream", t.id),
|
||||||
|
"title": t.title,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
format::json(json!({ "tracks": items }))
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn public_tracks(
|
async fn public_tracks(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
@@ -996,6 +1031,7 @@ pub fn routes() -> Routes {
|
|||||||
.add("/audio/stream/{filename}", get(raw_audio_stream))
|
.add("/audio/stream/{filename}", get(raw_audio_stream))
|
||||||
.add("/audio/albums", get(public_albums))
|
.add("/audio/albums", get(public_albums))
|
||||||
.add("/audio/albums/{slug}", get(public_album))
|
.add("/audio/albums/{slug}", get(public_album))
|
||||||
|
.add("/audio/albums/{slug}/tracks", get(public_album_tracks))
|
||||||
.add("/audio/tracks", get(public_tracks))
|
.add("/audio/tracks", get(public_tracks))
|
||||||
.add("/audio/tracks/{id}/stream", get(track_stream))
|
.add("/audio/tracks/{id}/stream", get(track_stream))
|
||||||
.add("/admin/images", get(admin_images))
|
.add("/admin/images", get(admin_images))
|
||||||
|
|||||||
Reference in New Issue
Block a user