瀏覽代碼

Add PDF spreads; restyle Musicify Now Playing

PDF viewers (Drive and PDF Viewer): add a page layout menu (automatic, single, two pages, two pages with cover alone). Automatic follows the document's /PageLayout. Spreads fall back to single pages on narrow views. Fit width/page, prev/next, keyboard navigation and resize handling are now spread-aware. Add a spread icon and localized strings in all languages.

Musicify: restyle the embedded player and Now Playing overlay to match the Drive music player, using stroked SVG icons, a blurred cover backdrop, a white play button and a thin seek bar. Add a spectrum visualizer (skipped on iOS/iPadOS), a scrolling marquee for long titles with a settings toggle, and a purple theme when there is no cover art.

Drive: small mobile CSS fixes for the sidebar, theme cards and popover.
Toby Chui 15 小時之前
父節點
當前提交
6165d65654

+ 12 - 2
src/web/Drive/css/drive.css

@@ -3629,6 +3629,8 @@ button.stg-row:hover .stg-txt b {
 
     html.nav-open .sidebar {
         transform: none;
+        border-radius: 1em;
+        margin: 0.4em;
     }
 
     html.nav-open .scrim {
@@ -3815,14 +3817,22 @@ button.stg-row:hover .stg-txt b {
         display: none;
     }
 
+    .theme-cards {
+        display: grid;
+        grid-template-columns: repeat(3, minmax(0, 1fr));
+        width: 100%;
+        flex: 1 1 100%;
+    }
+
     .theme-card {
-        width: calc(33.3% - 7px);
+        width: auto;
+        min-width: 0;
     }
 
     .popover {
         left: 8px !important;
         right: 8px;
-        width: auto;
+        width: auto !important; 
     }
 
     .hide-xs {

+ 8 - 0
src/web/Drive/css/viewer.css

@@ -1375,6 +1375,14 @@ button.vw-pill-label:hover {
     min-width: max-content;
 }
 
+.pdf-row {
+    display: flex;
+    align-items: flex-start;
+    justify-content: center;
+    gap: 12px;
+    flex: none;
+}
+
 .pdf-page {
     position: relative;
     background: #fff;

+ 1 - 0
src/web/Drive/js/icons.js

@@ -94,6 +94,7 @@
         sortAsc: '<path d="M7 20V4M3.5 7.5 7 4l3.5 3.5"/><path d="M13 7h8M13 12h6M13 17h4"/>',
         sortDesc: '<path d="M7 4v16M3.5 16.5 7 20l3.5-3.5"/><path d="M13 7h4M13 12h6M13 17h8"/>',
         panel: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/><path d="M17.5 8.5h1M17.5 11.5h1"/>',
+        spread: '<rect x="2.5" y="5" width="8.5" height="14" rx="1.5"/><rect x="13" y="5" width="8.5" height="14" rx="1.5"/>',
         info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5"/><path d="M12 7.6h.01"/>',
         x: '<path d="M6 6l12 12M18 6 6 18"/>',
         check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',

+ 104 - 23
src/web/Drive/js/viewer.pdf.js

@@ -24,6 +24,9 @@
     var CSS_UNITS = 96 / 72;
     var ZOOMS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4];
     var MAX_RENDERED = 24;
+    var SPREAD_GAP = 12;
+    //Narrower than this, two page spreads fall back to single pages
+    var SPREAD_MIN_WIDTH = 640;
 
     function ready() {
         return U.loadScript(BASE + "pdf.js?v=2.7.570").then(function () {
@@ -40,6 +43,7 @@
         }
         var st = {
             pdf: null, task: null, pages: [], count: 0, zoom: 1, mode: "auto", rot: 0, current: 1,
+            docSpread: "none", spread: null,
             destroyed: false, renderTimer: null, rendered: [], outline: null,
             find: { q: "", hits: [], at: -1, text: {} }
         };
@@ -68,6 +72,7 @@
             '<div class="vw-pill"><button class="vw-ic" data-t="out" title="' + esc(T("pv/zoomOut", "Zoom out")) + '">' + I("minimize") + '</button>' +
             '<button class="vw-pill-label" data-t="zoom" title="' + esc(T("vw/zoomOptions", "Zoom options")) + '">100%</button>' +
             '<button class="vw-ic" data-t="in" title="' + esc(T("pv/zoomIn", "Zoom in")) + '">' + I("plus") + '</button></div>' +
+            '<button class="vw-ic vw-hide-sm" data-t="layout" title="' + esc(T("vw/layout", "Page layout")) + '">' + I("spread") + '</button>' +
             '<button class="vw-ic vw-hide-sm" data-t="rotate" title="' + esc(T("pv/rotate", "Rotate")) + '">' + I("rotate") + '</button>' +
             '<button class="vw-ic" data-t="find" title="' + esc(T("vw/find", "Find in document")) + ' (Ctrl+F)">' + I("search") + '</button><span class="vw-sep vw-hide-sm"></span>';
         var zoomLabel = ctx.tools.querySelector('[data-t="zoom"]');
@@ -119,7 +124,11 @@
             }
             st.pdf = pdf;
             st.count = pdf.numPages;
-            return pdf.getPage(1).then(function (first) {
+            //Honour the /PageLayout the document asks for (e.g. a manual laid out as facing pages)
+            return pdf.getPageLayout().catch(function () { return ""; }).then(function (layout) {
+                st.docSpread = /^Two(Page|Column)Left$/.test(layout) ? "odd" : /^Two(Page|Column)Right$/.test(layout) ? "even" : "none";
+                return pdf.getPage(1);
+            }).then(function (first) {
                 st.base = first.getViewport({ scale: 1 });
                 build(first);
             });
@@ -140,7 +149,6 @@
                 var div = DriveUI.el('<div class="pdf-page" data-page="' + i + '"><div class="ph"><div class="vw-spin" style="width:22px;height:22px;border-width:2px;border-color:#ddd;border-top-color:#999"></div></div></div>');
                 var p = { n: i, div: div, viewport: i == 1 ? st.base : null, rotate: i == 1 ? first.rotate : 0, scale: 0, task: null, textDivs: null };
                 st.pages.push(p);
-                pagesEl.appendChild(div);
                 var t = DriveUI.el('<button class="pdf-thumb" data-page="' + i + '"><div class="pg" style="aspect-ratio:' + st.base.width + '/' + st.base.height + '"></div><span>' + i + '</span></button>');
                 t.addEventListener("click", (function (n) {
                     return function () {
@@ -154,6 +162,7 @@
             }
             nav.hidden = false;
             nav.querySelector("span").textContent = "/ " + st.count;
+            layoutPages();
             setZoomMode(mem.zoomMode || "auto", true);
             observe();
             ctx.setPrintable(true);
@@ -161,15 +170,79 @@
             main.focus();
         }
 
+        /* Page layout (single pages or two page spreads) ------------------------ */
+        function wantedSpread() {
+            var mode = mem.spreadMode || "auto";
+            var s = mode == "auto" ? st.docSpread : mode;
+            return main.clientWidth < SPREAD_MIN_WIDTH ? "none" : s;
+        }
+
+        //Puts the pages straight into the column, or two to a row. "odd" pairs
+        //1-2, 3-4...; "even" keeps page 1 (the cover) alone and pairs 2-3, 4-5...
+        function layoutPages() {
+            var s = wantedSpread();
+            if (s == st.spread) {
+                return false;
+            }
+            st.spread = s;
+            pagesEl.innerHTML = "";
+            var row = null;
+            st.pages.forEach(function (p) {
+                if (s == "none") {
+                    pagesEl.appendChild(p.div);
+                    return;
+                }
+                var starts = s == "odd" ? p.n % 2 == 1 : (p.n % 2 == 0 || p.n == 1);
+                if (!row || starts) {
+                    row = DriveUI.el('<div class="pdf-row"></div>');
+                    pagesEl.appendChild(row);
+                }
+                row.appendChild(p.div);
+            });
+            return true;
+        }
+
+        function relayout() {
+            var cur = st.current;
+            var changed = layoutPages();
+            if (changed || st.mode == "width" || st.mode == "page" || st.mode == "auto") {
+                setZoomMode(st.mode, changed);
+            }
+            if (changed) {
+                goToPage(cur);
+            }
+        }
+
+        function columns() {
+            return st.spread && st.spread != "none" ? 2 : 1;
+        }
+
+        //The first and last page of the row that shows page n
+        function rowOf(n) {
+            if (columns() == 1 || (n == 1 && st.spread == "even")) {
+                return [n, n];
+            }
+            var first = st.spread == "odd" ? n - (n + 1) % 2 : n - n % 2;
+            return [first, Math.min(st.count, first + 1)];
+        }
+
+        //Previous / next page, or spread when two pages share a row
+        function stepPage(dir) {
+            var r = rowOf(st.current);
+            goToPage(dir > 0 ? r[1] + 1 : rowOf(Math.max(1, r[0] - 1))[0]);
+        }
+
         /* Zoom ------------------------------------------------------------------ */
         function fitWidth() {
-            var w = main.clientWidth - 48;
-            return w / (st.base.width * CSS_UNITS * (st.rot % 180 ? st.base.height / st.base.width : 1));
+            var cols = columns();
+            var w = main.clientWidth - 48 - (cols - 1) * SPREAD_GAP;
+            return w / (cols * CSS_UNITS * (st.rot % 180 ? st.base.height : st.base.width));
         }
 
         function fitPage() {
-            var w = main.clientWidth - 48, h = main.clientHeight - 56;
-            var bw = (st.rot % 180 ? st.base.height : st.base.width) * CSS_UNITS;
+            var cols = columns();
+            var w = main.clientWidth - 48 - (cols - 1) * SPREAD_GAP, h = main.clientHeight - 56;
+            var bw = cols * (st.rot % 180 ? st.base.height : st.base.width) * CSS_UNITS;
             var bh = (st.rot % 180 ? st.base.width : st.base.height) * CSS_UNITS;
             return Math.min(w / bw, h / bh);
         }
@@ -183,7 +256,8 @@
             } else if (mode == "auto") {
                 setZoom(Math.min(fitWidth(), 1.25), initial);
             } else {
-                setZoom(parseFloat(mode), initial);
+                var z = parseFloat(mode);
+                setZoom(isNaN(z) ? st.zoom : z, initial);
             }
         }
 
@@ -449,8 +523,9 @@
                     thumbs.scrollTop += tr.top - rr.top - rr.height / 2 + tr.height / 2;
                 }
             }
-            nav.querySelector('[data-n="prev"]').disabled = st.current <= 1;
-            nav.querySelector('[data-n="next"]').disabled = st.current >= st.count;
+            var r = rowOf(st.current);
+            nav.querySelector('[data-n="prev"]').disabled = r[0] <= 1;
+            nav.querySelector('[data-n="next"]').disabled = r[1] >= st.count;
         }
 
         function goToPage(n, offsetY) {
@@ -473,8 +548,8 @@
             }).catch(function () { });
         }
 
-        nav.querySelector('[data-n="prev"]').addEventListener("click", function () { goToPage(st.current - 1); });
-        nav.querySelector('[data-n="next"]').addEventListener("click", function () { goToPage(st.current + 1); });
+        nav.querySelector('[data-n="prev"]').addEventListener("click", function () { stepPage(-1); });
+        nav.querySelector('[data-n="next"]').addEventListener("click", function () { stepPage(1); });
         navInput.addEventListener("keydown", function (e) {
             if (e.key == "Enter") {
                 var n = parseInt(navInput.value, 10);
@@ -496,11 +571,7 @@
             mem.rail = force === undefined ? !mem.rail : force;
             rail.classList.toggle("collapsed", !mem.rail);
             ctx.tools.querySelector('[data-t="rail"]').classList.toggle("on", mem.rail);
-            setTimeout(function () {
-                if (st.mode == "width" || st.mode == "page" || st.mode == "auto") {
-                    setZoomMode(st.mode);
-                }
-            }, 220);
+            setTimeout(relayout, 220);
         }
 
         ctx.body.querySelectorAll("[data-tab]").forEach(function (b) {
@@ -741,6 +812,20 @@
                     setZoomMode(st.mode);
                 } else if (t == "find") {
                     openFind();
+                } else if (t == "layout") {
+                    var cur = mem.spreadMode || "auto";
+                    var pickLayout = function (mode) {
+                        return function () {
+                            mem.spreadMode = mode;
+                            relayout();
+                        };
+                    };
+                    DriveUI.menu({ anchor: b }, [
+                        { checkable: true, checked: cur == "auto", label: T("vw/layoutAuto", "Automatic"), action: pickLayout("auto") },
+                        { checkable: true, checked: cur == "none", label: T("vw/layoutSingle", "Single page"), action: pickLayout("none") },
+                        { checkable: true, checked: cur == "odd", label: T("vw/layoutTwo", "Two pages"), action: pickLayout("odd") },
+                        { checkable: true, checked: cur == "even", label: T("vw/layoutCover", "Two pages, cover alone"), action: pickLayout("even") }
+                    ]);
                 } else if (t == "zoom") {
                     var pick = function (mode) {
                         return function () {
@@ -841,9 +926,9 @@
                 }
                 var k = e.key;
                 if (k == "ArrowRight" || k == "PageDown" || k == "n") {
-                    goToPage(st.current + 1);
+                    stepPage(1);
                 } else if (k == "ArrowLeft" || k == "PageUp" || k == "p") {
-                    goToPage(st.current - 1);
+                    stepPage(-1);
                 } else if (k == "Home") {
                     goToPage(1);
                 } else if (k == "End") {
@@ -867,11 +952,7 @@
                 return true;
             },
             print: print,
-            onResize: function () {
-                if (st.mode == "width" || st.mode == "page" || st.mode == "auto") {
-                    setZoomMode(st.mode);
-                }
-            },
+            onResize: relayout,
             destroy: function () {
                 st.destroyed = true;
                 clearTimeout(st.renderTimer);

+ 30 - 0
src/web/Drive/locale/drive.json

@@ -549,6 +549,11 @@
                 "vw/fit": "Fit to screen",
                 "vw/fitPage": "Fit page",
                 "vw/fitWidth": "Fit width",
+                "vw/layout": "Page layout",
+                "vw/layoutAuto": "Automatic",
+                "vw/layoutSingle": "Single page",
+                "vw/layoutTwo": "Two pages",
+                "vw/layoutCover": "Two pages, cover alone",
                 "vw/flash": "Flash",
                 "vw/focal": "Focal length",
                 "vw/formatHint": "Download it to open it with another program.",
@@ -1215,6 +1220,11 @@
                 "vw/fit": "符合螢幕",
                 "vw/fitPage": "符合頁面",
                 "vw/fitWidth": "符合寬度",
+                "vw/layout": "頁面配置",
+                "vw/layoutAuto": "自動",
+                "vw/layoutSingle": "單頁",
+                "vw/layoutTwo": "雙頁",
+                "vw/layoutCover": "雙頁,封面獨立",
                 "vw/flash": "閃光燈",
                 "vw/focal": "焦距",
                 "vw/formatHint": "下載後即可用其他程式開啟。",
@@ -1881,6 +1891,11 @@
                 "vw/fit": "符合螢幕",
                 "vw/fitPage": "符合頁面",
                 "vw/fitWidth": "符合寬度",
+                "vw/layout": "頁面配置",
+                "vw/layoutAuto": "自動",
+                "vw/layoutSingle": "單頁",
+                "vw/layoutTwo": "雙頁",
+                "vw/layoutCover": "雙頁,封面獨立",
                 "vw/flash": "閃光燈",
                 "vw/focal": "焦距",
                 "vw/formatHint": "下載後即可用其他程式開啟。",
@@ -2547,6 +2562,11 @@
                 "vw/fit": "适合屏幕",
                 "vw/fitPage": "适合页面",
                 "vw/fitWidth": "适合宽度",
+                "vw/layout": "页面布局",
+                "vw/layoutAuto": "自动",
+                "vw/layoutSingle": "单页",
+                "vw/layoutTwo": "双页",
+                "vw/layoutCover": "双页,封面独立",
                 "vw/flash": "闪光灯",
                 "vw/focal": "焦距",
                 "vw/formatHint": "下载后即可用其他程序打开。",
@@ -3213,6 +3233,11 @@
                 "vw/fit": "画面に合わせる",
                 "vw/fitPage": "ページに合わせる",
                 "vw/fitWidth": "幅に合わせる",
+                "vw/layout": "ページレイアウト",
+                "vw/layoutAuto": "自動",
+                "vw/layoutSingle": "単一ページ",
+                "vw/layoutTwo": "見開き",
+                "vw/layoutCover": "見開き(表紙は単独)",
                 "vw/flash": "フラッシュ",
                 "vw/focal": "焦点距離",
                 "vw/formatHint": "ダウンロードして別のプログラムで開いてください。",
@@ -3879,6 +3904,11 @@
                 "vw/fit": "화면에 맞추기",
                 "vw/fitPage": "페이지에 맞추기",
                 "vw/fitWidth": "너비에 맞추기",
+                "vw/layout": "페이지 레이아웃",
+                "vw/layoutAuto": "자동",
+                "vw/layoutSingle": "한 페이지",
+                "vw/layoutTwo": "두 페이지",
+                "vw/layoutCover": "두 페이지, 표지 단독",
                 "vw/flash": "플래시",
                 "vw/focal": "초점 거리",
                 "vw/formatHint": "다운로드한 뒤 다른 프로그램으로 여세요.",

+ 139 - 101
src/web/Musicify/embedded.html

@@ -12,26 +12,29 @@
 	<script src="../script/ao_module.js"></script>
 	<style>
 		*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+		/* Colours follow the Drive music player: a dark blue-grey stage under
+		   the blurred cover art, grey stroked icons, a white play disc and a
+		   blue accent for the progress and the active toggles. */
 		:root {
-			--bg:      #111114;
-			--bg2:     #18181e;
-			--bg3:     #1e1e27;
-			--border:  #2a2a38;
-			--accent:  #a855f7;
-			--accent2: #7c3aed;
-			--text:    #f0f0f5;
-			--text2:   #9090a8;
-			--text3:   #8c8c99;
-			--hover:   rgba(255,255,255,.055);
+			--stage:   #171c26;
+			--panel:   #121722;
+			--line:    rgba(255,255,255,.08);
+			--accent:  #5b93ff;
+			--text:    #e8ecf3;
+			--text2:   #9aa4b5;
+			--text3:   #6d7789;
+			--hover:   rgba(255,255,255,.12);
 		}
 		html, body {
 			height: 100%; overflow: hidden;
-			/* background: var(--bg); color: var(--text); */
 			font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
 			font-size: 13px;
 			background: transparent !important;
+			color: var(--text);
 		}
 		body { display: flex; flex-direction: column; }
+		button { font: inherit; }
+		.ic { width: 18px; height: 18px; display: block; flex: none; }
 
 		/* ── Player card ─────────────────────────────────────────────── */
 		#playerCard {
@@ -39,62 +42,75 @@
 			flex-shrink: 0;
 			overflow: hidden;
 			height: 224px;
+			background: var(--stage);
 		}
 		.bg-blur {
-			position: absolute; inset: 0;
+			position: absolute; inset: -40px;
 			background-size: cover; background-position: center;
-			filter: blur(4px) brightness(0.22);
-			transform: scale(1.5);
+			filter: blur(36px) saturate(1.4);
+			transform: scale(1.1);
 			transition: background-image 0.6s ease;
 			z-index: 0;
-			opacity: 0.85;
+			opacity: .38;
+			pointer-events: none;
 		}
 		.player-inner {
 			position: relative; z-index: 1;
 			padding: 13px 14px 10px;
-			display: flex; flex-direction: column; gap: 8px;
+			display: flex; flex-direction: column; gap: 5px;
 		}
 
 		/* Cover + meta */
 		.cover-meta { display: flex; align-items: center; gap: 12px; }
 		#coverArt {
-			width: 60px; height: 60px; border-radius: 8px;
+			width: 60px; height: 60px; border-radius: 10px;
 			object-fit: cover; flex-shrink: 0;
-			background: var(--bg3);
-			box-shadow: 0 4px 20px rgba(0,0,0,.6);
+			background: #222936;
+			box-shadow: 0 8px 24px rgba(0,0,0,.55);
 		}
 		.meta-col { flex: 1; min-width: 0; }
 		#songTitle {
-			font-size: 13px; font-weight: 700; color: var(--text);
+			font-size: 13.5px; font-weight: 700; color: var(--text); letter-spacing: -.1px;
 			white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
 		}
 		#artistLabel {
-			font-size: 11px; color: var(--text2);
+			font-size: 11.5px; color: var(--text2);
 			white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
 			margin-top: 2px;
 		}
 		#fileSize { font-size: 10px; color: var(--text3); margin-top: 2px; }
 
-		/* Seek */
-		.seek-row { display: flex; align-items: center; gap: 6px; }
-		.t { font-size: 10px; color: var(--text3); white-space: nowrap; min-width: 26px; }
+		/* Seek: a 4 px track that thickens on hover, knob only while pointing */
+		.seek-row { display: flex; align-items: center; gap: 8px; }
+		.t { font-size: 10.5px; color: var(--text2); white-space: nowrap; min-width: 26px; font-variant-numeric: tabular-nums; }
 		.t.right { text-align: right; }
 		#seekBar {
+			--track: 4px;
 			flex: 1; -webkit-appearance: none; appearance: none;
-			height: 23px; outline: none; cursor: pointer;
-			background: linear-gradient(to right,
-				var(--accent) 0%, var(--accent) var(--progress, 0%),
-				var(--border) var(--progress, 0%), var(--border) 100%)
-				center / 100% 3px no-repeat;
+			height: 23px; outline: none; cursor: pointer; background: transparent;
 		}
+		#seekBar:hover, #seekBar:active { --track: 6px; }
 		#seekBar::-webkit-slider-runnable-track {
-			height: 3px; background: transparent;
+			height: var(--track); border-radius: 3px;
+			background: linear-gradient(to right,
+				var(--accent) 0%, var(--accent) var(--progress, 0%),
+				rgba(255,255,255,.22) var(--progress, 0%), rgba(255,255,255,.22) 100%);
 		}
 		#seekBar::-webkit-slider-thumb {
-			-webkit-appearance: none; width: 12px; height: 12px;
-			border-radius: 50%; background: var(--accent); cursor: pointer;
-			margin-top: -4.5px;
+			-webkit-appearance: none; width: 12px; height: 12px; border: 0;
+			border-radius: 50%; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4);
+			margin-top: calc((var(--track) - 12px) / 2);
+			transform: scale(0); transition: transform .12s;
+		}
+		#seekBar:hover::-webkit-slider-thumb, #seekBar:active::-webkit-slider-thumb { transform: scale(1); }
+		#seekBar::-moz-range-track { height: var(--track); border-radius: 3px; background: rgba(255,255,255,.22); }
+		#seekBar::-moz-range-progress { height: var(--track); border-radius: 3px; background: var(--accent); }
+		#seekBar::-moz-range-thumb {
+			width: 12px; height: 12px; border: 0; border-radius: 50%;
+			background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4);
+			transform: scale(0); transition: transform .12s;
 		}
+		#seekBar:hover::-moz-range-thumb, #seekBar:active::-moz-range-thumb { transform: scale(1); }
 
 		/* Controls */
 		.controls {
@@ -102,85 +118,96 @@
 		}
 		.ctrl-btn {
 			background: none; border: none; cursor: pointer;
-			color: var(--text2); font-size: 15px;
+			color: var(--text2);
 			width: 34px; height: 34px; border-radius: 50%;
 			display: flex; align-items: center; justify-content: center;
-			position: relative;
-			transition: color .12s, background .12s;
+			position: relative; flex: none;
+			transition: color .12s, background .12s, transform .12s;
 		}
-		.ctrl-btn:hover { color: var(--text); background: var(--hover); }
+		.ctrl-btn:hover { color: #fff; background: var(--hover); }
 		.ctrl-btn.active { color: var(--accent); }
 		.ctrl-btn.play-btn {
-			background: var(--accent); color: #fff;
-			width: 40px; height: 40px; font-size: 16px;
-		}
-		.ctrl-btn.play-btn:hover { background: var(--accent2); }
-		.repeat1-badge {
-			position: absolute; font-size: 8px; font-weight: 900;
-			bottom: 2px; right: 2px; color: var(--accent); line-height: 1;
+			background: #fff; color: #111620;
+			width: 40px; height: 40px;
+			box-shadow: 0 6px 18px rgba(0,0,0,.35);
 		}
+		.ctrl-btn.play-btn:hover { background: #fff; color: #111620; transform: scale(1.04); }
+		#playIcon { display: flex; align-items: center; justify-content: center; }
+		.ctrl-btn.play-btn .ic { width: 20px; height: 20px; }
+		.ctrl-btn.play-btn i.icon { margin: 0; font-size: 15px; line-height: 1; height: auto; }
 
 		/* Volume */
 		.vol-row { display: flex; align-items: center; gap: 6px; }
 		.vol-icon {
-			color: var(--text3); font-size: 12px; cursor: pointer;
-			flex-shrink: 0; display: flex; align-items: center;
+			color: var(--text2); cursor: pointer;
+			width: 22px; height: 22px; border-radius: 6px;
+			flex-shrink: 0; display: flex; align-items: center; justify-content: center;
+			transition: color .12s;
 		}
+		.vol-icon:hover { color: #fff; }
+		.vol-icon .ic { width: 16px; height: 16px; }
 		#volBar {
 			flex: 1; -webkit-appearance: none; appearance: none;
-			height: 23px; outline: none; cursor: pointer;
-			background: linear-gradient(to right,
-				var(--accent) 0%, var(--accent) var(--vol, 60%),
-				var(--border) var(--vol, 60%), var(--border) 100%)
-				center / 100% 3px no-repeat;
+			height: 23px; outline: none; cursor: pointer; background: transparent;
 		}
 		#volBar::-webkit-slider-runnable-track {
-			height: 3px; background: transparent;
+			height: 4px; border-radius: 2px;
+			background: linear-gradient(to right,
+				#fff 0%, #fff var(--vol, 60%),
+				rgba(255,255,255,.25) var(--vol, 60%), rgba(255,255,255,.25) 100%);
 		}
 		#volBar::-webkit-slider-thumb {
-			-webkit-appearance: none; width: 10px; height: 10px;
-			border-radius: 50%; background: var(--accent); cursor: pointer;
-			margin-top: -3.5px;
+			-webkit-appearance: none; width: 12px; height: 12px; border: 0;
+			border-radius: 50%; background: #fff; margin-top: -4px;
 		}
+		#volBar::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255,255,255,.25); }
+		#volBar::-moz-range-progress { height: 4px; border-radius: 2px; background: #fff; }
+		#volBar::-moz-range-thumb { width: 12px; height: 12px; border: 0; border-radius: 50%; background: #fff; }
 
 		/* Action buttons */
 		.action-row { display: flex; gap: 6px; margin-top: 0;}
 		.act-btn {
-			flex: 1; padding: 6px 8px;
-			background: var(--bg3); border: 1px solid var(--border);
-			border-radius: 7px; color: var(--text2);
-			font-size: 11px; cursor: pointer;
-			display: flex; align-items: center; justify-content: center; gap: 5px;
+			flex: 1; padding: 5px 8px;
+			background: rgba(255,255,255,.05); border: 1px solid var(--line);
+			border-radius: 9px; color: var(--text);
+			font-size: 11.5px; font-weight: 600; cursor: pointer;
+			display: flex; align-items: center; justify-content: center; gap: 6px;
 			transition: background .12s, color .12s, border-color .12s;
 		}
-		.act-btn:hover { background: var(--bg2); color: var(--accent); border-color: var(--accent); }
-		.act-btn i { margin: 0 !important; }
+		.act-btn:hover { background: var(--hover); border-color: rgba(255,255,255,.16); color: #fff; }
+		.act-btn .ic { width: 14px; height: 14px; color: var(--text2); }
 
 		/* Song list */
 		#songList {
 			flex: 1; overflow-y: auto; overflow-x: hidden;
-			background: var(--bg2);
-			border-top: 1px solid var(--border);
+			background: var(--panel);
+			border-top: 1px solid var(--line);
+			padding: 4px 6px 8px;
+			scrollbar-width: thin;
+			scrollbar-color: rgba(255,255,255,.16) transparent;
+		}
+		#songList::-webkit-scrollbar { width: 8px; }
+		#songList::-webkit-scrollbar-thumb {
+			background: rgba(255,255,255,.16); border-radius: 8px;
+			border: 2px solid transparent; background-clip: padding-box;
 		}
-		#songList::-webkit-scrollbar { width: 4px; }
-		#songList::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
 		.list-item {
 			display: flex; align-items: center; gap: 10px;
-			padding: 8px 14px; cursor: pointer;
-			border-bottom: 1px solid var(--border);
-			color: var(--text2); font-size: 12px;
+			padding: 6px 8px; cursor: pointer;
+			border-radius: 9px;
+			color: var(--text); font-size: 12px;
 			transition: background .1s;
 		}
-		.list-item:hover { background: var(--hover); color: var(--text); }
-		.list-item.selected { background: rgba(168,85,247,.12); color: var(--accent); }
+		.list-item:hover { background: rgba(255,255,255,.08); }
+		.list-item.selected { background: rgba(59,124,246,.16); color: var(--accent); }
 		.list-item img {
-			width: 30px; height: 30px; border-radius: 4px;
-			object-fit: cover; flex-shrink: 0; background: var(--bg3);
+			width: 30px; height: 30px; border-radius: 6px;
+			object-fit: cover; flex-shrink: 0; background: #222936;
 		}
 		.list-item .li-name {
 			flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
 		}
-		.list-item .li-size { font-size: 10px; color: var(--text3); flex-shrink: 0; }
+		.list-item .li-size { font-size: 10.5px; color: var(--text3); flex-shrink: 0; }
 	</style>
 </head>
 <body>
@@ -209,38 +236,33 @@
 			<!-- Playback controls -->
 			<div class="controls">
 				<button class="ctrl-btn" id="shuffleBtn" title="Shuffle" onclick="toggleShuffle()">
-					<i class="random icon" style="margin:0;"></i>
+					<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7h3.5c2 0 3.2 1 4.3 2.7l2.4 4.6c1.1 1.7 2.3 2.7 4.3 2.7H21"/><path d="M3 17h3.5c1.4 0 2.4-.5 3.2-1.4M14.3 8.4c.8-.9 1.8-1.4 3.2-1.4H21"/><path d="M18.5 4.5 21 7l-2.5 2.5M18.5 14.5 21 17l-2.5 2.5"/></svg>
 				</button>
 				<button class="ctrl-btn" title="Previous" onclick="prevSong()">
-					<i class="step backward icon" style="margin:0;"></i>
+					<svg class="ic" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM19 5.5v13l-9.5-6.5z"/></svg>
 				</button>
 				<button class="ctrl-btn play-btn" id="playBtn" title="Play / Pause" onclick="togglePlay()">
-					<i class="pause icon" style="margin:0;" id="playIcon"></i>
+					<span id="playIcon"></span>
 				</button>
 				<button class="ctrl-btn" title="Next" onclick="nextSong()">
-					<i class="step forward icon" style="margin:0;"></i>
-				</button>
-				<button class="ctrl-btn" id="repeatBtn" title="Repeat: off" onclick="cycleRepeat()">
-					<i class="redo alternate icon" style="margin:0;" id="repeatIcon"></i>
-					<span class="repeat1-badge" id="repeat1Badge" style="display:none;">1</span>
+					<svg class="ic" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM5 5.5v13l9.5-6.5z"/></svg>
 				</button>
+				<button class="ctrl-btn" id="repeatBtn" title="Repeat: off" onclick="cycleRepeat()"></button>
 			</div>
 
 			<!-- Volume -->
 			<div class="vol-row">
-				<span class="vol-icon" onclick="toggleMute()">
-					<i class="volume up icon" style="margin:0;" id="volIconEl"></i>
-				</span>
-				<input type="range" id="volBar" min="0" max="100" value="60">
+				<span class="vol-icon" id="volIconEl" onclick="toggleMute()" title="Mute"></span>
+				<input type="range" id="volBar" min="0" max="100" value="60" aria-label="Volume">
 			</div>
 
 			<!-- Action row -->
 			<div class="action-row">
 				<button class="act-btn" onclick="openInMusicify()" title="Open folder in Musicify">
-					<i class="music icon"></i><span>Open in Musicify</span>
+					<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V6l11-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="17.5" cy="16" r="2.5"/></svg><span>Open in Musicify</span>
 				</button>
 				<button class="act-btn" id="listToggleBtn" onclick="toggleList()">
-					<i class="list icon"></i><span id="listBtnLabel">Show List</span>
+					<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r="1.3" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.3" fill="currentColor" stroke="none"/></svg><span id="listBtnLabel">Show List</span>
 				</button>
 			</div>
 		</div>
@@ -267,6 +289,28 @@
 		var isMuted     = false;
 		var savedVol    = 0;
 
+		// ── Icons (the stroked 24px set of the Drive music player) ────────
+		function strokeIcon(body) {
+			return '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" ' +
+				'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + body + '</svg>';
+		}
+		var ICON_PLAY  = strokeIcon('<path d="M8 5.5v13l10-6.5z"/>');
+		var ICON_PAUSE = strokeIcon('<path d="M9 6v12M15 6v12"/>');
+
+		function repeatIconSVG(mode) {
+			return strokeIcon('<path d="M4 11V9a3 3 0 0 1 3-3h12"/><path d="M16 3l3 3-3 3"/>' +
+				'<path d="M20 13v2a3 3 0 0 1-3 3H5"/><path d="M8 21l-3-3 3-3"/>' +
+				(mode === 'one' ? '<text x="12" y="14.6" text-anchor="middle" font-size="7.5" font-weight="700" ' +
+					'fill="currentColor" stroke="none" font-family="Segoe UI,Arial,sans-serif">1</text>' : ''));
+		}
+
+		function volumeIconSVG(fraction) {
+			var waves = fraction === 0 ? '<path d="M16 9.5l5 5M21 9.5l-5 5"/>' :
+				fraction < 0.5 ? '<path d="M15.5 9.2a4 4 0 0 1 0 5.6"/>' :
+				'<path d="M15.5 9.2a4 4 0 0 1 0 5.6M18.3 6.6a7.6 7.6 0 0 1 0 10.8"/>';
+			return strokeIcon('<path d="M4 9.5h3.2L12 5.5v13l-4.8-4H4z" fill="currentColor"/>' + waves);
+		}
+
 		// ── Safari fallback: full-buffer 48kHz transcode ─────────────────────
 		// Safari's <audio> element can silently get stuck on some formats it
 		// claims to support (FLAC especially: some files decode fine, others
@@ -339,10 +383,8 @@
 		// track — without this the player looks stuck in the exact way this
 		// fallback exists to avoid.
 		function setBuffering(isBuffering) {
-			var icon = document.getElementById('playIcon');
 			if (isBuffering) {
-				icon.className = 'notched circle loading icon';
-				icon.style.margin = '0';
+				document.getElementById('playIcon').innerHTML = '<i class="notched circle loading icon"></i>';
 			} else {
 				syncPlayIcon();
 			}
@@ -357,6 +399,8 @@
 		var initVol   = storedVol !== null ? parseFloat(storedVol) : 0.6;
 		player.volume = Math.max(0, Math.min(1, isNaN(initVol) ? 0.6 : initVol));
 		setVolumeUI(player.volume);
+		syncPlayIcon();
+		updateRepeatUI();
 
 		// Restore prefs from server (cross-device sync)
 		ao_module_storage.loadStorage("Musicify", "emb_repeat", function(val) {
@@ -477,7 +521,7 @@
 				_coverProbe = null;
 				coverEl.src = 'img/placeholder.png';
 				bgBlur.style.backgroundImage = 'none';
-				bgBlur.style.backgroundColor = '#323232';
+				bgBlur.style.backgroundColor = '';
 			};
 			probe.src = url;
 			ao_module_setWindowTitle(song.name);
@@ -583,9 +627,7 @@
 		});
 
 		function syncPlayIcon() {
-			var i = document.getElementById('playIcon');
-			i.className = (player.paused ? 'play' : 'pause') + ' icon';
-			i.style.margin = '0';
+			document.getElementById('playIcon').innerHTML = player.paused ? ICON_PLAY : ICON_PAUSE;
 		}
 
 		// ── Seek ──────────────────────────────────────────────────────────
@@ -606,10 +648,7 @@
 			var pct = Math.round(fraction * 100);
 			document.getElementById('volBar').value = pct;
 			document.getElementById('volBar').style.setProperty('--vol', pct + '%');
-			var icon = document.getElementById('volIconEl');
-			icon.className = (fraction === 0 ? 'volume off' :
-			                  fraction < 0.4  ? 'volume down' : 'volume up') + ' icon';
-			icon.style.margin = '0';
+			document.getElementById('volIconEl').innerHTML = volumeIconSVG(fraction);
 		}
 
 		function toggleMute() {
@@ -663,9 +702,8 @@
 
 		function updateRepeatUI() {
 			var btn   = document.getElementById('repeatBtn');
-			var badge = document.getElementById('repeat1Badge');
 			btn.classList.toggle('active', repeatMode !== 'none');
-			badge.style.display = repeatMode === 'one' ? 'block' : 'none';
+			btn.innerHTML = repeatIconSVG(repeatMode);
 			btn.title = repeatMode === 'none' ? 'Repeat: off' :
 			            repeatMode === 'all'  ? 'Repeat: all' : 'Repeat: one';
 		}

+ 203 - 60
src/web/Musicify/index.html

@@ -569,9 +569,6 @@
             .now-playing-overlay {
                 height: calc(100vh - var(--player-h) - 52px) !important;  /* compensate for mobile bar */
             }
-            .np-content { padding: 12px 16px 18px; gap: 10px; }
-            .np-art-wrap { width: min(68vw, 300px, 38vh); }
-            .np-title { font-size: 18px; }
         }
         .mobile-player-controls { display: none; }
         .mobile-seek-row { display: none; width: 100%; align-items: center; gap: 6px; padding: 2px 0 8px; }
@@ -686,26 +683,46 @@
         .main-content { position: relative; }
 
         /* ── Now Playing full-screen overlay ─────────────────────────────── */
+        /* The centre of the overlay follows the Drive music player: a dark
+           stage under the blurred cover art, thin stroked icons, a white play
+           disc, a 4 px seek bar and a spectrum under the title. */
         .now-playing-overlay {
+            --np-stage:    #171c26;
+            --np-text:     #e8ecf3;
+            --np-text2:    #9aa4b5;
+            --np-text3:    #6d7789;
+            --np-line:     rgba(255,255,255,.08);
+            --np-hover:    rgba(255,255,255,.12);
+            --np-accent:   #5b93ff;
             position: absolute; inset: 0;
-            z-index: 85; overflow: hidden;
-            background: var(--bg);
+            /* clip, not just hidden: the blurred backdrop reaches past the
+               edges and a hidden box can still be scrolled by focus changes */
+            z-index: 85; overflow: hidden; overflow: clip;
+            background: var(--np-stage);
+            color: var(--np-text);
             height: calc(100vh - var(--player-h));
         }
         .now-playing-overlay .np-bg {
-            position: absolute; inset: 0; z-index: 0;
+            position: absolute; inset: -60px; z-index: 0;
             background-size: cover; background-position: center;
-            filter: blur(48px) brightness(.45) saturate(1.3);
-            transform: scale(1.35); opacity: .6; pointer-events: none;
+            filter: blur(70px) saturate(1.4);
+            transform: scale(1.1); opacity: .38; pointer-events: none;
         }
-        .now-playing-overlay::before {
-            content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
-            background: linear-gradient(180deg, rgba(17,17,20,.45) 0%, rgba(17,17,20,.82) 100%);
+        /* No cover art: the blue tones move to the matching light purples and
+           the backdrop becomes a soft purple glow instead of the blurred art */
+        .now-playing-overlay.np-noart {
+            --np-stage:  #1d1728;
+            --np-accent: #b18cff;
+        }
+        .now-playing-overlay.np-noart .np-bg {
+            background-image:
+                radial-gradient(55% 45% at 28% 30%, #a855f7 0%, transparent 70%),
+                radial-gradient(50% 45% at 76% 74%, #6d28d9 0%, transparent 70%);
         }
         .np-content {
             position: relative; z-index: 2;
             height: calc(100% ); display: flex; flex-direction: column; align-items: center;
-            padding: 14px 22px 42px; gap: 14px; overflow-y: auto;
+            padding: 14px 22px 42px; gap: 22px; overflow-y: auto;
         }
         .np-slide-left  { animation: npSlideL .28s ease; }
         .np-slide-right { animation: npSlideR .28s ease; }
@@ -715,12 +732,16 @@
             width: 100%; display: flex; align-items: center; justify-content: space-between;
             flex-shrink: 0;
         }
-        .np-header-title { font-size: 13px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--text2); }
+        .np-header-title { font-size: 13px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--np-text2); }
+        .np-header .ctrl-btn { color: var(--np-text2); }
+        .np-header .ctrl-btn:hover { color: #fff; background: var(--np-hover); }
+        .np-header .ctrl-btn.active,
+        .np-header .ctrl-btn.active:hover { color: var(--np-accent); }
         .np-art-wrap {
             position: relative; margin-top: auto;
-            width: min(74vw, 360px, 40vh); max-width: 100%; aspect-ratio: 1 / 1;
-            border-radius: 14px; overflow: hidden;
-            background: var(--bg3); box-shadow: 0 14px 50px rgba(0,0,0,.55);
+            max-width: 100%; aspect-ratio: 1 / 1;
+            border-radius: 20px; overflow: hidden;
+            background: #222936; box-shadow: 0 24px 60px rgba(0,0,0,.55);
             flex-shrink: 0;
         }
         .np-art { width: 100%; height: 100%; object-fit: cover; display: block; }
@@ -730,25 +751,119 @@
             background: rgba(0,0,0,.5); backdrop-filter: blur(2px); color: #fff;
         }
         .np-art-loading i.icon { margin: 0; font-size: 34px; }
+        .np-meta { width: min(560px, 92%); text-align: center; flex-shrink: 0; }
         .np-title {
-            width: 100%; text-align: center; font-size: 20px; font-weight: 700; color: var(--text);
-            line-height: 1.3; max-height: 2.6em; overflow: hidden; flex-shrink: 0;
-        }
-        .np-artist { width: 100%; text-align: center; font-size: 14px; color: var(--text2); margin-top: -8px; flex-shrink: 0; }
-        .np-seek { width: 100%; max-width: 460px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
-        .np-seek .seek-bar-wrap { flex: 1; }
+            font-size: 24px; font-weight: 700; letter-spacing: -.3px; line-height: 1.3; color: var(--np-text);
+            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+        }
+        /* Marquee for a title wider than the column (.scrolling is set from JS) */
+        .np-mq-copy { display: none; }
+        .np-title.scrolling {
+            text-overflow: clip; text-align: left;
+            -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%);
+            mask-image: linear-gradient(90deg, transparent 0, #000 16px, #000 calc(100% - 16px), transparent 100%);
+        }
+        .np-title.scrolling .np-mq {
+            display: inline-block; padding-left: 16px;
+            animation: npMarquee var(--np-mq-dur, 12s) linear infinite;
+        }
+        .np-title.scrolling .np-mq-text { padding-right: 56px; }
+        .np-title.scrolling .np-mq-copy { display: inline; }
+        /* Rest at the start of every pass, then slide one copy's width (text +
+           gap, measured in JS) left, where the second copy sits exactly where
+           the first one started */
+        @keyframes npMarquee { 0%, 15% { transform: translateX(0); } 100% { transform: translateX(var(--np-mq-shift, -50%)); } }
+        .np-artist {
+            margin-top: 6px; font-size: 14px; color: var(--np-text2);
+            white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+        }
+        .np-vis { width: min(560px, 92%); height: 64px; display: block; flex-shrink: 0; }
+        .np-seek { width: min(560px, 92%); flex-shrink: 0; }
+        .np-times {
+            display: flex; justify-content: space-between; margin-top: 2px;
+            font-size: 12px; color: var(--np-text2); font-variant-numeric: tabular-nums;
+        }
+        /* Seek bar: a 4 px track that thickens on hover, knob only while pointing */
+        .np-range {
+            --np-track: 4px;
+            -webkit-appearance: none; appearance: none;
+            display: block; width: 100%; height: 20px; margin: 0;
+            background: transparent; outline: none; cursor: pointer; touch-action: none;
+        }
+        .np-range:hover, .np-range:active { --np-track: 6px; }
+        .np-range::-webkit-slider-runnable-track {
+            height: var(--np-track); border-radius: 3px;
+            background: linear-gradient(to right, var(--np-accent) 0%, var(--np-accent) var(--progress, 0%), rgba(255,255,255,.22) var(--progress, 0%), rgba(255,255,255,.22) 100%);
+        }
+        .np-range::-webkit-slider-thumb {
+            -webkit-appearance: none; width: 14px; height: 14px; border: 0; border-radius: 50%;
+            margin-top: calc((var(--np-track) - 14px) / 2);
+            background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4);
+            transform: scale(0); transition: transform .12s;
+        }
+        .np-range:hover::-webkit-slider-thumb, .np-range:active::-webkit-slider-thumb { transform: scale(1); }
+        .np-range::-moz-range-track { height: var(--np-track); border-radius: 3px; background: rgba(255,255,255,.22); }
+        .np-range::-moz-range-progress { height: var(--np-track); border-radius: 3px; background: var(--np-accent); }
+        .np-range::-moz-range-thumb {
+            width: 14px; height: 14px; border: 0; border-radius: 50%;
+            background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.4);
+            transform: scale(0); transition: transform .12s;
+        }
+        .np-range:hover::-moz-range-thumb, .np-range:active::-moz-range-thumb { transform: scale(1); }
         .np-controls {
-            width: 100%; max-width: 460px; display: flex; align-items: center; justify-content: center;
-            gap: 18px; margin-bottom: auto; flex-shrink: 0;
-        }
-        .np-controls .ctrl-btn { width: 46px; height: 46px; }
-        .np-controls .ctrl-btn i.icon { font-size: 18px; }
-        .np-controls .np-play { width: 60px; height: 60px; }
-        .np-controls .np-play i.icon { font-size: 24px; }
-        .np-hint { font-size: 11px; color: var(--text3); text-align: center; flex-shrink: 0; }
+            display: flex; align-items: center; justify-content: center;
+            gap: 14px; margin-bottom: auto; flex-shrink: 0;
+        }
+        .np-btn {
+            width: 44px; height: 44px; border-radius: 22px; border: 0; padding: 0;
+            background: none; color: var(--np-text2); cursor: pointer;
+            display: inline-flex; align-items: center; justify-content: center;
+            flex: none; position: relative;
+            transition: color .12s, background .12s, transform .12s;
+        }
+        .np-btn:hover { color: #fff; background: var(--np-hover); }
+        .np-btn.on { color: var(--np-accent); }
+        .np-btn svg { width: 22px; height: 22px; display: block; }
+        .np-btn.np-play {
+            width: 66px; height: 66px; border-radius: 33px;
+            background: #fff; color: #111620; box-shadow: 0 10px 30px rgba(0,0,0,.35);
+        }
+        .np-btn.np-play:hover { background: #fff; color: #111620; transform: scale(1.04); }
+        .np-btn.np-play svg { width: 28px; height: 28px; }
+        .np-hint { font-size: 11px; color: var(--np-text3); text-align: center; flex-shrink: 0; }
         /* Docked song-info section at the bottom of the overlay */
         .np-info { width: 100%; max-width: 460px; flex-shrink: 0; padding-bottom: 8px; }
-        .np-info .track-info-divider { margin: 6px 0 16px; }
+        .np-info .track-info-divider { margin: 6px 0 16px; background: var(--np-line); }
+        .np-info .track-info-meta { background: rgba(255,255,255,.05); border-color: var(--np-line); }
+        .np-info .track-meta-row { border-bottom-color: var(--np-line); }
+        .np-info .track-meta-label { color: var(--np-text3); }
+        .np-info .track-meta-value { color: var(--np-text); }
+        .np-info .track-meta-path { color: var(--np-text2); }
+        .np-info .track-action-btn { background: rgba(255,255,255,.05); border-color: var(--np-line); color: var(--np-text); }
+        .np-info .track-action-btn:hover { background: var(--np-hover); border-color: rgba(255,255,255,.16); color: #fff; }
+        .np-info .track-action-btn i,
+        .np-info .track-action-btn:hover i { color: var(--np-accent); }
+        /* The cover shrinks with the window height so the header, spectrum and
+           transport controls stay on one page. These follow the base rules
+           above so they win the cascade. */
+        .np-art-wrap { width: min(300px, 38vh, max(160px, calc(100vh - 560px))); }
+        @media (max-height: 760px) {
+            .np-content { gap: 12px; }
+            .np-art-wrap { width: min(300px, max(150px, calc(100vh - 476px))); }
+            .np-vis { height: 40px; }
+        }
+        @media (max-width: 768px) {
+            .np-content { padding: 12px 16px 18px; gap: 16px; }
+            .np-art-wrap { width: min(72vw, 300px, max(150px, calc(100vh - 528px))); }
+            .np-title { font-size: 20px; }
+            .np-vis { height: 48px; }
+            .np-controls { gap: 8px; }
+        }
+        @media (max-width: 768px) and (max-height: 760px) {
+            .np-content { gap: 12px; }
+            .np-art-wrap { width: min(72vw, 300px, max(140px, calc(100vh - 496px))); }
+            .np-vis { height: 40px; }
+        }
         /* When Now Playing is open, hide the duplicated transport/seek controls elsewhere */
         .np-open .player-controls,
         .np-open .mobile-seek-row,
@@ -758,6 +873,7 @@
            the overlay's now-redundant (and now-hidden-anyway) copies. */
         #app.mobile-landscape.np-open .player-controls { display: flex !important; }
         #app.mobile-landscape .np-seek,
+        #app.mobile-landscape .np-vis,
         #app.mobile-landscape .np-controls,
         #app.mobile-landscape .np-hint { display: none; }
         /* Give the now-visible controls room on narrower landscape phones by
@@ -1434,12 +1550,13 @@
             </div>
 
             <!-- ── NOW PLAYING OVERLAY ──────────────────────────────────────── -->
-            <div x-show="showNowPlaying" class="now-playing-overlay"
+            <div x-show="showNowPlaying" class="now-playing-overlay" :class="{'np-noart': npNoArt}"
                  x-on:touchstart="npTouchStart($event)"
                  x-on:touchend="npTouchEnd($event)">
                 <!-- Blurred album art backdrop -->
+                <!-- Without cover art it falls back to a purple glow (.np-noart) -->
                 <div class="np-bg"
-                     :style="currentTrack ? ('background-image:url(\'' + getCoverUrl(currentTrack) + '\')') : ''"></div>
+                     :style="currentTrack && !npNoArt ? ('background-image:url(\'' + getCoverUrl(currentTrack) + '\')') : ''"></div>
 
                 <div class="np-content" :class="{'np-slide-left': _npSwipeDir==='left', 'np-slide-right': _npSwipeDir==='right'}">
                     <!-- Header -->
@@ -1457,7 +1574,8 @@
                     <div class="np-art-wrap">
                         <img class="np-art" x-show="!privacyVisualizerMode"
                              :src="currentTrack ? getCoverUrl(currentTrack) : 'img/placeholder.png'"
-                             x-on:error="$event.target.src='img/placeholder.png'; $event.target.onerror=null"
+                             x-on:error="npNoArt = true; $event.target.src='img/placeholder.png'; $event.target.onerror=null"
+                             x-on:load="npNoArt = $event.target.src.indexOf('img/placeholder.png') !== -1"
                              alt="Cover">
                         <canvas id="np-viz-canvas" class="np-art-viz" x-show="privacyVisualizerMode" title="Album art hidden — Privacy Mode"></canvas>
                         <div class="np-art-loading" x-show="_fullBufferLoading">
@@ -1466,44 +1584,51 @@
                     </div>
 
                     <!-- Title + artist -->
-                    <div class="np-title"  x-text="currentTrack ? currentTrack.name : ''"></div>
-                    <div class="np-artist" x-text="currentTrack ? getArtistLabel(currentTrack) : ''"></div>
+                    <div class="np-meta">
+                        <!-- A title too long for one line scrolls (two copies loop seamlessly) -->
+                        <div class="np-title" :title="currentTrack ? currentTrack.name : ''"><span class="np-mq"><span class="np-mq-text" x-text="currentTrack ? currentTrack.name : ''"></span><span class="np-mq-text np-mq-copy" aria-hidden="true" x-text="currentTrack ? currentTrack.name : ''"></span></span></div>
+                        <div class="np-artist" x-text="currentTrack ? getArtistLabel(currentTrack) : ''"></div>
+                    </div>
+
+                    <!-- Spectrum (needs Web Audio, so never on iOS / iPadOS) -->
+                    <canvas id="np-spectrum" class="np-vis" x-show="spectrumSupported"></canvas>
 
                     <!-- Seek bar -->
                     <div class="np-seek">
-                        <span class="seek-time" x-text="formatTime(currentTime)"></span>
-                        <div class="seek-bar-wrap">
-                            <input type="range" class="seek-bar" min="0" :max="Math.floor(duration) || 100"
-                                :value="Math.floor(currentTime)"
-                                :style="'--progress:' + progressPercent() + '%'"
-                                x-on:mousedown="beginSeek()"
-                                x-on:touchstart.stop="beginSeek()"
-                                x-on:touchmove.stop="currentTime = parseFloat($event.target.value)"
-                                x-on:touchend.stop="endSeek($event.target.value)"
-                                x-on:touchcancel.stop="endSeek($event.target.value)"
-                                x-on:change="endSeek($event.target.value)"
-                                x-on:input="currentTime = parseFloat($event.target.value)">
+                        <input type="range" class="np-range" min="0" :max="Math.floor(duration) || 100"
+                            aria-label="Seek"
+                            :value="Math.floor(currentTime)"
+                            :style="'--progress:' + progressPercent() + '%'"
+                            x-on:mousedown="beginSeek()"
+                            x-on:touchstart.stop="beginSeek()"
+                            x-on:touchmove.stop="currentTime = parseFloat($event.target.value)"
+                            x-on:touchend.stop="endSeek($event.target.value)"
+                            x-on:touchcancel.stop="endSeek($event.target.value)"
+                            x-on:change="endSeek($event.target.value)"
+                            x-on:input="currentTime = parseFloat($event.target.value)">
+                        <div class="np-times">
+                            <span x-text="formatTime(currentTime)"></span>
+                            <span x-text="(_currentTrackTranscoded && !duration) ? '--:--' : formatTime(duration)"></span>
                         </div>
-                        <span class="seek-time right" x-text="(_currentTrackTranscoded && !duration) ? '--:--' : formatTime(duration)"></span>
                     </div>
 
                     <!-- Transport controls -->
                     <div class="np-controls">
-                        <button class="ctrl-btn" :class="{active: shuffle}" x-on:click="toggleShuffle()" title="Shuffle">
-                            <i class="ui random icon" style="margin:0;"></i>
+                        <button class="np-btn" :class="{on: shuffle}" x-on:click="toggleShuffle()" title="Shuffle">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7h3.5c2 0 3.2 1 4.3 2.7l2.4 4.6c1.1 1.7 2.3 2.7 4.3 2.7H21"/><path d="M3 17h3.5c1.4 0 2.4-.5 3.2-1.4M14.3 8.4c.8-.9 1.8-1.4 3.2-1.4H21"/><path d="M18.5 4.5 21 7l-2.5 2.5M18.5 14.5 21 17l-2.5 2.5"/></svg>
                         </button>
-                        <button class="ctrl-btn" x-on:click="prevTrack()" title="Previous">
-                            <i class="ui step backward icon" style="margin:0;"></i>
+                        <button class="np-btn" x-on:click="prevTrack()" title="Previous">
+                            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM19 5.5v13l-9.5-6.5z"/></svg>
                         </button>
-                        <button class="ctrl-btn play-btn np-play" x-on:click="togglePlay()" :title="isPlaying ? 'Pause' : 'Play'">
-                            <i :class="isPlaying ? 'pause' : 'play'" class="ui icon" style="margin:0;"></i>
+                        <button class="np-btn np-play" x-on:click="togglePlay()" :title="isPlaying ? 'Pause' : 'Play'">
+                            <svg x-show="!isPlaying" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 5.5v13l10-6.5z"/></svg>
+                            <svg x-show="isPlaying" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 6v12M15 6v12"/></svg>
                         </button>
-                        <button class="ctrl-btn" x-on:click="nextTrack()" title="Next">
-                            <i class="ui step forward icon" style="margin:0;"></i>
+                        <button class="np-btn" x-on:click="nextTrack()" title="Next">
+                            <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM5 5.5v13l9.5-6.5z"/></svg>
                         </button>
-                        <button class="ctrl-btn" :class="{active: repeat !== 'none'}" x-on:click="cycleRepeat()" :title="repeatTitle()">
-                            <i :class="repeatIcon()" class="ui icon" style="margin:0;"></i>
-                            <span x-show="repeat==='one'" style="position:absolute;font-size:9px;font-weight:900;line-height:1;bottom:3px;right:3px;color:var(--accent);">1</span>
+                        <button class="np-btn" :class="{on: repeat !== 'none'}" x-on:click="cycleRepeat()" :title="repeatTitle()">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 11V9a3 3 0 0 1 3-3h12"/><path d="M16 3l3 3-3 3"/><path d="M20 13v2a3 3 0 0 1-3 3H5"/><path d="M8 21l-3-3 3-3"/><text x-show="repeat === 'one'" x="12" y="14.6" text-anchor="middle" font-size="7.5" font-weight="700" fill="currentColor" stroke="none" font-family="Segoe UI,Arial,sans-serif">1</text></svg>
                         </button>
                     </div>
 
@@ -1654,6 +1779,24 @@
                         </div>
                     </div>
 
+                    <!-- Display subsection -->
+                    <div class="settings-section">
+                        <div class="settings-section-title">Display</div>
+                        <div class="settings-section-desc">
+                            How the Now Playing view shows the current track.
+                        </div>
+                        <div class="settings-toggle-row">
+                            <div>
+                                <div class="settings-toggle-label">Scroll Long Titles</div>
+                                <div class="settings-toggle-desc" x-text="scrollLongTitles ? 'Active – a title too long for one line scrolls slowly in a loop' : 'Inactive – long titles are cut off with …'"></div>
+                            </div>
+                            <label class="toggle-switch">
+                                <input type="checkbox" x-model="scrollLongTitles" x-on:change="saveScrollLongTitles()">
+                                <span class="toggle-slider"></span>
+                            </label>
+                        </div>
+                    </div>
+
                     <!-- Mobile Volume subsection -->
                     <div class="settings-section">
                         <div class="settings-section-title">Mobile Volume</div>

+ 149 - 12
src/web/Musicify/musicify.js

@@ -80,6 +80,8 @@ function musicifyApp() {
         volume: 80,
         isMuted: false,
         forceShowVolumeControl: false, // user override: force-show the in-app volume slider on mobile devices
+        scrollLongTitles: true, // Now Playing: scroll a title too long for one line instead of cutting it off
+        npNoArt: false,         // Now Playing: the current track has no cover art (purple theme)
         shuffle: false,
         repeat: 'none',         // 'none' | 'all' | 'one'
         showQueue: false,
@@ -136,6 +138,8 @@ function musicifyApp() {
         _vizData: null,
         _vizAnimId: null,
         _syntheticViz: false,          // iOS: animate the bars without Web Audio so screen-lock doesn't stop playback
+        spectrumSupported: false,      // Now Playing spectrum bars (hidden where Web Audio can't be used)
+        _spectrumIdleKey: '',          // size of the last idle spectrum frame, so a paused one isn't repainted
 
         // ── Arozcast ─────────────────────────────────────────────────────────
         castMode: false,
@@ -239,6 +243,13 @@ function musicifyApp() {
                 this._audio.volume = 1;
             }
 
+            // Long title scrolling (on unless turned off in Settings)
+            var _savedScrollTitles = localStorage.getItem('musicify_scrollLongTitles');
+            if (_savedScrollTitles !== null) {
+                this.scrollLongTitles = (_savedScrollTitles === 'true');
+            }
+            this.$watch('currentTrack', () => this.$nextTick(() => this._updateTitleMarquee()));
+
             // Restore shuffle / repeat / recently-played from server-side prefs
             // (cross-device: stored per user, not per browser)
             ao_module_storage.loadStorage("Musicify", "shuffle", function(val) {
@@ -277,6 +288,10 @@ function musicifyApp() {
                 this._startVizLoop();
             }
 
+            // Now Playing spectrum: real Web Audio analysis only, so it is left
+            // out wherever routing playback through Web Audio is not safe (iOS).
+            this.spectrumSupported = !this._webAudioUnsafe() && !!(window.AudioContext || window.webkitAudioContext);
+
             // MediaSession
             this._setupMediaSession();
 
@@ -339,6 +354,7 @@ function musicifyApp() {
                 resizeT = setTimeout(() => {
                     this._updateLandscapeState();
                     if (window.innerWidth <= 768 || this.isMobileLandscape) this.sidebarOpen = false;
+                    this._updateTitleMarquee();
                 }, 150);
             });
             window.addEventListener('orientationchange', () => this._updateLandscapeState());
@@ -1301,6 +1317,13 @@ function musicifyApp() {
             return /Android/.test(navigator.userAgent);
         },
 
+        // iOS suspends a Web Audio graph when the screen locks, which would stop
+        // background playback once the <audio> element is routed through it.
+        // iPadOS reports a desktop Mac user agent, so it is caught by touch support.
+        _webAudioUnsafe() {
+            return this._isIOS() || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+        },
+
         _isMobile() {
             return this._isIOS() || this._isAndroid();
         },
@@ -1654,6 +1677,38 @@ function musicifyApp() {
             event.target.onerror = null;
         },
 
+        // ── Long title scrolling ─────────────────────────────────────────────
+        saveScrollLongTitles() {
+            localStorage.setItem('musicify_scrollLongTitles', String(this.scrollLongTitles));
+            this._updateTitleMarquee();
+        },
+
+        // Turns the Now Playing title into a looping marquee when it does not fit
+        // on one line. The second copy trails the first by a fixed gap, and the
+        // strip slides exactly one copy's width, so every pass ends where it began.
+        _updateTitleMarquee() {
+            var box = document.querySelector('.now-playing-overlay .np-title');
+            var text = box ? box.querySelector('.np-mq-text') : null;
+            if (!box || !text) return;
+            var range = document.createRange();
+            range.selectNodeContents(text);
+            var textW = range.getBoundingClientRect().width;
+            var scroll = this.scrollLongTitles && box.clientWidth > 0 && textW > box.clientWidth + 1;
+            if (scroll) {
+                var travel = textW + 56;    // text + the gap (.np-mq-text padding)
+                box.style.setProperty('--np-mq-shift', (-travel) + 'px');
+                // About 32 px/s while moving; the first 15% of a pass is a rest
+                box.style.setProperty('--np-mq-dur', (travel / 32 / 0.85).toFixed(1) + 's');
+            }
+            // A new title restarts the pass from the beginning
+            if (scroll && box._mqText !== text.textContent) {
+                box.classList.remove('scrolling');
+                void box.offsetWidth;
+            }
+            box._mqText = scroll ? text.textContent : '';
+            box.classList.toggle('scrolling', scroll);
+        },
+
         // ── Privacy / Audio Visualizer Mode ──────────────────────────────────
         savePrivacyVisualizerMode() {
             localStorage.setItem('musicify_privacyVisualizer', String(this.privacyVisualizerMode));
@@ -1661,10 +1716,10 @@ function musicifyApp() {
                 this._ensureAnalyser();
                 if (this._audioCtx && this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
                 this._updateVizColor(this.currentTrack);
-                this._startVizLoop();
+                this._syncVizLoop();
                 this._showToast('Privacy Mode: album art hidden');
             } else {
-                this._stopVizLoop();
+                this._syncVizLoop();
                 this._showToast('Privacy Mode: album art shown');
             }
         },
@@ -1679,7 +1734,7 @@ function musicifyApp() {
             // suspends when the screen locks — stopping background audio. Skip Web
             // Audio there and drive the bars with a synthetic animation instead, so
             // playback behaves exactly like non-privacy mode.
-            if (this._isIOS()) {
+            if (this._webAudioUnsafe()) {
                 this._syntheticViz = true;
                 return;
             }
@@ -1688,14 +1743,28 @@ function musicifyApp() {
                 this._audioCtx = new AudioCtx();
                 var source = this._audioCtx.createMediaElementSource(this._audio);
                 this._analyser = this._audioCtx.createAnalyser();
-                this._analyser.fftSize = 64;
-                this._analyser.smoothingTimeConstant = 0.8;
+                // 128 bins for the Now Playing spectrum; the privacy bars fold
+                // them back into the 32 bands they were tuned on.
+                this._analyser.fftSize = 256;
+                this._analyser.smoothingTimeConstant = 0.78;
                 source.connect(this._analyser);
                 this._analyser.connect(this._audioCtx.destination);
                 this._vizData = new Uint8Array(this._analyser.frequencyBinCount);
+                if (this._audioCtx.state === 'suspended') this._audioCtx.resume().catch(() => {});
             } catch (e) {
                 if (MUSICIFY_DEBUG) console.warn('[Musicify] visualizer unavailable', e);
                 this._analyser = null;
+                this.spectrumSupported = false;
+            }
+        },
+
+        // Runs the animation loop only while something on screen draws from it:
+        // the privacy bars, or the spectrum of an open Now Playing overlay.
+        _syncVizLoop() {
+            if (this.privacyVisualizerMode || (this.showNowPlaying && this.spectrumSupported)) {
+                this._startVizLoop();
+            } else {
+                this._stopVizLoop();
             }
         },
 
@@ -1713,17 +1782,27 @@ function musicifyApp() {
         },
 
         _renderViz() {
+            if (this._analyser && this._vizData) this._analyser.getByteFrequencyData(this._vizData);
+            if (this.privacyVisualizerMode) this._renderPrivacyViz();
+            if (this.showNowPlaying && this.spectrumSupported) {
+                this._paintSpectrum(document.getElementById('np-spectrum'), this.isPlaying && !!this._analyser);
+            }
+        },
+
+        _renderPrivacyViz() {
             var bars = 27;
             var mid = Math.floor(bars / 2);
             var heights = new Array(bars);
             if (this._analyser && this._vizData) {
-                this._analyser.getByteFrequencyData(this._vizData);
-                var binCount = this._vizData.length;
-                var step = Math.max(1, Math.floor(binCount / (mid + 2)));
+                var group = Math.max(1, Math.floor(this._vizData.length / 32));
+                var bands = Math.floor(this._vizData.length / group);
+                var step = Math.max(1, Math.floor(bands / (mid + 2)));
                 for (var i = 0; i < bars; i++) {
                     var dist = Math.abs(i - mid);
-                    var idx = Math.min(binCount - 1, dist * step);
-                    heights[i] = Math.max(0.06, this._vizData[idx] / 255);
+                    var band = Math.min(bands - 1, dist * step);
+                    var sum = 0;
+                    for (var g = 0; g < group; g++) sum += this._vizData[band * group + g];
+                    heights[i] = Math.max(0.06, sum / group / 255);
                 }
             } else if (this._syntheticViz) {
                 // iOS fallback: procedurally animated, center-weighted bars that
@@ -1767,6 +1846,55 @@ function musicifyApp() {
             }
         },
 
+        // Now Playing spectrum, drawn like the Drive music player: 48 bars rising
+        // from the bottom, a blue-to-violet gradient, quiet bars more transparent.
+        _paintSpectrum(canvas, live) {
+            if (!canvas || !canvas.offsetParent) return;
+            var dpr = window.devicePixelRatio || 1;
+            var w = canvas.clientWidth, h = canvas.clientHeight;
+            if (!w || !h) return;
+            if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
+                canvas.width = Math.round(w * dpr);
+                canvas.height = Math.round(h * dpr);
+            }
+            // While paused every bar rests at its floor: paint that frame once
+            var key = canvas.width + 'x' + canvas.height + (this.npNoArt ? 'p' : 'b');
+            if (!live && this._spectrumIdleKey === key) return;
+            this._spectrumIdleKey = live ? '' : key;
+
+            var ctx = canvas.getContext('2d');
+            ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+            ctx.clearRect(0, 0, w, h);
+            var bars = 48, gap = 3;
+            var bw = (w - gap * (bars - 1)) / bars;
+            var grad = ctx.createLinearGradient(0, h, 0, 0);
+            // Blue to violet, or light purple to pink with the no-cover theme
+            grad.addColorStop(0, this.npNoArt ? 'rgba(168,120,255,.9)' : 'rgba(91,147,255,.9)');
+            grad.addColorStop(1, this.npNoArt ? 'rgba(240,120,210,.9)' : 'rgba(190,120,255,.9)');
+            ctx.fillStyle = grad;
+            var data = this._vizData;
+            for (var i = 0; i < bars; i++) {
+                var v = 0;
+                if (live && data) {
+                    // Lower bins carry most of the energy: spread them out
+                    v = data[Math.floor(Math.pow(i / bars, 1.6) * (data.length * 0.85))] / 255;
+                }
+                var bh = Math.max(3, v * h);
+                var x = i * (bw + gap);
+                var r = Math.min(3, bw / 2);
+                ctx.globalAlpha = 0.35 + v * 0.65;
+                ctx.beginPath();
+                ctx.moveTo(x + r, h - bh);
+                ctx.arcTo(x + bw, h - bh, x + bw, h, r);
+                ctx.lineTo(x + bw, h);
+                ctx.lineTo(x, h);
+                ctx.arcTo(x, h - bh, x + bw, h - bh, r);
+                ctx.closePath();
+                ctx.fill();
+            }
+            ctx.globalAlpha = 1;
+        },
+
         _roundRectPath(ctx, x, y, w, h, r) {
             r = Math.max(0, Math.min(r, w / 2, h / 2));
             ctx.beginPath();
@@ -1903,10 +2031,12 @@ function musicifyApp() {
                 }else{
                     $("#open-in-embedded").show();
                 }
-                this.$nextTick(() => {
+                // x-show reveals the section on the next animation frame, which
+                // comes after $nextTick: measure the scroll height one frame later.
+                this.$nextTick(() => requestAnimationFrame(() => {
                     var c = document.querySelector('.now-playing-overlay .np-content');
                     if (c) c.scrollTo({ top: c.scrollHeight, behavior: 'smooth' });
-                });
+                }));
             }
         },
 
@@ -1924,11 +2054,17 @@ function musicifyApp() {
             this.showTrackInfo = false;   // start collapsed
             this.trackInfoSong = this.currentTrack;
             this.showNowPlaying = true;
+            // Opening the overlay is a user gesture, so the AudioContext the
+            // spectrum needs can start running right away.
+            if (this.spectrumSupported) this._ensureAnalyser();
+            this._syncVizLoop();
             this.$nextTick(() => {
                 var c = overlay ? overlay.querySelector('.np-content') : null;
                 if (c) c.scrollTop = 0;
                 if (overlay) overlay.style.top = (mc.scrollTop) + 'px'; // readjust after content is revealed
                 this.updateQueuePanelPosition();
+                // x-show reveals the overlay on the next frame: measure the title then
+                requestAnimationFrame(() => this._updateTitleMarquee());
             });
             
         },
@@ -1945,6 +2081,7 @@ function musicifyApp() {
             this.showNowPlaying = false;
             this.showTrackInfo = false;
             this._npSwipeDir = '';
+            this._syncVizLoop();
             var mc = document.getElementById('mainContent');
             if (mc) mc.style.overflow = '';
             this.$nextTick(() => {

+ 8 - 0
src/web/PDF Viewer/css/reader.css

@@ -677,6 +677,14 @@ button.vw-pill-label:hover {
     min-width: max-content;
 }
 
+.pdf-row {
+    display: flex;
+    align-items: flex-start;
+    justify-content: center;
+    gap: 12px;
+    flex: none;
+}
+
 .pdf-page {
     position: relative;
     background: #fff;

+ 104 - 23
src/web/PDF Viewer/js/reader.pdf.js

@@ -31,6 +31,9 @@
     var CSS_UNITS = 96 / 72;
     var ZOOMS = [0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4];
     var MAX_RENDERED = 24;
+    var SPREAD_GAP = 12;
+    //Narrower than this, two page spreads fall back to single pages
+    var SPREAD_MIN_WIDTH = 640;
 
     function ready() {
         return U.loadScript(BASE + "pdf.js?v=2.7.570").then(function () {
@@ -47,6 +50,7 @@
         }
         var st = {
             pdf: null, task: null, pages: [], count: 0, zoom: 1, mode: "auto", rot: 0, current: 1,
+            docSpread: "none", spread: null,
             destroyed: false, renderTimer: null, rendered: [], outline: null,
             find: { q: "", hits: [], at: -1, text: {} }
         };
@@ -75,6 +79,7 @@
             '<div class="vw-pill"><button class="vw-ic" data-t="out" title="' + esc(T("pv/zoomOut", "Zoom out")) + '">' + I("minimize") + '</button>' +
             '<button class="vw-pill-label" data-t="zoom" title="' + esc(T("vw/zoomOptions", "Zoom options")) + '">100%</button>' +
             '<button class="vw-ic" data-t="in" title="' + esc(T("pv/zoomIn", "Zoom in")) + '">' + I("plus") + '</button></div>' +
+            '<button class="vw-ic vw-hide-sm" data-t="layout" title="' + esc(T("vw/layout", "Page layout")) + '">' + I("spread") + '</button>' +
             '<button class="vw-ic vw-hide-sm" data-t="rotate" title="' + esc(T("pv/rotate", "Rotate")) + '">' + I("rotate") + '</button>' +
             '<button class="vw-ic" data-t="find" title="' + esc(T("vw/find", "Find in document")) + ' (Ctrl+F)">' + I("search") + '</button><span class="vw-sep vw-hide-sm"></span>';
         var zoomLabel = ctx.tools.querySelector('[data-t="zoom"]');
@@ -126,7 +131,11 @@
             }
             st.pdf = pdf;
             st.count = pdf.numPages;
-            return pdf.getPage(1).then(function (first) {
+            //Honour the /PageLayout the document asks for (e.g. a manual laid out as facing pages)
+            return pdf.getPageLayout().catch(function () { return ""; }).then(function (layout) {
+                st.docSpread = /^Two(Page|Column)Left$/.test(layout) ? "odd" : /^Two(Page|Column)Right$/.test(layout) ? "even" : "none";
+                return pdf.getPage(1);
+            }).then(function (first) {
                 st.base = first.getViewport({ scale: 1 });
                 build(first);
             });
@@ -147,7 +156,6 @@
                 var div = ReaderUI.el('<div class="pdf-page" data-page="' + i + '"><div class="ph"><div class="vw-spin" style="width:22px;height:22px;border-width:2px;border-color:#ddd;border-top-color:#999"></div></div></div>');
                 var p = { n: i, div: div, viewport: i == 1 ? st.base : null, rotate: i == 1 ? first.rotate : 0, scale: 0, task: null, textDivs: null };
                 st.pages.push(p);
-                pagesEl.appendChild(div);
                 var t = ReaderUI.el('<button class="pdf-thumb" data-page="' + i + '"><div class="pg" style="aspect-ratio:' + st.base.width + '/' + st.base.height + '"></div><span>' + i + '</span></button>');
                 t.addEventListener("click", (function (n) {
                     return function () {
@@ -161,6 +169,7 @@
             }
             nav.hidden = false;
             nav.querySelector("span").textContent = "/ " + st.count;
+            layoutPages();
             setZoomMode(mem.zoomMode || "auto", true);
             observe();
             ctx.setPrintable(true);
@@ -168,15 +177,79 @@
             main.focus();
         }
 
+        /* Page layout (single pages or two page spreads) ------------------------ */
+        function wantedSpread() {
+            var mode = mem.spreadMode || "auto";
+            var s = mode == "auto" ? st.docSpread : mode;
+            return main.clientWidth < SPREAD_MIN_WIDTH ? "none" : s;
+        }
+
+        //Puts the pages straight into the column, or two to a row. "odd" pairs
+        //1-2, 3-4...; "even" keeps page 1 (the cover) alone and pairs 2-3, 4-5...
+        function layoutPages() {
+            var s = wantedSpread();
+            if (s == st.spread) {
+                return false;
+            }
+            st.spread = s;
+            pagesEl.innerHTML = "";
+            var row = null;
+            st.pages.forEach(function (p) {
+                if (s == "none") {
+                    pagesEl.appendChild(p.div);
+                    return;
+                }
+                var starts = s == "odd" ? p.n % 2 == 1 : (p.n % 2 == 0 || p.n == 1);
+                if (!row || starts) {
+                    row = ReaderUI.el('<div class="pdf-row"></div>');
+                    pagesEl.appendChild(row);
+                }
+                row.appendChild(p.div);
+            });
+            return true;
+        }
+
+        function relayout() {
+            var cur = st.current;
+            var changed = layoutPages();
+            if (changed || st.mode == "width" || st.mode == "page" || st.mode == "auto") {
+                setZoomMode(st.mode, changed);
+            }
+            if (changed) {
+                goToPage(cur);
+            }
+        }
+
+        function columns() {
+            return st.spread && st.spread != "none" ? 2 : 1;
+        }
+
+        //The first and last page of the row that shows page n
+        function rowOf(n) {
+            if (columns() == 1 || (n == 1 && st.spread == "even")) {
+                return [n, n];
+            }
+            var first = st.spread == "odd" ? n - (n + 1) % 2 : n - n % 2;
+            return [first, Math.min(st.count, first + 1)];
+        }
+
+        //Previous / next page, or spread when two pages share a row
+        function stepPage(dir) {
+            var r = rowOf(st.current);
+            goToPage(dir > 0 ? r[1] + 1 : rowOf(Math.max(1, r[0] - 1))[0]);
+        }
+
         /* Zoom ------------------------------------------------------------------ */
         function fitWidth() {
-            var w = main.clientWidth - 48;
-            return w / (st.base.width * CSS_UNITS * (st.rot % 180 ? st.base.height / st.base.width : 1));
+            var cols = columns();
+            var w = main.clientWidth - 48 - (cols - 1) * SPREAD_GAP;
+            return w / (cols * CSS_UNITS * (st.rot % 180 ? st.base.height : st.base.width));
         }
 
         function fitPage() {
-            var w = main.clientWidth - 48, h = main.clientHeight - 56;
-            var bw = (st.rot % 180 ? st.base.height : st.base.width) * CSS_UNITS;
+            var cols = columns();
+            var w = main.clientWidth - 48 - (cols - 1) * SPREAD_GAP, h = main.clientHeight - 56;
+            var bw = cols * (st.rot % 180 ? st.base.height : st.base.width) * CSS_UNITS;
             var bh = (st.rot % 180 ? st.base.width : st.base.height) * CSS_UNITS;
             return Math.min(w / bw, h / bh);
         }
@@ -190,7 +263,8 @@
             } else if (mode == "auto") {
                 setZoom(Math.min(fitWidth(), 1.25), initial);
             } else {
-                setZoom(parseFloat(mode), initial);
+                var z = parseFloat(mode);
+                setZoom(isNaN(z) ? st.zoom : z, initial);
             }
         }
 
@@ -456,8 +530,9 @@
                     thumbs.scrollTop += tr.top - rr.top - rr.height / 2 + tr.height / 2;
                 }
             }
-            nav.querySelector('[data-n="prev"]').disabled = st.current <= 1;
-            nav.querySelector('[data-n="next"]').disabled = st.current >= st.count;
+            var r = rowOf(st.current);
+            nav.querySelector('[data-n="prev"]').disabled = r[0] <= 1;
+            nav.querySelector('[data-n="next"]').disabled = r[1] >= st.count;
         }
 
         function goToPage(n, offsetY) {
@@ -480,8 +555,8 @@
             }).catch(function () { });
         }
 
-        nav.querySelector('[data-n="prev"]').addEventListener("click", function () { goToPage(st.current - 1); });
-        nav.querySelector('[data-n="next"]').addEventListener("click", function () { goToPage(st.current + 1); });
+        nav.querySelector('[data-n="prev"]').addEventListener("click", function () { stepPage(-1); });
+        nav.querySelector('[data-n="next"]').addEventListener("click", function () { stepPage(1); });
         navInput.addEventListener("keydown", function (e) {
             if (e.key == "Enter") {
                 var n = parseInt(navInput.value, 10);
@@ -503,11 +578,7 @@
             mem.rail = force === undefined ? !mem.rail : force;
             rail.classList.toggle("collapsed", !mem.rail);
             ctx.tools.querySelector('[data-t="rail"]').classList.toggle("on", mem.rail);
-            setTimeout(function () {
-                if (st.mode == "width" || st.mode == "page" || st.mode == "auto") {
-                    setZoomMode(st.mode);
-                }
-            }, 220);
+            setTimeout(relayout, 220);
         }
 
         ctx.body.querySelectorAll("[data-tab]").forEach(function (b) {
@@ -748,6 +819,20 @@
                     setZoomMode(st.mode);
                 } else if (t == "find") {
                     openFind();
+                } else if (t == "layout") {
+                    var cur = mem.spreadMode || "auto";
+                    var pickLayout = function (mode) {
+                        return function () {
+                            mem.spreadMode = mode;
+                            relayout();
+                        };
+                    };
+                    ReaderUI.menu({ anchor: b }, [
+                        { checkable: true, checked: cur == "auto", label: T("vw/layoutAuto", "Automatic"), action: pickLayout("auto") },
+                        { checkable: true, checked: cur == "none", label: T("vw/layoutSingle", "Single page"), action: pickLayout("none") },
+                        { checkable: true, checked: cur == "odd", label: T("vw/layoutTwo", "Two pages"), action: pickLayout("odd") },
+                        { checkable: true, checked: cur == "even", label: T("vw/layoutCover", "Two pages, cover alone"), action: pickLayout("even") }
+                    ]);
                 } else if (t == "zoom") {
                     var pick = function (mode) {
                         return function () {
@@ -848,9 +933,9 @@
                 }
                 var k = e.key;
                 if (k == "ArrowRight" || k == "PageDown" || k == "n") {
-                    goToPage(st.current + 1);
+                    stepPage(1);
                 } else if (k == "ArrowLeft" || k == "PageUp" || k == "p") {
-                    goToPage(st.current - 1);
+                    stepPage(-1);
                 } else if (k == "Home") {
                     goToPage(1);
                 } else if (k == "End") {
@@ -874,11 +959,7 @@
                 return true;
             },
             print: print,
-            onResize: function () {
-                if (st.mode == "width" || st.mode == "page" || st.mode == "auto") {
-                    setZoomMode(st.mode);
-                }
-            },
+            onResize: relayout,
             destroy: function () {
                 st.destroyed = true;
                 clearTimeout(st.renderTimer);

+ 1 - 0
src/web/PDF Viewer/js/reader.ui.js

@@ -95,6 +95,7 @@
         chevronRight: '<path d="M9 6l6 6-6 6"/>',
         chevronLeft: '<path d="M15 6l-6 6 6 6"/>',
         panel: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/><path d="M17.5 8.5h1M17.5 11.5h1"/>',
+        spread: '<rect x="2.5" y="5" width="8.5" height="14" rx="1.5"/><rect x="13" y="5" width="8.5" height="14" rx="1.5"/>',
         x: '<path d="M6 6l12 12M18 6 6 18"/>',
         check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
         search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.3-4.3"/>',

+ 30 - 0
src/web/PDF Viewer/locale/reader.json

@@ -30,6 +30,11 @@
                 "vw/firstLast": "First / last page",
                 "vw/fitPage": "Fit page",
                 "vw/fitWidth": "Fit width",
+                "vw/layout": "Page layout",
+                "vw/layoutAuto": "Automatic",
+                "vw/layoutSingle": "Single page",
+                "vw/layoutTwo": "Two pages",
+                "vw/layoutCover": "Two pages, cover alone",
                 "vw/keyPage": "Previous / next page",
                 "vw/nextPage": "Next page",
                 "vw/noMatches": "No matches",
@@ -79,6 +84,11 @@
                 "vw/firstLast": "第一頁 / 最後一頁",
                 "vw/fitPage": "符合頁面",
                 "vw/fitWidth": "符合寬度",
+                "vw/layout": "頁面配置",
+                "vw/layoutAuto": "自動",
+                "vw/layoutSingle": "單頁",
+                "vw/layoutTwo": "雙頁",
+                "vw/layoutCover": "雙頁,封面獨立",
                 "vw/keyPage": "上一頁 / 下一頁",
                 "vw/nextPage": "下一頁",
                 "vw/noMatches": "沒有符合的結果",
@@ -128,6 +138,11 @@
                 "vw/firstLast": "第一頁 / 最後一頁",
                 "vw/fitPage": "符合頁面",
                 "vw/fitWidth": "符合寬度",
+                "vw/layout": "頁面配置",
+                "vw/layoutAuto": "自動",
+                "vw/layoutSingle": "單頁",
+                "vw/layoutTwo": "雙頁",
+                "vw/layoutCover": "雙頁,封面獨立",
                 "vw/keyPage": "上一頁 / 下一頁",
                 "vw/nextPage": "下一頁",
                 "vw/noMatches": "沒有符合的結果",
@@ -177,6 +192,11 @@
                 "vw/firstLast": "第一页 / 最后一页",
                 "vw/fitPage": "适合页面",
                 "vw/fitWidth": "适合宽度",
+                "vw/layout": "页面布局",
+                "vw/layoutAuto": "自动",
+                "vw/layoutSingle": "单页",
+                "vw/layoutTwo": "双页",
+                "vw/layoutCover": "双页,封面独立",
                 "vw/keyPage": "上一页 / 下一页",
                 "vw/nextPage": "下一页",
                 "vw/noMatches": "没有匹配结果",
@@ -226,6 +246,11 @@
                 "vw/firstLast": "最初 / 最後のページ",
                 "vw/fitPage": "ページに合わせる",
                 "vw/fitWidth": "幅に合わせる",
+                "vw/layout": "ページレイアウト",
+                "vw/layoutAuto": "自動",
+                "vw/layoutSingle": "単一ページ",
+                "vw/layoutTwo": "見開き",
+                "vw/layoutCover": "見開き(表紙は単独)",
                 "vw/keyPage": "前 / 次のページ",
                 "vw/nextPage": "次のページ",
                 "vw/noMatches": "一致する結果はありません",
@@ -275,6 +300,11 @@
                 "vw/firstLast": "첫 페이지 / 마지막 페이지",
                 "vw/fitPage": "페이지에 맞추기",
                 "vw/fitWidth": "너비에 맞추기",
+                "vw/layout": "페이지 레이아웃",
+                "vw/layoutAuto": "자동",
+                "vw/layoutSingle": "한 페이지",
+                "vw/layoutTwo": "두 페이지",
+                "vw/layoutCover": "두 페이지, 표지 단독",
                 "vw/keyPage": "이전 / 다음 페이지",
                 "vw/nextPage": "다음 페이지",
                 "vw/noMatches": "일치하는 항목 없음",