Add picture-in-picture to the video player #8
11 changed files with 407 additions and 46 deletions
|
|
@ -340,6 +340,10 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
dashboard sees watch-party streams the same way it sees a native
|
dashboard sees watch-party streams the same way it sees a native
|
||||||
client's — one session per member, under their own account — see
|
client's — one session per member, under their own account — see
|
||||||
docs/architecture.md
|
docs/architecture.md
|
||||||
|
- [x] Queue a whole album, or an artist's entire discography, in one
|
||||||
|
action — resolved server-side (one atomic operation, not one
|
||||||
|
request per track) the same way launching a playlist already
|
||||||
|
expands into everything it contains — see docs/architecture.md
|
||||||
- [x] Picture-in-picture for the video player (standard
|
- [x] Picture-in-picture for the video player (standard
|
||||||
`requestPictureInPicture`, confirmed present on Android Chrome) —
|
`requestPictureInPicture`, confirmed present on Android Chrome) —
|
||||||
asked for directly for mobile, where there's no second window to
|
asked for directly for mobile, where there's no second window to
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,21 @@ export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promis
|
||||||
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
|
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Music only: queues `itemId` into a party's shared queue, whatever it
|
||||||
|
* turns out to be - a single track queues itself, an album or an
|
||||||
|
* artist expands into every track inside it (see index.ts's
|
||||||
|
* resolveMusicTracksForItem) - the "add to queue" half of
|
||||||
|
* views/album's/views/artist's own whole-album/whole-discography
|
||||||
|
* button (createParty(itemId) is the other half, for when there's no
|
||||||
|
* party active yet - see Album.ts/Artist.ts). Distinct from
|
||||||
|
* addTrackToQueue above, which takes an already-built track object a
|
||||||
|
* caller has in hand rather than an id to resolve server-side.
|
||||||
|
*/
|
||||||
|
export function queueItem(partyId: string, itemId: string): Promise<PartyPublicState> {
|
||||||
|
return apiFetch(`/api/parties/${partyId}/queue`, { method: 'POST', body: JSON.stringify({ itemId }) });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mints a short-lived, party-scoped token a Chromecast can use to reach
|
* Mints a short-lived, party-scoped token a Chromecast can use to reach
|
||||||
* this party's own shared stream routes, which otherwise only accept the
|
* this party's own shared stream routes, which otherwise only accept the
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
|
/* .albumHeader's own two children - .seriesInfo (Series.css) and
|
||||||
|
.playlistActions (Playlist.css) - are reused as-is, same precedent
|
||||||
|
Album.css already set for .seriesInfo itself: every route's CSS ends
|
||||||
|
up in the one bundle regardless of which view imports it, so there's
|
||||||
|
no need to duplicate a rule this generic (a flex row of actions under
|
||||||
|
a title) just because it was first written for a different page. */
|
||||||
.albumHeader {
|
.albumHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,11 @@
|
||||||
<div class="seriesInfo">
|
<div class="seriesInfo">
|
||||||
<h1 id="albumTitle"></h1>
|
<h1 id="albumTitle"></h1>
|
||||||
<p class="hint hide" id="albumArtist"></p>
|
<p class="hint hide" id="albumArtist"></p>
|
||||||
|
<div class="playlistActions">
|
||||||
|
<button id="launchBtn">Écouter cet album</button>
|
||||||
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />
|
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un morceau..." />
|
||||||
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import './Album.css';
|
||||||
import '../../styles/trackList.css';
|
import '../../styles/trackList.css';
|
||||||
import html from './Album.html';
|
import html from './Album.html';
|
||||||
import { getAlbumTracks, getItem } from '../../api/embyApi';
|
import { getAlbumTracks, getItem } from '../../api/embyApi';
|
||||||
import { addTrackToQueue, createParty } from '../../api/party';
|
import { addTrackToQueue, createParty, queueItem } from '../../api/party';
|
||||||
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
|
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
|
||||||
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
|
import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles';
|
||||||
import { LOADING_HINT } from '../../loading';
|
import { LOADING_HINT } from '../../loading';
|
||||||
|
|
@ -42,9 +42,11 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
||||||
(root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl;
|
(root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl;
|
||||||
|
|
||||||
const listEl = root.querySelector('#trackList')!;
|
const listEl = root.querySelector('#trackList')!;
|
||||||
|
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
|
||||||
if (!tracks.length) {
|
if (!tracks.length) {
|
||||||
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
||||||
root.querySelector('#searchInput')!.remove();
|
root.querySelector('#searchInput')!.remove();
|
||||||
|
launchBtn.disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +58,8 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
||||||
// someone hit "Quitter" on the bar while this page was already open).
|
// someone hit "Quitter" on the bar while this page was already open).
|
||||||
const startLabel = 'Écouter ensemble';
|
const startLabel = 'Écouter ensemble';
|
||||||
const addLabel = 'Ajouter à la file';
|
const addLabel = 'Ajouter à la file';
|
||||||
|
const startAlbumLabel = 'Écouter cet album';
|
||||||
|
const addAlbumLabel = "Ajouter l'album à la file";
|
||||||
|
|
||||||
listEl.innerHTML = tracks.map((t) => `
|
listEl.innerHTML = tracks.map((t) => `
|
||||||
<div class="trackRow">
|
<div class="trackRow">
|
||||||
|
|
@ -77,14 +81,18 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-labels every track button from current state, not just the one
|
// Re-labels every track button (and the whole-album one) from current
|
||||||
// just clicked - starting a party from track 3 means tracks 1, 2 and 4
|
// state, not just the one just clicked - starting a party from track 3
|
||||||
// can now also be queued instead of starting a party of their own.
|
// means tracks 1, 2 and 4 can now also be queued instead of starting a
|
||||||
|
// party of their own.
|
||||||
function refreshLabels(): void {
|
function refreshLabels(): void {
|
||||||
|
const active = getActiveMusicPartyId();
|
||||||
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((b) => {
|
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((b) => {
|
||||||
if (!b.disabled) b.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
|
if (!b.disabled) b.textContent = active ? addLabel : startLabel;
|
||||||
});
|
});
|
||||||
|
if (!launchBtn.disabled) launchBtn.textContent = active ? addAlbumLabel : startAlbumLabel;
|
||||||
}
|
}
|
||||||
|
refreshLabels(); // sets launchBtn's own initial label - the per-track buttons already get theirs from the template above
|
||||||
|
|
||||||
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn, i) => {
|
listEl.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn, i) => {
|
||||||
const track = tracks[i] as ItemDto;
|
const track = tracks[i] as ItemDto;
|
||||||
|
|
@ -115,5 +123,32 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whole-album equivalent of the per-track buttons above - asked for
|
||||||
|
// directly, alongside the same action for a whole artist (see
|
||||||
|
// Artist.ts). itemId is the album itself, not any one track - the
|
||||||
|
// server expands it into every track inside, in order (see index.ts's
|
||||||
|
// resolveMusicTracksForItem/createMusicPartyFromTracks), the same way
|
||||||
|
// launching a playlist already expands into everything it contains.
|
||||||
|
launchBtn.addEventListener('click', async () => {
|
||||||
|
launchBtn.disabled = true;
|
||||||
|
const activePartyId = getActiveMusicPartyId();
|
||||||
|
try {
|
||||||
|
if (activePartyId) {
|
||||||
|
launchBtn.textContent = 'Ajout...';
|
||||||
|
await queueItem(activePartyId, albumId);
|
||||||
|
} else {
|
||||||
|
launchBtn.textContent = 'Démarrage...';
|
||||||
|
const party = await createParty(albumId);
|
||||||
|
joinMusicParty(party.partyId, party);
|
||||||
|
}
|
||||||
|
launchBtn.textContent = 'Ajouté !';
|
||||||
|
setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
alert((err as Error).message);
|
||||||
|
launchBtn.disabled = false;
|
||||||
|
refreshLabels();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
|
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<button class="linklike" id="back">← Retour</button>
|
<button class="linklike" id="back">← Retour</button>
|
||||||
<div class="pageHeader"><h1></h1></div>
|
<div class="pageHeader">
|
||||||
|
<h1></h1>
|
||||||
|
<button id="launchBtn">Écouter toute la discographie</button>
|
||||||
|
</div>
|
||||||
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
|
<input id="searchInput" class="searchBar" type="search" placeholder="Rechercher un album..." />
|
||||||
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
<p class="hint searchEmpty hide">Aucun résultat.</p>
|
||||||
<div id="grid" class="grid"></div>
|
<div id="grid" class="grid"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import html from './Artist.html';
|
import html from './Artist.html';
|
||||||
import { getArtistAlbums, getItem } from '../../api/embyApi';
|
import { getArtistAlbums, getItem } from '../../api/embyApi';
|
||||||
|
import { createParty, queueItem } from '../../api/party';
|
||||||
|
import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar';
|
||||||
import type { RouteParams } from '../../router';
|
import type { RouteParams } from '../../router';
|
||||||
import { tileHtml, wireSearchFilter } from '../../tiles';
|
import { tileHtml, wireSearchFilter } from '../../tiles';
|
||||||
import { LOADING_HINT } from '../../loading';
|
import { LOADING_HINT } from '../../loading';
|
||||||
|
|
@ -8,9 +10,10 @@ import { LOADING_HINT } from '../../loading';
|
||||||
* library's top-level grid (see views/library) via a MusicArtist tile's
|
* library's top-level grid (see views/library) via a MusicArtist tile's
|
||||||
* "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
|
* "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the
|
||||||
* same "detail header + grid" shape as views/series, just one level up
|
* same "detail header + grid" shape as views/series, just one level up
|
||||||
* the music hierarchy (Artist -> Album, not Series -> Season/Episode) and
|
* the music hierarchy (Artist -> Album, not Series -> Season/Episode).
|
||||||
* with no per-item action beyond drilling further into an album (an
|
* #launchBtn is the one action available at this level itself (every
|
||||||
* artist has nothing itself to start a party from). */
|
* album's own tracks, in one go) - drilling into a specific album is
|
||||||
|
* still the only way to queue less than the whole discography. */
|
||||||
export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
|
export async function renderArtist(root: HTMLElement, params: RouteParams): Promise<void> {
|
||||||
const artistId = params.id as string;
|
const artistId = params.id as string;
|
||||||
root.innerHTML = LOADING_HINT;
|
root.innerHTML = LOADING_HINT;
|
||||||
|
|
@ -28,12 +31,52 @@ export async function renderArtist(root: HTMLElement, params: RouteParams): Prom
|
||||||
root.querySelector('h1')!.textContent = artist.name;
|
root.querySelector('h1')!.textContent = artist.name;
|
||||||
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
||||||
|
|
||||||
|
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
|
||||||
const grid = root.querySelector('#grid')!;
|
const grid = root.querySelector('#grid')!;
|
||||||
if (!albums.length) {
|
if (!albums.length) {
|
||||||
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
grid.innerHTML = '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
||||||
root.querySelector('#searchInput')!.remove();
|
root.querySelector('#searchInput')!.remove();
|
||||||
|
launchBtn.disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
grid.innerHTML = albums.map(tileHtml).join('');
|
grid.innerHTML = albums.map(tileHtml).join('');
|
||||||
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
|
wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.tile');
|
||||||
|
|
||||||
|
// Label reflects whether a listen party is already active - same
|
||||||
|
// "harmless, occasionally-stale until the next refresh" reasoning as
|
||||||
|
// views/album's own equivalent buttons (the click handler below
|
||||||
|
// always re-checks getActiveMusicPartyId() fresh regardless).
|
||||||
|
const startLabel = 'Écouter toute la discographie';
|
||||||
|
const addLabel = 'Ajouter la discographie à la file';
|
||||||
|
function refreshLabel(): void {
|
||||||
|
if (!launchBtn.disabled) launchBtn.textContent = getActiveMusicPartyId() ? addLabel : startLabel;
|
||||||
|
}
|
||||||
|
refreshLabel();
|
||||||
|
|
||||||
|
// itemId is the artist itself - the server expands it into every
|
||||||
|
// album's tracks, in order, the same mechanism a whole-album launch
|
||||||
|
// uses one level down (see views/album/Album.ts, and index.ts's
|
||||||
|
// resolveMusicTracksForItem/createMusicPartyFromTracks).
|
||||||
|
launchBtn.addEventListener('click', async () => {
|
||||||
|
launchBtn.disabled = true;
|
||||||
|
const activePartyId = getActiveMusicPartyId();
|
||||||
|
try {
|
||||||
|
if (activePartyId) {
|
||||||
|
launchBtn.textContent = 'Ajout...';
|
||||||
|
await queueItem(activePartyId, artistId);
|
||||||
|
} else {
|
||||||
|
launchBtn.textContent = 'Démarrage...';
|
||||||
|
// Same "attach the bar, stay put" pattern as views/album - see
|
||||||
|
// its own comment.
|
||||||
|
const party = await createParty(artistId);
|
||||||
|
joinMusicParty(party.partyId, party);
|
||||||
|
}
|
||||||
|
launchBtn.textContent = 'Ajouté !';
|
||||||
|
setTimeout(() => { launchBtn.disabled = false; refreshLabel(); }, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
alert((err as Error).message);
|
||||||
|
launchBtn.disabled = false;
|
||||||
|
refreshLabel();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3404,6 +3404,82 @@ device/login reachable from this sandbox, same standing gap as this
|
||||||
project's other browser-native integrations (casting, the intro-skip
|
project's other browser-native integrations (casting, the intro-skip
|
||||||
buttons).
|
buttons).
|
||||||
|
|
||||||
|
## Queuing a whole album, or an artist's entire discography, in one action
|
||||||
|
|
||||||
|
Asked for directly: `views/album`'s and `views/artist`'s per-track/
|
||||||
|
per-album "Écouter ensemble"/"Ajouter à la file" buttons had no
|
||||||
|
whole-album or whole-discography equivalent - queuing an album meant
|
||||||
|
clicking every track in it one at a time, and an artist page had no
|
||||||
|
listen-party action at all beyond drilling into a specific album.
|
||||||
|
|
||||||
|
**Server-side resolution, not a client-side loop** - the same choice
|
||||||
|
already made for playlists (`createPartyFromPlaylist`), and for the
|
||||||
|
same reason: an artist's "entire discography" can genuinely be dozens
|
||||||
|
of albums and hundreds of tracks, and a client-side loop of individual
|
||||||
|
`addTrackToQueue` calls would mean that many sequential round trips
|
||||||
|
(slow, and non-atomic - a network hiccup partway through leaves a
|
||||||
|
partial queue) instead of one. New `resolveMusicTracksForItem(session,
|
||||||
|
item)` (`index.ts`) takes a raw Emby item already known to be
|
||||||
|
`Audio`/`MusicAlbum`/`MusicArtist` and resolves it to a flat, ordered
|
||||||
|
`MusicTrackInput[]` - a single track for `Audio`, every track in Emby's
|
||||||
|
own `IndexNumber` order for a `MusicAlbum`
|
||||||
|
(`CatalogService.getAlbumTracks`), every album's tracks (same order)
|
||||||
|
for a `MusicArtist`'s own albums (`getArtistAlbums`, itself already
|
||||||
|
sorted `ProductionYear,SortName` - i.e. chronological), fetched with
|
||||||
|
one `Promise.all` rather than sequentially. `POST /api/parties` gained
|
||||||
|
a `MusicAlbum`/`MusicArtist` branch, right alongside the existing
|
||||||
|
`Playlist` one, calling this and a new shared `createMusicPartyFromTracks`
|
||||||
|
- itself just `MusicQueueService.init` for the first track plus the new
|
||||||
|
`addTracks` (below) for the rest, extracted so `createPartyFromPlaylist`'s
|
||||||
|
own music branch could be simplified onto the exact same two calls
|
||||||
|
rather than keep its own separate per-track loop.
|
||||||
|
|
||||||
|
**`MusicQueueService.addTracks`** is the batch sibling of the existing
|
||||||
|
`addTrack` (both now share a `buildTrackEntry` helper for the actual
|
||||||
|
per-track shape) - same auto-starts-if-the-queue-was-empty behavior,
|
||||||
|
but one `queueUpdated` broadcast for the whole batch rather than one
|
||||||
|
per track. That distinction is what a naive "just call `addTrack` in a
|
||||||
|
loop" version would have gotten wrong: for a hundred-track discography,
|
||||||
|
that's a hundred intermediate broadcasts to everyone in the party for
|
||||||
|
what is, from a user's perspective, a single action.
|
||||||
|
|
||||||
|
**Adding to an already-live party's queue** needed its own new route,
|
||||||
|
`POST /api/parties/:partyId/queue` - given just an `itemId`, it resolves
|
||||||
|
it the same way (`resolveMusicTracksForItem`) and calls `addTracks`.
|
||||||
|
Kept separate from the existing `POST .../tracks`, which takes an
|
||||||
|
already-built track object a caller already has in hand (`views/album`'s
|
||||||
|
own per-track buttons, which never needed this resolution step at
|
||||||
|
all) - overloading that route to guess whether it received a full
|
||||||
|
track or a bare id to resolve would have been more surprising than a
|
||||||
|
second, clearly-named one.
|
||||||
|
|
||||||
|
**The buttons themselves** mirror `views/playlist`'s own "Lancer cette
|
||||||
|
playlist" exactly - same label-reflects-current-state-and-re-checks-on-
|
||||||
|
click discipline `views/album`'s per-track buttons already established,
|
||||||
|
same "attach the bar and stay on the page" behavior when starting fresh.
|
||||||
|
`views/album` gained one page-level button (`.playlistActions`, reused
|
||||||
|
from `Playlist.css` the same way `Album.css` already reuses `.seriesInfo`
|
||||||
|
from `Series.css` - one CSS bundle regardless of which view's file a
|
||||||
|
rule was first written in, so nothing needed duplicating). `views/artist`
|
||||||
|
gained its first listen-party action ever, sitting in `.pageHeader`
|
||||||
|
itself (its own `justify-content: space-between` already puts a second
|
||||||
|
child on the right of the title with no new CSS needed).
|
||||||
|
|
||||||
|
Verified: full `typecheck`/`test`/`build` on both projects (147/147
|
||||||
|
server tests - new coverage for `addTracks`: batch order preserved,
|
||||||
|
auto-start only fires for the batch's own first track when the queue
|
||||||
|
was empty, exactly one broadcast regardless of batch size, and a
|
||||||
|
harmless no-op for an empty batch). `resolveMusicTracksForItem`/
|
||||||
|
`createMusicPartyFromTracks` themselves are `index.ts` route glue, not
|
||||||
|
independently unit-tested - consistent with this project's existing
|
||||||
|
boundary that route-level orchestration isn't, only the services it
|
||||||
|
calls into are (same reasoning `createPartyFromPlaylist` was never
|
||||||
|
tested directly either). The two new buttons' layout was checked
|
||||||
|
visually against the real compiled CSS (a static render, real theme
|
||||||
|
tokens) rather than eyeballed. **Not verified**: an actual large
|
||||||
|
discography queuing correctly end to end against a real server - no
|
||||||
|
Emby login reachable from this sandbox.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { PlaystateService } from './services/PlaystateService';
|
||||||
import { EmbySessionService } from './services/EmbySessionService';
|
import { EmbySessionService } from './services/EmbySessionService';
|
||||||
import { CastTokenService } from './services/CastTokenService';
|
import { CastTokenService } from './services/CastTokenService';
|
||||||
import { SocketGateway } from './realtime/SocketGateway';
|
import { SocketGateway } from './realtime/SocketGateway';
|
||||||
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
|
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents } from './types';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
||||||
|
|
@ -345,6 +345,18 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Neither is itself streamable (same reason a Playlist above isn't) -
|
||||||
|
// launching one queues every track inside it instead: every album for
|
||||||
|
// an artist, every track for an album, in Emby's own sort order (see
|
||||||
|
// resolveMusicTracksForItem). Asked for directly, alongside the same
|
||||||
|
// action on the party's own existing queue - see the "queue" route
|
||||||
|
// below - both share this resolution.
|
||||||
|
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
|
||||||
|
const tracks = await resolveMusicTracksForItem(session, item);
|
||||||
|
createMusicPartyFromTracks(res, session, tracks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
|
const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
|
||||||
|
|
||||||
// Starting a party from an episode queues the rest of the series from
|
// Starting a party from an episode queues the rest of the series from
|
||||||
|
|
@ -432,33 +444,15 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
||||||
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
|
const items = startIndex > 0 ? allItems.slice(startIndex) : allItems;
|
||||||
|
|
||||||
if (mediaType === 'music') {
|
if (mediaType === 'music') {
|
||||||
const first = items[0]!;
|
const trackInputs: MusicTrackInput[] = items.map((track) => ({
|
||||||
const party = PartyService.create(
|
|
||||||
session.userId, session.userName,
|
|
||||||
first.Id, first.Name, imageUrlFor(first),
|
|
||||||
null, mediaType
|
|
||||||
);
|
|
||||||
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
|
||||||
// Same mechanism POST /api/parties/:id/tracks already uses (see
|
|
||||||
// below) - init seeds+auto-starts the first track, addTrack queues
|
|
||||||
// the rest, exactly as if they'd been added one at a time.
|
|
||||||
items.forEach((track, i) => {
|
|
||||||
const trackInput = {
|
|
||||||
itemId: track.Id,
|
itemId: track.Id,
|
||||||
itemName: track.Name,
|
itemName: track.Name,
|
||||||
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
artistName: track.AlbumArtist || track.Artists?.[0] || null,
|
||||||
albumName: track.Album || null,
|
albumName: track.Album || null,
|
||||||
itemImageUrl: imageUrlFor(track),
|
itemImageUrl: imageUrlFor(track),
|
||||||
durationTicks: track.RunTimeTicks || null
|
durationTicks: track.RunTimeTicks || null
|
||||||
};
|
}));
|
||||||
if (i === 0) {
|
createMusicPartyFromTracks(res, session, trackInputs);
|
||||||
MusicQueueService.init(party, trackInput, session.userId, session.userName);
|
|
||||||
} else {
|
|
||||||
MusicQueueService.addTrack(party, trackInput, session.userId, session.userName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
SocketGateway.broadcastLobby(io);
|
|
||||||
res.json(PartyService.toPublicState(party, session.userId));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -477,6 +471,73 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
||||||
res.json(PartyService.toPublicState(party, session.userId));
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
|
||||||
|
* or MusicArtist - the three POST /api/parties and the "queue" route
|
||||||
|
* below ever call this for) resolved into a flat, ordered track list -
|
||||||
|
* a single track for Audio, every track in Emby's own IndexNumber order
|
||||||
|
* for an album, every album's tracks in Emby's own ProductionYear/
|
||||||
|
* SortName order for an artist's whole discography. Two Emby round
|
||||||
|
* trips for an artist's own albums+tracks would be one call each if
|
||||||
|
* this app ever needed to page through a huge discography - not
|
||||||
|
* attempted speculatively, since Emby has yet to show any sign of
|
||||||
|
* needing it for a personal library's worth of artists. */
|
||||||
|
async function resolveMusicTracksForItem(session: EmbySession, item: EmbyItem): Promise<MusicTrackInput[]> {
|
||||||
|
if (item.Type === 'MusicAlbum') {
|
||||||
|
const tracks = await CatalogService.getAlbumTracks(session, item.Id);
|
||||||
|
return tracks.map(itemDtoToTrackInput);
|
||||||
|
}
|
||||||
|
if (item.Type === 'MusicArtist') {
|
||||||
|
const albums = await CatalogService.getArtistAlbums(session, item.Id);
|
||||||
|
const perAlbum = await Promise.all(albums.map((album) => CatalogService.getAlbumTracks(session, album.id)));
|
||||||
|
return perAlbum.flat().map(itemDtoToTrackInput);
|
||||||
|
}
|
||||||
|
// A single Audio track - the only other type this is ever called for.
|
||||||
|
return [{
|
||||||
|
itemId: item.Id,
|
||||||
|
itemName: item.Name,
|
||||||
|
artistName: item.AlbumArtist || item.Artists?.[0] || null,
|
||||||
|
albumName: item.Album || null,
|
||||||
|
itemImageUrl: imageUrlFor(item),
|
||||||
|
durationTicks: item.RunTimeTicks || null
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemDtoToTrackInput(item: ItemDto): MusicTrackInput {
|
||||||
|
return {
|
||||||
|
itemId: item.id,
|
||||||
|
itemName: item.name,
|
||||||
|
artistName: item.artistName,
|
||||||
|
albumName: item.albumName,
|
||||||
|
itemImageUrl: item.imageUrl,
|
||||||
|
durationTicks: item.durationTicks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared by every "launch this as a listen party" path that can produce
|
||||||
|
* more than one track up front (an album, an artist's discography, a
|
||||||
|
* music playlist - see createPartyFromPlaylist above and the
|
||||||
|
* MusicAlbum/MusicArtist branch of POST /api/parties below) - seeds
|
||||||
|
* party.music with `tracks`, in order: the first auto-starts,
|
||||||
|
* everything else queues behind it via MusicQueueService.addTracks
|
||||||
|
* (one broadcast for however many tracks that is, not one per track). */
|
||||||
|
function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks: MusicTrackInput[]): void {
|
||||||
|
if (!tracks.length) {
|
||||||
|
res.status(400).json({ error: 'Aucun morceau à lire.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [first, ...rest] = tracks;
|
||||||
|
const party = PartyService.create(
|
||||||
|
session.userId, session.userName,
|
||||||
|
first!.itemId, first!.itemName, first!.itemImageUrl,
|
||||||
|
null, 'music'
|
||||||
|
);
|
||||||
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||||
|
MusicQueueService.init(party, first!, session.userId, session.userName);
|
||||||
|
MusicQueueService.addTracks(party, rest, session.userId, session.userName);
|
||||||
|
SocketGateway.broadcastLobby(io);
|
||||||
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
|
}
|
||||||
|
|
||||||
// Adding a track to a music party's shared queue is REST, not a socket
|
// Adding a track to a music party's shared queue is REST, not a socket
|
||||||
// event (see SocketGateway's top comment): the album-browsing page that
|
// event (see SocketGateway's top comment): the album-browsing page that
|
||||||
// this is actually called from has no live connection to the party it's
|
// this is actually called from has no live connection to the party it's
|
||||||
|
|
@ -505,6 +566,39 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
|
||||||
res.json(PartyService.toPublicState(party, session.userId));
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Same "queue this item" resolution as the MusicAlbum/MusicArtist branch
|
||||||
|
// of POST /api/parties above (a track queues itself; an album/artist
|
||||||
|
// expands into every track inside it - see resolveMusicTracksForItem),
|
||||||
|
// but adding into an ALREADY-LIVE party's queue instead of creating a
|
||||||
|
// new one - views/album's and views/artist's own "add to queue" buttons.
|
||||||
|
// Kept as its own route rather than overloading POST .../tracks above:
|
||||||
|
// that one takes an already-built track object the caller already has
|
||||||
|
// in hand (views/album's per-track buttons) - making it also guess
|
||||||
|
// whether it received that or a bare id-to-resolve would be more
|
||||||
|
// surprising than a second, clearly-named route.
|
||||||
|
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
|
if (!party || party.mediaType !== 'music') {
|
||||||
|
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = req.embySession!;
|
||||||
|
if (!party.members.has(session.userId)) {
|
||||||
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const itemId = req.body?.itemId;
|
||||||
|
if (!itemId) {
|
||||||
|
res.status(400).json({ error: 'itemId is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = await CatalogService.getRawItem(session, itemId);
|
||||||
|
const tracks = await resolveMusicTracksForItem(session, item);
|
||||||
|
MusicQueueService.addTracks(party, tracks, session.userId, session.userName);
|
||||||
|
SocketGateway.broadcastLobby(io); // the queue/now-playing preview may have just changed
|
||||||
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
|
}));
|
||||||
|
|
||||||
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||||
const party = PartyService.getById(param(req, 'partyId'));
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
if (!party) {
|
if (!party) {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,42 @@ class MusicQueueServiceClass {
|
||||||
|
|
||||||
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
|
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
|
||||||
const music = party.music!;
|
const music = party.music!;
|
||||||
const entry: MusicTrack = {
|
const entry = this.buildTrackEntry(track, addedByUserId, addedByUserName);
|
||||||
|
music.queue.push(entry);
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
|
||||||
|
if (!music.current) {
|
||||||
|
this.playNext(party); // queue was empty - this track starts immediately
|
||||||
|
}
|
||||||
|
this.broadcast(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same per-track behavior as addTrack (auto-starts if the queue was
|
||||||
|
* empty when the first of these lands), but ONE broadcast at the end
|
||||||
|
* instead of one per track - asked for directly, to queue a whole
|
||||||
|
* album or an artist's entire discography in one action rather than
|
||||||
|
* one track at a time. Without this, a hundred-track discography
|
||||||
|
* would spam the party with a hundred intermediate "queueUpdated"
|
||||||
|
* events for what's really a single user action - see index.ts's
|
||||||
|
* createMusicPartyFromTracks/the "queue" route, the two callers.
|
||||||
|
*/
|
||||||
|
addTracks(party: Party, tracks: MusicTrackInput[], addedByUserId: string, addedByUserName: string): void {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
const music = party.music!;
|
||||||
|
for (const track of tracks) {
|
||||||
|
music.queue.push(this.buildTrackEntry(track, addedByUserId, addedByUserName));
|
||||||
|
if (!music.current) {
|
||||||
|
this.playNext(party);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
Logger.party(`musicQueueAddBatch partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} count=${tracks.length} queueLength=${music.queue.length}`);
|
||||||
|
this.broadcast(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTrackEntry(track: MusicTrackInput, addedByUserId: string, addedByUserName: string): MusicTrack {
|
||||||
|
return {
|
||||||
itemId: track.itemId,
|
itemId: track.itemId,
|
||||||
itemName: track.itemName,
|
itemName: track.itemName,
|
||||||
artistName: track.artistName || null,
|
artistName: track.artistName || null,
|
||||||
|
|
@ -64,13 +99,6 @@ class MusicQueueServiceClass {
|
||||||
addedByUserId,
|
addedByUserId,
|
||||||
addedByUserName
|
addedByUserName
|
||||||
};
|
};
|
||||||
music.queue.push(entry);
|
|
||||||
party.lastActivityUtc = Date.now();
|
|
||||||
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
|
|
||||||
if (!music.current) {
|
|
||||||
this.playNext(party); // queue was empty - this track starts immediately
|
|
||||||
}
|
|
||||||
this.broadcast(party);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No host check - anyone can toggle this (see class comment). */
|
/** No host check - anyone can toggle this (see class comment). */
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,60 @@ describe('MusicQueueService', () => {
|
||||||
assert.equal(music(party).queue.length, 1);
|
assert.equal(music(party).queue.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('addTracks queues a whole batch (an album, a discography) in the given order, behind whatever is already playing', () => {
|
||||||
|
const party = fakeParty('p2b');
|
||||||
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||||
|
|
||||||
|
MusicQueueService.addTracks(party, [
|
||||||
|
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 },
|
||||||
|
{ itemId: 't3', itemName: 'Track Three', itemImageUrl: '/img/t3', durationTicks: 30_000_000 }
|
||||||
|
], 'u-bob', 'Bob');
|
||||||
|
|
||||||
|
assert.equal(music(party).current?.track.itemId, 't1', 'still the track already playing');
|
||||||
|
assert.deepEqual(music(party).queue.map((t) => t.itemId), ['t2', 't3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTracks auto-starts the first track (and only the first) when the queue was empty', () => {
|
||||||
|
const party = fakeParty('p2c');
|
||||||
|
MusicQueueService.init(party, null, 'u-alice', 'Alice'); // empty queue - nothing playing yet
|
||||||
|
|
||||||
|
MusicQueueService.addTracks(party, [
|
||||||
|
{ itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 },
|
||||||
|
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 }
|
||||||
|
], 'u-alice', 'Alice');
|
||||||
|
|
||||||
|
assert.equal(music(party).current?.track.itemId, 't1', 'the batch\'s own first track starts playing');
|
||||||
|
assert.deepEqual(music(party).queue.map((t) => t.itemId), ['t2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTracks broadcasts once for the whole batch, not once per track', () => {
|
||||||
|
const party = fakeParty('p2d');
|
||||||
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||||
|
let broadcasts = 0;
|
||||||
|
// Same "attach a fresh fake io" pattern socketGateway.test.ts's own
|
||||||
|
// attach() helper uses - MusicQueueService is a singleton, but every
|
||||||
|
// test that cares about broadcasts re-attaches its own fake at the
|
||||||
|
// start rather than resetting afterward, and every test that doesn't
|
||||||
|
// care never looks at this.io's side effects at all.
|
||||||
|
MusicQueueService.attachIo({ to: () => ({ emit: () => { broadcasts++; } }) } as unknown as Parameters<typeof MusicQueueService.attachIo>[0]);
|
||||||
|
|
||||||
|
MusicQueueService.addTracks(party, [
|
||||||
|
{ itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 20_000_000 },
|
||||||
|
{ itemId: 't3', itemName: 'Track Three', itemImageUrl: '/img/t3', durationTicks: 30_000_000 },
|
||||||
|
{ itemId: 't4', itemName: 'Track Four', itemImageUrl: '/img/t4', durationTicks: 40_000_000 }
|
||||||
|
], 'u-bob', 'Bob');
|
||||||
|
|
||||||
|
assert.equal(broadcasts, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTracks is a harmless no-op for an empty batch', () => {
|
||||||
|
const party = fakeParty('p2e');
|
||||||
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => MusicQueueService.addTracks(party, [], 'u-alice', 'Alice'));
|
||||||
|
assert.equal(music(party).queue.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('voteSkip needs a strict majority of connected members, not any count', () => {
|
it('voteSkip needs a strict majority of connected members, not any count', () => {
|
||||||
const party = fakeParty('p3');
|
const party = fakeParty('p3');
|
||||||
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue