All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Big UI pass towards looking/behaving like Emby. Deferred: music (needs
its own player + a real-time collaborative playlist - different enough
subsystem to be its own pass; sidebar/routing already treat it as a
first-class collectionType, page just says "not yet" for now).
Layout:
- client/js/shell.js: persistent sidebar (one entry per Emby library,
from GET /api/catalog/libraries which now also returns
collectionType) + a #mainContent the router swaps on navigation.
router.js made idempotent (start() safe to call more than once) so
a login -> shell boot can happen twice in one page lifetime (logout
then back in) without piling up duplicate hashchange listeners.
- app.js's boot() renders either the bare login screen or the full
shell depending on session state; login.js calls it again on
success instead of navigate() (no router running yet at that point).
Browsing, by collection type:
- views/home.js (route /home): a carousel of every active party
server-wide, a join-by-code fallback, then one "recently added"
carousel per non-music library (GET /api/catalog/libraries/:id/recent,
Emby's Items/Latest).
- views/library.js (route /library/:id): dispatches on collectionType -
movies/tvshows get the tile grid (Series tiles link into /series/:id
instead of starting a party directly), music gets an honest
placeholder.
- views/series.js (route /series/:id): season tabs + episode list
(new GET /api/catalog/series/:id/seasons and .../episodes, Emby's
/Shows/{id}/Seasons and /Episodes).
- tiles.js: poster-tile rendering shared by home.js and library.js so
they can't drift apart.
Episode queue / "next episode":
- POST /api/parties now builds a queue when starting from an episode
(fetches the rest of the series, from that episode onward) -
partyManager.js gained queue/queueIndex/advanceQueue.
- New WS message {type:"next"} (host-only) advances the queue and
broadcasts {type:"itemChanged", itemId, itemName, ...}; every client
reloads the player against the new item. player.js shows "Épisode
suivant" when hasNext, and auto-sends next() on the video's `ended`
event.
Player: language/subtitles/quality:
- New GET /api/catalog/item/:id/playback-options exposes PlaybackInfo's
MediaStreams as audio/subtitle track lists.
- master.m3u8 now forwards AudioStreamIndex/SubtitleStreamIndex/
MaxStreamingBitrate to PlaybackInfo; player.js has three <select>s
(host-only) that reload the stream at the same position on change.
Subtitles end up burned into the transcode (via SubtitleStreamIndex),
not a toggleable track - simpler than sidecar WebVTT for this pass.
"Quality" is a MaxStreamingBitrate ceiling, not true adaptive bitrate.
Verified by driving a browser against mocked /api/* responses (see
session history): sidebar renders/highlights correctly, home carousels
render (parties + one row per non-music library, music correctly
skipped), library grid dispatch works, series page season-tab
switching + episode list render and the season/episode navigation
flow works end to end. Real Emby response shapes for all of this
(Views' CollectionType, Seasons/Episodes, Items/Latest, PlaybackInfo's
MediaStreams) and the queue/track-picker protocol are NOT yet
confirmed against a live server - see docs/architecture.md.
Removed client/js/views/browse.js, superseded by home.js + library.js.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
96 lines
3 KiB
JavaScript
96 lines
3 KiB
JavaScript
// Talks ONLY to our own backend now - never directly to Emby. The backend
|
|
// holds the real Emby session (server/src/embyAuth.js); this module just
|
|
// wraps fetch calls to our own /api/* routes. See docs/architecture.md for
|
|
// why this changed from the first version (CORS, plus not wanting the
|
|
// browser to hold an Emby token at all).
|
|
|
|
async function apiFetch(path, options = {}) {
|
|
const res = await fetch(path, {
|
|
credentials: 'same-origin',
|
|
...options,
|
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export const auth = {
|
|
userName: null,
|
|
get isLoggedIn() { return !!this.userName; }
|
|
};
|
|
|
|
/** Call once on app start to find out if our session cookie is still valid. */
|
|
export async function restoreSession() {
|
|
try {
|
|
const data = await apiFetch('/api/auth/me');
|
|
auth.userName = data.userName;
|
|
return true;
|
|
} catch (err) {
|
|
auth.userName = null;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function login(username, password) {
|
|
const data = await apiFetch('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
auth.userName = data.userName;
|
|
return data;
|
|
}
|
|
|
|
export async function logout() {
|
|
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
|
auth.userName = null;
|
|
}
|
|
|
|
export async function searchItems(query) {
|
|
if (!query || query.trim().length < 2) return [];
|
|
const data = await apiFetch(`/api/catalog/search?q=${encodeURIComponent(query)}`);
|
|
return data.items || [];
|
|
}
|
|
|
|
export async function getItem(itemId) {
|
|
return apiFetch(`/api/catalog/item/${itemId}`);
|
|
}
|
|
|
|
export async function getLibraries() {
|
|
const data = await apiFetch('/api/catalog/libraries');
|
|
return data.libraries || [];
|
|
}
|
|
|
|
export async function getLibraryItems(libraryId) {
|
|
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/items`);
|
|
return data.items || [];
|
|
}
|
|
|
|
export async function getRecentItems(libraryId) {
|
|
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/recent`);
|
|
return data.items || [];
|
|
}
|
|
|
|
export async function getSeriesSeasons(seriesId) {
|
|
const data = await apiFetch(`/api/catalog/series/${seriesId}/seasons`);
|
|
return data.seasons || [];
|
|
}
|
|
|
|
export async function getSeriesEpisodes(seriesId, seasonId) {
|
|
const data = await apiFetch(`/api/catalog/series/${seriesId}/episodes?seasonId=${encodeURIComponent(seasonId)}`);
|
|
return data.episodes || [];
|
|
}
|
|
|
|
export async function getPlaybackOptions(itemId) {
|
|
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
|
}
|
|
|
|
/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate. */
|
|
export function streamUrl(itemId, options = {}) {
|
|
const qs = new URLSearchParams(
|
|
Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
|
|
).toString();
|
|
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
|
|
}
|