fix(portals): gate VOD auto-failover on engines that can report failures

Merges master and resolves the collision with its shared playback helpers,
then closes two Codex findings.

Master extracted the Play/Stop button state and the inline position writer
that this branch had modified. Rather than forking private copies back out of
Xtream, both behaviours move into the shared helpers: `alsoOwns` lets a page
own an external session launched for a copy of the same film in another
playlist, and the resume latch — which stops a timeupdate emitted before the
engine reaches `startTime` from overwriting the point being resumed from —
now protects Stalker too, which had the same bug.

Auto-failover was offered on every engine, but only the built-in web players
raise the playback diagnostic that reaches `onPlaybackFailed()`: Embedded MPV
has its diagnostics suppressed and MPV/VLC play outside the app. The toggle is
now hidden there, in settings and in the sources menu, instead of promising a
switch that can never happen.

`setAutoFailover` also ignored `updateSettings()`, which patches memory first
and rejects if the write fails — the toggle looked saved, silently reverted on
restart, and the rejection surfaced only as an unhandled promise. It now
reports the failure like the settings form's own save paths.

The three VOD-details route specs each carried a near-identical 150-line
TestBed; they now share one harness, which is what makes room for the new
cases (1012 -> 584 lines).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 10:20:02 +02:00
commit 56733db1ec
37 files changed
+1795 -910

No files matched your search

@@ -0,0 +1,91 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { StreamFormat, VideoPlayer } from '@iptvnator/shared/interfaces';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
/**
* Who gets offered VOD auto-failover.
*
* Only the built-in web players raise the playback diagnostic that triggers a
* source switch: Embedded MPV has its diagnostics suppressed and MPV/VLC play
* outside the app entirely. Offering the control there would let a user enable
* a feature that can never fire.
*/
describe('SettingsPlaybackSectionComponent — VOD auto-failover', () => {
let fixture: ComponentFixture<SettingsPlaybackSectionComponent>;
const settingRow = (): Element | null =>
fixture.nativeElement.querySelector(
'[data-test-id="vod-auto-failover-setting"]'
);
function render(player: VideoPlayer, supported = true): void {
fixture.componentRef.setInput('form', createForm(player));
fixture.componentRef.setInput('supportsVodMultiSource', supported);
fixture.detectChanges();
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
SettingsPlaybackSectionComponent,
NoopAnimationsModule,
ReactiveFormsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
fixture.componentRef.setInput('activeSection', 'playback');
fixture.componentRef.setInput('players', []);
fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
});
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('offers the toggle on %s', (player) => {
render(player);
expect(settingRow()).not.toBeNull();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'hides the toggle on %s, which never reports a playback failure',
(player) => {
render(player);
expect(settingRow()).toBeNull();
}
);
it('stays hidden in the PWA, where there is nothing to fail over to', () => {
render(VideoPlayer.Html5Player, false);
expect(settingRow()).toBeNull();
});
});
function createForm(player: VideoPlayer): FormGroup {
return new FormGroup({
player: new FormControl(player),
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
playerUpNextRail: new FormControl(true),
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
openStreamOnDoubleClick: new FormControl(false),
showExternalPlaybackBar: new FormControl(true),
embeddedMpvFrameCopy: new FormControl(false),
mpvPlayerPath: new FormControl(''),
mpvPlayerArguments: new FormControl(''),
mpvReuseInstance: new FormControl(false),
vlcPlayerPath: new FormControl(''),
vlcPlayerArguments: new FormControl(''),
vlcReuseInstance: new FormControl(false),
recordingFolder: new FormControl(''),
vodAutoFailover: new FormControl(false),
});
}
@@ -133,7 +133,9 @@
</div>
}
@if (supportsVodMultiSource()) {
<!-- Only the web players raise a playback diagnostic, so under MPV, VLC
or Embedded MPV nothing would ever trigger a failover. -->
@if (supportsVodMultiSource() && isWebPlayerSelected()) {
<div class="setting-item" data-test-id="vod-auto-failover-setting">
<div class="setting-item__meta">
<h4>
@@ -394,5 +394,6 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
vlcPlayerArguments: new FormControl(''),
vlcReuseInstance: new FormControl(false),
recordingFolder: new FormControl(''),
vodAutoFailover: new FormControl(false),
});
}
@@ -8,7 +8,11 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { StreamFormat, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
StreamFormat,
VideoPlayer,
reportsPlaybackFailures,
} from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models';
@Component({
@@ -59,12 +63,7 @@ export class SettingsPlaybackSectionComponent {
readonly selectRecordingFolder = output<void>();
isWebPlayerSelected(): boolean {
const player = this.form().value.player;
return (
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer
);
return reportsPlaybackFailures(this.form().value.player);
}
isExternalPlayerSelected(): boolean {