Merge pull request 'Sticky catalog search bar, and let anyone seek within a music track' (#5) from feature/sticky-search-and-music-seek into master
This commit is contained in:
commit
b41437e23e
12 changed files with 272 additions and 18 deletions
|
|
@ -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
|
||||
overlapping the new bottom bar in fullscreen) in favor of fully
|
||||
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
|
||||
native marker detection (`EnableMarkerDetection` + its "Detect
|
||||
Episode Intros" task) rather than building fingerprinting
|
||||
|
|
|
|||
|
|
@ -140,6 +140,10 @@ export interface PartyConnection {
|
|||
voteSkip(): void;
|
||||
/** Music only: no host check server-side, unlike sendPlaystate above. */
|
||||
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
|
||||
* disconnect their other device and take over here - call this from
|
||||
* onDeviceConflict's handler once the user says yes. */
|
||||
|
|
@ -217,6 +221,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
|
|||
setMusicPaused(isPaused) {
|
||||
socket.emit('musicPlaystate', { isPaused });
|
||||
},
|
||||
seekMusic(positionSeconds) {
|
||||
socket.emit('musicSeek', { positionSeconds });
|
||||
},
|
||||
confirmDeviceTakeover() {
|
||||
socket.emit('join', { partyId, deviceId, force: true });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,18 +20,34 @@
|
|||
|
||||
/* A thin strip along the bar's own top edge, not inside .musicBarRow -
|
||||
the same "compact player" convention Spotify/YouTube Music use for
|
||||
their mini-players. Read-only (see MusicBar.ts's own comment) - no
|
||||
click/drag handling, just a visual position indicator. */
|
||||
their mini-players. Click/drag to seek (see MusicBar.ts) - the
|
||||
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 {
|
||||
position: relative;
|
||||
height: 3px;
|
||||
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 {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: var(--accent);
|
||||
transition: width .25s linear;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.musicBarRow {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@
|
|||
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Read-only - see MusicBar.ts's own comment on why this doesn't seek
|
||||
(no host to arbitrate a jump, and MusicQueueService has no "seek"
|
||||
concept server-side today - just play/pause/advance). -->
|
||||
<!-- Click/drag to seek - see MusicBar.ts's pointerdown/move/up handlers.
|
||||
No host check needed to enable this, unlike the video player's own
|
||||
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 class="musicBarRow">
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export function initMusicBar(root: HTMLElement): void {
|
|||
const toggleQueueBtn = barEl.querySelector('#toggleQueueBtn')!;
|
||||
const queuePanel = barEl.querySelector('#musicBarQueue')!;
|
||||
const leaveBtn = barEl.querySelector('#leaveBtn')!;
|
||||
const trackProgress = barEl.querySelector('#trackProgress') as HTMLElement;
|
||||
|
||||
activeDriver = localDriver(audio);
|
||||
|
||||
|
|
@ -109,6 +110,40 @@ export function initMusicBar(root: HTMLElement): void {
|
|||
toggleQueueBtn.addEventListener('click', () => queuePanel.classList.toggle('hide'));
|
||||
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
|
||||
// gated on anything party-related, same as Player.ts's own castBtn
|
||||
// (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
|
||||
// as Home's party-elapsed labels and the old MusicPlayer.ts - runs
|
||||
// continuously rather than only while a party is active (cheap: a
|
||||
// no-op while !currentItemId).
|
||||
positionTicker = window.setInterval(() => {
|
||||
// no-op while !currentItemId). Also called directly right after a local
|
||||
// seek (see seekFromPointerEvent above), for instant feedback instead
|
||||
// of waiting up to 500ms for this same tick to catch up.
|
||||
positionTicker = window.setInterval(updatePositionDisplay, 500);
|
||||
}
|
||||
|
||||
function updatePositionDisplay(): void {
|
||||
if (!currentItemId) return;
|
||||
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%';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,10 +4,18 @@
|
|||
/* base.css's global `input {}` rule already gives this the right look
|
||||
(padding/border/background) - this just bounds its width and spaces it
|
||||
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 {
|
||||
max-width: 320px;
|
||||
margin-bottom: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.searchEmpty { margin-top: 8px; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
|
||||
Asked for directly: propose skipping a TV episode's opening/end credits,
|
||||
|
|
|
|||
|
|
@ -283,6 +283,12 @@ class SocketGatewayClass {
|
|||
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
|
||||
// to pause; the presence overlay (the video player) is what triggers
|
||||
// this. Broadcast to the whole party, not just the host, so everyone
|
||||
|
|
|
|||
|
|
@ -92,6 +92,41 @@ class MusicQueueServiceClass {
|
|||
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
|
||||
* majority of `connectedMemberCount` (computed by the caller, which has
|
||||
|
|
|
|||
|
|
@ -31,6 +31,11 @@ export interface ClientToServerEvents {
|
|||
next: () => void;
|
||||
voteSkip: () => 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;
|
||||
requestResume: () => void;
|
||||
/** Host-only (checked server-side exactly like "next") - picks which
|
||||
|
|
|
|||
|
|
@ -144,6 +144,67 @@ describe('MusicQueueService', () => {
|
|||
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 () => {
|
||||
const party = fakeParty('p5');
|
||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice');
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function attach(io: ReturnType<typeof fakeIo>) {
|
|||
}
|
||||
|
||||
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();
|
||||
attach(io);
|
||||
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;
|
||||
socket.trigger('voteSkip');
|
||||
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)', () => {
|
||||
|
|
@ -88,6 +89,23 @@ describe('SocketGateway', () => {
|
|||
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', () => {
|
||||
const io = fakeIo();
|
||||
attach(io);
|
||||
|
|
|
|||
Loading…
Reference in a new issue