mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(portals): eighth review round on the cinematic details
- The external player rows of every details menu follow the managed external player capability instead of the downloads availability. - An explicit Stalker movie launch is a pending start for the selected movie while its stream resolves, so the reset row is disabled meanwhile. - The resume clock formatter moves into the presentation helpers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e17c0533b
commit
d0fe593f92
13 files changed
+81
-42
No files matched your search
+1
-1
@@ -22,7 +22,7 @@
|
||||
[watchedToggleReady]="positionLoaded()"
|
||||
[playbackStartPending]="playbackStartPending()"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(resumeClicked)="onVodResume($event)"
|
||||
(resumeClicked)="onVodPlay($event.item, $event.positionSeconds)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(watchedToggled)="onVodWatchedToggled($event)"
|
||||
(downloadRequested)="vodDetailActions.download($event)"
|
||||
|
||||
+4
-7
@@ -245,13 +245,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onVodResume(event: {
|
||||
item: VodDetailsItem;
|
||||
positionSeconds: number;
|
||||
}): void {
|
||||
this.onVodPlay(event.item, event.positionSeconds);
|
||||
}
|
||||
|
||||
onVodFavoriteToggled(event: {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
@@ -285,6 +278,10 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
|
||||
selectedVodPosition: this.selectedVodPosition,
|
||||
discardPendingPositionLoad: () => ++this.positionLoadGeneration,
|
||||
beforeExternalLaunch: () => this.closeInlinePlayer(),
|
||||
beginPendingStart: () => {
|
||||
const startId = this.pendingStart.begin(this.playbackOwnerKey());
|
||||
return () => this.pendingStart.settle(startId);
|
||||
},
|
||||
afterProgressReset: (playlistId) =>
|
||||
void this.catalog.refreshPositions(playlistId),
|
||||
download: (item) =>
|
||||
|
||||
@@ -922,6 +922,7 @@ export class StalkerSearchComponent {
|
||||
selectedVodPosition: this.selectedVodPosition,
|
||||
discardPendingPositionLoad: () =>
|
||||
this.vodPlayback.discardPendingPositionLoad(),
|
||||
beginPendingStart: () => this.vodPlayback.beginPendingStart(),
|
||||
snackBar: this.snackBar,
|
||||
translate: this.translateService,
|
||||
logError: () => undefined,
|
||||
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
import { computed, inject, Injectable, Signal, signal } from '@angular/core';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type ExternalPlayerName,
|
||||
@@ -28,7 +28,7 @@ interface StalkerSeriesMenuBindings {
|
||||
*/
|
||||
@Injectable()
|
||||
export class StalkerSeriesMenuService {
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly bindings = signal<StalkerSeriesMenuBindings | null>(null);
|
||||
|
||||
@@ -62,7 +62,8 @@ export class StalkerSeriesMenuService {
|
||||
playbackActive: this.bindings()?.playbackActive() ?? false,
|
||||
sourcesCount: 0,
|
||||
externalPlayerHint:
|
||||
quickStart?.action && this.downloadsService.isAvailable()
|
||||
quickStart?.action &&
|
||||
this.runtime.supportsManagedExternalPlayers
|
||||
? this.externalPlayer() === 'vlc'
|
||||
? 'VLC'
|
||||
: 'MPV'
|
||||
|
||||
@@ -31,10 +31,13 @@ describe('createStalkerVodDetailActions openExternal', () => {
|
||||
const beforeExternalLaunch = jest.fn();
|
||||
const closeSession = jest.fn().mockResolvedValue(undefined);
|
||||
let running: ExternalPlayerSession | null = null;
|
||||
const settlePendingStart = jest.fn();
|
||||
const beginPendingStart = jest.fn().mockReturnValue(settlePendingStart);
|
||||
const actions = createStalkerVodDetailActions({
|
||||
resolvePlayback: resolvePlayback as never,
|
||||
portalPlayer: { openExternalPlayback },
|
||||
externalPlayback: { activeSession: () => running, closeSession },
|
||||
beginPendingStart,
|
||||
playbackPositions: { clearPlaybackPositionOrThrow: jest.fn() },
|
||||
playlistId: () => 'portal-1',
|
||||
selectedVodId,
|
||||
@@ -49,6 +52,8 @@ describe('createStalkerVodDetailActions openExternal', () => {
|
||||
openExternalPlayback,
|
||||
beforeExternalLaunch,
|
||||
closeSession,
|
||||
beginPendingStart,
|
||||
settlePendingStart,
|
||||
setRunning: (session: ExternalPlayerSession | null) =>
|
||||
(running = session),
|
||||
resolveLink: () => resolveLink({ streamUrl: 'http://cdn/42.mp4' }),
|
||||
@@ -98,6 +103,13 @@ describe('createStalkerVodDetailActions openExternal', () => {
|
||||
{ streamUrl: 'http://cdn/42.mp4' },
|
||||
'mpv'
|
||||
);
|
||||
// Pending from the click until the launch settled: a reset meanwhile
|
||||
// would be undone by the start.
|
||||
expect(t.beginPendingStart).toHaveBeenCalledTimes(1);
|
||||
expect(t.settlePendingStart).toHaveBeenCalledTimes(1);
|
||||
expect(t.beginPendingStart.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
t.openExternalPlayback.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the stream once another movie was selected meanwhile', async () => {
|
||||
|
||||
@@ -51,6 +51,8 @@ interface StalkerVodDetailActionsDeps {
|
||||
/** Retires a stored-position read in flight, which would restore the row. */
|
||||
readonly discardPendingPositionLoad?: () => void;
|
||||
readonly beforeExternalLaunch?: () => void;
|
||||
/** Marks the movie's start pending while the stream resolves; returns the settle. */
|
||||
readonly beginPendingStart?: () => () => void;
|
||||
readonly afterProgressReset?: (playlistId: string) => void;
|
||||
readonly snackBar: Pick<MatSnackBar, 'open'>;
|
||||
readonly translate: Pick<TranslateService, 'instant'>;
|
||||
@@ -102,6 +104,7 @@ export function createStalkerVodDetailActions(
|
||||
const stillSelected = () =>
|
||||
deps.selectedVodId() === vodId &&
|
||||
deps.playlistId() === playlistId;
|
||||
const settlePendingStart = deps.beginPendingStart?.();
|
||||
try {
|
||||
const playback = await deps.resolvePlayback(
|
||||
event.item.cmd,
|
||||
@@ -133,6 +136,8 @@ export function createStalkerVodDetailActions(
|
||||
if (stillSelected()) {
|
||||
notify('PORTALS.PLAYBACK_ERROR');
|
||||
}
|
||||
} finally {
|
||||
settlePendingStart?.();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ export class StalkerVodPlaybackController {
|
||||
|
||||
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
|
||||
|
||||
/** An explicit launch resolving its stream; the closure settles it. */
|
||||
beginPendingStart(): () => void {
|
||||
const startId = this.pendingStart.begin(
|
||||
this.config.playbackOwnerKey?.()
|
||||
);
|
||||
return () => this.pendingStart.settle(startId);
|
||||
}
|
||||
|
||||
async startVodPlayback(
|
||||
resolvePlayback: () => Promise<ResolvedPortalPlayback>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type ExternalPlayerName,
|
||||
@@ -58,7 +58,7 @@ export class SerialDetailsMenuService {
|
||||
private readonly dataSource = inject(XTREAM_DATA_SOURCE, {
|
||||
optional: true,
|
||||
});
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -157,7 +157,7 @@ export class SerialDetailsMenuService {
|
||||
playbackActive: this.bindings()?.playbackActive() ?? false,
|
||||
sourcesCount: 0,
|
||||
externalPlayerHint:
|
||||
quickStart && this.downloadsService.isAvailable()
|
||||
quickStart && this.runtime.supportsManagedExternalPlayers
|
||||
? this.externalPlayer() === 'vlc'
|
||||
? 'VLC'
|
||||
: 'MPV'
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
PORTAL_PLAYBACK_POSITIONS,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { DownloadsService, SettingsStore } from '@iptvnator/services';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type ExternalPlayerName,
|
||||
@@ -48,7 +48,7 @@ export class VodDetailsMenuService {
|
||||
private readonly msUi = inject(VodDetailsMultiSourceUiService);
|
||||
private readonly multiSource = inject(VodMultiSourceHostService);
|
||||
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
@@ -80,7 +80,7 @@ export class VodDetailsMenuService {
|
||||
testId: 'vod-menu-sources',
|
||||
});
|
||||
}
|
||||
if (item && this.downloadsService.isAvailable()) {
|
||||
if (item && this.runtime.supportsManagedExternalPlayers) {
|
||||
sourceRows.push({
|
||||
id: VOD_MENU_ACTION.ExternalPlayer,
|
||||
labelKey: 'PORTALS.DETAIL.OPEN_IN_EXTERNAL_PLAYER',
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
buildVodMenuSections,
|
||||
labelOf,
|
||||
vodDurationSeconds,
|
||||
formatPlaybackClock,
|
||||
} from './vod-details-presentation';
|
||||
|
||||
export interface VodDetailsHeroStateDeps {
|
||||
@@ -29,9 +30,9 @@ export interface VodDetailsHeroStateDeps {
|
||||
readonly playbackPosition: Signal<number | null>;
|
||||
readonly playbackDurationSeconds: Signal<number | null>;
|
||||
readonly hasPlaybackPosition: Signal<boolean>;
|
||||
readonly formattedPosition: Signal<string>;
|
||||
readonly isWatched: Signal<boolean>;
|
||||
readonly isElectron: Signal<boolean>;
|
||||
/** Managed MPV/VLC launches: the bridge serves openInMpv/openInVlc. */
|
||||
readonly supportsExternalPlayers: () => boolean;
|
||||
/** Play/Resume clicked, stream still resolving (Stalker `create_link`). */
|
||||
readonly playbackStartPending: Signal<boolean>;
|
||||
readonly isOfflinePrimary: Signal<boolean>;
|
||||
@@ -98,7 +99,9 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) {
|
||||
hasPlaybackPosition: deps.hasPlaybackPosition(),
|
||||
positionSeconds: deps.playbackPosition(),
|
||||
durationSeconds: durationSeconds(),
|
||||
formattedPosition: deps.formattedPosition(),
|
||||
formattedPosition: formatPlaybackClock(
|
||||
deps.playbackPosition()
|
||||
),
|
||||
},
|
||||
translate
|
||||
)
|
||||
@@ -111,7 +114,7 @@ export function createVodDetailsHeroState(deps: VodDetailsHeroStateDeps) {
|
||||
buildVodMenuSections({
|
||||
// Provider-only mode hides local/download controls, not the
|
||||
// provider's own stream in MPV/VLC.
|
||||
externalPlayerAvailable: deps.isElectron(),
|
||||
externalPlayerAvailable: deps.supportsExternalPlayers(),
|
||||
externalPlayerHint: externalPlayer() === 'vlc' ? 'VLC' : 'MPV',
|
||||
hasPlaybackPosition: deps.hasPlaybackPosition(),
|
||||
hasStoredProgress:
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import {
|
||||
buildVodMenuSections,
|
||||
formatPlaybackClock,
|
||||
VOD_DETAILS_MENU_ACTION,
|
||||
} from './vod-details-presentation';
|
||||
|
||||
describe('formatPlaybackClock', () => {
|
||||
it('formats a resume point as a clock', () => {
|
||||
expect(formatPlaybackClock(754)).toBe('12:34');
|
||||
expect(formatPlaybackClock(5025)).toBe('1:23:45');
|
||||
expect(formatPlaybackClock(65)).toBe('1:05');
|
||||
expect(formatPlaybackClock(0)).toBe('');
|
||||
expect(formatPlaybackClock(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
const BASE = {
|
||||
externalPlayerAvailable: true,
|
||||
externalPlayerHint: 'MPV' as const,
|
||||
|
||||
@@ -159,6 +159,20 @@ export function buildPrimaryAction(
|
||||
};
|
||||
}
|
||||
|
||||
/** "12:34" or "1:23:45" for a resume point; empty without one. */
|
||||
export function formatPlaybackClock(
|
||||
seconds: number | null | undefined
|
||||
): string {
|
||||
const total = Math.floor(seconds ?? 0);
|
||||
if (total <= 0) {
|
||||
return '';
|
||||
}
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const rest = `${minutes.toString().padStart(2, '0')}:${(total % 60).toString().padStart(2, '0')}`;
|
||||
const hours = Math.floor(total / 3600);
|
||||
return hours > 0 ? `${hours}:${rest}` : `${minutes}:${rest.slice(3)}`;
|
||||
}
|
||||
|
||||
export function buildVodMenuSections(input: {
|
||||
externalPlayerAvailable: boolean;
|
||||
externalPlayerHint: 'MPV' | 'VLC';
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
CrossPortalSimilarItem,
|
||||
CrossPortalSimilarService,
|
||||
DownloadsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
@@ -194,6 +195,8 @@ export class VodDetailsComponent {
|
||||
// ============ Services ============
|
||||
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly crossPortalSimilar = inject(CrossPortalSimilarService);
|
||||
private readonly externalPlaybackActions = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly router = inject(Router);
|
||||
@@ -264,25 +267,9 @@ export class VodDetailsComponent {
|
||||
* Whether there's a playback position to resume from. A watched movie
|
||||
* shows Play, not "Resume 1:32:00" from its final seconds.
|
||||
*/
|
||||
readonly hasPlaybackPosition = computed(() => {
|
||||
const pos = this.playbackPosition();
|
||||
return pos !== null && pos > 0 && !this.isWatched();
|
||||
});
|
||||
|
||||
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
|
||||
readonly formattedPosition = computed(() => {
|
||||
const pos = this.playbackPosition();
|
||||
if (!pos || pos <= 0) return '';
|
||||
|
||||
const hours = Math.floor(pos / 3600);
|
||||
const minutes = Math.floor((pos % 3600) / 60);
|
||||
const seconds = Math.floor(pos % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
readonly hasPlaybackPosition = computed(
|
||||
() => (this.playbackPosition() ?? 0) > 0 && !this.isWatched()
|
||||
);
|
||||
|
||||
private readonly downloadState = createVodDownloadState(
|
||||
this.downloadsService,
|
||||
@@ -322,9 +309,9 @@ export class VodDetailsComponent {
|
||||
playbackPosition: this.playbackPosition,
|
||||
playbackDurationSeconds: this.playbackDurationSeconds,
|
||||
hasPlaybackPosition: this.hasPlaybackPosition,
|
||||
formattedPosition: this.formattedPosition,
|
||||
isWatched: this.isWatched,
|
||||
isElectron: this.isElectron,
|
||||
supportsExternalPlayers: () =>
|
||||
this.runtime.supportsManagedExternalPlayers,
|
||||
playbackStartPending: this.playbackStartPending,
|
||||
isOfflinePrimary: this.isOfflinePrimary,
|
||||
externalLabel: this.externalPrimaryLabel,
|
||||
|
||||
Reference in new issue
Block a user