mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
* feat(playback): Up Next episode rail for the inline series player On wide windows the inline series player now docks left and fills the leftover stage column with a Netflix-style "Up Next" rail: the rest of the current season plus next-season spillover, the playing episode highlighted, and watch-progress bars from playback positions. Clicking an episode plays it inline through the host's existing episode flow (Xtream serial-details and Stalker series view). - New app-up-next-rail component + buildUpNextRailItems() util in ui/playback; entries carry the host's raw episode object so selection needs no id lookup. - PortalInlinePlayerComponent measures the theater stage with a ResizeObserver and docks the rail only when the leftover beside the 16:9 player is >= 320px; narrower stages keep the centered theater/ambient behavior from #1223. Movies and live never show the rail. - New playerUpNextRail setting (Settings > Playback, default on, built-in web players only), mirroring playerAmbientMode; enforced at runtime for non-web engines. - i18n: SETTINGS.PLAYER_UP_NEXT_RAIL(+_DESCRIPTION) and PORTALS.UP_NEXT in all 18 locales. - The rail renders as an opaque panel on top of the stage, so the ambient fill stays behind it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): address Greptile review on the Up Next rail - Stage overflow: `.player-shell__viewport` had no border-box sizing (the repo has no global reset), so the docked-rail modifier's 12px padding widened the stage past its container and the right edge was clipped. - Width gate: compute the width the rail actually receives (stage minus the docked layout's padding, the height-driven 16:9 player, and the flex gap) instead of raw stage slack, and observe the stage's border box so the modifier's own padding cannot feed back into the measurement. - Stalker lazy seasons: Ministra VOD-series seasons hold no episodes until opened, so the rail's next-season spillover stopped at the current season. Prefetch the following season while an episode plays inline. Adds regression coverage for the gate boundary, gate stability across the padding toggle, and the lazy-season prefetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): stop the rail spillover prefetch from retrying forever A failed or genuinely empty Ministra season resets isLoading while leaving episodes empty, so the prefetch effect re-requested the same season on every emission for as long as inline playback continued. Remember which seasons this view already requested and ask at most once each. Regression test asserts the empty-response case fetches exactly once and does not retrigger on further playback in the same season. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): let a failed spillover prefetch recover on the next episode The previous guard was permanent, so a transient network or authorization failure disabled the rail's next-season prefetch for the component's lifetime. Distinguish the two outcomes instead: - Answered (even with zero episodes) — a real answer, never asked again. - Failed — the claim is released, but pinned to the episode that triggered it, so the retry waits for the next playback change. Retrying immediately would loop, since the failure itself flips isLoading and re-runs the effect. The claim is taken synchronously; awaiting first let the isLoading flip re-run the effect and fire a duplicate request before the answer arrived. `loadEpisodesForSeason` now reports whether the portal answered; existing callers ignore the result and are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
396 lines
15 KiB
HTML
396 lines
15 KiB
HTML
<section
|
|
class="settings-group"
|
|
[class.settings-group--active]="activeSection() === 'playback'"
|
|
id="playback"
|
|
[formGroup]="form()"
|
|
>
|
|
<header class="settings-group__header">
|
|
<div class="settings-group__header-icon">
|
|
<mat-icon>play_circle</mat-icon>
|
|
</div>
|
|
<div class="settings-group__header-text">
|
|
<h3>{{ 'SETTINGS.PLAYBACK' | translate }}</h3>
|
|
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="setting-item">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}</h4>
|
|
<p>{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}</p>
|
|
</div>
|
|
<div class="setting-item__control">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label>{{
|
|
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
|
}}</mat-label>
|
|
<mat-select
|
|
formControlName="player"
|
|
data-test-id="select-video-player"
|
|
>
|
|
@for (player of players(); track player.id) {
|
|
<mat-option
|
|
[value]="player.id"
|
|
[attr.data-test-id]="player.id"
|
|
>{{ player.labelKey | translate }}
|
|
</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.STREAM_FORMAT' | translate }}</h4>
|
|
<p>{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}</p>
|
|
</div>
|
|
<div class="setting-item__control">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label>{{
|
|
'SETTINGS.VIDEO_PLAYER_PLACEHOLDER' | translate
|
|
}}</mat-label>
|
|
<mat-select
|
|
formControlName="streamFormat"
|
|
data-test-id="select-stream-format"
|
|
>
|
|
@for (
|
|
streamFormat of streamFormatEnum() | keyvalue;
|
|
track streamFormat
|
|
) {
|
|
<mat-option
|
|
[value]="streamFormat.value"
|
|
[attr.data-test-id]="streamFormat.value"
|
|
>{{ streamFormat.value }}
|
|
</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
|
|
@if (isWebPlayerSelected()) {
|
|
<div
|
|
class="setting-item"
|
|
data-test-id="web-player-shared-controls-setting"
|
|
>
|
|
<div class="setting-item__meta">
|
|
<h4>
|
|
{{ 'SETTINGS.WEB_PLAYER_SHARED_CONTROLS' | translate }}
|
|
</h4>
|
|
<p>
|
|
{{
|
|
'SETTINGS.WEB_PLAYER_SHARED_CONTROLS_DESCRIPTION'
|
|
| translate
|
|
}}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox
|
|
[aria-label]="
|
|
'SETTINGS.WEB_PLAYER_SHARED_CONTROLS' | translate
|
|
"
|
|
formControlName="webPlayerSharedControls"
|
|
data-test-id="web-player-shared-controls-toggle"
|
|
></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item" data-test-id="player-ambient-mode-setting">
|
|
<div class="setting-item__meta">
|
|
<h4>
|
|
{{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }}
|
|
</h4>
|
|
<p>
|
|
{{ 'SETTINGS.PLAYER_AMBIENT_MODE_DESCRIPTION' | translate }}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox
|
|
[aria-label]="'SETTINGS.PLAYER_AMBIENT_MODE' | translate"
|
|
formControlName="playerAmbientMode"
|
|
data-test-id="player-ambient-mode-toggle"
|
|
></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item" data-test-id="player-up-next-rail-setting">
|
|
<div class="setting-item__meta">
|
|
<h4>
|
|
{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }}
|
|
</h4>
|
|
<p>
|
|
{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION' | translate }}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox
|
|
[aria-label]="'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate"
|
|
formControlName="playerUpNextRail"
|
|
data-test-id="player-up-next-rail-toggle"
|
|
></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
|
|
<div
|
|
class="setting-item"
|
|
data-test-id="external-player-double-click-setting"
|
|
>
|
|
<div class="setting-item__meta">
|
|
<h4>
|
|
{{
|
|
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK'
|
|
| translate
|
|
}}
|
|
</h4>
|
|
<p>
|
|
{{
|
|
'SETTINGS.OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION'
|
|
| translate
|
|
}}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox
|
|
formControlName="openStreamOnDoubleClick"
|
|
></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (isDesktop()) {
|
|
<div class="setting-item">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}</h4>
|
|
<p>
|
|
{{
|
|
'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION'
|
|
| translate
|
|
}}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox
|
|
formControlName="showExternalPlaybackBar"
|
|
></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
|
|
@if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {
|
|
<div
|
|
class="setting-item"
|
|
data-test-id="embedded-mpv-frame-copy-setting"
|
|
>
|
|
<div class="setting-item__meta">
|
|
<h4>
|
|
{{ 'SETTINGS.EMBEDDED_MPV_FRAME_COPY' | translate }}
|
|
</h4>
|
|
<p>
|
|
{{
|
|
'SETTINGS.EMBEDDED_MPV_FRAME_COPY_DESCRIPTION'
|
|
| translate
|
|
}}
|
|
@if (
|
|
form().value.embeddedMpvFrameCopy !==
|
|
frameCopyActive()
|
|
) {
|
|
<strong>
|
|
{{
|
|
'SETTINGS.EMBEDDED_MPV_FRAME_COPY_RESTART'
|
|
| translate
|
|
}}
|
|
</strong>
|
|
}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox
|
|
formControlName="embeddedMpvFrameCopy"
|
|
data-test-id="embedded-mpv-frame-copy-toggle"
|
|
></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<div class="setting-item" data-test-id="recording-folder-setting">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}</h4>
|
|
<p>
|
|
{{ 'SETTINGS.RECORDING_FOLDER_DESCRIPTION' | translate }}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label for="recordingFolder">{{
|
|
'SETTINGS.RECORDING_FOLDER' | translate
|
|
}}</mat-label>
|
|
<input
|
|
matInput
|
|
type="text"
|
|
id="recordingFolder"
|
|
formControlName="recordingFolder"
|
|
/>
|
|
<button
|
|
mat-icon-button
|
|
matSuffix
|
|
type="button"
|
|
[attr.aria-label]="
|
|
'SETTINGS.RECORDING_FOLDER_SELECT' | translate
|
|
"
|
|
(click)="selectRecordingFolder.emit()"
|
|
>
|
|
<mat-icon>folder_open</mat-icon>
|
|
</button>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (
|
|
supportsExternalPlayerPathSettings() && form().value.player === 'mpv'
|
|
) {
|
|
<div class="setting-item">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
|
|
<p>
|
|
{{ 'SETTINGS.MPV_PLAYER_PATH_DESCRIPTION' | translate }}
|
|
</p>
|
|
<p data-test-id="mpv-compatible-player-tip">
|
|
{{ 'SETTINGS.MPV_COMPATIBLE_PLAYER_TIP' | translate }}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label for="mpvPlayerPath">{{
|
|
'SETTINGS.MPV_PLAYER_PATH' | translate
|
|
}}</mat-label>
|
|
<input
|
|
matInput
|
|
type="text"
|
|
id="mpvPlayerPath"
|
|
formControlName="mpvPlayerPath"
|
|
/>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="setting-item setting-item--external-player-arguments"
|
|
data-test-id="mpv-player-arguments-setting"
|
|
>
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.MPV_PLAYER_ARGUMENTS_LABEL' | translate }}</h4>
|
|
<p>
|
|
{{
|
|
'SETTINGS.MPV_PLAYER_ARGUMENTS_DESCRIPTION' | translate
|
|
}}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label for="mpvPlayerArguments">{{
|
|
'SETTINGS.MPV_PLAYER_ARGUMENTS' | translate
|
|
}}</mat-label>
|
|
<textarea
|
|
matInput
|
|
id="mpvPlayerArguments"
|
|
formControlName="mpvPlayerArguments"
|
|
[placeholder]="mpvPlayerArgumentsPlaceholder"
|
|
rows="3"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
></textarea>
|
|
<mat-hint>{{
|
|
'SETTINGS.EXTERNAL_PLAYER_ARGUMENTS_HINT' | translate
|
|
}}</mat-hint>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}</h4>
|
|
<p>
|
|
{{ 'SETTINGS.MPV_REUSE_INSTANCE_DESCRIPTION' | translate }}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox formControlName="mpvReuseInstance"></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (
|
|
supportsExternalPlayerPathSettings() && form().value.player === 'vlc'
|
|
) {
|
|
<div class="setting-item">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
|
|
<p>
|
|
{{ 'SETTINGS.VLC_PLAYER_PATH_DESCRIPTION' | translate }}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label for="vlcPlayerPath">{{
|
|
'SETTINGS.VLC_PLAYER_PATH' | translate
|
|
}}</mat-label>
|
|
<input
|
|
matInput
|
|
type="text"
|
|
id="vlcPlayerPath"
|
|
formControlName="vlcPlayerPath"
|
|
/>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="setting-item setting-item--external-player-arguments"
|
|
data-test-id="vlc-player-arguments-setting"
|
|
>
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.VLC_PLAYER_ARGUMENTS_LABEL' | translate }}</h4>
|
|
<p>
|
|
{{
|
|
'SETTINGS.VLC_PLAYER_ARGUMENTS_DESCRIPTION' | translate
|
|
}}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control">
|
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
<mat-label for="vlcPlayerArguments">{{
|
|
'SETTINGS.VLC_PLAYER_ARGUMENTS' | translate
|
|
}}</mat-label>
|
|
<textarea
|
|
matInput
|
|
id="vlcPlayerArguments"
|
|
formControlName="vlcPlayerArguments"
|
|
[placeholder]="vlcPlayerArgumentsPlaceholder"
|
|
rows="3"
|
|
spellcheck="false"
|
|
autocomplete="off"
|
|
></textarea>
|
|
<mat-hint>{{
|
|
'SETTINGS.EXTERNAL_PLAYER_ARGUMENTS_HINT' | translate
|
|
}}</mat-hint>
|
|
</mat-form-field>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="setting-item">
|
|
<div class="setting-item__meta">
|
|
<h4>{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}</h4>
|
|
<p>
|
|
{{ 'SETTINGS.VLC_REUSE_INSTANCE_DESCRIPTION' | translate }}
|
|
</p>
|
|
</div>
|
|
<div class="setting-item__control setting-item__toggle">
|
|
<mat-checkbox formControlName="vlcReuseInstance"></mat-checkbox>
|
|
</div>
|
|
</div>
|
|
}
|
|
</section>
|