diff --git a/.gitignore b/.gitignore
index db90696..a37e8ff 100755
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,6 @@ perf/.tmp/
# UI regression screenshots and local measurements
plans/ui-shots/
+
+# Transport regression screenshots
+plans/transport-shots/
diff --git a/frontend/admin.html b/frontend/admin.html
index d2dd8ac..dd44c63 100644
--- a/frontend/admin.html
+++ b/frontend/admin.html
@@ -1199,7 +1199,7 @@
-
+
diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js
index f6dc72e..3ef08ad 100644
--- a/frontend/app-seams.test.js
+++ b/frontend/app-seams.test.js
@@ -4,13 +4,12 @@ const {readFileSync}=require('node:fs');
const {createHash}=require('node:crypto');
const {join}=require('node:path');
const app=readFileSync(join(__dirname,'app.js'),'utf8');
-test('Phase 4 preserves the exact player, continuity, tracking and queue bodies',()=>{
+test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");}
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}
{const start=app.indexOf("function wirePlayerEvents() {");assert.ok(start>=0,"wirePlayerEvents");const body=app.slice(start,start+4913);assert.equal(createHash('sha256').update(body).digest('hex'),"1c5a2b5ce310f6aef6764325eda85a95f47e4f507f2049036ce1da206379908c","wirePlayerEvents body unchanged");}
- {const start=app.indexOf("function updatePlayBtn() {");assert.ok(start>=0,"updatePlayBtn");const body=app.slice(start,start+522);assert.equal(createHash('sha256').update(body).digest('hex'),"1326233af07ae92a5ecc81cc211bd98d21ed5f291dc4eb59d040b8df9baba8c8","updatePlayBtn body unchanged");}
{const start=app.indexOf("function persist() {");assert.ok(start>=0,"persist");const body=app.slice(start,start+147);assert.equal(createHash('sha256').update(body).digest('hex'),"01e5c5dbc80616266db09b3829dc45b66476a3256b1c169d8e44f4983b24ac45","persist body unchanged");}
{const start=app.indexOf("function playFromList(list, index, source = '', { playFull = false } = {}) {");assert.ok(start>=0,"playFromList");const body=app.slice(start,start+401);assert.equal(createHash('sha256').update(body).digest('hex'),"f8009c9674b3478959997dca881fb7b9e1ab3708c98e5abdf1958d752e47ee41","playFromList body unchanged");}
{const start=app.indexOf("function advanceQueue() {");assert.ok(start>=0,"advanceQueue");const body=app.slice(start,start+596);assert.equal(createHash('sha256').update(body).digest('hex'),"be405b9c163e3d41a949ef11f6022ef13115b04083e3b6ad21d97beba1f45ae6","advanceQueue body unchanged");}
diff --git a/frontend/app.js b/frontend/app.js
index 9c44f7d..e05ee6b 100755
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -1598,8 +1598,8 @@ function escapeHtml(s) {
.replace(/"/g, '"').replace(/'/g, ''');
}
-const ICON_PLAY = '
';
-const ICON_PAUSE = '
';
+const ICON_PLAY = TransportIcons.svg('play');
+const ICON_PAUSE = TransportIcons.svg('pause');
// Speaker icon follows the volume (muted / low / high); the level before a mute
// is remembered so un-muting returns to it.
let lastVolume = 1;
@@ -3088,14 +3088,10 @@ function wirePlayerEvents() {
function updatePlayBtn() {
Remote.changed();
- els.playBtn.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE;
- const sp = $('stgPlay');
- if (sp) sp.innerHTML = Player.master.paused ? ICON_PLAY : ICON_PAUSE;
+ const kind = Player.master.paused ? 'play' : 'pause';
+ for (const id of ['playBtn','stgPlay','miniPlayBtn','servicePlayBtn']) TransportIcons.set($(id), kind);
const stage = document.querySelector('.player-stage');
if (stage) stage.classList.toggle('is-paused', !!Player.master.paused);
- $('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸';
- const spBtn = $('servicePlayBtn');
- if (spBtn) spBtn.textContent = Player.master.paused ? '▶' : '⏸';
}
// "⚠ Broken": ask the server to drop its cached copy and fetch a fresh one.
// Playback switches to live YouTube streams meanwhile (resuming where it
@@ -3245,7 +3241,7 @@ function openMoreControls() {
['eqBtn', '🎚', 'Equalizer', false],
['pipBtn', '⧉', 'Picture-in-picture', false],
];
- const selects = [['speedSelect', '⏩', 'Speed'], ['qualitySelect', '🎞', 'Quality']];
+ const selects = [['speedSelect', TransportIcons.svg('forward'), 'Speed'], ['qualitySelect', '🎞', 'Quality']];
const list = document.createElement('div');
list.className = 'more-list';
const draw = () => {
@@ -3256,7 +3252,7 @@ function openMoreControls() {
const row = document.createElement('label');
row.className = 'more-row';
row.innerHTML = '
';
- row.querySelector('.more-ico').textContent = icon;
+ row.querySelector('.more-ico').innerHTML = icon;
row.querySelector('b').textContent = title;
const sel = document.createElement('select');
sel.className = 'more-sel';
@@ -3274,7 +3270,7 @@ function openMoreControls() {
row.type = 'button';
row.className = 'more-row' + (on ? ' on' : '');
row.innerHTML = '
' + (on ? '●' : '›') + '';
- row.querySelector('.more-ico').textContent = icon;
+ row.querySelector('.more-ico').innerHTML = icon;
row.querySelector('b').textContent = title;
row.querySelector('small').textContent = String(src.title || '').replace(/\s*\[[A-Za-z]\]\s*$/, '');
row.onclick = () => {
@@ -3301,13 +3297,13 @@ function openAddChoice() {
r.type = 'button';
r.className = 'more-row';
r.innerHTML = '
›';
- r.querySelector('.more-ico').textContent = icon;
+ r.querySelector('.more-ico').innerHTML = icon;
r.querySelector('b').textContent = title;
r.querySelector('small').textContent = sub;
r.onclick = () => { closeModal(); setTimeout(fn, 0); };
list.appendChild(r);
};
- row('▶', 'Add to queue', 'Play it next in this session', () => addToQueue(video));
+ row(TransportIcons.svg('play'), 'Add to queue', 'Play it next in this session', () => addToQueue(video));
row('☰', 'Add to playlist', 'Save it to one of your playlists', () => openCardMenu(video));
showModal('Add “' + (video.title || 'video').slice(0, 40) + '”', list, [{ label: 'Cancel', onClick: closeModal }]);
}
@@ -3332,7 +3328,7 @@ function openMoreActions() {
row.className = 'more-row' + (danger ? ' danger' : '');
row.disabled = !!src.disabled;
row.innerHTML = '
›';
- row.querySelector('.more-ico').textContent = icon;
+ row.querySelector('.more-ico').innerHTML = icon;
row.querySelector('b').textContent = title;
row.querySelector('small').textContent = sub;
row.onclick = () => { closeModal(); setTimeout(() => src.click(), 0); };
@@ -3633,7 +3629,7 @@ const Notes = (() => {
${m.kind === 'note' ? '📝 ' : ''}${escapeHtml(m.title)}
${m.note ? `
${escapeHtml(m.note)}
` : ''}
-