mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(portals): address the review on the cinematic details
- The "…" menu's MPV/VLC launch goes through the regular episode and movie starts, so recent history and the launch position are recorded as for the Play button. - Resetting a Stalker movie's progress only clears the shown position while that movie is still selected, and retires a position read in flight so it cannot restore the reset row. - The sources panel takes keyboard focus when it replaces the menu rows. - The trailer backdrop drops its frame and waits again (muted) when the trailer changes, and stops while the document is hidden. - The dashboard hero strips the season marker from series only. - Provider country lists keep "Korea, Republic of" whole. - The web season-watched E2E reacquires the menu row after every close. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
78314c7fbc
commit
25cc6b1ced
20 files changed
+432
-85
No files matched your search
@@ -706,19 +706,23 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
|
||||
seasonTabs.first().locator('.season-tabs__done')
|
||||
).toBeVisible();
|
||||
|
||||
// Season 1 itself still holds the 8 persisted rows.
|
||||
// Season 1 itself still holds the 8 persisted rows. The row lives in
|
||||
// the overlay, so it is reacquired after every close.
|
||||
await seasonTabs.first().click();
|
||||
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText('Mark season as unwatched', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(8, { timeout: 10_000 });
|
||||
|
||||
// Second click clears every episode's position again.
|
||||
await seasonToggle.click();
|
||||
await expect(watchedCards).toHaveCount(0, { timeout: 15_000 });
|
||||
seasonToggle = await seriesMenuRow(page, 'toggle-season-watched');
|
||||
await expect(seasonToggle).toContainText('Mark season as watched (8)', {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(watchedCards).toHaveCount(0);
|
||||
await closeSeriesMenu(page);
|
||||
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -798,7 +802,6 @@ test('@xtream season cover — shows the provider season cover and follows the s
|
||||
// unwatch-all once the whole series is watched, and survives a reload.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
/** Opens the series "…" menu (if closed) and returns the row with that test id. */
|
||||
async function seriesMenuRow(page: Page, rowTestId: string) {
|
||||
const row = page.locator(`[data-test-id="${rowTestId}"]`);
|
||||
@@ -810,9 +813,13 @@ async function seriesMenuRow(page: Page, rowTestId: string) {
|
||||
}
|
||||
|
||||
async function closeSeriesMenu(page: Page): Promise<void> {
|
||||
if (await page.locator('.cdk-overlay-backdrop').isVisible().catch(() => false)) {
|
||||
// The panel, not the backdrop: a backdrop locator can match a second
|
||||
// overlay and then fail the strict check, leaving the menu open.
|
||||
const panel = page.locator('[data-test-id="vod-more-menu"]');
|
||||
if (await panel.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
await expect(panel).toBeHidden({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test('@xtream series watched toggle — marks every season from the header menu, survives reload, and clears again', async ({
|
||||
@@ -875,9 +882,9 @@ test('@xtream series watched toggle — marks every season from the header menu,
|
||||
// PWA persistence: positions live in localStorage, so a reload must come
|
||||
// back fully watched across all seasons.
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-testid="series-more-menu"]')).toBeVisible(
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
await expect(page.locator('[data-testid="series-more-menu"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.locator('.season-tabs__done')).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
@@ -1011,6 +1018,8 @@ for (const theme of ['light', 'dark']) {
|
||||
.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');
|
||||
const toggle = shell.locator('mat-button-toggle-group');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(shell.locator('.hero__content')).toHaveCSS('opacity', '1');
|
||||
@@ -1020,7 +1029,7 @@ for (const theme of ['light', 'dark']) {
|
||||
await expect
|
||||
.poll(() => rasterizedBorderContrast(favorite))
|
||||
.toBeGreaterThan(1.1);
|
||||
for (const surface of [card, toggle]) {
|
||||
for (const surface of [artwork, toggle]) {
|
||||
await expect
|
||||
.poll(async () => (await surfaceContrast(surface)).border)
|
||||
.toBeGreaterThan(1.15);
|
||||
@@ -1035,7 +1044,7 @@ for (const theme of ['light', 'dark']) {
|
||||
});
|
||||
await card.hover();
|
||||
await expect
|
||||
.poll(async () => (await surfaceContrast(card)).border)
|
||||
.poll(async () => (await surfaceContrast(artwork)).border)
|
||||
.toBeGreaterThan(1.15);
|
||||
await page
|
||||
.getByRole('radio', { name: 'List view', exact: true })
|
||||
|
||||
@@ -17,6 +17,17 @@ describe('shortCountryName', () => {
|
||||
expect(shortCountryName('', 'xx')).toBe('XX');
|
||||
});
|
||||
|
||||
it('keeps a comma-bearing country name whole inside a list', () => {
|
||||
expect(shortCountryList('Korea, Republic of, Japan')).toEqual([
|
||||
'South Korea',
|
||||
'Japan',
|
||||
]);
|
||||
expect(shortCountryList('France, Taiwan, Province of China')).toEqual([
|
||||
'France',
|
||||
'Taiwan',
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits provider lists', () => {
|
||||
expect(shortCountryList('United States, Canada / France')).toEqual([
|
||||
'USA',
|
||||
|
||||
@@ -70,10 +70,37 @@ export function shortCountryName(
|
||||
return trimmed || (code ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
/** First country of a comma-separated provider list, shortened. */
|
||||
/** Canonical names that carry a comma themselves ("Korea, Republic of"). */
|
||||
const COMMA_NAMES = new Set(
|
||||
Object.keys(SHORT_BY_NAME).filter((name) => name.includes(','))
|
||||
);
|
||||
|
||||
/** Every country of a provider list, shortened. */
|
||||
export function shortCountryList(value: string | null | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(/[,;/]/)
|
||||
const parts: string[] = [];
|
||||
for (const group of (value ?? '').split(/[;/]/)) {
|
||||
parts.push(...splitCommaList(group));
|
||||
}
|
||||
return parts
|
||||
.map((part) => shortCountryName(part))
|
||||
.filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
/** Splits on commas but keeps a known comma-bearing country name whole. */
|
||||
function splitCommaList(value: string): string[] {
|
||||
const segments = value
|
||||
.split(',')
|
||||
.map((segment) => segment.trim())
|
||||
.filter((segment) => segment.length > 0);
|
||||
const parts: string[] = [];
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const joined = `${segments[index]}, ${segments[index + 1] ?? ''}`;
|
||||
if (COMMA_NAMES.has(joined.toLowerCase())) {
|
||||
parts.push(joined);
|
||||
index++;
|
||||
} else {
|
||||
parts.push(segments[index]);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
+5
-1
@@ -145,7 +145,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
readonly selectedVodPlaybackDuration = computed<number | null>(
|
||||
() => this.selectedVodPosition()?.durationSeconds ?? null
|
||||
);
|
||||
readonly sourceLabel = computed(() => this.catalog.playlist()?.title ?? null);
|
||||
readonly sourceLabel = computed(
|
||||
() => this.catalog.playlist()?.title ?? null
|
||||
);
|
||||
readonly selectedVodPlaybackPosition = computed<number | null>(
|
||||
() => this.selectedVodPosition()?.positionSeconds ?? null
|
||||
);
|
||||
@@ -281,7 +283,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
portalPlayer: this.portalPlayer,
|
||||
playbackPositions: this.playbackPositions,
|
||||
playlistId: () => this.catalog.playlist()?.id,
|
||||
selectedVodId: () => Number(this.selectedItem()?.id) || null,
|
||||
selectedVodPosition: this.selectedVodPosition,
|
||||
discardPendingPositionLoad: () => ++this.positionLoadGeneration,
|
||||
beforeExternalLaunch: () => this.closeInlinePlayer(),
|
||||
afterProgressReset: (playlistId) =>
|
||||
void this.catalog.refreshPositions(playlistId),
|
||||
|
||||
@@ -912,7 +912,10 @@ export class StalkerSearchComponent {
|
||||
portalPlayer: this.portalPlayer,
|
||||
playbackPositions: this.playbackPositions,
|
||||
playlistId: () => this.stalkerStore.currentPlaylist()?._id,
|
||||
selectedVodId: () => Number(this.itemDetails()?.id) || null,
|
||||
selectedVodPosition: this.selectedVodPosition,
|
||||
discardPendingPositionLoad: () =>
|
||||
this.vodPlayback.discardPendingPositionLoad(),
|
||||
snackBar: this.snackBar,
|
||||
translate: this.translateService,
|
||||
logError: () => undefined,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { signal } from '@angular/core';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
VodDetailsItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createStalkerVodDetailActions } from './stalker-vod-detail-actions';
|
||||
|
||||
const MOVIE = {
|
||||
type: 'stalker',
|
||||
data: { id: '42' },
|
||||
playlistId: 'portal-1',
|
||||
cmd: 'ffmpeg http://portal/42',
|
||||
} as unknown as VodDetailsItem;
|
||||
|
||||
const POSITION = {
|
||||
playlistId: 'portal-1',
|
||||
contentXtreamId: 42,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 600,
|
||||
durationSeconds: 5400,
|
||||
} as PlaybackPositionData;
|
||||
|
||||
describe('createStalkerVodDetailActions resetProgress', () => {
|
||||
function setup(selectedVodId: () => number | null) {
|
||||
const selectedVodPosition = signal<PlaybackPositionData | null>(
|
||||
POSITION
|
||||
);
|
||||
const clearPlaybackPositionOrThrow = jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined);
|
||||
const discardPendingPositionLoad = jest.fn();
|
||||
const afterProgressReset = jest.fn();
|
||||
const open = jest.fn();
|
||||
const actions = createStalkerVodDetailActions({
|
||||
resolvePlayback: jest.fn(),
|
||||
portalPlayer: { openExternalPlayback: jest.fn() },
|
||||
playbackPositions: { clearPlaybackPositionOrThrow },
|
||||
playlistId: () => 'portal-1',
|
||||
selectedVodId,
|
||||
selectedVodPosition,
|
||||
discardPendingPositionLoad,
|
||||
afterProgressReset,
|
||||
snackBar: { open },
|
||||
translate: { instant: (key: string) => key },
|
||||
logError: jest.fn(),
|
||||
});
|
||||
return {
|
||||
actions,
|
||||
selectedVodPosition,
|
||||
clearPlaybackPositionOrThrow,
|
||||
discardPendingPositionLoad,
|
||||
afterProgressReset,
|
||||
open,
|
||||
};
|
||||
}
|
||||
|
||||
it('clears the shown position of the movie that is still selected', async () => {
|
||||
const t = setup(() => 42);
|
||||
await t.actions.resetProgress(MOVIE);
|
||||
|
||||
expect(t.clearPlaybackPositionOrThrow).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
42,
|
||||
'vod'
|
||||
);
|
||||
// No read still in flight may put the row back.
|
||||
expect(t.discardPendingPositionLoad).toHaveBeenCalledTimes(1);
|
||||
expect(t.selectedVodPosition()).toBeNull();
|
||||
expect(t.afterProgressReset).toHaveBeenCalledWith('portal-1');
|
||||
expect(t.open).toHaveBeenCalledWith(
|
||||
'PORTALS.DETAIL.PROGRESS_RESET',
|
||||
undefined,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the position of a movie selected meanwhile untouched', async () => {
|
||||
// The user moved on to movie 7 while the clear for 42 was pending.
|
||||
const t = setup(() => 7);
|
||||
await t.actions.resetProgress(MOVIE);
|
||||
|
||||
expect(t.clearPlaybackPositionOrThrow).toHaveBeenCalledWith(
|
||||
'portal-1',
|
||||
42,
|
||||
'vod'
|
||||
);
|
||||
expect(t.discardPendingPositionLoad).not.toHaveBeenCalled();
|
||||
expect(t.selectedVodPosition()).toEqual(POSITION);
|
||||
expect(t.afterProgressReset).toHaveBeenCalledWith('portal-1');
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,11 @@ interface StalkerVodDetailActionsDeps {
|
||||
'clearPlaybackPositionOrThrow'
|
||||
>;
|
||||
readonly playlistId: () => string | undefined;
|
||||
/** The movie on screen now; a reset finished for another one leaves it alone. */
|
||||
readonly selectedVodId: () => number | null;
|
||||
readonly selectedVodPosition: WritableSignal<PlaybackPositionData | null>;
|
||||
/** Retires a stored-position read in flight, which would restore the row. */
|
||||
readonly discardPendingPositionLoad?: () => void;
|
||||
readonly beforeExternalLaunch?: () => void;
|
||||
readonly afterProgressReset?: (playlistId: string) => void;
|
||||
readonly snackBar: Pick<MatSnackBar, 'open'>;
|
||||
@@ -47,7 +51,9 @@ interface StalkerVodDetailActionsDeps {
|
||||
* external player" resolves the stream (a `create_link` round trip) and
|
||||
* hands it to MPV/VLC; "Reset progress" clears the saved position.
|
||||
*/
|
||||
export function createStalkerVodDetailActions(deps: StalkerVodDetailActionsDeps) {
|
||||
export function createStalkerVodDetailActions(
|
||||
deps: StalkerVodDetailActionsDeps
|
||||
) {
|
||||
const notify = (key: string) =>
|
||||
deps.snackBar.open(deps.translate.instant(key), undefined, {
|
||||
duration: 3000,
|
||||
@@ -111,7 +117,15 @@ export function createStalkerVodDetailActions(deps: StalkerVodDetailActionsDeps)
|
||||
deps.logError('Resetting the VOD position failed', error);
|
||||
return;
|
||||
}
|
||||
deps.selectedVodPosition.set(null);
|
||||
// The clear was async: only the movie still on screen loses its
|
||||
// shown progress, and no older read may put it back.
|
||||
if (
|
||||
deps.selectedVodId() === vodId &&
|
||||
deps.playlistId() === playlistId
|
||||
) {
|
||||
deps.discardPendingPositionLoad?.();
|
||||
deps.selectedVodPosition.set(null);
|
||||
}
|
||||
deps.afterProgressReset?.(playlistId);
|
||||
notify('PORTALS.DETAIL.PROGRESS_RESET');
|
||||
},
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
createLogger,
|
||||
formatSeriesEpisodeCode,
|
||||
PORTAL_PLAYER,
|
||||
type SeriesQuickStartAction,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
type PlayerContentInfo,
|
||||
type ResolvedPortalPlayback,
|
||||
type XtreamCategory,
|
||||
type XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildSeriesMenuSections,
|
||||
@@ -36,6 +36,11 @@ interface SerialDetailsMenuBindings {
|
||||
readonly categoryId: Signal<string>;
|
||||
readonly episodePositions: Signal<ReadonlyMap<number, unknown>>;
|
||||
readonly resetProgress: () => Promise<void>;
|
||||
/** The regular episode start forced to MPV/VLC, so history and the launch position are recorded. */
|
||||
readonly openEpisodeExternally: (
|
||||
episode: XtreamSerieEpisode,
|
||||
player: ExternalPlayerName
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,8 +53,9 @@ interface SerialDetailsMenuBindings {
|
||||
export class SerialDetailsMenuService {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
/** Optional: hosts without the data source simply get no history row. */
|
||||
private readonly dataSource = inject(XTREAM_DATA_SOURCE, { optional: true });
|
||||
private readonly portalPlayer = inject(PORTAL_PLAYER);
|
||||
private readonly dataSource = inject(XTREAM_DATA_SOURCE, {
|
||||
optional: true,
|
||||
});
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly router = inject(Router);
|
||||
@@ -88,10 +94,11 @@ export class SerialDetailsMenuService {
|
||||
if (!categoryId) {
|
||||
return null;
|
||||
}
|
||||
const categories = this.xtreamStore.serialCategories() as ReadonlyArray<{
|
||||
category_id?: string | number;
|
||||
category_name?: string;
|
||||
}>;
|
||||
const categories =
|
||||
this.xtreamStore.serialCategories() as ReadonlyArray<{
|
||||
category_id?: string | number;
|
||||
category_name?: string;
|
||||
}>;
|
||||
const found = categories.find(
|
||||
(candidate) => String(candidate.category_id) === categoryId
|
||||
);
|
||||
@@ -104,7 +111,11 @@ export class SerialDetailsMenuService {
|
||||
/** Whether the series row sits in this playlist's recently viewed list. */
|
||||
private readonly inContinueWatching = computed(() => {
|
||||
const seriesId = this.seriesId();
|
||||
if (!seriesId || !this.dataSource || this.hiddenSeriesIds().has(seriesId)) {
|
||||
if (
|
||||
!seriesId ||
|
||||
!this.dataSource ||
|
||||
this.hiddenSeriesIds().has(seriesId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
@@ -174,7 +185,7 @@ export class SerialDetailsMenuService {
|
||||
await container?.downloadPresenter.enqueueSeason();
|
||||
return;
|
||||
case SERIES_MENU_ACTION.ExternalPlayer:
|
||||
await this.openExternal();
|
||||
this.openExternal();
|
||||
return;
|
||||
case SERIES_MENU_ACTION.CopyUrl:
|
||||
await this.copyStreamUrl();
|
||||
@@ -217,20 +228,13 @@ export class SerialDetailsMenuService {
|
||||
};
|
||||
}
|
||||
|
||||
private async openExternal(): Promise<void> {
|
||||
const playback = this.buildEpisodePlayback();
|
||||
if (!playback) {
|
||||
private openExternal(): void {
|
||||
const bindings = this.bindings();
|
||||
const episode = bindings?.quickStart()?.episode;
|
||||
if (!bindings || !episode) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.portalPlayer.openExternalPlayback(
|
||||
playback,
|
||||
this.externalPlayer()
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('External episode launch failed', error);
|
||||
this.notify('PORTALS.PLAYBACK_ERROR');
|
||||
}
|
||||
bindings.openEpisodeExternally(episode, this.externalPlayer());
|
||||
}
|
||||
|
||||
private async copyStreamUrl(): Promise<void> {
|
||||
|
||||
+13
-5
@@ -19,6 +19,7 @@ import {
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import {
|
||||
ExternalPlayerName,
|
||||
PlaybackPositionData,
|
||||
PlayerContentInfo,
|
||||
ResolvedPortalPlayback,
|
||||
@@ -204,7 +205,11 @@ export class SerialDetailsPlaybackService {
|
||||
this.activeEpisodeId.set(null);
|
||||
}
|
||||
|
||||
playEpisode(episode: XtreamSerieEpisode): void {
|
||||
/** `player` forces MPV/VLC (the "…" menu); history and the launch position are recorded either way. */
|
||||
playEpisode(
|
||||
episode: XtreamSerieEpisode,
|
||||
player?: ExternalPlayerName
|
||||
): void {
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
const selectedItem = this.selectedItem();
|
||||
if (!playlist || !selectedItem) {
|
||||
@@ -244,7 +249,7 @@ export class SerialDetailsPlaybackService {
|
||||
fallbackSeasonNumber: Number(episode.season),
|
||||
fallbackEpisodeNumber: Number(episode.episode_num),
|
||||
});
|
||||
this.startPlayback(playback, episodeState);
|
||||
this.startPlayback(playback, episodeState, player);
|
||||
}
|
||||
|
||||
playQuickStartEpisode(): void {
|
||||
@@ -411,10 +416,11 @@ export class SerialDetailsPlaybackService {
|
||||
|
||||
private startPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
|
||||
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null,
|
||||
player?: ExternalPlayerName
|
||||
): void {
|
||||
this.lastSaveTime = 0;
|
||||
if (this.portalPlayer.isEmbeddedPlayer()) {
|
||||
if (!player && this.portalPlayer.isEmbeddedPlayer()) {
|
||||
this.inlinePlaybackSessionEpisodeState.set(episodeState);
|
||||
this.inlinePlayback.set(playback);
|
||||
return;
|
||||
@@ -423,7 +429,9 @@ export class SerialDetailsPlaybackService {
|
||||
this.closeInlinePlayer();
|
||||
void this.playbackPositionState.recordExternalLaunch(
|
||||
playback,
|
||||
this.portalPlayer.openResolvedPlayback(playback, true),
|
||||
player
|
||||
? this.portalPlayer.openExternalPlayback(playback, player)
|
||||
: this.portalPlayer.openResolvedPlayback(playback, true),
|
||||
(playlistId, position) =>
|
||||
this.playbackPositions.savePlaybackPosition(
|
||||
playlistId,
|
||||
|
||||
@@ -31,10 +31,14 @@ import {
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
|
||||
import { PlaybackPositionData, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
import { SerialDetailsComponent } from './serial-details.component';
|
||||
import { SerialDetailsMenuService } from './serial-details-menu.service';
|
||||
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
@@ -815,6 +819,46 @@ describe('SerialDetailsComponent', () => {
|
||||
expect(quickStartButton()?.textContent).toContain('S01E01');
|
||||
});
|
||||
|
||||
it('records history and the launch position when the menu opens the next episode in MPV', async () => {
|
||||
openExternalPlayback.mockResolvedValue({
|
||||
id: 'mpv-session-2',
|
||||
player: 'mpv',
|
||||
status: 'opened',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const menu = fixture.debugElement.injector.get(
|
||||
SerialDetailsMenuService
|
||||
);
|
||||
await menu.run('external-player');
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(openResolvedPlayback).not.toHaveBeenCalled();
|
||||
expect(openExternalPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'http://xtream.example/series/1001.mp4',
|
||||
}),
|
||||
'mpv'
|
||||
);
|
||||
// Same bookkeeping as the Play button: the view lands once the
|
||||
// stream plays, the launch position is written right away.
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: ['http://xtream.example/series/1001.mp4'],
|
||||
});
|
||||
expect(addRecentItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ xtreamId: '103', contentType: 'series' })
|
||||
);
|
||||
expect(savePlaybackPosition).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
contentXtreamId: 1001,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('persists the launched episode after an external fallback succeeds', async () => {
|
||||
openExternalPlayback.mockResolvedValue({
|
||||
id: 'mpv-session-1',
|
||||
|
||||
@@ -217,9 +217,13 @@ export class SerialDetailsComponent implements OnDestroy {
|
||||
selectedItem: this.selectedItem,
|
||||
quickStart: this.quickStartAction,
|
||||
seasonContainer: this.seasonContainer,
|
||||
categoryId: computed(() => String(this.routeParams()['categoryId'] ?? '')),
|
||||
categoryId: computed(() =>
|
||||
String(this.routeParams()['categoryId'] ?? '')
|
||||
),
|
||||
episodePositions: this.episodePlaybackPositions,
|
||||
resetProgress: () => this.resetProgress(),
|
||||
openEpisodeExternally: (episode, player) =>
|
||||
this.playback.playEpisode(episode, player),
|
||||
});
|
||||
|
||||
// TMDB season enrichment, keyed on (tmdb_id, selected season). With
|
||||
@@ -299,7 +303,8 @@ export class SerialDetailsComponent implements OnDestroy {
|
||||
|
||||
/** Clears every saved episode position of the series. */
|
||||
resetProgress(): Promise<void> {
|
||||
const request = this.seasonContainer()?.watchPresenter.buildResetRequest();
|
||||
const request =
|
||||
this.seasonContainer()?.watchPresenter.buildResetRequest();
|
||||
return request
|
||||
? this.playback.handleWatchToggleRequested(request, 'series')
|
||||
: Promise.resolve();
|
||||
|
||||
@@ -129,20 +129,17 @@ export class VodDetailsMenuService {
|
||||
async run(actionId: string): Promise<void> {
|
||||
const item = this.bindings()?.item() ?? null;
|
||||
switch (actionId) {
|
||||
case VOD_MENU_ACTION.ExternalPlayer: {
|
||||
const playback = this.playback.buildVodPlayback(
|
||||
item,
|
||||
this.msUi.hasPlaybackPosition()
|
||||
);
|
||||
if (playback) {
|
||||
await this.playback
|
||||
.launchExternal(playback, this.externalPlayer())
|
||||
.catch((error) =>
|
||||
this.logger.warn('External launch failed', error)
|
||||
);
|
||||
}
|
||||
case VOD_MENU_ACTION.ExternalPlayer:
|
||||
await this.playback
|
||||
.openInExternalPlayer(
|
||||
item,
|
||||
this.msUi.hasPlaybackPosition(),
|
||||
this.externalPlayer()
|
||||
)
|
||||
?.catch((error) =>
|
||||
this.logger.warn('External launch failed', error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
case VOD_MENU_ACTION.CopyUrl:
|
||||
await this.copyStreamUrl(item);
|
||||
return;
|
||||
|
||||
@@ -163,6 +163,37 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records the view when the menu opens the movie in MPV or VLC', async () => {
|
||||
const movie = {
|
||||
info: {},
|
||||
movie_data: {
|
||||
stream_id: ROUTE_VOD_ID,
|
||||
name: 'Route movie',
|
||||
container_extension: 'mkv',
|
||||
},
|
||||
} as never;
|
||||
const launch = service.openInExternalPlayer(movie, false, 'vlc');
|
||||
expect(launch).not.toBeNull();
|
||||
await launch;
|
||||
|
||||
expect(openExternalPlayback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
streamUrl: 'https://example.com/route.mkv',
|
||||
}),
|
||||
'vlc'
|
||||
);
|
||||
expect(addRecentItem).not.toHaveBeenCalled();
|
||||
TestBed.inject(PlaybackHistoryGate).confirm({
|
||||
streamUrls: ['https://example.com/route.mkv'],
|
||||
});
|
||||
expect(addRecentItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
xtreamId: ROUTE_VOD_ID,
|
||||
contentType: 'movie',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('records the movie as recently viewed only once its stream played', async () => {
|
||||
await service.startResolvedPlayback({
|
||||
streamUrl: 'https://example.com/broken.mkv',
|
||||
|
||||
@@ -306,10 +306,32 @@ export class VodDetailsPlaybackService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "…" menu's explicit MPV/VLC launch. It counts as a view like any
|
||||
* start; `launchExternal` alone serves the inline player's fallback,
|
||||
* whose start already recorded the item.
|
||||
*/
|
||||
openInExternalPlayer(
|
||||
vodItem: XtreamVodDetails | null,
|
||||
resume: boolean,
|
||||
player: ExternalPlayerName
|
||||
): Promise<ExternalPlayerSession | void> | null {
|
||||
const playback = this.buildVodPlayback(vodItem, resume);
|
||||
if (!playback) {
|
||||
return null;
|
||||
}
|
||||
this.recordRecentItem(playback.streamUrl, {
|
||||
xtreamId: this.bindings()?.vodId() ?? NaN,
|
||||
contentType: 'movie',
|
||||
backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0],
|
||||
});
|
||||
return this.launchExternal(playback, player);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the playback to MPV or VLC regardless of the configured player
|
||||
* (the "Open in external player" action and the inline player's
|
||||
* fallback), owned and settled like a regular external start.
|
||||
* (the inline player's fallback), owned and settled like a regular
|
||||
* external start.
|
||||
*/
|
||||
launchExternal(
|
||||
playback: ResolvedPortalPlayback,
|
||||
|
||||
@@ -83,6 +83,49 @@ describe('HeroTrailerBackdropComponent', () => {
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it('drops the frame and waits again, muted, when the trailer changes', () => {
|
||||
renderFocused();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
host()
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-test-id="trailer-backdrop-mute"]'
|
||||
)
|
||||
?.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.muted()).toBe(false);
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'embedUrl',
|
||||
'https://www.youtube-nocookie.com/embed/next456'
|
||||
);
|
||||
fixture.detectChanges();
|
||||
// No mounted frame may autoplay the next URL at once.
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')?.getAttribute('src')).toContain(
|
||||
'playlist=next456'
|
||||
);
|
||||
expect(fixture.componentInstance.muted()).toBe(true);
|
||||
});
|
||||
|
||||
it('stops while the document is hidden', () => {
|
||||
renderFocused();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).not.toBeNull();
|
||||
|
||||
const visibility = jest
|
||||
.spyOn(document, 'visibilityState', 'get')
|
||||
.mockReturnValue('hidden');
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
fixture.detectChanges();
|
||||
expect(host().querySelector('iframe')).toBeNull();
|
||||
visibility.mockRestore();
|
||||
});
|
||||
|
||||
it('stops when the window loses focus', () => {
|
||||
renderFocused();
|
||||
jest.advanceTimersByTime(TRAILER_BACKDROP_IDLE_MS);
|
||||
|
||||
@@ -23,9 +23,9 @@ export const TRAILER_BACKDROP_IDLE_MS = 3000;
|
||||
/**
|
||||
* Plays a title's trailer, muted and looping, as the hero backdrop after a
|
||||
* few idle seconds (`Settings → Playback → Play trailers in details
|
||||
* background`). It stops when the hero scrolls out of view or the window
|
||||
* loses focus, and never starts under `prefers-reduced-motion` or on a
|
||||
* metered connection. The 32px button in the corner toggles the sound
|
||||
* background`). It stops when the hero scrolls out of view, the window
|
||||
* loses focus or the document is hidden, and never starts under
|
||||
* `prefers-reduced-motion` or on a metered connection. The 32px button in the corner toggles the sound
|
||||
* through the YouTube IFrame API (`enablejsapi`).
|
||||
*/
|
||||
@Component({
|
||||
@@ -50,6 +50,7 @@ export class HeroTrailerBackdropComponent {
|
||||
private readonly frame = viewChild<ElementRef<HTMLIFrameElement>>('frame');
|
||||
private readonly inView = signal(true);
|
||||
private readonly windowFocused = signal(true);
|
||||
private readonly documentVisible = signal(true);
|
||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly frameUrl = computed<SafeResourceUrl>(() =>
|
||||
@@ -60,17 +61,19 @@ export class HeroTrailerBackdropComponent {
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// A new trailer or a hero that left the screen resets the idle wait.
|
||||
// A new trailer, a hero off screen or a hidden window: the frame
|
||||
// goes away and the idle wait starts over (a mounted frame would
|
||||
// autoplay the next URL at once).
|
||||
this.embedUrl();
|
||||
const visible = this.inView() && this.windowFocused();
|
||||
const visible =
|
||||
this.inView() && this.windowFocused() && this.documentVisible();
|
||||
untracked(() => {
|
||||
this.clearIdleTimer();
|
||||
if (!visible) {
|
||||
this.playing.set(false);
|
||||
return;
|
||||
}
|
||||
if (canAutoplayTrailer()) {
|
||||
this.playing.set(false);
|
||||
if (visible && canAutoplayTrailer()) {
|
||||
this.idleTimer = setTimeout(() => {
|
||||
// Every frame mounts with `mute=1`.
|
||||
this.muted.set(true);
|
||||
this.playing.set(true);
|
||||
this.idleTimer = null;
|
||||
}, TRAILER_BACKDROP_IDLE_MS);
|
||||
@@ -109,12 +112,17 @@ export class HeroTrailerBackdropComponent {
|
||||
}
|
||||
const onFocus = () => this.windowFocused.set(true);
|
||||
const onBlur = () => this.windowFocused.set(false);
|
||||
const onVisibility = () =>
|
||||
this.documentVisible.set(document.visibilityState !== 'hidden');
|
||||
this.windowFocused.set(document.hasFocus());
|
||||
onVisibility();
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('blur', onBlur);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
this.destroyRef.onDestroy(() => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -65,13 +65,17 @@ describe('VodMoreMenuComponent', () => {
|
||||
expect(overlay.querySelector('[role="menu"]')).toBeTruthy();
|
||||
expect(overlay.textContent).toContain('Watching');
|
||||
expect(overlay.textContent).toContain('Other sources');
|
||||
expect(overlay.querySelector('.more-menu__hint')?.textContent?.trim()).toBe('2');
|
||||
expect(
|
||||
overlay.querySelector('.more-menu__hint')?.textContent?.trim()
|
||||
).toBe('2');
|
||||
const reset = rows().find((row) => row.textContent?.includes('Reset'))!;
|
||||
expect(reset.disabled).toBe(true);
|
||||
|
||||
const spy = jest.fn();
|
||||
fixture.componentInstance.selected.subscribe(spy);
|
||||
overlay.querySelector<HTMLButtonElement>('[data-test-id="menu-copy"]')!.click();
|
||||
overlay
|
||||
.querySelector<HTMLButtonElement>('[data-test-id="menu-copy"]')!
|
||||
.click();
|
||||
fixture.detectChanges();
|
||||
expect(spy).toHaveBeenCalledWith('copy');
|
||||
expect(overlay.querySelector('[role="menu"]')).toBeNull();
|
||||
@@ -99,7 +103,7 @@ describe('VodMoreMenuComponent', () => {
|
||||
expect(fixture.componentInstance.isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('switches to the sources panel and back', () => {
|
||||
it('switches to the sources panel, moves focus into it, and back', async () => {
|
||||
fixture.componentRef.setInput('sources', []);
|
||||
trigger().click();
|
||||
fixture.detectChanges();
|
||||
@@ -109,6 +113,11 @@ describe('VodMoreMenuComponent', () => {
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.view()).toBe('sources');
|
||||
expect(overlay.querySelector('app-vod-sources-menu')).toBeTruthy();
|
||||
// The focused row is gone; keyboard focus lands on the panel's first control.
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
expect(document.activeElement).toBe(
|
||||
overlay.querySelector('.more-menu__back')
|
||||
);
|
||||
overlay.querySelector<HTMLButtonElement>('.more-menu__back')!.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.view()).toBe('menu');
|
||||
|
||||
@@ -21,7 +21,10 @@ import type {
|
||||
VodSourceMatchKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { VodSourcesMenuComponent } from '../vod-sources/vod-sources-menu.component';
|
||||
import type { VodMoreMenuItem, VodMoreMenuSection } from './vod-more-menu.model';
|
||||
import type {
|
||||
VodMoreMenuItem,
|
||||
VodMoreMenuSection,
|
||||
} from './vod-more-menu.model';
|
||||
|
||||
type MenuView = 'menu' | 'sources';
|
||||
|
||||
@@ -129,6 +132,7 @@ export class VodMoreMenuComponent {
|
||||
}
|
||||
if (item.kind === 'sources') {
|
||||
this.view.set('sources');
|
||||
this.focusFirstControl();
|
||||
return;
|
||||
}
|
||||
this.close(true);
|
||||
@@ -207,4 +211,15 @@ export class VodMoreMenuComponent {
|
||||
// The overlay attaches after this change detection pass.
|
||||
setTimeout(() => this.rows()[index]?.focus());
|
||||
}
|
||||
|
||||
/** The sources panel replaces the focused row; focus follows into it. */
|
||||
private focusFirstControl(): void {
|
||||
setTimeout(() =>
|
||||
this.panel()
|
||||
?.nativeElement.querySelector<HTMLElement>(
|
||||
'button:not([disabled])'
|
||||
)
|
||||
?.focus()
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -230,10 +230,10 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a season marker from a series title and leaves live titles alone', () => {
|
||||
it('drops a season marker from a series title and leaves movies and live titles alone', () => {
|
||||
recentItems.set([
|
||||
{ ...series, title: 'Big Pharma (1 сезон)' },
|
||||
watchedMovie,
|
||||
{ ...watchedMovie, title: 'Film Season 2' },
|
||||
]);
|
||||
candidates.set([
|
||||
{ origin: 'favorite', item: { ...channel, title: 'Sport S01' } },
|
||||
@@ -242,6 +242,7 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
const titles = presenter.slides().map((slide) => slide.title);
|
||||
expect(titles).toContain('Big Pharma');
|
||||
expect(titles).not.toContain('Big Pharma (1 сезон)');
|
||||
expect(titles).toContain('Film Season 2');
|
||||
expect(titles).toContain('Sport S01');
|
||||
});
|
||||
|
||||
|
||||
@@ -226,21 +226,22 @@ export class DashboardHeroSlidesPresenter {
|
||||
this.failedImages()
|
||||
);
|
||||
|
||||
// Watch kind: a Stalker embedded-VOD show routes as a movie
|
||||
const watchKind = isLive
|
||||
? 'live'
|
||||
: (resolvePortalActivityWatchKind(item) ?? item.type);
|
||||
return {
|
||||
...artwork,
|
||||
id: `${source.kind}:${dashboardHeroItemKey(item)}`,
|
||||
kind: source.kind,
|
||||
contentType: item.type,
|
||||
// "Большая фарма (1 сезон)" → "Большая фарма": the episode badge
|
||||
// already names the season.
|
||||
title: isLive ? item.title : splitSeasonSuffix(item.title).title,
|
||||
// Watch kind: a Stalker embedded-VOD show routes as a movie
|
||||
typeLabelKey:
|
||||
TYPE_LABEL_KEYS[
|
||||
isLive
|
||||
? 'live'
|
||||
: (resolvePortalActivityWatchKind(item) ?? item.type)
|
||||
],
|
||||
// already names the season. A movie keeps its name as is.
|
||||
title:
|
||||
watchKind === 'series'
|
||||
? splitSeasonSuffix(item.title).title
|
||||
: item.title,
|
||||
typeLabelKey: TYPE_LABEL_KEYS[watchKind],
|
||||
reasonLabelKey: this.reasonLabelKey(source),
|
||||
episodeBadge: buildDashboardEpisodeBadge(
|
||||
item,
|
||||
|
||||
Reference in new issue
Block a user