Compare commits
6 commits
feat/stati
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ceaa2e050 | |||
| 1a10e5bf57 | |||
| 363cf44ebe | |||
| 56f969ba78 | |||
| b0d2100210 | |||
| db3cf68843 |
16 changed files with 274 additions and 39 deletions
|
|
@ -55,13 +55,16 @@ jobs:
|
||||||
# node:20-alpine n'a pas git préinstallé - même approche de clone
|
# node:20-alpine n'a pas git préinstallé - même approche de clone
|
||||||
# manuel que le job publish (pas d'actions/checkout, action JS qui a
|
# manuel que le job publish (pas d'actions/checkout, action JS qui a
|
||||||
# ses propres soucis dans les conteneurs Alpine minimalistes ici).
|
# ses propres soucis dans les conteneurs Alpine minimalistes ici).
|
||||||
|
# Sur une PR, `ref_name` vaut le numéro de la PR (ex. `29`), qui n'est
|
||||||
|
# pas une ref récupérable ("couldn't find remote ref 29") : on récupère
|
||||||
|
# alors la branche source de la PR (`head_ref`, vide hors PR).
|
||||||
- name: Récupérer les sources
|
- name: Récupérer les sources
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
apk add --no-cache git
|
apk add --no-cache git
|
||||||
git init -q
|
git init -q
|
||||||
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
git fetch -q --depth 1 origin "${{ github.ref_name }}"
|
git fetch -q --depth 1 origin "${{ github.head_ref || github.ref_name }}"
|
||||||
git checkout -q FETCH_HEAD
|
git checkout -q FETCH_HEAD
|
||||||
|
|
||||||
- name: Serveur - installer les dépendances
|
- name: Serveur - installer les dépendances
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ export interface LobbyPartyDto {
|
||||||
export interface StaticPartySummaryDto {
|
export interface StaticPartySummaryDto {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
sourceType: 'series' | 'playlist';
|
sourceType: 'series' | 'season' | 'playlist';
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
groupWatchedCount: number;
|
groupWatchedCount: number;
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
|
||||||
if (items[0]!.type !== 'Audio') {
|
if (items[0]!.type !== 'Audio') {
|
||||||
const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement;
|
const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement;
|
||||||
createStaticBtn.classList.remove('hide');
|
createStaticBtn.classList.remove('hide');
|
||||||
wireCreateStaticButton(createStaticBtn, playlistId);
|
wireCreateStaticButton(createStaticBtn, () => playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
listEl.innerHTML = items.map((item, i) => {
|
listEl.innerHTML = items.map((item, i) => {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@
|
||||||
|
|
||||||
.seriesPoster.noImage { visibility: hidden; }
|
.seriesPoster.noImage { visibility: hidden; }
|
||||||
|
|
||||||
.seriesActions {
|
.seriesActions,
|
||||||
|
.seasonActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -25,6 +26,11 @@
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The selected season's own launch buttons, between the tabs and its
|
||||||
|
episodes. */
|
||||||
|
.seasonActions { margin: 0 0 16px; }
|
||||||
|
.seasonActions button { padding: 8px 14px; font-size: 13px; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.seriesHeader { flex-direction: column; }
|
.seriesHeader { flex-direction: column; }
|
||||||
.seriesPoster { width: 120px; }
|
.seriesPoster { width: 120px; }
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,15 @@
|
||||||
<h1 id="seriesTitle"></h1>
|
<h1 id="seriesTitle"></h1>
|
||||||
<p class="seriesOverview hide" id="seriesOverview"></p>
|
<p class="seriesOverview hide" id="seriesOverview"></p>
|
||||||
<div class="seriesActions">
|
<div class="seriesActions">
|
||||||
<button id="createStaticBtn">Créer une lounge party</button>
|
<button id="launchSeriesBtn">Lancer toute la série</button>
|
||||||
|
<button class="secondaryBtn" id="createStaticBtn">Créer une lounge party</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>
|
</div>
|
||||||
<nav id="seasonTabs" class="tabs"></nav>
|
<nav id="seasonTabs" class="tabs"></nav>
|
||||||
|
<div class="seasonActions hide" id="seasonActions">
|
||||||
|
<button id="launchSeasonBtn">Lancer cette saison</button>
|
||||||
|
<button class="secondaryBtn" id="createSeasonStaticBtn">Lounge party de cette saison</button>
|
||||||
|
</div>
|
||||||
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>
|
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,26 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
|
||||||
overviewEl.classList.remove('hide');
|
overviewEl.classList.remove('hide');
|
||||||
}
|
}
|
||||||
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
|
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
|
||||||
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, seriesId);
|
|
||||||
|
// Whole series: a watch party over every season from S1E1, or a lounge
|
||||||
|
// party over the same (see the server's seriesQueueFrom).
|
||||||
|
wireLaunchButton(root.querySelector('#launchSeriesBtn') as HTMLButtonElement, () => seriesId);
|
||||||
|
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, () => seriesId);
|
||||||
|
|
||||||
if (!seasons.length) {
|
if (!seasons.length) {
|
||||||
|
(root.querySelector('#launchSeriesBtn') as HTMLButtonElement).disabled = true;
|
||||||
|
(root.querySelector('#createStaticBtn') as HTMLButtonElement).disabled = true;
|
||||||
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
|
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Just the selected season - both buttons read whichever tab is active
|
||||||
|
// at click time, so they're wired once rather than per tab switch.
|
||||||
|
let selectedSeasonId = seasons[0]!.id;
|
||||||
|
root.querySelector('#seasonActions')!.classList.remove('hide');
|
||||||
|
wireLaunchButton(root.querySelector('#launchSeasonBtn') as HTMLButtonElement, () => selectedSeasonId);
|
||||||
|
wireCreateStaticButton(root.querySelector('#createSeasonStaticBtn') as HTMLButtonElement, () => selectedSeasonId);
|
||||||
|
|
||||||
const tabsEl = root.querySelector('#seasonTabs')!;
|
const tabsEl = root.querySelector('#seasonTabs')!;
|
||||||
tabsEl.innerHTML = seasons
|
tabsEl.innerHTML = seasons
|
||||||
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
|
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
|
||||||
|
|
@ -52,13 +65,35 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
|
||||||
if (tab.classList.contains('active')) return;
|
if (tab.classList.contains('active')) return;
|
||||||
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
||||||
tab.classList.add('active');
|
tab.classList.add('active');
|
||||||
loadEpisodes(root, seriesId, tab.dataset.seasonId as string);
|
selectedSeasonId = tab.dataset.seasonId as string;
|
||||||
|
loadEpisodes(root, seriesId, selectedSeasonId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
loadEpisodes(root, seriesId, seasons[0]!.id);
|
loadEpisodes(root, seriesId, seasons[0]!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A button that launches a watch party over whatever `itemId()` returns
|
||||||
|
* at click time - the series (every season) or the selected season (see
|
||||||
|
* the server's POST /api/parties Series/Season branches) - then opens it.
|
||||||
|
*/
|
||||||
|
function wireLaunchButton(btn: HTMLButtonElement, itemId: () => string): void {
|
||||||
|
const idleLabel = btn.textContent || '';
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Démarrage...';
|
||||||
|
createParty(itemId())
|
||||||
|
.then((party) => navigate(`/party/${party.partyId}`))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error('[series] launch failed', err);
|
||||||
|
showToast((err as Error).message, { variant: 'error' });
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = idleLabel;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
|
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
|
||||||
const listEl = root.querySelector('#episodeList')!;
|
const listEl = root.querySelector('#episodeList')!;
|
||||||
listEl.innerHTML = LOADING_HINT;
|
listEl.innerHTML = LOADING_HINT;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import html from './StaticParties.html';
|
||||||
import { connectLobby } from '../../api/party';
|
import { connectLobby } from '../../api/party';
|
||||||
import { listStaticParties } from '../../api/staticParty';
|
import { listStaticParties } from '../../api/staticParty';
|
||||||
import { escapeHtml } from '../../tiles';
|
import { escapeHtml } from '../../tiles';
|
||||||
|
import { SOURCE_LABELS } from '../staticParty/StaticParty';
|
||||||
import { LOADING_HINT } from '../../loading';
|
import { LOADING_HINT } from '../../loading';
|
||||||
import type { StaticPartySummaryDto } from '../../types';
|
import type { StaticPartySummaryDto } from '../../types';
|
||||||
|
|
||||||
|
|
@ -64,7 +65,7 @@ function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
|
||||||
<img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
<img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
<div class="staticCardInfo">
|
<div class="staticCardInfo">
|
||||||
<strong>${escapeHtml(p.name)}</strong>
|
<strong>${escapeHtml(p.name)}</strong>
|
||||||
<span class="hint">${p.sourceType === 'series' ? 'Série' : 'Playlist'} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
|
<span class="hint">${SOURCE_LABELS[p.sourceType]} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
|
||||||
<div class="staticCardBar"><div style="width:${percent}%"></div></div>
|
<div class="staticCardBar"><div style="width:${percent}%"></div></div>
|
||||||
${p.livePartyId
|
${p.livePartyId
|
||||||
? '<span class="staticCardLive">● Séance en cours</span>'
|
? '<span class="staticCardLive">● Séance en cours</span>'
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ import { LOADING_HINT } from '../../loading';
|
||||||
import { showToast } from '../../toast';
|
import { showToast } from '../../toast';
|
||||||
import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types';
|
import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types';
|
||||||
|
|
||||||
|
/** How each lounge party source is named in the UI - shared with the
|
||||||
|
* "Lounge parties" tab (views/staticParties). */
|
||||||
|
export const SOURCE_LABELS: Record<StaticPartyDetailDto['sourceType'], string> = {
|
||||||
|
series: 'Série',
|
||||||
|
season: 'Saison',
|
||||||
|
playlist: 'Playlist'
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route `/static/:id` - one static (persistent) watch party: the group's
|
* Route `/static/:id` - one static (persistent) watch party: the group's
|
||||||
* progress through the whole series/playlist, its members, and - the
|
* progress through the whole series/playlist, its members, and - the
|
||||||
|
|
@ -61,7 +69,7 @@ async function reload(root: HTMLElement, staticId: string): Promise<void> {
|
||||||
function render(root: HTMLElement, detail: StaticPartyDetailDto): void {
|
function render(root: HTMLElement, detail: StaticPartyDetailDto): void {
|
||||||
(root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl;
|
(root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl;
|
||||||
root.querySelector('#staticTitle')!.textContent = detail.name;
|
root.querySelector('#staticTitle')!.textContent = detail.name;
|
||||||
const sourceLabel = detail.sourceType === 'series' ? 'Série' : 'Playlist';
|
const sourceLabel = SOURCE_LABELS[detail.sourceType];
|
||||||
root.querySelector('#staticMeta')!.textContent =
|
root.querySelector('#staticMeta')!.textContent =
|
||||||
`${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`;
|
`${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`;
|
||||||
const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0;
|
const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0;
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,17 @@ import { navigate } from '../../router';
|
||||||
import { showToast } from '../../toast';
|
import { showToast } from '../../toast';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Créer une lounge party" - shared by views/series and
|
* "Créer une lounge party" - shared by views/series (whole series, or
|
||||||
* views/playlist: creates a static watch party over `sourceId` (a series
|
* the selected season) and views/playlist: creates a lounge party over
|
||||||
* or a video playlist - the server decides which, and refuses anything
|
* the source id (a series, a season or a video playlist - the server
|
||||||
* else with a message) and opens its page.
|
* decides which, and refuses anything else with a message) and opens its
|
||||||
|
* page. `sourceId` is read at click time, so a caller whose source can
|
||||||
|
* change (the selected season tab) wires the button only once.
|
||||||
*/
|
*/
|
||||||
export function wireCreateStaticButton(btn: HTMLButtonElement, sourceId: string): void {
|
export function wireCreateStaticButton(btn: HTMLButtonElement, getSourceId: () => string): void {
|
||||||
const idleLabel = btn.textContent || '';
|
const idleLabel = btn.textContent || '';
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
|
const sourceId = getSourceId();
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Création...';
|
btn.textContent = 'Création...';
|
||||||
createStaticParty(sourceId)
|
createStaticParty(sourceId)
|
||||||
|
|
|
||||||
|
|
@ -851,6 +851,23 @@ player against the new `itemId` on that message. A movie or a
|
||||||
standalone-started episode just gets a one-item queue, so this is a strict
|
standalone-started episode just gets a one-item queue, so this is a strict
|
||||||
superset of the old single-item behavior, not a separate code path.
|
superset of the old single-item behavior, not a separate code path.
|
||||||
|
|
||||||
|
**Later: a whole series or one season, not just "from this episode".**
|
||||||
|
`POST /api/parties` also accepts a `Series` id and a `Season` id, not
|
||||||
|
only an episode.
|
||||||
|
|
||||||
|
- **Series**: queues every season from S1E1. Specials (season 0) sort
|
||||||
|
first in Emby's list and are left out, so launching the whole series
|
||||||
|
builds the same queue as clicking S1E1. This is the pure
|
||||||
|
`CatalogService.seriesQueueFrom`, which the episode path now uses too.
|
||||||
|
- **Season**: queues only that season's episodes
|
||||||
|
(`CatalogService.seasonQueue`).
|
||||||
|
- **Where it's launched from**: the series page's "Lancer toute la
|
||||||
|
série" / "Lancer cette saison" buttons. A series tile in a library
|
||||||
|
also gets the normal "Lancer une watch party" button: `Series` joined
|
||||||
|
`WATCH_PARTY_TYPES`.
|
||||||
|
- **Lounge parties** take the same two sources (a whole series or one
|
||||||
|
season). See spec/static-watch-party.md.
|
||||||
|
|
||||||
### Player: language, subtitles, quality
|
### Player: language, subtitles, quality
|
||||||
|
|
||||||
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s
|
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Server as SocketIoServer } from 'socket.io';
|
||||||
import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config';
|
import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config';
|
||||||
import { Logger } from './Logger';
|
import { Logger } from './Logger';
|
||||||
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
|
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
|
||||||
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService';
|
import { CatalogService, CatalogFetchError, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from './services/CatalogService';
|
||||||
import { PartyService } from './services/PartyService';
|
import { PartyService } from './services/PartyService';
|
||||||
import { MusicQueueService } from './services/MusicQueueService';
|
import { MusicQueueService } from './services/MusicQueueService';
|
||||||
import { TranscodeService } from './services/TranscodeService';
|
import { TranscodeService } from './services/TranscodeService';
|
||||||
|
|
@ -356,6 +356,20 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
// resolveMusicTracksForItem). Asked for directly, alongside the same
|
// resolveMusicTracksForItem). Asked for directly, alongside the same
|
||||||
// action on the party's own existing queue - see the "queue" route
|
// action on the party's own existing queue - see the "queue" route
|
||||||
// below - both share this resolution.
|
// below - both share this resolution.
|
||||||
|
// A whole series - every season, in order, from S1E1 (see
|
||||||
|
// seriesQueueFrom). Launched from the series page's own button or its
|
||||||
|
// library tile; an episode's own button still starts from that episode
|
||||||
|
// (the Episode branch below).
|
||||||
|
if (item.Type === 'Series') {
|
||||||
|
await createPartyFromSeries(res, session, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Just one season - the series page's per-season button (see seasonQueue).
|
||||||
|
if (item.Type === 'Season') {
|
||||||
|
await createPartyFromSeason(res, session, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
|
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
|
||||||
const tracks = await resolveMusicTracksForItem(session, item);
|
const tracks = await resolveMusicTracksForItem(session, item);
|
||||||
createMusicPartyFromTracks(res, session, tracks);
|
createMusicPartyFromTracks(res, session, tracks);
|
||||||
|
|
@ -372,9 +386,8 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
let queue: QueueItem[] | null = null;
|
let queue: QueueItem[] | null = null;
|
||||||
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
||||||
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
|
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
|
||||||
const startIndex = all.findIndex((e) => e.Id === itemId);
|
const remaining = seriesQueueFrom(all, itemId);
|
||||||
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
|
queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem);
|
||||||
queue = remaining.map(toVideoQueueItem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const party = PartyService.create(
|
const party = PartyService.create(
|
||||||
|
|
@ -479,6 +492,45 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
||||||
res.json(PartyService.toPublicState(party, session.userId));
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The "launch the whole series" half of POST /api/parties - a watch
|
||||||
|
* party queued with every episode of every season (see seriesQueueFrom). */
|
||||||
|
async function createPartyFromSeries(res: Response, session: EmbySession, series: EmbyItem): Promise<void> {
|
||||||
|
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, series.Id);
|
||||||
|
const queue = seriesQueueFrom(episodes).map(toVideoQueueItem);
|
||||||
|
if (!queue.length) {
|
||||||
|
res.status(400).json({ error: "Cette série n'a aucun épisode." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createVideoPartyFromQueue(res, session, queue, `series="${series.Name}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "launch just this season" half of POST /api/parties - only that
|
||||||
|
* season's episodes, nothing after it (see seasonQueue). */
|
||||||
|
async function createPartyFromSeason(res: Response, session: EmbySession, season: EmbyItem): Promise<void> {
|
||||||
|
if (!season.SeriesId) {
|
||||||
|
res.status(400).json({ error: 'Saison introuvable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, season.SeriesId);
|
||||||
|
const queue = seasonQueue(episodes, season).map(toVideoQueueItem);
|
||||||
|
if (!queue.length) {
|
||||||
|
res.status(400).json({ error: "Cette saison n'a aucun épisode." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createVideoPartyFromQueue(res, session, queue, `season="${season.Name}" series=${season.SeriesId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a video watch party playing `queue` in order, hosted by
|
||||||
|
* `session` - shared by the whole-series and single-season launches. */
|
||||||
|
function createVideoPartyFromQueue(res: Response, session: EmbySession, queue: QueueItem[], logContext: string): void {
|
||||||
|
const first = queue[0]!;
|
||||||
|
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
|
||||||
|
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
|
||||||
|
Logger.party(`queue launch partyId=${party.id} ${logContext} episodes=${queue.length}`);
|
||||||
|
SocketGateway.broadcastLobby(io);
|
||||||
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
|
}
|
||||||
|
|
||||||
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
|
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
|
||||||
* or MusicArtist - the three POST /api/parties and the "queue" route
|
* or MusicArtist - the three POST /api/parties and the "queue" route
|
||||||
* below ever call this for) resolved into a flat, ordered track list -
|
* below ever call this for) resolved into a flat, ordered track list -
|
||||||
|
|
@ -682,12 +734,18 @@ app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
|
||||||
// StaticPartyService and spec/static-watch-party.md. Each session is an
|
// StaticPartyService and spec/static-watch-party.md. Each session is an
|
||||||
// ordinary live party (PartyService) linked back through party.staticLink.
|
// ordinary live party (PartyService) linked back through party.staticLink.
|
||||||
|
|
||||||
/** Fetches a static party's source (every episode of the series, or the
|
/** Fetches a static party's source under `session`'s own account - so
|
||||||
* playlist's video items) under `session`'s own account - so each item
|
* each item carries *their* Emby UserData (Played): the whole series
|
||||||
* carries *their* Emby UserData (Played). */
|
* from S1E1 (specials left out - see seriesQueueFrom), one season's
|
||||||
|
* episodes, or a playlist's video items. */
|
||||||
async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> {
|
async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> {
|
||||||
if (source.type === 'series') {
|
if (source.type === 'series') {
|
||||||
return CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId);
|
return seriesQueueFrom(await CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId));
|
||||||
|
}
|
||||||
|
if (source.type === 'season') {
|
||||||
|
if (!source.seriesId) return [];
|
||||||
|
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, source.seriesId);
|
||||||
|
return seasonQueue(episodes, { Id: source.sourceId, IndexNumber: source.seasonNumber });
|
||||||
}
|
}
|
||||||
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
|
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
|
||||||
return mediaType === 'video' ? items : [];
|
return mediaType === 'video' ? items : [];
|
||||||
|
|
@ -743,28 +801,38 @@ app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
const session = req.embySession!;
|
const session = req.embySession!;
|
||||||
const item = await CatalogService.getRawItem(session, sourceId);
|
const item = await CatalogService.getRawItem(session, sourceId);
|
||||||
|
|
||||||
// An episode stands for its whole series - the natural entry point from
|
// A whole series, one season of it, or a video playlist. An episode
|
||||||
|
// stands for its whole series - the natural entry point from
|
||||||
// views/series is the series itself, but nothing's lost accepting one.
|
// views/series is the series itself, but nothing's lost accepting one.
|
||||||
|
// `sourceItem` is what gives the default name and the artwork: the
|
||||||
|
// series itself for a season (a season's own poster is often missing).
|
||||||
let source: StaticParty['source'];
|
let source: StaticParty['source'];
|
||||||
let sourceItem = item;
|
let sourceItem = item;
|
||||||
|
let defaultName = item.Name;
|
||||||
if (item.Type === 'Series') {
|
if (item.Type === 'Series') {
|
||||||
source = { type: 'series', sourceId: item.Id };
|
source = { type: 'series', sourceId: item.Id };
|
||||||
|
} else if (item.Type === 'Season' && item.SeriesId) {
|
||||||
|
source = { type: 'season', sourceId: item.Id, seriesId: item.SeriesId, seasonNumber: item.IndexNumber };
|
||||||
|
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
|
||||||
|
defaultName = `${sourceItem.Name} - ${item.Name}`;
|
||||||
} else if (item.Type === 'Episode' && item.SeriesId) {
|
} else if (item.Type === 'Episode' && item.SeriesId) {
|
||||||
source = { type: 'series', sourceId: item.SeriesId };
|
source = { type: 'series', sourceId: item.SeriesId };
|
||||||
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
|
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
|
||||||
|
defaultName = sourceItem.Name;
|
||||||
} else if (item.Type === 'Playlist') {
|
} else if (item.Type === 'Playlist') {
|
||||||
source = { type: 'playlist', sourceId: item.Id };
|
source = { type: 'playlist', sourceId: item.Id };
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({ error: 'Une lounge party se crée depuis une série ou une playlist vidéo.' });
|
res.status(400).json({ error: 'Une lounge party se crée depuis une série, une saison ou une playlist vidéo.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = await fetchStaticSourceItems(session, source);
|
const raw = await fetchStaticSourceItems(session, source);
|
||||||
if (!raw.length) {
|
if (!raw.length) {
|
||||||
res.status(400).json({ error: source.type === 'playlist' ? 'Cette playlist ne contient aucune vidéo.' : "Cette série n'a aucun épisode." });
|
const emptyMessages = { series: "Cette série n'a aucun épisode.", season: "Cette saison n'a aucun épisode.", playlist: 'Cette playlist ne contient aucune vidéo.' };
|
||||||
|
res.status(400).json({ error: emptyMessages[source.type] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : sourceItem.Name;
|
const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : defaultName;
|
||||||
const sp = StaticPartyService.create(
|
const sp = StaticPartyService.create(
|
||||||
{ userId: session.userId, userName: session.userName },
|
{ userId: session.userId, userName: session.userName },
|
||||||
source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem)
|
source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem)
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,11 @@ import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMar
|
||||||
|
|
||||||
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
||||||
// contents (distinct from the generic 'Video', which is home videos).
|
// contents (distinct from the generic 'Video', which is home videos).
|
||||||
// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't
|
// 'Audio' is a music track. 'Series' isn't itself streamable, but
|
||||||
// directly playable - only show "start a party" for things Emby can
|
// launching one queues the whole series (see seriesQueueFrom and POST
|
||||||
// actually stream. "Voir sur Emby" always works though.
|
// /api/parties). Season/MusicArtist/MusicAlbum get no tile button - their
|
||||||
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
|
// own pages have their own launch actions. "Voir sur Emby" always works.
|
||||||
|
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio', 'Series']);
|
||||||
|
|
||||||
// Emby's /Views can return all sorts of things beyond actual media
|
// Emby's /Views can return all sorts of things beyond actual media
|
||||||
// libraries - boxset collections, live TV, photos, books, a mixed-content
|
// libraries - boxset collections, live TV, photos, books, a mixed-content
|
||||||
|
|
@ -68,6 +69,36 @@ export function imageUrlFor(item: EmbyItem): string {
|
||||||
return `/api/catalog/image/${imageItemId}`;
|
return `/api/catalog/image/${imageItemId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a watch party queues from a series' full episode list (Emby's own
|
||||||
|
* order, every season - see getAllSeriesEpisodesRaw): everything from
|
||||||
|
* `fromEpisodeId` on, or - when launching the whole series - everything
|
||||||
|
* from its first regular episode on. Specials (season 0) sort first in
|
||||||
|
* Emby's list, so "the whole series" deliberately starts at S1E1, the
|
||||||
|
* same queue clicking S1E1's own button builds. An unknown
|
||||||
|
* `fromEpisodeId` yields an empty list (the caller falls back to the one
|
||||||
|
* item). Pure.
|
||||||
|
*/
|
||||||
|
export function seriesQueueFrom(episodes: EmbyItem[], fromEpisodeId?: string): EmbyItem[] {
|
||||||
|
if (fromEpisodeId) {
|
||||||
|
const index = episodes.findIndex((e) => e.Id === fromEpisodeId);
|
||||||
|
return index >= 0 ? episodes.slice(index) : [];
|
||||||
|
}
|
||||||
|
const firstRegular = episodes.findIndex((e) => e.ParentIndexNumber !== 0);
|
||||||
|
return firstRegular >= 0 ? episodes.slice(firstRegular) : episodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just one season's episodes, in Emby's order, out of a series' full
|
||||||
|
* episode list - "launch this season". Matched by SeasonId, falling back
|
||||||
|
* to the season number for an episode Emby returned without one. Pure.
|
||||||
|
*/
|
||||||
|
export function seasonQueue(episodes: EmbyItem[], season: { Id: string; IndexNumber?: number }): EmbyItem[] {
|
||||||
|
return episodes.filter((e) => e.SeasonId
|
||||||
|
? e.SeasonId === season.Id
|
||||||
|
: season.IndexNumber != null && e.ParentIndexNumber === season.IndexNumber);
|
||||||
|
}
|
||||||
|
|
||||||
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
|
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
|
||||||
if (!items.length) return { mediaType: 'video', items: [] };
|
if (!items.length) return { mediaType: 'video', items: [] };
|
||||||
const isMusic = items[0]!.Type === 'Audio';
|
const isMusic = items[0]!.Type === 'Audio';
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,8 @@ export interface EmbyItem {
|
||||||
Type?: string;
|
Type?: string;
|
||||||
SeriesId?: string;
|
SeriesId?: string;
|
||||||
SeriesName?: string;
|
SeriesName?: string;
|
||||||
|
/** An episode's own season - see CatalogService.seasonQueue. */
|
||||||
|
SeasonId?: string;
|
||||||
ParentIndexNumber?: number;
|
ParentIndexNumber?: number;
|
||||||
IndexNumber?: number;
|
IndexNumber?: number;
|
||||||
RunTimeTicks?: number;
|
RunTimeTicks?: number;
|
||||||
|
|
@ -452,6 +454,10 @@ export interface LobbyPartyDto {
|
||||||
|
|
||||||
// --- Static (persistent) watch party ------------------------------------------
|
// --- Static (persistent) watch party ------------------------------------------
|
||||||
|
|
||||||
|
/** A lounge party's source: a whole series, one season of it, or a
|
||||||
|
* video playlist. */
|
||||||
|
export type StaticPartySourceType = 'series' | 'season' | 'playlist';
|
||||||
|
|
||||||
/** One member of a static watch party - persisted, independent of any
|
/** One member of a static watch party - persisted, independent of any
|
||||||
* live connection (unlike PartyMember). */
|
* live connection (unlike PartyMember). */
|
||||||
export interface StaticPartyMember {
|
export interface StaticPartyMember {
|
||||||
|
|
@ -481,7 +487,10 @@ export interface StaticPartyGroupProgress {
|
||||||
export interface StaticParty {
|
export interface StaticParty {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
source: { type: 'series' | 'playlist'; sourceId: string };
|
/** What the items come from. A `season` also records its series
|
||||||
|
* (`seriesId`) and number (`seasonNumber`) - episodes are fetched per
|
||||||
|
* series, then filtered (see CatalogService.seasonQueue). */
|
||||||
|
source: { type: StaticPartySourceType; sourceId: string; seriesId?: string; seasonNumber?: number };
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
createdByUserId: string;
|
createdByUserId: string;
|
||||||
createdUtc: number;
|
createdUtc: number;
|
||||||
|
|
@ -506,7 +515,7 @@ export interface StaticPartyItemDto extends QueueItem {
|
||||||
export interface StaticPartySummaryDto {
|
export interface StaticPartySummaryDto {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
sourceType: 'series' | 'playlist';
|
sourceType: StaticPartySourceType;
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
groupWatchedCount: number;
|
groupWatchedCount: number;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { CatalogService, imageUrlFor, splitPlaylistByMediaType } from '../src/services/CatalogService';
|
import { CatalogService, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from '../src/services/CatalogService';
|
||||||
import { EmbyClient } from '../src/emby/EmbyClient';
|
import { EmbyClient } from '../src/emby/EmbyClient';
|
||||||
import type { EmbyItem, EmbySession } from '../src/types';
|
import type { EmbyItem, EmbySession } from '../src/types';
|
||||||
|
|
||||||
|
|
@ -314,3 +314,34 @@ describe('imageUrlFor', () => {
|
||||||
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
|
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('seriesQueueFrom / seasonQueue', () => {
|
||||||
|
const ep = (id: string, season: number, seasonId?: string): EmbyItem => ({ Id: id, Name: id, Type: 'Episode', ParentIndexNumber: season, SeasonId: seasonId });
|
||||||
|
// Emby's own order: specials (season 0) first, then every season in turn.
|
||||||
|
const all = [ep('sp1', 0, 'se0'), ep('s1e1', 1, 'se1'), ep('s1e2', 1, 'se1'), ep('s2e1', 2, 'se2'), ep('s2e2', 2, 'se2')];
|
||||||
|
|
||||||
|
it('the whole series starts at S1E1 - specials sort first and are skipped', () => {
|
||||||
|
assert.deepEqual(seriesQueueFrom(all).map((e) => e.Id), ['s1e1', 's1e2', 's2e1', 's2e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a series with only specials still queues them', () => {
|
||||||
|
assert.deepEqual(seriesQueueFrom([ep('sp1', 0), ep('sp2', 0)]).map((e) => e.Id), ['sp1', 'sp2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('from an episode, queues it and everything after it, across seasons', () => {
|
||||||
|
assert.deepEqual(seriesQueueFrom(all, 's1e2').map((e) => e.Id), ['s1e2', 's2e1', 's2e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unknown starting episode yields nothing (the caller falls back to that one item)', () => {
|
||||||
|
assert.deepEqual(seriesQueueFrom(all, 'nope'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a season queues only its own episodes', () => {
|
||||||
|
assert.deepEqual(seasonQueue(all, { Id: 'se1', IndexNumber: 1 }).map((e) => e.Id), ['s1e1', 's1e2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a season falls back to the season number for episodes without a SeasonId', () => {
|
||||||
|
const noIds = [ep('a', 1), ep('b', 2), ep('c', 2)];
|
||||||
|
assert.deepEqual(seasonQueue(noIds, { Id: 'se2', IndexNumber: 2 }).map((e) => e.Id), ['b', 'c']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -195,11 +195,15 @@ describe('StaticPartyStore', () => {
|
||||||
const otherFile = path.join(partiesDir(), `${other.id}.json`);
|
const otherFile = path.join(partiesDir(), `${other.id}.json`);
|
||||||
const past = new Date(Date.now() - 60000);
|
const past = new Date(Date.now() - 60000);
|
||||||
fs.utimesSync(otherFile, past, past);
|
fs.utimesSync(otherFile, past, past);
|
||||||
|
// Compared against what the filesystem itself reports back, not
|
||||||
|
// past.getTime(): Linux returns mtimeMs as a float that can land a
|
||||||
|
// hair off the exact millisecond set (e.g. ...780.999 for ...781).
|
||||||
|
const mtimeBefore = fs.statSync(otherFile).mtimeMs;
|
||||||
|
|
||||||
StaticPartyService.markWatched(sp, alice, 'e1');
|
StaticPartyService.markWatched(sp, alice, 'e1');
|
||||||
await StaticPartyStore.writeNow();
|
await StaticPartyStore.writeNow();
|
||||||
|
|
||||||
assert.equal(fs.statSync(otherFile).mtimeMs, past.getTime(), 'the untouched party\'s file was not rewritten');
|
assert.equal(fs.statSync(otherFile).mtimeMs, mtimeBefore, 'the untouched party\'s file was not rewritten');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deleting a party removes its file, even with a save still pending', async () => {
|
it('deleting a party removes its file, even with a save still pending', async () => {
|
||||||
|
|
|
||||||
|
|
@ -79,15 +79,25 @@ des watch parties en cours.
|
||||||
devient l'hôte.
|
devient l'hôte.
|
||||||
- Quitter efface sa propre progression.
|
- Quitter efface sa propre progression.
|
||||||
- Seul le créateur peut supprimer la watch party.
|
- Seul le créateur peut supprimer la watch party.
|
||||||
- **Contenus** : séries et playlists vidéo uniquement. Une playlist musicale
|
- **Contenus** : une série entière, **une seule saison**, ou une playlist
|
||||||
est refusée.
|
vidéo. Une playlist musicale est refusée.
|
||||||
|
- Série entière : tous les épisodes de toutes les saisons à partir de S1E1.
|
||||||
|
Les spéciaux (saison 0) sont exclus : Emby les trie en premier, et une
|
||||||
|
séance commencerait par eux (`CatalogService.seriesQueueFrom`). Les
|
||||||
|
lounge parties créées avant ce changement gardent leurs spéciaux déjà
|
||||||
|
en liste.
|
||||||
|
- Saison : uniquement les épisodes de cette saison
|
||||||
|
(`CatalogService.seasonQueue`, par `SeasonId`, sinon par numéro de
|
||||||
|
saison). La source garde `seriesId` et `seasonNumber`, car les
|
||||||
|
épisodes sont récupérés par série puis filtrés. Nom par défaut :
|
||||||
|
« Série - Saison », avec l'affiche de la série.
|
||||||
|
|
||||||
## API (`server/src/index.ts`)
|
## API (`server/src/index.ts`)
|
||||||
|
|
||||||
| Méthode | Route | Rôle |
|
| Méthode | Route | Rôle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/api/static-parties` | liste, avec le nombre de manqués de l'utilisateur et la séance en cours |
|
| GET | `/api/static-parties` | liste, avec le nombre de manqués de l'utilisateur et la séance en cours |
|
||||||
| POST | `/api/static-parties` | `{sourceId, name?}` : série, épisode (sa série) ou playlist |
|
| POST | `/api/static-parties` | `{sourceId, name?}` : série, saison, épisode (sa série) ou playlist |
|
||||||
| GET | `/api/static-parties/:id` | détail : items + état groupe / utilisateur, membres, `missedItemIds` |
|
| GET | `/api/static-parties/:id` | détail : items + état groupe / utilisateur, membres, `missedItemIds` |
|
||||||
| POST | `/:id/join`, `/:id/leave` | devenir membre / quitter |
|
| POST | `/:id/join`, `/:id/leave` | devenir membre / quitter |
|
||||||
| DELETE | `/:id` | supprimer (créateur uniquement) |
|
| DELETE | `/:id` | supprimer (créateur uniquement) |
|
||||||
|
|
@ -131,8 +141,12 @@ des watch parties en cours.
|
||||||
solo, ou lancer une watch party de rattrapage ;
|
solo, ou lancer une watch party de rattrapage ;
|
||||||
- les membres ;
|
- les membres ;
|
||||||
- la liste complète des items.
|
- la liste complète des items.
|
||||||
- Bouton « Créer une lounge party » sur `views/series` et sur
|
- Page série (`views/series`) :
|
||||||
`views/playlist` (vidéo uniquement).
|
- en-tête : « Lancer toute la série » (watch party) et « Créer une lounge
|
||||||
|
party » (série entière) ;
|
||||||
|
- au-dessus des épisodes de l'onglet de saison sélectionné : « Lancer
|
||||||
|
cette saison » (watch party) et « Lounge party de cette saison ».
|
||||||
|
- « Créer une lounge party » sur `views/playlist` (vidéo uniquement).
|
||||||
- Onglet dédié « Lounge parties » (sidebar et menu mobile), route
|
- Onglet dédié « Lounge parties » (sidebar et menu mobile), route
|
||||||
`/static` (`views/staticParties/`) : une carte par watch party avec la
|
`/static` (`views/staticParties/`) : une carte par watch party avec la
|
||||||
progression, le prochain élément, un badge « N manqués » et une pastille
|
progression, le prochain élément, un badge « N manqués » et une pastille
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue