diff --git a/.changes/details-episodes-bare-skeleton.md b/.changes/details-episodes-bare-skeleton.md new file mode 100644 index 000000000..3afa21041 --- /dev/null +++ b/.changes/details-episodes-bare-skeleton.md @@ -0,0 +1,8 @@ +--- +type: fix +area: details +--- + +Seasons without episode pictures or plots show compact text rows, and episode +lists load as placeholder rows of the final size instead of a spinner. Stalker +series no longer flash "No episodes available" while their seasons load. diff --git a/.changes/details-episodes-list-redesign.md b/.changes/details-episodes-list-redesign.md new file mode 100644 index 000000000..b73dd86aa --- /dev/null +++ b/.changes/details-episodes-list-redesign.md @@ -0,0 +1,10 @@ +--- +type: feature +area: details +highlight: Redesigned episode list +--- + +Series pages get a calmer episode list: flat rows with runtime and air date, +two lines of plot, a check on watched episodes and a bar only on started ones. +Actions appear on hover or keyboard focus. Seasons are picked from chips, or a +menu with watched counts from five seasons on, without season posters. diff --git a/.changes/details-hero-fade.md b/.changes/details-hero-fade.md new file mode 100644 index 000000000..2003ffbf8 --- /dev/null +++ b/.changes/details-hero-fade.md @@ -0,0 +1,8 @@ +--- +type: fix +area: details +--- + +The artwork of the dashboard banner and of movie and series pages now fades +smoothly into the page under the first row, instead of stopping at a visible +edge above it. diff --git a/.changes/details-hero-skeleton.md b/.changes/details-hero-skeleton.md new file mode 100644 index 000000000..6ed66bbc3 --- /dev/null +++ b/.changes/details-hero-skeleton.md @@ -0,0 +1,8 @@ +--- +type: fix +area: details +--- + +Movie and series pages load with placeholders laid out like the finished page, +so the title, chips and buttons no longer jump into place, and the Play button +of a Stalker series keeps its spot while seasons load. diff --git a/.changes/playback-reused-player-start.md b/.changes/playback-reused-player-start.md new file mode 100644 index 000000000..d9253bb3e --- /dev/null +++ b/.changes/playback-reused-player-start.md @@ -0,0 +1,8 @@ +--- +type: fix +area: playback +--- + +With "Reuse player instance" on, the next episode, "Play from beginning" and +a resumed episode sent to MPV or VLC now start where they should. The reused +player kept the first launch's resume offset for every later title. diff --git a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts index f45655f48..5c3fe9953 100644 --- a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts +++ b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts @@ -236,7 +236,7 @@ async function openFirstSeries(page: Page): Promise { timeout: 20_000, } ); - await expect(page.locator('.episode-card').first()).toBeVisible({ + await expect(page.locator('.episode-item').first()).toBeVisible({ timeout: 20_000, }); return page.url(); @@ -244,7 +244,7 @@ async function openFirstSeries(page: Page): Promise { async function startFirstEpisode(page: Page): Promise { const shell = page.locator('app-portal-detail-shell'); - await page.locator('.episode-card').first().click(); + await page.locator('.episode-item').first().click(); await expect(shell).toHaveClass(/shell-host--watch/); await expect( shell.locator('app-portal-inline-player app-web-player-view') @@ -337,7 +337,9 @@ test.describe('Portal detail header Back', () => { // On a phone the list's drawer toggle keeps the slot: it is the // only way into the categories. await page.setViewportSize({ width: 375, height: 800 }); - await expect(page.getByTestId('context-drawer-toggle')).toBeVisible(); + await expect( + page.getByTestId('context-drawer-toggle') + ).toBeVisible(); await expect(headerBack(page)).toBeHidden(); await page.setViewportSize({ width: widths[0], height: 800 }); diff --git a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts index d40295db6..832ab9b45 100644 --- a/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts +++ b/apps/electron-backend-e2e/src/season-watched-toggle.e2e.ts @@ -45,7 +45,7 @@ const expectedEpisodeNumbers = [1, 2, 3, 4, 5, 6, 7, 8]; const portalName = 'Season Watched Toggle'; const watchedEpisodeToggleSelector = - '[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched'; + '[data-testid="episode-watched-toggle"].episode-item__watched-toggle--watched'; type EpisodePositionRow = { episodeNumber: number | null; @@ -169,9 +169,9 @@ test.describe('Electron Season Watched Toggle', () => { ); await closeSeriesMenu(app.mainWindow); - const episodeCards = app.mainWindow.locator('.episode-card'); + const episodeCards = app.mainWindow.locator('.episode-item'); const watchedCards = app.mainWindow.locator( - '.episode-card--watched' + '.episode-item--watched' ); await expect(episodeCards).toHaveCount(seasonEpisodeCount, { timeout: 10_000, @@ -256,7 +256,7 @@ test.describe('Electron Season Watched Toggle', () => { 'toggle-season-watched' ); const restartedWatchedCards = app.mainWindow.locator( - '.episode-card--watched' + '.episode-item--watched' ); // With season 1 completed, the fresh mount auto-selects the // earliest season with unwatched episodes (issue #1441): season 2 diff --git a/apps/electron-backend/src/app/events/mpv-reusable-process.ts b/apps/electron-backend/src/app/events/mpv-reusable-process.ts index 5642c799b..db5234cdb 100644 --- a/apps/electron-backend/src/app/events/mpv-reusable-process.ts +++ b/apps/electron-backend/src/app/events/mpv-reusable-process.ts @@ -11,6 +11,19 @@ import { traceExternalPlayer, } from './external-player-runtime'; +/** + * The per-file start of a reuse load: the requested offset, else zero. A + * `seek` right after `loadfile` runs before the file is loaded and fails, + * so the offset travels with the load itself. + */ +function reuseStartSeconds(startTime: number | undefined): number { + return typeof startTime === 'number' && + Number.isFinite(startTime) && + startTime >= 0 + ? startTime + : 0; +} + const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000; export interface MpvReuseAttemptState { @@ -309,10 +322,19 @@ export class MpvReusableProcess { if (!dispatched) return; } if (!shouldDispatch()) return; - const loadFileArgs: Array = [options.url, 'replace']; + // The reused process was launched with a global `--start=`, + // which mpv applies to every later file too. A per-file `start` + // (0 when nothing is resumed) keeps each load at its own offset. + const fileOptions = [`start=${reuseStartSeconds(options.startTime)}`]; if (options.title) { - loadFileArgs.push(-1, `force-media-title=${options.title}`); + fileOptions.push(`force-media-title=${options.title}`); } + const loadFileArgs: Array = [ + options.url, + 'replace', + -1, + fileOptions.join(','), + ]; const dispatched = await sendMpvCommand( socketPath, 'loadfile', diff --git a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts index dd11945a8..0adfc02d2 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.spec.ts @@ -371,6 +371,71 @@ describe('external player shutdown on app quit', () => { expect(headerArg).not.toContain('(KHTML, like Gecko)'); }); + it('gives every reused load its own start offset, zero by default', async () => { + // mpv applies the first launch's global --start= to every + // later loadfile of that process; the per-file start option (0 when + // nothing is resumed) keeps a next episode, or "Play from + // beginning", at zero. A seek right after loadfile fails in mpv. + shutdownMpvSession(); + const proc = createMockChildProcess(); + (spawn as unknown as jest.Mock).mockReturnValue(proc); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: true, + }); + await openMpvPlayer({ + title: 'Episode 3', + url: 'https://portal.example/ep/3', + startTime: 42, + }); + const launchArgs = (spawn as unknown as jest.Mock).mock.calls.at( + -1 + )?.[1] as string[]; + expect(launchArgs).toContain('--start=42'); + + const written: string[] = []; + (createConnection as unknown as jest.Mock).mockImplementation(() => { + const socket = Object.assign(new EventEmitter(), { + write: jest.fn((chunk: string) => written.push(chunk)), + end: jest.fn(), + destroy: jest.fn(), + }); + setImmediate(() => socket.emit('connect')); + return socket; + }); + const loadfileOptionsOf = async (startTime: number | undefined) => { + written.length = 0; + await openMpvPlayer({ + title: 'Episode 4', + url: 'https://portal.example/ep/4', + ...(startTime === undefined ? {} : { startTime }), + }); + const loadfile = written + .map( + (chunk) => + JSON.parse(chunk.trim()).command as Array< + string | number + > + ) + .find((command) => command[0] === 'loadfile'); + expect(loadfile).toBeDefined(); + // ['loadfile', url, 'replace', index, options] + return String(loadfile?.[4]); + }; + + expect(await loadfileOptionsOf(0)).toBe( + 'start=0,force-media-title=Episode 4' + ); + expect(await loadfileOptionsOf(undefined)).toBe( + 'start=0,force-media-title=Episode 4' + ); + expect(await loadfileOptionsOf(90)).toBe( + 'start=90,force-media-title=Episode 4' + ); + + shutdownMpvSession(); + }); + it('escapes commas in http header fields on the reused-instance IPC path', async () => { // Reset any reusable instance a previous test may have left behind so // the first launch below spawns rather than reuses. @@ -647,8 +712,8 @@ describe('external player shutdown on app quit', () => { await expect(closing).resolves.toMatchObject({ status: 'closed' }); await expect(opening).resolves.toMatchObject({ status: 'closed' }); const commands = sockets.flatMap((socket) => - socket.write.mock.calls.map(([request]) => - JSON.parse(String(request)).command[0] + socket.write.mock.calls.map( + ([request]) => JSON.parse(String(request)).command[0] ) ); expect(commands).not.toContain('loadfile'); diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts index 5250b813c..20ae58322 100644 --- a/apps/electron-backend/src/app/events/player.events.spec.ts +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -587,7 +587,7 @@ describe('buildVlcEnqueueCommands', () => { it('clears the playlist and adds the URL with no extra options', () => { expect( buildVlcEnqueueCommands({ url: 'http://stream.example/a.m3u8' }) - ).toEqual(['clear', 'add http://stream.example/a.m3u8']); + ).toEqual(['clear', 'add http://stream.example/a.m3u8 :start-time=0']); }); it('attaches per-input HTTP options inline with the add command', () => { @@ -601,7 +601,7 @@ describe('buildVlcEnqueueCommands', () => { expect(commands[0]).toBe('clear'); expect(commands[1]).toBe( - 'add http://stream.example/a.m3u8 :http-user-agent=Custom/1.0 :http-referrer=https://referer.example :http-header=X-Token: abc :meta-title=Channel One' + 'add http://stream.example/a.m3u8 :start-time=0 :http-user-agent=Custom/1.0 :http-referrer=https://referer.example :http-header=X-Token: abc :meta-title=Channel One' ); }); @@ -622,7 +622,7 @@ describe('buildVlcEnqueueCommands', () => { expect(commands).toEqual([ 'clear', - 'add http://stream.example/a.m3u8', + 'add http://stream.example/a.m3u8 :start-time=42', 'seek 42', ]); }); diff --git a/apps/electron-backend/src/app/events/vlc-rc.ts b/apps/electron-backend/src/app/events/vlc-rc.ts index efa3963fe..3b818cd4d 100644 --- a/apps/electron-backend/src/app/events/vlc-rc.ts +++ b/apps/electron-backend/src/app/events/vlc-rc.ts @@ -13,6 +13,17 @@ export function buildVlcEnqueueCommands(options: { }): string[] { const inputOptions: string[] = []; + // The reused process was launched with a global `--start-time=`, + // which VLC applies to every later input too. A per-input start time (0 + // when nothing is resumed) keeps each item at its own offset. + const startTime = + typeof options.startTime === 'number' && + Number.isFinite(options.startTime) && + options.startTime >= 0 + ? Math.floor(options.startTime) + : 0; + inputOptions.push(`:start-time=${startTime}`); + if (options.userAgent) { inputOptions.push(`:http-user-agent=${options.userAgent}`); } @@ -35,8 +46,8 @@ export function buildVlcEnqueueCommands(options: { ? `${options.url} ${inputOptions.join(' ')}` : options.url; const commands = ['clear', `add ${inputLine}`]; - if (options.startTime && Number.isFinite(options.startTime)) { - commands.push(`seek ${Math.floor(options.startTime)}`); + if (startTime > 0) { + commands.push(`seek ${startTime}`); } return commands; } diff --git a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts index 5b7c0f145..48d8ed677 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.lifecycle.spec.ts @@ -162,7 +162,7 @@ describe('vlc-session.service process lifecycle', () => { expect(spawnMock).toHaveBeenCalledTimes(1); expect(rcWrites).toEqual([ 'clear\n', - 'add https://example.com/two.m3u8 ' + + 'add https://example.com/two.m3u8 :start-time=0 ' + ':http-referrer=https://ref.example :meta-title=Second\n', ]); expect(session.status).toBe('opened'); @@ -299,9 +299,7 @@ describe('vlc-session.service process lifecycle', () => { } finally { sockets[0].emit('data', Buffer.from('> 12')); while (sockets.length < 2) { - await new Promise((resolve) => - setImmediate(resolve) - ); + await new Promise((resolve) => setImmediate(resolve)); } sockets[1].emit('data', Buffer.from('> 120')); await closing; @@ -494,11 +492,7 @@ describe('vlc-session.service process lifecycle', () => { () => ({ unref: jest.fn(), on: jest.fn(), - listen: ( - _port: number, - _host: string, - cb: () => void - ) => { + listen: (_port: number, _host: string, cb: () => void) => { releasePort = cb; }, address: () => ({ port: 43211 }), @@ -513,21 +507,13 @@ describe('vlc-session.service process lifecycle', () => { }); const replacementId = externalPlayerSessions.getActiveSessionId() as string; - for ( - let attempt = 0; - attempt < 20 && !releasePort; - attempt += 1 - ) { + for (let attempt = 0; attempt < 20 && !releasePort; attempt += 1) { await new Promise((resolve) => setImmediate(resolve)); } expect(releasePort).toBeDefined(); const closing = externalPlayerSessions.closeSession(replacementId); - for ( - let attempt = 0; - attempt < 20 && !releaseClose; - attempt += 1 - ) { + for (let attempt = 0; attempt < 20 && !releaseClose; attempt += 1) { await new Promise((resolve) => setImmediate(resolve)); } expect(releaseClose).toBeDefined(); @@ -917,9 +903,11 @@ describe('vlc-session.service process lifecycle', () => { let rejectPort: ((error: Error) => void) | undefined; (createServer as unknown as jest.Mock).mockImplementation(() => ({ unref: jest.fn(), - on: jest.fn((event: string, listener: (error: Error) => void) => { - if (event === 'error') rejectPort = listener; - }), + on: jest.fn( + (event: string, listener: (error: Error) => void) => { + if (event === 'error') rejectPort = listener; + } + ), listen: () => setImmediate(() => rejectPort?.(new Error('port allocation failed')) @@ -930,7 +918,10 @@ describe('vlc-session.service process lifecycle', () => { const proc = createMockChildProcess(); spawnMock.mockReturnValueOnce(proc); - const opening = openVlcPlayer({ title: 'Fallback', url: streamUrl }); + const opening = openVlcPlayer({ + title: 'Fallback', + url: streamUrl, + }); await waitForSpawnCallCount(1); proc.emit('spawn'); const session = await opening; diff --git a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts index e7043479c..86e41d58b 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.spec.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.spec.ts @@ -126,13 +126,26 @@ describe('vlc-session.service helpers and launch args', () => { }) ).toEqual([ 'clear', - 'add http://srv/1 :http-user-agent=UA/1.0 ' + + 'add http://srv/1 :start-time=12 :http-user-agent=UA/1.0 ' + ':http-referrer=https://ref.example ' + ':http-header=X-A: padded :meta-title=My Title', 'seek 12', ]); }); + it('starts every reuse load at zero unless an offset is requested', () => { + // A reused VLC was launched with --start-time=, which it + // applies to every later input; the per-input start keeps a + // next episode, or "Play from beginning", at zero. + expect(buildVlcEnqueueCommands({ url: 'http://srv/5' })).toEqual([ + 'clear', + 'add http://srv/5 :start-time=0', + ]); + expect( + buildVlcEnqueueCommands({ url: 'http://srv/5', startTime: 0 }) + ).toEqual(['clear', 'add http://srv/5 :start-time=0']); + }); + it('falls back to origin as referrer and omits empty options', () => { expect( buildVlcEnqueueCommands({ @@ -141,12 +154,12 @@ describe('vlc-session.service helpers and launch args', () => { }) ).toEqual([ 'clear', - 'add http://srv/2 :http-referrer=https://origin.example ' + + 'add http://srv/2 :start-time=0 :http-referrer=https://origin.example ' + ':http-header=Origin: https://origin.example', ]); expect(buildVlcEnqueueCommands({ url: 'http://srv/3' })).toEqual([ 'clear', - 'add http://srv/3', + 'add http://srv/3 :start-time=0', ]); }); @@ -160,7 +173,7 @@ describe('vlc-session.service helpers and launch args', () => { }) ).toEqual([ 'clear', - 'add http://srv/4 :http-referrer=https://ref.example ' + + 'add http://srv/4 :start-time=0 :http-referrer=https://ref.example ' + ':http-header=Origin: https://explicit.example', ]); }); diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index e3e924604..337637cec 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -243,7 +243,7 @@ export async function rasterizedBorderContrast( return ratios[Math.floor(ratios.length / 2)]; } -/** Check the shared series UI under a provider host before testing watched state. */ +/** Check the shared series UI under the Stalker host before testing watched state. */ export async function expectSeriesSurfacesInBothThemes( page: Page, testInfo: TestInfo @@ -266,39 +266,24 @@ export async function expectSeriesSurfacesInBothThemes( ) ) .toBeGreaterThan(1.1); - // The flat card's visible edge is its artwork's hairline. - for (const selector of [ - '.episode-card__thumbnail', - 'mat-button-toggle-group', - ]) { - await expect - .poll( - async () => - (await surfaceContrast(shell.locator(selector).first())) - .border - ) - .toBeGreaterThan(1.15); - } - await page - .getByRole('radio', { name: 'List view', exact: true }) - .click(); - await expect - .poll( - async () => - ( - await surfaceContrast( - shell.locator('.episode-list-item').first() - ) - ).border - ) - .toBeGreaterThan(1.15); + // The modeled Stalker series carry neither stills (every episode + // repeats the series poster) nor plots: the season renders bare + // 44px rows without thumbnails, and there is no grid form to offer. + const rows = shell.locator('app-episode-item'); + await expect(rows.first()).toBeVisible(); + expect(await rows.count()).toBeGreaterThan(0); + await expect( + shell.locator('app-episode-item:not(.episode-item--bare)') + ).toHaveCount(0); + await expect(shell.locator('.episode-item__thumb')).toHaveCount(0); + await expect(shell.locator('mat-button-toggle-group')).toHaveCount(0); + expect( + Math.round((await rows.first().boundingBox())?.height ?? 0) + ).toBe(44); await shell.screenshot({ - path: testInfo.outputPath(`stalker-series-list-${theme}.png`), + path: testInfo.outputPath(`stalker-series-bare-${theme}.png`), animations: 'disabled', }); - await page - .getByRole('radio', { name: 'Grid view', exact: true }) - .click(); } } diff --git a/apps/web-e2e/src/stalker-embedded-series.fixture.ts b/apps/web-e2e/src/stalker-embedded-series.fixture.ts index 67096aef6..cf4ae51ec 100644 --- a/apps/web-e2e/src/stalker-embedded-series.fixture.ts +++ b/apps/web-e2e/src/stalker-embedded-series.fixture.ts @@ -52,7 +52,7 @@ export async function openEmbeddedSeriesItem( await expect( page.getByRole('heading', { - name: `${episodeCount}. Episode ${episodeCount}`, + name: `Episode ${episodeCount}`, exact: true, }) ).toBeVisible({ timeout: 10_000 }); diff --git a/apps/web-e2e/src/stalker-season-markers.fixture.ts b/apps/web-e2e/src/stalker-season-markers.fixture.ts index f6be83973..c033ac247 100644 --- a/apps/web-e2e/src/stalker-season-markers.fixture.ts +++ b/apps/web-e2e/src/stalker-season-markers.fixture.ts @@ -137,30 +137,30 @@ export async function verifyStalkerSeasonMarkers( ); await expect( page.getByRole('heading', { - name: `1. Season ${season} premiere`, + name: `Season ${season} premiere`, exact: true, }) ).toBeVisible(); await expect(page.getByTestId('season-description')).toContainText( `Season ${season} overview` ); - await expect(page.locator('.episode-card')).toContainText( + await expect(page.locator('.episode-item')).toContainText( `Season ${season} episode plot` ); - await expect(page.locator('.episode-card img')).toHaveAttribute( + await expect(page.locator('.episode-item img')).toHaveAttribute( 'src', new RegExp(`/season-${season}\\.jpg$`) ); await page.locator('[data-testid="series-more-menu"]').click(); await page.locator('[data-test-id="toggle-season-watched"]').click(); - await expect(page.locator('.episode-card--watched')).toHaveCount(1); + await expect(page.locator('.episode-item--watched')).toHaveCount(1); await page.getByRole('button', { name: 'Back', exact: true }).click(); } await page.getByText(titles[0], { exact: true }).first().click(); await expect( page.getByRole('tab', { name: 'Season 2', exact: true }) ).toBeVisible(); - await expect(page.locator('.episode-card--watched')).toHaveCount(1); + await expect(page.locator('.episode-item--watched')).toHaveCount(1); expect(seasonRequests).toEqual(expect.arrayContaining([2, 3])); expect(seasonRequests).not.toContain(1); expect(searchLanguages).toEqual(expect.arrayContaining(['en-US', 'ru-RU'])); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 051acc8af..1c744ef63 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -803,12 +803,12 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e // Snapshot episodes are visible immediately… await expect( - page.getByRole('heading', { name: '1. Episode 1', exact: true }) + page.getByRole('heading', { name: 'Episode 1', exact: true }) ).toBeVisible({ timeout: 10_000 }); // …and the newly released episode appears after the background refresh await expect( page.getByRole('heading', { - name: `${episodeCount + 1}. Episode ${episodeCount + 1}`, + name: `Episode ${episodeCount + 1}`, exact: true, }) ).toBeVisible({ timeout: 10_000 }); @@ -847,7 +847,7 @@ test('@stalker season watched toggle — embedded series marks and clears every await expect(seasonToggle).toContainText( `Mark season as watched (${episodeCount})` ); - const watchedCards = page.locator('.episode-card--watched'); + const watchedCards = page.locator('.episode-item--watched'); await expect(watchedCards).toHaveCount(0); await seasonToggle.click(); @@ -857,7 +857,7 @@ test('@stalker season watched toggle — embedded series marks and clears every await expect(watchedCards).toHaveCount(episodeCount, { timeout: 15_000 }); await expect( page.locator( - '[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched' + '[data-testid="episode-watched-toggle"].episode-item__watched-toggle--watched' ) ).toHaveCount(episodeCount); seasonToggle = await seriesMenuRow(page, 'toggle-season-watched'); @@ -904,7 +904,7 @@ test('@stalker series watched toggle — embedded series marks and clears from t // Every episode of the embedded season flips to watched and the action // becomes unwatch-all. - const watchedCards = page.locator('.episode-card--watched'); + const watchedCards = page.locator('.episode-item--watched'); await expect(watchedCards).toHaveCount(episodeCount, { timeout: 15_000, }); diff --git a/apps/web-e2e/src/xtream-series-playback.fixture.ts b/apps/web-e2e/src/xtream-series-playback.fixture.ts index 206d83271..aea6bc932 100644 --- a/apps/web-e2e/src/xtream-series-playback.fixture.ts +++ b/apps/web-e2e/src/xtream-series-playback.fixture.ts @@ -225,7 +225,7 @@ export async function playFirstSeriesEpisode( await expect(seriesCard).toBeVisible({ timeout: 10_000 }); await seriesCard.click(); - const episodeCards = page.locator('.episode-card'); + const episodeCards = page.locator('.episode-item'); await expect(episodeCards).toHaveCount(8, { timeout: 15_000 }); await episodeCards.first().click(); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 815ddbd15..7c08e8c6e 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -663,9 +663,9 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle await expect(seasonToggle).toContainText('Mark season as watched (8)'); await closeSeriesMenu(page); - const episodeCards = page.locator('.episode-card'); + const episodeCards = page.locator('.episode-item'); await expect(episodeCards).toHaveCount(8, { timeout: 10_000 }); - const watchedCards = page.locator('.episode-card--watched'); + const watchedCards = page.locator('.episode-item--watched'); await expect(watchedCards).toHaveCount(0); seasonToggle = await seriesMenuRow(page, 'toggle-season-watched'); @@ -680,7 +680,7 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle await closeSeriesMenu(page); await expect( page.locator( - '[data-testid="episode-watched-toggle"].episode-card__watched-toggle--watched' + '[data-testid="episode-watched-toggle"].episode-item__watched-toggle--watched' ) ).toHaveCount(8); @@ -738,7 +738,7 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle // cannot fold the column (the app hides a cover whose image failed). // --------------------------------------------------------------------------- -test('@xtream season cover — shows the provider season cover and follows the selected tab', async ({ +test('@xtream season header — chips, no season cover, synopsis on the number column', async ({ page, request, }) => { @@ -780,22 +780,38 @@ test('@xtream season cover — shows the provider season cover and follows the s await expect(seriesCard).toBeVisible({ timeout: 10_000 }); await seriesCard.click(); - // Season 1 is auto-selected; its provider cover sits beside the tabs. - // The mock seeds season art as `season--` (cover) and - // `season-big--` (cover_big); the app prefers cover_big. - const cover = page.locator('[data-testid="season-cover"]'); - await expect(cover).toBeVisible({ timeout: 15_000 }); - await expect(cover).toHaveAttribute( - 'src', - new RegExp(`season(-big)?-${targetSeries.series_id}-1/`) - ); + // The mock seeds season art (`season--`) and a synopsis per + // season. Three seasons → chips; the art is never shown in the header + // or beside the synopsis. + const description = page.getByTestId('season-description'); + await expect(description).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.season-tabs__pill')).toHaveCount(3); + await expect(page.getByTestId('season-dropdown')).toHaveCount(0); + await expect(page.locator('[data-testid="season-cover"]')).toHaveCount(0); + await expect( + page.locator('app-season-container img[src*="/season-"]') + ).toHaveCount(0); + + // Offset to the episode number column, 24px above the list. + const geometry = await description.evaluate((element) => { + const style = getComputedStyle(element); + const list = element.parentElement?.querySelector('.episodes-list'); + return { + paddingLeft: style.paddingLeft, + gapToList: list + ? list.getBoundingClientRect().top - + element.getBoundingClientRect().bottom + : null, + }; + }); + expect(geometry.paddingLeft).toBe('52px'); + expect(geometry.gapToList).toBeCloseTo(24, 0); - // The cover follows the selected tab. await page.locator('.season-tabs__pill').nth(1).click(); - await expect(cover).toHaveAttribute( - 'src', - new RegExp(`season(-big)?-${targetSeries.series_id}-2/`) - ); + await expect(description).toBeVisible(); + await expect( + page.locator('app-season-container img[src*="/season-"]') + ).toHaveCount(0); }); // --------------------------------------------------------------------------- @@ -854,7 +870,7 @@ test('@xtream series watched toggle — marks every season from the header menu, await expect(page.locator('.season-tabs__done')).toHaveCount(3, { timeout: 15_000, }); - await expect(page.locator('.episode-card--watched')).toHaveCount(8); + await expect(page.locator('.episode-item--watched')).toHaveCount(8); // The action now offers unwatch-all. seriesToggle = await seriesMenuRow(page, 'toggle-series-watched'); @@ -878,7 +894,7 @@ test('@xtream series watched toggle — marks every season from the header menu, await expect(page.locator('.season-tabs__done')).toHaveCount(0, { timeout: 15_000, }); - await expect(page.locator('.episode-card--watched')).toHaveCount(0); + await expect(page.locator('.episode-item--watched')).toHaveCount(0); seriesToggle = await seriesMenuRow(page, 'toggle-series-watched'); await expect(seriesToggle).toContainText('Mark series as watched (24)'); }); @@ -999,11 +1015,12 @@ for (const theme of ['light', 'dark']) { const favorite = shell .locator('[data-testid="series-favorite-toggle"]') .first(); - const card = shell.locator('.episode-card').first(); - // The card is flat; its visible edge is the artwork's hairline. - const artwork = card.locator('.episode-card__thumbnail'); + // List is the default view: borderless rows whose visible edge is + // the thumbnail's hairline. + const row = shell.locator('.episode-list-item').first(); + const rowArtwork = row.locator('.episode-item__thumb'); const toggle = shell.locator('mat-button-toggle-group'); - await expect(card).toBeVisible(); + await expect(row).toBeVisible(); await expect(shell.locator('.hero__content')).toHaveCSS('opacity', '1'); await page.mouse.move(0, 0); // A subtle edge must survive compositing on the actual theme surface. @@ -1011,7 +1028,7 @@ for (const theme of ['light', 'dark']) { await expect .poll(() => rasterizedBorderContrast(favorite)) .toBeGreaterThan(1.1); - for (const surface of [artwork, toggle]) { + for (const surface of [rowArtwork, toggle]) { await expect .poll(async () => (await surfaceContrast(surface)).border) .toBeGreaterThan(1.15); @@ -1021,41 +1038,75 @@ for (const theme of ['light', 'dark']) { .poll(async () => (await surfaceContrast(selected)).fill) .toBeGreaterThan(1.1); await shell.screenshot({ - path: testInfo.outputPath(`series-grid-${theme}.png`), + path: testInfo.outputPath(`series-list-${theme}.png`), + animations: 'disabled', + }); + // Hover reveals the reserved actions without moving the text. + const title = row.locator('.episode-item__title'); + const before = await title.boundingBox(); + await row.hover(); + await expect(row.locator('.episode-item__actions')).toHaveCSS( + 'opacity', + '1' + ); + expect(await title.boundingBox()).toEqual(before); + await row.screenshot({ + path: testInfo.outputPath(`series-row-hover-${theme}.png`), animations: 'disabled', }); - await card.hover(); - await expect - .poll(async () => (await surfaceContrast(artwork)).border) - .toBeGreaterThan(1.15); await page - .getByRole('radio', { name: 'List view', exact: true }) + .getByRole('radio', { name: 'Grid view', exact: true }) .click(); - const row = shell.locator('.episode-list-item').first(); - await expect(row).toBeVisible(); + const card = shell.locator('.episode-card').first(); + const artwork = card.locator('.episode-card__thumbnail'); + await expect(card).toBeVisible(); await page.mouse.move(0, 0); await expect - .poll(async () => (await surfaceContrast(row)).border) + .poll(async () => (await surfaceContrast(artwork)).border) .toBeGreaterThan(1.15); await expect .poll(async () => (await surfaceContrast(selected)).fill) .toBeGreaterThan(1.1); await shell.screenshot({ - path: testInfo.outputPath(`series-list-${theme}.png`), + path: testInfo.outputPath(`series-grid-${theme}.png`), animations: 'disabled', }); - await row.hover(); - await expect - .poll(async () => (await surfaceContrast(row)).border) - .toBeGreaterThan(1.15); await page - .getByRole('radio', { name: 'List view', exact: true }) + .getByRole('radio', { name: 'Grid view', exact: true }) .focus(); - await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowRight'); await expect( - page.getByRole('radio', { name: 'Grid view', exact: true }) + page.getByRole('radio', { name: 'List view', exact: true }) ).toBeChecked(); - await expect(card).toBeVisible(); + await expect(row).toBeVisible(); + + // Phone width: the title keeps readable room and the actions move + // under the text instead of squeezing it. + await page.setViewportSize({ width: 360, height: 740 }); + await row.hover(); + const narrowTitle = await title.boundingBox(); + const narrowActions = await row + .locator('.episode-item__actions') + .boundingBox(); + expect(narrowTitle?.width ?? 0).toBeGreaterThan(120); + expect(narrowActions?.y ?? 0).toBeGreaterThanOrEqual( + (narrowTitle?.y ?? 0) + (narrowTitle?.height ?? 0) + ); + // A grid card at that width keeps its text under the artwork. + await page + .getByRole('radio', { name: 'Grid view', exact: true }) + .click(); + const narrowCard = shell.locator('.episode-card').first(); + const cardArt = await narrowCard + .locator('.episode-item__thumb') + .boundingBox(); + const cardTitle = await narrowCard + .locator('.episode-item__title') + .boundingBox(); + expect(cardArt?.width ?? 0).toBeGreaterThan(200); + expect(cardTitle?.y ?? 0).toBeGreaterThanOrEqual( + (cardArt?.y ?? 0) + (cardArt?.height ?? 0) + ); }); test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({ diff --git a/apps/web/src/_cover-size.scss b/apps/web/src/_cover-size.scss index 74e3de6f4..6a45242b0 100644 --- a/apps/web/src/_cover-size.scss +++ b/apps/web/src/_cover-size.scss @@ -1,39 +1,32 @@ // Cover size tokens — consumed by all cover/poster grids and rails. // Toggled via [data-cover-size] attribute on , set from SettingsStore. -// --season-cover-width sizes the season cover beside the season tabs on the -// series detail page; medium matches the About block's 120px poster. :root { --cover-grid-min-width: 148px; --cover-rail-width: 172px; --cover-gap: 16px; - --season-cover-width: 120px; } :root[data-cover-size='small'] { --cover-grid-min-width: 120px; --cover-rail-width: 140px; --cover-gap: 12px; - --season-cover-width: 96px; } :root[data-cover-size='medium'] { --cover-grid-min-width: 148px; --cover-rail-width: 172px; --cover-gap: 16px; - --season-cover-width: 120px; } :root[data-cover-size='large'] { --cover-grid-min-width: 200px; --cover-rail-width: 220px; --cover-gap: 20px; - --season-cover-width: 144px; } @media (max-width: 600px) { :root[data-cover-size='large'] { --cover-grid-min-width: 160px; --cover-rail-width: 170px; - --season-cover-width: 120px; } } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 0d1ab62b0..57fae439f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "الموسم {{number}}", "EPISODE_COUNT_ONE": "حلقة واحدة", "EPISODE_COUNT_OTHER": "{{count}} حلقات", + "EPISODES_WATCHED_COUNT": "{{count}} تمت مشاهدتها", "BACK_TO_PLAYING_EPISODE": "العودة إلى الحلقة قيد التشغيل", "GRID_VIEW": "عرض شبكي", "LIST_VIEW": "عرض قائمة", @@ -1406,6 +1407,7 @@ "EPISODES": "الحلقات", "EPISODE_NUMBER": "الحلقة {{episode}}", "RESET_PROGRESS": "إعادة ضبط التقدم", + "PLAY_FROM_BEGINNING": "التشغيل من البداية", "OTHER_SOURCES": "مصادر أخرى", "OPEN_IN_EXTERNAL_PLAYER": "فتح في مشغّل خارجي", "SHOW_IN_CATEGORY": "عرض في الفئة", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} س {{minutes}} د", "DURATION_MINUTES": "{{minutes}} د", "WATCHED": "تمت مشاهدته", + "RESUME_AT": "استئناف عند {{time}}", "TRAILER_MUTE": "كتم صوت الإعلان", "TRAILER_UNMUTE": "تشغيل صوت الإعلان", "PROGRESS_RESET": "تمت إعادة ضبط التقدم", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3720b8271..84364f9ed 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "الموسم {{number}}", "EPISODE_COUNT_ONE": "حلقة وحدة", "EPISODE_COUNT_OTHER": "{{count}} حلقات", + "EPISODES_WATCHED_COUNT": "{{count}} تشافو", "BACK_TO_PLAYING_EPISODE": "رجوع للحلقة اللي كتشغل", "GRID_VIEW": "عرض الشبكة", "LIST_VIEW": "عرض اللائحة", @@ -1406,6 +1407,7 @@ "EPISODES": "الحلقات", "EPISODE_NUMBER": "الحلقة {{episode}}", "RESET_PROGRESS": "رجّع التقدم", + "PLAY_FROM_BEGINNING": "شغّل من اللول", "OTHER_SOURCES": "مصادر أخرى", "OPEN_IN_EXTERNAL_PLAYER": "حلّ فمشغّل خارجي", "SHOW_IN_CATEGORY": "ورّي فالفئة", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} س {{minutes}} د", "DURATION_MINUTES": "{{minutes}} د", "WATCHED": "تشاف", + "RESUME_AT": "كمّل من {{time}}", "TRAILER_MUTE": "سكّت الإعلان", "TRAILER_UNMUTE": "خدّم صوت الإعلان", "PROGRESS_RESET": "ترجّع التقدم", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index c127d968a..245eecce6 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Сезон {{number}}", "EPISODE_COUNT_ONE": "1 эпізод", "EPISODE_COUNT_OTHER": "{{count}} эпізодаў", + "EPISODES_WATCHED_COUNT": "{{count}} прагледжана", "BACK_TO_PLAYING_EPISODE": "Назад да бягучага эпізоду", "GRID_VIEW": "Выгляд сеткай", "LIST_VIEW": "Выгляд спісам", @@ -1406,6 +1407,7 @@ "EPISODES": "Серыі", "EPISODE_NUMBER": "Серыя {{episode}}", "RESET_PROGRESS": "Скінуць прагрэс", + "PLAY_FROM_BEGINNING": "Глядзець з пачатку", "OTHER_SOURCES": "Іншыя крыніцы", "OPEN_IN_EXTERNAL_PLAYER": "Адкрыць у знешнім плэеры", "SHOW_IN_CATEGORY": "Паказаць у катэгорыі", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} г {{minutes}} хв", "DURATION_MINUTES": "{{minutes}} хв", "WATCHED": "прагледжана", + "RESUME_AT": "Працягнуць з {{time}}", "TRAILER_MUTE": "Выключыць гук трэйлера", "TRAILER_UNMUTE": "Уключыць гук трэйлера", "PROGRESS_RESET": "Прагрэс скінуты", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8475d6bf2..0fba0c5ce 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Staffel {{number}}", "EPISODE_COUNT_ONE": "1 Episode", "EPISODE_COUNT_OTHER": "{{count}} Episoden", + "EPISODES_WATCHED_COUNT": "{{count}} gesehen", "BACK_TO_PLAYING_EPISODE": "Zurück zur laufenden Folge", "GRID_VIEW": "Rasteransicht", "LIST_VIEW": "Listenansicht", @@ -1406,6 +1407,7 @@ "EPISODES": "Episoden", "EPISODE_NUMBER": "Episode {{episode}}", "RESET_PROGRESS": "Fortschritt zurücksetzen", + "PLAY_FROM_BEGINNING": "Von Anfang an abspielen", "OTHER_SOURCES": "Andere Quellen", "OPEN_IN_EXTERNAL_PLAYER": "In externem Player öffnen", "SHOW_IN_CATEGORY": "In Kategorie anzeigen", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min", "DURATION_MINUTES": "{{minutes}} min", "WATCHED": "gesehen", + "RESUME_AT": "Fortsetzen bei {{time}}", "TRAILER_MUTE": "Trailer stummschalten", "TRAILER_UNMUTE": "Trailer-Ton einschalten", "PROGRESS_RESET": "Fortschritt zurückgesetzt", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 83d5f05ed..435a4cb76 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Σεζόν {{number}}", "EPISODE_COUNT_ONE": "1 επεισόδιο", "EPISODE_COUNT_OTHER": "{{count}} επεισόδια", + "EPISODES_WATCHED_COUNT": "{{count}} προβλήθηκαν", "BACK_TO_PLAYING_EPISODE": "Πίσω στο επεισόδιο που παίζει", "GRID_VIEW": "Προβολή πλέγματος", "LIST_VIEW": "Προβολή λίστας", @@ -1406,6 +1407,7 @@ "EPISODES": "Επεισόδια", "EPISODE_NUMBER": "Επεισόδιο {{episode}}", "RESET_PROGRESS": "Επαναφορά προόδου", + "PLAY_FROM_BEGINNING": "Αναπαραγωγή από την αρχή", "OTHER_SOURCES": "Άλλες πηγές", "OPEN_IN_EXTERNAL_PLAYER": "Άνοιγμα σε εξωτερικό player", "SHOW_IN_CATEGORY": "Εμφάνιση στην κατηγορία", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} ώ {{minutes}} λ", "DURATION_MINUTES": "{{minutes}} λεπ.", "WATCHED": "παρακολουθήθηκε", + "RESUME_AT": "Συνέχεια από {{time}}", "TRAILER_MUTE": "Σίγαση τρέιλερ", "TRAILER_UNMUTE": "Ενεργοποίηση ήχου τρέιλερ", "PROGRESS_RESET": "Η πρόοδος επαναφέρθηκε", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 95277e829..bec118552 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Season {{number}}", "EPISODE_COUNT_ONE": "1 episode", "EPISODE_COUNT_OTHER": "{{count}} episodes", + "EPISODES_WATCHED_COUNT": "{{count}} watched", "BACK_TO_PLAYING_EPISODE": "Back to playing episode", "GRID_VIEW": "Grid view", "LIST_VIEW": "List view", @@ -1406,6 +1407,7 @@ "EPISODES": "Episodes", "EPISODE_NUMBER": "Episode {{episode}}", "RESET_PROGRESS": "Reset progress", + "PLAY_FROM_BEGINNING": "Play from beginning", "OTHER_SOURCES": "Other sources", "OPEN_IN_EXTERNAL_PLAYER": "Open in external player", "SHOW_IN_CATEGORY": "Show in category", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min", "DURATION_MINUTES": "{{minutes}} min", "WATCHED": "watched", + "RESUME_AT": "Resume at {{time}}", "TRAILER_MUTE": "Mute trailer", "TRAILER_UNMUTE": "Unmute trailer", "PROGRESS_RESET": "Progress reset", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index bf6d47b02..3ebff226b 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Temporada {{number}}", "EPISODE_COUNT_ONE": "1 episodio", "EPISODE_COUNT_OTHER": "{{count}} episodios", + "EPISODES_WATCHED_COUNT": "{{count}} vistos", "BACK_TO_PLAYING_EPISODE": "Volver al episodio en reproducción", "GRID_VIEW": "Vista de cuadrícula", "LIST_VIEW": "Vista de lista", @@ -1406,6 +1407,7 @@ "EPISODES": "Episodios", "EPISODE_NUMBER": "Episodio {{episode}}", "RESET_PROGRESS": "Restablecer progreso", + "PLAY_FROM_BEGINNING": "Reproducir desde el principio", "OTHER_SOURCES": "Otras fuentes", "OPEN_IN_EXTERNAL_PLAYER": "Abrir en reproductor externo", "SHOW_IN_CATEGORY": "Mostrar en la categoría", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min", "DURATION_MINUTES": "{{minutes}} min", "WATCHED": "visto", + "RESUME_AT": "Continuar en {{time}}", "TRAILER_MUTE": "Silenciar tráiler", "TRAILER_UNMUTE": "Activar sonido del tráiler", "PROGRESS_RESET": "Progreso restablecido", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 6e3698fd2..24131a8f1 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Saison {{number}}", "EPISODE_COUNT_ONE": "1 épisode", "EPISODE_COUNT_OTHER": "{{count}} épisodes", + "EPISODES_WATCHED_COUNT": "{{count}} vus", "BACK_TO_PLAYING_EPISODE": "Retour à l'épisode en cours", "GRID_VIEW": "Vue en grille", "LIST_VIEW": "Vue en liste", @@ -1406,6 +1407,7 @@ "EPISODES": "Épisodes", "EPISODE_NUMBER": "Épisode {{episode}}", "RESET_PROGRESS": "Réinitialiser la progression", + "PLAY_FROM_BEGINNING": "Lire depuis le début", "OTHER_SOURCES": "Autres sources", "OPEN_IN_EXTERNAL_PLAYER": "Ouvrir dans un lecteur externe", "SHOW_IN_CATEGORY": "Afficher dans la catégorie", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min", "DURATION_MINUTES": "{{minutes}} min", "WATCHED": "vu", + "RESUME_AT": "Reprendre à {{time}}", "TRAILER_MUTE": "Couper le son de la bande-annonce", "TRAILER_UNMUTE": "Activer le son de la bande-annonce", "PROGRESS_RESET": "Progression réinitialisée", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index adf6532df..fb201a877 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "{{number}}. évad", "EPISODE_COUNT_ONE": "1 epizód", "EPISODE_COUNT_OTHER": "{{count}} epizód", + "EPISODES_WATCHED_COUNT": "{{count}} megnézve", "BACK_TO_PLAYING_EPISODE": "Vissza a lejátszás alatt álló epizódhoz", "GRID_VIEW": "Rácsnézet", "LIST_VIEW": "Listanézet", @@ -1406,6 +1407,7 @@ "EPISODES": "Epizódok", "EPISODE_NUMBER": "{{episode}}. epizód", "RESET_PROGRESS": "Haladás törlése", + "PLAY_FROM_BEGINNING": "Lejátszás az elejétől", "OTHER_SOURCES": "Más források", "OPEN_IN_EXTERNAL_PLAYER": "Megnyitás külső lejátszóban", "SHOW_IN_CATEGORY": "Megjelenítés a kategóriában", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} ó {{minutes}} p", "DURATION_MINUTES": "{{minutes}} perc", "WATCHED": "megnézve", + "RESUME_AT": "Folytatás innen: {{time}}", "TRAILER_MUTE": "Előzetes némítása", "TRAILER_UNMUTE": "Előzetes hangjának bekapcsolása", "PROGRESS_RESET": "Haladás törölve", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index a497a72e1..f3f7db425 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Stagione {{number}}", "EPISODE_COUNT_ONE": "1 episodio", "EPISODE_COUNT_OTHER": "{{count}} episodi", + "EPISODES_WATCHED_COUNT": "{{count}} visti", "BACK_TO_PLAYING_EPISODE": "Torna all'episodio in riproduzione", "GRID_VIEW": "Vista griglia", "LIST_VIEW": "Vista elenco", @@ -1406,6 +1407,7 @@ "EPISODES": "Episodi", "EPISODE_NUMBER": "Episodio {{episode}}", "RESET_PROGRESS": "Azzera avanzamento", + "PLAY_FROM_BEGINNING": "Riproduci dall'inizio", "OTHER_SOURCES": "Altre fonti", "OPEN_IN_EXTERNAL_PLAYER": "Apri nel lettore esterno", "SHOW_IN_CATEGORY": "Mostra nella categoria", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min", "DURATION_MINUTES": "{{minutes}} min", "WATCHED": "visto", + "RESUME_AT": "Riprendi da {{time}}", "TRAILER_MUTE": "Disattiva audio del trailer", "TRAILER_UNMUTE": "Attiva audio del trailer", "PROGRESS_RESET": "Avanzamento azzerato", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index edd8be7a3..dd739f4c1 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "シーズン{{number}}", "EPISODE_COUNT_ONE": "1 エピソード", "EPISODE_COUNT_OTHER": "{{count}} エピソード", + "EPISODES_WATCHED_COUNT": "{{count}} 本視聴済み", "BACK_TO_PLAYING_EPISODE": "再生中のエピソードに戻る", "GRID_VIEW": "グリッド表示", "LIST_VIEW": "リスト表示", @@ -1406,6 +1407,7 @@ "EPISODES": "エピソード", "EPISODE_NUMBER": "第{{episode}}話", "RESET_PROGRESS": "再生位置をリセット", + "PLAY_FROM_BEGINNING": "最初から再生", "OTHER_SOURCES": "他のソース", "OPEN_IN_EXTERNAL_PLAYER": "外部プレーヤーで開く", "SHOW_IN_CATEGORY": "カテゴリで表示", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}}時間{{minutes}}分", "DURATION_MINUTES": "{{minutes}}分", "WATCHED": "視聴済み", + "RESUME_AT": "{{time}} から再開", "TRAILER_MUTE": "予告編をミュート", "TRAILER_UNMUTE": "予告編の音を出す", "PROGRESS_RESET": "再生位置をリセットしました", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index b85d35f3a..6fcbb517e 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "시즌 {{number}}", "EPISODE_COUNT_ONE": "에피소드 1개", "EPISODE_COUNT_OTHER": "에피소드 {{count}}개", + "EPISODES_WATCHED_COUNT": "{{count}}개 시청함", "BACK_TO_PLAYING_EPISODE": "재생 중인 에피소드로 돌아가기", "GRID_VIEW": "그리드 보기", "LIST_VIEW": "목록 보기", @@ -1406,6 +1407,7 @@ "EPISODES": "에피소드", "EPISODE_NUMBER": "{{episode}}화", "RESET_PROGRESS": "진행 상태 초기화", + "PLAY_FROM_BEGINNING": "처음부터 재생", "OTHER_SOURCES": "다른 소스", "OPEN_IN_EXTERNAL_PLAYER": "외부 플레이어로 열기", "SHOW_IN_CATEGORY": "카테고리에서 보기", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}}시간 {{minutes}}분", "DURATION_MINUTES": "{{minutes}}분", "WATCHED": "시청함", + "RESUME_AT": "{{time}}부터 이어보기", "TRAILER_MUTE": "예고편 음소거", "TRAILER_UNMUTE": "예고편 소리 켜기", "PROGRESS_RESET": "진행 상태를 초기화했습니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 7b81617fc..2e0762b59 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Seizoen {{number}}", "EPISODE_COUNT_ONE": "1 aflevering", "EPISODE_COUNT_OTHER": "{{count}} afleveringen", + "EPISODES_WATCHED_COUNT": "{{count}} bekeken", "BACK_TO_PLAYING_EPISODE": "Terug naar lopende aflevering", "GRID_VIEW": "Rasterweergave", "LIST_VIEW": "Lijstweergave", @@ -1406,6 +1407,7 @@ "EPISODES": "Afleveringen", "EPISODE_NUMBER": "Aflevering {{episode}}", "RESET_PROGRESS": "Voortgang wissen", + "PLAY_FROM_BEGINNING": "Vanaf het begin afspelen", "OTHER_SOURCES": "Andere bronnen", "OPEN_IN_EXTERNAL_PLAYER": "Openen in externe speler", "SHOW_IN_CATEGORY": "Tonen in categorie", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} u {{minutes}} min", "DURATION_MINUTES": "{{minutes}} min", "WATCHED": "bekeken", + "RESUME_AT": "Hervatten bij {{time}}", "TRAILER_MUTE": "Trailer dempen", "TRAILER_UNMUTE": "Trailergeluid aan", "PROGRESS_RESET": "Voortgang gewist", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 2f4b1cf83..7efe3bb3d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Sezon {{number}}", "EPISODE_COUNT_ONE": "1 odcinek", "EPISODE_COUNT_OTHER": "{{count}} odcinków", + "EPISODES_WATCHED_COUNT": "{{count}} obejrzanych", "BACK_TO_PLAYING_EPISODE": "Wróć do odtwarzanego odcinka", "GRID_VIEW": "Widok siatki", "LIST_VIEW": "Widok listy", @@ -1406,6 +1407,7 @@ "EPISODES": "Odcinki", "EPISODE_NUMBER": "Odcinek {{episode}}", "RESET_PROGRESS": "Resetuj postęp", + "PLAY_FROM_BEGINNING": "Odtwórz od początku", "OTHER_SOURCES": "Inne źródła", "OPEN_IN_EXTERNAL_PLAYER": "Otwórz w zewnętrznym odtwarzaczu", "SHOW_IN_CATEGORY": "Pokaż w kategorii", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} godz. {{minutes}} min", "DURATION_MINUTES": "{{minutes}} min", "WATCHED": "obejrzane", + "RESUME_AT": "Wznów od {{time}}", "TRAILER_MUTE": "Wycisz zwiastun", "TRAILER_UNMUTE": "Włącz dźwięk zwiastuna", "PROGRESS_RESET": "Postęp zresetowany", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 6c49bead8..f3e5403bf 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Temporada {{number}}", "EPISODE_COUNT_ONE": "1 episódio", "EPISODE_COUNT_OTHER": "{{count}} episódios", + "EPISODES_WATCHED_COUNT": "{{count}} assistidos", "BACK_TO_PLAYING_EPISODE": "Voltar ao episódio em reprodução", "GRID_VIEW": "Visualização em grade", "LIST_VIEW": "Visualização em lista", @@ -1406,6 +1407,7 @@ "EPISODES": "Episódios", "EPISODE_NUMBER": "Episódio {{episode}}", "RESET_PROGRESS": "Redefinir progresso", + "PLAY_FROM_BEGINNING": "Reproduzir desde o início", "OTHER_SOURCES": "Outras fontes", "OPEN_IN_EXTERNAL_PLAYER": "Abrir no reprodutor externo", "SHOW_IN_CATEGORY": "Mostrar na categoria", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} h {{minutes}} min", "DURATION_MINUTES": "{{minutes}} min", "WATCHED": "assistido", + "RESUME_AT": "Retomar em {{time}}", "TRAILER_MUTE": "Silenciar trailer", "TRAILER_UNMUTE": "Ativar som do trailer", "PROGRESS_RESET": "Progresso redefinido", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index dac6e8569..0a01750c3 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "Сезон {{number}}", "EPISODE_COUNT_ONE": "1 эпизод", "EPISODE_COUNT_OTHER": "{{count}} эпизодов", + "EPISODES_WATCHED_COUNT": "{{count}} просмотрено", "BACK_TO_PLAYING_EPISODE": "Вернуться к воспроизводимому эпизоду", "GRID_VIEW": "Вид сеткой", "LIST_VIEW": "Вид списком", @@ -1406,6 +1407,7 @@ "EPISODES": "Серии", "EPISODE_NUMBER": "Серия {{episode}}", "RESET_PROGRESS": "Сбросить прогресс", + "PLAY_FROM_BEGINNING": "Смотреть с начала", "OTHER_SOURCES": "Другие источники", "OPEN_IN_EXTERNAL_PLAYER": "Открыть во внешнем плеере", "SHOW_IN_CATEGORY": "Показать в категории", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} ч {{minutes}} мин", "DURATION_MINUTES": "{{minutes}} мин", "WATCHED": "просмотрено", + "RESUME_AT": "Продолжить с {{time}}", "TRAILER_MUTE": "Выключить звук трейлера", "TRAILER_UNMUTE": "Включить звук трейлера", "PROGRESS_RESET": "Прогресс сброшен", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 340af8097..0e22c7e16 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "{{number}}. Sezon", "EPISODE_COUNT_ONE": "1 bölüm", "EPISODE_COUNT_OTHER": "{{count}} bölüm", + "EPISODES_WATCHED_COUNT": "{{count}} izlendi", "BACK_TO_PLAYING_EPISODE": "Oynatılan bölüme dön", "GRID_VIEW": "Izgara görünümü", "LIST_VIEW": "Liste görünümü", @@ -1406,6 +1407,7 @@ "EPISODES": "Bölümler", "EPISODE_NUMBER": "Bölüm {{episode}}", "RESET_PROGRESS": "İlerlemeyi sıfırla", + "PLAY_FROM_BEGINNING": "Baştan oynat", "OTHER_SOURCES": "Diğer kaynaklar", "OPEN_IN_EXTERNAL_PLAYER": "Harici oynatıcıda aç", "SHOW_IN_CATEGORY": "Kategoride göster", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} sa {{minutes}} dk", "DURATION_MINUTES": "{{minutes}} dk", "WATCHED": "izlendi", + "RESUME_AT": "{{time}} konumundan devam et", "TRAILER_MUTE": "Fragmanı sessize al", "TRAILER_UNMUTE": "Fragman sesini aç", "PROGRESS_RESET": "İlerleme sıfırlandı", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index b7accbce1..d557b6d4a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "第 {{number}} 季", "EPISODE_COUNT_ONE": "1 集", "EPISODE_COUNT_OTHER": "{{count}} 集", + "EPISODES_WATCHED_COUNT": "已看 {{count}} 集", "BACK_TO_PLAYING_EPISODE": "返回正在播放的剧集", "GRID_VIEW": "网格视图", "LIST_VIEW": "列表视图", @@ -1406,6 +1407,7 @@ "EPISODES": "剧集", "EPISODE_NUMBER": "第 {{episode}} 集", "RESET_PROGRESS": "重置进度", + "PLAY_FROM_BEGINNING": "从头播放", "OTHER_SOURCES": "其他来源", "OPEN_IN_EXTERNAL_PLAYER": "在外部播放器中打开", "SHOW_IN_CATEGORY": "在分类中显示", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} 小时 {{minutes}} 分钟", "DURATION_MINUTES": "{{minutes}} 分钟", "WATCHED": "已观看", + "RESUME_AT": "从 {{time}} 继续", "TRAILER_MUTE": "预告片静音", "TRAILER_UNMUTE": "取消预告片静音", "PROGRESS_RESET": "进度已重置", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 112b787f6..b5921f631 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1323,6 +1323,7 @@ "SEASON_TAB": "第 {{number}} 季", "EPISODE_COUNT_ONE": "1 集", "EPISODE_COUNT_OTHER": "{{count}} 集", + "EPISODES_WATCHED_COUNT": "已看 {{count}} 集", "BACK_TO_PLAYING_EPISODE": "返回正在播放的集數", "GRID_VIEW": "格狀檢視", "LIST_VIEW": "清單檢視", @@ -1406,6 +1407,7 @@ "EPISODES": "集數", "EPISODE_NUMBER": "第 {{episode}} 集", "RESET_PROGRESS": "重設進度", + "PLAY_FROM_BEGINNING": "從頭播放", "OTHER_SOURCES": "其他來源", "OPEN_IN_EXTERNAL_PLAYER": "在外部播放器開啟", "SHOW_IN_CATEGORY": "在分類中顯示", @@ -1415,6 +1417,7 @@ "DURATION_HOURS_MINUTES": "{{hours}} 小時 {{minutes}} 分鐘", "DURATION_MINUTES": "{{minutes}} 分鐘", "WATCHED": "已觀看", + "RESUME_AT": "從 {{time}} 繼續", "TRAILER_MUTE": "預告片靜音", "TRAILER_UNMUTE": "取消預告片靜音", "PROGRESS_RESET": "進度已重設", diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index f37d6d652..3f4f4e52d 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -111,6 +111,9 @@ html { // Live / on-air accent (broadcast indicator) --app-live-color: #d12c2c; + // Done / watched status (episode watched toggle) + --app-success-color: #15803d; + // Content area --app-content-shadow: -2px 4px 12px rgba(0, 0, 0, 0.06); @@ -211,6 +214,9 @@ html { // Live / on-air accent (broadcast indicator) --app-live-color: #ff8f8f; + // Done / watched status (episode watched toggle) + --app-success-color: #4ade80; + // Content area --app-content-shadow: -4px 8px 20px rgba(0, 0, 0, 0.18); diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index a26601334..7923f995f 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -286,19 +286,16 @@ episode" chip appears when the playing episode is outside the opened season. Season descriptions come from `get_series_info` seasons (Xtream) or `TmdbEnrichmentService.getSeason` (Stalker). -The tabs and description sit in a **season card** with an optional cover -column: the selected season's own poster (`seasonPosters` input, keyed like -`seasonDescriptions`; TMDB season poster first, provider `seasons[].cover_big` -next — see "Season/Episode Enrichment" in `tmdb-metadata-enrichment.md`). -The column is sized by `--season-cover-width` (96 / 120 / 144px for -`Settings.coverSize` small / medium / large, `apps/web/src/_cover-size.scss`; -medium matches the About block's poster) and is not rendered at all — the -card collapses to one column and the tabs render exactly as before — when -the selected season has no poster, when the item has a single season (that -poster is the show poster a few hundred pixels below the hero), or when the -image request failed. The hero poster never follows the season: the show -keeps its identity element, the season gets its own picture next to its own -text. The fullscreen episode panel shows the same poster as a compact season +The season picker carries no artwork: chips up to four seasons, a menu +button with "N episodes · M watched" rows from five. The selected season's +synopsis sits under the header, offset to the episode number column, only +when it is non-empty and does not repeat the series description. The season +posters (`seasonPosters` input, keyed like `seasonDescriptions`; TMDB season +poster first, provider `seasons[].cover_big` next — see "Season/Episode +Enrichment" in `tmdb-metadata-enrichment.md`) are not drawn on the detail +page; the container only uses them to recognise an episode still that is the +season cover again. The hero poster never follows the season. +The fullscreen episode panel shows the same poster as a compact season strip (poster, season name, episode count — `PORTALS.EPISODE_COUNT_ONE` / `PORTALS.EPISODE_COUNT_OTHER`) above its season tabs, under the same gates, from `PortalInlinePlayerComponent.seasonPosters` through diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 39cd04a35..7a4773f5e 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -65,6 +65,7 @@ in `apps/web/src/m3-theme.scss`): - `--app-widget-border` / `--app-rail-border` — hairlines - `--app-on-surface` — primary text - `--app-eyebrow-color` — secondary/muted text +- `--app-success-color` — done/watched status (episode watched toggle) Angular Material mixins and Material-component overrides may use the tokens owned by that component. Outside a Material-owned component, prefer the @@ -158,8 +159,11 @@ VOD and series detail screens share `app-portal-detail-shell` and kind label ("Movie · playlist") → title → chips → description (three lines, "More") → resume bar → action row → credits, with the poster bottom-aligned on the left and the backdrop filling the hero behind a two-layer scrim built -from `--app-content-bg`. The hero keeps `min(480px, 60vh)` of stage for a -16:9 backdrop. Without one, or when the provider sends the poster as the +from `--app-content-bg`. The hero keeps `min(520px, 60vh)` of stage for a +16:9 backdrop. The shell gives it a 140px `--hero-tail` and pulls the first +section up over it, so the artwork continues under that section's heading and +the vertical scrim ends on the exact page surface there, never as a band at +the hero's edge. Without one, or when the provider sends the poster as the backdrop, the hero is compact (`hero--compact`, sized by its content) over the blurred poster. The layout is decided once per title, so a backdrop that TMDB enrichment adds a moment later fills the compact hero instead of @@ -177,7 +181,10 @@ keys, Escape, hosts the alternative-sources panel), `app-detail-credits` `app-detail-rail`/`app-similar-rail` (hidden scrollbar, prev/next arrows, title + year) and `TrailerDialogService`. The dashboard hero reuses the same light primary (`light-primary-button` in `libs/ui/styles/_detail-view-actions.scss`) -and chip. Series titles drop their season marker (`splitSeasonSuffix`) into a +and chip, and the same kind of tail: its artwork runs 160px under the first +rail, whose heading keeps a trace of it, and reaches the page colour before +that rail's cards (their scroll-edge fades are drawn in the page colour), with +no tail in the narrow layout. Series titles drop their season marker (`splitSeasonSuffix`) into a "Season N" chip. Rows a provider cannot serve are left out of the menu, never disabled. The page-level Sass mixin (`libs/ui/styles/_detail-view.scss`) only carries the page shell, meta items and the episodes section. @@ -317,10 +324,7 @@ Movie and series covers render in three surfaces: the catalog grid recent card (`app-content-card`, same lib) and the dashboard rails. All of them size from the `--cover-grid-min-width` / `--cover-rail-width` / `--cover-gap` tokens that `Settings.coverSize` writes onto `` as -`data-cover-size` (`apps/web/src/_cover-size.scss`). The same file carries -`--season-cover-width` (96 / 120 / 144px) for the season cover beside the -season tabs on series detail pages; medium equals the About block's 120px -poster so browse and watch share one secondary-poster size. +`data-cover-size` (`apps/web/src/_cover-size.scss`). ### Posters-only wall @@ -599,7 +603,7 @@ A title's watch progress (its resume share, `progressPercent`) has exactly one colour per context, and never a literal of its own: - **App chrome** — dashboard rail cards and the hero, catalog grids and season - episodes (`app-progress-capsule`, and the season list rows' own fill): + episodes (`app-progress-capsule`, and the episode items' own 3px bar): `--app-progress-color`, declared per theme in `apps/web/src/m3-theme.scss` as that theme's `--app-selection-color`, and declared again inside `.dark-theme` because a derived custom property resolves where it is @@ -685,6 +689,14 @@ page down. For the same reason it stays until every source that can fill it has loaded, not only the first one. Use the same rules for any page that stacks independently loading blocks. +The movie and series detail hero has an immediate skeleton too, built from +the shared shimmer (`libs/ui/components/src/lib/styles/_skeleton-shimmer.scss`) +at the loaded hero's geometry: stage height, bottom-aligned eyebrow, title, +one row of chips, two description lines, the action row and two credit lines. +The Xtream series page adds an episodes section skeleton under it, and the +Stalker series hero holds the Play button's place +(`app-detail-action-skeleton`) while its seasons load. + ### Reload with content on screen: non-destructive indicator A reload of a list that is already rendered (the collection page's @@ -735,17 +747,55 @@ If the label is too long for the rail, shorten the label key instead of shrinkin The action row never wraps on a desktop window: the primary, the Trailer button, then the 44px icon buttons and the "…" menu. Season and series actions (mark watched, download season, reset progress) live in that menu -and drive the season container's presenters; the container's header is -"Episodes" with the season pills and the episode count, plus the grid/list -toggle. The checked toggle uses `--app-selection-surface` and -`--app-selection-color`; hover uses the app's neutral surface treatment. +and drive the season container's presenters. -Episode cards are flat: a 16:9 thumbnail (with a light hairline so its edge -survives the light theme) and a 3px watched bar at its bottom edge, "N. Title", the plot clamped to two lines and a "42 min · -18m left / watched" line. List rows keep a subtle neutral fill. The selected -season's cover and synopsis sit in a compact strip under the header only -when present. Keep these treatments in the shared season components so -Xtream and Stalker share the same behavior. +Every section below the hero (Episodes, Cast & crew, Similar) uses +`app-detail-section-header`: an 18px/600 title, a muted 12.5px tabular +counter, a lead slot (the season pills or dropdown, a 30px pill) and an end +slot (the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px +between a header and its content and 56px after the last one. The Episodes +counter reads "N episodes · M watched". The list/grid switch is a segmented +track whose checked segment is a neutral raised fill with heading ink; the +list is the default view. + +Episodes render through `app-episode-item` as a flat list row or grid card, +never a bordered box. A row is a number column, a 168px 16:9 thumbnail (with +a light hairline border so its edge survives the light theme), the title with +a "46 min · 12 Jan" meta line (time left once started, "Resume at 12:34" when +no runtime is known) and the plot clamped to +two lines at 74ch, and a 112px action slot. Rows are separated by a hairline +that gives way around a hovered, focused or highlighted row. Watched episodes +carry a check badge on the thumbnail and muted text, and never a bar; a +started one gets a 3px bar and the time left. Mark watched, download and "…" +(Episode details, Play from beginning) are always laid out and only fade in on +`:hover`/`:focus-within`, so nothing shifts. The whole item is one stretched +button: Tab focuses it and Enter plays. The playing episode, else the most +recently watched unfinished one, is highlighted. Without distinct stills the +thumbnails show a dimmed fallback, not a column of bright posters. The season +picker never shows a season poster: chips up to four seasons, a menu button +from five whose rows read "Season N" and "N episodes · M watched" (the second +part left out at zero, and no count at all for a lazy Stalker VOD season the +portal has not answered yet — `seasonLoadStates`). The selected season's +synopsis sits under the header, +offset 52px to the number column and 24px above the list, only when present; +one that repeats the series description (or is cut short from it) is dropped, +and without one no row renders. + +A season whose final episode data has no plot and no usable still (a still +equal to the series poster or the season cover counts as none) renders bare: +44px rows of number, title, meta and an inline check or 72px bar, the same +action slot, and no grid toggle. Only some stills or plots missing keeps full +rows (dimmed tile, line left out). While the provider list is outstanding, +and while a TMDB lookup that could still fill a season that would otherwise +render bare is outstanding, the episodes are skeleton rows at the full row's +geometry (`app-episode-skeleton`); a season the provider already describes +renders at once and the metadata lands in place. The state is decided from +data (`episode-meta-state.util.ts`); the metadata wait is only capped +(`EPISODE_METADATA_WAIT_MS`, 4s) because TMDB requests carry no timeout, and +it never applies to a season without episodes, which no host enriches. At a +pane width of 480px or less the thumbnail shrinks to 96px and the actions +take their own row under the text. Keep these treatments in the shared season +components so Xtream and Stalker share the same behavior. Browser regression coverage measures the composited neutral edges and selected toggle fill, in addition to capturing light/dark grid and list screenshots. diff --git a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts index aa42c7433..c5586aefb 100644 --- a/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/offline-detail/download-offline-detail.component.spec.ts @@ -283,7 +283,7 @@ describe('DownloadOfflineDetailComponent', () => { expect(downloads.loadDownloads).toHaveBeenCalledTimes(1); expect( (fixture.nativeElement as HTMLElement).querySelector( - 'ngx-skeleton-loader' + '[data-test-id="hero-skeleton"]' ) ).toBeTruthy(); expect(text()).not.toContain('Download not found'); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/index.ts b/libs/portal/stalker/data-access/src/lib/stores/features/index.ts index 81f0ebf8e..f045e62f2 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/index.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/index.ts @@ -7,4 +7,3 @@ export * from './with-stalker-recent.feature'; export * from './with-stalker-selection.feature'; export * from './with-stalker-series.feature'; export * from './with-stalker-snapshot-refresh.feature'; - diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index e627ecac3..ea84a3837 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -25,6 +25,11 @@ export interface StalkerSelectionState { page: number; searchPhrase: string; selectedItem: StalkerVodSource | null | undefined; + /** + * True while the selected item's show-level TMDB match is in flight: the + * series view keeps episode rows it would render bare as skeletons. + */ + selectedItemTmdbPending: boolean; } const initialSelectionState: StalkerSelectionState = { @@ -36,6 +41,7 @@ const initialSelectionState: StalkerSelectionState = { page: 0, searchPhrase: '', selectedItem: undefined, + selectedItemTmdbPending: false, }; export function withStalkerSelection() { @@ -43,114 +49,138 @@ export function withStalkerSelection() { withState(initialSelectionState), withMethods((store) => { const tmdbEnrichment = inject(TmdbEnrichmentService); + // Bumped on every selection: only the latest selection's TMDB + // match may clear `selectedItemTmdbPending`, also when the same + // item is reopened or an item carries no id. + let selectionToken = 0; return { - setSelectedContentType( - type: 'vod' | 'itv' | 'series' | 'radio' - ) { - if (store.selectedContentType() === type) { - return; - } - - // Without the page reset, switching e.g. /vod -> /series with - // the same category id ('*' on both section roots) would leave - // page > 1 in place and make the new type's FIRST response an - // append onto the old type's accumulated list. - patchState(store, { selectedContentType: type, page: 0 }); - }, - setSelectedCategory(id: string | number | null) { - const newId = - id !== null && id !== undefined ? String(id) : null; - if (store.selectedCategoryId() === newId) { - return; - } - patchState(store, { - selectedCategoryId: newId, - page: 0, - }); - }, - setSelectedSerialId(id: string) { - patchState(store, { selectedSerialId: id }); - }, - setSelectedVodId(id: string) { - patchState(store, { selectedVodId: id }); - }, - setSelectedItvId(id: string) { - patchState(store, { selectedItvId: id }); - }, - setPage(page: number) { - if (store.page() === page) { - return; - } - - patchState(store, { page }); - }, - /** Advances to the next portal page (infinite-scroll append). */ - nextPage() { - patchState(store, { page: store.page() + 1 }); - }, - setSearchPhrase(phrase: string) { - if (store.searchPhrase() === phrase) { - return; - } - - // ITV search is local to each surface; keep the shared catalog pages. - patchState(store, { - searchPhrase: phrase, - ...(store.selectedContentType() === 'itv' ? {} : { page: 0 }), - }); - }, - setSelectedItem(selectedItem: StalkerVodSource | null | undefined) { - const selectedIdRaw = - selectedItem?.id !== undefined && selectedItem?.id !== null - ? selectedItem.id - : undefined; - const selectedId = - selectedIdRaw !== undefined - ? normalizeStalkerEntityId(selectedIdRaw) - : undefined; - // serialSeasonsResource fetches regular-series seasons - // (get_ordered_list&type=series) on every selectedSerialId - // change, and it is the only episode source for a `series` - // selection. Every other content type resolves episodes - // elsewhere — embedded series[] and Ministra is_series items - // are always opened as `vod` — so carrying the id there only - // fires a portal request whose result is discarded. - const contentType = store.selectedContentType(); - patchState(store, { - selectedVodId: selectedId, - selectedSerialId: - contentType === 'series' ? selectedId : undefined, - selectedItvId: selectedId, - selectedItem, - }); - - // Async, best-effort TMDB enrichment for VOD/series detail - // selections. Applies via patchState (not setSelectedItem) - // so the hook cannot recurse; live/radio items are skipped. - if ( - selectedItem && - (contentType === 'vod' || contentType === 'series') + setSelectedContentType( + type: 'vod' | 'itv' | 'series' | 'radio' ) { - void enrichStalkerSelectionWithTmdb( - store, - tmdbEnrichment, + if (store.selectedContentType() === type) { + return; + } + + // Without the page reset, switching e.g. /vod -> /series with + // the same category id ('*' on both section roots) would leave + // page > 1 in place and make the new type's FIRST response an + // append onto the old type's accumulated list. + patchState(store, { selectedContentType: type, page: 0 }); + }, + setSelectedCategory(id: string | number | null) { + const newId = + id !== null && id !== undefined ? String(id) : null; + if (store.selectedCategoryId() === newId) { + return; + } + patchState(store, { + selectedCategoryId: newId, + page: 0, + }); + }, + setSelectedSerialId(id: string) { + patchState(store, { selectedSerialId: id }); + }, + setSelectedVodId(id: string) { + patchState(store, { selectedVodId: id }); + }, + setSelectedItvId(id: string) { + patchState(store, { selectedItvId: id }); + }, + setPage(page: number) { + if (store.page() === page) { + return; + } + + patchState(store, { page }); + }, + /** Advances to the next portal page (infinite-scroll append). */ + nextPage() { + patchState(store, { page: store.page() + 1 }); + }, + setSearchPhrase(phrase: string) { + if (store.searchPhrase() === phrase) { + return; + } + + // ITV search is local to each surface; keep the shared catalog pages. + patchState(store, { + searchPhrase: phrase, + ...(store.selectedContentType() === 'itv' + ? {} + : { page: 0 }), + }); + }, + setSelectedItem( + selectedItem: StalkerVodSource | null | undefined + ) { + const selectedIdRaw = + selectedItem?.id !== undefined && + selectedItem?.id !== null + ? selectedItem.id + : undefined; + const selectedId = + selectedIdRaw !== undefined + ? normalizeStalkerEntityId(selectedIdRaw) + : undefined; + // serialSeasonsResource fetches regular-series seasons + // (get_ordered_list&type=series) on every selectedSerialId + // change, and it is the only episode source for a `series` + // selection. Every other content type resolves episodes + // elsewhere — embedded series[] and Ministra is_series items + // are always opened as `vod` — so carrying the id there only + // fires a portal request whose result is discarded. + const contentType = store.selectedContentType(); + patchState(store, { + selectedVodId: selectedId, + selectedSerialId: + contentType === 'series' ? selectedId : undefined, + selectedItvId: selectedId, selectedItem, - stalkerSelectionMediaType(selectedItem, contentType), - (enriched) => - patchState(store, { selectedItem: enriched }) - ); - } - }, - clearSelectedItem() { - patchState(store, { - selectedVodId: undefined, - selectedSerialId: undefined, - selectedItvId: undefined, - selectedItem: undefined, - }); - }, - }; + selectedItemTmdbPending: false, + }); + + // Async, best-effort TMDB enrichment for VOD/series detail + // selections. Applies via patchState (not setSelectedItem) + // so the hook cannot recurse; live/radio items are skipped. + if ( + selectedItem && + (contentType === 'vod' || contentType === 'series') + ) { + const token = ++selectionToken; + patchState(store, { + selectedItemTmdbPending: tmdbEnrichment.isEnabled(), + }); + void enrichStalkerSelectionWithTmdb( + store, + tmdbEnrichment, + selectedItem, + stalkerSelectionMediaType( + selectedItem, + contentType + ), + (enriched) => + patchState(store, { selectedItem: enriched }) + ).finally(() => { + if (token === selectionToken) { + patchState(store, { + selectedItemTmdbPending: false, + }); + } + }); + } + }, + clearSelectedItem() { + patchState(store, { + selectedVodId: undefined, + selectedSerialId: undefined, + selectedItvId: undefined, + selectedItem: undefined, + selectedItemTmdbPending: false, + }); + }, + }; }) ); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.tmdb-pending.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.tmdb-pending.spec.ts new file mode 100644 index 000000000..a4b1bbae7 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.tmdb-pending.spec.ts @@ -0,0 +1,70 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore } from '@ngrx/signals'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import { enrichStalkerSelectionWithTmdb } from '../stalker-tmdb-enrichment'; +import { withStalkerSelection } from './with-stalker-selection.feature'; + +jest.mock('../stalker-tmdb-enrichment', () => ({ + ...jest.requireActual('../stalker-tmdb-enrichment'), + enrichStalkerSelectionWithTmdb: jest.fn(), +})); + +const TestSelectionStore = signalStore(withStalkerSelection()); +const enrich = enrichStalkerSelectionWithTmdb as jest.MockedFunction< + typeof enrichStalkerSelectionWithTmdb +>; + +/** A TMDB match that settles when the test says so. */ +function deferredMatch(): () => void { + let finish!: () => void; + enrich.mockReturnValueOnce( + new Promise((resolve) => (finish = resolve)) + ); + return () => finish(); +} + +const flush = () => new Promise((resolve) => setTimeout(resolve)); + +describe('withStalkerSelection TMDB pending flag', () => { + let store: InstanceType; + + beforeEach(() => { + enrich.mockReset(); + TestBed.configureTestingModule({ + providers: [ + TestSelectionStore, + { + provide: TmdbEnrichmentService, + useValue: { isEnabled: () => true }, + }, + ], + }); + store = TestBed.inject(TestSelectionStore); + }); + + it('stays pending when an earlier match of the reopened item settles', async () => { + const finishFirst = deferredMatch(); + store.setSelectedItem({ id: '55', name: 'Series', is_series: 1 }); + const finishSecond = deferredMatch(); + store.setSelectedItem({ id: '55', name: 'Series', is_series: 1 }); + expect(store.selectedItemTmdbPending()).toBe(true); + + finishFirst(); + await flush(); + expect(store.selectedItemTmdbPending()).toBe(true); + + finishSecond(); + await flush(); + expect(store.selectedItemTmdbPending()).toBe(false); + }); + + it('clears the flag for an item without an id', async () => { + const finish = deferredMatch(); + store.setSelectedItem({ name: 'No id series', is_series: 1 }); + expect(store.selectedItemTmdbPending()).toBe(true); + + finish(); + await flush(); + expect(store.selectedItemTmdbPending()).toBe(false); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts index 2e8ff6b7e..9f3511a62 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-snapshot-refresh.feature.spec.ts @@ -290,5 +290,4 @@ describe('withStalkerSnapshotRefresh', () => { expect(store.selectedItem()?.series).toEqual([1]); }); - }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts index f24f726e3..923bcd58e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts @@ -44,6 +44,8 @@ export class StalkerSeriesTmdbSeasonsService { ); /** mapKey → season number currently being fetched (in-flight dedup) */ private readonly pending = new Map(); + /** mapKeys whose fetch has finished, with or without a result. */ + private readonly settledKeys = signal>(new Set()); /** * Overlays fetched TMDB episode data (real names, overviews, stills) @@ -86,6 +88,28 @@ export class StalkerSeriesTmdbSeasonsService { return this.forShow(this.postersByKey(), tmdbId); } + /** + * True once the season's TMDB fetch has finished (or the season is + * cached), whatever it returned. Reads a signal, so callers can use it + * in a `computed`: the series view keeps rows it would render bare as + * skeletons until then. + */ + isSeasonSettled( + tmdbId: number | null | undefined, + seasonKey: string + ): boolean { + const mapKey = `${tmdbId}|${seasonKey}`; + return ( + this.settledKeys().has(mapKey) || this.seasonsByKey().has(mapKey) + ); + } + + private markSettled(mapKey: string): void { + if (!this.settledKeys().has(mapKey)) { + this.settledKeys.update((keys) => new Set(keys).add(mapKey)); + } + } + private forShow( entries: ReadonlyMap, tmdbId: number | null | undefined @@ -127,6 +151,7 @@ export class StalkerSeriesTmdbSeasonsService { const providerSeasonNumber = Number(episodes?.[0]?.season ?? seasonKey); if (!Number.isFinite(providerSeasonNumber)) { + this.markSettled(`${tmdbId}|${seasonKey}`); return; } @@ -197,6 +222,7 @@ export class StalkerSeriesTmdbSeasonsService { } finally { if (this.pending.get(mapKey) === seasonNumber) { this.pending.delete(mapKey); + this.markSettled(mapKey); } } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 5e6f3efb5..da7f22a9d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -111,6 +111,8 @@ testId="series-quick-start" (pressed)="playQuickStartEpisode()" /> + } @else if (episodeListLoading()) { + } @if (heroPresenter.trailerEmbedUrl()) { (null); + /** + * The season the detail container shows. `selectedSeasonKey` is also + * written by the fullscreen episode picker, whose choice loads another + * season without changing the detail list; the container's loading and + * metadata flags must follow the container's own selection. + */ + private readonly detailSeasonKey = computed( + () => this.seasonContainerRef()?.selectedSeason() ?? null + ); + + /** + * True while the episode list itself is on its way: the season list + * (VOD or regular series) or the detail container's lazy VOD season. + * The season container shows skeleton rows, the hero a placeholder for + * Play. + */ + readonly episodeListLoading = computed( + () => + (this.isVodSeries() + ? this.isVodSeriesSeasonsLoading() + : this.isSerialSeasonsLoading()) || + this.isCurrentSeasonLoading(this.detailSeasonKey() ?? undefined) + ); + + /** + * True while TMDB may still fill the selected season's episodes: the + * show-level match is in flight, or — once matched — that season's + * fetch has not finished. The season container then keeps rows it would + * render bare as skeletons. Before the container reports its selection, + * the lowest season stands in. + */ + readonly seasonMetadataLoading = computed(() => { + if (!this.tmdbEnrichment.isEnabled()) { + return false; + } + if (this.stalkerStore.selectedItemTmdbPending()) { + return true; + } + const tmdbId = this.displayItem()?.info?.tmdb_id; + const seasonKey = + this.detailSeasonKey() ?? + Object.keys(this.mappedSeasons()).sort( + (a, b) => Number(a) - Number(b) + )[0]; + return ( + !!tmdbId && + !!seasonKey && + !this.tmdbSeasons.isSeasonSettled(tmdbId, seasonKey) + ); + }); + /** Season descriptions for the season tabs (TMDB overview per season). */ readonly seasonDescriptions = computed>(() => this.tmdbSeasons.descriptions(this.displayItem()?.info?.tmdb_id) @@ -847,16 +900,23 @@ export class StalkerSeriesViewComponent implements OnDestroy { * offset for an episode whose position row the page could not attach * (matched by coordinates only), so it resumes where the card said. */ + /** `fromStart` ignores the saved position (the episode menu's "Play from beginning"). */ onEpisodeClicked( episode: XtreamSerieEpisode, startTimeOverride?: number, - forcePlayer?: ExternalPlayerName + forcePlayer?: ExternalPlayerName, + fromStart = false ) { if (this.seasonWatchBatchRunning()) { // The batch rewrites the very rows a start resumes from: the // choice waits for it, like the Reset and watched rows do. this.watchToggle.holdChoice(() => - this.onEpisodeClicked(episode, startTimeOverride, forcePlayer) + this.onEpisodeClicked( + episode, + startTimeOverride, + forcePlayer, + fromStart + ) ); return; } @@ -865,17 +925,27 @@ export class StalkerSeriesViewComponent implements OnDestroy { // The launch cannot be cancelled: the choice replaces its player // once it settled. this.launchQueue.hold(seriesKey, () => - this.startEpisode(episode, startTimeOverride, forcePlayer) + this.startEpisode( + episode, + startTimeOverride, + forcePlayer, + fromStart + ) ); return; } - this.startEpisode(episode, startTimeOverride, forcePlayer); + this.startEpisode(episode, startTimeOverride, forcePlayer, fromStart); + } + + onEpisodeRestartRequested(episode: XtreamSerieEpisode): void { + this.onEpisodeClicked(episode, undefined, undefined, true); } private startEpisode( episode: XtreamSerieEpisode, startTimeOverride?: number, - forcePlayer?: ExternalPlayerName + forcePlayer?: ExternalPlayerName, + fromStart = false ): void { const item = this.displayItem(); const episodeState = resolveSelectedStalkerEpisodeState({ @@ -894,9 +964,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { ? `${item.info.name} - ${mappedEpisode.title || `Episode ${episodeState.episodeNumber}`}` : item.info.name; const trackingId = Number(mappedEpisode.id); - const startTime = - this.episodePlaybackPositions().get(trackingId)?.positionSeconds ?? - startTimeOverride; + const startTime = fromStart + ? 0 + : (this.episodePlaybackPositions().get(trackingId) + ?.positionSeconds ?? startTimeOverride); void this.startPlayback( command, diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.detail-loading.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.detail-loading.spec.ts new file mode 100644 index 000000000..4415b22ee --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.detail-loading.spec.ts @@ -0,0 +1,113 @@ +import { ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { createStalkerSeriesViewHarness } from './stalker-series-view.test-harness'; +import { StubSeasonContainerComponent } from './stalker-series-view.test-helpers'; + +/** + * The inline player's "Up Next" rail across lazy Ministra VOD-series seasons: + * the season after the playing one is fetched ahead so the rail can spill + * over into it. + */ +describe('StalkerSeriesViewComponent detail list loading', () => { + let fixture: ComponentFixture; + const { + selectedContentType, + selectedItem, + serialSeasonsResource, + vodSeriesSeasonsResource, + fetchVodSeriesEpisodes, + createFixture, + } = createStalkerSeriesViewHarness(() => jest.fn()); + + async function stabilize(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + } + + beforeEach(async () => { + fixture = await createFixture(); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it('keeps the loaded detail season on screen while the fullscreen picker loads another', async () => { + // The fullscreen episode picker reports through onSeasonSelected + // too, but does not change the detail container's season: loading + // the picked lazy season must not turn the detail rows into + // skeletons. + selectedContentType.set('vod'); + selectedItem.set({ + id: '50001', + is_series: true, + info: { + name: 'VOD Flagged Series', + description: 'Lazy seasons', + movie_image: 'vod-series.jpg', + }, + }); + serialSeasonsResource.set([]); + vodSeriesSeasonsResource.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + }, + ]); + await stabilize(); + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season-1', + video_id: '50001', + season_number: '1', + name: 'Season 1', + episodes: [ + { id: 'episode-1', series_number: 1, name: 'Pilot' }, + ], + isLoading: false, + isExpanded: false, + }, + { + id: 'season-2', + video_id: '50001', + season_number: '2', + name: 'Season 2', + episodes: [], + isLoading: false, + isExpanded: false, + }, + ]); + // Season 2's portal request never answers during the test. + fetchVodSeriesEpisodes.mockClear(); + fetchVodSeriesEpisodes.mockReturnValue(new Promise(() => undefined)); + const seasonContainer = fixture.debugElement.query( + By.directive(StubSeasonContainerComponent) + ).componentInstance as StubSeasonContainerComponent; + seasonContainer.selectedSeason.set('1'); + await stabilize(); + expect(seasonContainer.isLoading()).toBe(false); + + fixture.componentInstance.onSeasonSelected('2'); + await stabilize(); + expect(fetchVodSeriesEpisodes).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.isCurrentSeasonLoading('2')).toBe( + true + ); + // The detail container still shows season 1. + expect(seasonContainer.isLoading()).toBe(false); + + // Once the container itself moves to season 2, it is loading. + seasonContainer.selectedSeason.set('2'); + await stabilize(); + expect(seasonContainer.isLoading()).toBe(true); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts index 9eff80e6e..94accf795 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.resume.spec.ts @@ -19,6 +19,7 @@ import { TmdbEnrichmentService } from '@iptvnator/services'; import type { PlaybackPositionData, ResolvedPortalPlayback, + XtreamSerieEpisode, } from '@iptvnator/shared/interfaces'; import { StalkerSeriesViewComponent } from './stalker-series-view.component'; import { STALKER_SERIES_RESUME_TARGET } from './stalker-series-resume'; @@ -189,6 +190,40 @@ describe('StalkerSeriesViewComponent dashboard resume handoff', () => { afterEach(() => fixture.destroy()); + it('plays an episode from the beginning despite its saved offset', async () => { + getSeriesPlaybackPositions.mockImplementation(async () => [ + { + playlistId: 'stalker-1', + contentXtreamId: trackingIdOf('1', 2), + contentType: 'episode', + seriesXtreamId: 30001, + seasonNumber: 1, + episodeNumber: 2, + positionSeconds: 300, + durationSeconds: 1800, + } satisfies PlaybackPositionData, + ]); + await stabilize(); + const episode = fixture.componentInstance + .mappedSeasons() + ?.['1']?.find((item) => Number(item.episode_num) === 2); + expect(episode).toBeDefined(); + + fixture.componentInstance.onEpisodeRestartRequested( + episode as XtreamSerieEpisode + ); + await settle(); + + expect(resolveVodPlayback).toHaveBeenCalledWith( + '/media/file_30001.mpg', + 'Regular Series', + 'poster.jpg', + 2, + trackingIdOf('1', 2), + 0 + ); + }); + it('resumes the handoff episode at its saved offset once the series positions are read', async () => { // Position saved under the episode's own tracking id — resolved // lazily, since a regular-series id hashes the season command. diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts index d9b043755..c09d59af8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-harness.ts @@ -154,6 +154,7 @@ export function createStalkerSeriesViewHarness< vodSeriesSeasonsResource(), isVodSeriesSeasonsLoading: signal(false), isSerialSeasonsLoading, + selectedItemTmdbPending: signal(false), fetchVodSeriesEpisodes, resolveVodPlayback, fetchLinkToPlay, diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-helpers.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-helpers.ts index daf852ef8..0935c51dc 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-helpers.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-helpers.ts @@ -25,13 +25,18 @@ export class StubSeasonContainerComponent { readonly activeEpisodeId = input(null); readonly playingEpisodeId = input(null); readonly seasonDescriptions = input(null); + readonly seriesDescription = input(null); readonly seasonPosters = input(null); + readonly seriesPosterUrl = input(null); + readonly seasonLoadStates = input(null); + readonly metadataLoading = input(false); readonly isLoading = input(false); readonly downloadsEnabled = input(true); readonly downloadAdapter = input(null); readonly seasonWatchBatchRunning = input(false); readonly seasonSelected = output(); readonly episodeClicked = output(); + readonly episodeRestartRequested = output(); readonly playbackToggleRequested = output(); readonly seasonPlaybackToggleRequested = output(); readonly selectedSeason = signal(undefined); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts index 3440d321c..c2987bb83 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.tmdb-season.spec.ts @@ -189,4 +189,55 @@ describe('StalkerSeriesViewComponent TMDB season fetch', () => { expect(tmdbGetSeason).toHaveBeenCalledWith(777, 2); }); + it('reports the regular series season request as loading, not as an empty series', async () => { + serialSeasonsResource.set([]); + isSerialSeasonsLoading.set(true); + await stabilize(); + + const seasonContainer = fixture.debugElement.query( + By.directive(StubSeasonContainerComponent) + ).componentInstance as StubSeasonContainerComponent; + expect(seasonContainer.isLoading()).toBe(true); + expect(seasonContainer.seriesPosterUrl()).toBe('poster.jpg'); + + isSerialSeasonsLoading.set(false); + await stabilize(); + expect(seasonContainer.isLoading()).toBe(false); + }); + + it('keeps the episode metadata loading until the matched season fetch settles', async () => { + let resolveSeason!: (value: unknown) => void; + tmdbGetSeason.mockReturnValue( + new Promise((resolve) => (resolveSeason = resolve)) + ); + await stabilize(); + const seasonContainer = fixture.debugElement.query( + By.directive(StubSeasonContainerComponent) + ).componentInstance as StubSeasonContainerComponent; + + // Without a show-level TMDB match there is nothing to wait for. + expect(seasonContainer.metadataLoading()).toBe(false); + + selectedItem.set({ + id: '30001', + cmd: '/media/file_30001.mpg', + info: { + name: 'Regular Series', + description: 'Series description', + movie_image: 'poster.jpg', + tmdb_id: 777, + }, + } as never); + fixture.componentInstance.onSeasonSelected('1'); + await stabilize(); + expect(tmdbGetSeason).toHaveBeenCalledWith(777, 1); + expect(seasonContainer.metadataLoading()).toBe(true); + + // Settled with nothing (TMDB has no such season): the rows may now + // be judged from the provider data alone. + resolveSeason(null); + await stabilize(); + fixture.detectChanges(); + expect(seasonContainer.metadataLoading()).toBe(false); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/index.ts b/libs/portal/xtream/data-access/src/lib/stores/features/index.ts index a23f0c4c6..419a491ef 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/index.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/index.ts @@ -5,3 +5,4 @@ export * from './with-player.feature'; export * from './with-portal.feature'; export * from './with-search.feature'; export * from './with-selection.feature'; +export * from './with-tmdb-episode-metadata.feature'; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-tmdb-episode-metadata.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-tmdb-episode-metadata.feature.spec.ts new file mode 100644 index 000000000..01f3e638d --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-tmdb-episode-metadata.feature.spec.ts @@ -0,0 +1,97 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore } from '@ngrx/signals'; +import { + tmdbSeasonMetadataKey, + tmdbShowMetadataKey, + withTmdbEpisodeMetadata, +} from './with-tmdb-episode-metadata.feature'; + +const TestStore = signalStore(withTmdbEpisodeMetadata()); + +describe('withTmdbEpisodeMetadata', () => { + let store: InstanceType; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [TestStore] }); + store = TestBed.inject(TestStore); + }); + + it('marks a lookup pending until it settles', async () => { + let finish!: () => void; + const work = new Promise((resolve) => (finish = resolve)); + const key = tmdbSeasonMetadataKey(42, '2'); + + const tracked = store.trackTmdbEpisodeMetadata(key, work); + expect(store.tmdbEpisodeMetadata()[key]).toBe('pending'); + + finish(); + await tracked; + expect(store.tmdbEpisodeMetadata()[key]).toBe('settled'); + }); + + it('settles a failed lookup too', async () => { + const key = tmdbShowMetadataKey(42); + await expect( + store.trackTmdbEpisodeMetadata(key, Promise.reject(new Error('x'))) + ).rejects.toThrow('x'); + expect(store.tmdbEpisodeMetadata()[key]).toBe('settled'); + }); + it('keeps a settled season settled when its enrichment re-runs', async () => { + const key = tmdbSeasonMetadataKey(42, '1'); + await store.trackTmdbEpisodeMetadata(key, Promise.resolve()); + + let finish!: () => void; + const rerun = store.trackTmdbEpisodeMetadata( + key, + new Promise((resolve) => (finish = resolve)), + { keepSettled: true } + ); + expect(store.tmdbEpisodeMetadata()[key]).toBe('settled'); + finish(); + await rerun; + expect(store.tmdbEpisodeMetadata()[key]).toBe('settled'); + }); + + it('lets only the latest lookup of a key settle it', async () => { + const key = tmdbShowMetadataKey(42); + let finishFirst!: () => void; + const first = store.trackTmdbEpisodeMetadata( + key, + new Promise((resolve) => (finishFirst = resolve)) + ); + let finishSecond!: () => void; + const second = store.trackTmdbEpisodeMetadata( + key, + new Promise((resolve) => (finishSecond = resolve)) + ); + + finishFirst(); + await first; + expect(store.tmdbEpisodeMetadata()[key]).toBe('pending'); + + finishSecond(); + await second; + expect(store.tmdbEpisodeMetadata()[key]).toBe('settled'); + }); + + it("forgets a series' seasons on a new visit, also against lookups in flight", async () => { + const settled = tmdbSeasonMetadataKey(42, '1'); + const inFlight = tmdbSeasonMetadataKey(42, '2'); + const other = tmdbSeasonMetadataKey(7, '1'); + await store.trackTmdbEpisodeMetadata(settled, Promise.resolve()); + await store.trackTmdbEpisodeMetadata(other, Promise.resolve()); + let finish!: () => void; + const stale = store.trackTmdbEpisodeMetadata( + inFlight, + new Promise((resolve) => (finish = resolve)) + ); + + store.resetTmdbSeasonMetadata(42); + finish(); + await stale; + + expect(store.tmdbEpisodeMetadata()[settled]).toBeUndefined(); + expect(store.tmdbEpisodeMetadata()[inFlight]).toBeUndefined(); + expect(store.tmdbEpisodeMetadata()[other]).toBe('settled'); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-tmdb-episode-metadata.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-tmdb-episode-metadata.feature.ts new file mode 100644 index 000000000..2bd733f0f --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-tmdb-episode-metadata.feature.ts @@ -0,0 +1,106 @@ +import { + patchState, + signalStoreFeature, + withMethods, + withState, +} from '@ngrx/signals'; + +export type TmdbEpisodeMetadataStatus = 'pending' | 'settled'; + +/** + * Progress of the TMDB lookups that can still change a series page's + * episode rows: the show-level match (`show:`) and each season's + * episode enrichment (`season::`). The detail view + * keeps a season's rows as skeletons while a lookup that could upgrade them + * is outstanding, instead of rendering bare rows that would grow later. + */ +export interface TmdbEpisodeMetadataState { + tmdbEpisodeMetadata: Readonly>; +} + +export const tmdbShowMetadataKey = (seriesId: string | number): string => + `show:${seriesId}`; + +export const tmdbSeasonMetadataKey = ( + seriesId: string | number, + seasonKey: string +): string => `season:${seriesId}:${seasonKey}`; + +export function withTmdbEpisodeMetadata() { + return signalStoreFeature( + withState({ tmdbEpisodeMetadata: {} }), + withMethods((store) => { + // The latest tracked lookup per key: only it may settle the key, + // so a superseded one (the same series reopened) cannot clear a + // newer lookup's pending state. + const latest = new Map(); + let nextToken = 0; + const mark = (key: string, status: TmdbEpisodeMetadataStatus) => + patchState(store, { + tmdbEpisodeMetadata: { + ...store.tmdbEpisodeMetadata(), + [key]: status, + }, + }); + return { + /** + * Marks `key` pending until `work` settles, either way. With + * `keepSettled`, a key that already settled stays settled: + * season enrichment re-runs on every selection write and only + * re-reads the cache, which must not turn rendered rows back + * into skeletons. + */ + trackTmdbEpisodeMetadata( + key: string, + work: Promise, + options: { keepSettled?: boolean } = {} + ): Promise { + const token = ++nextToken; + latest.set(key, token); + if ( + !options.keepSettled || + store.tmdbEpisodeMetadata()[key] !== 'settled' + ) { + mark(key, 'pending'); + } + return work.finally(() => { + if (latest.get(key) === token) { + latest.delete(key); + mark(key, 'settled'); + } + }); + }, + + /** + * Forgets a series' season statuses: a new visit loads fresh + * provider data that its seasons must be enriched again. + */ + resetTmdbSeasonMetadata(seriesId: string | number): void { + const prefix = tmdbSeasonMetadataKey(seriesId, ''); + // A lookup of the previous visit still in flight must not + // settle the new visit's season. + for (const key of [...latest.keys()]) { + if (key.startsWith(prefix)) { + latest.delete(key); + } + } + const status = store.tmdbEpisodeMetadata(); + if ( + !Object.keys(status).some((key) => + key.startsWith(prefix) + ) + ) { + return; + } + const kept: Record = {}; + for (const [key, value] of Object.entries(status)) { + if (!key.startsWith(prefix)) { + kept[key] = value; + } + } + patchState(store, { tmdbEpisodeMetadata: kept }); + }, + }; + }) + ); +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index 1c21edae4..c19a1d5ab 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -21,6 +21,9 @@ import { withPortal, withSearch, withSelection, + tmdbSeasonMetadataKey, + tmdbShowMetadataKey, + withTmdbEpisodeMetadata, } from './features'; import { enrichSerialSeasonWithTmdb, @@ -47,6 +50,7 @@ export const XtreamStore = signalStore( withFavorites(), withRecentItems(), withPlaybackPositions(), + withTmdbEpisodeMetadata(), withComputed((store) => ({ /** @@ -253,11 +257,15 @@ export const XtreamStore = signalStore( ...serialDetails, series_id: params.serialId, }); - void enrichSerialSelectionWithTmdb( - store, - tmdbEnrichment, - params.serialId, - isCurrentRequest + store.resetTmdbSeasonMetadata(params.serialId); + void store.trackTmdbEpisodeMetadata( + tmdbShowMetadataKey(params.serialId), + enrichSerialSelectionWithTmdb( + store, + tmdbEnrichment, + params.serialId, + isCurrentRequest + ) ); }) .catch((error: unknown) => { @@ -286,12 +294,51 @@ export const XtreamStore = signalStore( if (!playlistId) { return; } - void enrichSerialSeasonWithTmdb( + const seriesId = ( + store.selectedItem() as { series_id?: string | number } + )?.series_id; + const work = enrichSerialSeasonWithTmdb( store, tmdbEnrichment, seasonKey, () => store.currentPlaylist()?.id === playlistId ); + void (seriesId === undefined + ? work + : store.trackTmdbEpisodeMetadata( + tmdbSeasonMetadataKey(seriesId, seasonKey), + work, + { keepSettled: true } + )); + }, + + /** + * True while a TMDB lookup that could still fill the selected + * series' `seasonKey` episodes is outstanding: the show-level + * match, or — once matched — that season's episode enrichment + * not yet settled. False with TMDB off or without a match. + */ + isTmdbEpisodeMetadataPending(seasonKey: string): boolean { + const item = store.selectedItem() as { + series_id?: string | number; + info?: { tmdb_id?: number | null } | unknown[]; + } | null; + const seriesId = item?.series_id; + if (seriesId === undefined || !tmdbEnrichment.isEnabled()) { + return false; + } + const status = store.tmdbEpisodeMetadata(); + if (status[tmdbShowMetadataKey(seriesId)] === 'pending') { + return true; + } + const info = item?.info; + if (!info || Array.isArray(info) || !info.tmdb_id) { + return false; + } + return ( + status[tmdbSeasonMetadataKey(seriesId, seasonKey)] !== + 'settled' + ); }, }; }) diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 0ebc12109..dec748bb0 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -212,17 +212,22 @@ export class SerialDetailsPlaybackService { this.activeEpisodeId.set(null); } - /** `player` forces MPV/VLC (the "…" menu); history and the launch position are recorded either way. */ + /** + * `player` forces MPV/VLC (the "…" menu); history and the launch position + * are recorded either way. `fromStart` ignores the saved position (the + * episode menu's "Play from beginning"). + */ playEpisode( episode: XtreamSerieEpisode, - player?: ExternalPlayerName + player?: ExternalPlayerName, + fromStart = false ): Promise | void { // A forced launch still settling owns the next start: the latest // choice made meanwhile replaces its player once it settled. const owner = this.launchOwner(); if (!player && owner && this.forcedLaunchPending()) { queueEpisodeChoice(this, owner, episode, (queued) => - this.playEpisode(queued) + this.playEpisode(queued, undefined, fromStart) ); return; } @@ -246,7 +251,7 @@ export class SerialDetailsPlaybackService { selectedItem, episode, streamUrl, - startTime: position?.positionSeconds, + startTime: fromStart ? 0 : position?.positionSeconds, }); return this.startPlayback(playback, episodeState, player); } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.spec.ts new file mode 100644 index 000000000..744c3245b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.spec.ts @@ -0,0 +1,63 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import type { XtreamSerieDetailsView } from './serial-details-playback.service'; +import { SerialDetailsSeasonsService } from './serial-details-seasons.service'; + +describe('SerialDetailsSeasonsService', () => { + const pendingSeasons = new Set(); + const store = { + isTmdbEpisodeMetadataPending: jest.fn((seasonKey: string) => + pendingSeasons.has(seasonKey) + ), + enrichSelectedSerialSeason: jest.fn(), + }; + const item = signal({ + info: { tmdb_id: 777 }, + episodes: { '1': [], '2': [] }, + } as unknown as XtreamSerieDetailsView); + let service: SerialDetailsSeasonsService; + + beforeEach(() => { + pendingSeasons.clear(); + store.enrichSelectedSerialSeason.mockClear(); + TestBed.configureTestingModule({ + providers: [ + SerialDetailsSeasonsService, + { provide: XtreamStore, useValue: store }, + ], + }); + service = TestBed.inject(SerialDetailsSeasonsService); + }); + + it('follows the detail container, not the fullscreen picker, for metadata loading', () => { + // Season 2's enrichment is outstanding; the detail container shows + // season 1, which has settled. + pendingSeasons.add('2'); + const detailSeasonKey = signal('1'); + service.bind({ selectedItem: item, detailSeasonKey }); + expect(service.metadataLoading()).toBe(false); + + // The fullscreen picker chooses season 2: it gets enriched, but the + // detail rows of season 1 must stay. + service.select('2'); + TestBed.flushEffects(); + expect(store.enrichSelectedSerialSeason).toHaveBeenCalledWith('2'); + expect(service.metadataLoading()).toBe(false); + + // Only once the container itself moves to season 2 is it loading. + detailSeasonKey.set('2'); + expect(service.metadataLoading()).toBe(true); + }); + + it('stands in with the lowest season before the container reports one', () => { + pendingSeasons.add('1'); + service.bind({ selectedItem: item }); + expect(service.metadataLoading()).toBe(true); + + pendingSeasons.delete('1'); + pendingSeasons.add('2'); + service.bind({ selectedItem: item }); + expect(service.metadataLoading()).toBe(false); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts index 7da3ed964..318256a55 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-seasons.service.ts @@ -14,6 +14,13 @@ import type { XtreamSerieDetailsView } from './serial-details-playback.service'; interface SerialDetailsSeasonsBindings { readonly selectedItem: Signal; + /** + * The season the detail container shows. `select()` is also called for + * the fullscreen episode picker, whose choice enriches another season + * without changing the detail list; the container's metadata flag must + * follow the container's own selection. + */ + readonly detailSeasonKey?: Signal; } /** @@ -31,7 +38,7 @@ export class SerialDetailsSeasonsService { () => this.bindings()?.selectedItem() ?? null ); - /** Season currently selected in the season container. */ + /** Season selected last, in the season container or the fullscreen picker. */ private readonly selectedSeasonKey = signal(null); /** Season descriptions (provider text, TMDB fallback, URL junk dropped). */ @@ -44,6 +51,24 @@ export class SerialDetailsSeasonsService { buildSeasonPosters(this.selectedItem()) ); + /** + * True while TMDB may still fill the detail container's season (show + * match or season enrichment outstanding); the container then keeps + * rows it would otherwise render bare as skeletons. Before the + * container reports its selection, the lowest season stands in. + */ + readonly metadataLoading = computed(() => { + const seasonKey = + this.bindings()?.detailSeasonKey?.() ?? + Object.keys(this.selectedItem()?.episodes ?? {}).sort( + (a, b) => Number(a) - Number(b) + )[0]; + return ( + !!seasonKey && + this.xtreamStore.isTmdbEpisodeMetadataPending(seasonKey) + ); + }); + constructor() { // TMDB season enrichment, keyed on (tmdb_id, selected season). With // season tabs the first seasonSelected fires as soon as seasons load — diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index ccd18aa9c..937b475e6 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -181,6 +181,9 @@ [seriesId]="item.series_id" [playlistId]="currentPlaylistId()" [seriesTitle]="info.name" + [seriesDescription]="info.plot" + [seriesPosterUrl]="info.cover" + [metadataLoading]="seasonMetadataLoading()" [playbackPositions]="episodePlaybackPositions()" [downloadAdapter]="episodeDownloadAdapter()" [downloadsEnabled]="!providerOnly()" @@ -193,6 +196,7 @@ [seasonPosters]="seasonPosters()" [seasonWatchBatchRunning]="seasonWatchBatchRunning()" (episodeClicked)="playEpisode($event)" + (episodeRestartRequested)="playEpisodeFromStart($event)" (playbackToggleRequested)="handlePlaybackToggleRequested($event)" (seasonPlaybackToggleRequested)=" handleSeasonPlaybackToggleRequested($event) @@ -222,5 +226,14 @@ (backClicked)="goBack()" /> } @else { - + + +
+ + +
+
} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 6cb7cc12d..cc27cf1e7 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -2,7 +2,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ContentHeroComponent } from '@iptvnator/ui/components'; import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; -import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; import { SerialDetailsComponent } from './serial-details.component'; import { SerialDetailsMenuService } from './serial-details-menu.service'; import { SerialDetailsPlaybackService } from './serial-details-playback.service'; @@ -338,6 +341,40 @@ describe('SerialDetailsComponent', () => { ); }); + it('plays an episode from the beginning despite a stored position', async () => { + getSeriesPlaybackPositions.mockResolvedValue([ + { + contentXtreamId: 1001, + contentType: 'episode', + seriesXtreamId: 103, + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 42, + durationSeconds: 120, + playlistId: 'xtream-1', + updatedAt: '2026-05-10T12:00:00.000Z', + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const episode = fixture.componentInstance.selectedItem()?.episodes?.[ + '1' + ]?.[0] as XtreamSerieEpisode; + fixture.componentInstance.playEpisodeFromStart(episode); + + expect(openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + startTime: 0, + contentInfo: expect.objectContaining({ + contentXtreamId: 1001, + }), + }), + true + ); + }); + it('records the selected episode after a successful external-player launch', async () => { openResolvedPlayback.mockResolvedValue({ id: 'vlc-session-1', diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index c35ead6ed..cd02f46a5 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -19,7 +19,9 @@ import { DetailCreditsComponent, DetailIconButtonComponent, DetailMetaTemplateDirective, + DetailSectionHeaderComponent, DetailTagsTemplateDirective, + EpisodeSkeletonComponent, MetaChipComponent, PortalDetailShellComponent, SeasonContainerComponent, @@ -29,6 +31,7 @@ import { SimilarRailComponent, ViewInPortalActionComponent, VodMoreMenuComponent, + readSavedEpisodeViewMode, scrollToCastCrewRow, } from '@iptvnator/ui/components'; import { @@ -95,7 +98,9 @@ import { SerialDetailsDownloadAdapterService } from './serial-details-download-a DetailCreditsComponent, DetailIconButtonComponent, DetailMetaTemplateDirective, + DetailSectionHeaderComponent, DetailTagsTemplateDirective, + EpisodeSkeletonComponent, MetaChipComponent, PortalDetailShellComponent, PortalInlinePlayerComponent, @@ -174,6 +179,9 @@ export class SerialDetailsComponent implements OnDestroy { private readonly seasons = inject(SerialDetailsSeasonsService); readonly seasonDescriptions = this.seasons.descriptions; readonly seasonPosters = this.seasons.posters; + readonly seasonMetadataLoading = this.seasons.metadataLoading; + /** Layout of the loading shell's episode placeholders. */ + readonly savedEpisodeViewMode = readSavedEpisodeViewMode(); /** Clickable year/genre/country chips (Discover pages) */ readonly discover = this.navigation.discover; @@ -181,7 +189,12 @@ export class SerialDetailsComponent implements OnDestroy { constructor() { this.playback.bind({ selectedItem: this.selectedItem }); this.similar.bind({ selectedItem: this.selectedItem }); - this.seasons.bind({ selectedItem: this.selectedItem }); + this.seasons.bind({ + selectedItem: this.selectedItem, + detailSeasonKey: computed( + () => this.seasonContainer()?.selectedSeason() ?? null + ), + }); this.downloadAdapter.bind(this.selectedItem); this.heroPresenter.bind({ selectedItem: this.selectedItem, @@ -276,6 +289,10 @@ export class SerialDetailsComponent implements OnDestroy { this.playback.playEpisode(episode); } + playEpisodeFromStart(episode: XtreamSerieEpisode): void { + this.playback.playEpisode(episode, undefined, true); + } + playQuickStartEpisode(): void { this.playback.playQuickStartEpisode(); } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts index 5a57feb7f..e6c8f864d 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.harness.ts @@ -210,6 +210,7 @@ export async function configureSerialDetailsTestBed( recentItems: signal([]), serialCategories: signal([]), loadRecentItems: jest.fn(), + isTmdbEpisodeMetadataPending: jest.fn(() => false), }, }, { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts index a093e78f9..c994b03d3 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.test-stubs.ts @@ -3,6 +3,7 @@ import { Component, input, output, + signal, } from '@angular/core'; import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; @@ -26,9 +27,14 @@ export class StubSeasonContainerComponent { readonly activeEpisodeId = input(null); readonly playingEpisodeId = input(null); readonly seasonDescriptions = input(null); + readonly seriesDescription = input(null); readonly seasonPosters = input(null); + readonly seriesPosterUrl = input(null); + readonly metadataLoading = input(false); + readonly selectedSeason = signal(undefined); readonly seasonWatchBatchRunning = input(false); readonly episodeClicked = output(); + readonly episodeRestartRequested = output(); readonly playbackToggleRequested = output(); readonly seasonPlaybackToggleRequested = output(); } diff --git a/libs/services/src/lib/tmdb/tmdb-episode-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-episode-merge.spec.ts index ec989321b..381b7ae57 100644 --- a/libs/services/src/lib/tmdb/tmdb-episode-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-episode-merge.spec.ts @@ -94,6 +94,31 @@ describe('mergeEpisodesWithTmdb', () => { expect(info.duration_secs).toBe(3300); }); + it('fills a missing runtime from TMDB but keeps a provider runtime', () => { + const withRuntime = { ...tmdbEpisode, runtime: 46 }; + const [filled] = mergeEpisodesWithTmdb( + [providerEpisode()], + [withRuntime] + ); + const [zeroString] = mergeEpisodesWithTmdb( + [providerEpisode({ info: { duration: '00:00:00' } as never })], + [withRuntime] + ); + const [kept] = mergeEpisodesWithTmdb( + [providerEpisode({ info: { duration_secs: 2520 } as never })], + [withRuntime] + ); + const [noTmdbRuntime] = mergeEpisodesWithTmdb( + [providerEpisode()], + [{ ...tmdbEpisode, runtime: null }] + ); + + expect(filled.info).toMatchObject({ duration_secs: 2760 }); + expect(zeroString.info).toMatchObject({ duration_secs: 2760 }); + expect(kept.info).toMatchObject({ duration_secs: 2520 }); + expect(noTmdbRuntime.info).not.toHaveProperty('duration_secs'); + }); + it('passes through episodes without a TMDB counterpart', () => { const episode = providerEpisode({ episode_num: 99 }); const [merged] = mergeEpisodesWithTmdb([episode], [tmdbEpisode]); diff --git a/libs/services/src/lib/tmdb/tmdb-episode-merge.ts b/libs/services/src/lib/tmdb/tmdb-episode-merge.ts index f9f2514f2..59decf72c 100644 --- a/libs/services/src/lib/tmdb/tmdb-episode-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-episode-merge.ts @@ -31,6 +31,11 @@ function episodeInfoOf( return !episode.info || Array.isArray(episode.info) ? {} : episode.info; } +/** A runtime the provider sent: seconds, or a duration string that is not all zeros ("00:00:00"). */ +function hasProviderRuntime(info: Partial): boolean { + return Number(info.duration_secs) > 0 || /[1-9]/.test(info.duration ?? ''); +} + export function mergeEpisodesWithTmdb( episodes: readonly XtreamSerieEpisode[], tmdbEpisodes: readonly TmdbEpisode[] @@ -69,6 +74,11 @@ export function mergeEpisodesWithTmdb( ...(tmdb.overview?.trim() ? { plot: tmdb.overview } : {}), ...(still ? { movie_image: still } : {}), releasedate: info.releasedate || (tmdb.air_date ?? ''), + // Most Stalker portals send no runtime; TMDB's fills the + // episode meta line. A provider runtime always wins. + ...(!hasProviderRuntime(info) && (tmdb.runtime ?? 0) > 0 + ? { duration_secs: Number(tmdb.runtime) * 60 } + : {}), ...(info.rating === undefined && rating !== null ? { rating } : {}), diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 1c5be4283..967522cb2 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -19,6 +19,7 @@ export * from './lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component export * from './lib/password-visibility-toggle/password-visibility-toggle.directive'; export * from './lib/progress-capsule/progress-capsule.component'; export * from './lib/resizable/resizable.directive'; +export * from './lib/season-container/episode-skeleton.component'; export * from './lib/season-container/season-container.component'; export * from './lib/season-container/season-tabs.component'; export * from './lib/season-container/season-watch-presenter'; @@ -41,6 +42,8 @@ export * from './lib/detail-ui/detail-credits.component'; export * from './lib/detail-ui/detail-icon-button.component'; export * from './lib/detail-ui/detail-action-button.component'; export * from './lib/detail-ui/detail-rail.component'; +export * from './lib/detail-ui/detail-action-skeleton.component'; +export * from './lib/detail-ui/detail-section-header.component'; export * from './lib/detail-ui/meta-chip.component'; export * from './lib/detail-ui/similar-rail.component'; export * from './lib/detail-ui/trailer-dialog.component'; diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index e73c4edcc..1ddb3ad6d 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -1,5 +1,10 @@ -
+ +
@if (errorMessage()) {
cloud_off @@ -7,81 +12,38 @@
} @else if (isLoading()) { -
- + + } @else { diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 2420745ec..de5af1dc0 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -1,3 +1,5 @@ +@use '../styles/skeleton-shimmer' as shimmer; + // ============================================================================ // Detail hero — cinematic layout shared by movie and series pages // ============================================================================ @@ -50,7 +52,11 @@ width: 100%; // Room for a 16:9 backdrop above the content, capped so the episodes or // cast row still starts inside the first screen of a desktop window. - min-height: min(480px, 60vh); + // `--hero-tail` (set by the detail shell) continues the artwork below + // the content, under the first section's heading, so the fade into the + // page has room and never ends as a band at the hero's edge. + min-height: calc(min(520px, 60vh) + var(--hero-tail, 0px)); + padding-bottom: var(--hero-tail, 0px); box-sizing: border-box; // `overflow: hidden` clips the scaled blurred backdrop fallback. overflow: hidden; @@ -138,13 +144,15 @@ color-mix(in srgb, var(--surface-bg) 45%, transparent) 64%, color-mix(in srgb, var(--surface-bg) 15%, transparent) 100% ), + // Ends on the exact page surface, not an alpha of it: the + // section below starts on the same colour, so there is no seam. linear-gradient( - 180deg, - color-mix(in srgb, var(--surface-bg) 25%, transparent) 0%, - transparent 30%, - transparent 60%, - var(--surface-bg) 100% - ); + 180deg, + color-mix(in srgb, var(--surface-bg) 20%, transparent) 0%, + transparent 28%, + color-mix(in srgb, var(--surface-bg) 55%, transparent) 62%, + var(--surface-bg) 100% + ); } &__content { @@ -156,12 +164,8 @@ width: 100%; max-width: var(--content-max-width); margin: 0 auto; - padding: 48px 32px 24px; + padding: 48px 32px 32px; box-sizing: border-box; - - &--skeleton { - align-items: flex-start; - } } &__error { @@ -483,18 +487,87 @@ } } -// Skeleton Loader Overrides -ngx-skeleton-loader { - display: block; - width: 100%; +// ============================================================================ +// Loading skeleton — the loaded hero's geometry, so nothing moves on load +// ============================================================================ - ::ng-deep .loader { - display: block; - height: 100%; - background-color: color-mix( - in srgb, - var(--app-on-surface, #e6e1e5) 10%, - transparent - ); +@include shimmer.keyframes; + +.skeleton { + @include shimmer.block(4px); + + flex: none; + + &--poster { + width: 100%; + aspect-ratio: 2 / 3; + border-radius: 10px; + } + + // 11px eyebrow; the title's own line box (font size × 1.08). + &--kind { + width: 140px; + height: 11px; + } + + &--title { + width: min(60%, 420px); + height: calc(clamp(28px, 3.2vw, 40px) * 1.08); + border-radius: 6px; + } + + // Meta chip: 22.8px pill. + &--chip { + width: 64px; + height: 22.8px; + border-radius: 999px; + + &:nth-child(2) { + width: 88px; + } + } + + &--primary { + width: 168px; + height: 44px; + border-radius: 10px; + } + + &--icon-button { + width: 44px; + height: 44px; + border-radius: 10px; + } +} + +// Text lines: bars centred in the real line boxes (13.5px × 1.55 for the +// description, 21.5px credit rows). +.skeleton-lines { + --line-box: 20.925px; + + display: flex; + flex-direction: column; + width: 100%; + + .skeleton--line { + width: 100%; + height: 10px; + margin-block: calc((var(--line-box) - 10px) / 2); + + & + .skeleton--line { + width: 70%; + } + } + + &--meta { + --line-box: 21.5px; + + .skeleton--line { + width: min(100%, 320px); + + & + .skeleton--line { + width: min(70%, 220px); + } + } } } diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts index 6fe106423..a232d3d0e 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.spec.ts @@ -258,4 +258,22 @@ describe('ContentHeroComponent cinematic layout', () => { Node.DOCUMENT_POSITION_FOLLOWING ); }); + it('lays the loading skeleton out like the loaded hero: stage height, one row of chips and actions', () => { + fixture.componentRef.setInput('isLoading', true); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + + const skeleton = root.querySelector('[data-test-id="hero-skeleton"]'); + expect(skeleton).not.toBeNull(); + // Nothing is known about a backdrop yet: keep the stage height. + expect(root.querySelector('.hero--compact')).toBeNull(); + // Chips and buttons sit in the same flex rows as the real ones. + expect( + skeleton?.querySelectorAll('.details__tags > .skeleton--chip') + ).toHaveLength(3); + expect( + skeleton?.querySelectorAll('.action-buttons > .skeleton') + ).toHaveLength(3); + expect(root.querySelector('ngx-skeleton-loader')).toBeNull(); + }); }); diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index 6413a66b5..7fc466f68 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -16,7 +16,6 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; -import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader'; import { HeroTrailerBackdropComponent } from '../detail-ui/hero-trailer-backdrop.component'; /** `stage` keeps room for a 16:9 backdrop; `compact` is sized by the content. */ @@ -30,7 +29,6 @@ export type ContentHeroLayout = 'stage' | 'compact'; MatIconModule, MatButtonModule, MatTooltipModule, - NgxSkeletonLoaderComponent, TranslateModule, ], templateUrl: './content-hero.component.html', diff --git a/libs/ui/components/src/lib/detail-ui/detail-action-skeleton.component.scss b/libs/ui/components/src/lib/detail-ui/detail-action-skeleton.component.scss new file mode 100644 index 000000000..522fc2379 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-action-skeleton.component.scss @@ -0,0 +1,13 @@ +@use '../styles/skeleton-shimmer' as shimmer; + +@include shimmer.keyframes; + +// The primary action's footprint: a 44px pill about as wide as "Play" with +// its episode line. +:host { + @include shimmer.block(10px); + + width: 168px; + height: 44px; + flex: none; +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-action-skeleton.component.ts b/libs/ui/components/src/lib/detail-ui/detail-action-skeleton.component.ts new file mode 100644 index 000000000..e6a170626 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-action-skeleton.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +/** + * Placeholder for the hero's primary action while the host cannot build it + * yet (a Stalker series before its seasons load): keeps the action row's + * width, so the favourite and "…" buttons do not jump right when the Play + * button arrives. + */ +@Component({ + selector: 'app-detail-action-skeleton', + template: '', + styleUrl: './detail-action-skeleton.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'aria-hidden': 'true', + 'data-test-id': 'detail-action-skeleton', + }, +}) +export class DetailActionSkeletonComponent {} diff --git a/libs/ui/components/src/lib/detail-ui/detail-rail.component.html b/libs/ui/components/src/lib/detail-ui/detail-rail.component.html index ba6490deb..7f56447b6 100644 --- a/libs/ui/components/src/lib/detail-ui/detail-rail.component.html +++ b/libs/ui/components/src/lib/detail-ui/detail-rail.component.html @@ -1,16 +1,17 @@
-
-

{{ title() }}

- @if (count() !== null) { - {{ count() }} - } - + @if (overflowing()) { -
+
} -
+
diff --git a/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss b/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss index 7c5a22e62..b3c2bb4f5 100644 --- a/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss +++ b/libs/ui/components/src/lib/detail-ui/detail-rail.component.scss @@ -3,30 +3,9 @@ min-width: 0; } -.rail__header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 14px; -} - -.rail__title { - margin: 0; - color: var(--app-heading-color, #e7ecf3); - font-size: 16px; - font-weight: 600; - line-height: 1.3; -} - -.rail__count { - color: var(--app-eyebrow-color, #6b7384); - font-size: 12px; -} - .rail__arrows { display: flex; gap: 6px; - margin-left: auto; } .rail__arrow { diff --git a/libs/ui/components/src/lib/detail-ui/detail-rail.component.ts b/libs/ui/components/src/lib/detail-ui/detail-rail.component.ts index e8c597da4..3f8672832 100644 --- a/libs/ui/components/src/lib/detail-ui/detail-rail.component.ts +++ b/libs/ui/components/src/lib/detail-ui/detail-rail.component.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { TranslatePipe } from '@ngx-translate/core'; +import { DetailSectionHeaderComponent } from './detail-section-header.component'; const SCROLL_STEP_PX = 450; @@ -21,7 +22,7 @@ const SCROLL_STEP_PX = 450; */ @Component({ selector: 'app-detail-rail', - imports: [MatIcon, TranslatePipe], + imports: [DetailSectionHeaderComponent, MatIcon, TranslatePipe], templateUrl: './detail-rail.component.html', styleUrl: './detail-rail.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/libs/ui/components/src/lib/detail-ui/detail-section-header.component.scss b/libs/ui/components/src/lib/detail-ui/detail-section-header.component.scss new file mode 100644 index 000000000..854f63391 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-section-header.component.scss @@ -0,0 +1,37 @@ +// One heading scale for every details-page section, so Episodes, Cast & crew +// and Similar read as siblings. +:host { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + min-height: 34px; + margin-bottom: 16px; +} + +.section-header__title { + margin: 0; + color: var(--app-heading-color, #e7ecf3); + font-size: 18px; + font-weight: 600; + letter-spacing: -0.2px; + line-height: 1.3; +} + +.section-header__count { + color: var(--app-eyebrow-color, #6b7384); + font-size: 12.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.section-header__end { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; + + &:empty { + display: none; + } +} diff --git a/libs/ui/components/src/lib/detail-ui/detail-section-header.component.spec.ts b/libs/ui/components/src/lib/detail-ui/detail-section-header.component.spec.ts new file mode 100644 index 000000000..1d824e394 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-section-header.component.spec.ts @@ -0,0 +1,54 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { DetailSectionHeaderComponent } from './detail-section-header.component'; + +@Component({ + imports: [DetailSectionHeaderComponent], + template: ` + + + toggle + + `, +}) +class HostComponent { + count: string | number | null = '8 episodes · 3 watched'; +} + +describe('DetailSectionHeaderComponent', () => { + function render(count: string | number | null) { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.count = count; + fixture.detectChanges(); + return fixture.nativeElement as HTMLElement; + } + + it('orders title, lead slot, counter and the end slot', () => { + const host = render('8 episodes · 3 watched'); + const parts = [ + ...host.querySelectorAll('h3, .lead, .section-header__count, .end'), + ].map((node) => node.textContent?.trim()); + + expect(parts).toEqual([ + 'Episodes', + 'Season 1', + '8 episodes · 3 watched', + 'toggle', + ]); + expect(host.querySelector('h3')?.getAttribute('data-test-id')).toBe( + 'episodes-heading' + ); + }); + + it('renders no counter without a count', () => { + expect(render(null).querySelector('.section-header__count')).toBeNull(); + expect(render('').querySelector('.section-header__count')).toBeNull(); + expect( + render(0).querySelector('.section-header__count')?.textContent + ).toBe('0'); + }); +}); diff --git a/libs/ui/components/src/lib/detail-ui/detail-section-header.component.ts b/libs/ui/components/src/lib/detail-ui/detail-section-header.component.ts new file mode 100644 index 000000000..55eebc219 --- /dev/null +++ b/libs/ui/components/src/lib/detail-ui/detail-section-header.component.ts @@ -0,0 +1,35 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +/** + * The heading row every details-page section shares (Episodes, Cast & crew, + * Similar): an 18px title, an optional muted counter, and two projection + * slots — `[section-header-lead]` right after the title (the season picker) + * and `[section-header-end]` pushed to the far edge (view toggle, arrows). + */ +@Component({ + selector: 'app-detail-section-header', + template: ` +

+ {{ title() }} +

+ + @if (count() !== null && count() !== '') { + {{ count() }} + } +
+ +
+ `, + styleUrl: './detail-section-header.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DetailSectionHeaderComponent { + readonly title = input.required(); + readonly count = input(null); + readonly headingId = input(null); + readonly titleTestId = input(null); +} diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss index c3fcb45e8..03a220312 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss @@ -50,14 +50,22 @@ // ============================================================================ .shell__hero { + // The hero's artwork runs on under the first section's heading: the hero + // grows by the tail and the sections below are pulled up over it, so the + // fade into the page surface ends behind the heading, not at a hard edge. + --hero-tail: 140px; + display: grid; grid-template-rows: 1fr; + margin-bottom: calc(-1 * var(--hero-tail)); transition: grid-template-rows 300ms ease, - opacity 250ms ease; + opacity 250ms ease, + margin-bottom 300ms ease; &--collapsed { grid-template-rows: 0fr; + margin-bottom: 0; opacity: 0; visibility: hidden; pointer-events: none; @@ -66,6 +74,7 @@ transition: grid-template-rows 300ms ease, opacity 250ms ease, + margin-bottom 300ms ease, visibility 0s linear 300ms; } } @@ -73,6 +82,16 @@ .shell__hero-inner { min-height: 0; overflow: hidden; + // Keeps the hero's layered scrim and content in their own stacking + // context, so the sections pulled up over the tail paint above them. + isolation: isolate; +} + +.shell__player, +.shell__episodes, +.shell__about, +.shell__extras { + position: relative; } // ============================================================================ @@ -106,17 +125,33 @@ animation: shell-fade-in 250ms ease 100ms both; } +// Section rhythm: 56px between sections (Episodes → Cast & crew → Similar) +// and after the last one, 16px between a section's header and its content +// (app-detail-section-header). .shell__extras { - display: flex; - flex-direction: column; - gap: 28px; max-width: var(--content-max-width); margin: 0 auto; - padding: 8px 32px 40px; + padding: 0 32px 56px; &:empty { display: none; } + + // The hosts project one wrapper with their rails; a rail without items + // renders nothing and must not leave a 56px hole. + ::ng-deep > [detail-extras] { + display: flex; + flex-direction: column; + gap: 56px; + + > :empty { + display: none; + } + } +} + +.shell__episodes:not(:empty) ~ .shell__extras { + margin-top: 56px; } @keyframes shell-fade-in { @@ -140,7 +175,7 @@ } .shell__extras { - padding: 0 20px 32px; + padding: 0 20px 40px; } } diff --git a/libs/ui/components/src/lib/season-container/episode-item.component.html b/libs/ui/components/src/lib/season-container/episode-item.component.html new file mode 100644 index 000000000..e6eacdcb9 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-item.component.html @@ -0,0 +1,186 @@ +@let item = episode(); +@let details = info(); + + + +@if (layout() === 'list') { + +} + +@if (!bare()) { + +} + +
+
+

{{ item.title }}

+ @if (timeLabel() || airDate()) { + + @if (timeLabel(); as time) { + {{ time.key | translate: time.params }} + } + @if (timeLabel() && airDate()) { + · + } + {{ airDate() }} + + } + @if (bare()) { + + @if (watched()) { + check + } @else if (progressPercent() > 0) { + + + + } + } +
+ @if (!bare() && details?.plot) { +

{{ details?.plot }}

+ } +
+ +
+ + @if (downloadPresenter.presentationVisible()) { + @let download = downloadPresenter.rowFor(item).presentation; + @let downloadLabel = + download.action === 'play' + ? ('DOWNLOADS.PLAY_LOCAL' | translate) + ': ' + item.title + : (download.ariaKey | translate: { title: item.title }); + + } + + + @if (inProgress()) { + + } + + +
diff --git a/libs/ui/components/src/lib/season-container/episode-item.component.scss b/libs/ui/components/src/lib/season-container/episode-item.component.scss new file mode 100644 index 000000000..6cbddd4fb --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-item.component.scss @@ -0,0 +1,551 @@ +@use '@angular/material' as mat; + +// ============================================================================ +// Episode item — list row and grid card of the season container +// ============================================================================ +// +// Page chrome reads the app's theme tokens, so both themes work. The badge, +// progress track and play disc sit over the artwork and keep fixed colours, +// like the player overlay. + +:host { + --ep-hover-bg: var(--app-widget-bg, #1e2330); + --ep-action-hover-bg: var(--app-card-hover-bg, #252b3a); + --ep-thumb-bg: color-mix(in srgb, var(--app-on-surface) 8%, transparent); + --ep-hairline: color-mix(in srgb, var(--app-on-surface) 12%, transparent); + --ep-text-primary: var(--app-heading-color, #d8dce8); + --ep-text-secondary: var(--app-body-color, #8b93a8); + --ep-text-tertiary: var(--app-eyebrow-color, #6b7494); + // Between the secondary and the primary ink: a long plot stays readable + // without competing with the title. + --ep-description: color-mix( + in srgb, + var(--app-heading-color, #d8dce8) 55%, + var(--app-body-color, #8b93a8) + ); + --ep-accent: var(--app-progress-color, #78adff); + --ep-success: var(--app-success-color, #4ade80); + + position: relative; + display: grid; + min-width: 0; + border-radius: 10px; + color: var(--ep-text-primary); + cursor: pointer; +} + +// The stretched play target covers the item; the actions sit above it. +.episode-item__target { + position: absolute; + inset: 0; + z-index: 1; + margin: 0; + padding: 0; + border: 0; + border-radius: inherit; + background: transparent; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: -2px; + } + + &[aria-disabled='true'] { + cursor: progress; + } +} + +// ============================================================================ +// Thumbnail: still (or dimmed fallback), watched badge, progress, play disc +// ============================================================================ + +.episode-item__thumb { + position: relative; + aspect-ratio: 16 / 9; + overflow: hidden; + // A hairline keeps the artwork's edge visible on the light theme too. + border: 1px solid var(--ep-hairline); + border-radius: 6px; + background: var(--ep-thumb-bg); + + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + // No distinct still (or the provider repeats the series poster): the + // image stays as a dimmed texture, never as a bright duplicate poster. + &--fallback img { + filter: saturate(0.7); + opacity: 0.55; + } +} + +.episode-item__badge { + position: absolute; + top: 6px; + right: 6px; + display: grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: rgba(5, 7, 11, 0.7); + color: #4ade80; + + mat-icon { + width: 11px; + height: 11px; + font-size: 11px; + line-height: 11px; + font-weight: 700; + } +} + +.episode-item__progress { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + background: rgba(0, 0, 0, 0.5); + + i { + display: block; + height: 100%; + background: var(--ep-accent); + } +} + +.episode-item__play { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(5, 7, 11, 0.35); + opacity: 0; + transition: opacity 150ms ease; +} + +.episode-item__play-disc { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(242, 244, 248, 0.95); + color: #0a0e14; + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + + mat-progress-spinner { + @include mat.progress-spinner-overrides( + ( + active-indicator-color: #0a0e14, + ) + ); + } +} + +:host(:hover), +:host(:focus-within), +:host(.episode-item--launching), +:host(.episode-item--playing) { + .episode-item__play { + opacity: 1; + } +} + +// ============================================================================ +// Text +// ============================================================================ + +.episode-item__text { + min-width: 0; +} + +.episode-item__title-row { + display: flex; + align-items: baseline; + gap: 10px; + min-width: 0; +} + +.episode-item__title { + min-width: 0; + margin: 0; + overflow: hidden; + color: inherit; + font-size: 14.5px; + font-weight: 600; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.episode-item__meta { + flex: none; + color: var(--ep-text-tertiary); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.episode-item__description { + display: -webkit-box; + max-width: 74ch; + margin: 4px 0 0; + overflow: hidden; + color: var(--ep-description); + font-size: 12.5px; + line-height: 1.45; + text-wrap: pretty; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; +} + +:host(.episode-item--watched) { + .episode-item__title { + color: var(--ep-text-secondary); + } + + .episode-item__description { + color: var(--ep-text-tertiary); + } +} + +// ============================================================================ +// Actions: always reserved, shown on hover and keyboard focus +// ============================================================================ + +.episode-item__actions { + position: relative; + z-index: 2; + display: flex; + justify-content: flex-end; + gap: 2px; + opacity: 0; + transition: opacity 120ms ease; +} + +:host(:hover), +:host(:focus-within) { + .episode-item__actions { + opacity: 1; + } +} + +.episode-item__action { + display: grid; + place-items: center; + width: 34px; + height: 34px; + padding: 0; + border: 0; + border-radius: 8px; + background: none; + color: var(--ep-text-secondary); + cursor: pointer; + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + line-height: 16px; + } + + &:hover:not(:disabled) { + background: var(--ep-action-hover-bg); + color: var(--ep-text-primary); + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: -2px; + } + + &:disabled { + cursor: default; + opacity: 0.6; + } + + &--success, + &.episode-item__watched-toggle--watched { + color: var(--ep-success); + } +} + +// ============================================================================ +// List row +// ============================================================================ + +:host(.episode-list-item) { + grid-template-columns: 36px 168px minmax(0, 1fr) 112px; + column-gap: 16px; + align-items: center; + padding: 10px 12px 10px 8px; +} + +:host(.episode-list-item:hover), +:host(.episode-list-item:focus-within) { + background: var(--ep-hover-bg); +} + +:host(.episode-list-item.episode-item--current) { + background: color-mix(in srgb, var(--ep-accent) 6%, transparent); + + .episode-item__number, + .episode-item__meta { + color: var(--ep-accent); + } +} + +.episode-item__number { + display: grid; + place-items: center; + color: var(--ep-text-tertiary); + font-size: 15px; + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: center; + + mat-progress-spinner { + @include mat.progress-spinner-overrides( + ( + active-indicator-color: var(--ep-accent), + ) + ); + } +} + +.episode-item__number-icon { + width: 18px; + height: 18px; + color: var(--ep-accent); + font-size: 18px; + line-height: 18px; +} + +// Bare row: a season without stills or plots. No thumbnail or description, +// a 44px line with the watched check or the progress inline. +:host(.episode-list-item.episode-item--bare) { + grid-template-columns: 36px minmax(0, 1fr) 112px; + min-height: 44px; + padding: 0 12px 0 8px; + + .episode-item__title { + font-size: 13.5px; + font-weight: 500; + } +} + +.episode-item__inline-check { + flex: none; + align-self: center; + width: 13px; + height: 13px; + color: var(--ep-success); + font-size: 13px; + line-height: 13px; + font-weight: 700; +} + +.episode-item__inline-progress { + flex: none; + align-self: center; + width: 72px; + height: 3px; + overflow: hidden; + border-radius: 2px; + background: color-mix(in srgb, var(--app-on-surface) 10%, transparent); + + i { + display: block; + height: 100%; + background: var(--ep-accent); + } +} + +// ============================================================================ +// Grid card +// ============================================================================ + +:host(.episode-card) { + grid-template-columns: minmax(0, 1fr); + align-content: start; + + .episode-item__thumb { + border-radius: 10px; + } + + .episode-item__text { + margin-top: 10px; + } + + .episode-item__title-row { + gap: 8px; + } + + .episode-item__title { + font-size: 13.5px; + } + + .episode-item__meta { + font-size: 11.5px; + } + + .episode-item__description { + color: var(--ep-text-tertiary); + font-size: 12px; + } + + // The actions ride on the artwork's top-right corner in a dark pill. + .episode-item__actions { + position: absolute; + top: 8px; + right: 8px; + border-radius: 8px; + background: rgba(5, 7, 11, 0.72); + } + + .episode-item__action { + width: 30px; + height: 30px; + color: rgba(242, 244, 248, 0.86); + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.14); + color: #fff; + } + + &--success, + &.episode-item__watched-toggle--watched { + color: #4ade80; + } + } + + .episode-item__badge { + // The action pill covers the top-right corner while it is shown. + transition: opacity 120ms ease; + } +} + +:host(.episode-card:hover), +:host(.episode-card:focus-within) { + .episode-item__badge { + opacity: 0; + } +} + +:host(.episode-card.episode-item--watched) { + .episode-item__title, + .episode-item__description { + color: var(--ep-text-tertiary); + } +} + +:host(.episode-card.episode-item--current) .episode-item__thumb { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--ep-accent) 60%, transparent); +} + +.episode-item__chip { + position: absolute; + top: 8px; + left: 8px; + padding: 2px 7px; + border-radius: 5px; + background: rgba(5, 7, 11, 0.72); + color: #e7ecf3; + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +@media (prefers-reduced-motion: reduce) { + .episode-item__play, + .episode-item__actions, + :host(.episode-card) .episode-item__badge { + transition: none; + } +} + +// Touch screens have no hover: keep the actions reachable. +@media (hover: none) { + .episode-item__actions { + opacity: 1; + } +} + +// Narrow details pane (beside the categories panel, phones). +@container detail (max-width: 640px) { + :host(.episode-list-item) { + grid-template-columns: 24px 112px minmax(0, 1fr) auto; + column-gap: 12px; + } + + :host(.episode-list-item.episode-item--bare) { + grid-template-columns: 24px minmax(0, 1fr) auto; + } + + .episode-item__title-row { + flex-wrap: wrap; + row-gap: 0; + } + + .episode-item__description { + -webkit-line-clamp: 1; + line-clamp: 1; + } +} + +// Phone width: the thumbnail shrinks and the actions take a row of their +// own under the text, so the title keeps the width instead of the slot. +@container detail (max-width: 480px) { + :host(.episode-list-item) { + grid-template-columns: 24px 96px minmax(0, 1fr); + grid-template-areas: + 'number thumb text' + 'number thumb actions'; + row-gap: 2px; + padding: 8px 8px 8px 4px; + } + + :host(.episode-list-item.episode-item--bare) { + grid-template-columns: 24px minmax(0, 1fr); + grid-template-areas: + 'number text' + 'number actions'; + } + + // Only the list row declares these areas; a grid card keeps its + // stacked layout (thumbnail, text, actions pinned on the artwork). + :host(.episode-list-item) { + .episode-item__number { + grid-area: number; + } + + .episode-item__thumb { + grid-area: thumb; + } + + .episode-item__text { + grid-area: text; + align-self: end; + } + + .episode-item__actions { + grid-area: actions; + justify-content: flex-start; + margin-left: -8px; + } + } +} diff --git a/libs/ui/components/src/lib/season-container/episode-item.component.spec.ts b/libs/ui/components/src/lib/season-container/episode-item.component.spec.ts new file mode 100644 index 000000000..d807fb7f6 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-item.component.spec.ts @@ -0,0 +1,215 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import { EpisodeItemComponent } from './episode-item.component'; +import { SeasonDownloadPresenter } from './season-download-presenter'; + +const episode = (info: Record = {}): XtreamSerieEpisode => + ({ + id: '101', + episode_num: 3, + title: 'Episode 3', + container_extension: 'mp4', + info: { plot: 'A plot', duration_secs: 2760, ...info }, + custom_sid: '', + added: '', + season: 1, + direct_source: '', + }) as XtreamSerieEpisode; + +const position = (positionSeconds: number): PlaybackPositionData => ({ + contentXtreamId: 101, + contentType: 'episode', + positionSeconds, + durationSeconds: 2760, +}); + +describe('EpisodeItemComponent', () => { + let fixture: ComponentFixture; + const el = () => fixture.nativeElement as HTMLElement; + const q = (selector: string) => el().querySelector(selector); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + EpisodeItemComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: SeasonDownloadPresenter, + useValue: { presentationVisible: () => false }, + }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(EpisodeItemComponent); + fixture.componentRef.setInput('episode', episode()); + }); + + it('renders an unstarted list row: number, runtime, no badge, no bar', () => { + fixture.detectChanges(); + + expect(el().classList).toContain('episode-list-item'); + expect(q('.episode-item__number')?.textContent?.trim()).toBe('3'); + expect(q('.episode-item__meta')?.textContent).toContain( + 'PORTALS.DETAIL.DURATION_MINUTES' + ); + expect(q('.episode-item__badge')).toBeNull(); + expect(q('.episode-item__progress')).toBeNull(); + }); + + it('shows the bar and the time left for a started episode', () => { + fixture.componentRef.setInput('position', position(900)); + fixture.detectChanges(); + + expect(el().classList).toContain('episode-item--in-progress'); + expect( + (q('.episode-item__progress i') as HTMLElement).style.width + ).toBe('33%'); + expect(q('.episode-item__meta')?.textContent).toContain( + 'WORKSPACE.DASHBOARD.REMAINING_MINUTES' + ); + expect(q('.episode-item__badge')).toBeNull(); + }); + + it('marks a watched episode with the badge and without a bar', () => { + fixture.componentRef.setInput('position', position(2700)); + fixture.detectChanges(); + + expect(el().classList).toContain('episode-item--watched'); + expect(q('[data-testid="episode-watched-badge"]')).not.toBeNull(); + expect(q('.episode-item__progress')).toBeNull(); + const toggle = q('[data-testid="episode-watched-toggle"]'); + expect(toggle?.getAttribute('aria-pressed')).toBe('true'); + expect(toggle?.classList).toContain( + 'episode-item__watched-toggle--watched' + ); + }); + + it('plays from the stretched target, but not while launching', () => { + const played = jest.fn(); + fixture.componentInstance.played.subscribe(played); + fixture.detectChanges(); + + const target = q('[data-testid="episode-play-target"]') as HTMLElement; + expect(target.tagName).toBe('BUTTON'); + target.click(); + expect(played).toHaveBeenCalledTimes(1); + + fixture.componentRef.setInput('launching', true); + fixture.detectChanges(); + target.click(); + expect(played).toHaveBeenCalledTimes(1); + expect(target.getAttribute('aria-disabled')).toBe('true'); + }); + + it('emits the watched toggle without playing', () => { + const played = jest.fn(); + const toggled = jest.fn(); + fixture.componentInstance.played.subscribe(played); + fixture.componentInstance.watchedToggled.subscribe(toggled); + fixture.detectChanges(); + + (q('[data-testid="episode-watched-toggle"]') as HTMLElement).click(); + + expect(toggled).toHaveBeenCalledTimes(1); + expect(played).not.toHaveBeenCalled(); + }); + + it('keeps the actions in the DOM for every row (reserved slot)', () => { + fixture.detectChanges(); + expect(q('.episode-item__actions')).not.toBeNull(); + expect(q('[data-testid="episode-more-button"]')).not.toBeNull(); + }); + + it('renders a grid card with a number chip instead of the number column', () => { + fixture.componentRef.setInput('layout', 'grid'); + fixture.detectChanges(); + + expect(el().classList).toContain('episode-card'); + expect(q('.episode-item__number')).toBeNull(); + expect(q('.episode-item__chip')?.textContent?.trim()).toBe('3'); + }); + + it('dims the still when the season has no distinct stills', () => { + fixture.componentRef.setInput( + 'episode', + episode({ movie_image: 'poster.jpg' }) + ); + fixture.componentRef.setInput('showStill', false); + fixture.detectChanges(); + + expect(q('.episode-item__thumb--fallback img')).not.toBeNull(); + }); + + it('leaves a failed still out', () => { + fixture.componentRef.setInput( + 'episode', + episode({ movie_image: 'broken.jpg' }) + ); + fixture.detectChanges(); + q('.episode-item__thumb img')?.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + expect(q('.episode-item__thumb img')).toBeNull(); + expect(q('.episode-item__thumb--fallback')).not.toBeNull(); + }); + describe('bare row', () => { + beforeEach(() => { + fixture.componentRef.setInput( + 'episode', + episode({ plot: '', movie_image: '' }) + ); + fixture.componentRef.setInput('bare', true); + }); + + it('drops the thumbnail and the description', () => { + fixture.componentRef.setInput( + 'episode', + episode({ plot: 'A plot' }) + ); + fixture.detectChanges(); + + expect(el().classList).toContain('episode-item--bare'); + expect(q('.episode-item__thumb')).toBeNull(); + expect(q('.episode-item__description')).toBeNull(); + expect(q('.episode-item__number')?.textContent?.trim()).toBe('3'); + expect(q('[data-testid="episode-more-button"]')).not.toBeNull(); + }); + + it('puts the watched check inline, without a bar', () => { + fixture.componentRef.setInput('position', position(2760)); + fixture.detectChanges(); + + expect( + q('.episode-item__title-row .episode-item__inline-check') + ).not.toBeNull(); + expect(q('.episode-item__inline-progress')).toBeNull(); + }); + + it('puts a 72px bar inline for a started episode', () => { + fixture.componentRef.setInput('position', position(900)); + fixture.detectChanges(); + + expect( + (q('.episode-item__inline-progress i') as HTMLElement).style + .width + ).toBe('33%'); + expect(q('.episode-item__inline-check')).toBeNull(); + }); + + it('shows the start in the number column while launching', () => { + fixture.componentRef.setInput('launching', true); + fixture.detectChanges(); + + expect( + q('.episode-item__number mat-progress-spinner') + ).not.toBeNull(); + }); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/episode-item.component.ts b/libs/ui/components/src/lib/season-container/episode-item.component.ts new file mode 100644 index 000000000..ec90ade85 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-item.component.ts @@ -0,0 +1,128 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, + signal, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatTooltip } from '@angular/material/tooltip'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { + getPortalPlaybackProgressPercent, + isPortalPlaybackInProgress, + isPortalPlaybackWatched, +} from '@iptvnator/portal/shared/util'; +import { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import { episodeTimeLabel, formatEpisodeAirDate } from './episode-meta.util'; +import { SeasonDownloadPresenter } from './season-download-presenter'; +import { resolveEpisodeInfo } from './season-watch-toggle.util'; + +export type EpisodeItemLayout = 'list' | 'grid'; + +/** + * One episode of the selected season, as a list row or a grid card. + * + * The whole item is one stretched button: a click or Enter plays (resuming + * from the saved position), Tab lands on it and reveals the actions through + * `:focus-within`. The actions — mark watched, download, "…" — sit in a + * reserved slot above that button, so showing them never moves the text. + * Watched episodes carry a check on the thumbnail and muted text, started + * ones a progress bar and the time left; there is no bar on a watched one. + * A season without any stills or plots renders `bare` rows: no thumbnail, + * no description, the check or a 72px bar inline after the meta. + */ +@Component({ + selector: 'app-episode-item', + templateUrl: './episode-item.component.html', + styleUrl: './episode-item.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatIcon, + MatMenuModule, + MatProgressSpinner, + MatTooltip, + TranslatePipe, + ], + host: { + class: 'episode-item', + '[class.episode-list-item]': "layout() === 'list'", + '[class.episode-card]': "layout() === 'grid'", + '[class.episode-item--bare]': 'bare()', + '[class.episode-item--watched]': 'watched()', + '[class.episode-item--in-progress]': 'inProgress()', + '[class.episode-item--current]': 'current()', + '[class.episode-item--launching]': 'launching()', + '[class.episode-item--playing]': 'playing()', + '[attr.data-episode-id]': 'episode().id', + }, +}) +export class EpisodeItemComponent { + private readonly translate = inject(TranslateService); + readonly downloadPresenter = inject(SeasonDownloadPresenter); + + readonly episode = input.required(); + readonly layout = input('list'); + readonly position = input(undefined); + /** Distinct per-episode still available; otherwise a dimmed fallback. */ + readonly showStill = input(true); + readonly launching = input(false); + /** Playing inline or in an external player session. */ + readonly playing = input(false); + /** The "now playing / continue" row of the season. */ + readonly current = input(false); + /** + * The season has neither stills nor plots: a 44px row without a + * thumbnail or description, the watched check or progress inline. + */ + readonly bare = input(false); + + readonly played = output(); + readonly restartRequested = output(); + readonly watchedToggled = output(); + readonly infoRequested = output(); + + readonly info = computed(() => resolveEpisodeInfo(this.episode())); + readonly watched = computed(() => isPortalPlaybackWatched(this.position())); + readonly inProgress = computed(() => + isPortalPlaybackInProgress(this.position()) + ); + /** Bar only for a started, unfinished episode. */ + readonly progressPercent = computed(() => + this.watched() ? 0 : getPortalPlaybackProgressPercent(this.position()) + ); + readonly timeLabel = computed(() => + episodeTimeLabel(this.info(), this.position()) + ); + + private readonly failedStill = signal(null); + readonly stillUrl = computed(() => { + const url = this.info()?.movie_image; + return url && url !== this.failedStill() ? url : null; + }); + + /** Read on every check: follows a language switch with the pipes. */ + airDate(): string { + return formatEpisodeAirDate( + this.info()?.releasedate, + this.translate.currentLang || this.translate.defaultLang + ); + } + + onStillError(url: string): void { + this.failedStill.set(url); + } + + play(): void { + if (!this.launching()) { + this.played.emit(); + } + } +} diff --git a/libs/ui/components/src/lib/season-container/episode-meta-state.util.spec.ts b/libs/ui/components/src/lib/season-container/episode-meta-state.util.spec.ts new file mode 100644 index 000000000..fe895240f --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-meta-state.util.spec.ts @@ -0,0 +1,131 @@ +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + resolveEpisodeMetaState, + usableStillUrl, +} from './episode-meta-state.util'; + +const SERIES_POSTER = 'https://img.test/series.jpg'; +const SEASON_COVER = 'https://img.test/season-1.jpg'; + +const episode = ( + id: number, + info: { plot?: string; movie_image?: string } = {} +): XtreamSerieEpisode => + ({ + id: String(id), + episode_num: id, + title: `Episode ${id}`, + container_extension: 'mp4', + info, + custom_sid: '', + added: '', + season: 1, + direct_source: '', + }) as XtreamSerieEpisode; + +const context = (distinctStills = true) => ({ + distinctStills, + posterUrls: [SERIES_POSTER, SEASON_COVER], +}); + +describe('usableStillUrl', () => { + it('keeps an episode still of its own', () => { + expect( + usableStillUrl( + episode(1, { movie_image: 'https://img.test/e1.jpg' }), + context() + ) + ).toBe('https://img.test/e1.jpg'); + }); + + it('treats the series poster or the season cover as no still', () => { + expect( + usableStillUrl( + episode(1, { movie_image: SERIES_POSTER }), + context() + ) + ).toBeNull(); + expect( + usableStillUrl( + episode(1, { movie_image: ` ${SEASON_COVER} ` }), + context() + ) + ).toBeNull(); + }); + + it('treats one image repeated across the season as no still', () => { + expect( + usableStillUrl( + episode(1, { movie_image: 'https://img.test/same.jpg' }), + context(false) + ) + ).toBeNull(); + }); +}); + +describe('resolveEpisodeMetaState', () => { + const listLoading = { list: true, metadata: false }; + const metadataLoading = { list: false, metadata: true }; + const settled = { list: false, metadata: false }; + + it('stays loading while the provider list is outstanding, whatever it holds', () => { + expect( + resolveEpisodeMetaState([episode(1)], listLoading, context()) + ).toBe('loading'); + expect(resolveEpisodeMetaState([], listLoading, context())).toBe( + 'loading' + ); + }); + + it('waits for metadata only when the data would render bare', () => { + const bare = [episode(1, { movie_image: SERIES_POSTER })]; + const described = [ + episode(1, { plot: 'Plot', movie_image: 'https://img.test/1.jpg' }), + ]; + + expect(resolveEpisodeMetaState(bare, metadataLoading, context())).toBe( + 'loading' + ); + expect( + resolveEpisodeMetaState(described, metadataLoading, context()) + ).toBe('full'); + expect(resolveEpisodeMetaState([], metadataLoading, context())).toBe( + 'full' + ); + }); + + it('is bare when no episode has a plot or a usable still', () => { + const episodes = [ + episode(1, { movie_image: SERIES_POSTER }), + episode(2, { plot: ' ' }), + episode(3), + ]; + expect(resolveEpisodeMetaState(episodes, settled, context())).toBe( + 'bare' + ); + }); + + it('is full when only some episodes lack a still or a plot', () => { + expect( + resolveEpisodeMetaState( + [episode(1, { plot: 'Something happens.' }), episode(2)], + settled, + context() + ) + ).toBe('full'); + expect( + resolveEpisodeMetaState( + [ + episode(1, { movie_image: 'https://img.test/e1.jpg' }), + episode(2), + ], + settled, + context() + ) + ).toBe('full'); + }); + + it('is full for an empty season (the empty state owns that case)', () => { + expect(resolveEpisodeMetaState([], settled, context())).toBe('full'); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/episode-meta-state.util.ts b/libs/ui/components/src/lib/season-container/episode-meta-state.util.ts new file mode 100644 index 000000000..7de85323c --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-meta-state.util.ts @@ -0,0 +1,74 @@ +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { resolveEpisodeInfo } from './season-watch-toggle.util'; + +/** + * How the selected season's episodes render: + * - `loading`: metadata is still on its way — skeleton rows at final height; + * - `full`: rows with thumbnails and plots (a missing still falls back to + * a dimmed tile, a missing plot leaves its line out); + * - `bare`: no episode has a plot or a usable still — 44px text rows. + */ +export type EpisodeMetaState = 'loading' | 'full' | 'bare'; + +export interface EpisodeStillContext { + /** + * False when every episode of the season repeats one image: providers + * often send the series poster as each episode's picture. + */ + readonly distinctStills: boolean; + /** Series poster and season cover: a "still" equal to either is none. */ + readonly posterUrls: readonly (string | null | undefined)[]; +} + +/** The episode's own still, or null when it has none worth showing. */ +export function usableStillUrl( + episode: XtreamSerieEpisode, + context: EpisodeStillContext +): string | null { + const image = resolveEpisodeInfo(episode)?.movie_image?.trim(); + if (!image || !context.distinctStills) { + return null; + } + return context.posterUrls.some((poster) => poster?.trim() === image) + ? null + : image; +} + +function hasPlot(episode: XtreamSerieEpisode): boolean { + return !!resolveEpisodeInfo(episode)?.plot?.trim(); +} + +export interface EpisodeLoadingState { + /** The provider's season or episode list is still on its way. */ + readonly list: boolean; + /** A TMDB lookup that could still fill the episodes is outstanding. */ + readonly metadata: boolean; +} + +/** + * Decided from the data, never from timing alone: `loading` while the + * provider list is outstanding; once it is in, `bare` only when no episode + * has a plot and none has a usable still — and `loading` instead of `bare` + * while metadata that could still fill those rows is outstanding. A season + * the provider already describes renders at once; the metadata then lands + * in place. + */ +export function resolveEpisodeMetaState( + episodes: readonly XtreamSerieEpisode[], + loading: EpisodeLoadingState, + context: EpisodeStillContext +): EpisodeMetaState { + if (loading.list) { + return 'loading'; + } + if (episodes.length === 0) { + return 'full'; + } + const bare = episodes.every( + (episode) => !hasPlot(episode) && !usableStillUrl(episode, context) + ); + if (!bare) { + return 'full'; + } + return loading.metadata ? 'loading' : 'bare'; +} diff --git a/libs/ui/components/src/lib/season-container/episode-meta.util.spec.ts b/libs/ui/components/src/lib/season-container/episode-meta.util.spec.ts new file mode 100644 index 000000000..c36b8ad62 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-meta.util.spec.ts @@ -0,0 +1,109 @@ +import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { episodeTimeLabel, formatEpisodeAirDate } from './episode-meta.util'; + +const position = ( + positionSeconds: number, + durationSeconds = 2700 +): PlaybackPositionData => ({ + contentXtreamId: 1, + contentType: 'episode', + positionSeconds, + durationSeconds, +}); + +describe('episodeTimeLabel', () => { + it('shows the runtime for an unstarted or a watched episode', () => { + expect(episodeTimeLabel({ duration_secs: 2760 }, undefined)).toEqual({ + key: 'PORTALS.DETAIL.DURATION_MINUTES', + params: { minutes: 46 }, + }); + expect( + episodeTimeLabel({ duration_secs: 2760 }, position(2700, 2760)) + ).toEqual({ + key: 'PORTALS.DETAIL.DURATION_MINUTES', + params: { minutes: 46 }, + }); + }); + + it('shows the time left for a started episode', () => { + expect( + episodeTimeLabel({ duration_secs: 2760 }, position(840)) + ).toEqual({ + key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES', + params: { minutes: 31 }, + }); + }); + + it('is null when the provider sent no runtime', () => { + expect(episodeTimeLabel({}, undefined)).toBeNull(); + expect(episodeTimeLabel(undefined, undefined)).toBeNull(); + }); + + it('shows where a started episode resumes when nobody knows its duration', () => { + const unknownDuration: PlaybackPositionData = { + contentXtreamId: 1, + contentType: 'episode', + positionSeconds: 754, + }; + expect(episodeTimeLabel({}, unknownDuration)).toEqual({ + key: 'PORTALS.DETAIL.RESUME_AT', + params: { time: '12:34' }, + }); + expect( + episodeTimeLabel({}, { ...unknownDuration, positionSeconds: 3725 }) + ).toEqual({ + key: 'PORTALS.DETAIL.RESUME_AT', + params: { time: '1:02:05' }, + }); + // A known runtime still wins over the bare position. + expect( + episodeTimeLabel({ duration_secs: 2760 }, unknownDuration) + ).toEqual({ + key: 'PORTALS.DETAIL.DURATION_MINUTES', + params: { minutes: 46 }, + }); + }); +}); + +describe('formatEpisodeAirDate', () => { + const now = new Date(2026, 9, 8); + + it('drops the year for a date in the current year', () => { + expect(formatEpisodeAirDate('2026-01-12', 'en', now)).toBe('Jan 12'); + }); + + it('keeps the year for an older date', () => { + expect(formatEpisodeAirDate('2019-01-12', 'en', now)).toBe( + 'Jan 12, 2019' + ); + }); + + it('reads an ISO day as a local calendar day', () => { + const label = formatEpisodeAirDate('2026-03-01', 'en', now); + expect(label).toBe('Mar 1'); + }); + + it('maps app locale codes', () => { + expect(formatEpisodeAirDate('2026-01-12', 'ru', now)).toMatch( + /^12 янв/ + ); + }); + + it('is empty for missing or unparseable dates', () => { + expect(formatEpisodeAirDate(undefined, 'en', now)).toBe(''); + expect(formatEpisodeAirDate(' ', 'en', now)).toBe(''); + expect(formatEpisodeAirDate('soon', 'en', now)).toBe(''); + }); + + it('is empty for a provider placeholder or an impossible day', () => { + // Providers send "0000-00-00" for "no date"; the Date constructor + // would make a day in 1899 of it and roll Feb 31 into March. + expect(formatEpisodeAirDate('0000-00-00', 'en', now)).toBe(''); + expect(formatEpisodeAirDate('0000-00-00 00:00:00', 'en', now)).toBe(''); + expect(formatEpisodeAirDate('2025-02-31', 'en', now)).toBe(''); + expect(formatEpisodeAirDate('2025-13-01', 'en', now)).toBe(''); + expect(formatEpisodeAirDate('2024-02-29', 'en', now)).toBe( + 'Feb 29, 2024' + ); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/episode-meta.util.ts b/libs/ui/components/src/lib/season-container/episode-meta.util.ts new file mode 100644 index 000000000..96ae2991d --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-meta.util.ts @@ -0,0 +1,112 @@ +import { formatWithIntl } from '@iptvnator/pipes/date-format'; +import { + formatDurationLabel, + formatRemainingLabel, + isPortalPlaybackInProgress, + type RemainingTimeLabel, +} from '@iptvnator/portal/shared/util'; +import { + PlaybackPositionData, + XtreamSerieEpisodeInfo, +} from '@iptvnator/shared/interfaces'; +import { episodeRuntimeSeconds } from './episode-progress.util'; + +/** An i18n key with its parameters, as the meta line's time part. */ +export interface EpisodeTimeLabel { + readonly key: string; + readonly params: Record; +} + +/** "5:00" / "1:05:00" from a number of seconds. */ +function formatClock(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = seconds % 60; + const pad = (value: number) => String(value).padStart(2, '0'); + return hours > 0 + ? `${hours}:${pad(minutes)}:${pad(rest)}` + : `${minutes}:${pad(rest)}`; +} + +/** + * The time part of an episode's meta line: the time left for a started + * episode, otherwise its runtime. A started episode whose duration nobody + * knows (no provider or TMDB runtime, no duration saved with the position) + * still shows where it resumes. Null when there is nothing to say. + */ +export function episodeTimeLabel( + info: XtreamSerieEpisodeInfo | undefined, + position: PlaybackPositionData | undefined +): EpisodeTimeLabel | null { + if (isPortalPlaybackInProgress(position)) { + const remaining: RemainingTimeLabel | null = + formatRemainingLabel(position); + if (remaining) { + return remaining; + } + } + const runtime = formatDurationLabel(episodeRuntimeSeconds(info)); + if (runtime) { + return runtime; + } + return position && isPortalPlaybackInProgress(position) + ? { + key: 'PORTALS.DETAIL.RESUME_AT', + params: { time: formatClock(position.positionSeconds) }, + } + : null; +} + +const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/; + +/** + * A local calendar day from an ISO day, or null when the parts do not name + * one: the `Date` constructor would otherwise turn a provider's + * "0000-00-00" placeholder into a day in 1899 and roll "2025-02-31" over + * into March. + */ +function parseIsoDay(match: RegExpExecArray): Date | null { + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31) { + return null; + } + const date = new Date(year, month - 1, day); + return date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day + ? date + : null; +} + +/** + * "12 Jan" for an air date in the current year, "12 Jan 2019" otherwise. + * An ISO day ("2019-01-12", the Xtream and TMDB form) is read as a local + * calendar day, so it never shifts by one west of UTC. Empty when the + * provider sent nothing parseable, a placeholder or an impossible day. + */ +export function formatEpisodeAirDate( + releaseDate: string | undefined | null, + locale: string | undefined | null, + now: Date = new Date() +): string { + const value = releaseDate?.trim(); + if (!value) { + return ''; + } + const iso = ISO_DAY.exec(value); + const date = iso ? parseIsoDay(iso) : new Date(value); + if (!date || Number.isNaN(date.getTime())) { + return ''; + } + return formatWithIntl(date, { + locale, + day: 'numeric', + month: 'short', + ...(date.getFullYear() === now.getFullYear() + ? {} + : { year: 'numeric' }), + }); +} diff --git a/libs/ui/components/src/lib/season-container/episode-progress.util.ts b/libs/ui/components/src/lib/season-container/episode-progress.util.ts index 91868e8d5..03c5db889 100644 --- a/libs/ui/components/src/lib/season-container/episode-progress.util.ts +++ b/libs/ui/components/src/lib/season-container/episode-progress.util.ts @@ -1,57 +1,11 @@ -import { - isPortalPlaybackWatched, - parseDurationSeconds, -} from '@iptvnator/portal/shared/util'; -import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; +import { parseDurationSeconds } from '@iptvnator/portal/shared/util'; -/** Pure helpers for episode duration parsing and progress-text formatting. */ +/** Pure helpers for episode duration parsing. */ export function parseDuration(duration: string | number | undefined): number { return parseDurationSeconds(duration); } -/** - * Human-readable in-progress text ("41:12 left" / elapsed time when the - * duration is unknown). Returns null for watched or position-less episodes. - */ -export function formatEpisodePositionText( - position: PlaybackPositionData | undefined -): string | null { - if (isPortalPlaybackWatched(position)) { - return null; - } - - if (!position || !position.positionSeconds) { - return null; - } - - let seconds = position.positionSeconds; - let suffix = ''; - - if (position.durationSeconds && position.durationSeconds > 0) { - const remaining = Math.max( - 0, - position.durationSeconds - position.positionSeconds - ); - - if (remaining <= 0) { - return null; - } - - seconds = remaining; - suffix = ' left'; - } - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - const formatted = [hours, minutes, secs] - .map((value) => String(value).padStart(2, '0')) - .filter((value, index) => (index === 0 ? value !== '00' : true)) - .join(':'); - return `${formatted}${suffix}`; -} - /** The episode runtime: the provider's `duration_secs` first, then its formatted `duration`. */ export function episodeRuntimeSeconds( info: { duration_secs?: number; duration?: string } | null | undefined diff --git a/libs/ui/components/src/lib/season-container/episode-skeleton.component.scss b/libs/ui/components/src/lib/season-container/episode-skeleton.component.scss new file mode 100644 index 000000000..c7a085c04 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-skeleton.component.scss @@ -0,0 +1,177 @@ +@use '../styles/skeleton-shimmer' as shimmer; + +@include shimmer.keyframes; + +// Geometry mirrors episode-item.component.scss: change both together. + +:host { + display: block; +} + +:host(.episodes-list) { + display: flex; + flex-direction: column; + gap: 2px; +} + +:host(.episodes-grid) { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(236px, 1fr)); + gap: 20px 16px; +} + +.episode-skeleton { + position: relative; + display: grid; + grid-template-columns: 36px 168px minmax(0, 1fr) 112px; + column-gap: 16px; + align-items: center; + padding: 10px 12px 10px 8px; + + // Same hairline as the real rows, inset past the number column. + & + &::before { + content: ''; + position: absolute; + top: -2px; + right: 12px; + left: 56px; + height: 1px; + background: color-mix(in srgb, var(--app-on-surface) 7%, transparent); + } + + &__number { + justify-self: center; + width: 14px; + height: 14px; + @include shimmer.block(4px); + } + + // The real thumbnail is 16:9 inside a 1px hairline. + &__thumb { + aspect-ratio: 16 / 9; + border: 1px solid transparent; + @include shimmer.block(6px); + } + + // Starts the fourth column empty: the reserved action slot. + &__text { + display: flex; + flex-direction: column; + min-width: 0; + } + + // One title line (14.5px × 1.35) with the meta beside it. + &__title-row { + display: flex; + align-items: center; + gap: 10px; + height: 19.575px; + } + + &__title { + width: 90px; + height: 12px; + @include shimmer.block(4px); + } + + &__meta { + width: 110px; + height: 10px; + @include shimmer.block(4px); + } + + // Two description lines of 12.5px × 1.45 (18.125px each), 4px under the + // title; each bar sits centred in its line box. + &__line { + --line-box: 18.125px; + --bar: 9px; + + width: min(100%, 74ch); + height: var(--bar); + margin-block: calc((var(--line-box) - var(--bar)) / 2); + @include shimmer.block(4px); + + .episode-skeleton__title-row + & { + margin-top: calc(4px + (var(--line-box) - var(--bar)) / 2); + } + + &--short { + width: min(70%, 52ch); + } + } + + // Grid card: the artwork on top, title and two lines under it. + &--card { + grid-template-columns: minmax(0, 1fr); + align-items: start; + padding: 0; + + &::before { + display: none; + } + + .episode-skeleton__thumb { + @include shimmer.block(10px); + } + + .episode-skeleton__text { + margin-top: 10px; + } + + .episode-skeleton__title-row { + height: 18.225px; + } + + .episode-skeleton__line { + --line-box: 17.4px; + --bar: 8px; + } + } +} + +// The reserved action slot: empty at the real row's 34px button height. +.episode-skeleton__actions { + height: 34px; +} + +// Narrow details pane: the real row's 24px / 112px columns. +@container detail (max-width: 640px) { + .episode-skeleton:not(.episode-skeleton--card) { + grid-template-columns: 24px 112px minmax(0, 1fr) auto; + column-gap: 12px; + + .episode-skeleton__line--short { + display: none; + } + } +} + +// Phone width: the real row's 96px thumbnail and the action row under the +// text, so finished rows land exactly where the placeholders were. +@container detail (max-width: 480px) { + .episode-skeleton:not(.episode-skeleton--card) { + grid-template-columns: 24px 96px minmax(0, 1fr); + grid-template-areas: + 'number thumb text' + 'number thumb actions'; + row-gap: 2px; + padding: 8px 8px 8px 4px; + + .episode-skeleton__number { + grid-area: number; + } + + .episode-skeleton__thumb { + grid-area: thumb; + } + + .episode-skeleton__text { + grid-area: text; + align-self: end; + } + + .episode-skeleton__actions { + grid-area: actions; + } + } +} diff --git a/libs/ui/components/src/lib/season-container/episode-skeleton.component.ts b/libs/ui/components/src/lib/season-container/episode-skeleton.component.ts new file mode 100644 index 000000000..26e95ef26 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-skeleton.component.ts @@ -0,0 +1,69 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { EpisodeItemLayout } from './episode-item.component'; + +/** Rows shown while the provider has not said how many episodes there are. */ +const DEFAULT_SKELETON_ROWS = 6; + +/** + * Loading placeholders for the season container's episodes, at the exact + * geometry of the full-density rows (or grid cards) they precede, so the + * real items swap in without moving anything: a 168×94 thumbnail, a 90px + * title and 110px meta line, two description lines. No hover state, no + * actions (the slot is reserved, and at phone width it is the row the + * real actions take under the text). Bare mode is never anticipated — it + * is decided once the data is final. + */ +@Component({ + selector: 'app-episode-skeleton', + template: ` + @for (row of rows(); track row) { +
+ @if (layout() === 'list') { + + } + + + + + + + + + + @if (layout() === 'list') { + + } +
+ } + `, + styleUrl: './episode-skeleton.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'aria-hidden': 'true', + '[class.episodes-list]': "layout() === 'list'", + '[class.episodes-grid]': "layout() === 'grid'", + }, +}) +export class EpisodeSkeletonComponent { + readonly layout = input('list'); + /** Episode count when known (TMDB pending), else a default screenful. */ + readonly count = input(null); + + readonly rows = computed(() => + Array.from( + { length: this.count() || DEFAULT_SKELETON_ROWS }, + (_, index) => index + ) + ); +} diff --git a/libs/ui/components/src/lib/season-container/episode-subline.util.ts b/libs/ui/components/src/lib/season-container/episode-subline.util.ts deleted file mode 100644 index 2b73f8b50..000000000 --- a/libs/ui/components/src/lib/season-container/episode-subline.util.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { TranslateService } from '@ngx-translate/core'; -import { - formatDurationLabel, - formatRemainingLabel, - isPortalPlaybackWatched, -} from '@iptvnator/portal/shared/util'; -import { - PlaybackPositionData, - XtreamSerieEpisodeInfo, -} from '@iptvnator/shared/interfaces'; -import { - episodeRuntimeSeconds, - formatEpisodePositionText, -} from './episode-progress.util'; - -/** - * The line under an episode card's title, built from the episode's runtime - * and its saved playback position: - * "42 min · 18m left", "42 min · watched", "42 min" — or null. - */ -export function buildEpisodeSubline( - info: XtreamSerieEpisodeInfo | undefined, - position: PlaybackPositionData | undefined, - translate: Pick -): string | null { - const duration = formatDurationLabel(episodeRuntimeSeconds(info)); - const remaining = formatRemainingLabel(position); - const parts = [ - duration ? translate.instant(duration.key, duration.params) : null, - isPortalPlaybackWatched(position) - ? translate.instant('PORTALS.DETAIL.WATCHED') - : remaining - ? translate.instant(remaining.key, remaining.params) - : formatEpisodePositionText(position), - ].filter((part): part is string => !!part); - return parts.length ? parts.join(' · ') : null; -} diff --git a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts index 29951e5d9..4205da228 100644 --- a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts +++ b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts @@ -1,8 +1,4 @@ -import { - episodeRuntimeSeconds, - formatEpisodePositionText, - parseDuration, -} from './episode-progress.util'; +import { episodeRuntimeSeconds, parseDuration } from './episode-progress.util'; describe('episodeRuntimeSeconds', () => { it('prefers the provider seconds over the formatted string', () => { @@ -30,27 +26,4 @@ describe('episode-progress.util', () => { expect(parseDuration(120)).toBe(120); expect(parseDuration(undefined)).toBe(0); }); - - it('formats remaining time when duration is known', () => { - expect( - formatEpisodePositionText({ - contentXtreamId: 1, - contentType: 'episode', - positionSeconds: 60, - durationSeconds: 360, - }) - ).toBe('05:00 left'); - }); - - it('returns null for watched or missing positions', () => { - expect(formatEpisodePositionText(undefined)).toBeNull(); - expect( - formatEpisodePositionText({ - contentXtreamId: 1, - contentType: 'episode', - positionSeconds: 350, - durationSeconds: 360, - }) - ).toBeNull(); - }); }); diff --git a/libs/ui/components/src/lib/season-container/metadata-wait.state.ts b/libs/ui/components/src/lib/season-container/metadata-wait.state.ts new file mode 100644 index 000000000..e766eefc8 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/metadata-wait.state.ts @@ -0,0 +1,40 @@ +import { + type Signal, + computed, + effect, + signal, + untracked, +} from '@angular/core'; + +/** + * How long episode rows wait as skeletons for TMDB metadata before the + * provider's own data renders anyway. TMDB requests carry no timeout, and a + * blocked or throttled TMDB host must not hold back episodes the provider + * has already delivered. + */ +export const EPISODE_METADATA_WAIT_MS = 4000; + +/** + * `waiting`, capped: true while the host reports pending metadata, until + * `EPISODE_METADATA_WAIT_MS` has passed for the same `scope` (the selected + * season). A new scope, or the wait ending and starting again, re-arms it. + * Must be created in an injection context. + */ +export function createCappedMetadataWait( + waiting: Signal, + scope: Signal, + timeoutMs = EPISODE_METADATA_WAIT_MS +): Signal { + const expired = signal(false); + effect((onCleanup) => { + scope(); + const isWaiting = waiting(); + untracked(() => expired.set(false)); + if (!isWaiting) { + return; + } + const timer = setTimeout(() => expired.set(true), timeoutMs); + onCleanup(() => clearTimeout(timer)); + }); + return computed(() => waiting() && !expired()); +} diff --git a/libs/ui/components/src/lib/season-container/season-container.component.html b/libs/ui/components/src/lib/season-container/season-container.component.html index 6f257527c..7339d06ca 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.html +++ b/libs/ui/components/src/lib/season-container/season-container.component.html @@ -1,35 +1,45 @@
- -
-

- {{ 'PORTALS.DETAIL.EPISODES' | translate }} -

+ + @let episodeCount = selectedSeasonEpisodes().length; + @let watchedCount = watchedCounts()[selectedSeason() ?? ''] ?? 0; + @if (hasSeasons()) { - @if (selectedSeasonEpisodes().length; as count) { - - {{ - (count === 1 - ? 'PORTALS.EPISODE_COUNT_ONE' - : 'PORTALS.EPISODE_COUNT_OTHER' - ) | translate: { count } - }} - - } } - @if (hasSeasons() && !showSeasonEmptyState()) { -
+ @if ( + hasSeasons() && !showSeasonEmptyState() && metaState() !== 'bare' + ) { +
} -
+ - @if ( - hasSeasons() && - (selectedSeasonPosterUrl() || selectedSeasonDescription()) - ) { - -
- @if (selectedSeasonPosterUrl(); as posterUrl) { - - } - @if (selectedSeasonDescription(); as description) { - - } -
+ @if (hasSeasons() && selectedSeasonDescription(); as description) { + + }
- @if (isLoading()) { -
- -
+ @if (metaState() === 'loading') { + } @else if (showSeriesEmptyState()) {
} @else { - @if (viewMode() === 'grid') { - -
- @for (episode of selectedSeasonEpisodes(); track $index) { - @let episodeInfo = getEpisodeInfo(episode); -
-
- @if (episodeInfo?.movie_image) { - Episode thumbnail - } @else { -
- play_circle_outline -
- } - @if (getEpisodeProgress(episode) > 0) { - - } - -
- @if ( - downloadPresenter.presentationVisible() - ) { - @let downloadRow = - downloadPresenter.rowFor(episode); - @let downloadState = - downloadRow.presentation; - - } -
-
-
- @if (isEpisodeLaunching(episode)) { - - } @else { - {{ - isEpisodeActiveExternal( - episode - ) || - isEpisodePlayingInline(episode) - ? 'equalizer' - : 'play_arrow' - }} - } -
-
-
-
-
-

- {{ episode.episode_num }}. - {{ episode.title }} -

- @if (episodeInfo?.plot) { - - } -
- @if (episodeInfo?.plot) { -

- {{ episodeInfo?.plot }} -

- } - @if (getEpisodeSubline(episode); as subline) { - - {{ subline }} - - } -
-
- } -
- } @else { - -
- @for (episode of selectedSeasonEpisodes(); track $index) { - @let episodeInfo = getEpisodeInfo(episode); -
- @if (listThumbnailsEnabled()) { -
- @if (episodeInfo?.movie_image) { - Episode thumbnail - } @else { - play_circle_outline - } - {{ episode.episode_num }} -
- } @else { -
- {{ episode.episode_num }} -
- } -
-
-

- {{ episode.title }} - @if (isEpisodeWatched(episode)) { - check_circle - } -

-
- @if (episodeInfo?.duration) { - - {{ episodeInfo?.duration }} - - } - @if ( - getEpisodePositionText(episode); - as time - ) { - - {{ time }} - - } -
-
- @if (episodeInfo?.plot) { -

- {{ episodeInfo?.plot }} -

- } - @if (getEpisodeProgress(episode) > 0) { -
-
-
- } -
-
- - @if (downloadPresenter.presentationVisible()) { - @let downloadRow = - downloadPresenter.rowFor(episode); - @let downloadState = - downloadRow.presentation; - - } - @if (episodeInfo?.plot) { - - } - -
-
- } -
- } +
+ @for (episode of selectedSeasonEpisodes(); track $index) { + + } +
}
diff --git a/libs/ui/components/src/lib/season-container/season-container.component.scss b/libs/ui/components/src/lib/season-container/season-container.component.scss index a73d02e2d..05047a5ec 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.scss +++ b/libs/ui/components/src/lib/season-container/season-container.component.scss @@ -1,21 +1,5 @@ .seasons-section { width: 100%; - margin-top: 4px; -} - -@media (prefers-reduced-motion: reduce) { - .episode-card, - .episode-card__thumbnail img { - transition: none; - } -} - -.loading-container { - display: flex; - justify-content: center; - align-items: center; - padding: 30px 0; - width: 100%; } .empty-state-panel { @@ -92,768 +76,130 @@ } } -// Grid Layout -.episodes-grid { - display: grid; - gap: 16px; - width: 100%; - grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); -} - -// "Episodes" header: title, season pills, count and the view toggle on one -// line; the pills wrap first when the pane is narrow. +// Episodes header: the shared section header with the season picker after +// the title and the view toggle at the far edge. .episodes-header { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 6px 10px; - margin-bottom: 14px; - - &__title { - margin: 0 6px 0 0; - color: var(--text-primary); - font-size: 16px; - font-weight: 600; - line-height: 1.3; - } - &__tabs { display: block; min-width: 0; } - &__count { - color: var(--text-muted); - font-size: 12px; - white-space: nowrap; - } - - .view-toggle { - margin-left: auto; - - mat-button-toggle-group { - border: 1px solid - color-mix(in srgb, var(--app-on-surface) 14%, transparent); - border-radius: 6px; - overflow: hidden; - background: color-mix( - in srgb, - var(--app-on-surface) 3%, - transparent - ); - - ::ng-deep { - .mat-button-toggle { - background: transparent; - - .mat-button-toggle-button { - padding: 0 8px; - height: 30px; - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: var(--text-secondary); - } - - &.mat-button-toggle-checked { - background: var(--app-selection-surface); - - mat-icon { - color: var(--app-selection-color); - } - } - - &:hover:not(.mat-button-toggle-checked) { - background: var(--app-hover-overlay); - } - } - - .mat-button-toggle-appearance-standard { - .mat-button-toggle-label-content { - line-height: 30px; - padding: 0; - } - } - - .mat-button-toggle + .mat-button-toggle { - border-left: 1px solid - color-mix( - in srgb, - var(--app-on-surface) 14%, - transparent - ); - } - } - } - } -} - -// The selected season's cover (--season-cover-width follows -// Settings.coverSize) and synopsis under the header. -.season-strip { - display: flex; - align-items: flex-start; - gap: 14px; - margin-bottom: 14px; - - &__cover { - display: block; - flex: 0 0 auto; - width: calc(var(--season-cover-width, 120px) * 0.5); - aspect-ratio: 2 / 3; - object-fit: cover; + // Segmented list/grid switch: a 2px-padded track with 30×26 segments; + // the checked one is a neutral raised segment with full-contrast ink. + .view-toggle mat-button-toggle-group { + gap: 2px; + height: auto; + padding: 2px; + overflow: visible; + border: 1px solid + color-mix(in srgb, var(--app-on-surface) 14%, transparent); border-radius: 8px; background: var(--app-widget-bg); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); - } - &__description { - flex: 1 1 auto; - min-width: 0; - max-width: 760px; - font-size: 12.5px; - line-height: 1.55; - color: var(--text-secondary); + ::ng-deep { + .mat-button-toggle { + border: 0; + border-radius: 6px; + background: transparent; + color: var(--app-eyebrow-color); + + .mat-button-toggle-button { + width: 30px; + height: 26px; + padding: 0; + } + + .mat-button-toggle-label-content { + display: grid; + place-items: center; + padding: 0; + line-height: 26px; + } + + mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + line-height: 16px; + color: inherit; + } + + &:hover:not(.mat-button-toggle-checked) { + color: var(--app-heading-color); + } + + &.mat-button-toggle-checked { + background: color-mix( + in srgb, + var(--app-on-surface) 12%, + transparent + ); + color: var(--app-heading-color); + } + } + + .mat-button-toggle + .mat-button-toggle { + border-left: 0; + } + } } } -// Episode Card — flat: the thumbnail and the text sit on the page, no box. -.episode-card { - position: relative; - cursor: pointer; - border-radius: 10px; - transition: opacity 0.2s ease; - - &--launching, - &--active-external { - .episode-card__thumbnail { - box-shadow: 0 0 0 2px - color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent); - } - } - - &--launching { - .episode-card__overlay { - opacity: 1; - background: rgba(0, 0, 0, 0.56); - } - } - - // Playing in the inline player - &--playing .episode-card__thumbnail { - box-shadow: 0 0 0 2px - color-mix(in srgb, var(--mat-sys-primary) 60%, transparent); - } - - &:focus-visible { - outline: 2px solid var(--app-selection-color); - outline-offset: 2px; - } - - // Watched state — the sole indicator is the watched toggle on the - // thumbnail (plus the dimmed card); no extra checkmarks elsewhere. - &--watched { - opacity: 0.7; - - &:hover { - opacity: 1; - } - } - - // Watched indicator + toggle in one control, top-left on the artwork. - // Watched: always visible, green (the old badge look). Unwatched: ghost - // circle revealed on card hover. - &__watched-toggle { - position: absolute; - top: 6px; - left: 6px; - z-index: 2; - width: 28px; - height: 28px; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - color: rgba(255, 255, 255, 0.75); - background: rgba(0, 0, 0, 0.6); - border: 1px solid rgba(255, 255, 255, 0.14); - opacity: 0; - transition: - opacity 0.2s ease, - background 0.2s ease, - color 0.2s ease; - - mat-icon { - font-size: 17px; - width: 17px; - height: 17px; - line-height: 17px; - } - - &:hover { - color: white; - background: rgba(0, 0, 0, 0.85); - } - - &:focus-visible { - opacity: 1; - } - - &--watched { - opacity: 1; - color: white; - background: rgba(70, 211, 105, 0.9); - border-color: transparent; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); - - &:hover { - color: white; - background: rgba(70, 211, 105, 1); - } - } - } - - &:hover .episode-card__watched-toggle { - opacity: 1; - } - - &:hover { - .episode-card__overlay { - opacity: 1; - } - - .episode-card__play { - transform: scale(1); - } - - .episode-card__thumbnail img { - transform: scale(1.03); - } - } - - &__thumbnail { - position: relative; - aspect-ratio: 16 / 9; - background: #000; - // A hairline keeps the artwork's edge visible on the light theme too. - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 10px; - overflow: hidden; - - img { - width: 100%; - height: 100%; - object-fit: cover; - transition: transform 0.2s ease; - } - } - - // 3px watched share at the bottom of the artwork - &__progress { - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 3px; - background: rgba(255, 255, 255, 0.15); - z-index: 2; - - i { - display: block; - height: 100%; - background: var(--app-selection-color, #98b8ff); - } - } - - &__placeholder { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%); - color: var(--text-muted); - - mat-icon { - font-size: 32px; - width: 32px; - height: 32px; - opacity: 0.3; - } - } - - &__overlay { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.4); - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.2s ease; - z-index: 1; - } - - &__actions { - position: absolute; - top: 4px; - right: 4px; - z-index: 2; - opacity: 0; - transition: opacity 0.2s ease; - display: flex; - gap: 4px; - - .download-btn { - color: rgba(255, 255, 255, 0.75); - background: rgba(0, 0, 0, 0.6); - border: 1px solid rgba(255, 255, 255, 0.14); - width: 28px; - height: 28px; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - - &:hover { - color: white; - background: rgba(0, 0, 0, 0.85); - } - - mat-icon { - font-size: 17px; - width: 17px; - height: 17px; - line-height: 17px; - } - } - - .download-btn:hover { - background: rgba(25, 118, 210, 0.8); - } - - .download-btn--completed { - color: var(--mat-sys-tertiary) !important; - - &:hover { - background: color-mix( - in srgb, - var(--mat-sys-tertiary) 80%, - transparent - ); - } - } - - .download-btn--downloading { - color: rgba(255, 255, 255, 0.5) !important; - cursor: not-allowed; - } - - .download-btn--paused { - color: #fbbf24 !important; - - &:hover { - background: color-mix(in srgb, #fbbf24 20%, transparent); - } - } - } - - &:hover { - .episode-card__actions { - opacity: 1; - } - } - - &__play { - width: 40px; - height: 40px; - border-radius: 50%; - background: var(--accent-color); - display: flex; - align-items: center; - justify-content: center; - color: white; - transform: scale(0.8); - transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); - - mat-icon { - font-size: 24px; - width: 24px; - height: 24px; - } - } - - &__info { - padding: 8px 2px 0; - } - - &__title-row { - display: flex; - align-items: flex-start; - gap: 4px; - margin-bottom: 2px; - } - - &__title { - flex: 1; - min-width: 0; - font-size: 13px; - font-weight: 500; - margin: 0; - color: var(--text-primary); - line-height: 1.4; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - // "About the episode" lives with the text, not on the artwork: a quiet - // ghost button at the end of the title row, revealed on card hover. - &__info-btn { - flex: none; - width: 22px; - height: 22px; - padding: 0; - margin: -1px -2px 0 0; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--text-muted); - opacity: 0; - transition: - opacity 0.2s ease, - color 0.2s ease; - - mat-icon { - font-size: 17px; - width: 17px; - height: 17px; - line-height: 17px; - } - - &:hover { - color: var(--text-primary); - } - - &:focus-visible { - opacity: 1; - } - - // The default 48px MDC touch target would swallow clicks on the - // title around this tiny button (card click = play). - ::ng-deep .mat-mdc-button-touch-target { - width: 100%; - height: 100%; - } - } - - &:hover .episode-card__info-btn { - opacity: 1; - } - - &__plot { - font-size: 12px; - color: var(--text-secondary); - margin: 0 0 4px 0; - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - - &__subline { - display: block; - font-size: 11.5px; - color: var(--text-muted); - } +// The selected season's own synopsis under the header, offset past the +// 36px number column and its gap so it lines up with the episode numbers. +// One that repeats the hero's series description is dropped by the +// container, and without a synopsis nothing renders. +.season-description { + display: block; + max-width: 72ch; + margin: 4px 0 24px; + padding-left: 52px; + font-size: 13px; + line-height: 1.55; + color: var(--text-secondary); } -// Episodes List View +// ============================================================================ +// Episodes: list rows and grid cards (app-episode-item) +// ============================================================================ + +.episodes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(236px, 1fr)); + gap: 20px 16px; + width: 100%; +} + +// Borderless rows separated by a hairline inset past the number column. The +// hairline gives way around a hovered, focused or highlighted row, so the +// row's fill never sits against a line. .episodes-list { display: flex; flex-direction: column; gap: 2px; - width: 100%; -} -.episode-list-item { - display: flex; - align-items: center; - gap: 16px; - padding: 12px 16px; - background: color-mix(in srgb, var(--app-on-surface) 3%, transparent); - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; - border: 1px solid color-mix(in srgb, var(--app-on-surface) 14%, transparent); - - &:hover { - background: var(--app-card-hover-bg); - border-color: color-mix( - in srgb, - var(--app-on-surface) 22%, - transparent - ); - - .episode-list-item__actions { - opacity: 1; - } - } - - &--watched { - opacity: 0.6; - - &:hover { - opacity: 1; - } - } - - &--in-progress { - border-color: color-mix( - in srgb, - var(--mat-sys-primary) 40%, - transparent - ); - background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent); - - &:hover { - border-color: color-mix( - in srgb, - var(--mat-sys-primary) 60%, - transparent - ); - background: color-mix( - in srgb, - var(--mat-sys-primary) 8%, - transparent - ); - } - } - - &__number { - display: flex; - align-items: center; - justify-content: center; - min-width: 36px; - height: 36px; - background: var(--app-hover-overlay); - border-radius: 6px; - font-size: 0.875rem; - font-weight: 600; - color: var(--text-secondary); - flex-shrink: 0; - } - - // Compact 16:9 episode still (only when the season has distinct - // per-episode art); the episode number rides on the image. - &__thumb { - position: relative; - flex-shrink: 0; - width: 96px; - height: 54px; - border-radius: 6px; - overflow: hidden; - background: rgba(255, 255, 255, 0.05); - display: flex; - align-items: center; - justify-content: center; - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } - } - - &__thumb-placeholder { - color: var(--text-muted); - font-size: 22px; - width: 22px; - height: 22px; - } - - &__thumb-number { + > app-episode-item + app-episode-item::before { + content: ''; position: absolute; - bottom: 3px; - right: 3px; - background: rgba(0, 0, 0, 0.8); - color: white; - font-size: 0.65rem; - font-weight: 600; - line-height: 1.5; - padding: 0 5px; - border-radius: 3px; + top: -2px; + right: 12px; + left: 56px; + height: 1px; + background: color-mix(in srgb, var(--app-on-surface) 7%, transparent); + pointer-events: none; } - &__content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; + // Bare rows have no thumbnail: the hairline starts past the number. + &--bare > app-episode-item + app-episode-item::before { + left: 48px; } - &__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - } - - &__title { - margin: 0; - font-size: 0.9375rem; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: flex; - align-items: center; - gap: 6px; - - .watched-icon { - font-size: 16px; - width: 16px; - height: 16px; - color: var(--mat-sys-tertiary); - } - } - - &__meta { - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; - } - - &__duration { - font-size: 0.75rem; - color: var(--text-muted); - padding: 2px 8px; - background: rgba(255, 255, 255, 0.06); - border-radius: 10px; - } - - &__progress-text { - font-size: 0.75rem; - font-weight: 500; - color: var(--mat-sys-primary); - padding: 2px 8px; - background: color-mix(in srgb, var(--mat-sys-primary) 15%, transparent); - border-radius: 10px; - } - - &__description { - margin: 0; - font-size: 0.8125rem; - color: var(--text-secondary); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; - } - - &__progress-bar { - height: 3px; - background: rgba(255, 255, 255, 0.1); - border-radius: 2px; - overflow: hidden; - margin-top: 4px; - } - - &__progress-fill { - height: 100%; - background: var(--app-progress-color); - border-radius: 2px; - transition: width 0.3s ease; - } - - &__actions { - display: flex; - align-items: center; - gap: 4px; + > app-episode-item:hover::before, + > app-episode-item:focus-within::before, + > app-episode-item.episode-item--current::before, + > app-episode-item:hover + app-episode-item::before, + > app-episode-item:focus-within + app-episode-item::before, + > app-episode-item.episode-item--current + app-episode-item::before { opacity: 0; - transition: opacity 0.15s ease; - - .action-btn { - width: 36px; - height: 36px; - color: var(--text-secondary); - transition: all 0.15s ease; - - mat-icon { - font-size: 20px; - width: 20px; - height: 20px; - } - - &:hover { - color: var(--text-primary); - background: rgba(255, 255, 255, 0.1); - } - - &.play-btn { - color: var(--accent-color); - - &:hover { - background: rgba(var(--accent-color-rgb), 0.15); - } - } - - &--success { - color: var(--mat-sys-tertiary); - - &:hover { - background: color-mix( - in srgb, - var(--mat-sys-tertiary) 15%, - transparent - ); - } - } - - &:disabled { - color: var(--text-muted); - cursor: not-allowed; - opacity: 0.5; - } - } - } - - &--launching, - &--active-external { - border-color: color-mix( - in srgb, - var(--mat-sys-tertiary) 46%, - transparent - ); - background: color-mix( - in srgb, - var(--mat-sys-tertiary-container) 12%, - transparent - ); - } - - &--playing { - border-color: color-mix( - in srgb, - var(--mat-sys-primary) 60%, - transparent - ); - background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); - } - - // Always show actions on touch devices - @media (hover: none) { - .episode-list-item__actions { - opacity: 1; - } } } diff --git a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts index 392b02a88..a744534a5 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts @@ -267,10 +267,10 @@ describe('SeasonContainerComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector( - '.loading-container mat-spinner' - ) - ).not.toBeNull(); + fixture.nativeElement.querySelectorAll( + '[data-testid="episode-skeleton"]' + ).length + ).toBeGreaterThan(0); expect( fixture.nativeElement.querySelector('.empty-state-panel') ).toBeNull(); @@ -286,7 +286,7 @@ describe('SeasonContainerComponent', () => { expect(component.selectedSeason()).toBe('1'); expect(emittedSeasons).toEqual(['1']); expect( - fixture.nativeElement.querySelectorAll('.episode-card').length + fixture.nativeElement.querySelectorAll('.episode-item').length ).toBe(1); expect( fixture.nativeElement.querySelectorAll('.season-tabs__pill').length @@ -301,7 +301,7 @@ describe('SeasonContainerComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelectorAll('.episode-card').length + fixture.nativeElement.querySelectorAll('.episode-item').length ).toBe(1); expect( fixture.nativeElement.querySelectorAll( @@ -414,9 +414,9 @@ describe('SeasonContainerComponent', () => { expect(emittedSeasons).toEqual(['1', '2']); }); - it('uses a dropdown selector when there are more than six seasons', () => { + it('uses a dropdown selector from five seasons on', () => { const seasons: Record = {}; - for (let index = 1; index <= 7; index++) { + for (let index = 1; index <= 5; index++) { seasons[String(index)] = [ createEpisode({ id: String(100 + index), season: index }), ]; @@ -460,14 +460,16 @@ describe('SeasonContainerComponent', () => { ).not.toBeNull(); }); - it('marks the inline-playing episode card', () => { + it('marks the inline-playing episode as playing and current', () => { fixture.componentRef.setInput('playingEpisodeId', 101); setRequiredInputs({ '1': [createEpisode()] }); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('.episode-card--playing') - ).not.toBeNull(); + const item = fixture.nativeElement.querySelector( + '.episode-item' + ) as HTMLElement; + expect(item.classList).toContain('episode-item--playing'); + expect(item.classList).toContain('episode-item--current'); }); it('opens the episode info dialog and plays on the dialog play action', () => { @@ -479,7 +481,13 @@ describe('SeasonContainerComponent', () => { component.episodeClicked.subscribe((episode) => played.push(episode)); fixture.detectChanges(); - const infoButton = fixture.nativeElement.querySelector( + ( + fixture.nativeElement.querySelector( + '[data-testid="episode-more-button"]' + ) as HTMLButtonElement + ).click(); + fixture.detectChanges(); + const infoButton = document.querySelector( '[data-testid="episode-info-button"]' ) as HTMLButtonElement; expect(infoButton).toBeTruthy(); @@ -492,47 +500,43 @@ describe('SeasonContainerComponent', () => { expect(played.length).toBe(1); }); - it('hides the episode info button when the episode has no plot', () => { - setRequiredInputs({ - '1': [createEpisode({ info: { duration: '45 min' } as never })], - }); - fixture.detectChanges(); - - expect( - fixture.nativeElement.querySelector( - '[data-testid="episode-info-button"]' - ) - ).toBeNull(); - }); - - it('shows list thumbnails only for distinct episode images', () => { + it('dims the thumbnails unless the episodes have distinct stills', () => { const withImages = (a: string, b: string) => ({ '1': [ - createEpisode({ info: { movie_image: a } as never }), + createEpisode({ + info: { movie_image: a, plot: 'One.' } as never, + }), createEpisode({ id: '102', episode_num: 2, - info: { movie_image: b } as never, + info: { movie_image: b, plot: 'Two.' } as never, }), ], }); const query = (selector: string) => fixture.nativeElement.querySelectorAll(selector).length; - // Distinct stills → thumbnails replace the number square + // Distinct stills → bright thumbnails beside the number column setRequiredInputs(withImages('still-1.jpg', 'still-2.jpg')); - component.setViewMode('list'); fixture.detectChanges(); - expect(component.listThumbnailsEnabled()).toBe(true); - expect(query('.episode-list-item__thumb')).toBe(2); - expect(query('.episode-list-item__number')).toBe(0); + expect(component.distinctStills()).toBe(true); + expect(query('.episode-item__thumb')).toBe(2); + expect(query('.episode-item__thumb--fallback')).toBe(0); + expect(query('.episode-item__number')).toBe(2); - // Same poster on every episode → number squares stay + // One episode's own still, the other without any → the lone still + // is kept; only a repeated image counts as none + setRequiredInputs(withImages('still-1.jpg', '')); + fixture.detectChanges(); + expect(component.distinctStills()).toBe(true); + expect(query('.episode-item__thumb--fallback')).toBe(1); + + // Same poster on every episode → dimmed fallback tiles (the plots + // keep the rows full; without them the season would render bare) setRequiredInputs(withImages('poster.jpg', 'poster.jpg')); fixture.detectChanges(); - expect(component.listThumbnailsEnabled()).toBe(false); - expect(query('.episode-list-item__thumb')).toBe(0); - expect(query('.episode-list-item__number')).toBe(2); + expect(component.distinctStills()).toBe(false); + expect(query('.episode-item__thumb--fallback')).toBe(2); }); it('renders the season description for the selected season', () => { diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index bfe596afc..4bc3d52e9 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -13,36 +13,44 @@ import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatDialog } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { type SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; import { createLogger, - getPortalPlaybackProgressPercent, isPortalPlaybackInProgress, isPortalPlaybackWatched, } from '@iptvnator/portal/shared/util'; import { PlaybackPositionData, XtreamSerieEpisode, - XtreamSerieEpisodeInfo, } from '@iptvnator/shared/interfaces'; +import { DetailSectionHeaderComponent } from '../detail-ui/detail-section-header.component'; import { ExpandableTextComponent } from '../expandable-text/expandable-text.component'; import { EPISODE_INFO_PLAY, EpisodeInfoDialogComponent, buildEpisodeInfoDialogData, } from './episode-info-dialog.component'; -import { formatEpisodePositionText } from './episode-progress.util'; -import { buildEpisodeSubline } from './episode-subline.util'; +import { EpisodeItemComponent } from './episode-item.component'; +import { + type EpisodeMetaState, + resolveEpisodeMetaState, + usableStillUrl, +} from './episode-meta-state.util'; +import { EpisodeSkeletonComponent } from './episode-skeleton.component'; +import { createCappedMetadataWait } from './metadata-wait.state'; import { type SeasonAutoSelectState, createSeasonAutoSelectState, findSeasonOfEpisode, } from './season-auto-select.state'; +import { repeatsSeriesDescription } from './season-description.util'; import { SeasonDownloadPresenter } from './season-download-presenter'; -import { SeasonTabsComponent } from './season-tabs.component'; +import { + type SeasonCountLoadState, + SeasonTabsComponent, +} from './season-tabs.component'; import { SeasonWatchPresenter } from './season-watch-presenter'; import { type SeasonContainerPlaybackToggleRequest, @@ -56,6 +64,17 @@ const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode'; export type EpisodeViewMode = 'grid' | 'list'; +/** The viewer's saved list/grid choice for episodes; list by default. */ +export function readSavedEpisodeViewMode(): EpisodeViewMode { + try { + return localStorage.getItem(EPISODE_VIEW_MODE_KEY) === 'grid' + ? 'grid' + : 'list'; + } catch { + return 'list'; + } +} + @Component({ selector: 'app-season-container', templateUrl: './season-container.component.html', @@ -63,11 +82,13 @@ export type EpisodeViewMode = 'grid' | 'list'; changeDetection: ChangeDetectionStrategy.OnPush, providers: [SeasonDownloadPresenter, SeasonWatchPresenter], imports: [ + DetailSectionHeaderComponent, + EpisodeItemComponent, + EpisodeSkeletonComponent, ExpandableTextComponent, MatButtonModule, MatButtonToggleModule, MatIcon, - MatProgressSpinnerModule, MatTooltipModule, SeasonTabsComponent, TranslateModule, @@ -87,7 +108,18 @@ export class SeasonContainerComponent implements OnInit { readonly seriesId = input.required(); readonly playlistId = input.required(); readonly seriesTitle = input(''); + /** The hero's description: a season synopsis repeating it is not shown again. */ + readonly seriesDescription = input(null); + /** True while the provider's season or episode list is on its way. */ readonly isLoading = input(false); + /** + * True while the selected season's episode metadata is still being + * fetched after the provider list (TMDB enrichment): its rows stay + * skeletons so they do not change height when the data lands. + */ + readonly metadataLoading = input(false); + /** The series poster: an episode "still" that repeats it counts as none. */ + readonly seriesPosterUrl = input(null); readonly playbackPositions = input>( new Map() ); @@ -102,7 +134,8 @@ export class SeasonContainerComponent implements OnInit { readonly seasonDescriptions = input | null>(null); /** * Per-season poster URLs (TMDB season poster, provider season cover), - * keyed by season key. Rendered as the season cover beside the tabs. + * keyed by season key. Never rendered here: an episode "still" that is + * only the season cover again counts as no still. */ readonly seasonPosters = input | null>(null); /** True while a host is persisting a season-level watched toggle. */ @@ -113,6 +146,10 @@ export class SeasonContainerComponent implements OnInit { * count from the series action label. */ readonly hasUnloadedSeasons = input(false); + /** Per-season load state of those lazy seasons; their menu row shows no count. */ + readonly seasonLoadStates = input + > | null>(null); /** * The seasons whose lists are not loaded yet (Stalker lazy-VOD), so the * auto-selection can tell them from seasons the portal answered empty. @@ -120,6 +157,8 @@ export class SeasonContainerComponent implements OnInit { readonly unloadedSeasonKeys = input([]); readonly episodeClicked = output(); + /** "Play from beginning" in an episode's menu: start at 0, not the saved position. */ + readonly episodeRestartRequested = output(); readonly playbackToggleRequested = output(); readonly seasonPlaybackToggleRequested = @@ -127,7 +166,7 @@ export class SeasonContainerComponent implements OnInit { readonly seriesPlaybackToggleRequested = output(); readonly seasonSelected = output(); - readonly viewMode = signal('grid'); + readonly viewMode = signal('list'); readonly sortedSeasonKeys = computed(() => Object.keys(this.seasons()).sort((a, b) => Number(a) - Number(b)) @@ -179,20 +218,81 @@ export class SeasonContainerComponent implements OnInit { }); /** - * Show thumbnails in the list view only when episodes have genuinely - * distinct stills (TMDB or per-episode provider art). When every episode - * carries the same image (providers often repeat the series poster) a - * column of identical pictures is worse than the plain number square. + * True when the season's episodes carry genuinely distinct stills (TMDB + * or per-episode provider art). When one image repeats across episodes + * (providers often send the series poster as every still) the + * thumbnails fall back to a dimmed tile instead of a column of identical + * bright posters. A still that only one episode carries is its own, not + * a repeat: the other episodes simply have none. */ - readonly listThumbnailsEnabled = computed(() => { - const episodes = this.selectedSeasonEpisodes(); - const images = episodes - .map((episode) => this.getEpisodeInfo(episode)?.movie_image) + readonly distinctStills = computed(() => { + const images = this.selectedSeasonEpisodes() + .map((episode) => resolveEpisodeInfo(episode)?.movie_image) .filter((image): image is string => !!image); - if (images.length === 0) { - return false; + // One image on one episode is a still; one image on several is a + // repeat; two or more different images are stills. + return images.length === 1 || new Set(images).size > 1; + }); + + private readonly stillContext = computed(() => ({ + distinctStills: this.distinctStills(), + posterUrls: [ + this.seriesPosterUrl(), + this.seasonPosters()?.[this.selectedSeason() ?? ''], + ], + })); + + /** + * Metadata only holds rows back when there are rows that would render + * bare: a season without episodes is never enriched, so its host never + * reports it settled and it must reach the empty state. The wait is + * also capped in time. + */ + private readonly metadataPending = createCappedMetadataWait( + computed( + () => + this.metadataLoading() && + this.selectedSeasonEpisodes().length > 0 + ), + this.selectedSeason + ); + + /** Skeleton, full rows or bare rows; see `resolveEpisodeMetaState`. */ + readonly metaState = computed(() => + resolveEpisodeMetaState( + this.selectedSeasonEpisodes(), + { list: this.isLoading(), metadata: this.metadataPending() }, + this.stillContext() + ) + ); + + /** Bare rows have no grid form: the view toggle hides and lists show. */ + readonly effectiveViewMode = computed(() => + this.metaState() === 'bare' ? 'list' : this.viewMode() + ); + + /** + * The highlighted "now playing / continue" episode of the season: the + * one playing (inline or external), else the most recently watched + * episode that is not finished. + */ + readonly currentEpisodeId = computed(() => { + const playing = this.playingEpisodeId() ?? this.activeEpisodeId(); + if (playing !== null) { + return playing; } - return episodes.length === 1 || new Set(images).size > 1; + let best: { id: number; updatedAt: string } | null = null; + for (const episode of this.selectedSeasonEpisodes()) { + const position = this.getEpisodePosition(episode); + if (!isPortalPlaybackInProgress(position)) { + continue; + } + const updatedAt = position?.updatedAt ?? ''; + if (!best || updatedAt > best.updatedAt) { + best = { id: this.getEpisodeContentId(episode), updatedAt }; + } + } + return best?.id ?? null; }); readonly selectedSeasonDescription = computed(() => { @@ -200,27 +300,11 @@ export class SeasonContainerComponent implements OnInit { if (!selected) { return null; } - return this.seasonDescriptions()?.[selected] ?? null; - }); - - /** Poster URLs whose image request failed; the cover column then folds. */ - private readonly failedSeasonPosters = signal>( - new Set() - ); - - /** - * The selected season's cover. Withheld for one-season items — that - * poster is the show poster again, a few hundred pixels below the hero — - * and for a URL whose image failed, so a dead provider link never leaves - * a broken-image frame beside the tabs. - */ - readonly selectedSeasonPosterUrl = computed(() => { - const selected = this.selectedSeason(); - if (!selected || this.sortedSeasonKeys().length < 2) { - return null; - } - const url = this.seasonPosters()?.[selected] ?? null; - return url && !this.failedSeasonPosters().has(url) ? url : null; + const description = this.seasonDescriptions()?.[selected] ?? null; + return description && + !repeatsSeriesDescription(description, this.seriesDescription()) + ? description + : null; }); constructor() { @@ -273,12 +357,7 @@ export class SeasonContainerComponent implements OnInit { } ngOnInit() { - const savedMode = localStorage.getItem( - EPISODE_VIEW_MODE_KEY - ) as EpisodeViewMode; - if (savedMode === 'grid' || savedMode === 'list') { - this.viewMode.set(savedMode); - } + this.viewMode.set(readSavedEpisodeViewMode()); } setViewMode(mode: EpisodeViewMode) { @@ -306,10 +385,6 @@ export class SeasonContainerComponent implements OnInit { this.selectedSeason.set(seasonKey); } - onSeasonPosterError(url: string): void { - this.failedSeasonPosters.update((failed) => new Set(failed).add(url)); - } - scrollToPlayingEpisode(): void { const playingSeason = this.playingSeasonKey(); if (!playingSeason) { @@ -329,6 +404,10 @@ export class SeasonContainerComponent implements OnInit { this.episodeClicked.emit(episode); } + restartEpisode(episode: XtreamSerieEpisode) { + this.episodeRestartRequested.emit(episode); + } + openEpisodeInfo(event: Event, episode: XtreamSerieEpisode) { event.stopPropagation(); this.dialog @@ -377,20 +456,14 @@ export class SeasonContainerComponent implements OnInit { }); } - getEpisodeInfo( - episode: XtreamSerieEpisode - ): XtreamSerieEpisodeInfo | undefined { - return resolveEpisodeInfo(episode); + hasUsableStill(episode: XtreamSerieEpisode): boolean { + return usableStillUrl(episode, this.stillContext()) !== null; } isEpisodeWatched(episode: XtreamSerieEpisode): boolean { return isPortalPlaybackWatched(this.getEpisodePosition(episode)); } - isEpisodeInProgress(episode: XtreamSerieEpisode): boolean { - return isPortalPlaybackInProgress(this.getEpisodePosition(episode)); - } - isEpisodeLaunching(episode: XtreamSerieEpisode): boolean { return this.openingEpisodeId() === this.getEpisodeContentId(episode); } @@ -403,30 +476,11 @@ export class SeasonContainerComponent implements OnInit { return this.playingEpisodeId() === this.getEpisodeContentId(episode); } - getEpisodeProgress(episode: XtreamSerieEpisode): number { - return getPortalPlaybackProgressPercent( - this.getEpisodePosition(episode) - ); - } - - /** "42 min · 18m left", "42 min · watched", "42 min" — or null. */ - getEpisodeSubline(episode: XtreamSerieEpisode): string | null { - return buildEpisodeSubline( - this.getEpisodeInfo(episode), - this.getEpisodePosition(episode), - this.translate - ); - } - - getEpisodePositionText(episode: XtreamSerieEpisode): string | null { - return formatEpisodePositionText(this.getEpisodePosition(episode)); - } - getEpisodeContentId(episode: XtreamSerieEpisode): number { return Number(episode.id); } - private getEpisodePosition( + getEpisodePosition( episode: XtreamSerieEpisode ): PlaybackPositionData | undefined { return this.playbackPositions().get(this.getEpisodeContentId(episode)); diff --git a/libs/ui/components/src/lib/season-container/season-container.episode-list.spec.ts b/libs/ui/components/src/lib/season-container/season-container.episode-list.spec.ts new file mode 100644 index 000000000..2d185cf43 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-container.episode-list.spec.ts @@ -0,0 +1,225 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access'; +import { + PlaybackPositionData, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import { DownloadsService } from '@iptvnator/services'; +import { SeasonContainerComponent } from './season-container.component'; + +// The episode list of the selected season: the "now playing / continue" +// highlight, the header counter, the episode menu and the season +// description. The main season-container spec sits at the max-lines cap. + +function createEpisode( + overrides: Partial = {} +): XtreamSerieEpisode { + return { + id: '101', + episode_num: 1, + title: 'Pilot', + container_extension: 'mp4', + info: { duration: '45 min', plot: 'Pilot episode' }, + custom_sid: '', + added: '', + season: 1, + direct_source: '', + ...overrides, + } as XtreamSerieEpisode; +} + +describe('SeasonContainerComponent episode list', () => { + let fixture: ComponentFixture; + let component: SeasonContainerComponent; + + const setRequiredInputs = ( + seasons: Record + ) => { + fixture.componentRef.setInput('seasons', seasons); + fixture.componentRef.setInput('seriesId', 20); + fixture.componentRef.setInput('playlistId', 'playlist-1'); + }; + + beforeEach(async () => { + localStorage.removeItem('iptvnator_episode_view_mode'); + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + SeasonContainerComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: DownloadsService, + useValue: { + isAvailable: signal(false), + hasAuthoritativeDownloadList: signal(false), + hasLoadedDownloads: signal(false), + downloads: signal([]), + }, + }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + SeasonDownloadCoordinator, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SeasonContainerComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + localStorage.removeItem('iptvnator_episode_view_mode'); + }); + + it('defaults to the list view', () => { + setRequiredInputs({ '1': [createEpisode()] }); + fixture.detectChanges(); + + expect(component.viewMode()).toBe('list'); + expect( + fixture.nativeElement.querySelector( + '.episodes-list .episode-list-item' + ) + ).not.toBeNull(); + }); + + it('counts the episodes and the watched ones in the header', () => { + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [ + 102, + { + contentXtreamId: 102, + contentType: 'episode', + positionSeconds: 2700, + durationSeconds: 2700, + } as PlaybackPositionData, + ], + ]) + ); + setRequiredInputs({ + '1': [ + createEpisode(), + createEpisode({ id: '102', episode_num: 2 }), + ], + }); + fixture.detectChanges(); + + expect( + fixture.nativeElement + .querySelector('.section-header__count') + ?.textContent.trim() + ).toBe('PORTALS.EPISODE_COUNT_OTHER · PORTALS.EPISODES_WATCHED_COUNT'); + }); + + it('highlights the most recently watched unfinished episode when nothing plays', () => { + const position = ( + contentXtreamId: number, + positionSeconds: number, + updatedAt: string + ): PlaybackPositionData => ({ + contentXtreamId, + contentType: 'episode', + positionSeconds, + durationSeconds: 2700, + updatedAt, + }); + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [101, position(101, 600, '2026-10-01T10:00:00Z')], + [102, position(102, 900, '2026-10-02T10:00:00Z')], + [103, position(103, 2690, '2026-10-03T10:00:00Z')], + ]) + ); + setRequiredInputs({ + '1': [ + createEpisode(), + createEpisode({ id: '102', episode_num: 2 }), + createEpisode({ id: '103', episode_num: 3 }), + ], + }); + fixture.detectChanges(); + + expect(component.currentEpisodeId()).toBe(102); + const current = fixture.nativeElement.querySelectorAll( + '.episode-item--current' + ); + expect(current.length).toBe(1); + expect(current[0].getAttribute('data-episode-id')).toBe('102'); + }); + + it('plays from the beginning through the episode menu', () => { + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [ + 101, + { + contentXtreamId: 101, + contentType: 'episode', + positionSeconds: 600, + durationSeconds: 2700, + } as PlaybackPositionData, + ], + ]) + ); + const restarted: XtreamSerieEpisode[] = []; + component.episodeRestartRequested.subscribe((episode) => + restarted.push(episode) + ); + setRequiredInputs({ '1': [createEpisode()] }); + fixture.detectChanges(); + + ( + fixture.nativeElement.querySelector( + '[data-testid="episode-more-button"]' + ) as HTMLButtonElement + ).click(); + fixture.detectChanges(); + ( + document.querySelector( + '[data-testid="episode-play-from-beginning"]' + ) as HTMLButtonElement + ).click(); + + expect(restarted.map((episode) => episode.id)).toEqual(['101']); + }); + it('drops a season description that repeats the series description', () => { + const series = 'The series plot. It goes on.'; + fixture.componentRef.setInput('seriesDescription', series); + fixture.componentRef.setInput('seasonDescriptions', { + '1': 'The series plot.', + '2': 'A season of its own.', + }); + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201' })], + }); + component.selectSeason('1'); + fixture.detectChanges(); + + expect(component.selectedSeasonDescription()).toBeNull(); + expect( + fixture.nativeElement.querySelector( + '[data-testid="season-description"]' + ) + ).toBeNull(); + + component.selectSeason('2'); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-testid="season-description"]' + )?.textContent + ).toContain('A season of its own.'); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/season-container.meta-state.spec.ts b/libs/ui/components/src/lib/season-container/season-container.meta-state.spec.ts new file mode 100644 index 000000000..f2a14fb12 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-container.meta-state.spec.ts @@ -0,0 +1,268 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access'; +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { DownloadsService } from '@iptvnator/services'; +import { SeasonContainerComponent } from './season-container.component'; + +// Loading skeleton, full rows and bare rows of the season container, +// decided from the episode data (see episode-meta-state.util.ts). + +const SERIES_POSTER = 'https://img.test/series.jpg'; + +function createEpisode( + id: number, + info: Record = {} +): XtreamSerieEpisode { + return { + id: String(id), + episode_num: id, + title: `Episode ${id}`, + container_extension: 'mp4', + info, + custom_sid: '', + added: '', + season: 1, + direct_source: '', + } as XtreamSerieEpisode; +} + +/** A Stalker-like season: every "still" is the series poster, no plots. */ +const bareSeason = () => ({ + '1': [1, 2, 3].map((id) => + createEpisode(id, { movie_image: SERIES_POSTER }) + ), +}); + +describe('SeasonContainerComponent metadata state', () => { + let fixture: ComponentFixture; + let component: SeasonContainerComponent; + + const setRequiredInputs = ( + seasons: Record + ) => { + fixture.componentRef.setInput('seasons', seasons); + fixture.componentRef.setInput('seriesId', 20); + fixture.componentRef.setInput('playlistId', 'playlist-1'); + }; + const all = (selector: string): HTMLElement[] => + Array.from(fixture.nativeElement.querySelectorAll(selector)); + + beforeEach(async () => { + localStorage.clear(); + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + SeasonContainerComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: DownloadsService, + useValue: { + isAvailable: signal(false), + hasAuthoritativeDownloadList: signal(false), + hasLoadedDownloads: signal(false), + downloads: signal([]), + }, + }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + SeasonDownloadCoordinator, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SeasonContainerComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('seriesPosterUrl', SERIES_POSTER); + }); + + afterEach(() => localStorage.clear()); + + it('renders bare rows without thumbnails and hides the grid toggle', () => { + setRequiredInputs(bareSeason()); + fixture.detectChanges(); + + expect(component.metaState()).toBe('bare'); + expect(all('app-episode-item.episode-item--bare')).toHaveLength(3); + expect(all('.episode-item__thumb')).toHaveLength(0); + expect(all('.view-toggle')).toHaveLength(0); + expect(all('.episodes-list--bare')).toHaveLength(1); + }); + + it('keeps bare rows in list form even when grid was the saved view', () => { + component.setViewMode('grid'); + setRequiredInputs(bareSeason()); + fixture.detectChanges(); + + expect(component.effectiveViewMode()).toBe('list'); + expect(all('.episodes-grid')).toHaveLength(0); + expect(all('app-episode-item.episode-list-item')).toHaveLength(3); + }); + + it('treats a still that repeats the season cover as missing', () => { + fixture.componentRef.setInput('seasonPosters', { + '1': 'https://img.test/season-1.jpg', + }); + setRequiredInputs({ + '1': [ + createEpisode(1, { + movie_image: 'https://img.test/season-1.jpg', + }), + createEpisode(2, { movie_image: SERIES_POSTER }), + ], + }); + fixture.detectChanges(); + + expect(component.metaState()).toBe('bare'); + }); + + it('keeps full rows with a dimmed tile when only some stills are missing', () => { + setRequiredInputs({ + '1': [ + createEpisode(1, { movie_image: 'https://img.test/e1.jpg' }), + createEpisode(2, { movie_image: SERIES_POSTER }), + ], + }); + fixture.detectChanges(); + + expect(component.metaState()).toBe('full'); + expect(all('.episode-item__thumb')).toHaveLength(2); + expect(all('.episode-item__thumb--fallback')).toHaveLength(1); + expect(all('.view-toggle')).toHaveLength(1); + }); + + it('keeps full rows and the grid toggle for a lone episode still', () => { + // No plots, one genuine still among three episodes: the UI contract + // keeps full rows when only some stills are missing. + setRequiredInputs({ + '1': [ + createEpisode(1, { movie_image: 'https://img.test/e1.jpg' }), + createEpisode(2), + createEpisode(3), + ], + }); + fixture.detectChanges(); + + expect(component.metaState()).toBe('full'); + expect(all('.episode-item__thumb')).toHaveLength(3); + expect(all('.episode-item__thumb--fallback')).toHaveLength(2); + expect(all('.view-toggle')).toHaveLength(1); + }); + + it('renders a season the provider already describes at once, metadata or not', () => { + fixture.componentRef.setInput('metadataLoading', true); + setRequiredInputs({ + '1': [1, 2].map((id) => + createEpisode(id, { + plot: `Plot ${id}`, + movie_image: `https://img.test/e${id}.jpg`, + }) + ), + }); + fixture.detectChanges(); + + expect(component.metaState()).toBe('full'); + expect(all('[data-testid="episode-skeleton"]')).toHaveLength(0); + expect(all('app-episode-item')).toHaveLength(2); + }); + + it('shows one skeleton row per episode while the metadata loads, then the rows', () => { + fixture.componentRef.setInput('metadataLoading', true); + setRequiredInputs(bareSeason()); + fixture.detectChanges(); + + // Not judged bare from the provider list alone. + expect(component.metaState()).toBe('loading'); + expect(all('[data-testid="episode-skeleton"]')).toHaveLength(3); + expect(all('app-episode-item')).toHaveLength(0); + + fixture.componentRef.setInput('seasons', { + '1': [1, 2, 3].map((id) => + createEpisode(id, { + plot: `Plot ${id}`, + movie_image: `https://img.test/e${id}.jpg`, + }) + ), + }); + fixture.componentRef.setInput('metadataLoading', false); + fixture.detectChanges(); + + expect(component.metaState()).toBe('full'); + expect(all('[data-testid="episode-skeleton"]')).toHaveLength(0); + expect(all('app-episode-item')).toHaveLength(3); + expect(all('.episode-item__thumb--fallback')).toHaveLength(0); + }); + + it('falls back to bare only after the metadata answered with nothing', () => { + fixture.componentRef.setInput('metadataLoading', true); + setRequiredInputs(bareSeason()); + fixture.detectChanges(); + expect(component.metaState()).toBe('loading'); + + fixture.componentRef.setInput('metadataLoading', false); + fixture.detectChanges(); + expect(component.metaState()).toBe('bare'); + }); + + it('reserves the action row only in skeleton list rows', () => { + fixture.componentRef.setInput('metadataLoading', true); + setRequiredInputs(bareSeason()); + fixture.detectChanges(); + + expect(all('.episode-skeleton__actions')).toHaveLength(3); + + component.setViewMode('grid'); + fixture.detectChanges(); + expect(all('.episode-skeleton__actions')).toHaveLength(0); + }); + + it('follows the saved grid view with skeleton cards', () => { + component.setViewMode('grid'); + fixture.componentRef.setInput('metadataLoading', true); + setRequiredInputs(bareSeason()); + fixture.detectChanges(); + + expect(all('app-episode-skeleton.episodes-grid')).toHaveLength(1); + expect(all('.episode-skeleton--card')).toHaveLength(3); + }); + it('shows an empty season instead of skeletons while metadata is pending', () => { + // A host never enriches (and so never settles) a season without + // episodes: its pending flag would otherwise hold skeletons forever. + fixture.componentRef.setInput('metadataLoading', true); + setRequiredInputs({ '1': [] }); + fixture.detectChanges(); + + expect(component.metaState()).not.toBe('loading'); + expect(all('[data-testid="episode-skeleton"]')).toHaveLength(0); + expect(fixture.nativeElement.textContent).toContain( + 'PORTALS.SEASON_EMPTY' + ); + }); + + it('stops waiting for metadata after the cap and renders the provider rows', () => { + jest.useFakeTimers(); + try { + fixture.componentRef.setInput('metadataLoading', true); + setRequiredInputs(bareSeason()); + fixture.detectChanges(); + expect(component.metaState()).toBe('loading'); + + jest.advanceTimersByTime(3999); + fixture.detectChanges(); + expect(component.metaState()).toBe('loading'); + + jest.advanceTimersByTime(1); + fixture.detectChanges(); + expect(component.metaState()).toBe('bare'); + expect(all('[data-testid="episode-skeleton"]')).toHaveLength(0); + expect(all('.episode-item')).toHaveLength(3); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/libs/ui/components/src/lib/season-container/season-container.progress-colour.spec.ts b/libs/ui/components/src/lib/season-container/season-container.progress-colour.spec.ts index a8728c0dd..4ff90915e 100644 --- a/libs/ui/components/src/lib/season-container/season-container.progress-colour.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.progress-colour.spec.ts @@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; // Watch progress in app chrome reads one theme token in both season view -// modes; the list rows draw their own fill rather than the grid's capsule -// (see the UI guidelines' Progress Bars section). +// modes: the episode item draws its own 3px bar on the thumbnail (see the UI +// guidelines' Progress Bars section). const DIR = resolve( process.cwd(), 'libs/ui/components/src/lib/season-container' @@ -14,14 +14,17 @@ function read(file: string): string { } describe('season container watch-progress colour', () => { - it('fills list rows with the app watch-progress token', () => { - const styles = read('season-container.component.scss'); - const listItem = styles.slice(styles.indexOf('.episode-list-item {')); - const fill = listItem.match(/&__progress-fill\s*\{([^}]*)\}/)?.[1]; + it('fills the episode progress bar with the app watch-progress token', () => { + const styles = read('episode-item.component.scss'); + const bar = styles.slice(styles.indexOf('.episode-item__progress {')); + const fill = bar.match(/\bi\s*\{([^}]*)\}/)?.[1]; - expect(read('season-container.component.html')).toContain( - 'class="episode-list-item__progress-fill"' + expect(read('episode-item.component.html')).toContain( + 'class="episode-item__progress"' + ); + expect(fill).toMatch(/background:\s*var\(--ep-accent\);/); + expect(styles).toMatch( + /--ep-accent:\s*var\(--app-progress-color, #78adff\);/ ); - expect(fill).toMatch(/background:\s*var\(--app-progress-color\);/); }); }); diff --git a/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts b/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts deleted file mode 100644 index dfd9e861f..000000000 --- a/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatDialog } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { TranslateModule } from '@ngx-translate/core'; -import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access'; -import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; -import { DownloadsService } from '@iptvnator/services'; -import { SeasonContainerComponent } from './season-container.component'; - -// Coverage for the season cover beside the season tabs and the season -// thumbnails in the >6-seasons dropdown (both fed by the `seasonPosters` -// input). Lives beside season-container.component.spec.ts, which sits at the -// max-lines cap. - -function createEpisode( - overrides: Partial = {} -): XtreamSerieEpisode { - return { - id: '101', - episode_num: 1, - title: 'Pilot', - container_extension: 'mp4', - info: { duration: '45 min' }, - custom_sid: '', - added: '', - season: 1, - direct_source: '', - ...overrides, - } as XtreamSerieEpisode; -} - -describe('SeasonContainerComponent season cover', () => { - let fixture: ComponentFixture; - let component: SeasonContainerComponent; - - const setRequiredInputs = ( - seasons: Record - ) => { - fixture.componentRef.setInput('seasons', seasons); - fixture.componentRef.setInput('seriesId', 20); - fixture.componentRef.setInput('playlistId', 'playlist-1'); - }; - - const cover = () => - fixture.nativeElement.querySelector( - '[data-testid="season-cover"]' - ) as HTMLImageElement | null; - - const coverColumnRendered = () => - fixture.nativeElement.querySelector('.season-strip__cover') !== null; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [ - NoopAnimationsModule, - SeasonContainerComponent, - TranslateModule.forRoot(), - ], - providers: [ - { - provide: DownloadsService, - useValue: { - isAvailable: signal(false), - hasAuthoritativeDownloadList: signal(false), - hasLoadedDownloads: signal(false), - downloads: signal([]), - }, - }, - { provide: MatDialog, useValue: { open: jest.fn() } }, - SeasonDownloadCoordinator, - { provide: MatSnackBar, useValue: { open: jest.fn() } }, - ], - }).compileComponents(); - - fixture = TestBed.createComponent(SeasonContainerComponent); - component = fixture.componentInstance; - }); - - it('renders the selected season cover and swaps it with the tab', () => { - fixture.componentRef.setInput('seasonPosters', { - '1': 'https://img.test/season-1.jpg', - '2': 'https://img.test/season-2.jpg', - }); - setRequiredInputs({ - '1': [createEpisode()], - '2': [createEpisode({ id: '201', season: 2 })], - }); - fixture.detectChanges(); - - expect(cover()?.src).toBe('https://img.test/season-1.jpg'); - expect(coverColumnRendered()).toBe(true); - - const tabs = fixture.nativeElement.querySelectorAll( - '.season-tabs__pill' - ) as NodeListOf; - tabs[1].click(); - fixture.detectChanges(); - expect(cover()?.src).toBe('https://img.test/season-2.jpg'); - }); - - it('folds the cover column when the image request fails', () => { - fixture.componentRef.setInput('seasonPosters', { - '1': 'https://img.test/season-1.jpg', - '2': 'https://img.test/season-2.jpg', - }); - setRequiredInputs({ - '1': [createEpisode()], - '2': [createEpisode({ id: '201', season: 2 })], - }); - fixture.detectChanges(); - - // A dead image link folds the cover column instead of leaving a - // broken-image frame beside the tabs; the tabs stay in place. - cover()?.dispatchEvent(new Event('error')); - fixture.detectChanges(); - expect(cover()).toBeNull(); - expect(coverColumnRendered()).toBe(false); - expect( - fixture.nativeElement.querySelectorAll('.season-tabs__pill') - ).toHaveLength(2); - }); - - it('withholds the season cover for a one-season item', () => { - fixture.componentRef.setInput('seasonPosters', { - '1': 'https://img.test/season-1.jpg', - }); - setRequiredInputs({ '1': [createEpisode()] }); - fixture.detectChanges(); - - expect(cover()).toBeNull(); - expect(coverColumnRendered()).toBe(false); - }); - - describe('dropdown thumbnails (more than six seasons)', () => { - const sevenSeasons = (): Record => { - const seasons: Record = {}; - for (let index = 1; index <= 7; index++) { - seasons[String(index)] = [ - createEpisode({ - id: String(100 + index), - season: index, - }), - ]; - } - return seasons; - }; - const trigger = () => - fixture.nativeElement.querySelector( - '[data-testid="season-dropdown"]' - ) as HTMLButtonElement | null; - const triggerThumb = () => - trigger()?.querySelector( - '[data-testid="season-dropdown-thumb"]' - ) ?? null; - const openMenu = (): HTMLButtonElement[] => { - trigger()?.click(); - fixture.detectChanges(); - return Array.from( - document.querySelectorAll( - '.mat-mdc-menu-panel .mat-mdc-menu-item' - ) - ); - }; - - afterEach(() => fixture.destroy()); - - it('renders a thumbnail per menu row that has a poster and none where a season has no poster', () => { - fixture.componentRef.setInput('seasonPosters', { - '1': 'https://img.test/season-1.jpg', - '3': 'https://img.test/season-3.jpg', - }); - setRequiredInputs(sevenSeasons()); - fixture.detectChanges(); - - // The pill row is untouched: the dropdown replaces it. - expect( - fixture.nativeElement.querySelectorAll('.season-tabs__pill') - ).toHaveLength(0); - const items = openMenu(); - expect(items).toHaveLength(7); - const thumbOf = (item: HTMLButtonElement) => - item.querySelector( - '[data-testid="season-menu-thumb"]' - ); - expect(thumbOf(items[0])?.src).toBe( - 'https://img.test/season-1.jpg' - ); - // Projected into the leading slot, ahead of Material's text span. - expect(thumbOf(items[0])?.parentElement).toBe(items[0]); - expect(thumbOf(items[1])).toBeNull(); - expect(thumbOf(items[2])?.src).toBe( - 'https://img.test/season-3.jpg' - ); - // Rows without a poster carry no placeholder either. - expect(items[1].querySelector('.season-tabs__thumb')).toBeNull(); - }); - - it('shows the selected season poster in the closed trigger and swaps it with the selection', () => { - fixture.componentRef.setInput('seasonPosters', { - '1': 'https://img.test/season-1.jpg', - '2': 'https://img.test/season-2.jpg', - }); - setRequiredInputs(sevenSeasons()); - fixture.detectChanges(); - - expect(component.selectedSeason()).toBe('1'); - expect(triggerThumb()?.src).toBe('https://img.test/season-1.jpg'); - expect( - trigger()?.classList.contains( - 'season-tabs__dropdown--with-thumb' - ) - ).toBe(true); - - openMenu()[1].click(); - fixture.detectChanges(); - expect(component.selectedSeason()).toBe('2'); - expect(triggerThumb()?.src).toBe('https://img.test/season-2.jpg'); - - // A season without a poster leaves the trigger text-only. - openMenu()[2].click(); - fixture.detectChanges(); - expect(component.selectedSeason()).toBe('3'); - expect(triggerThumb()).toBeNull(); - expect( - trigger()?.classList.contains( - 'season-tabs__dropdown--with-thumb' - ) - ).toBe(false); - }); - - it('drops a thumbnail whose image request fails instead of leaving a broken frame', () => { - fixture.componentRef.setInput('seasonPosters', { - '1': 'https://img.test/season-1.jpg', - }); - setRequiredInputs(sevenSeasons()); - fixture.detectChanges(); - - triggerThumb()?.dispatchEvent(new Event('error')); - fixture.detectChanges(); - expect(triggerThumb()).toBeNull(); - // The same URL is dropped from the menu row too. - expect( - openMenu()[0].querySelector('[data-testid="season-menu-thumb"]') - ).toBeNull(); - }); - }); - - it('withholds the cover when the selected season has no poster', () => { - fixture.componentRef.setInput('seasonPosters', { - '2': 'https://img.test/season-2.jpg', - }); - setRequiredInputs({ - '1': [createEpisode()], - '2': [createEpisode({ id: '201', season: 2 })], - }); - fixture.detectChanges(); - - expect(component.selectedSeason()).toBe('1'); - expect(cover()).toBeNull(); - expect(coverColumnRendered()).toBe(false); - expect( - fixture.nativeElement.querySelector('app-season-tabs') - ).not.toBeNull(); - }); -}); diff --git a/libs/ui/components/src/lib/season-container/season-container.season-picker.spec.ts b/libs/ui/components/src/lib/season-container/season-container.season-picker.spec.ts new file mode 100644 index 000000000..55d967392 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-container.season-picker.spec.ts @@ -0,0 +1,256 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access'; +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { DownloadsService } from '@iptvnator/services'; +import { SeasonContainerComponent } from './season-container.component'; + +// The episodes header's season picker (chips up to four seasons, a menu +// with "N episodes · M watched" rows from five) and the season synopsis +// under it. No season poster renders in either, even when the host passes +// `seasonPosters`. Lives beside season-container.component.spec.ts, which +// sits at the max-lines cap. + +function createEpisode( + overrides: Partial = {} +): XtreamSerieEpisode { + return { + id: '101', + episode_num: 1, + title: 'Pilot', + container_extension: 'mp4', + info: { duration: '45 min' }, + custom_sid: '', + added: '', + season: 1, + direct_source: '', + ...overrides, + } as XtreamSerieEpisode; +} + +/** `count` seasons of `perSeason` episodes; ids are season*100 + episode. */ +function seasonsOf( + count: number, + perSeason = 1 +): Record { + const seasons: Record = {}; + for (let season = 1; season <= count; season++) { + seasons[String(season)] = Array.from( + { length: perSeason }, + (_, index) => + createEpisode({ + id: String(season * 100 + index + 1), + episode_num: index + 1, + season, + }) + ); + } + return seasons; +} + +function postersFor(count: number): Record { + const posters: Record = {}; + for (let season = 1; season <= count; season++) { + posters[String(season)] = `https://img.test/season-${season}.jpg`; + } + return posters; +} + +const watchedPosition = (contentXtreamId: number) => ({ + contentXtreamId, + contentType: 'episode' as const, + seriesXtreamId: 20, + positionSeconds: 100, + durationSeconds: 100, + playlistId: 'playlist-1', +}); + +describe('SeasonContainerComponent season picker', () => { + let fixture: ComponentFixture; + let component: SeasonContainerComponent; + + const setRequiredInputs = ( + seasons: Record + ) => { + fixture.componentRef.setInput('seasons', seasons); + fixture.componentRef.setInput('seriesId', 20); + fixture.componentRef.setInput('playlistId', 'playlist-1'); + }; + + const query = (selector: string): HTMLElement | null => + fixture.nativeElement.querySelector(selector); + const chips = () => + fixture.nativeElement.querySelectorAll( + '.season-tabs__pill' + ) as NodeListOf; + const trigger = () => + query('[data-testid="season-dropdown"]') as HTMLButtonElement | null; + const openMenu = (): HTMLButtonElement[] => { + trigger()?.click(); + fixture.detectChanges(); + return Array.from( + document.querySelectorAll( + '.season-tabs-menu .mat-mdc-menu-item' + ) + ); + }; + /** Any season artwork in the header, the synopsis or an open menu. */ + const seasonImages = () => [ + ...Array.from( + fixture.nativeElement.querySelectorAll( + 'app-detail-section-header img, [data-testid="season-description"] img' + ) + ), + ...Array.from(document.querySelectorAll('.season-tabs-menu img')), + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + SeasonContainerComponent, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: DownloadsService, + useValue: { + isAvailable: signal(false), + hasAuthoritativeDownloadList: signal(false), + hasLoadedDownloads: signal(false), + downloads: signal([]), + }, + }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + SeasonDownloadCoordinator, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + ], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + PORTALS: { + SEASON_TAB: 'Season {{number}}', + EPISODE_COUNT_ONE: '1 episode', + EPISODE_COUNT_OTHER: '{{count}} episodes', + EPISODES_WATCHED_COUNT: '{{count}} watched', + }, + }); + translate.use('en'); + + fixture = TestBed.createComponent(SeasonContainerComponent); + component = fixture.componentInstance; + }); + + afterEach(() => fixture.destroy()); + + it('shows chips for a three-season show and no season poster', () => { + fixture.componentRef.setInput('seasonPosters', postersFor(3)); + fixture.componentRef.setInput('seasonDescriptions', { + '1': 'A season of its own.', + }); + setRequiredInputs(seasonsOf(3)); + fixture.detectChanges(); + + expect(chips()).toHaveLength(3); + expect(trigger()).toBeNull(); + expect(query('[data-testid="season-description"]')).not.toBeNull(); + expect(seasonImages()).toHaveLength(0); + }); + + it('keeps chips at four seasons and switches to the menu at five', () => { + setRequiredInputs(seasonsOf(4)); + fixture.detectChanges(); + expect(chips()).toHaveLength(4); + expect(trigger()).toBeNull(); + + setRequiredInputs(seasonsOf(5)); + fixture.detectChanges(); + expect(chips()).toHaveLength(0); + expect(trigger()).not.toBeNull(); + }); + + it('lists "N episodes · M watched" per season in the menu of a twelve-season show', () => { + fixture.componentRef.setInput('seasonPosters', postersFor(12)); + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [101, watchedPosition(101)], + [102, watchedPosition(102)], + ]) + ); + setRequiredInputs(seasonsOf(12, 3)); + fixture.detectChanges(); + + expect(trigger()?.textContent).toContain('Season 1'); + const items = openMenu(); + expect(items).toHaveLength(12); + + const countOf = (item: HTMLButtonElement) => + item + .querySelector('[data-testid="season-menu-count"]') + ?.textContent?.replace(/\s+/g, ' ') + .trim(); + expect(countOf(items[0])).toBe('3 episodes · 2 watched'); + // Nothing watched: the second half is left out, not "0 watched". + expect(countOf(items[1])).toBe('3 episodes'); + expect( + items[0].classList.contains('season-tabs__menu-item--selected') + ).toBe(true); + expect( + items[1].classList.contains('season-tabs__menu-item--selected') + ).toBe(false); + expect(seasonImages()).toHaveLength(0); + + items[4].click(); + fixture.detectChanges(); + expect(component.selectedSeason()).toBe('5'); + }); + + it('shows no count for a lazy season the portal has not answered yet', () => { + // Stalker VOD seasons start as empty lists and load on request: an + // unfetched season is unknown, not "0 episodes". + const seasons = seasonsOf(6, 2); + seasons['5'] = []; + seasons['6'] = []; + fixture.componentRef.setInput('hasUnloadedSeasons', true); + fixture.componentRef.setInput('seasonLoadStates', { + '5': 'unloaded', + '6': 'loading', + }); + setRequiredInputs(seasons); + fixture.detectChanges(); + + const items = openMenu(); + const countOf = (item: HTMLButtonElement) => + item.querySelector('[data-testid="season-menu-count"]'); + expect(countOf(items[0])?.textContent).toContain('2 episodes'); + expect(countOf(items[4])).toBeNull(); + expect(countOf(items[5])).toBeNull(); + expect(items[4].textContent).toContain('Season 5'); + }); + + it('renders no synopsis row for a season without a plot', () => { + fixture.componentRef.setInput('seasonPosters', postersFor(2)); + fixture.componentRef.setInput('seasonDescriptions', { + '2': 'A season of its own.', + }); + setRequiredInputs(seasonsOf(2)); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('1'); + expect(query('[data-testid="season-description"]')).toBeNull(); + expect(query('.season-strip')).toBeNull(); + expect(seasonImages()).toHaveLength(0); + + chips()[1].click(); + fixture.detectChanges(); + expect( + query('[data-testid="season-description"]')?.textContent + ).toContain('A season of its own.'); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/season-description.util.spec.ts b/libs/ui/components/src/lib/season-container/season-description.util.spec.ts new file mode 100644 index 000000000..f8f1c0a25 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-description.util.spec.ts @@ -0,0 +1,35 @@ +import { repeatsSeriesDescription } from './season-description.util'; + +const SERIES = + 'Two researchers find a cure. The family fights over it. The eldest son steps in.'; + +describe('repeatsSeriesDescription', () => { + it('matches the same text after trimming and whitespace folding', () => { + expect(repeatsSeriesDescription(` ${SERIES}\n`, SERIES)).toBe(true); + expect( + repeatsSeriesDescription(SERIES.replace(/ /g, ' '), SERIES) + ).toBe(true); + }); + + it('matches a season text that is the series text cut short', () => { + expect( + repeatsSeriesDescription( + 'Two researchers find a cure. The family fights over it.', + SERIES + ) + ).toBe(true); + expect(repeatsSeriesDescription(`${SERIES} More.`, SERIES)).toBe(true); + }); + + it('keeps a season synopsis of its own', () => { + expect( + repeatsSeriesDescription('The second season moves abroad.', SERIES) + ).toBe(false); + }); + + it('never hides a description against a missing series description', () => { + expect(repeatsSeriesDescription(SERIES, null)).toBe(false); + expect(repeatsSeriesDescription(SERIES, ' ')).toBe(false); + expect(repeatsSeriesDescription('', SERIES)).toBe(false); + }); +}); diff --git a/libs/ui/components/src/lib/season-container/season-description.util.ts b/libs/ui/components/src/lib/season-container/season-description.util.ts new file mode 100644 index 000000000..a5ae3c746 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-description.util.ts @@ -0,0 +1,22 @@ +/** Whitespace-insensitive form of a synopsis for comparison. */ +function normalize(text: string | null | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim(); +} + +/** + * True when a season synopsis only repeats the series description shown a + * few hundred pixels above in the hero: the same text, or one of the two + * cut short from the other (providers often send the series plot, or its + * first sentences, as every season's description). + */ +export function repeatsSeriesDescription( + seasonDescription: string | null | undefined, + seriesDescription: string | null | undefined +): boolean { + const season = normalize(seasonDescription); + const series = normalize(seriesDescription); + if (!season || !series) { + return false; + } + return series.startsWith(season) || season.startsWith(series); +} diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.html b/libs/ui/components/src/lib/season-container/season-tabs.component.html index 478c73e71..598f7b1d3 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.html +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.html @@ -3,64 +3,58 @@ - + @for (seasonKey of seasonKeys(); track seasonKey) { + @let episodeCount = episodeCounts()[seasonKey] ?? 0; + @let watchedCount = watchedCounts()[seasonKey] ?? 0; } diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.scss b/libs/ui/components/src/lib/season-container/season-tabs.component.scss index 093642b62..0dab9577e 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.scss +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.scss @@ -1,3 +1,5 @@ +@use '@angular/material' as mat; + :host { display: block; min-width: 0; @@ -7,15 +9,18 @@ display: flex; align-items: center; flex-wrap: wrap; - gap: 6px; + gap: 4px; + // Season pills and the dropdown share the section header's 30px pill. &__pill { display: inline-flex; align-items: center; gap: 6px; - padding: 8px 16px; - border: none; + height: 30px; + padding: 0 12px; + border: 1px solid transparent; border-radius: 999px; + box-sizing: border-box; background: transparent; color: var(--text-secondary, rgba(255, 255, 255, 0.7)); font-size: 13px; @@ -30,61 +35,48 @@ } &--active { - background: var(--tag-bg, rgba(255, 255, 255, 0.1)); + border-color: color-mix( + in srgb, + var(--app-on-surface) 14%, + transparent + ); + background: var(--app-widget-bg); color: var(--text-primary, #fff); - font-weight: 600; - outline: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2)); + font-weight: 500; } } &__dropdown { display: inline-flex; align-items: center; - gap: 4px; - padding: 8px 10px 8px 16px; - border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2)); + gap: 6px; + height: 30px; + padding: 0 10px 0 12px; + border: 1px solid + color-mix(in srgb, var(--app-on-surface) 14%, transparent); border-radius: 999px; - background: transparent; + box-sizing: border-box; + background: var(--app-widget-bg); color: var(--text-primary, #fff); font-size: 13px; - font-weight: 600; + font-weight: 500; font-family: inherit; cursor: pointer; - // The trigger hugs its thumbnail: less inset on the picture side and - // vertically, so the pill grows only as much as the 42px poster needs. - &--with-thumb { - gap: 8px; - padding: 4px 10px 4px 4px; + > mat-icon { + width: 16px; + height: 16px; + font-size: 16px; + line-height: 16px; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); } } - // Season thumbnail in the dropdown (trigger + menu rows). Poster ratio at - // 28×42 — small enough to keep the 48px menu row height Material sets. - &__thumb { - flex: none; - width: 28px; - height: 42px; - object-fit: cover; - border-radius: 3px; - background: var(--tag-bg, rgba(255, 255, 255, 0.08)); - - &--menu { - margin-right: 12px; - } - } - - &__menu-count { - margin-left: 12px; - color: var(--text-muted, rgba(255, 255, 255, 0.5)); - font-size: 12px; - } - &__done { font-size: 15px; width: 15px; height: 15px; - color: var(--accent-success, #4ade80); + color: var(--app-success-color, #4ade80); } &__back-to-playing { @@ -112,8 +104,71 @@ height: 16px; } } +} - &__menu-item--selected { - font-weight: 700; +// The season menu: two columns per row, the season name and its +// "N episodes · M watched" count; the selected season gets a selection tint. +// The panel lives in the overlay container, outside this host. +::ng-deep .season-tabs-menu.mat-mdc-menu-panel { + @include mat.menu-overrides( + ( + container-color: var(--mat-sys-surface-container-high), + ) + ); + min-width: 280px; + max-width: min(360px, calc(100vw - 24px)); + border: 1px solid color-mix(in srgb, var(--app-on-surface) 10%, transparent); + border-radius: 10px; + + .mat-mdc-menu-content { + padding: 6px; } } + +.season-tabs__menu-item { + min-height: 0; + padding: 8px 10px; + border-radius: 6px; + + // Material wraps the projected text in a span that must take the row. + ::ng-deep .mat-mdc-menu-item-text { + flex: 1 1 auto; + min-width: 0; + } + + &--selected { + background: color-mix( + in srgb, + var(--app-selection-color) 10%, + transparent + ); + + .season-tabs__menu-count { + color: var(--app-selection-color); + } + } +} + +.season-tabs__menu-row { + display: flex; + align-items: baseline; + gap: 16px; + width: 100%; +} + +.season-tabs__menu-name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: var(--app-heading-color); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.season-tabs__menu-count { + flex: none; + color: var(--app-muted-color); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.ts b/libs/ui/components/src/lib/season-container/season-tabs.component.ts index 65ab2edf5..5afc5d947 100644 --- a/libs/ui/components/src/lib/season-container/season-tabs.component.ts +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.ts @@ -4,29 +4,27 @@ import { computed, input, output, - signal, } from '@angular/core'; import { MatIcon } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { TranslateModule } from '@ngx-translate/core'; -/** Above this count the pill row becomes a dropdown selector. */ -const MAX_SEASON_PILLS = 6; +/** Up to this many seasons the picker is a row of chips, beyond it a menu. */ +const MAX_SEASON_CHIPS = 4; + +/** A season whose episodes are not in yet: `loading` now, `unloaded` until asked. */ +export type SeasonCountLoadState = 'loading' | 'unloaded'; /** - * Season selector for the season container: a pill row ("Season 1 · 2 · 3") - * for up to 6 seasons, a dropdown beyond that. Shows an optional season - * description under the tabs and a "back to playing episode" chip when the - * currently playing episode belongs to a different season. + * Season selector for the season container: a chip row ("Season 1 · 2 · 3") + * for up to 4 seasons, a menu button beyond that, and a "back to playing + * episode" chip when the currently playing episode belongs to a different + * season. The threshold is the season count, not the available width. * - * The dropdown carries season thumbnails (`seasonPosters`): a small poster at - * the start of each menu row and, for the selected season, in the closed - * trigger — a long menu of "Season 7 … Season 14" rows reads far faster with - * a picture per row. The pill row deliberately stays text-only: a thumbnail - * per pill turns a compact tab strip into a second poster rail. A season - * without a poster simply has no thumbnail (no placeholder tile), and a - * poster whose image request fails is dropped rather than left as a - * broken-image frame. + * Neither form shows a season poster: the chips are a compact tab strip and + * the menu rows carry "N episodes · M watched" instead, which tells a long + * run of "Season 7 … Season 14" apart better than a column of near-identical + * thumbnails. */ @Component({ selector: 'app-season-tabs', @@ -41,22 +39,22 @@ export class SeasonTabsComponent { readonly selectedSeason = input(undefined); readonly episodeCounts = input>({}); readonly watchedCounts = input>({}); - /** Description of the selected season (TMDB/provider), if available. */ + /** + * Seasons whose episode list is still a portal request away (lazy + * Stalker VOD): their count is unknown, not zero, so the menu row shows + * none until the portal answers. + */ + readonly seasonLoadStates = input + > | null>(null); /** Season key of the episode currently playing inline, if any. */ readonly playingSeasonKey = input(null); - /** - * Per-season poster URLs keyed by season key, rendered as thumbnails in - * the dropdown only (menu rows + the selected season's trigger). - */ - readonly seasonPosters = input> | null>( - null - ); readonly seasonSelected = output(); readonly backToPlayingRequested = output(); readonly useDropdown = computed( - () => this.seasonKeys().length > MAX_SEASON_PILLS + () => this.seasonKeys().length > MAX_SEASON_CHIPS ); readonly showBackToPlaying = computed(() => { @@ -64,22 +62,9 @@ export class SeasonTabsComponent { return playing !== null && playing !== this.selectedSeason(); }); - /** Poster URLs whose image request failed; their thumbnail is dropped. */ - private readonly failedPosters = signal>(new Set()); - - /** Thumbnail shown in the closed dropdown trigger. */ - readonly selectedPosterUrl = computed(() => { - const selected = this.selectedSeason(); - return selected === undefined ? null : this.posterUrlOf(selected); - }); - - posterUrlOf(seasonKey: string): string | null { - const url = this.seasonPosters()?.[seasonKey]; - return url && !this.failedPosters().has(url) ? url : null; - } - - onPosterError(url: string): void { - this.failedPosters.update((failed) => new Set(failed).add(url)); + /** False while the season's episode list is still on its way. */ + isCountKnown(seasonKey: string): boolean { + return !this.seasonLoadStates()?.[seasonKey]; } isSeasonCompleted(seasonKey: string): boolean { diff --git a/libs/ui/components/src/lib/season-container/season-watch-presenter.ts b/libs/ui/components/src/lib/season-container/season-watch-presenter.ts index 394b1a0f4..e39f2ab1d 100644 --- a/libs/ui/components/src/lib/season-container/season-watch-presenter.ts +++ b/libs/ui/components/src/lib/season-container/season-watch-presenter.ts @@ -42,9 +42,7 @@ export interface SeasonWatchPresenterSources { @Injectable() export class SeasonWatchPresenter { private readonly logger = createLogger('SeasonWatchPresenter'); - private readonly sources = signal( - null - ); + private readonly sources = signal(null); connect(sources: SeasonWatchPresenterSources): void { this.sources.set(sources); @@ -199,9 +197,7 @@ export class SeasonWatchPresenter { ) { return false; } - return ( - this.loadedEpisodes().length > 0 || sources.hasUnloadedSeasons() - ); + return this.loadedEpisodes().length > 0 || sources.hasUnloadedSeasons(); }); readonly seriesActionDisabled = computed(() => { diff --git a/libs/ui/components/src/lib/season-container/season-watch-toggle.util.ts b/libs/ui/components/src/lib/season-container/season-watch-toggle.util.ts index 76626059c..174d519e8 100644 --- a/libs/ui/components/src/lib/season-container/season-watch-toggle.util.ts +++ b/libs/ui/components/src/lib/season-container/season-watch-toggle.util.ts @@ -15,8 +15,7 @@ export interface SeasonContainerSeriesPlaybackToggleRequest { requests: SeasonContainerPlaybackToggleRequest[]; } -export interface SeasonContainerSeasonPlaybackToggleRequest - extends SeasonContainerSeriesPlaybackToggleRequest { +export interface SeasonContainerSeasonPlaybackToggleRequest extends SeasonContainerSeriesPlaybackToggleRequest { seasonKey: string; } diff --git a/libs/ui/components/src/lib/styles/_skeleton-shimmer.scss b/libs/ui/components/src/lib/styles/_skeleton-shimmer.scss new file mode 100644 index 000000000..539e6235d --- /dev/null +++ b/libs/ui/components/src/lib/styles/_skeleton-shimmer.scss @@ -0,0 +1,48 @@ +// Placeholder blocks for detail-page loading states (hero, episode rows). +// +// A block is a flat fill a step above the page surface with a moving +// highlight a step higher, both mixed from the theme's on-surface ink so the +// placeholders read on the light and the dark theme alike. Under +// `prefers-reduced-motion` the highlight stays put. +// +// Include `keyframes` once in a component stylesheet that uses `block`: +// Angular scopes keyframes to the component that declares them. + +$duration: 1.4s; + +@mixin keyframes { + @keyframes skeleton-shimmer { + from { + background-position: 100% 0; + } + + to { + background-position: -100% 0; + } + } +} + +@mixin block($radius: 4px) { + --skeleton-base: color-mix(in srgb, var(--app-on-surface) 6%, transparent); + --skeleton-highlight: color-mix( + in srgb, + var(--app-on-surface) 11%, + transparent + ); + + display: block; + border-radius: $radius; + background: linear-gradient( + 90deg, + var(--skeleton-base) 25%, + var(--skeleton-highlight) 50%, + var(--skeleton-base) 75% + ); + background-size: 200% 100%; + animation: skeleton-shimmer $duration ease-in-out infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + background: var(--skeleton-base); + } +} diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html index eec3ed5c3..5a78e54c6 100644 --- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html @@ -36,7 +36,7 @@ [selectedSeason]="selectedSeasonKey() ?? undefined" [episodeCounts]="episodeCounts()" [watchedCounts]="watchedCounts()" - [seasonPosters]="seasonPosters()" + [seasonLoadStates]="seasonLoadStates()" [playingSeasonKey]="playingSeasonKey()" (seasonSelected)="onSeasonSelected($event)" (backToPlayingRequested)="backToPlaying()" diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.spec.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.spec.ts index b728dd570..c77e9ae4b 100644 --- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.spec.ts +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.spec.ts @@ -241,49 +241,67 @@ describe('FullscreenEpisodePanelComponent', () => { expect(strip()).toBeNull(); }); - it('hands the season posters to the tabs dropdown of a long series', () => { + it('shows a text-only season menu for a long series, posters or not', () => { const many: FullscreenEpisodePanelSeason[] = Array.from( { length: 7 }, (_, index) => ({ key: String(index + 1), loadState: 'loaded', episodes: [item(10 * (index + 1), String(index + 1), 1)], - ...(index % 2 === 0 - ? { posterUrl: `https://img.test/season-${index + 1}.jpg` } - : {}), + posterUrl: `https://img.test/season-${index + 1}.jpg`, }) ); fixture.componentRef.setInput('seasons', many); fixture.detectChanges(); - expect(component.seasonPosters()).toEqual({ - '1': 'https://img.test/season-1.jpg', - '3': 'https://img.test/season-3.jpg', - '5': 'https://img.test/season-5.jpg', - '7': 'https://img.test/season-7.jpg', - }); expect(pills()).toHaveLength(0); const trigger = fixture.nativeElement.querySelector( '[data-testid="season-dropdown"]' ) as HTMLButtonElement; - expect( - trigger.querySelector( - '[data-testid="season-dropdown-thumb"]' - )?.src - ).toBe('https://img.test/season-1.jpg'); + expect(trigger.querySelector('img')).toBeNull(); trigger.click(); fixture.detectChanges(); - const thumbs = Array.from( - document.querySelectorAll( - '.mat-mdc-menu-panel [data-testid="season-menu-thumb"]' - ) - ).map((thumb) => thumb.src); - expect(thumbs).toEqual([ - 'https://img.test/season-1.jpg', - 'https://img.test/season-3.jpg', - 'https://img.test/season-5.jpg', - 'https://img.test/season-7.jpg', - ]); + expect( + document.querySelectorAll('.season-tabs-menu .mat-mdc-menu-item') + ).toHaveLength(7); + expect(document.querySelectorAll('.season-tabs-menu img')).toHaveLength( + 0 + ); + }); + + it('leaves the count out of the menu for a season still loading or not asked for', () => { + // Lazy Stalker VOD: an unanswered season's empty list is not a count. + const many: FullscreenEpisodePanelSeason[] = Array.from( + { length: 7 }, + (_, index) => ({ + key: String(index + 1), + loadState: 'loaded', + episodes: [item(10 * (index + 1), String(index + 1), 1)], + }) + ); + many[5] = { key: '6', loadState: 'unloaded', episodes: [] }; + many[6] = { key: '7', loadState: 'loading', episodes: [] }; + fixture.componentRef.setInput('seasons', many); + fixture.detectChanges(); + + ( + fixture.nativeElement.querySelector( + '[data-testid="season-dropdown"]' + ) as HTMLButtonElement + ).click(); + fixture.detectChanges(); + const items = Array.from( + document.querySelectorAll('.season-tabs-menu .mat-mdc-menu-item') + ); + const countOf = (item: Element) => + item.querySelector('[data-testid="season-menu-count"]'); + expect(items).toHaveLength(7); + // Raw key here: this harness loads no translations. + expect(countOf(items[0])?.textContent).toContain( + 'PORTALS.EPISODE_COUNT_ONE' + ); + expect(countOf(items[5])).toBeNull(); + expect(countOf(items[6])).toBeNull(); }); it('withholds the season strip for a one-season series', () => { diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts index d8c490ade..cb261f756 100644 --- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts @@ -103,19 +103,23 @@ export class FullscreenEpisodePanelComponent { private readonly shownSeasonKey = computed( () => this.selectedSeason()?.key ?? null ); + /** + * Seasons still loading or not asked for yet: the picker's menu shows no + * count for them, since their empty list is not a count. + */ + readonly seasonLoadStates = computed(() => { + const states: Record = {}; + for (const season of this.seasons()) { + if (season.loadState !== 'loaded') { + states[season.key] = season.loadState; + } + } + return states; + }); + readonly episodeCounts = computed(() => countBySeason(this.seasons(), (season) => season.episodes.length) ); - /** Season poster URLs keyed by season, for the tabs' dropdown thumbnails. */ - readonly seasonPosters = computed>(() => { - const posters: Record = {}; - for (const season of this.seasons()) { - if (season.posterUrl) { - posters[season.key] = season.posterUrl; - } - } - return posters; - }); /** Season poster URLs whose image request failed; the strip then folds. */ private readonly failedPosters = signal>(new Set()); /** diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss index 9487469b0..7250e5679 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss @@ -45,6 +45,11 @@ var(--app-on-surface, #e6e1e5) 12%, transparent ); + // The artwork continues this far below the slide, under the first + // rail's heading and the top of its cards, so the fade into the page + // ends there instead of at the hero's edge. The rails page pulls the + // next rail up by the same amount. + --hero-tail: 160px; position: relative; // A floor, not a fixed height: every slide sits in normal flow in the @@ -57,12 +62,14 @@ grid-template-columns: minmax(0, 1fr); align-items: end; box-sizing: border-box; - min-height: clamp(320px, 42vh, 520px); + min-height: calc(clamp(320px, 42vh, 520px) + var(--hero-tail)); + padding-bottom: var(--hero-tail); // Flush with the content area's top and sides; the rails page's own // top padding and gutters are cancelled. The negative bottom margin - // tucks the first rail into the fade (the gradient does the spacing). + // tucks the first rail into the fade (the gradient does the spacing) + // and over the tail. margin: calc(-1 * var(--dashboard-top-gutter, 24px)) - calc(-1 * var(--hero-inset)) -12px; + calc(-1 * var(--hero-inset)) calc(-12px - var(--hero-tail)); overflow: hidden; isolation: isolate; color: var(--hero-heading); @@ -215,13 +222,21 @@ calc(var(--hero-text-edge) + 18%), transparent 100% ), + // Clear over the art, then a long fade that is mostly done where + // the slide ends, leaves a trace of the art behind the first rail's + // heading and reaches the exact page colour before its cards (whose + // scroll-edge fades are drawn in that colour). linear-gradient( - 180deg, - color-mix(in srgb, var(--hero-scrim) 30%, transparent) 0%, - transparent 22%, - transparent 55%, - var(--hero-scrim) 100% - ); + 180deg, + color-mix(in srgb, var(--hero-scrim) 30%, transparent) 0%, + transparent 20%, + transparent calc(100% - var(--hero-tail) - 160px), + color-mix(in srgb, var(--hero-scrim) 70%, transparent) + calc(100% - var(--hero-tail)), + color-mix(in srgb, var(--hero-scrim) 92%, transparent) + calc(100% - var(--hero-tail) + 50px), + var(--hero-scrim) calc(100% - var(--hero-tail) * 0.4) + ); } // ── Slide content ────────────────────────────────────────────────────────── @@ -485,7 +500,7 @@ .hero__controls { position: absolute; right: var(--hero-inset); - bottom: 34px; + bottom: calc(34px + var(--hero-tail)); z-index: 1; display: flex; align-items: center; @@ -691,6 +706,12 @@ // ── Narrow content area ──────────────────────────────────────────────────── @container dashboard (max-width: 720px) { + // The slide carries its own scrim block here; a tail below it would + // show the art again under that block's hard bottom edge. + .hero { + --hero-tail: 0px; + } + .hero__content { margin-right: var(--hero-inset); // Clears the rotation controls, which move to the top here. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss index 077d7a999..dd6654f94 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -26,6 +26,12 @@ flex-direction: column; gap: 32px; min-width: 0; + + // The hero's artwork runs on under the first rails (its tail); they + // must paint above the hero's isolated stacking context. + > lib-dashboard-hero ~ * { + position: relative; + } } .rails-page--empty {