mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
feat(portals): find the same movie in your other playlists
A movie that exists in several imported Xtream playlists now shows a "Sources N" chip on its detail page and in the player. Switching playlist mid-film keeps the timecode, a preferred source can be pinned per movie, and a failed stream offers the alternatives instead of a dead end. The governing rule is that a guess is never presented as a fact. Every metadata value carries where it came from — `api` (the provider said so), `parsed` (inferred from the title) or `probe` (we contacted the stream). Facts render as plain tags, guesses are prefixed `~` in a warning colour, and an unknown value renders no tag at all plus a "check" affordance. Ranking and failover read through `factualOnly()`, so a filename claiming 4K is structurally unable to outrank a source that was actually reached. A probe that could not complete reports "unknown", never "unavailable". Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces live content; both are additive later, since the candidate type already carries all three portal kinds. In the PWA every entry point is gated off and the chip renders nothing. Auto-failover is opt-in and off by default. Each source is tried at most once per session, so it terminates structurally, and the switch is never silent — the toast names the new playlist, offers an undo, and warns that the dub may differ only when both sides state an audio track as fact. Notable details: - Playlist names are routinely the pasted URL, credentials included. They are never rendered raw; a short host-only label is derived instead. - Quality is derived from pixel width, not height: a 2.39:1 1080p master is 1920x800, and bucketing that by height would publish "720p" as a fact. - Switching is a single `inlinePlayback.set()` so the player and engine survive and re-seek; the carried position is read before the 15s persistence throttle so it does not rewind. - Sources from one playlist collapse into a group, since the same film often appears there several times under different stream ids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2300bea11
commit
004cb65fe6
101 files changed
+7183
-192
No files matched your search
@@ -24,8 +24,11 @@ class StubWebPlayerViewComponent {
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
readonly alternativeSourceRequested = output<string>();
|
||||
readonly playbackFailed = output<unknown>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
@@ -27,6 +27,19 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="player-shell__actions">
|
||||
@if (allSources().length > 1) {
|
||||
<app-vod-sources-chip
|
||||
class="player-shell__sources"
|
||||
[sources]="allSources()"
|
||||
[count]="alternativeSources().length"
|
||||
[resumeLabel]="sourcesResumeLabel()"
|
||||
(playRequested)="
|
||||
alternativeSourceRequested.emit($event)
|
||||
"
|
||||
(pinRequested)="sourcePinRequested.emit($event)"
|
||||
(checkRequested)="sourceCheckRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
@@ -68,6 +81,11 @@
|
||||
[playback]="playback()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
[alternativeSources]="alternativeSources()"
|
||||
(alternativeSourceRequested)="
|
||||
alternativeSourceRequested.emit($event)
|
||||
"
|
||||
(playbackFailed)="playbackFailed.emit($event)"
|
||||
(timeUpdate)="onTimeUpdate($event)"
|
||||
(playbackEnded)="onPlaybackEnded()"
|
||||
(previousEpisodeRequested)="onPreviousEpisodeRequested()"
|
||||
|
||||
@@ -75,6 +75,30 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// The sources chip sits between a plain text button (Copy Stream URL) and an
|
||||
// outlined one (Close player). It is a peer of the latter, so it borrows that
|
||||
// shape — outlined pill, transparent fill, Material's 40px button height —
|
||||
// rather than carrying the filled surface it wears in the detail hero.
|
||||
.player-shell__actions ::ng-deep app-vod-sources-chip .sources-chip {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--mat-sys-outline, rgb(255 255 255 / 24%));
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease;
|
||||
|
||||
&:hover {
|
||||
background: rgb(255 255 255 / 8%);
|
||||
border-color: rgb(255 255 255 / 38%);
|
||||
}
|
||||
}
|
||||
|
||||
// The viewport is the "theater stage": it spans the full content width and,
|
||||
|
||||
@@ -25,8 +25,11 @@ class StubWebPlayerViewComponent {
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
readonly timeUpdate = output<{ currentTime: number; duration: number }>();
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
readonly alternativeSourceRequested = output<string>();
|
||||
readonly playbackFailed = output<unknown>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
PlayerContentInfo,
|
||||
ResolvedPortalPlayback,
|
||||
VideoPlayer,
|
||||
type VodSourceDescriptor,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util';
|
||||
import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
|
||||
import type { PlayerMediaTitle } from '../player-controls';
|
||||
@@ -29,6 +31,7 @@ import type {
|
||||
SeriesEpisodeMetadata,
|
||||
SeriesPlaybackNavigation,
|
||||
} from './series-playback-navigation';
|
||||
import { VodSourcesChipComponent } from '@iptvnator/ui/components';
|
||||
import { UpNextRailComponent } from './up-next-rail.component';
|
||||
import type { UpNextRailItem } from './up-next-rail.util';
|
||||
|
||||
@@ -49,6 +52,7 @@ const RAIL_STAGE_GAP = 18;
|
||||
MatTooltipModule,
|
||||
TranslateModule,
|
||||
UpNextRailComponent,
|
||||
VodSourcesChipComponent,
|
||||
WebPlayerViewComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -201,6 +205,17 @@ export class PortalInlinePlayerComponent {
|
||||
}>();
|
||||
readonly streamUrlCopied = output<void>();
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
/** Alternatives offered on the playback-error screen. */
|
||||
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
|
||||
/** Every source incl. the playing one — powers the in-player picker. */
|
||||
readonly allSources = input<VodSourceDescriptor[]>([]);
|
||||
/** Formatted resume timecode shown in the picker header. */
|
||||
readonly sourcesResumeLabel = input<string | null>(null);
|
||||
readonly sourcePinRequested = output<string>();
|
||||
readonly sourceCheckRequested = output<string>();
|
||||
readonly alternativeSourceRequested = output<string>();
|
||||
/** Relayed playback failure, so hosts can offer/auto-pick a source. */
|
||||
readonly playbackFailed = output<PlaybackDiagnosticCode>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
@@ -127,6 +127,36 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (alternativeSources().length > 0) {
|
||||
<div
|
||||
class="web-player-diagnostic__alternatives"
|
||||
data-test-id="playback-alternative-sources"
|
||||
>
|
||||
<h3 class="web-player-diagnostic__alternatives-title">
|
||||
{{
|
||||
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
|
||||
| translate
|
||||
}}
|
||||
</h3>
|
||||
@for (source of visibleAlternatives(); track source.id) {
|
||||
<app-vod-source-row
|
||||
[source]="source"
|
||||
[showPin]="false"
|
||||
(playRequested)="alternativeSourceRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
@if (hiddenAlternativeCount() > 0) {
|
||||
<p class="web-player-diagnostic__alternatives-more">
|
||||
{{
|
||||
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
|
||||
| translate
|
||||
: { count: hiddenAlternativeCount() }
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (canShowExternalFallbackActions()) {
|
||||
<div
|
||||
class="web-player-diagnostic__player-actions"
|
||||
|
||||
@@ -118,6 +118,41 @@ app-html-video-player {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
// "Try another source" — the alternatives block that turns the error screen
|
||||
// into a recovery point. The overlay is always dark, so these are literals
|
||||
// rather than theme tokens, matching the rest of this diagnostic surface.
|
||||
.web-player-diagnostic__alternatives {
|
||||
width: min(420px, 100%);
|
||||
margin: 20px auto 0;
|
||||
padding: 6px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 12px;
|
||||
background: rgb(22 27 36 / 90%);
|
||||
text-align: left;
|
||||
// Scrolls itself rather than growing the whole overlay: otherwise a long
|
||||
// list pushes the error headline and message off the top of the screen,
|
||||
// which is precisely the information the user needs first.
|
||||
max-height: min(46vh, 320px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__alternatives-more {
|
||||
margin: 0;
|
||||
padding: 6px 12px 8px;
|
||||
color: rgb(255 255 255 / 45%);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__alternatives-title {
|
||||
margin: 0;
|
||||
padding: 8px 12px 4px;
|
||||
color: rgb(255 255 255 / 55%);
|
||||
font-size: 0.594rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(168px, 192px));
|
||||
|
||||
@@ -23,9 +23,14 @@ import {
|
||||
Settings,
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
type VodSourceDescriptor,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { VodSourceRowComponent } from '@iptvnator/ui/components';
|
||||
|
||||
/** How many recovery options the error screen shows before it stops helping. */
|
||||
const ERROR_SCREEN_ALTERNATIVES = 5;
|
||||
import { ArtPlayerComponent } from '../art-player/art-player.component';
|
||||
import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component';
|
||||
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
} from '../player-controls';
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
type PlaybackDiagnosticCode,
|
||||
type PlaybackFallbackRequest,
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from '../playback-diagnostics/playback-diagnostics.util';
|
||||
@@ -73,6 +79,7 @@ function resolveWebPlayerSharedControls(): boolean {
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
VjsPlayerComponent,
|
||||
VodSourceRowComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -102,6 +109,31 @@ export class WebPlayerViewComponent {
|
||||
duration: number;
|
||||
}>();
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
/**
|
||||
* Alternative sources offered on the error screen, turning a dead end into
|
||||
* a recovery point. Empty (the default) keeps the overlay exactly as it
|
||||
* was for every non-multi-source host.
|
||||
*/
|
||||
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
|
||||
readonly alternativeSourceRequested = output<string>();
|
||||
/**
|
||||
* A playback failure the host may be able to recover from by switching
|
||||
* source. Emitted alongside showing the overlay, never instead of it: if
|
||||
* the host does nothing, the user still sees the honest error.
|
||||
*/
|
||||
readonly playbackFailed = output<PlaybackDiagnosticCode>();
|
||||
|
||||
/**
|
||||
* An error screen is a recovery point, not a catalogue. Offering fifty
|
||||
* options here is worse than offering the best few — the full list stays
|
||||
* one click away behind the player's own sources button.
|
||||
*/
|
||||
readonly visibleAlternatives = computed(() =>
|
||||
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
|
||||
);
|
||||
readonly hiddenAlternativeCount = computed(() =>
|
||||
Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES)
|
||||
);
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
@@ -264,6 +296,9 @@ export class WebPlayerViewComponent {
|
||||
}
|
||||
|
||||
this.playbackDiagnostic.set(issue);
|
||||
if (issue) {
|
||||
this.playbackFailed.emit(issue.code);
|
||||
}
|
||||
}
|
||||
|
||||
requestExternalFallback(player: ExternalPlayerName): void {
|
||||
|
||||
Reference in new issue
Block a user