Sticky catalog search bar, and let anyone seek within a music track #5

Merged
kyuno merged 2 commits from feature/sticky-search-and-music-seek into master 2026-09-16 07:21:15 +02:00
12 changed files with 272 additions and 18 deletions

View file

@ -328,6 +328,9 @@ Then put it behind your existing reverse proxy like everything else
as its own always-on-top bar, duplicating the cast icon and as its own always-on-top bar, duplicating the cast icon and
overlapping the new bottom bar in fullscreen) in favor of fully overlapping the new bottom bar in fullscreen) in favor of fully
custom play/pause, a seek bar, and mute — see docs/architecture.md custom play/pause, a seek bar, and mute — see docs/architecture.md
- [x] Sticky search bar on the library/artist/album pages, and letting
anyone seek within the current music track (same no-host-check
model pause/resume already had) — see docs/architecture.md
- [x] Skip intro / jump to next episode at end credits, using Emby's own - [x] Skip intro / jump to next episode at end credits, using Emby's own
native marker detection (`EnableMarkerDetection` + its "Detect native marker detection (`EnableMarkerDetection` + its "Detect
Episode Intros" task) rather than building fingerprinting Episode Intros" task) rather than building fingerprinting

View file

@ -140,6 +140,10 @@ export interface PartyConnection {
voteSkip(): void; voteSkip(): void;
/** Music only: no host check server-side, unlike sendPlaystate above. */ /** Music only: no host check server-side, unlike sendPlaystate above. */
setMusicPaused(isPaused: boolean): void; setMusicPaused(isPaused: boolean): void;
/** Music only: jump to `positionSeconds` in the current track - same
* no-host-check treatment as setMusicPaused above (see
* MusicQueueService.seek's own comment for why). */
seekMusic(positionSeconds: number): void;
/** Re-sends "join" with force:true after the user confirms they want to /** Re-sends "join" with force:true after the user confirms they want to
* disconnect their other device and take over here - call this from * disconnect their other device and take over here - call this from
* onDeviceConflict's handler once the user says yes. */ * onDeviceConflict's handler once the user says yes. */
@ -217,6 +221,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
setMusicPaused(isPaused) { setMusicPaused(isPaused) {
socket.emit('musicPlaystate', { isPaused }); socket.emit('musicPlaystate', { isPaused });
}, },
seekMusic(positionSeconds) {
socket.emit('musicSeek', { positionSeconds });
},
confirmDeviceTakeover() { confirmDeviceTakeover() {
socket.emit('join', { partyId, deviceId, force: true }); socket.emit('join', { partyId, deviceId, force: true });
}, },

View file

@ -20,18 +20,34 @@
/* A thin strip along the bar's own top edge, not inside .musicBarRow - /* A thin strip along the bar's own top edge, not inside .musicBarRow -
the same "compact player" convention Spotify/YouTube Music use for the same "compact player" convention Spotify/YouTube Music use for
their mini-players. Read-only (see MusicBar.ts's own comment) - no their mini-players. Click/drag to seek (see MusicBar.ts) - the
click/drag handling, just a visual position indicator. */ generous ::before hit-area below (not just the 3px visual track)
matters most for drag on a touch screen, same trick
views/player/Player.css's own .progressBar::before uses.
`position: relative` is what gives that absolutely-positioned
::before something to size itself against. */
.musicBarProgressTrack { .musicBarProgressTrack {
position: relative;
height: 3px; height: 3px;
background: rgba(255, 255, 255, .12); background: rgba(255, 255, 255, .12);
cursor: pointer;
touch-action: none; /* this app's own pointer handlers drive the drag, not the browser's default touch scroll/zoom gestures */
}
.musicBarProgressTrack::before {
content: '';
position: absolute;
top: -8px;
bottom: -8px;
left: 0;
right: 0;
} }
.musicBarProgressFill { .musicBarProgressFill {
height: 100%; height: 100%;
width: 0%; width: 0%;
background: var(--accent); background: var(--accent);
transition: width .25s linear; pointer-events: none;
} }
.musicBarRow { .musicBarRow {

View file

@ -5,9 +5,12 @@
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div> <div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
</div> </div>
<!-- Read-only - see MusicBar.ts's own comment on why this doesn't seek <!-- Click/drag to seek - see MusicBar.ts's pointerdown/move/up handlers.
(no host to arbitrate a jump, and MusicQueueService has no "seek" No host check needed to enable this, unlike the video player's own
concept server-side today - just play/pause/advance). --> seek bar: a listen party has no host at all, and a seek here gets
the exact same "anyone can do it" treatment play/pause already has
(see MusicQueueService.seek's own comment for why that's the right
model for this, not voteSkip's majority gate). -->
<div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div> <div id="trackProgress" class="musicBarProgressTrack"><div id="trackProgressFill" class="musicBarProgressFill"></div></div>
<div class="musicBarRow"> <div class="musicBarRow">

View file

@ -99,6 +99,7 @@ export function initMusicBar(root: HTMLElement): void {
const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!; const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!;
const queuePanel = barEl.querySelector('#musicBarQueue')!; const queuePanel = barEl.querySelector('#musicBarQueue')!;
const leaveBtn = barEl.querySelector('#leaveBtn')!; const leaveBtn = barEl.querySelector('#leaveBtn')!;
const trackProgress = barEl.querySelector('#trackProgress') as HTMLElement;
activeDriver = localDriver(audio); activeDriver = localDriver(audio);
@ -109,6 +110,40 @@ export function initMusicBar(root: HTMLElement): void {
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide')); toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
leaveBtn.addEventListener('click', () => leaveMusicParty()); leaveBtn.addEventListener('click', () => leaveMusicParty());
// Click/drag to seek - no host check, unlike the video player's own
// seek bar, since a listen party has no host at all (see activeDriver's
// own comment) - anyone dragging this updates their own driver
// immediately (for instant visual feedback, same as the video player's
// own seekFromPointerEvent) and broadcasts it, same "anyone, straight
// away" treatment play/pause already has (see MusicQueueService.seek).
// Pointer events (not click/drag) so mouse and touch share one
// implementation, and setPointerCapture keeps the drag tracking even if
// the pointer leaves the track's own bounds mid-gesture - same pattern
// as Player.ts's progressBar.
let isDraggingProgress = false;
function seekFromPointerEvent(e: PointerEvent): void {
if (currentDurationSeconds <= 0) return;
const rect = trackProgress.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const seconds = fraction * currentDurationSeconds;
activeDriver!.currentTime = seconds;
updatePositionDisplay();
conn?.seekMusic(seconds);
}
trackProgress.addEventListener('pointerdown', (e) => {
isDraggingProgress = true;
trackProgress.setPointerCapture(e.pointerId);
seekFromPointerEvent(e);
});
trackProgress.addEventListener('pointermove', (e) => {
if (!isDraggingProgress) return;
seekFromPointerEvent(e);
});
trackProgress.addEventListener('pointerup', () => { isDraggingProgress = false; });
trackProgress.addEventListener('pointercancel', () => { isDraggingProgress = false; });
// Visible to any member the moment a Chromecast is on the network - not // Visible to any member the moment a Chromecast is on the network - not
// gated on anything party-related, same as Player.ts's own castBtn // gated on anything party-related, same as Player.ts's own castBtn
// (see its comment: casting is available to any member, and this bar // (see its comment: casting is available to any member, and this bar
@ -126,15 +161,19 @@ export function initMusicBar(root: HTMLElement): void {
// server updates, same "derive it client-side from a snapshot" approach // server updates, same "derive it client-side from a snapshot" approach
// as Home's party-elapsed labels and the old MusicPlayer.ts - runs // as Home's party-elapsed labels and the old MusicPlayer.ts - runs
// continuously rather than only while a party is active (cheap: a // continuously rather than only while a party is active (cheap: a
// no-op while !currentItemId). // no-op while !currentItemId). Also called directly right after a local
positionTicker = window.setInterval(() => { // seek (see seekFromPointerEvent above), for instant feedback instead
if (!currentItemId) return; // of waiting up to 500ms for this same tick to catch up.
const currentTime = activeDriver!.currentTime; positionTicker = window.setInterval(updatePositionDisplay, 500);
const position = barEl!.querySelector('#trackPosition')!; }
position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`;
const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement; function updatePositionDisplay(): void {
fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%'; if (!currentItemId) return;
}, 500); const currentTime = activeDriver!.currentTime;
const position = barEl!.querySelector('#trackPosition')!;
position.textContent = `${formatDuration(currentTime)} / ${formatDuration(currentDurationSeconds)}`;
const fill = barEl!.querySelector('#trackProgressFill') as HTMLElement;
fill.style.width = currentDurationSeconds > 0 ? `${Math.min(100, (currentTime / currentDurationSeconds) * 100)}%` : '0%';
} }
/** /**

View file

@ -4,10 +4,18 @@
/* base.css's global `input {}` rule already gives this the right look /* base.css's global `input {}` rule already gives this the right look
(padding/border/background) - this just bounds its width and spaces it (padding/border/background) - this just bounds its width and spaces it
from the grid below, shared by views/library, views/artist, views/album - from the grid below, shared by views/library, views/artist, views/album -
see tiles.ts's wireSearchFilter. */ see tiles.ts's wireSearchFilter. Sticky, not just top-of-page: asked
for directly, so it stays reachable while scrolling a long grid
instead of scrolling away with the page title above it. Its own
background (base.css's input rule already gives it var(--panel), not
transparent) is what keeps tiles scrolling underneath from showing
through its own bounds once stuck. */
.searchBar { .searchBar {
max-width: 320px; max-width: 320px;
margin-bottom: 16px; margin-bottom: 16px;
position: sticky;
top: 0;
z-index: 2;
} }
.searchEmpty { margin-top: 8px; } .searchEmpty { margin-top: 8px; }

View file

@ -3123,6 +3123,59 @@ and play the stream end to end - no device reachable from this
sandbox, same standing limitation as the rest of "Casting to a sandbox, same standing limitation as the rest of "Casting to a
Chromecast". Chromecast".
## A sticky search bar, and letting anyone seek within a playing track
Two small, unrelated requests handled together.
**The search bar** on `views/library`/`views/artist`/`views/album`
(`tiles.ts`'s shared `.searchBar`) scrolled away with the page - asked
to stay reachable while scrolling a long grid instead. `position:
sticky; top: 0` on the input itself, not a wrapper - `base.css`'s own
generic `input` rule already gives it an opaque `var(--panel)`
background, which is what actually keeps tiles scrolling underneath
from showing through its bounds once stuck; nothing else needed
changing.
**Seeking within the current music track** was explicit, deliberate
scope this session's own "participative playlist" work had cut - the
progress bar was marked read-only specifically because, at the time,
`MusicQueueService` had "no `seek` concept... just play/pause/advance".
Asked for directly now. The permission model was already decided,
though, by that same class's own documented reasoning for why *anyone*
(not a vote, not a host) can pause/resume: it's reversible and doesn't
change what's playing - a seek is exactly that same shape, just
continuous instead of binary, so it gets the identical treatment, not
`voteSkip`'s majority gate (which changes *what's* playing).
`MusicQueueService.seek(party, positionSeconds)` mirrors `setPaused`'s
own elapsed-time math (clamped into `[0, durationSeconds]` when known),
with one real difference `setPaused`'s resume branch never has to deal
with: `scheduleAdvance` only ever *sets* `advanceTimer`, it never clears
a prior one - resuming gets away with calling it directly only because
pausing already cleared the timer first. Seeking while still playing
has no such guarantee (the old timer, counting down against the *old*
position, is still live) - skipping the clear first would leave it
running alongside the new one, eventually firing `playNext` at the
wrong moment. New `musicSeek` socket event, no host check (same as
`musicPlaystate`). Client-side, `musicBar/MusicBar.ts`'s progress track
gained the same pointerdown/move/up drag-to-seek handlers the video
player's own seek bar already has - no host branch to port over this
time, since a listen party has none - and a local, optimistic
`activeDriver.currentTime` write before broadcasting, for the same
instant-feedback reason the video player's own `seekFromPointerEvent`
does that.
Verified: full `typecheck`/`test`/`build` on both projects (128/128
server tests - new coverage for `seek`'s clamping, its paused-vs-playing
branches, and specifically a real-timer test proving the old advance
timer is cleared rather than left running alongside a re-armed one, the
exact bug the naive "just call scheduleAdvance again" version would
have shipped). No server is needed to see the sticky search bar itself,
so that was checked by reading `.mainContent`'s own layout directly
(no nested scroll container between it and the document, confirming
`position: sticky` resolves against the page scroll as intended, not
some inner element).
## Skipping the intro/end credits, using Emby's own native detection ## Skipping the intro/end credits, using Emby's own native detection
Asked for directly: propose skipping a TV episode's opening/end credits, Asked for directly: propose skipping a TV episode's opening/end credits,

View file

@ -283,6 +283,12 @@ class SocketGatewayClass {
MusicQueueService.setPaused(party, !!msg?.isPaused); MusicQueueService.setPaused(party, !!msg?.isPaused);
})); }));
// No host check either, same reasoning - see MusicQueueService.seek.
socket.on('musicSeek', this.safe((msg: { positionSeconds: number }) => {
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
MusicQueueService.seek(party, Number(msg?.positionSeconds) || 0);
}));
// Anyone (not just followers - harmless either way) can ask the host // Anyone (not just followers - harmless either way) can ask the host
// to pause; the presence overlay (the video player) is what triggers // to pause; the presence overlay (the video player) is what triggers
// this. Broadcast to the whole party, not just the host, so everyone // this. Broadcast to the whole party, not just the host, so everyone

View file

@ -92,6 +92,41 @@ class MusicQueueServiceClass {
this.broadcast(party); this.broadcast(party);
} }
/**
* No host check - anyone can do this (see class comment): a seek is
* just a continuous version of pause/resume, reversible and not a
* change to what's playing - not the majority-vote model reserved for
* actually skipping to different content. Clamped into
* [0, durationSeconds] when the duration is known (an unknown one -
* never actually seen in practice, see MusicTrack - just trusts the
* caller's own non-negative value, same as the video player's seek
* has no server-side duration to clamp against either).
*/
seek(party: Party, positionSeconds: number): void {
const current = party.music?.current;
if (!current) return;
const duration = current.track.durationSeconds;
const clampedSeconds = Math.max(0, duration != null ? Math.min(positionSeconds, duration) : positionSeconds);
const elapsedMs = clampedSeconds * 1000;
if (current.isPaused) {
current.pausedElapsedMs = elapsedMs;
} else {
// Must clear the existing timer first, not just overwrite it -
// scheduleAdvance only ever sets advanceTimer, it never clears a
// prior one itself (setPaused's own resume branch gets away with
// skipping this only because pausing already cleared it - see its
// own "if (isPaused)" branch). Skipping this here would leave the
// OLD, now-wrong-duration timer running alongside the new one,
// eventually firing playNext at the wrong time.
this.clearTimer(party);
current.startedUtc = Date.now() - elapsedMs;
this.scheduleAdvance(party);
}
party.lastActivityUtc = Date.now();
Logger.party(`musicSeek partyId=${party.id} -> ${clampedSeconds}s`);
this.broadcast(party);
}
/** /**
* Registers `userId`'s vote to skip the current track - a strict * Registers `userId`'s vote to skip the current track - a strict
* majority of `connectedMemberCount` (computed by the caller, which has * majority of `connectedMemberCount` (computed by the caller, which has

View file

@ -31,6 +31,11 @@ export interface ClientToServerEvents {
next: () => void; next: () => void;
voteSkip: () => void; voteSkip: () => void;
musicPlaystate: (msg: { isPaused: boolean }) => void; musicPlaystate: (msg: { isPaused: boolean }) => void;
/** No host check, same as "musicPlaystate" - see MusicQueueService's own
* class comment on why a seek gets the same permissive treatment as
* pause/resume (reversible, doesn't change what's playing) rather than
* "voteSkip"'s majority-vote gate (which does). */
musicSeek: (msg: { positionSeconds: number }) => void;
requestPause: () => void; requestPause: () => void;
requestResume: () => void; requestResume: () => void;
/** Host-only (checked server-side exactly like "next") - picks which /** Host-only (checked server-side exactly like "next") - picks which

View file

@ -144,6 +144,67 @@ describe('MusicQueueService', () => {
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
}); });
it('seek while paused jumps the frozen elapsed time directly, without needing a resume', () => {
const party = fakeParty('p4b');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s */ }, 'u-alice', 'Alice');
MusicQueueService.setPaused(party, true);
MusicQueueService.seek(party, 30);
assert.equal(music(party).current?.isPaused, true, 'seeking must not itself resume playback');
assert.equal(MusicQueueService.getElapsedSeconds(party), 30);
});
it('seek clamps into [0, duration] rather than trusting an out-of-range value verbatim', () => {
const party = fakeParty('p4c');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 100_000_000 /* 10s */ }, 'u-alice', 'Alice');
MusicQueueService.setPaused(party, true);
MusicQueueService.seek(party, 999);
assert.equal(MusicQueueService.getElapsedSeconds(party), 10, 'clamped to the track\'s own duration');
MusicQueueService.seek(party, -5);
assert.equal(MusicQueueService.getElapsedSeconds(party), 0, 'clamped to zero, not left negative');
});
it('seek while playing re-arms the auto-advance timer against the new position, not the old one', async () => {
const party = fakeParty('p4d');
// Exactly a 2s track (20_000_000 ticks / 10_000_000 per second - no
// rounding to worry about) - seeking to 1.7s leaves ~300ms remaining.
// If the timer were left un-re-armed (still counting down from the
// original full 2s mark instead), it would still be playing at the
// 600ms mark below - comfortably short of the original ~2000ms, so
// this genuinely distinguishes the two.
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 20_000_000 }, 'u-alice', 'Alice');
MusicQueueService.seek(party, 1.7);
assert.equal(music(party).current?.track.itemId, 't1', 'still the same track right after seeking');
await sleep(600); // past the re-armed ~300ms, comfortably short of the original ~2000ms
assert.equal(music(party).current, null, 'nothing queued behind it - should have auto-advanced off the re-armed timer');
});
it('seek while playing clears the old timer before scheduling a new one - never double-scheduled', (t) => {
const realClearTimeout = global.clearTimeout;
const cleared = mock.fn(realClearTimeout);
global.clearTimeout = cleared as typeof clearTimeout;
t.after(() => { global.clearTimeout = realClearTimeout; });
const party = fakeParty('p4e');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice');
MusicQueueService.seek(party, 10);
assert.equal(cleared.mock.callCount(), 1, 'the pre-seek timer must be cleared, not left running alongside the new one');
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
});
it('seek is a harmless no-op when nothing is currently playing', () => {
const party = fakeParty('p4f');
MusicQueueService.init(party, null, 'u-alice', 'Alice');
assert.doesNotThrow(() => MusicQueueService.seek(party, 30));
});
it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => { it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => {
const party = fakeParty('p5'); const party = fakeParty('p5');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice'); MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice');

View file

@ -56,7 +56,7 @@ function attach(io: ReturnType<typeof fakeIo>) {
} }
describe('SocketGateway', () => { describe('SocketGateway', () => {
it('voteSkip/musicPlaystate are no-ops on a video party', () => { it('voteSkip/musicPlaystate/musicSeek are no-ops on a video party', () => {
const io = fakeIo(); const io = fakeIo();
attach(io); attach(io);
const party = PartyService.create('u-host', 'Host', 'm1', 'Un Film', '/img/m1', null, 'video'); const party = PartyService.create('u-host', 'Host', 'm1', 'Un Film', '/img/m1', null, 'video');
@ -67,7 +67,8 @@ describe('SocketGateway', () => {
const before = io.roomEmits.length; const before = io.roomEmits.length;
socket.trigger('voteSkip'); socket.trigger('voteSkip');
socket.trigger('musicPlaystate', { isPaused: true }); socket.trigger('musicPlaystate', { isPaused: true });
assert.equal(io.roomEmits.length, before, 'neither event should produce any broadcast on a video party'); socket.trigger('musicSeek', { positionSeconds: 30 });
assert.equal(io.roomEmits.length, before, 'none of these events should produce any broadcast on a video party');
}); });
it('a non-host member can pause and resume a music party directly (no host check)', () => { it('a non-host member can pause and resume a music party directly (no host check)', () => {
@ -88,6 +89,23 @@ describe('SocketGateway', () => {
assert.equal(party.music?.current?.isPaused, false); assert.equal(party.music?.current?.isPaused, false);
}); });
it('a non-host member can seek a music party directly (no host check)', () => {
const io = fakeIo();
attach(io);
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice');
MusicQueueService.setPaused(party, true); // frozen elapsed time makes the resulting position deterministic below
PartyService.join(party, 'u-bob', 'Bob');
const bobSocket = fakeSocket('u-bob');
io.trigger('connection', bobSocket as unknown as AppSocket);
bobSocket.trigger('join', { partyId: party.id });
assert.notEqual(party.hostUserId, 'u-bob');
bobSocket.trigger('musicSeek', { positionSeconds: 30 });
assert.equal(MusicQueueService.getElapsedSeconds(party), 30);
});
it('voteSkip needs a majority of connected members and broadcasts queueUpdated on the party room', () => { it('voteSkip needs a majority of connected members and broadcasts queueUpdated on the party room', () => {
const io = fakeIo(); const io = fakeIo();
attach(io); attach(io);