This commit is contained in:
parent
0e75b24a7a
commit
bc3f82d975
11
CHANGELOG.md
11
CHANGELOG.md
@ -9,6 +9,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.46.0] — 2026-09-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Magazine: readable filenames for exported MP3s.** A downloaded or zipped article MP3 is now named `Magazine Title - p<page> - Article Title - Author.mp3` instead of its internal id; the on-disk file itself is unchanged (still the stable id), only the name offered on download/inside the zip. The zip's entries are also numbered by reading order, so extracting and sorting by filename plays back in the magazine's own order.
|
||||||
|
- **Magazine: an MP3 playlist.** A new Playlist panel lists every article with a generated MP3, in reading order, with cover art, title/author and a player that auto-advances to the next track. "Play all articles" now uses this directly (no synthesis wait) whenever every approved article already has a generated MP3; a queue with even one ungenerated article keeps the original live sentence-by-sentence reader, so a listener never hits a silent gap mid-drive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.45.0] — 2026-09-22
|
## [1.45.0] — 2026-09-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -4335,7 +4343,8 @@ Initial feature-complete release.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.45.0...HEAD
|
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.46.0...HEAD
|
||||||
|
[1.46.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.45.0...v1.46.0
|
||||||
[1.45.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.44.0...v1.45.0
|
[1.45.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.44.0...v1.45.0
|
||||||
[1.44.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.43.1...v1.44.0
|
[1.44.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.43.1...v1.44.0
|
||||||
[1.43.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.43.0...v1.43.1
|
[1.43.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.43.0...v1.43.1
|
||||||
|
|||||||
@ -1703,14 +1703,40 @@ async def magazine_generate_article_audio(doc_id: str, article_id: str, request:
|
|||||||
return {"job_id": job_id}
|
return {"job_id": job_id}
|
||||||
|
|
||||||
|
|
||||||
|
_FILENAME_UNSAFE_RE = re.compile(r'[\\/:*?"<>|]+')
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_filename_part(text: str) -> str:
|
||||||
|
text = _FILENAME_UNSAFE_RE.sub("-", text or "").strip()
|
||||||
|
return re.sub(r"\s+", " ", text)
|
||||||
|
|
||||||
|
|
||||||
|
def _article_download_filename(meta: dict, article: dict) -> str:
|
||||||
|
"""A human-readable name for an exported MP3 -- the on-disk file stays
|
||||||
|
named by article id (stable, filesystem-safe, never collides); this is
|
||||||
|
only what a download or a zip entry is called, since that's what a
|
||||||
|
person actually sees in their music/podcast player or file browser."""
|
||||||
|
parts = [meta.get("title") or "Magazine"]
|
||||||
|
page = (article.get("pages") or [None])[0]
|
||||||
|
if page is not None:
|
||||||
|
parts.append(f"p{page}")
|
||||||
|
parts.append(article.get("title") or "Untitled")
|
||||||
|
if article.get("author"):
|
||||||
|
parts.append(article["author"])
|
||||||
|
name = " - ".join(_sanitize_filename_part(p) for p in parts if p)
|
||||||
|
return (name[:180] or article.get("id") or "article") + ".mp3"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/magazine/{doc_id}/articles/{article_id}/audio")
|
@router.get("/api/magazine/{doc_id}/articles/{article_id}/audio")
|
||||||
async def magazine_article_audio_file(doc_id: str, article_id: str):
|
async def magazine_article_audio_file(doc_id: str, article_id: str):
|
||||||
d, _ = _require_magazine(doc_id)
|
d, meta = _require_magazine(doc_id)
|
||||||
article_id = _article_id(article_id)
|
article_id = _article_id(article_id)
|
||||||
path = d / "audio" / f"{article_id}.mp3"
|
path = d / "audio" / f"{article_id}.mp3"
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise HTTPException(404, "No audio generated yet")
|
raise HTTPException(404, "No audio generated yet")
|
||||||
return FileResponse(path, media_type="audio/mpeg", filename=f"{article_id}.mp3")
|
articles = mag.load_articles(d)
|
||||||
|
article = next((a for a in articles if a.get("id") == article_id), {"id": article_id})
|
||||||
|
return FileResponse(path, media_type="audio/mpeg", filename=_article_download_filename(meta, article))
|
||||||
|
|
||||||
|
|
||||||
def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str) -> None:
|
def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str) -> None:
|
||||||
@ -1759,13 +1785,27 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request):
|
|||||||
async def magazine_audio_zip(doc_id: str):
|
async def magazine_audio_zip(doc_id: str):
|
||||||
d, meta = _require_magazine(doc_id)
|
d, meta = _require_magazine(doc_id)
|
||||||
audio_dir = d / "audio"
|
audio_dir = d / "audio"
|
||||||
files = sorted(audio_dir.glob("*.mp3")) if audio_dir.is_dir() else []
|
if not audio_dir.is_dir():
|
||||||
if not files:
|
raise HTTPException(404, "No audio generated yet")
|
||||||
|
articles = mag.load_articles(d)
|
||||||
|
# Reading order, not filesystem/alpha order, so extracting the zip and
|
||||||
|
# sorting by filename plays back in the magazine's own order.
|
||||||
|
ordered = sorted(
|
||||||
|
(a for a in articles if (audio_dir / f"{a['id']}.mp3").is_file()),
|
||||||
|
key=lambda a: (a.get("pages") or [0])[0],
|
||||||
|
)
|
||||||
|
if not ordered:
|
||||||
raise HTTPException(404, "No audio generated yet")
|
raise HTTPException(404, "No audio generated yet")
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
|
||||||
for f in files:
|
for i, a in enumerate(ordered, start=1):
|
||||||
zf.write(f, arcname=f.name)
|
page = (a.get("pages") or [0])[0]
|
||||||
|
name = _article_download_filename(meta, a)
|
||||||
|
# Zero-padded sequence prefix so extracting the zip and sorting
|
||||||
|
# by filename gives the reading order even where two articles
|
||||||
|
# start on the same page.
|
||||||
|
arcname = f"{i:03d} - " + name
|
||||||
|
zf.write(audio_dir / f"{a['id']}.mp3", arcname=arcname)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine"
|
safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine"
|
||||||
return Response(content=buf.getvalue(), media_type="application/zip",
|
return Response(content=buf.getvalue(), media_type="application/zip",
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="theme-color" content="#2563EB">
|
<meta name="theme-color" content="#2563EB">
|
||||||
<meta name="app-version" content="1.45.0">
|
<meta name="app-version" content="1.46.0">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||||
@ -27,12 +27,12 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.45.0">
|
<link rel="stylesheet" href="/static/style.css?v=1.46.0">
|
||||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.45.0">
|
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.46.0">
|
||||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.45.0">
|
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.46.0">
|
||||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.45.0">
|
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.46.0">
|
||||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.45.0">
|
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.46.0">
|
||||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.45.0">
|
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.46.0">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.45.0"></script>
|
<script src="/static/loader.js?v=1.46.0"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const state = {
|
|||||||
contentsView: 'list', // list | grid — how the article list itself is shown
|
contentsView: 'list', // list | grid — how the article list itself is shown
|
||||||
contentsSelectMode: false, // batch-select articles for bulk approve/reject
|
contentsSelectMode: false, // batch-select articles for bulk approve/reject
|
||||||
contentsSelected: new Set(),
|
contentsSelected: new Set(),
|
||||||
|
playlistCurrent: null, // article id of the MP3 playlist's current track
|
||||||
pane: 'reflow', // reflow | pdf
|
pane: 'reflow', // reflow | pdf
|
||||||
playing: false,
|
playing: false,
|
||||||
cancel: false,
|
cancel: false,
|
||||||
@ -848,6 +849,7 @@ async function magGenerateAllAudio() {
|
|||||||
if (status) status.textContent = '';
|
if (status) status.textContent = '';
|
||||||
await magRefreshArticles();
|
await magRefreshArticles();
|
||||||
renderContents();
|
renderContents();
|
||||||
|
if (!$('mag-playlist')?.hidden) renderPlaylistTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
function magUpdateAudioZipLink() {
|
function magUpdateAudioZipLink() {
|
||||||
@ -2963,14 +2965,101 @@ function playBlob(blob, sentence) {
|
|||||||
|
|
||||||
// ── Play all approved articles in sequence ──────────────────────────────
|
// ── Play all approved articles in sequence ──────────────────────────────
|
||||||
async function magPlayAll() {
|
async function magPlayAll() {
|
||||||
const ids = state.articles.filter((a) => a.status === 'approved').map((a) => a.id);
|
const approved = state.articles.filter((a) => a.status === 'approved');
|
||||||
if (!ids.length) return say('Approve at least one article first', 'err');
|
if (!approved.length) return say('Approve at least one article first', 'err');
|
||||||
|
// Every approved article already has a generated MP3: use those directly
|
||||||
|
// (ready instantly, tagged, works exactly like the standalone export)
|
||||||
|
// instead of synthesising sentence by sentence live. A queue with even
|
||||||
|
// one ungenerated article keeps the original live-reading behaviour,
|
||||||
|
// since a listener partway through a magazine mid-drive should not hit a
|
||||||
|
// gap where one piece silently has no audio.
|
||||||
|
if (approved.every((a) => a.has_audio)) {
|
||||||
|
magTogglePlaylist(true);
|
||||||
|
magPlaylistPlay(_magPlaylistOrder()[0]?.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = approved.map((a) => a.id);
|
||||||
state.queue = ids;
|
state.queue = ids;
|
||||||
state.queueIndex = 0;
|
state.queueIndex = 0;
|
||||||
await magRead(ids[0], { fromStart: true });
|
await magRead(ids[0], { fromStart: true });
|
||||||
magPlay();
|
magPlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MP3 Playlist (generated audio, reading order) ───────────────────────────
|
||||||
|
// A separate, self-contained playback path from the live sentence-by-
|
||||||
|
// sentence reader above: this just points a plain <audio> element at the
|
||||||
|
// already-rendered, already-tagged files from the MP3 export feature, one
|
||||||
|
// after another. No synthesis wait, no karaoke word-timing to estimate --
|
||||||
|
// exactly what "driving mode" wants, when the audio already exists.
|
||||||
|
function _magPlaylistOrder() {
|
||||||
|
return state.articles
|
||||||
|
.filter((a) => a.has_audio)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => (a.pages?.[0] ?? 0) - (b.pages?.[0] ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function magTogglePlaylist(forceOpen) {
|
||||||
|
const panel = $('mag-playlist');
|
||||||
|
const btn = $('mag-playlist-toggle');
|
||||||
|
if (!panel) return;
|
||||||
|
const open = forceOpen === true ? true : forceOpen === false ? false : panel.hidden;
|
||||||
|
panel.hidden = !open;
|
||||||
|
if (btn) btn.setAttribute('aria-pressed', String(open));
|
||||||
|
if (open) renderPlaylistTracks();
|
||||||
|
else { const audio = $('mag-playlist-audio'); if (audio) audio.pause(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlaylistTracks() {
|
||||||
|
const list = $('mag-playlist-tracks');
|
||||||
|
if (!list) return;
|
||||||
|
const order = _magPlaylistOrder();
|
||||||
|
if (!order.length) {
|
||||||
|
list.innerHTML = '<li class="muted">No generated MP3s yet — use "Generate all audio" above.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = order.map((a, i) => {
|
||||||
|
const active = a.id === state.playlistCurrent;
|
||||||
|
return '<li class="mag-playlist-track' + (active ? ' is-playing' : '') + '" ' +
|
||||||
|
'onclick="magPlaylistPlay(\'' + a.id + '\')">' +
|
||||||
|
'<span class="mag-playlist-track-num">' + (active ? '<span class="mdi mdi-volume-high" aria-hidden="true"></span>' : i + 1) + '</span>' +
|
||||||
|
'<span class="mag-playlist-track-main">' +
|
||||||
|
'<span class="mag-playlist-track-title">' + esc(a.title || 'Untitled') + '</span>' +
|
||||||
|
(a.author ? '<span class="mag-playlist-track-sub muted">by ' + esc(a.author) + '</span>' : '') +
|
||||||
|
'</span>' +
|
||||||
|
'<span class="muted">' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '</span>' +
|
||||||
|
'</li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function magPlaylistPlay(articleId) {
|
||||||
|
if (!articleId) return;
|
||||||
|
const a = state.articles.find((x) => x.id === articleId);
|
||||||
|
if (!a) return;
|
||||||
|
state.playlistCurrent = articleId;
|
||||||
|
const audio = $('mag-playlist-audio');
|
||||||
|
if (audio) {
|
||||||
|
audio.src = '/api/magazine/' + state.doc.id + '/articles/' + articleId + '/audio';
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
}
|
||||||
|
const titleEl = $('mag-playlist-title');
|
||||||
|
const subEl = $('mag-playlist-sub');
|
||||||
|
if (titleEl) titleEl.textContent = a.title || 'Untitled';
|
||||||
|
if (subEl) subEl.textContent = (a.author ? 'by ' + a.author + ' · ' : '') + fmtPages(a.pages);
|
||||||
|
const cover = $('mag-playlist-cover');
|
||||||
|
if (cover) {
|
||||||
|
const firstPage = (a.pages || [])[0];
|
||||||
|
if (firstPage) { cover.src = '/api/magazine/' + state.doc.id + '/pages/' + firstPage; cover.hidden = false; }
|
||||||
|
else cover.hidden = true;
|
||||||
|
}
|
||||||
|
renderPlaylistTracks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _magPlaylistNext() {
|
||||||
|
const order = _magPlaylistOrder();
|
||||||
|
const i = order.findIndex((a) => a.id === state.playlistCurrent);
|
||||||
|
if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id);
|
||||||
|
}
|
||||||
|
|
||||||
function magStopQueue() {
|
function magStopQueue() {
|
||||||
state.queue = [];
|
state.queue = [];
|
||||||
state.queueIndex = -1;
|
state.queueIndex = -1;
|
||||||
@ -3231,6 +3320,11 @@ function syncDrivingPlayButton() {
|
|||||||
async function magOnShow() {
|
async function magOnShow() {
|
||||||
setupMagDropzone();
|
setupMagDropzone();
|
||||||
setupMagIngestPreviewResize();
|
setupMagIngestPreviewResize();
|
||||||
|
const playlistAudio = $('mag-playlist-audio');
|
||||||
|
if (playlistAudio && !playlistAudio.dataset.wired) {
|
||||||
|
playlistAudio.dataset.wired = '1';
|
||||||
|
playlistAudio.addEventListener('ended', _magPlaylistNext);
|
||||||
|
}
|
||||||
if ($('mag-library-list')) {
|
if ($('mag-library-list')) {
|
||||||
magLoadLibrary();
|
magLoadLibrary();
|
||||||
magLoadVoices();
|
magLoadVoices();
|
||||||
@ -3240,6 +3334,11 @@ async function magOnShow() {
|
|||||||
async function magInit() {
|
async function magInit() {
|
||||||
setupMagDropzone();
|
setupMagDropzone();
|
||||||
setupMagIngestPreviewResize();
|
setupMagIngestPreviewResize();
|
||||||
|
const playlistAudio = $('mag-playlist-audio');
|
||||||
|
if (playlistAudio && !playlistAudio.dataset.wired) {
|
||||||
|
playlistAudio.dataset.wired = '1';
|
||||||
|
playlistAudio.addEventListener('ended', _magPlaylistNext);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY);
|
const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY);
|
||||||
if (savedView === 'grid' || savedView === 'list') state.contentsView = savedView;
|
if (savedView === 'grid' || savedView === 'list') state.contentsView = savedView;
|
||||||
@ -3276,6 +3375,8 @@ root.magToggleContentsView = magToggleContentsView;
|
|||||||
root.magContentsToggleSelect = magContentsToggleSelect;
|
root.magContentsToggleSelect = magContentsToggleSelect;
|
||||||
root.magContentsCommitTitle = magContentsCommitTitle;
|
root.magContentsCommitTitle = magContentsCommitTitle;
|
||||||
root.magGenerateAllAudio = magGenerateAllAudio;
|
root.magGenerateAllAudio = magGenerateAllAudio;
|
||||||
|
root.magTogglePlaylist = magTogglePlaylist;
|
||||||
|
root.magPlaylistPlay = magPlaylistPlay;
|
||||||
root.magContentsToggleArticle = magContentsToggleArticle;
|
root.magContentsToggleArticle = magContentsToggleArticle;
|
||||||
root.magContentsSelectAllVisible = magContentsSelectAllVisible;
|
root.magContentsSelectAllVisible = magContentsSelectAllVisible;
|
||||||
root.magContentsClearSelection = magContentsClearSelection;
|
root.magContentsClearSelection = magContentsClearSelection;
|
||||||
|
|||||||
@ -64,6 +64,10 @@
|
|||||||
<button type="button" class="btn-sm" id="mag-contents-select-toggle" aria-pressed="false" onclick="magContentsToggleSelect()" title="Select several articles to approve or reject at once">
|
<button type="button" class="btn-sm" id="mag-contents-select-toggle" aria-pressed="false" onclick="magContentsToggleSelect()" title="Select several articles to approve or reject at once">
|
||||||
<span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select
|
<span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn-sm" id="mag-playlist-toggle" aria-pressed="false" onclick="magTogglePlaylist()"
|
||||||
|
title="Show every article with a generated MP3, in reading order, with a player">
|
||||||
|
<span class="mdi mdi-playlist-music-outline"></span> Playlist
|
||||||
|
</button>
|
||||||
<button type="button" class="btn-sm primary" onclick="magPlayAll()">
|
<button type="button" class="btn-sm primary" onclick="magPlayAll()">
|
||||||
<span class="mdi mdi-playlist-play"></span> Play all articles
|
<span class="mdi mdi-playlist-play"></span> Play all articles
|
||||||
</button>
|
</button>
|
||||||
@ -88,6 +92,20 @@
|
|||||||
</a>
|
</a>
|
||||||
<span id="mag-audio-export-status" class="muted" role="status" aria-live="polite"></span>
|
<span id="mag-audio-export-status" class="muted" role="status" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="mag-playlist" class="mag-playlist" hidden>
|
||||||
|
<div class="mag-playlist-player">
|
||||||
|
<img id="mag-playlist-cover" class="mag-playlist-cover" alt="" hidden>
|
||||||
|
<div class="mag-playlist-nowplaying">
|
||||||
|
<strong id="mag-playlist-title">—</strong>
|
||||||
|
<span id="mag-playlist-sub" class="muted"></span>
|
||||||
|
<audio id="mag-playlist-audio" controls preload="none" style="width:100%"></audio>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-sm" onclick="magTogglePlaylist()" title="Close playlist" aria-label="Close playlist">
|
||||||
|
<span class="mdi mdi-close"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ol id="mag-playlist-tracks" class="mag-playlist-tracks"></ol>
|
||||||
|
</div>
|
||||||
<div id="mag-ingest-status" class="mag-status" hidden></div>
|
<div id="mag-ingest-status" class="mag-status" hidden></div>
|
||||||
<!-- Decorative "watch it work" visualisation -- the essential
|
<!-- Decorative "watch it work" visualisation -- the essential
|
||||||
progress information (percentage, page count, done/failed) is
|
progress information (percentage, page count, done/failed) is
|
||||||
|
|||||||
@ -4934,6 +4934,33 @@ input[list="lang-codes"] {
|
|||||||
padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
background: var(--panel); }
|
background: var(--panel); }
|
||||||
.mag-audio-export label { color: var(--subtext); font-size: 13px; }
|
.mag-audio-export label { color: var(--subtext); font-size: 13px; }
|
||||||
|
|
||||||
|
/* MP3 playlist -- a separate, self-contained player from the live reader:
|
||||||
|
just an <audio> element pointed at already-rendered files, one track
|
||||||
|
after another. Sticky player bar + scrollable track list underneath. */
|
||||||
|
.mag-playlist { border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
background: var(--panel); margin-bottom: 12px; overflow: hidden; }
|
||||||
|
.mag-playlist-player { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
|
||||||
|
background: var(--surface); border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky; top: 0; z-index: 2; }
|
||||||
|
.mag-playlist-cover { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
|
||||||
|
.mag-playlist-nowplaying { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.mag-playlist-nowplaying strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mag-playlist-tracks { list-style: none; margin: 0; padding: 4px; max-height: 420px; overflow-y: auto; }
|
||||||
|
.mag-playlist-track { display: flex; align-items: center; gap: 10px; padding: 8px 10px;
|
||||||
|
border-radius: 6px; cursor: pointer; }
|
||||||
|
.mag-playlist-track:hover { background: var(--surface); }
|
||||||
|
.mag-playlist-track.is-playing { background: var(--surface); border-left: 3px solid var(--accent); }
|
||||||
|
.mag-playlist-track-num { width: 22px; text-align: center; color: var(--subtext); flex-shrink: 0; }
|
||||||
|
.mag-playlist-track-num .mdi { color: var(--accent); }
|
||||||
|
.mag-playlist-track-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.mag-playlist-track-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mag-playlist-track-sub { font-size: 12px; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mag-playlist-player { flex-wrap: wrap; }
|
||||||
|
.mag-playlist-nowplaying { order: 3; flex-basis: 100%; }
|
||||||
|
.mag-playlist-track .muted:last-child { display: none; }
|
||||||
|
}
|
||||||
.mag-audio-export select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
|
.mag-audio-export select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
background: var(--surface); color: var(--text); }
|
background: var(--surface); color: var(--text); }
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user