mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(portals): describe the copy the primary button will actually play
Two gaps found by review. A pin makes the primary button play a copy the page never loaded a position for — positions are keyed by (playlist, stream). The label, timecode and Restart affordance still came from the route copy's row, so the button could read "Resume 42:18" and start an unwatched copy at zero, or read "Play" and jump into the middle of one already watched. `createPrimaryActionPosition` lets the pinned copy's row govern, including when that row is absent: never watched is an answer, not a fallback to someone else's progress. A manual source switch also mounts a DIFFERENT stream in the same host while marking the new source active at once, so the previous stream's timeupdate was still vouching for it — the caption and the badge claimed the new source while it was still opening. That path now clears the latch like Play and Restart do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d8b6857ce
commit
b6e3a6752e
6 files changed
+391
-18
No files matched your search
@@ -423,6 +423,15 @@ first save would write that timecode under the pinned source's key.
|
||||
When no lookup function is supplied at all, nothing is applied: "never watched"
|
||||
was never established, so there is nothing to correct.
|
||||
|
||||
## What the primary button describes
|
||||
|
||||
A pin means the button plays a copy the page did not load a position for.
|
||||
`createPrimaryActionPosition` therefore looks that copy's row up and lets it
|
||||
govern the label, the timecode and the Restart affordance — including when the
|
||||
lookup comes back empty, because "never watched" is an answer: the button must
|
||||
read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the
|
||||
route's own row changes nothing; the loaded position already IS that copy's.
|
||||
|
||||
## Claims about the present
|
||||
|
||||
`isActive` means "the source a switch or Play would use". Discovery sets it the
|
||||
@@ -433,7 +442,9 @@ its own, back a statement in the present tense.
|
||||
`timeupdate`: `inlinePlayback()` is only the REQUEST to play, non-null while
|
||||
the engine is still opening the stream and still non-null after it fails, while
|
||||
a timeupdate is the engine reporting frames. External, it needs the session
|
||||
past `launching`.
|
||||
past `launching`. Every path that mounts a stream clears the latch first —
|
||||
Play, Restart, and a source switch, which puts a DIFFERENT stream into the
|
||||
same host and so cannot inherit the old one's evidence.
|
||||
|
||||
Two things read it, and they must agree: the "Playing from" caption, and the
|
||||
source row's badge — which reads `Current` when a source is merely selected and
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getXtreamVodInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
|
||||
import { formatPlaybackPosition } from './vod-primary-action-position';
|
||||
|
||||
export interface VodDetailsPlaybackBindings {
|
||||
/** Current vod id resolved from the route */
|
||||
@@ -239,13 +240,7 @@ export class VodDetailsPlaybackService {
|
||||
}
|
||||
|
||||
formatPosition(): string {
|
||||
const position = this.vodPlaybackPosition();
|
||||
if (!position) return '';
|
||||
|
||||
const date = new Date(0);
|
||||
date.setSeconds(position.positionSeconds);
|
||||
const timeString = date.toISOString().substr(11, 8);
|
||||
return timeString.startsWith('00:') ? timeString.substr(3) : timeString;
|
||||
return formatPlaybackPosition(this.vodPlaybackPosition());
|
||||
}
|
||||
|
||||
closeInlinePlayer(): void {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
import { VodDetailsRouteComponent } from './vod-details-route.component';
|
||||
import {
|
||||
@@ -137,6 +138,48 @@ describe('VodDetailsRouteComponent — source caption', () => {
|
||||
expect(component.activeSourceCaption()).toBeNull();
|
||||
});
|
||||
|
||||
it('waits again after a manual source switch', () => {
|
||||
currentPlaylist.set({ id: 'playlist-1' });
|
||||
const component = fixture.componentInstance;
|
||||
const playback = fixture.debugElement.injector.get(
|
||||
VodDetailsPlaybackService
|
||||
);
|
||||
stubs.isEmbeddedPlayer.mockReturnValue(true);
|
||||
withActiveSource('playlist-1', 650020);
|
||||
playback.inlinePlayback.set({
|
||||
streamUrl: 'http://example.com/movie.mkv',
|
||||
title: 'Example',
|
||||
});
|
||||
component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 });
|
||||
expect(component.activeSourceCaption()).not.toBeNull();
|
||||
|
||||
// A switch mounts a DIFFERENT stream in the same host and marks the
|
||||
// new source active at once, so the old stream's timeupdate must not
|
||||
// vouch for it — it may still be opening, or never start at all.
|
||||
// The exact callback `switchToSource` invokes once it has resolved a
|
||||
// new source. Standing up the resolver here would test the host, not
|
||||
// the route's half of the seam.
|
||||
(
|
||||
component.multiSource as unknown as {
|
||||
bindings: {
|
||||
startPlayback: (playback: ResolvedPortalPlayback) => void;
|
||||
};
|
||||
}
|
||||
).bindings.startPlayback({
|
||||
streamUrl: 'http://example.com/alt.mkv',
|
||||
title: 'Example',
|
||||
startTime: 3,
|
||||
});
|
||||
|
||||
expect(playback.inlinePlayback()?.streamUrl).toBe(
|
||||
'http://example.com/alt.mkv'
|
||||
);
|
||||
expect(component.activeSourceCaption()).toBeNull();
|
||||
|
||||
component.handleInlineTimeUpdate({ currentTime: 4, duration: 90 });
|
||||
expect(component.activeSourceCaption()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says nothing while an external player is still launching', () => {
|
||||
currentPlaylist.set({ id: 'playlist-1' });
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
@@ -49,7 +49,9 @@ import {
|
||||
XtreamVodInfo,
|
||||
XtreamVodStream,
|
||||
youtubeEmbedUrl,
|
||||
type PlaybackPositionData,
|
||||
type VodSourceCandidate,
|
||||
type VodSourceDescriptor,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
SimilarCatalogItem,
|
||||
@@ -62,6 +64,10 @@ import {
|
||||
import { VodDetailsPlaybackService } from './vod-details-playback.service';
|
||||
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
|
||||
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
import {
|
||||
createPrimaryActionPosition,
|
||||
formatPlaybackPosition,
|
||||
} from './vod-primary-action-position';
|
||||
|
||||
@Component({
|
||||
templateUrl: './vod-details-route.component.html',
|
||||
@@ -229,7 +235,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
readonly externalPrimaryButtonState =
|
||||
this.playback.externalPrimaryButtonState;
|
||||
readonly vodPlaybackProgress = this.playback.vodPlaybackProgress;
|
||||
readonly hasPlaybackPosition = this.playback.hasPlaybackPosition;
|
||||
|
||||
/**
|
||||
* What the primary button acts on — the PINNED copy's position when a pin
|
||||
* names another copy, since that is the stream the button will play.
|
||||
*/
|
||||
private readonly primaryAction = createPrimaryActionPosition({
|
||||
sources: this.multiSource.sources,
|
||||
routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
|
||||
routeContentId: this.selectedVodId,
|
||||
routePosition: this.playback.vodPlaybackPosition,
|
||||
load: (source) => this.positionFor(source),
|
||||
});
|
||||
readonly hasPlaybackPosition = this.primaryAction.hasPosition;
|
||||
|
||||
readonly isDownloaded = computed(() => {
|
||||
const vodId = this.selectedVodId();
|
||||
@@ -367,8 +385,14 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
this.multiSource.bind({
|
||||
// Route every switch through the same inline-vs-external fork a
|
||||
// normal Play uses, so the two paths cannot drift apart.
|
||||
startPlayback: (playback) =>
|
||||
this.playback.startResolvedPlayback(playback),
|
||||
startPlayback: (playback) => {
|
||||
// A switch mounts a DIFFERENT stream in the same host, so the
|
||||
// evidence that the previous one was playing says nothing
|
||||
// about this one — without clearing it the caption and the
|
||||
// badge would claim the new source while it is still opening.
|
||||
this.inlineTimeSeen.set(false);
|
||||
this.playback.startResolvedPlayback(playback);
|
||||
},
|
||||
movie: this.multiSourceMovie,
|
||||
});
|
||||
|
||||
@@ -500,23 +524,26 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
* Positions are keyed by (playlist, stream), so a pinned alternative has
|
||||
* its own row — the one this page loaded belongs to the route's copy.
|
||||
*/
|
||||
private readonly resumeSecondsFor = async (
|
||||
source: VodSourceCandidate
|
||||
): Promise<number | null> => {
|
||||
const position = await this.playbackPositions.getPlaybackPosition(
|
||||
private readonly positionFor = (
|
||||
source: VodSourceCandidate | VodSourceDescriptor
|
||||
): Promise<PlaybackPositionData | null> =>
|
||||
this.playbackPositions.getPlaybackPosition(
|
||||
source.playlistId,
|
||||
source.contentId,
|
||||
'vod'
|
||||
);
|
||||
return position?.positionSeconds ?? null;
|
||||
};
|
||||
|
||||
private readonly resumeSecondsFor = async (
|
||||
source: VodSourceCandidate
|
||||
): Promise<number | null> =>
|
||||
(await this.positionFor(source))?.positionSeconds ?? null;
|
||||
|
||||
stopExternalPlayback(): Promise<void> {
|
||||
return this.playback.stopExternalPlayback();
|
||||
}
|
||||
|
||||
formatPosition(): string {
|
||||
return this.playback.formatPosition();
|
||||
return formatPlaybackPosition(this.primaryAction.position());
|
||||
}
|
||||
|
||||
toggleFavorite(): void {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
VodSourceDescriptor,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
createPrimaryActionPosition,
|
||||
formatPlaybackPosition,
|
||||
} from './vod-primary-action-position';
|
||||
|
||||
/**
|
||||
* Which position the primary button describes.
|
||||
*
|
||||
* The page loads the ROUTE copy's row, but a pin means the button plays a
|
||||
* different copy — with its own row, or none at all. Reading the wrong one
|
||||
* makes the button lie about where it will start.
|
||||
*/
|
||||
|
||||
const ROUTE_PLAYLIST = 'playlist-1';
|
||||
const ROUTE_CONTENT = 650020;
|
||||
|
||||
function source(
|
||||
overrides: Partial<VodSourceDescriptor> = {}
|
||||
): VodSourceDescriptor {
|
||||
return {
|
||||
id: `${ROUTE_PLAYLIST}:xtream:${ROUTE_CONTENT}`,
|
||||
playlistId: ROUTE_PLAYLIST,
|
||||
playlistName: 'Portal One',
|
||||
portalType: 'xtream',
|
||||
contentId: ROUTE_CONTENT,
|
||||
rawTitle: 'Example',
|
||||
matchConfidence: 'exact',
|
||||
year: null,
|
||||
isActive: true,
|
||||
isPinned: false,
|
||||
isTried: false,
|
||||
probe: { status: 'idle' },
|
||||
...overrides,
|
||||
} as VodSourceDescriptor;
|
||||
}
|
||||
|
||||
function position(seconds: number): PlaybackPositionData {
|
||||
return {
|
||||
playlistId: 'any',
|
||||
contentXtreamId: 1,
|
||||
contentType: 'vod',
|
||||
positionSeconds: seconds,
|
||||
durationSeconds: 7200,
|
||||
};
|
||||
}
|
||||
|
||||
const ALT = source({
|
||||
id: 'playlist-2:xtream:991',
|
||||
playlistId: 'playlist-2',
|
||||
contentId: 991,
|
||||
isPinned: true,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
function setup(
|
||||
sources: VodSourceDescriptor[],
|
||||
routePosition: PlaybackPositionData | null,
|
||||
load: (source: VodSourceDescriptor) => Promise<PlaybackPositionData | null>
|
||||
) {
|
||||
const sourcesSignal = signal(sources);
|
||||
const api = TestBed.runInInjectionContext(() =>
|
||||
createPrimaryActionPosition({
|
||||
sources: sourcesSignal,
|
||||
routePlaylistId: signal<string | undefined>(ROUTE_PLAYLIST),
|
||||
routeContentId: signal(ROUTE_CONTENT),
|
||||
routePosition: signal(routePosition),
|
||||
load,
|
||||
})
|
||||
);
|
||||
return { api, sourcesSignal };
|
||||
}
|
||||
|
||||
describe('createPrimaryActionPosition', () => {
|
||||
it('describes the route copy when nothing is pinned', () => {
|
||||
const load = jest.fn();
|
||||
const { api } = setup([source()], position(2538), load);
|
||||
TestBed.tick();
|
||||
|
||||
expect(api.hasPosition()).toBe(true);
|
||||
expect(api.position()?.positionSeconds).toBe(2538);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('describes the pinned copy once its row is loaded', async () => {
|
||||
const { api } = setup([source(), ALT], position(2538), async () =>
|
||||
position(4200)
|
||||
);
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
TestBed.tick();
|
||||
|
||||
// Otherwise the button offers to resume at the ROUTE copy's timecode
|
||||
// and then starts the pinned copy somewhere else entirely.
|
||||
expect(api.position()?.positionSeconds).toBe(4200);
|
||||
});
|
||||
|
||||
it('reads Play for a pinned copy that was never watched', async () => {
|
||||
const { api } = setup(
|
||||
[source(), ALT],
|
||||
position(2538),
|
||||
async () => null
|
||||
);
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
TestBed.tick();
|
||||
|
||||
// "Never watched" is an answer, not an absence: falling back to the
|
||||
// route copy here would show "Resume 42:18" on a copy that starts at 0.
|
||||
expect(api.position()).toBeNull();
|
||||
expect(api.hasPosition()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a pin on the route’s own copy', () => {
|
||||
const load = jest.fn();
|
||||
const { api } = setup(
|
||||
[source({ isPinned: true })],
|
||||
position(2538),
|
||||
load
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
// Same row: the position the page already loaded IS this copy's.
|
||||
expect(api.position()?.positionSeconds).toBe(2538);
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drops a lookup the pin outran', async () => {
|
||||
let resolveFirst: (value: PlaybackPositionData | null) => void = () => {
|
||||
/* replaced below */
|
||||
};
|
||||
const load = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<PlaybackPositionData | null>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(position(60));
|
||||
|
||||
const { api, sourcesSignal } = setup(
|
||||
[source(), ALT],
|
||||
position(2538),
|
||||
load
|
||||
);
|
||||
TestBed.tick();
|
||||
|
||||
const other = source({
|
||||
id: 'playlist-3:xtream:77',
|
||||
playlistId: 'playlist-3',
|
||||
contentId: 77,
|
||||
isPinned: true,
|
||||
isActive: false,
|
||||
});
|
||||
sourcesSignal.set([source(), other]);
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
TestBed.tick();
|
||||
|
||||
// The first lookup lands late, describing a copy the button no longer
|
||||
// plays.
|
||||
resolveFirst(position(4200));
|
||||
await Promise.resolve();
|
||||
TestBed.tick();
|
||||
|
||||
expect(api.position()?.positionSeconds).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPlaybackPosition', () => {
|
||||
it('drops the hour when there is none', () => {
|
||||
expect(formatPlaybackPosition(position(123))).toBe('02:03');
|
||||
});
|
||||
|
||||
it('keeps the hour when there is one', () => {
|
||||
expect(formatPlaybackPosition(position(3723))).toBe('01:02:03');
|
||||
});
|
||||
|
||||
it('is empty without a position', () => {
|
||||
expect(formatPlaybackPosition(null)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Signal, computed, effect, signal } from '@angular/core';
|
||||
import { getPortalPlaybackProgressPercent } from '@iptvnator/portal/shared/util';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
VodSourceDescriptor,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* The position the PRIMARY button acts on.
|
||||
*
|
||||
* The page loads the route copy's position, but a pin means the button plays
|
||||
* a different copy — and positions are keyed by (playlist, stream), so that
|
||||
* copy has its own row. Reading the route's would let the button say
|
||||
* "Resume 42:00" and then start an unwatched copy from zero, or say "Play"
|
||||
* and jump into the middle of one that was already watched.
|
||||
*
|
||||
* When a pin names another copy its row governs, INCLUDING when there is no
|
||||
* row: never watched means Play, not Resume.
|
||||
*/
|
||||
|
||||
export interface PrimaryActionPositionDeps {
|
||||
/** Discovered sources; the pinned one, if any, carries `isPinned`. */
|
||||
sources: Signal<VodSourceDescriptor[]>;
|
||||
/** The route's own (playlist, stream), for telling "another copy" apart. */
|
||||
routePlaylistId: Signal<string | undefined>;
|
||||
routeContentId: Signal<number>;
|
||||
/** The route copy's position, as loaded by the page. */
|
||||
routePosition: Signal<PlaybackPositionData | null>;
|
||||
load: (source: VodSourceDescriptor) => Promise<PlaybackPositionData | null>;
|
||||
}
|
||||
|
||||
export interface PrimaryActionPosition {
|
||||
position: Signal<PlaybackPositionData | null>;
|
||||
/** Whether the button should read Resume rather than Play. */
|
||||
hasPosition: Signal<boolean>;
|
||||
}
|
||||
|
||||
export function createPrimaryActionPosition(
|
||||
deps: PrimaryActionPositionDeps
|
||||
): PrimaryActionPosition {
|
||||
/** The pinned copy, only while it is NOT the route's own row. */
|
||||
const foreignPin = computed(() => {
|
||||
const pinned = deps.sources().find((source) => source.isPinned);
|
||||
if (
|
||||
!pinned ||
|
||||
(pinned.playlistId === deps.routePlaylistId() &&
|
||||
pinned.contentId === deps.routeContentId())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pinned;
|
||||
});
|
||||
|
||||
const pinnedPosition = signal<PlaybackPositionData | null>(null);
|
||||
/** Distinguishes "not looked up yet" from "looked up, never watched". */
|
||||
const pinnedLoadedFor = signal<string | null>(null);
|
||||
|
||||
effect(() => {
|
||||
const pinned = foreignPin();
|
||||
if (!pinned) {
|
||||
pinnedPosition.set(null);
|
||||
pinnedLoadedFor.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pinnedLoadedFor() === pinned.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
void deps.load(pinned).then((position) => {
|
||||
// The pin can change across the lookup — applying a stale answer
|
||||
// would describe a copy the button no longer plays.
|
||||
if (foreignPin()?.id !== pinned.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
pinnedPosition.set(position);
|
||||
pinnedLoadedFor.set(pinned.id);
|
||||
});
|
||||
});
|
||||
|
||||
const position = computed(() =>
|
||||
foreignPin() && pinnedLoadedFor()
|
||||
? pinnedPosition()
|
||||
: deps.routePosition()
|
||||
);
|
||||
|
||||
const hasPosition = computed(() => {
|
||||
const progress = getPortalPlaybackProgressPercent(position());
|
||||
return progress > 0 && progress < 90;
|
||||
});
|
||||
|
||||
return { position, hasPosition };
|
||||
}
|
||||
|
||||
/** `01:02:03`, or `02:03` for anything under an hour. */
|
||||
export function formatPlaybackPosition(
|
||||
position: PlaybackPositionData | null
|
||||
): string {
|
||||
if (!position) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(0);
|
||||
date.setSeconds(position.positionSeconds);
|
||||
const timeString = date.toISOString().substring(11, 19);
|
||||
return timeString.startsWith('00:') ? timeString.substring(3) : timeString;
|
||||
}
|
||||
Reference in new issue
Block a user