Skip to content

Commit 55e5d4f

Browse files
committed
Stop footer slider drags from firing prev/next swipes (#44)
1 parent c5bf615 commit 55e5d4f

3 files changed

Lines changed: 121 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,26 @@ jobs:
3939
run: |
4040
python -m compileall -q .
4141
42+
ui-gestures:
43+
name: UI gesture regression test
44+
runs-on: ubuntu-latest
45+
timeout-minutes: 10
46+
steps:
47+
- uses: actions/checkout@v7
48+
49+
- uses: actions/setup-node@v4
50+
with:
51+
node-version: "20"
52+
53+
- name: Install jsdom
54+
run: npm install --no-save jsdom@25
55+
56+
- name: Now-playing footer gestures
57+
# Guards issue #44: a slider drag inside the footer must not be
58+
# read as a prev/next swipe. Runs the real handler source against
59+
# the real footer markup, both parsed out of templates/index.html.
60+
run: node tests/swipe-gestures.test.js
61+
4262
import-smoke:
4363
name: Import smoke test
4464
runs-on: ubuntu-latest

templates/index.html

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3949,18 +3949,31 @@ <h2 style="font-family:var(--display-font);font-size:1rem;margin-bottom:1rem">Ke
39493949
async function playerNext(){await fetch('/api/player/next',{method:'POST'})}
39503950
async function playerPrev(){await fetch('/api/player/prev',{method:'POST'})}
39513951

3952-
var _swipeStartX=0,_swipeStartY=0;
3952+
var _swipeStartX=0,_swipeStartY=0,_swipeIgnore=false;
3953+
// Controls inside the footer that own their own horizontal drag. A gesture
3954+
// starting on one of these is never a prev/next swipe: dragging an EQ, volume,
3955+
// or seek control used to bubble up here and skip a track (issue #44), which
3956+
// read as the sliders moving the seek bar.
3957+
var SWIPE_IGNORE_SEL='input,button,select,textarea,a,label,.np-progress-bar,.np-progress-row,.eq-row';
39533958
function initSwipeGestures(){
39543959
const npBar=document.getElementById('np-footer');
39553960
if(!npBar)return;
3956-
npBar.addEventListener('pointerdown',e=>{_swipeStartX=e.clientX;_swipeStartY=e.clientY},{passive:true});
3961+
npBar.addEventListener('pointerdown',e=>{
3962+
const t=e.target;
3963+
_swipeIgnore=!!(t&&t.closest&&t.closest(SWIPE_IGNORE_SEL));
3964+
_swipeStartX=e.clientX;_swipeStartY=e.clientY;
3965+
},{passive:true});
39573966
npBar.addEventListener('pointerup',e=>{
3967+
if(_swipeIgnore){_swipeIgnore=false;return}
39583968
const dx=e.clientX-_swipeStartX;
39593969
const dy=e.clientY-_swipeStartY;
39603970
const adx=Math.abs(dx);
39613971
const ady=Math.abs(dy);
39623972
if(adx>50&&adx>ady){if(dx>0){playerPrev()}else{playerNext()}}
39633973
},{passive:true});
3974+
// A drag that leaves the footer (finger sliding past the edge) never fires
3975+
// pointerup here, so clear the flag on cancel too.
3976+
npBar.addEventListener('pointercancel',()=>{_swipeIgnore=false},{passive:true});
39643977
}
39653978
async function playerRepeat(){const r=await fetch('/api/player/repeat',{method:'POST'}).then(r=>r.json());if(r.ok)updateRepeatButton(r.repeat_mode)}
39663979
function updateRepeatButton(mode){var btn=document.getElementById('np-repeat');if(!btn)return;var repeatSvg='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>';var repeatOneSvg='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/><text x="12" y="15" text-anchor="middle" fill="currentColor" stroke="none" font-size="8" font-weight="bold">1</text></svg>';if(mode==='album'){btn.style.opacity='1';btn.innerHTML=repeatSvg;btn.title='Repeat album'}else if(mode==='track'){btn.style.opacity='1';btn.innerHTML=repeatOneSvg;btn.title='Repeat track'}else{btn.style.opacity='0.4';btn.innerHTML=repeatSvg;btn.title='Repeat off'}}

tests/swipe-gestures.test.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Regression test for issue #44: dragging an EQ/volume slider inside the
2+
// now-playing footer must not register as a prev/next swipe.
3+
// Runs the REAL initSwipeGestures source extracted from templates/index.html
4+
// against the REAL footer markup extracted from the same file.
5+
const fs = require('fs');
6+
const path = require('path');
7+
const { JSDOM } = require('jsdom');
8+
9+
const TEMPLATE = process.argv[2] || path.join(__dirname, '..', 'templates', 'index.html');
10+
const html = fs.readFileSync(TEMPLATE, 'utf8');
11+
12+
// Pull the actual footer markup out of the template
13+
const footerStart = html.indexOf('<div class="np-bar hidden" id="np-footer">');
14+
if (footerStart < 0) throw new Error('np-footer not found');
15+
const footerEnd = html.indexOf('<!-- Toast Container -->', footerStart);
16+
const footerHtml = html.slice(footerStart, footerEnd);
17+
18+
// Pull the actual gesture code out of the template
19+
// SWIPE_IGNORE_SEL is absent in the pre-fix source; the test then runs the old
20+
// handler unchanged, which is the point of the regression check.
21+
const selMatch = html.match(/var SWIPE_IGNORE_SEL=[^\n]*/) || [''];
22+
const fnMatch = html.match(/function initSwipeGestures\(\)\{[\s\S]*?\n\}/);
23+
if (!fnMatch) throw new Error('gesture source not found');
24+
25+
const dom = new JSDOM(`<body>${footerHtml}</body>`, {
26+
pretendToBeVisual: true,
27+
runScripts: 'dangerously',
28+
});
29+
const { window } = dom;
30+
31+
// Evaluate the real source in the jsdom window, with the transport calls
32+
// stubbed to counters the test can read back off the window.
33+
window.eval(`
34+
window.__prev=0; window.__next=0;
35+
function playerPrev(){window.__prev++}
36+
function playerNext(){window.__next++}
37+
var _swipeStartX=0,_swipeStartY=0,_swipeIgnore=false;
38+
${selMatch[0]}
39+
${fnMatch[0]}
40+
initSwipeGestures();
41+
`);
42+
43+
// jsdom lacks PointerEvent; MouseEvent carries the clientX/clientY the handler reads
44+
function drag(el, fromX, toX, y = 100) {
45+
el.dispatchEvent(new window.MouseEvent('pointerdown', { clientX: fromX, clientY: y, bubbles: true }));
46+
el.dispatchEvent(new window.MouseEvent('pointerup', { clientX: toX, clientY: y, bubbles: true }));
47+
}
48+
49+
const results = [];
50+
function check(name, expectPrev, expectNext, fn) {
51+
window.__prev = 0; window.__next = 0;
52+
fn();
53+
const pass = window.__prev === expectPrev && window.__next === expectNext;
54+
results.push({ name, pass, got: `prev=${window.__prev} next=${window.__next}`, want: `prev=${expectPrev} next=${expectNext}` });
55+
}
56+
57+
const q = (s) => window.document.querySelector(s);
58+
59+
// The reported bug: a long rightward drag on a slider fired playerPrev,
60+
// which jumped the seek bar backwards.
61+
check('drag volume slider right', 0, 0, () => drag(q('#eq-volume'), 100, 300));
62+
check('drag volume slider left', 0, 0, () => drag(q('#eq-volume'), 300, 100));
63+
check('drag bass slider right', 0, 0, () => drag(q('#eq-bass'), 100, 300));
64+
check('drag treble slider left', 0, 0, () => drag(q('#eq-treble'), 300, 100));
65+
check('drag across the seek bar', 0, 0, () => drag(q('.np-progress-bar'), 100, 300));
66+
check('tap a transport button', 0, 0, () => drag(q('#np-play-pause'), 100, 105));
67+
68+
// The gesture must still work where it was intended: on the footer body.
69+
check('swipe right on footer body', 1, 0, () => drag(q('#np-footer .np-text'), 100, 300));
70+
check('swipe left on footer body', 0, 1, () => drag(q('#np-footer .np-text'), 300, 100));
71+
check('short tap on footer body', 0, 0, () => drag(q('#np-footer .np-text'), 100, 110));
72+
73+
// A drag that leaves the footer mid-gesture must not arm a later swipe.
74+
check('slider drag cancelled, then real swipe', 1, 0, () => {
75+
q('#eq-volume').dispatchEvent(new window.MouseEvent('pointerdown', { clientX: 100, clientY: 100, bubbles: true }));
76+
q('#np-footer').dispatchEvent(new window.MouseEvent('pointercancel', { bubbles: true }));
77+
drag(q('#np-footer .np-text'), 100, 300);
78+
});
79+
80+
let failed = 0;
81+
for (const r of results) {
82+
if (!r.pass) failed++;
83+
console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.name} (got ${r.got}, want ${r.want})`);
84+
}
85+
console.log(failed === 0 ? `\nAll ${results.length} checks passed` : `\n${failed} of ${results.length} FAILED`);
86+
process.exit(failed === 0 ? 0 : 1);

0 commit comments

Comments
 (0)