mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
feat(playback): switch temporarily to recommended players
This commit is contained in:
1 parent
5e489b537e
commit
051afe5c21
36 files changed
+2019
-1543
No files matched your search
@@ -29,8 +29,7 @@ describe('mpegts.js playback evidence', () => {
|
||||
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
|
||||
});
|
||||
expect(mpegts.ErrorDetails).toEqual({
|
||||
NETWORK_EXCEPTION:
|
||||
MpegTsPlaybackEngineDetails.NetworkException,
|
||||
NETWORK_EXCEPTION: MpegTsPlaybackEngineDetails.NetworkException,
|
||||
NETWORK_STATUS_CODE_INVALID:
|
||||
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
|
||||
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
|
||||
@@ -46,47 +45,25 @@ describe('mpegts.js playback evidence', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'NetworkError',
|
||||
'HttpStatusCodeInvalid',
|
||||
'loader',
|
||||
'http',
|
||||
],
|
||||
['NetworkError', 'HttpStatusCodeInvalid', 'loader', 'http'],
|
||||
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
|
||||
['NetworkError', 'Exception', 'loader', 'network'],
|
||||
[
|
||||
'NetworkError',
|
||||
'UnrecoverableEarlyEof',
|
||||
'loader',
|
||||
'truncated-stream',
|
||||
],
|
||||
['NetworkError', 'UnrecoverableEarlyEof', 'loader', 'truncated-stream'],
|
||||
['MediaError', 'FormatError', 'demux', 'format'],
|
||||
['MediaError', 'FormatUnsupported', 'demux', 'format'],
|
||||
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
|
||||
[
|
||||
'MediaError',
|
||||
'MediaMSEError',
|
||||
'media-source',
|
||||
'media-source',
|
||||
],
|
||||
])(
|
||||
'maps %s + %s to %s/%s',
|
||||
(engineType, engineDetails, stage, failure) => {
|
||||
expect(
|
||||
createMpegTsPlaybackEvidence(
|
||||
engineType,
|
||||
engineDetails,
|
||||
undefined
|
||||
)
|
||||
).toEqual({
|
||||
engineType,
|
||||
engineDetails,
|
||||
disposition: 'terminal',
|
||||
stage,
|
||||
failure,
|
||||
});
|
||||
}
|
||||
);
|
||||
['MediaError', 'MediaMSEError', 'media-source', 'media-source'],
|
||||
])('maps %s + %s to %s/%s', (engineType, engineDetails, stage, failure) => {
|
||||
expect(
|
||||
createMpegTsPlaybackEvidence(engineType, engineDetails, undefined)
|
||||
).toEqual({
|
||||
engineType,
|
||||
engineDetails,
|
||||
disposition: 'terminal',
|
||||
stage,
|
||||
failure,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['OtherError', 'CodecUnsupported'],
|
||||
@@ -96,15 +73,18 @@ describe('mpegts.js playback evidence', () => {
|
||||
['NetworkError', 'httpstatuscodeinvalid'],
|
||||
[{ type: 'NetworkError' }, 'Exception'],
|
||||
['MediaError', { details: 'FormatError' }],
|
||||
])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => {
|
||||
const evidence = createMpegTsPlaybackEvidence(type, details, {
|
||||
code: 503,
|
||||
});
|
||||
])(
|
||||
'keeps inconsistent or malformed %p + %p evidence unknown',
|
||||
(type, details) => {
|
||||
const evidence = createMpegTsPlaybackEvidence(type, details, {
|
||||
code: 503,
|
||||
});
|
||||
|
||||
expect(evidence.stage).toBe('unknown');
|
||||
expect(evidence.failure).toBe('unknown');
|
||||
expect(evidence.httpStatus).toBeUndefined();
|
||||
});
|
||||
expect(evidence.stage).toBe('unknown');
|
||||
expect(evidence.failure).toBe('unknown');
|
||||
expect(evidence.httpStatus).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([399, 600, 404.5, '404', null, undefined])(
|
||||
'rejects invalid HTTP status %p',
|
||||
@@ -163,45 +143,24 @@ describe('mpegts.js playback evidence', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false],
|
||||
['NetworkError', 'ConnectingTimeout', 'network-error', false],
|
||||
['NetworkError', 'Exception', 'network-error', false],
|
||||
[
|
||||
'NetworkError',
|
||||
'UnrecoverableEarlyEof',
|
||||
'media-decode-error',
|
||||
true,
|
||||
],
|
||||
['MediaError', 'FormatError', 'media-decode-error', true],
|
||||
[
|
||||
'MediaError',
|
||||
'FormatUnsupported',
|
||||
'unsupported-container',
|
||||
true,
|
||||
],
|
||||
[
|
||||
'MediaError',
|
||||
'CodecUnsupported',
|
||||
'unsupported-codec',
|
||||
true,
|
||||
],
|
||||
['MediaError', 'MediaMSEError', 'media-decode-error', true],
|
||||
['OtherError', 'Exception', 'unknown-playback-error', false],
|
||||
])(
|
||||
'classifies %s + %s as %s with fallback=%s',
|
||||
(type, details, code, externalFallbackRecommended) => {
|
||||
const issue = classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(type, details, undefined),
|
||||
METADATA
|
||||
);
|
||||
['NetworkError', 'HttpStatusCodeInvalid', 'network-error'],
|
||||
['NetworkError', 'ConnectingTimeout', 'network-error'],
|
||||
['NetworkError', 'Exception', 'network-error'],
|
||||
['NetworkError', 'UnrecoverableEarlyEof', 'media-decode-error'],
|
||||
['MediaError', 'FormatError', 'media-decode-error'],
|
||||
['MediaError', 'FormatUnsupported', 'unsupported-container'],
|
||||
['MediaError', 'CodecUnsupported', 'unsupported-codec'],
|
||||
['MediaError', 'MediaMSEError', 'media-decode-error'],
|
||||
['OtherError', 'Exception', 'unknown-playback-error'],
|
||||
])('classifies %s + %s as %s', (type, details, code) => {
|
||||
const issue = classifyMpegTsPlaybackIssue(
|
||||
createMpegTsPlaybackEvidence(type, details, undefined),
|
||||
METADATA
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(code);
|
||||
expect(issue.externalFallbackRecommended).toBe(
|
||||
externalFallbackRecommended
|
||||
);
|
||||
expect(issue.details).toBeUndefined();
|
||||
}
|
||||
);
|
||||
expect(issue.code).toBe(code);
|
||||
expect(issue.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves an exact HTTP failure without retaining provider details', () => {
|
||||
const secret = 'mpegts-http-secret';
|
||||
@@ -230,7 +189,6 @@ describe('mpegts.js playback evidence', () => {
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
}),
|
||||
externalFallbackRecommended: false,
|
||||
})
|
||||
);
|
||||
expect(issue.details).toBeUndefined();
|
||||
|
||||
@@ -264,7 +264,6 @@ export interface PlaybackDiagnostic {
|
||||
readonly hls?: HlsPlaybackEvidence;
|
||||
readonly mpegTs?: MpegTsPlaybackEvidence;
|
||||
readonly shaka?: ShakaPlaybackEvidence;
|
||||
readonly externalFallbackRecommended: boolean;
|
||||
}
|
||||
|
||||
export interface PlaybackFallbackRequest {
|
||||
|
||||
@@ -90,7 +90,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
||||
expect(issue?.audioCodecs).toEqual(['ac-3']);
|
||||
expect(issue?.videoCodecs).toEqual(['avc1.64001f']);
|
||||
});
|
||||
@@ -111,7 +110,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies native decode and unsupported source errors without using network wording', () => {
|
||||
@@ -133,11 +131,9 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
expect(decodeIssue.externalFallbackRecommended).toBe(true);
|
||||
expect(unsupportedSourceIssue.code).toBe(
|
||||
PlaybackDiagnosticCode.UnsupportedContainer
|
||||
);
|
||||
expect(unsupportedSourceIssue.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies MIME-only unsupported containers as container diagnostics', () => {
|
||||
@@ -166,7 +162,6 @@ describe('playback diagnostics', () => {
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.container).toBe('mp2t');
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies native HTTP source failures from a safe status and error type', () => {
|
||||
@@ -186,7 +181,6 @@ describe('playback diagnostics', () => {
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue.httpStatus).toBe(404);
|
||||
expect(issue.nativeErrorType).toBe('networkrequestfailed');
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies exact VHS network evidence without using provider text', () => {
|
||||
@@ -210,7 +204,6 @@ describe('playback diagnostics', () => {
|
||||
source: 'vhs',
|
||||
httpStatus: undefined,
|
||||
nativeErrorMessage: undefined,
|
||||
externalFallbackRecommended: false,
|
||||
vhs: {
|
||||
engineType: 'networkrequestfailed',
|
||||
mediaErrorCode: 4,
|
||||
@@ -238,7 +231,6 @@ describe('playback diagnostics', () => {
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue.source).toBe('vhs');
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
expect(issue.nativeErrorMessage).toBeUndefined();
|
||||
expect(issue.vhs).toEqual({
|
||||
engineType: 'streamingfailedtodecryptsegment',
|
||||
@@ -280,7 +272,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(vhsIssue.externalFallbackRecommended).toBe(false);
|
||||
expect(vhsIssue.nativeErrorMessage).toBeUndefined();
|
||||
expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
});
|
||||
@@ -343,7 +334,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
expect(issue.httpStatus).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -364,7 +354,6 @@ describe('playback diagnostics', () => {
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue.httpStatus).toBeUndefined();
|
||||
expect(issue.nativeErrorType).toBeUndefined();
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -433,7 +422,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('retains structured HLS HTTP and stage evidence', () => {
|
||||
@@ -464,7 +452,6 @@ describe('playback diagnostics', () => {
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
}),
|
||||
externalFallbackRecommended: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -504,7 +491,6 @@ describe('playback diagnostics', () => {
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue?.httpStatus).toBeUndefined();
|
||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies exact HLS decrypt evidence as DRM or encryption', () => {
|
||||
@@ -523,7 +509,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
expect(issue?.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies exact HLS key load failures as network errors', () => {
|
||||
@@ -542,7 +527,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])(
|
||||
@@ -582,7 +566,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
|
||||
expect(issue?.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies native CORS failures as browser access errors', () => {
|
||||
@@ -599,7 +582,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue.code).toBe('browser-access-error');
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps opaque native network failures generic when the browser hides access details', () => {
|
||||
@@ -615,7 +597,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('does not guess mpegts browser access from exception messages', () => {
|
||||
@@ -632,7 +613,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
expect(JSON.stringify(issue)).not.toContain(secret);
|
||||
});
|
||||
|
||||
@@ -651,7 +631,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
expect(issue.mpegTs?.failure).toBe('truncated-stream');
|
||||
expect(issue.details).toBeUndefined();
|
||||
});
|
||||
@@ -671,7 +650,6 @@ describe('playback diagnostics', () => {
|
||||
);
|
||||
|
||||
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
|
||||
expect(issue.externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers stream extension query metadata over web script path extensions', () => {
|
||||
|
||||
@@ -209,9 +209,6 @@ export function createPlaybackDiagnostic(options: {
|
||||
readonly hls?: HlsPlaybackEvidence;
|
||||
readonly mpegTs?: MpegTsPlaybackEvidence;
|
||||
readonly shaka?: ShakaPlaybackEvidence;
|
||||
/** Overrides the code-derived recommendation, e.g. when external players
|
||||
* are known to be unable to handle the stream either. */
|
||||
readonly externalFallbackRecommended?: boolean;
|
||||
}): PlaybackDiagnostic {
|
||||
const {
|
||||
code,
|
||||
@@ -246,9 +243,6 @@ export function createPlaybackDiagnostic(options: {
|
||||
hls,
|
||||
mpegTs,
|
||||
shaka,
|
||||
externalFallbackRecommended:
|
||||
options.externalFallbackRecommended ??
|
||||
isExternalFallbackRecommended(code),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,16 +316,6 @@ function getNativeErrorType(errorType: unknown): string | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean {
|
||||
return (
|
||||
code === DiagnosticCode.UnsupportedContainer ||
|
||||
code === DiagnosticCode.UnsupportedCodec ||
|
||||
code === DiagnosticCode.MediaDecodeError ||
|
||||
code === DiagnosticCode.BrowserAccessError ||
|
||||
code === DiagnosticCode.DrmOrEncryption
|
||||
);
|
||||
}
|
||||
|
||||
function hasCodecs(metadata: PlaybackSourceMetadata): boolean {
|
||||
return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0;
|
||||
}
|
||||
@@ -216,8 +216,5 @@ describe('createUnsupportedDrmDiagnostic', () => {
|
||||
expect(issue.details).toBe('Unsupported DRM license configuration');
|
||||
expect(JSON.stringify(issue)).not.toContain(secret);
|
||||
expect(JSON.stringify(issue)).not.toContain('provider.example');
|
||||
// MPV/VLC cannot receive KODIPROP license config, so the diagnostic
|
||||
// must not offer them as a fallback.
|
||||
expect(issue.externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -88,9 +88,6 @@ export function createUnsupportedDrmDiagnostic(
|
||||
source: DiagnosticSource.Shaka,
|
||||
metadata,
|
||||
details: 'Unsupported DRM license configuration',
|
||||
// External MPV/VLC cannot receive the KODIPROP license config either,
|
||||
// so offering them as a fallback would just fail differently.
|
||||
externalFallbackRecommended: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ function diagnostic(code: PlaybackDiagnosticCode): PlaybackDiagnostic {
|
||||
container: '',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ function diagnostic(
|
||||
container: '',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
externalFallbackRecommended: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -590,7 +590,6 @@ describe('VideoPlayerComponent', () => {
|
||||
container: 'm3u8',
|
||||
audioCodecs: ['ac-3'],
|
||||
videoCodecs: ['avc1.64001f'],
|
||||
externalFallbackRecommended: true,
|
||||
},
|
||||
} satisfies PlaybackFallbackRequest);
|
||||
|
||||
|
||||
-1
@@ -619,7 +619,6 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
audioCodecs: [],
|
||||
externalFallbackRecommended: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
-1
@@ -556,7 +556,6 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
audioCodecs: [],
|
||||
externalFallbackRecommended: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ describe('ArtPlayerComponent', () => {
|
||||
code: 'unsupported-container',
|
||||
source: 'native',
|
||||
sourceUrl: 'https://example.com/archive/movie.mkv',
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -124,7 +123,6 @@ describe('ArtPlayerComponent', () => {
|
||||
code: 'unsupported-codec',
|
||||
source: 'hls',
|
||||
sourceUrl: 'https://example.com/live/playlist.m3u8',
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -256,7 +254,6 @@ describe('ArtPlayerComponent', () => {
|
||||
engineDetails: 'CodecUnsupported',
|
||||
failure: 'codec',
|
||||
}),
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(issues)).not.toContain(secret);
|
||||
|
||||
-1
@@ -154,7 +154,6 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
}),
|
||||
externalFallbackRecommended: false,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(issues)).not.toContain(secret);
|
||||
|
||||
@@ -203,7 +203,6 @@ describe('HtmlVideoPlayerComponent', () => {
|
||||
code: 'unsupported-container',
|
||||
source: 'native',
|
||||
sourceUrl: 'http://test.ts',
|
||||
externalFallbackRecommended: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
-1
@@ -26,7 +26,6 @@ const DIAGNOSTIC: PlaybackDiagnostic = {
|
||||
player: 'videojs',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
externalFallbackRecommended: true,
|
||||
};
|
||||
|
||||
const PLAYBACK: ResolvedPortalPlayback = {
|
||||
|
||||
-1
@@ -21,7 +21,6 @@ describe('playback diagnostic view formatters', () => {
|
||||
player: 'videojs',
|
||||
audioCodecs: ['ac-3'],
|
||||
videoCodecs: ['hvc1.1.6.L93.B0'],
|
||||
externalFallbackRecommended: true,
|
||||
};
|
||||
|
||||
it('preserves existing title, metadata, codec and detail formatting', () => {
|
||||
|
||||
@@ -160,7 +160,6 @@ describe('PortalInlinePlayerComponent', () => {
|
||||
mimeType: '',
|
||||
videoCodecs: [],
|
||||
audioCodecs: [],
|
||||
externalFallbackRecommended: true,
|
||||
},
|
||||
};
|
||||
let forwarded: PlaybackFallbackRequest | undefined;
|
||||
|
||||
@@ -116,8 +116,6 @@ describe('ShakaVideoSession', () => {
|
||||
stage: 'unknown',
|
||||
failure: 'drm',
|
||||
});
|
||||
// Without KODIPROP config the DRM hint may still help externally.
|
||||
expect(issues[0].externalFallbackRecommended).toBe(true);
|
||||
// Critical errors end playback: the dead engine must be torn down.
|
||||
expect(player.destroyCount).toBe(1);
|
||||
expect(session.getPlayer()).toBeNull();
|
||||
@@ -142,7 +140,7 @@ describe('ShakaVideoSession', () => {
|
||||
expect(session.getPlayer()).toBe(player);
|
||||
});
|
||||
|
||||
it('drops the external-fallback hint for every failure on ClearKey channels', async () => {
|
||||
it('emits structured failures without owning ClearKey recovery policy', async () => {
|
||||
const environment = createFakeShakaEnvironment();
|
||||
const session = createSession(environment);
|
||||
session.start(video, 'http://example.com/enc.mpd', {
|
||||
@@ -160,12 +158,6 @@ describe('ShakaVideoSession', () => {
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
|
||||
// MPV/VLC never receive the KODIPROP license config, so they are not
|
||||
// offered as a fallback for key failures on ClearKey channels.
|
||||
expect(issues[0].externalFallbackRecommended).toBe(false);
|
||||
|
||||
// Non-DRM failures (media/codec/manifest/…) are equally unsolvable
|
||||
// externally while the stream itself stays encrypted.
|
||||
session.start(video, 'http://example.com/enc.mpd', {
|
||||
licenseType: 'clearkey',
|
||||
supported: true,
|
||||
@@ -180,7 +172,6 @@ describe('ShakaVideoSession', () => {
|
||||
|
||||
expect(issues).toHaveLength(2);
|
||||
expect(issues[1].code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
|
||||
expect(issues[1].externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('emits a diagnostic and tears the engine down when load rejects', async () => {
|
||||
@@ -497,7 +488,7 @@ describe('ShakaVideoSession', () => {
|
||||
expect(player.selectTextTrackCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps external fallback available when the browser cannot run Shaka for clear DASH', async () => {
|
||||
it('emits an unknown diagnostic when the browser cannot run Shaka for clear DASH', async () => {
|
||||
const environment = createFakeShakaEnvironment();
|
||||
environment.browserSupported = false;
|
||||
const session = createSession(environment);
|
||||
@@ -517,10 +508,9 @@ describe('ShakaVideoSession', () => {
|
||||
stage: 'unknown',
|
||||
failure: 'unknown',
|
||||
});
|
||||
expect(issues[0].externalFallbackRecommended).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps external fallback unavailable when browser support fails for KODIPROP DRM', async () => {
|
||||
it('emits the same unknown diagnostic when browser support fails with DRM', async () => {
|
||||
const environment = createFakeShakaEnvironment();
|
||||
environment.browserSupported = false;
|
||||
const session = createSession(environment);
|
||||
@@ -536,7 +526,6 @@ describe('ShakaVideoSession', () => {
|
||||
expect(issues[0].code).toBe(
|
||||
PlaybackDiagnosticCode.UnknownPlaybackError
|
||||
);
|
||||
expect(issues[0].externalFallbackRecommended).toBe(false);
|
||||
});
|
||||
|
||||
it('destroy tears down the engine and blocks later starts', async () => {
|
||||
|
||||
@@ -139,18 +139,13 @@ export class ShakaVideoSession {
|
||||
}
|
||||
|
||||
if (!module.Player.isBrowserSupported()) {
|
||||
this.emitTerminalIfCurrent(
|
||||
generation,
|
||||
null,
|
||||
url,
|
||||
drm === undefined
|
||||
);
|
||||
this.emitTerminalIfCurrent(generation, null, url);
|
||||
return;
|
||||
}
|
||||
|
||||
const player = new module.Player();
|
||||
this.player = player;
|
||||
this.bindPlayerListeners(player, generation, url, drm !== undefined);
|
||||
this.bindPlayerListeners(player, generation, url);
|
||||
|
||||
if (drm?.clearKeys) {
|
||||
player.configure({ drm: { clearKeys: drm.clearKeys } });
|
||||
@@ -160,13 +155,7 @@ export class ShakaVideoSession {
|
||||
await player.attach(video);
|
||||
await player.load(url);
|
||||
} catch (error: unknown) {
|
||||
this.handleLoadFailure(
|
||||
generation,
|
||||
player,
|
||||
url,
|
||||
error,
|
||||
drm !== undefined
|
||||
);
|
||||
this.handleLoadFailure(generation, player, url, error);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -202,8 +191,7 @@ export class ShakaVideoSession {
|
||||
generation: number,
|
||||
player: ShakaPlayerLike,
|
||||
url: string,
|
||||
error: unknown,
|
||||
drmProvided: boolean
|
||||
error: unknown
|
||||
): void {
|
||||
const shakaError = asShakaError(error);
|
||||
if (
|
||||
@@ -220,36 +208,17 @@ export class ShakaVideoSession {
|
||||
ShakaDisposition.Terminal
|
||||
);
|
||||
if (issue) {
|
||||
this.config.emitPlaybackIssue(
|
||||
this.withoutUnusableDrmFallback(issue, drmProvided)
|
||||
);
|
||||
this.config.emitPlaybackIssue(issue);
|
||||
}
|
||||
// Never leave a non-functional engine attached to the media element
|
||||
// or exposed to the shared-controls bridge.
|
||||
this.beginPlayerTeardown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels carrying KODIPROP ClearKey config cannot be handed to MPV/VLC
|
||||
* at all — external players never receive the license config, so the
|
||||
* encrypted stream fails there regardless of what broke inline (DRM,
|
||||
* manifest, codec, network, …). Suppress the fallback hint entirely.
|
||||
*/
|
||||
private withoutUnusableDrmFallback(
|
||||
issue: PlaybackDiagnostic,
|
||||
drmProvided: boolean
|
||||
): PlaybackDiagnostic {
|
||||
if (!drmProvided || !issue.externalFallbackRecommended) {
|
||||
return issue;
|
||||
}
|
||||
return { ...issue, externalFallbackRecommended: false };
|
||||
}
|
||||
|
||||
private bindPlayerListeners(
|
||||
player: ShakaPlayerLike,
|
||||
generation: number,
|
||||
url: string,
|
||||
drmProvided: boolean
|
||||
url: string
|
||||
): void {
|
||||
const errorListener = (event: Event): void => {
|
||||
if (this.isStale(generation) || this.player !== player) {
|
||||
@@ -272,9 +241,7 @@ export class ShakaVideoSession {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.emitPlaybackIssue(
|
||||
this.withoutUnusableDrmFallback(issue, drmProvided)
|
||||
);
|
||||
this.config.emitPlaybackIssue(issue);
|
||||
// Critical errors end playback; never leave the dead engine
|
||||
// attached or exposed to the shared-controls bridge.
|
||||
this.beginPlayerTeardown();
|
||||
@@ -366,8 +333,7 @@ export class ShakaVideoSession {
|
||||
private emitTerminalIfCurrent(
|
||||
generation: number,
|
||||
error: Partial<ShakaErrorLike> | null,
|
||||
url: string,
|
||||
externalFallbackRecommended?: boolean
|
||||
url: string
|
||||
): void {
|
||||
const issue = classifyShakaPlaybackIssue(
|
||||
error,
|
||||
@@ -375,12 +341,7 @@ export class ShakaVideoSession {
|
||||
ShakaDisposition.Terminal
|
||||
);
|
||||
if (issue) {
|
||||
this.emitIfCurrent(
|
||||
generation,
|
||||
externalFallbackRecommended === undefined
|
||||
? issue
|
||||
: { ...issue, externalFallbackRecommended }
|
||||
);
|
||||
this.emitIfCurrent(generation, issue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,6 @@ describe('VjsMpegTsSession', () => {
|
||||
failure: 'http',
|
||||
httpStatus: 503,
|
||||
}),
|
||||
externalFallbackRecommended: false,
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
|
||||
@@ -167,7 +166,6 @@ describe('VjsMpegTsSession', () => {
|
||||
expect.objectContaining({
|
||||
code: 'network-error',
|
||||
mpegTs: expect.objectContaining({ failure: 'network' }),
|
||||
externalFallbackRecommended: false,
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain(
|
||||
|
||||
@@ -196,7 +196,6 @@ describe('VjsPlayerComponent', () => {
|
||||
code: 'unsupported-container',
|
||||
source: 'native',
|
||||
sourceUrl: 'https://example.test/archive/movie.mkv',
|
||||
externalFallbackRecommended: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -236,7 +235,6 @@ describe('VjsPlayerComponent', () => {
|
||||
stage: 'unknown',
|
||||
httpStatus: 404,
|
||||
},
|
||||
externalFallbackRecommended: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -272,7 +270,6 @@ describe('VjsPlayerComponent', () => {
|
||||
disposition: 'terminal',
|
||||
stage: 'unknown',
|
||||
},
|
||||
externalFallbackRecommended: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -300,7 +297,6 @@ describe('VjsPlayerComponent', () => {
|
||||
code: 'media-decode-error',
|
||||
source: 'native',
|
||||
nativeErrorMessage: 'Native media decode failed',
|
||||
externalFallbackRecommended: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -170,6 +170,14 @@ describe('PlaybackRecoverySession', () => {
|
||||
|
||||
expect(Object.isFrozen(binding)).toBe(true);
|
||||
expect(activeBinding).toBe(binding);
|
||||
expect(activeBinding).toEqual({
|
||||
generation: 1,
|
||||
target: InlinePlaybackPlayer.VideoJs,
|
||||
});
|
||||
expect(Object.keys(activeBinding ?? {})).toEqual([
|
||||
'generation',
|
||||
'target',
|
||||
]);
|
||||
expect(Object.isFrozen(activeBinding)).toBe(true);
|
||||
expect('set' in session.attemptedTargets).toBe(false);
|
||||
expect('set' in session.temporaryPlayerOverride).toBe(false);
|
||||
|
||||
@@ -62,6 +62,11 @@ export class PlaybackRecoverySession {
|
||||
this.invalidatePlaybackBinding();
|
||||
}
|
||||
|
||||
endPlayback(): void {
|
||||
this.switchPendingState.set(false);
|
||||
this.invalidatePlaybackBinding();
|
||||
}
|
||||
|
||||
recordFailure(binding: PlaybackBinding): boolean {
|
||||
if (!this.accepts(binding)) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
import {
|
||||
type PlaybackBinding,
|
||||
PlaybackRecoverySession,
|
||||
} from './playback-recovery-session';
|
||||
import type { WebPlayerApplicationToken } from './web-player-application-state';
|
||||
import {
|
||||
createVideoJsOptions,
|
||||
createWebPlayerChannel,
|
||||
} from './web-player-playback-state';
|
||||
|
||||
export interface WebPlayerApplicationHandoff {
|
||||
readonly channel: ReturnType<typeof createWebPlayerChannel>;
|
||||
readonly vjsOptions: VideoPlayerOptions;
|
||||
}
|
||||
|
||||
interface WebPlayerApplicationOwnership {
|
||||
readonly binding: PlaybackBinding;
|
||||
readonly token: WebPlayerApplicationToken;
|
||||
}
|
||||
|
||||
export class WebPlayerApplicationHandoffCoordinator {
|
||||
private headerScopeStreamUrl: string | null = null;
|
||||
private activeOwnership: WebPlayerApplicationOwnership | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly streamHeaders: ElectronStreamHeadersService,
|
||||
private readonly recoverySession: PlaybackRecoverySession
|
||||
) {}
|
||||
|
||||
apply(
|
||||
playback: ResolvedPortalPlayback,
|
||||
isLive: boolean,
|
||||
reloadToken: number,
|
||||
binding: PlaybackBinding,
|
||||
token: WebPlayerApplicationToken,
|
||||
currentToken: () => WebPlayerApplicationToken,
|
||||
accept: (handoff: WebPlayerApplicationHandoff) => void
|
||||
): void {
|
||||
this.activeOwnership = Object.freeze({ binding, token });
|
||||
const headerSync = this.streamHeaders.apply(playback);
|
||||
this.headerScopeStreamUrl = playback.streamUrl;
|
||||
const acceptSource = (): void => {
|
||||
const options = createVideoJsOptions({
|
||||
streamUrl: playback.streamUrl,
|
||||
isLive,
|
||||
reloadToken,
|
||||
});
|
||||
accept({
|
||||
channel: createWebPlayerChannel(playback),
|
||||
vjsOptions: {
|
||||
...options,
|
||||
sources: options.sources.map((source) => ({ ...source })),
|
||||
},
|
||||
});
|
||||
};
|
||||
if (!headerSync) {
|
||||
acceptSource();
|
||||
return;
|
||||
}
|
||||
const handOff = (): void => {
|
||||
if (this.ownsCurrentApplication(binding, token, currentToken)) {
|
||||
acceptSource();
|
||||
}
|
||||
};
|
||||
void headerSync.then(
|
||||
(stillCurrent) => {
|
||||
if (stillCurrent) {
|
||||
handOff();
|
||||
} else if (
|
||||
this.ownsCurrentApplication(binding, token, currentToken)
|
||||
) {
|
||||
this.recoverySession.settle(binding);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (this.ownsCurrentApplication(binding, token, currentToken)) {
|
||||
this.recoverySession.settle(binding);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
owns(binding: PlaybackBinding, token: WebPlayerApplicationToken): boolean {
|
||||
if (!this.recoverySession.accepts(binding)) {
|
||||
return false;
|
||||
}
|
||||
const active = this.activeOwnership;
|
||||
return active?.binding === binding && active.token === token;
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.activeOwnership = null;
|
||||
}
|
||||
|
||||
private ownsCurrentApplication(
|
||||
binding: PlaybackBinding,
|
||||
token: WebPlayerApplicationToken,
|
||||
currentToken: () => WebPlayerApplicationToken
|
||||
): boolean {
|
||||
return this.owns(binding, token) && currentToken() === token;
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.invalidate();
|
||||
this.streamHeaders.clear(this.headerScopeStreamUrl);
|
||||
this.headerScopeStreamUrl = null;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.recoverySession.endPlayback();
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { computed, type Signal } from '@angular/core';
|
||||
import type {
|
||||
ResolvedPortalPlayback,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
resolveWebPlayerIsLive,
|
||||
resolveWebPlayerPlayback,
|
||||
} from './web-player-playback-state';
|
||||
|
||||
export type WebPlayerApplicationToken = symbol;
|
||||
|
||||
export function createWebPlayerApplicationState(sources: {
|
||||
readonly playback: Signal<ResolvedPortalPlayback | null>;
|
||||
readonly streamUrl: Signal<string>;
|
||||
readonly title: Signal<string>;
|
||||
readonly startTime: Signal<number>;
|
||||
readonly selectedPlayer: Signal<VideoPlayer>;
|
||||
readonly reloadToken: Signal<number>;
|
||||
}): {
|
||||
readonly playback: Signal<ResolvedPortalPlayback>;
|
||||
readonly isLive: Signal<boolean>;
|
||||
readonly token: Signal<WebPlayerApplicationToken>;
|
||||
} {
|
||||
const playback = computed(() => {
|
||||
const explicit = sources.playback();
|
||||
return (
|
||||
explicit ??
|
||||
resolveWebPlayerPlayback({
|
||||
playback: null,
|
||||
streamUrl: sources.streamUrl(),
|
||||
title: sources.title(),
|
||||
startTime: sources.startTime(),
|
||||
})
|
||||
);
|
||||
});
|
||||
const isLive = computed(() => {
|
||||
const explicit = sources.playback();
|
||||
return explicit ? resolveWebPlayerIsLive(explicit) : true;
|
||||
});
|
||||
const token = computed<WebPlayerApplicationToken>(() => {
|
||||
if (sources.playback() === null) {
|
||||
void sources.streamUrl();
|
||||
void sources.startTime();
|
||||
}
|
||||
void isLive();
|
||||
void sources.selectedPlayer();
|
||||
void sources.reloadToken();
|
||||
return Symbol();
|
||||
});
|
||||
return { playback, isLive, token };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { type ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
createVideoJsOptions,
|
||||
createWebPlayerChannel,
|
||||
resolveWebPlayerIsLive,
|
||||
resolveWebPlayerMediaTitle,
|
||||
resolveWebPlayerPlayback,
|
||||
} from './web-player-playback-state';
|
||||
|
||||
describe('web player playback state', () => {
|
||||
it('preserves an existing resolved playback payload', () => {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://provider.example/movie/7.ts',
|
||||
title: 'Movie Seven',
|
||||
startTime: 42,
|
||||
headers: { Authorization: 'Bearer secret' },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveWebPlayerPlayback({
|
||||
playback,
|
||||
streamUrl: 'https://fallback.example/stream',
|
||||
title: 'Fallback',
|
||||
startTime: 0,
|
||||
})
|
||||
).toBe(playback);
|
||||
});
|
||||
|
||||
it('constructs fallback playback and uses the URL for a missing title', () => {
|
||||
expect(
|
||||
resolveWebPlayerPlayback({
|
||||
playback: null,
|
||||
streamUrl: 'https://example.com/live/1.ts',
|
||||
title: '',
|
||||
startTime: 19,
|
||||
})
|
||||
).toEqual({
|
||||
streamUrl: 'https://example.com/live/1.ts',
|
||||
title: 'https://example.com/live/1.ts',
|
||||
startTime: 19,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses explicit live metadata before inferring VOD from content info', () => {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.com/movie.ts',
|
||||
title: 'Movie',
|
||||
isLive: true,
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 7,
|
||||
contentType: 'vod',
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveWebPlayerIsLive(playback)).toBe(true);
|
||||
expect(resolveWebPlayerIsLive({ ...playback, isLive: undefined })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an explicit media title, playback title, and raw URL fallback', () => {
|
||||
const playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.com/movie.ts',
|
||||
title: 'Movie',
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveWebPlayerMediaTitle(
|
||||
{ primary: 'Series', secondary: 'S01E02' },
|
||||
playback
|
||||
)
|
||||
).toEqual({ primary: 'Series', secondary: 'S01E02' });
|
||||
expect(resolveWebPlayerMediaTitle(null, playback)).toEqual({
|
||||
primary: 'Movie',
|
||||
secondary: null,
|
||||
});
|
||||
expect(
|
||||
resolveWebPlayerMediaTitle(null, {
|
||||
...playback,
|
||||
title: playback.streamUrl,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers explicit HTTP metadata when constructing a channel', () => {
|
||||
expect(
|
||||
createWebPlayerChannel({
|
||||
streamUrl: 'https://example.com/live/channel.m3u8',
|
||||
title: 'Header Locked Channel',
|
||||
thumbnail: 'https://example.com/logo.png',
|
||||
userAgent: 'ProviderAgent/1.0',
|
||||
referer: 'https://provider.example/ref',
|
||||
origin: 'https://provider.example',
|
||||
headers: {
|
||||
'User-Agent': 'IgnoredAgent/1.0',
|
||||
Referer: 'https://ignored.example/ref',
|
||||
Origin: 'https://ignored.example',
|
||||
},
|
||||
drm: {
|
||||
licenseType: 'clearkey',
|
||||
supported: true,
|
||||
clearKeys: { '0011': '2233' },
|
||||
},
|
||||
})
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'https://example.com/live/channel.m3u8',
|
||||
url: 'https://example.com/live/channel.m3u8',
|
||||
name: 'Header Locked Channel',
|
||||
tvg: expect.objectContaining({
|
||||
name: 'Header Locked Channel',
|
||||
logo: 'https://example.com/logo.png',
|
||||
}),
|
||||
http: {
|
||||
'user-agent': 'ProviderAgent/1.0',
|
||||
referrer: 'https://provider.example/ref',
|
||||
origin: 'https://provider.example',
|
||||
},
|
||||
radio: 'false',
|
||||
drm: {
|
||||
licenseType: 'clearkey',
|
||||
supported: true,
|
||||
clearKeys: { '0011': '2233' },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to case-insensitive HTTP headers', () => {
|
||||
expect(
|
||||
createWebPlayerChannel({
|
||||
streamUrl: 'https://example.com/live/channel.m3u8',
|
||||
title: '',
|
||||
headers: {
|
||||
'uSeR-aGeNt': 'HeaderAgent/1.0',
|
||||
rEfErEr: 'https://headers.example/ref',
|
||||
ORIGIN: 'https://headers.example',
|
||||
},
|
||||
}).http
|
||||
).toEqual({
|
||||
'user-agent': 'HeaderAgent/1.0',
|
||||
referrer: 'https://headers.example/ref',
|
||||
origin: 'https://headers.example',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['https://example.com/live/index.m3u8', 'application/x-mpegURL'],
|
||||
[
|
||||
'https://example.com/play?extension=m3u8&token=signed',
|
||||
'application/x-mpegURL',
|
||||
],
|
||||
['https://example.com/live/channel.ts', 'video/mp2t'],
|
||||
['https://example.com/live.php?stream=123', 'video/mp2t'],
|
||||
['https://example.com/archive/movie.mkv', 'video/matroska'],
|
||||
[
|
||||
'https://example.com/play?container=mkv&token=signed',
|
||||
'video/matroska',
|
||||
],
|
||||
['https://example.com/archive/movie.mp4', 'video/mp4'],
|
||||
])('selects the existing MIME type for %s', (streamUrl, type) => {
|
||||
expect(
|
||||
createVideoJsOptions({
|
||||
streamUrl,
|
||||
isLive: false,
|
||||
reloadToken: 3,
|
||||
})
|
||||
).toEqual({
|
||||
isLive: false,
|
||||
reloadToken: 3,
|
||||
sources: [{ src: streamUrl, type }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util';
|
||||
import type {
|
||||
Channel,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { PlayerMediaTitle } from '../player-controls';
|
||||
|
||||
export function resolveWebPlayerPlayback(options: {
|
||||
readonly playback: ResolvedPortalPlayback | null;
|
||||
readonly streamUrl: string;
|
||||
readonly title: string;
|
||||
readonly startTime: number;
|
||||
}): ResolvedPortalPlayback {
|
||||
return (
|
||||
options.playback ?? {
|
||||
streamUrl: options.streamUrl,
|
||||
title: options.title || options.streamUrl,
|
||||
startTime: options.startTime,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveWebPlayerIsLive(
|
||||
playback: ResolvedPortalPlayback
|
||||
): boolean {
|
||||
return typeof playback.isLive === 'boolean'
|
||||
? playback.isLive
|
||||
: !playback.contentInfo;
|
||||
}
|
||||
|
||||
export function resolveWebPlayerMediaTitle(
|
||||
explicit: PlayerMediaTitle | null,
|
||||
playback: ResolvedPortalPlayback
|
||||
): PlayerMediaTitle | null {
|
||||
if (explicit?.primary?.trim()) {
|
||||
return explicit;
|
||||
}
|
||||
const title = playback.title?.trim();
|
||||
return !title || title === playback.streamUrl
|
||||
? null
|
||||
: { primary: title, secondary: null };
|
||||
}
|
||||
|
||||
export function createWebPlayerChannel(
|
||||
playback: ResolvedPortalPlayback
|
||||
): Channel {
|
||||
const title = playback.title || playback.streamUrl;
|
||||
return {
|
||||
id: playback.streamUrl,
|
||||
url: playback.streamUrl,
|
||||
name: title,
|
||||
group: { title: '' },
|
||||
tvg: {
|
||||
id: '',
|
||||
name: title,
|
||||
url: '',
|
||||
logo: playback.thumbnail ?? '',
|
||||
rec: '',
|
||||
},
|
||||
http: {
|
||||
referrer:
|
||||
playback.referer ??
|
||||
getHeaderValue(playback.headers, 'Referer') ??
|
||||
'',
|
||||
'user-agent':
|
||||
playback.userAgent ??
|
||||
getHeaderValue(playback.headers, 'User-Agent') ??
|
||||
'',
|
||||
origin:
|
||||
playback.origin ??
|
||||
getHeaderValue(playback.headers, 'Origin') ??
|
||||
'',
|
||||
},
|
||||
radio: 'false',
|
||||
drm: playback.drm,
|
||||
};
|
||||
}
|
||||
|
||||
export function createVideoJsOptions(options: {
|
||||
readonly streamUrl: string;
|
||||
readonly isLive: boolean;
|
||||
readonly reloadToken: number;
|
||||
}): {
|
||||
readonly isLive: boolean;
|
||||
readonly reloadToken: number;
|
||||
readonly sources: readonly {
|
||||
readonly src: string;
|
||||
readonly type: string;
|
||||
}[];
|
||||
} {
|
||||
const extension = getPlaybackMediaExtensionFromUrl(options.streamUrl);
|
||||
const type =
|
||||
extension === 'm3u' || extension === 'm3u8'
|
||||
? 'application/x-mpegURL'
|
||||
: extension === 'ts' || !extension
|
||||
? 'video/mp2t'
|
||||
: extension === 'mkv'
|
||||
? 'video/matroska'
|
||||
: 'video/mp4';
|
||||
|
||||
return {
|
||||
isLive: options.isLive,
|
||||
reloadToken: options.reloadToken,
|
||||
sources: [{ src: options.streamUrl, type }],
|
||||
};
|
||||
}
|
||||
|
||||
function getHeaderValue(
|
||||
headers: ResolvedPortalPlayback['headers'] | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
const matchingKey = Object.keys(headers).find(
|
||||
(key) => key.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return matchingKey ? headers[matchingKey] : undefined;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
InlinePlaybackPlayer,
|
||||
createPlaybackTargetCapabilities,
|
||||
recommendPlaybackRecovery,
|
||||
resolvePlaybackSourceKind,
|
||||
type PlaybackDiagnostic,
|
||||
type PlaybackRecommendation,
|
||||
type PlaybackRecommendationTarget,
|
||||
} from '@iptvnator/playback/util';
|
||||
import {
|
||||
VideoPlayer,
|
||||
type ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { PlaybackBinding } from './playback-recovery-session';
|
||||
|
||||
export function createWebPlayerRecommendations(options: {
|
||||
readonly diagnostic: PlaybackDiagnostic | null;
|
||||
readonly binding: PlaybackBinding | null;
|
||||
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
|
||||
readonly managedExternalPlayersAvailable: boolean;
|
||||
readonly playback: ResolvedPortalPlayback;
|
||||
readonly isLive: boolean;
|
||||
readonly alternativeSourceCount: number;
|
||||
}): readonly PlaybackRecommendation[] {
|
||||
if (!options.diagnostic || !options.binding) {
|
||||
return [];
|
||||
}
|
||||
const sourceKind = resolvePlaybackSourceKind(options.diagnostic);
|
||||
const drm = options.playback.drm;
|
||||
return recommendPlaybackRecovery({
|
||||
diagnostic: options.diagnostic,
|
||||
activeTarget: options.binding.target,
|
||||
attemptedTargets: options.attemptedTargets,
|
||||
targetCapabilities: createPlaybackTargetCapabilities({
|
||||
sourceKind,
|
||||
managedExternalPlayersAvailable:
|
||||
options.managedExternalPlayersAvailable,
|
||||
}),
|
||||
source: {
|
||||
kind: sourceKind,
|
||||
isLive: options.isLive,
|
||||
drm: drm ? 'untransferable' : 'none',
|
||||
externalTransferable: !drm,
|
||||
},
|
||||
alternativeSourceCount: options.alternativeSourceCount,
|
||||
});
|
||||
}
|
||||
|
||||
export function toVideoPlayer(target: InlinePlaybackPlayer): VideoPlayer {
|
||||
switch (target) {
|
||||
case InlinePlaybackPlayer.VideoJs:
|
||||
return VideoPlayer.VideoJs;
|
||||
case InlinePlaybackPlayer.Html5:
|
||||
return VideoPlayer.Html5Player;
|
||||
case InlinePlaybackPlayer.ArtPlayer:
|
||||
return VideoPlayer.ArtPlayer;
|
||||
}
|
||||
}
|
||||
|
||||
export function toInlinePlaybackPlayer(
|
||||
player: VideoPlayer
|
||||
): InlinePlaybackPlayer | null {
|
||||
switch (player) {
|
||||
case VideoPlayer.VideoJs:
|
||||
return InlinePlaybackPlayer.VideoJs;
|
||||
case VideoPlayer.Html5Player:
|
||||
return InlinePlaybackPlayer.Html5;
|
||||
case VideoPlayer.ArtPlayer:
|
||||
return InlinePlaybackPlayer.ArtPlayer;
|
||||
case VideoPlayer.EmbeddedMpv:
|
||||
case VideoPlayer.MPV:
|
||||
case VideoPlayer.VLC:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
PlaybackDiagnosticCode,
|
||||
getLikelyBrowserUnsupportedCodecLabels,
|
||||
} from '@iptvnator/playback/util';
|
||||
|
||||
export type PlaybackDiagnosticDetail = {
|
||||
readonly labelKey: string;
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
|
||||
return `${getDiagnosticTranslationBase(issue)}.TITLE`;
|
||||
}
|
||||
|
||||
export function getDiagnosticDescriptionKey(
|
||||
issue: PlaybackDiagnostic,
|
||||
supportsManagedExternalPlayers: boolean
|
||||
): string {
|
||||
if (
|
||||
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
|
||||
!supportsManagedExternalPlayers
|
||||
) {
|
||||
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION';
|
||||
}
|
||||
|
||||
return `${getDiagnosticTranslationBase(issue)}.DESCRIPTION`;
|
||||
}
|
||||
|
||||
export function getDiagnosticMeta(issue: PlaybackDiagnostic): string {
|
||||
if (issue.httpStatus !== undefined) {
|
||||
return `HTTP ${issue.httpStatus}`;
|
||||
}
|
||||
|
||||
const codecs = [...issue.videoCodecs, ...issue.audioCodecs].join(', ');
|
||||
if (codecs) {
|
||||
return codecs;
|
||||
}
|
||||
|
||||
return issue.container || issue.mimeType || '';
|
||||
}
|
||||
|
||||
export function getDiagnosticCodecHint(issue: PlaybackDiagnostic): string {
|
||||
return getLikelyBrowserUnsupportedCodecLabels(issue).join(', ');
|
||||
}
|
||||
|
||||
export function getDiagnosticDetails(
|
||||
issue: PlaybackDiagnostic
|
||||
): readonly PlaybackDiagnosticDetail[] {
|
||||
return [
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
|
||||
value: issue.code,
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
|
||||
value: formatPlayer(issue.player),
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
|
||||
value: formatDiagnosticSource(issue.source),
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CONTAINER',
|
||||
value: issue.container,
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_MIME_TYPE',
|
||||
value: issue.mimeType ?? '',
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_VIDEO_CODECS',
|
||||
value: issue.videoCodecs.join(', '),
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_AUDIO_CODECS',
|
||||
value: issue.audioCodecs.join(', '),
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_CODE',
|
||||
value: issue.nativeErrorCode?.toString() ?? '',
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_NATIVE_ERROR_MESSAGE',
|
||||
value:
|
||||
issue.vhs || issue.mpegTs || issue.shaka
|
||||
? ''
|
||||
: (issue.nativeErrorMessage ?? ''),
|
||||
},
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
|
||||
value: formatDiagnosticErrorDetails(issue),
|
||||
},
|
||||
].filter(({ value }) => value.trim().length > 0);
|
||||
}
|
||||
|
||||
function formatDiagnosticErrorDetails(issue: PlaybackDiagnostic): string {
|
||||
if (issue.mpegTs) {
|
||||
return [
|
||||
`stage=${issue.mpegTs.stage}`,
|
||||
`failure=${issue.mpegTs.failure}`,
|
||||
`type=${issue.mpegTs.engineType}`,
|
||||
`details=${issue.mpegTs.engineDetails}`,
|
||||
`disposition=${issue.mpegTs.disposition}`,
|
||||
issue.mpegTs.httpStatus === undefined
|
||||
? ''
|
||||
: `HTTP ${issue.mpegTs.httpStatus}`,
|
||||
]
|
||||
.filter((value) => value.length > 0)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
if (issue.shaka) {
|
||||
return [
|
||||
`stage=${issue.shaka.stage}`,
|
||||
`failure=${issue.shaka.failure}`,
|
||||
`severity=${issue.shaka.severity}`,
|
||||
`category=${issue.shaka.category}`,
|
||||
`code=${issue.shaka.engineCode}`,
|
||||
`disposition=${issue.shaka.disposition}`,
|
||||
issue.shaka.httpStatus === undefined
|
||||
? ''
|
||||
: `HTTP ${issue.shaka.httpStatus}`,
|
||||
]
|
||||
.filter((value) => value.length > 0)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
if (issue.vhs) {
|
||||
return [
|
||||
`stage=${issue.vhs.stage}`,
|
||||
`type=${issue.vhs.engineType}`,
|
||||
`code=${issue.vhs.mediaErrorCode}`,
|
||||
`disposition=${issue.vhs.disposition}`,
|
||||
issue.vhs.httpStatus === undefined
|
||||
? ''
|
||||
: `HTTP ${issue.vhs.httpStatus}`,
|
||||
]
|
||||
.filter((value) => value.length > 0)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
if (issue.hls) {
|
||||
return [
|
||||
`stage=${issue.hls.stage}`,
|
||||
`failure=${issue.hls.failure}`,
|
||||
`type=${issue.hls.engineType}`,
|
||||
`details=${issue.hls.engineDetails}`,
|
||||
`disposition=${issue.hls.disposition}`,
|
||||
issue.hls.httpStatus === undefined
|
||||
? ''
|
||||
: `HTTP ${issue.hls.httpStatus}`,
|
||||
]
|
||||
.filter((value) => value.length > 0)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
return [
|
||||
issue.httpStatus !== undefined ? `HTTP ${issue.httpStatus}` : '',
|
||||
issue.nativeErrorType ?? '',
|
||||
issue.details ?? '',
|
||||
]
|
||||
.filter((value) => value.trim().length > 0)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function getDiagnosticTranslationBase(issue: PlaybackDiagnostic): string {
|
||||
switch (issue.code) {
|
||||
case PlaybackDiagnosticCode.UnsupportedContainer:
|
||||
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER';
|
||||
case PlaybackDiagnosticCode.UnsupportedCodec:
|
||||
return 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC';
|
||||
case PlaybackDiagnosticCode.MediaDecodeError:
|
||||
return 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR';
|
||||
case PlaybackDiagnosticCode.NetworkError:
|
||||
return 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR';
|
||||
case PlaybackDiagnosticCode.BrowserAccessError:
|
||||
return 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR';
|
||||
case PlaybackDiagnosticCode.DrmOrEncryption:
|
||||
return 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION';
|
||||
case PlaybackDiagnosticCode.UnknownPlaybackError:
|
||||
default:
|
||||
return 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPlayer(player: PlaybackDiagnostic['player']): string {
|
||||
switch (player) {
|
||||
case 'videojs':
|
||||
return 'Video.js';
|
||||
case 'html5':
|
||||
return 'HTML5';
|
||||
case 'artplayer':
|
||||
return 'ArtPlayer';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiagnosticSource(source: PlaybackDiagnostic['source']): string {
|
||||
switch (source) {
|
||||
case 'vhs':
|
||||
return 'Video.js / VHS';
|
||||
case 'hls':
|
||||
return 'HLS.js';
|
||||
case 'mpegts':
|
||||
return 'mpegts.js';
|
||||
case 'native':
|
||||
return 'Native media element';
|
||||
case 'shaka':
|
||||
return 'Shaka Player';
|
||||
case 'source':
|
||||
return 'Stream metadata';
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -1,278 +1,95 @@
|
||||
@if (selectedPlayer() === 'videojs') {
|
||||
@defer (on immediate) {
|
||||
<!-- The source hand-off is deferred until the scoped Electron header
|
||||
override is configured; mounting the player before `vjsOptions`
|
||||
exists would fire a source-less (or credential-less) first load. -->
|
||||
@if (vjsOptions) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
@for (binding of renderedBindings(); track binding.generation) {
|
||||
@if (binding.target === 'videojs') {
|
||||
@defer (on immediate) {
|
||||
@if (vjsOptions) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="effectiveStartTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="handleTimeUpdate($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event, binding)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'html5') {
|
||||
@defer (on immediate) {
|
||||
@if (channel) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
} @else if (binding.target === 'html5') {
|
||||
@defer (on immediate) {
|
||||
@if (channel) {
|
||||
<app-html-video-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="effectiveStartTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="handleTimeUpdate($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event, binding)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'artplayer') {
|
||||
@defer (on immediate) {
|
||||
@if (channel) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
} @else if (binding.target === 'artplayer') {
|
||||
@defer (on immediate) {
|
||||
@if (channel) {
|
||||
<app-art-player
|
||||
[channel]="$any(channel)"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[isLive]="resolvedIsLive()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="effectiveStartTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="handleTimeUpdate($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event, binding)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
} @else if (selectedPlayer() === 'embedded-mpv') {
|
||||
}
|
||||
|
||||
@if (selectedPlayer() === 'embedded-mpv') {
|
||||
<app-embedded-mpv-player
|
||||
[playback]="resolvedPlayback()"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[recordingFolder]="recordingFolder()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(timeUpdate)="handleTimeUpdate($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
} @else {
|
||||
@defer (on immediate) {
|
||||
<app-vjs-player
|
||||
[options]="vjsOptions"
|
||||
[mediaTitle]="resolvedMediaTitle()"
|
||||
[volume]="volume()"
|
||||
[showCaptions]="showCaptions()"
|
||||
[interactionEnabled]="playbackInteractionEnabled()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
(timeUpdate)="timeUpdate.emit($event)"
|
||||
(playbackIssue)="handlePlaybackIssue($event)"
|
||||
(playbackEnded)="playbackEnded.emit()"
|
||||
(previousEpisodeRequested)="previousEpisodeRequested.emit()"
|
||||
(nextEpisodeRequested)="nextEpisodeRequested.emit()"
|
||||
/>
|
||||
} @placeholder {
|
||||
<div class="web-player-defer-placeholder" aria-hidden="true"></div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (visiblePlaybackDiagnostic(); as issue) {
|
||||
<section
|
||||
class="web-player-diagnostic"
|
||||
data-test-id="playback-diagnostic-banner"
|
||||
role="status"
|
||||
>
|
||||
<div class="web-player-diagnostic__content">
|
||||
<div class="web-player-diagnostic__badge">
|
||||
<mat-icon aria-hidden="true">warning</mat-icon>
|
||||
<span>{{ getDiagnosticTitleKey(issue) | translate }}</span>
|
||||
</div>
|
||||
|
||||
<h2 class="web-player-diagnostic__headline">
|
||||
{{ diagnosticHeadlineKey() | translate }}
|
||||
</h2>
|
||||
|
||||
<div class="web-player-diagnostic__description">
|
||||
{{ getDiagnosticDescriptionKey(issue) | translate }}
|
||||
</div>
|
||||
|
||||
@if (getDiagnosticMeta(issue); as meta) {
|
||||
<div class="web-player-diagnostic__meta">
|
||||
{{ meta }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (getDiagnosticCodecHint(issue); as codecHint) {
|
||||
<div
|
||||
class="web-player-diagnostic__codec-hint"
|
||||
data-test-id="playback-codec-hint"
|
||||
>
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
|
||||
| translate: { codecs: codecHint }
|
||||
}}
|
||||
</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)"
|
||||
(checkRequested)="sourceCheckRequested.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"
|
||||
aria-label="External player fallbacks"
|
||||
>
|
||||
<button
|
||||
class="web-player-diagnostic__player-card web-player-diagnostic__player-card--primary"
|
||||
type="button"
|
||||
data-test-id="playback-fallback-mpv"
|
||||
(click)="requestExternalFallback('mpv')"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
<span class="web-player-diagnostic__player-copy">
|
||||
<span class="web-player-diagnostic__player-label">
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
<span class="web-player-diagnostic__player-hint">
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV_HINT'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="web-player-diagnostic__player-card"
|
||||
type="button"
|
||||
data-test-id="playback-fallback-vlc"
|
||||
(click)="requestExternalFallback('vlc')"
|
||||
>
|
||||
<mat-icon aria-hidden="true">open_in_new</mat-icon>
|
||||
<span class="web-player-diagnostic__player-copy">
|
||||
<span class="web-player-diagnostic__player-label">
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
<span class="web-player-diagnostic__player-hint">
|
||||
{{
|
||||
'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC_HINT'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="web-player-diagnostic__utilities">
|
||||
<button
|
||||
class="web-player-diagnostic__utility-button"
|
||||
type="button"
|
||||
[cdkCopyToClipboard]="resolvedPlayback().streamUrl"
|
||||
[matTooltip]="resolvedPlayback().streamUrl"
|
||||
data-test-id="playback-copy-url"
|
||||
>
|
||||
<mat-icon aria-hidden="true">content_copy</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="web-player-diagnostic__divider"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<button
|
||||
class="web-player-diagnostic__utility-button"
|
||||
type="button"
|
||||
data-test-id="playback-retry"
|
||||
(click)="retryPlayback()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">refresh</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY' | translate }}
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="web-player-diagnostic__divider"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
|
||||
<details
|
||||
class="web-player-diagnostic__details"
|
||||
data-test-id="playback-diagnostic-details"
|
||||
>
|
||||
<summary>
|
||||
<mat-icon aria-hidden="true">expand_more</mat-icon>
|
||||
{{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }}
|
||||
</summary>
|
||||
<dl>
|
||||
@for (
|
||||
detail of getDiagnosticDetails(issue);
|
||||
track detail.labelKey
|
||||
) {
|
||||
<div class="web-player-diagnostic__detail-row">
|
||||
<dt>{{ detail.labelKey | translate }}</dt>
|
||||
<dd>{{ detail.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<app-playback-diagnostic-panel
|
||||
[diagnostic]="issue"
|
||||
[recommendations]="recommendations()"
|
||||
[playback]="resolvedPlayback()"
|
||||
[alternativeSources]="alternativeSources()"
|
||||
[pending]="recoveryPending()"
|
||||
(retryRequested)="retryPlayback()"
|
||||
(playerRequested)="requestRecommendedPlayer($event)"
|
||||
(alternativeSourceRequested)="alternativeSourceRequested.emit($event)"
|
||||
(sourceCheckRequested)="sourceCheckRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,462 +22,3 @@ app-html-video-player {
|
||||
height: 100%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.web-player-diagnostic {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px);
|
||||
overflow: auto;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 18%,
|
||||
rgb(255 176 76 / 6%),
|
||||
transparent 28%
|
||||
),
|
||||
rgb(0 0 0 / 96%);
|
||||
color: #f6f6f6;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__content {
|
||||
width: min(100%, 720px);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 11px;
|
||||
border: 1px solid rgb(255 174 66 / 42%);
|
||||
border-radius: 6px;
|
||||
background: rgb(255 148 35 / 15%);
|
||||
color: #ffb24c;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__headline {
|
||||
max-width: 660px;
|
||||
margin: 20px 0 0;
|
||||
color: #fff;
|
||||
font-size: 1.65rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__description {
|
||||
max-width: 760px;
|
||||
margin-top: 12px;
|
||||
color: rgb(255 255 255 / 73%);
|
||||
font-size: 1rem;
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__meta {
|
||||
margin-top: 9px;
|
||||
color: rgb(255 255 255 / 48%);
|
||||
font-family: var(--app-monospace-font, monospace);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__codec-hint {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 6px;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: rgb(255 255 255 / 68%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 620;
|
||||
line-height: 1.3;
|
||||
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));
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card {
|
||||
display: grid;
|
||||
min-height: 60px;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid rgb(255 255 255 / 13%);
|
||||
border-radius: 8px;
|
||||
background: rgb(0 0 0 / 22%);
|
||||
color: #f5f5f5;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease,
|
||||
color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card:hover {
|
||||
border-color: rgb(255 255 255 / 28%);
|
||||
background: rgb(255 255 255 / 6%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card:focus-visible,
|
||||
.web-player-diagnostic__utility-button:focus-visible,
|
||||
.web-player-diagnostic__details summary:focus-visible {
|
||||
outline: 2px solid #ffb24c;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card--primary {
|
||||
border-color: transparent;
|
||||
background: #fff;
|
||||
color: #151515;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card--primary:hover {
|
||||
border-color: transparent;
|
||||
background: rgb(255 255 255 / 88%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-label {
|
||||
overflow: hidden;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 760;
|
||||
line-height: 1.05;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-hint {
|
||||
overflow: hidden;
|
||||
color: currentColor;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.1;
|
||||
opacity: 0.62;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
margin-top: 22px;
|
||||
color: rgb(255 255 255 / 45%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utility-button,
|
||||
.web-player-diagnostic__details summary {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 640;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utility-button:hover,
|
||||
.web-player-diagnostic__details summary:hover {
|
||||
color: rgb(255 255 255 / 76%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utility-button mat-icon,
|
||||
.web-player-diagnostic__details summary mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin-top: 3px;
|
||||
background: rgb(255 255 255 / 16%);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details[open] {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 7px 14px;
|
||||
max-width: min(100%, 640px);
|
||||
margin: 12px 0 0;
|
||||
padding: 12px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
color: rgb(255 255 255 / 68%);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row dt {
|
||||
color: rgb(255 255 255 / 48%);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@container (max-width: 760px) {
|
||||
.web-player-diagnostic {
|
||||
align-items: flex-start;
|
||||
padding: 22px 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge span {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__headline {
|
||||
margin-top: 16px;
|
||||
font-size: 1.48rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utilities {
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details dl {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row dt {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
.web-player-diagnostic {
|
||||
align-items: flex-start;
|
||||
padding: clamp(14px, 5cqi, 22px);
|
||||
}
|
||||
|
||||
.web-player-diagnostic__content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge {
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.7rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__badge span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__headline {
|
||||
margin-top: 14px;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__description {
|
||||
margin-top: 10px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__meta {
|
||||
margin-top: 8px;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__codec-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-actions {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-card {
|
||||
min-height: 52px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utilities {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__utility-button,
|
||||
.web-player-diagnostic__details summary {
|
||||
width: fit-content;
|
||||
min-height: 22px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details[open] {
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__details dl {
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__detail-row dt {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 340px) {
|
||||
.web-player-diagnostic {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__headline {
|
||||
font-size: 1.22rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__description {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.web-player-diagnostic__player-label {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
+40
-20
@@ -30,6 +30,7 @@ import {
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
@@ -85,6 +86,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
PlaybackDiagnosticPanelComponent,
|
||||
ClipboardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -199,11 +201,11 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
expect(artPlayer.showCaptions()).toBe(true);
|
||||
expect(artPlayer.interactionEnabled()).toBe(true);
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
emitPlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(artPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.handlePlaybackIssue(null);
|
||||
emitPlaybackIssue(null);
|
||||
fixture.detectChanges();
|
||||
expect(artPlayer.interactionEnabled()).toBe(true);
|
||||
});
|
||||
@@ -211,7 +213,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
it('disables HTML5 surface interaction while a diagnostic is visible', async () => {
|
||||
const htmlPlayer = await renderHtmlPlayer();
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
emitPlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.playbackInteractionEnabled()).toBe(false);
|
||||
@@ -221,43 +223,49 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
it('re-enables HTML5 interaction after retrying or clearing the issue', async () => {
|
||||
const htmlPlayer = await renderHtmlPlayer();
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
emitPlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.retryPlayback();
|
||||
fixture.detectChanges();
|
||||
const retriedHtmlPlayer = fixture.debugElement.query(
|
||||
By.directive(StubHtmlVideoPlayerComponent)
|
||||
).componentInstance as StubHtmlVideoPlayerComponent;
|
||||
expect(component.playbackInteractionEnabled()).toBe(true);
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(true);
|
||||
expect(retriedHtmlPlayer.interactionEnabled()).toBe(true);
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
emitPlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(false);
|
||||
expect(retriedHtmlPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.handlePlaybackIssue(null);
|
||||
emitPlaybackIssue(null);
|
||||
fixture.detectChanges();
|
||||
expect(component.playbackInteractionEnabled()).toBe(true);
|
||||
expect(htmlPlayer.interactionEnabled()).toBe(true);
|
||||
expect(retriedHtmlPlayer.interactionEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('disables and restores Video.js interaction around diagnostics', async () => {
|
||||
const vjsPlayer = await renderVjsPlayer();
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
emitPlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(vjsPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.retryPlayback();
|
||||
fixture.detectChanges();
|
||||
expect(vjsPlayer.interactionEnabled()).toBe(true);
|
||||
const retriedVjsPlayer = fixture.debugElement.query(
|
||||
By.directive(StubVjsPlayerComponent)
|
||||
).componentInstance as StubVjsPlayerComponent;
|
||||
expect(retriedVjsPlayer.interactionEnabled()).toBe(true);
|
||||
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
emitPlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
expect(vjsPlayer.interactionEnabled()).toBe(false);
|
||||
expect(retriedVjsPlayer.interactionEnabled()).toBe(false);
|
||||
|
||||
component.handlePlaybackIssue(null);
|
||||
emitPlaybackIssue(null);
|
||||
fixture.detectChanges();
|
||||
expect(vjsPlayer.interactionEnabled()).toBe(true);
|
||||
expect(retriedVjsPlayer.interactionEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -271,17 +279,20 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
'uses the resolved value for Video.js effect and retry: %s',
|
||||
(_label, metadata, expected) => {
|
||||
const streamUrl = 'https://example.com/video.ts';
|
||||
const setVjsOptions = jest.spyOn(component, 'setVjsOptions');
|
||||
setPlayback(metadata, streamUrl);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
|
||||
expect(component.vjsOptions).toEqual(
|
||||
expect.objectContaining({ isLive: expected, reloadToken: 0 })
|
||||
);
|
||||
|
||||
setVjsOptions.mockClear();
|
||||
component.retryPlayback();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected);
|
||||
expect(component.vjsOptions).toEqual(
|
||||
expect.objectContaining({ isLive: expected, reloadToken: 1 })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -338,6 +349,16 @@ describe('WebPlayerViewComponent shared web controls metadata', () => {
|
||||
return fixture.debugElement.query(By.directive(StubArtPlayerComponent))
|
||||
.componentInstance as StubArtPlayerComponent;
|
||||
}
|
||||
|
||||
function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void {
|
||||
fixture.detectChanges();
|
||||
const binding = component.activeBinding();
|
||||
expect(binding).not.toBeNull();
|
||||
if (!binding) {
|
||||
throw new Error('Expected an active inline playback binding');
|
||||
}
|
||||
component.handlePlaybackIssue(issue, binding);
|
||||
}
|
||||
});
|
||||
|
||||
function createVodContentInfo() {
|
||||
@@ -358,6 +379,5 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
|
||||
player: 'html5',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,14 @@ import {
|
||||
PlaybackDiagnosticCode,
|
||||
PlaybackDiagnosticSource,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
|
||||
import {
|
||||
getDiagnosticCodecHint,
|
||||
getDiagnosticDescriptionKey as resolveDiagnosticDescriptionKey,
|
||||
getDiagnosticDetails,
|
||||
getDiagnosticMeta,
|
||||
getDiagnosticTitleKey,
|
||||
} from '../playback-diagnostic-panel/playback-diagnostic-view.util';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
@@ -141,6 +149,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
StubEmbeddedMpvPlayerComponent,
|
||||
StubHtmlVideoPlayerComponent,
|
||||
StubVjsPlayerComponent,
|
||||
PlaybackDiagnosticPanelComponent,
|
||||
// Real, not stubbed: the point of the test below is
|
||||
// that this row's Check action reaches the host.
|
||||
VodSourceRowComponent,
|
||||
@@ -237,7 +246,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
]);
|
||||
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
|
||||
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
const check = fixture.debugElement.query(
|
||||
@@ -259,7 +268,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(createUnsupportedContainerDiagnostic());
|
||||
emitPlaybackIssue(createUnsupportedContainerDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.query(
|
||||
@@ -296,7 +305,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
const issue = createHttpDiagnostic();
|
||||
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(issue);
|
||||
emitPlaybackIssue(issue);
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.query(
|
||||
@@ -308,8 +317,8 @@ describe('WebPlayerViewComponent', () => {
|
||||
|
||||
expect(banner.nativeElement.textContent).toContain('HTTP 404');
|
||||
expect(mpvButton).toBeNull();
|
||||
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
|
||||
expect(component.getDiagnosticDetails(issue)).toEqual(
|
||||
expect(getDiagnosticMeta(issue)).toBe('HTTP 404');
|
||||
expect(getDiagnosticDetails(issue)).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS',
|
||||
@@ -322,10 +331,10 @@ describe('WebPlayerViewComponent', () => {
|
||||
it('renders only sanitized structured HLS evidence in technical details', () => {
|
||||
const issue = createStructuredHlsDiagnostic();
|
||||
|
||||
component.handlePlaybackIssue(issue);
|
||||
emitPlaybackIssue(issue);
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = component.getDiagnosticDetails(issue);
|
||||
const details = getDiagnosticDetails(issue);
|
||||
const renderedDetails = details.map(({ value }) => value).join(' ');
|
||||
|
||||
expect(details).toEqual(
|
||||
@@ -346,10 +355,10 @@ describe('WebPlayerViewComponent', () => {
|
||||
it('renders only sanitized structured VHS evidence in technical details', () => {
|
||||
const issue = createStructuredVhsDiagnostic();
|
||||
|
||||
component.handlePlaybackIssue(issue);
|
||||
emitPlaybackIssue(issue);
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = component.getDiagnosticDetails(issue);
|
||||
const details = getDiagnosticDetails(issue);
|
||||
const renderedDetails = details.map(({ value }) => value).join(' ');
|
||||
|
||||
expect(details).toEqual(
|
||||
@@ -375,10 +384,10 @@ describe('WebPlayerViewComponent', () => {
|
||||
it('renders only sanitized structured Shaka evidence in technical details', () => {
|
||||
const issue = createStructuredShakaDiagnostic();
|
||||
|
||||
component.handlePlaybackIssue(issue);
|
||||
emitPlaybackIssue(issue);
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = component.getDiagnosticDetails(issue);
|
||||
const details = getDiagnosticDetails(issue);
|
||||
const renderedDetails = details.map(({ value }) => value).join(' ');
|
||||
|
||||
expect(details).toEqual(
|
||||
@@ -405,13 +414,13 @@ describe('WebPlayerViewComponent', () => {
|
||||
it('renders only sanitized structured mpegts evidence in technical details', () => {
|
||||
const issue = createStructuredMpegTsDiagnostic();
|
||||
|
||||
component.handlePlaybackIssue(issue);
|
||||
emitPlaybackIssue(issue);
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = component.getDiagnosticDetails(issue);
|
||||
const details = getDiagnosticDetails(issue);
|
||||
const renderedDetails = details.map(({ value }) => value).join(' ');
|
||||
|
||||
expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404');
|
||||
expect(getDiagnosticMeta(issue)).toBe('HTTP 404');
|
||||
expect(details).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
@@ -433,59 +442,6 @@ describe('WebPlayerViewComponent', () => {
|
||||
expect(renderedDetails).not.toContain('response body');
|
||||
});
|
||||
|
||||
it('keeps query-declared HLS streams on the HLS mime type', () => {
|
||||
const streamUrl =
|
||||
'https://example.com/play?extension=m3u8&token=signed';
|
||||
|
||||
component.setVjsOptions(streamUrl);
|
||||
|
||||
expect(component.vjsOptions.sources).toEqual([
|
||||
{
|
||||
src: streamUrl,
|
||||
type: 'application/x-mpegURL',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the Matroska mime type for MKV paths', () => {
|
||||
const streamUrl = 'https://example.com/archive/movie.mkv';
|
||||
|
||||
component.setVjsOptions(streamUrl);
|
||||
|
||||
expect(component.vjsOptions.sources).toEqual([
|
||||
{
|
||||
src: streamUrl,
|
||||
type: 'video/matroska',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the Matroska mime type for query-declared MKV streams', () => {
|
||||
const streamUrl = 'https://example.com/play?container=mkv&token=signed';
|
||||
|
||||
component.setVjsOptions(streamUrl);
|
||||
|
||||
expect(component.vjsOptions.sources).toEqual([
|
||||
{
|
||||
src: streamUrl,
|
||||
type: 'video/matroska',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats web script playback URLs without declared media extension as MPEG-TS', () => {
|
||||
const streamUrl = 'https://example.com/live.php?stream=123&token=x';
|
||||
|
||||
component.setVjsOptions(streamUrl);
|
||||
|
||||
expect(component.vjsOptions.sources).toEqual([
|
||||
{
|
||||
src: streamUrl,
|
||||
type: 'video/mp2t',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => {
|
||||
const streamUrl = 'https://example.com/movie/123.ts';
|
||||
fixture.componentRef.setInput('playback', {
|
||||
@@ -629,11 +585,11 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
|
||||
component.playbackDiagnostic.set(createUnsupportedCodecDiagnostic());
|
||||
fixture.detectChanges();
|
||||
component.requestExternalFallback('mpv');
|
||||
component.requestRecommendedPlayer('mpv');
|
||||
|
||||
expect(component.playbackDiagnostic()).toBeNull();
|
||||
expect(component.visiblePlaybackDiagnostic()).toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.directive(StubEmbeddedMpvPlayerComponent)
|
||||
@@ -798,10 +754,10 @@ describe('WebPlayerViewComponent', () => {
|
||||
it('uses the PWA browser access diagnostic description key outside desktop', () => {
|
||||
const issue = createBrowserAccessDiagnostic();
|
||||
|
||||
expect(component.getDiagnosticTitleKey(issue)).toBe(
|
||||
expect(getDiagnosticTitleKey(issue)).toBe(
|
||||
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.TITLE'
|
||||
);
|
||||
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
|
||||
expect(getDiagnosticDescriptionKey(issue)).toBe(
|
||||
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
|
||||
);
|
||||
});
|
||||
@@ -810,14 +766,14 @@ describe('WebPlayerViewComponent', () => {
|
||||
runtimeCapabilities.supportsManagedExternalPlayers = true;
|
||||
const issue = createBrowserAccessDiagnostic();
|
||||
|
||||
expect(component.getDiagnosticDescriptionKey(issue)).toBe(
|
||||
expect(getDiagnosticDescriptionKey(issue)).toBe(
|
||||
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses an inline recovery headline when external fallback actions are unavailable', () => {
|
||||
fixture.detectChanges();
|
||||
component.handlePlaybackIssue(createNetworkDiagnostic());
|
||||
emitPlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
const banner = fixture.debugElement.query(
|
||||
@@ -840,7 +796,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
fixture.detectChanges();
|
||||
const issue = createUnsupportedCodecDiagnostic();
|
||||
|
||||
component.handlePlaybackIssue(issue);
|
||||
emitPlaybackIssue(issue);
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = fixture.debugElement.query(
|
||||
@@ -853,8 +809,8 @@ describe('WebPlayerViewComponent', () => {
|
||||
expect(details.nativeElement.textContent).toContain(
|
||||
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
|
||||
);
|
||||
expect(component.getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
|
||||
expect(component.getDiagnosticDetails(issue)).toEqual(
|
||||
expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
|
||||
expect(getDiagnosticDetails(issue)).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE',
|
||||
@@ -890,7 +846,7 @@ describe('WebPlayerViewComponent', () => {
|
||||
expect.objectContaining({ reloadToken: 0 })
|
||||
);
|
||||
|
||||
component.handlePlaybackIssue(createUnsupportedCodecDiagnostic());
|
||||
emitPlaybackIssue(createNetworkDiagnostic());
|
||||
fixture.detectChanges();
|
||||
|
||||
const retryButton = fixture.debugElement.query(
|
||||
@@ -904,9 +860,12 @@ describe('WebPlayerViewComponent', () => {
|
||||
|
||||
retryButton.nativeElement.click();
|
||||
fixture.detectChanges();
|
||||
const retriedPlayer = fixture.debugElement.query(
|
||||
By.directive(StubVjsPlayerComponent)
|
||||
).componentInstance as StubVjsPlayerComponent;
|
||||
|
||||
expect(component.playbackDiagnostic()).toBeNull();
|
||||
expect(player.options()).toEqual(
|
||||
expect(retriedPlayer.options()).toEqual(
|
||||
expect.objectContaining({ reloadToken: 1 })
|
||||
);
|
||||
expect(
|
||||
@@ -1031,6 +990,23 @@ describe('WebPlayerViewComponent', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void {
|
||||
fixture.detectChanges();
|
||||
const binding = component.activeBinding();
|
||||
expect(binding).not.toBeNull();
|
||||
if (!binding) {
|
||||
throw new Error('Expected an active inline playback binding');
|
||||
}
|
||||
component.handlePlaybackIssue(issue, binding);
|
||||
}
|
||||
|
||||
function getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
|
||||
return resolveDiagnosticDescriptionKey(
|
||||
issue,
|
||||
runtimeCapabilities.supportsManagedExternalPlayers
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
|
||||
@@ -1043,7 +1019,6 @@ function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic {
|
||||
player: 'videojs',
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
externalFallbackRecommended: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1058,7 +1033,6 @@ function createBrowserAccessDiagnostic(): PlaybackDiagnostic {
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
details: 'blocked by CORS policy',
|
||||
externalFallbackRecommended: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1073,7 +1047,6 @@ function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic {
|
||||
audioCodecs: ['ac-3'],
|
||||
videoCodecs: ['hvc1.1.6.L93.B0'],
|
||||
details: 'manifestIncompatibleCodecsError',
|
||||
externalFallbackRecommended: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1088,7 +1061,6 @@ function createNetworkDiagnostic(): PlaybackDiagnostic {
|
||||
audioCodecs: [],
|
||||
videoCodecs: [],
|
||||
details: 'HttpStatusCodeInvalid {"code":456,"msg":"<none>"}',
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1106,7 +1078,6 @@ function createHttpDiagnostic(): PlaybackDiagnostic {
|
||||
nativeErrorMessage: 'source not supported',
|
||||
httpStatus: 404,
|
||||
nativeErrorType: 'networkrequestfailed',
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1130,7 +1101,6 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic {
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
},
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1156,7 +1126,6 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic {
|
||||
stage: 'unknown',
|
||||
httpStatus: 503,
|
||||
},
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1184,7 +1153,6 @@ function createStructuredShakaDiagnostic(): PlaybackDiagnostic {
|
||||
failure: 'network',
|
||||
httpStatus: 503,
|
||||
},
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1211,6 +1179,5 @@ function createStructuredMpegTsDiagnostic(): PlaybackDiagnostic {
|
||||
failure: 'http',
|
||||
httpStatus: 404,
|
||||
},
|
||||
externalFallbackRecommended: false,
|
||||
};
|
||||
}
|
||||
@@ -12,50 +12,47 @@ import {
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
Channel,
|
||||
ResolvedPortalPlayback,
|
||||
Settings,
|
||||
type PlaybackDiagnostic,
|
||||
type PlaybackDiagnosticCode,
|
||||
type PlaybackFallbackRequest,
|
||||
type PlaybackRecommendationTarget,
|
||||
} from '@iptvnator/playback/util';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
STORE_KEY,
|
||||
VideoPlayer,
|
||||
type Channel,
|
||||
type ResolvedPortalPlayback,
|
||||
type Settings,
|
||||
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';
|
||||
import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component';
|
||||
import {
|
||||
type PlayerMediaTitle,
|
||||
WEB_PLAYER_SHARED_CONTROLS,
|
||||
WEB_PLAYER_SHARED_CONTROLS_ENABLED,
|
||||
} from '../player-controls';
|
||||
import {
|
||||
type PlaybackDiagnostic,
|
||||
type PlaybackDiagnosticCode,
|
||||
type PlaybackFallbackRequest,
|
||||
getPlaybackMediaExtensionFromUrl,
|
||||
} from '@iptvnator/playback/util';
|
||||
import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation';
|
||||
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
|
||||
import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types';
|
||||
import { ElectronStreamHeadersService } from './electron-stream-headers.service';
|
||||
import {
|
||||
getDiagnosticCodecHint,
|
||||
getDiagnosticDescriptionKey,
|
||||
getDiagnosticDetails,
|
||||
getDiagnosticMeta,
|
||||
getDiagnosticTitleKey,
|
||||
} from './web-player-view-diagnostics.utils';
|
||||
type PlaybackBinding,
|
||||
PlaybackRecoverySession,
|
||||
} from './playback-recovery-session';
|
||||
import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff';
|
||||
import { createWebPlayerApplicationState } from './web-player-application-state';
|
||||
import { resolveWebPlayerMediaTitle } from './web-player-playback-state';
|
||||
import {
|
||||
createWebPlayerRecommendations,
|
||||
toInlinePlaybackPlayer,
|
||||
toVideoPlayer,
|
||||
} from './web-player-recovery-policy';
|
||||
|
||||
function resolveWebPlayerSharedControls(): boolean {
|
||||
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
|
||||
@@ -68,20 +65,13 @@ function resolveWebPlayerSharedControls(): boolean {
|
||||
selector: 'app-web-player-view',
|
||||
templateUrl: './web-player-view.component.html',
|
||||
styleUrls: ['./web-player-view.component.scss'],
|
||||
host: {
|
||||
class: 'web-player-view',
|
||||
},
|
||||
host: { class: 'web-player-view' },
|
||||
imports: [
|
||||
ArtPlayerComponent,
|
||||
ClipboardModule,
|
||||
EmbeddedMpvPlayerComponent,
|
||||
HtmlVideoPlayerComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
PlaybackDiagnosticPanelComponent,
|
||||
VjsPlayerComponent,
|
||||
VodSourceRowComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -92,82 +82,78 @@ function resolveWebPlayerSharedControls(): boolean {
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
})
|
||||
export class WebPlayerViewComponent implements OnDestroy {
|
||||
storage = inject(StorageMap);
|
||||
private readonly storage = inject(StorageMap);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly recoverySession = new PlaybackRecoverySession();
|
||||
private readonly applicationHandoff =
|
||||
new WebPlayerApplicationHandoffCoordinator(
|
||||
inject(ElectronStreamHeadersService),
|
||||
this.recoverySession
|
||||
);
|
||||
|
||||
streamUrl = input.required<string>();
|
||||
readonly streamUrl = input.required<string>();
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
title = input('');
|
||||
playback = input<ResolvedPortalPlayback | null>(null);
|
||||
startTime = input<number>(0);
|
||||
volume = input<number>(1);
|
||||
playerOverride = input<VideoPlayer | null>(null);
|
||||
seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
||||
/** Display-ready title lines for the fullscreen overlay; hosts with richer
|
||||
* context (e.g. series name + episode label) pass it explicitly. */
|
||||
mediaTitle = input<PlayerMediaTitle | null>(null);
|
||||
readonly title = input('');
|
||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<VideoPlayer | null>(null);
|
||||
readonly seriesNavigation = input<SeriesPlaybackNavigation | null>(null);
|
||||
readonly mediaTitle = input<PlayerMediaTitle | null>(null);
|
||||
readonly alternativeSources = input<VodSourceDescriptor[]>([]);
|
||||
|
||||
readonly timeUpdate = output<{
|
||||
currentTime: number;
|
||||
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>();
|
||||
/** The row's Check action, which has to reach the host that can probe. */
|
||||
readonly sourceCheckRequested = 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>();
|
||||
|
||||
settings = toSignal(this.storage.get(STORE_KEY.Settings)) as Signal<
|
||||
Settings | undefined
|
||||
>;
|
||||
|
||||
/**
|
||||
* Subtitle preference for the built-in web players. Read from the settings
|
||||
* store instead of an input so every host (M3U, Xtream, Stalker, portal
|
||||
* detail pages) gets it without having to wire it through — the missing
|
||||
* bindings were why the setting looked like a no-op (#1155).
|
||||
*/
|
||||
readonly settings = toSignal(
|
||||
this.storage.get(STORE_KEY.Settings)
|
||||
) as Signal<Settings | undefined>;
|
||||
readonly showCaptions = computed(
|
||||
() => this.settingsStore.showCaptions?.() ?? false
|
||||
);
|
||||
|
||||
channel!: Channel;
|
||||
vjsOptions!: {
|
||||
isLive: boolean;
|
||||
reloadToken: number;
|
||||
sources: { src: string; type: string }[];
|
||||
};
|
||||
readonly reloadToken = signal(0);
|
||||
readonly playbackDiagnostic = signal<PlaybackDiagnostic | null>(null);
|
||||
readonly recoveryPending = this.recoverySession.switchPending;
|
||||
readonly activeBinding = this.recoverySession.activeBinding;
|
||||
|
||||
channel: Channel | undefined;
|
||||
vjsOptions: VideoPlayerOptions | undefined;
|
||||
|
||||
readonly selectedPlayer = computed<VideoPlayer>(() => {
|
||||
const temporary = this.recoverySession.temporaryPlayerOverride();
|
||||
return temporary
|
||||
? toVideoPlayer(temporary)
|
||||
: (this.playerOverride() ??
|
||||
this.settings()?.player ??
|
||||
VideoPlayer.VideoJs);
|
||||
});
|
||||
private readonly applicationState = createWebPlayerApplicationState({
|
||||
playback: this.playback,
|
||||
streamUrl: this.streamUrl,
|
||||
title: this.title,
|
||||
startTime: this.startTime,
|
||||
selectedPlayer: this.selectedPlayer,
|
||||
reloadToken: this.reloadToken,
|
||||
});
|
||||
readonly resolvedPlayback = this.applicationState.playback;
|
||||
readonly resolvedIsLive = this.applicationState.isLive;
|
||||
readonly playbackApplicationToken = this.applicationState.token;
|
||||
readonly effectiveStartTime = computed(() =>
|
||||
this.recoverySession.resumeStartTime(
|
||||
this.startTime(),
|
||||
this.resolvedIsLive()
|
||||
)
|
||||
);
|
||||
readonly visiblePlaybackDiagnostic = computed(() =>
|
||||
this.selectedPlayer() === VideoPlayer.EmbeddedMpv
|
||||
? null
|
||||
@@ -176,229 +162,147 @@ export class WebPlayerViewComponent implements OnDestroy {
|
||||
readonly playbackInteractionEnabled = computed(
|
||||
() => this.visiblePlaybackDiagnostic() === null
|
||||
);
|
||||
readonly canShowExternalFallbackActions = computed(
|
||||
() =>
|
||||
this.runtime.supportsManagedExternalPlayers &&
|
||||
!!this.visiblePlaybackDiagnostic()?.externalFallbackRecommended
|
||||
readonly resolvedMediaTitle = computed(() =>
|
||||
resolveWebPlayerMediaTitle(this.mediaTitle(), this.resolvedPlayback())
|
||||
);
|
||||
readonly diagnosticHeadlineKey = computed(() =>
|
||||
this.canShowExternalFallbackActions()
|
||||
? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
|
||||
: 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
|
||||
);
|
||||
|
||||
readonly resolvedPlayback = computed<ResolvedPortalPlayback>(() => {
|
||||
const playback = this.playback();
|
||||
if (playback) {
|
||||
return playback;
|
||||
}
|
||||
|
||||
return {
|
||||
streamUrl: this.streamUrl(),
|
||||
title: this.title() || this.streamUrl(),
|
||||
startTime: this.startTime(),
|
||||
};
|
||||
});
|
||||
readonly resolvedIsLive = computed(() => {
|
||||
const playback = this.resolvedPlayback();
|
||||
return typeof playback.isLive === 'boolean'
|
||||
? playback.isLive
|
||||
: !playback.contentInfo;
|
||||
});
|
||||
readonly selectedPlayer = computed(
|
||||
() =>
|
||||
this.playerOverride() ??
|
||||
this.settings()?.player ??
|
||||
VideoPlayer.VideoJs
|
||||
);
|
||||
readonly resolvedMediaTitle = computed<PlayerMediaTitle | null>(() => {
|
||||
const explicit = this.mediaTitle();
|
||||
if (explicit?.primary?.trim()) {
|
||||
return explicit;
|
||||
}
|
||||
const playback = this.resolvedPlayback();
|
||||
const title = playback.title?.trim();
|
||||
// resolvedPlayback() falls back to the stream URL as title; a raw URL
|
||||
// is not a watchable overlay title.
|
||||
if (!title || title === playback.streamUrl) {
|
||||
return null;
|
||||
}
|
||||
return { primary: title, secondary: null };
|
||||
});
|
||||
readonly recordingFolder = computed(
|
||||
() => this.settings()?.recordingFolder ?? ''
|
||||
);
|
||||
|
||||
/** Stream URL the currently configured Electron header override belongs to. */
|
||||
private headerScopeStreamUrl: string | null = null;
|
||||
private readonly streamHeaders = inject(ElectronStreamHeadersService);
|
||||
readonly recommendations = computed(() => {
|
||||
const binding = this.activeBinding();
|
||||
const token = this.playbackApplicationToken();
|
||||
return createWebPlayerRecommendations({
|
||||
diagnostic: this.visiblePlaybackDiagnostic(),
|
||||
binding:
|
||||
binding && this.applicationHandoff.owns(binding, token)
|
||||
? binding
|
||||
: null,
|
||||
attemptedTargets: this.recoverySession.attemptedTargets(),
|
||||
managedExternalPlayersAvailable:
|
||||
this.runtime.supportsManagedExternalPlayers,
|
||||
playback: this.resolvedPlayback(),
|
||||
isLive: this.resolvedIsLive(),
|
||||
alternativeSourceCount: this.alternativeSources().length,
|
||||
});
|
||||
});
|
||||
readonly renderedBindings = computed<readonly PlaybackBinding[]>(() => {
|
||||
const binding = this.activeBinding();
|
||||
return binding ? [binding] : [];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
// Track player changes so stale browser diagnostics are cleared on switch.
|
||||
this.selectedPlayer();
|
||||
|
||||
const playback = this.resolvedPlayback();
|
||||
const isLive = this.resolvedIsLive();
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.applyPlayback(playback, isLive);
|
||||
this.syncRecoverySession();
|
||||
const token = this.playbackApplicationToken();
|
||||
const playback = untracked(this.resolvedPlayback);
|
||||
const isLive = untracked(this.resolvedIsLive);
|
||||
const selectedPlayer = untracked(this.selectedPlayer);
|
||||
const reloadToken = untracked(this.reloadToken);
|
||||
const target = toInlinePlaybackPlayer(selectedPlayer);
|
||||
this.channel = undefined;
|
||||
this.vjsOptions = undefined;
|
||||
if (target === null) {
|
||||
this.applicationHandoff.release();
|
||||
this.recoverySession.clearPlaybackBinding();
|
||||
this.playbackDiagnostic.set(null);
|
||||
return;
|
||||
}
|
||||
const binding = untracked(() =>
|
||||
this.recoverySession.beginPlayback(target)
|
||||
);
|
||||
this.applicationHandoff.apply(
|
||||
playback,
|
||||
isLive,
|
||||
reloadToken,
|
||||
binding,
|
||||
token,
|
||||
() => this.playbackApplicationToken(),
|
||||
(handoff) => {
|
||||
this.channel = handoff.channel;
|
||||
this.vjsOptions = handoff.vjsOptions;
|
||||
this.playbackDiagnostic.set(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Portal credentials must not outlive the playback session that
|
||||
// needed them: dropping the scoped override here keeps only the
|
||||
// playlist-level (unscoped) User-Agent/Referer defaults active. The
|
||||
// service no-ops if a newer consumer already owns the override slot.
|
||||
this.streamHeaders.clear(this.headerScopeStreamUrl);
|
||||
this.applicationHandoff.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the scoped Electron request headers BEFORE the stream source
|
||||
* is handed to a player, so the very first media request already carries
|
||||
* them — an auth-gated portal stream answers 403 without its
|
||||
* Cookie/Authorization, and several engines treat that first failure as
|
||||
* fatal. In the PWA there is no header bridge and the source applies
|
||||
* synchronously, exactly as before.
|
||||
*/
|
||||
private applyPlayback(
|
||||
playback: ResolvedPortalPlayback,
|
||||
isLive: boolean
|
||||
handlePlaybackIssue(
|
||||
issue: PlaybackDiagnostic | null,
|
||||
binding: PlaybackBinding
|
||||
): void {
|
||||
const headerSync = this.streamHeaders.apply(playback);
|
||||
this.headerScopeStreamUrl = playback.streamUrl;
|
||||
const handOff = (): void => {
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(playback.streamUrl, isLive);
|
||||
};
|
||||
|
||||
if (!headerSync) {
|
||||
handOff();
|
||||
this.syncRecoverySession();
|
||||
if (!this.recoverySession.accepts(binding)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void headerSync.then((stillCurrent) => {
|
||||
if (stillCurrent) {
|
||||
handOff();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setVjsOptions(streamUrl: string, isLive = true) {
|
||||
const extension = getPlaybackMediaExtensionFromUrl(streamUrl);
|
||||
const mimeType =
|
||||
extension === 'm3u' || extension === 'm3u8'
|
||||
? 'application/x-mpegURL'
|
||||
: extension === 'ts' || !extension
|
||||
? 'video/mp2t'
|
||||
: extension === 'mkv'
|
||||
? 'video/matroska'
|
||||
: 'video/mp4';
|
||||
|
||||
this.vjsOptions = {
|
||||
isLive,
|
||||
reloadToken: untracked(() => this.reloadToken()),
|
||||
sources: [{ src: streamUrl, type: mimeType }],
|
||||
};
|
||||
}
|
||||
|
||||
setChannel(playbackOrUrl: ResolvedPortalPlayback | string) {
|
||||
const playback =
|
||||
typeof playbackOrUrl === 'string'
|
||||
? {
|
||||
streamUrl: playbackOrUrl,
|
||||
title: playbackOrUrl,
|
||||
}
|
||||
: playbackOrUrl;
|
||||
|
||||
this.channel = {
|
||||
id: playback.streamUrl,
|
||||
url: playback.streamUrl,
|
||||
name: playback.title || playback.streamUrl,
|
||||
group: { title: '' },
|
||||
tvg: {
|
||||
id: '',
|
||||
name: playback.title || playback.streamUrl,
|
||||
url: '',
|
||||
logo: playback.thumbnail ?? '',
|
||||
rec: '',
|
||||
},
|
||||
http: {
|
||||
referrer:
|
||||
playback.referer ??
|
||||
this.getHeaderValue(playback.headers, 'Referer') ??
|
||||
'',
|
||||
'user-agent':
|
||||
playback.userAgent ??
|
||||
this.getHeaderValue(playback.headers, 'User-Agent') ??
|
||||
'',
|
||||
origin:
|
||||
playback.origin ??
|
||||
this.getHeaderValue(playback.headers, 'Origin') ??
|
||||
'',
|
||||
},
|
||||
radio: 'false',
|
||||
drm: playback.drm,
|
||||
};
|
||||
}
|
||||
|
||||
handlePlaybackIssue(issue: PlaybackDiagnostic | null): void {
|
||||
if (this.selectedPlayer() === VideoPlayer.EmbeddedMpv) {
|
||||
if (
|
||||
!this.applicationHandoff.owns(
|
||||
binding,
|
||||
this.playbackApplicationToken()
|
||||
)
|
||||
) {
|
||||
this.applicationHandoff.invalidate();
|
||||
this.recoverySession.clearPlaybackBinding();
|
||||
this.playbackDiagnostic.set(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.playbackDiagnostic.set(issue);
|
||||
if (issue) {
|
||||
this.playbackFailed.emit(issue.code);
|
||||
if (!issue) {
|
||||
this.recoverySession.settle(binding);
|
||||
this.playbackDiagnostic.set(null);
|
||||
return;
|
||||
}
|
||||
if (!this.recoverySession.recordFailure(binding)) {
|
||||
return;
|
||||
}
|
||||
this.playbackDiagnostic.set(issue);
|
||||
this.playbackFailed.emit(issue.code);
|
||||
}
|
||||
|
||||
requestExternalFallback(player: ExternalPlayerName): void {
|
||||
handleTimeUpdate(event: { currentTime: number; duration: number }): void {
|
||||
this.recoverySession.recordTimeUpdate(event, this.resolvedIsLive());
|
||||
this.timeUpdate.emit(event);
|
||||
}
|
||||
|
||||
requestRecommendedPlayer(target: PlaybackRecommendationTarget): void {
|
||||
const diagnostic = this.visiblePlaybackDiagnostic();
|
||||
if (!diagnostic) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.externalFallbackRequested.emit({
|
||||
player,
|
||||
playback: this.resolvedPlayback(),
|
||||
diagnostic,
|
||||
});
|
||||
const available = this.recommendations().some(
|
||||
(item) => item.action === 'player' && item.target === target
|
||||
);
|
||||
if (!available) {
|
||||
if (target !== 'mpv' && target !== 'vlc') {
|
||||
this.recoverySession.recordInlineAttempt(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target === 'mpv' || target === 'vlc') {
|
||||
this.recoverySession.recordExternalAttempt(target);
|
||||
this.externalFallbackRequested.emit({
|
||||
player: target,
|
||||
playback: this.resolvedPlayback(),
|
||||
diagnostic,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive());
|
||||
}
|
||||
|
||||
retryPlayback(): void {
|
||||
const playback = this.resolvedPlayback();
|
||||
|
||||
this.playbackDiagnostic.set(null);
|
||||
this.reloadToken.update((value) => value + 1);
|
||||
this.setChannel(playback);
|
||||
this.setVjsOptions(playback.streamUrl, this.resolvedIsLive());
|
||||
}
|
||||
|
||||
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
|
||||
readonly getDiagnosticMeta = getDiagnosticMeta;
|
||||
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
|
||||
readonly getDiagnosticDetails = getDiagnosticDetails;
|
||||
|
||||
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
|
||||
return getDiagnosticDescriptionKey(
|
||||
issue,
|
||||
this.runtime.supportsManagedExternalPlayers
|
||||
);
|
||||
}
|
||||
|
||||
private getHeaderValue(
|
||||
headers: ResolvedPortalPlayback['headers'] | undefined,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
if (!this.recoverySession.beginRetry()) {
|
||||
return;
|
||||
}
|
||||
this.reloadToken.update((value) => value + 1);
|
||||
}
|
||||
|
||||
const matchingKey = Object.keys(headers).find(
|
||||
(key) => key.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return matchingKey ? headers[matchingKey] : undefined;
|
||||
private syncRecoverySession(): void {
|
||||
const sessionKey = this.playbackSessionKey();
|
||||
if (untracked(() => this.recoverySession.syncSession(sessionKey))) {
|
||||
this.playbackDiagnostic.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
|
||||
* without pulling in video.js, ArtPlayer or the Electron bridge.
|
||||
*/
|
||||
|
||||
@Component({ selector: 'app-vjs-player', template: '' })
|
||||
@Component({
|
||||
selector: 'app-vjs-player',
|
||||
template: '<div data-test-id="stub-vjs-player"></div>',
|
||||
})
|
||||
export class StubVjsPlayerComponent {
|
||||
readonly options = input<unknown>();
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
@@ -23,7 +26,10 @@ export class StubVjsPlayerComponent {
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-html-video-player', template: '' })
|
||||
@Component({
|
||||
selector: 'app-html-video-player',
|
||||
template: '<div data-test-id="stub-html-player"></div>',
|
||||
})
|
||||
export class StubHtmlVideoPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
@@ -40,7 +46,10 @@ export class StubHtmlVideoPlayerComponent {
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-art-player', template: '' })
|
||||
@Component({
|
||||
selector: 'app-art-player',
|
||||
template: '<div data-test-id="stub-art-player"></div>',
|
||||
})
|
||||
export class StubArtPlayerComponent {
|
||||
readonly channel = input<unknown>();
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
@@ -57,7 +66,10 @@ export class StubArtPlayerComponent {
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-embedded-mpv-player', template: '' })
|
||||
@Component({
|
||||
selector: 'app-embedded-mpv-player',
|
||||
template: '<div data-test-id="stub-embedded-mpv-player"></div>',
|
||||
})
|
||||
export class StubEmbeddedMpvPlayerComponent {
|
||||
readonly playback = input.required<unknown>();
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
|
||||
Reference in new issue
Block a user