fix(playback): route non-HLS containers to the native source path (#1510)

The HTML5 player chose its engine by exclusion: anything that was not
mpd/ts/mp4 went to hls.js, so .mkv (the default Xtream series/VOD
container), .webm, .avi, .mov and .m4v were fed to hls.js as manifests.
hls.js raised a manifest error and the player showed the network/provider
diagnostic over media Chromium plays natively.

Add resolvePlaybackUrlSourceKind() to @iptvnator/playback/util as the one
URL-to-engine rule (mpd -> dash, m3u8/m3u -> hls, ts/m2ts/extension-less
-> mpegts, everything else -> native) and read it from both the HTML5
player and ArtPlayer's getArtPlayerVideoType(), so the two engines agree.
ArtPlayer serves every native container through a single
ART_PLAYER_NATIVE_SOURCE_TYPE custom type, keeping the source session the
owner of teardown and controls binding. The HTML5 native <source> carries
the video/mp4 hint only for MP4-family files: a hint the browser's
canPlayType() rejects makes it skip the source, and Chromium demuxes
containers it does not advertise there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-03 09:43:33 +02:00
1 parent 0a2f6121f8
commit 5e51c1ea5d
15 files changed
+257 -35

No files matched your search

@@ -0,0 +1,9 @@
---
type: fix
area: playback
---
The built-in HTML5 player now plays `.mkv`, `.webm`, `.avi`, `.mov` and other
non-HLS video files directly instead of handing them to the HLS engine, which
failed with a "network or provider loading error" on many Xtream episodes and
movies. ArtPlayer and the HTML5 player now choose their engine by the same rule.
+8 -1
View File
@@ -815,7 +815,14 @@ app as a real argument, so it is not an option.
**Video Players**:
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer. The HTML5
player and ArtPlayer pick their source engine from one URL rule,
`resolvePlaybackUrlSourceKind()` in `libs/playback/util` (`mpd` → Shaka,
`m3u8`/`m3u` → hls.js, `ts`/`m2ts`/extension-less → mpegts.js, every other
container → native `<video>`), so hls.js never receives an `.mkv`/`.webm`
file. The HTML5 native `<source>` carries a `video/mp4` hint only for
MP4-family files (`resolveNativeSourceMimeType`); a hint `canPlayType()`
rejects would make the browser skip the source.
- mpegts.js `1.8.1` errors from all three built-in players cross one
version-locked structured evidence boundary in `libs/playback/util`. It
retains only exact public type/detail pairs, pair-derived stage/failure,
+17 -4
View File
@@ -635,12 +635,25 @@ video.
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
The HTML5 player and ArtPlayer choose their source engine from one URL rule,
`resolvePlaybackUrlSourceKind()` in `@iptvnator/playback/util`, which maps the
normalized media extension to `dash` (`mpd`), `hls` (`m3u8`/`m3u`), `mpegts`
(`ts`, `m2ts`, and extension-less proxy/script URLs) or `native` (every other
container: `mkv`, `webm`, `mp4`, `avi`, `mov`, `m4v`, audio files). hls.js
therefore only ever receives HLS manifests; it used to be handed every
unlisted container and raised a manifest error over media Chromium plays by
itself. ArtPlayer serves the `native` kind through a single
`ART_PLAYER_NATIVE_SOURCE_TYPE` custom type so `ArtPlayerSourceSession` keeps
owning engine teardown and controls binding; the HTML5 player appends one
`<source>` whose `video/mp4` MIME hint is set only for MP4-family files, since
a hint the browser's `canPlayType()` rejects makes it skip the source
(`resolveNativeSourceMimeType()`).
MKV sources are attempted through Chromium's native Matroska path. Video.js
receives `video/matroska` for `.mkv` URLs and explicit query metadata such as
`extension=mkv` or `container=mkv`; ArtPlayer and HTML5 continue to use their
native video paths. This is container support rather than a universal codec
guarantee: native source or decode failures still produce a diagnostic whose
ranked actions may include MPV/VLC.
`extension=mkv` or `container=mkv`. This is container support rather than a
universal codec guarantee: native source or decode failures still produce a
diagnostic whose ranked actions may include MPV/VLC.
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
+3 -2
View File
@@ -1037,8 +1037,9 @@ player in settings.
because those players never receive its keys.
Channels with `drm.supported === false` emit a `DrmOrEncryption` diagnostic
with a fixed safe detail string and without starting an engine.
- HTML5 player: `extension === 'mpd'` branch in `playChannel()`. ArtPlayer:
`customType.mpd` in `ArtPlayerSourceSession`. Shared-controls bridge:
- HTML5 player: the `dash` branch of `playChannel()` (source kind from the
shared `resolvePlaybackUrlSourceKind()`). ArtPlayer: `customType.mpd` in
`ArtPlayerSourceSession`. Shared-controls bridge:
`WebVideoControlsSource` kind `'shaka'` + `WebVideoShakaControls`
(audio/text tracks via the Shaka 5 API — selecting a text track shows it,
`selectTextTrack(null)` hides).
+1
View File
@@ -6,4 +6,5 @@ export type * from './lib/diagnostics/shaka-error.types';
export * from './lib/playback-recommendation.model';
export * from './lib/playback-recommendation-policy';
export * from './lib/playback-session-key';
export * from './lib/playback-source-routing';
export * from './lib/playback-target-capabilities';
@@ -0,0 +1,43 @@
import { PlaybackSourceKind } from './playback-recommendation.model';
import { resolvePlaybackUrlSourceKind } from './playback-source-routing';
describe('resolvePlaybackUrlSourceKind', () => {
it.each([
['https://example.test/live/playlist.m3u8', PlaybackSourceKind.Hls],
[
'https://example.test/live/playlist.M3U8?token=signed',
PlaybackSourceKind.Hls,
],
['https://example.test/live/list.m3u', PlaybackSourceKind.Hls],
['https://example.test/play?format=hls', PlaybackSourceKind.Hls],
['https://example.test/live.mpd', PlaybackSourceKind.Dash],
[
'https://example.test/stream.MPD?token=signed#frag',
PlaybackSourceKind.Dash,
],
['https://example.test/raw.ts', PlaybackSourceKind.MpegTs],
['https://example.test/disc.m2ts', PlaybackSourceKind.MpegTs],
['https://example.test/live/user/pass/1', PlaybackSourceKind.MpegTs],
['https://example.test/live.php?id=7', PlaybackSourceKind.MpegTs],
['https://example.test/play?ext=mpegts', PlaybackSourceKind.MpegTs],
])('routes %s to the %s engine', (url, kind) => {
expect(resolvePlaybackUrlSourceKind(url)).toBe(kind);
});
it.each([
'https://example.test/movie/user/pass/2000000.mp4',
'https://example.test/series/user/pass/80000.mkv',
'https://example.test/movie.MKV?token=signed',
'https://example.test/play?extension=mkv',
'https://example.test/movie.webm',
'https://example.test/movie.avi',
'https://example.test/movie.mov',
'https://example.test/movie.m4v',
'https://example.test/track.mp3',
'https://example.test/movie.bin',
])('hands the non-HLS container %s to the native element', (url) => {
expect(resolvePlaybackUrlSourceKind(url)).toBe(
PlaybackSourceKind.Native
);
});
});
@@ -0,0 +1,46 @@
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/shared/m3u-utils';
import { PlaybackSourceKind } from './playback-recommendation.model';
/**
* Engine family a playback URL is routed to BEFORE any engine runs.
*
* `resolvePlaybackSourceKind()` answers the opposite question — which family
* a diagnostic came out of after an engine failed. Every built-in web player
* selects its source engine through this function, so the HTML5 player and
* ArtPlayer cannot disagree on what a URL is. A URL never yields `unknown`:
* an unrecognized container is exactly what the native `<video>` element is
* for.
*/
export type PlaybackUrlSourceKind = Exclude<
PlaybackSourceKind,
typeof PlaybackSourceKind.Unknown
>;
const DASH_MANIFEST_EXTENSION = 'mpd';
const HLS_MANIFEST_EXTENSIONS: ReadonlySet<string> = new Set(['m3u', 'm3u8']);
/**
* `.m2ts` (BDAV, 192-byte packets) rides with `.ts`: mpegts.js probes the
* packet size itself, so its `mpegts` player type covers both.
*/
const MPEG_TS_EXTENSIONS: ReadonlySet<string> = new Set(['ts', 'm2ts']);
export function resolvePlaybackUrlSourceKind(
url: string
): PlaybackUrlSourceKind {
const extension = getPlaybackMediaExtensionFromUrl(url);
if (extension === DASH_MANIFEST_EXTENSION) {
return PlaybackSourceKind.Dash;
}
if (HLS_MANIFEST_EXTENSIONS.has(extension)) {
return PlaybackSourceKind.Hls;
}
// Extension-less IPTV proxy/script URLs (`/live/user/pass/1`, `.php`)
// are predominantly raw MPEG-TS and keep the established engine.
if (!extension || MPEG_TS_EXTENSIONS.has(extension)) {
return PlaybackSourceKind.MpegTs;
}
// Every other container — mkv, webm, mp4, avi, mov, m4v, audio files —
// is handed to the browser. hls.js only ever receives HLS manifests: fed
// an .mkv it raised a manifest error over media Chromium plays natively.
return PlaybackSourceKind.Native;
}
@@ -1,4 +1,5 @@
import {
ART_PLAYER_NATIVE_SOURCE_TYPE,
buildArtPlayerChrome,
exitOwnedArtPlayerFullscreen,
getArtPlayerVideoType,
@@ -79,11 +80,25 @@ describe('ArtPlayer setup', () => {
'https://example.test/play?extension=m3u8&token=signed'
)
).toBe('m3u8');
expect(getArtPlayerVideoType('https://example.test/movie.mkv')).toBe(
'video/matroska'
expect(getArtPlayerVideoType('https://example.test/disc.m2ts')).toBe(
'ts'
);
expect(getArtPlayerVideoType('https://example.test/list.m3u')).toBe(
'm3u8'
);
});
it.each(['mkv', 'webm', 'avi', 'mov', 'm4v', 'mp4'])(
'hands .%s files to the session-owned native type, never hls.js',
(extension) => {
expect(
getArtPlayerVideoType(
`https://example.test/series/user/pass/80000.${extension}`
)
).toBe(ART_PLAYER_NATIVE_SOURCE_TYPE);
}
);
it('routes DASH manifests to the mpd custom type', () => {
expect(getArtPlayerVideoType('https://example.test/live.mpd')).toBe(
'mpd'
@@ -1,4 +1,17 @@
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util';
import {
PlaybackSourceKind,
getPlaybackMediaExtensionFromUrl,
resolvePlaybackUrlSourceKind,
} from '@iptvnator/playback/util';
/**
* ArtPlayer `type` under which every container that is not an HLS/DASH
* manifest or raw MPEG-TS (mkv, webm, mp4, avi, mov, m4v, …) reaches
* `ArtPlayerSourceSession`'s native path. One key keeps the session the owner
* of that source change (engine teardown plus controls binding) instead of
* ArtPlayer's bare `video.src` assignment for an unmatched type.
*/
export const ART_PLAYER_NATIVE_SOURCE_TYPE = 'native';
/**
* ArtPlayer option overrides for the legacy and shared-control surfaces.
@@ -67,23 +80,21 @@ export function resolveArtPlayerIsLive(
return extension === 'm3u8' || extension === 'ts' || !extension;
}
/**
* Maps the shared URL routing decision onto the `customType` keys served by
* `ArtPlayerSourceSession`, so ArtPlayer and the HTML5 player always pick the
* same engine for a URL.
*/
export function getArtPlayerVideoType(url: string): string {
const extension = getPlaybackMediaExtensionFromUrl(url);
switch (extension) {
case 'mkv':
return 'video/matroska';
case 'm3u8':
return 'm3u8';
case 'mp4':
return 'mp4';
case 'mpd':
switch (resolvePlaybackUrlSourceKind(url)) {
case PlaybackSourceKind.Dash:
return 'mpd';
case 'ts':
case PlaybackSourceKind.Hls:
return 'm3u8';
case PlaybackSourceKind.MpegTs:
return 'ts';
default:
// Extensionless IPTV proxy/script URLs are predominantly raw
// MPEG-TS and retain the established ArtPlayer fallback.
return extension ? 'auto' : 'ts';
return ART_PLAYER_NATIVE_SOURCE_TYPE;
}
}
@@ -23,6 +23,7 @@ import {
} from '../web-video-support/web-video-source-controls.bridge';
import { WebVideoSourceTracks } from '../web-video-support/web-video-source-tracks';
import { addHlsAudioTrackSettings } from './art-player-audio-tracks';
import { ART_PLAYER_NATIVE_SOURCE_TYPE } from './art-player-setup';
export interface ArtPlayerSourceSessionConfig {
sharedControls: boolean;
@@ -48,7 +49,8 @@ export class ArtPlayerSourceSession {
m3u8: (video, url, art) => this.startHls(video, url, art),
mpd: (video, url) => this.startDash(video, url),
ts: (video, url) => this.startMpegTs(video, url),
'video/matroska': (video, url) => this.startNative(video, url),
[ART_PLAYER_NATIVE_SOURCE_TYPE]: (video, url) =>
this.startNative(video, url),
};
private player: Artplayer | null = null;
@@ -101,7 +101,7 @@ export function resetArtPlayerSpecFixtures(): void {
}
export function getCustomType(
type: 'm3u8' | 'ts' | 'mkv'
type: 'm3u8' | 'mpd' | 'ts' | 'native'
): (video: HTMLVideoElement, url: string) => void {
const customType = (
artPlayerInstances[0].options['customType'] as Record<
@@ -81,7 +81,13 @@ describe('ArtPlayerComponent', () => {
},
});
getCustomType('video/matroska')(
// The mkv channel must reach the session's native custom type: an
// unregistered `type` would fall to ArtPlayer's bare `video.src`.
expect(artPlayerInstances[0].options['type']).toBe('native');
expect(
Object.keys(artPlayerInstances[0].options['customType'] as object)
).toContain('native');
getCustomType('native')(
artPlayerInstances[0].video,
'https://example.com/archive/movie.mkv'
);
@@ -7,8 +7,14 @@ import { PictureInPictureTestEnvironment } from '../player-controls/picture-in-p
import { SeriesPlaybackNavigationControlsComponent } from '../portal-inline-player/series-playback-navigation-controls.component';
import type { HtmlVideoPlayerComponent as HtmlVideoPlayerComponentInstance } from './html-video-player.component';
import {
TEST_CHANNEL,
cleanupSharedControlsTests,
configureSharedControlsTests,
hlsInstances,
mpegTsInstances,
mpegTsIsSupported,
observeBridgeSourceBinding,
readHtmlPlayerInternals,
renderSharedControls,
renderSharedControlsDefaults,
type SharedControlsFixture,
@@ -284,6 +290,43 @@ describe('HtmlVideoPlayerComponent shared controls host', () => {
expect(tracks[0].mode).toBe('showing');
});
it.each([
['mkv', null],
['webm', null],
['avi', null],
['mov', null],
['m4v', 'video/mp4'],
])(
'plays a .%s file through the native element instead of hls.js',
(extension, mimeHint) => {
// Both MSE engines report support, so a native pick is routing,
// not a fallback.
mpegTsIsSupported.mockReturnValue(true);
const { component, fixture } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{ isLive: false }
);
const setSource = observeBridgeSourceBinding(component);
const video = fixture.debugElement.query(By.css('video'))
.nativeElement as HTMLVideoElement;
const url = `https://example.test/series/user/pass/80000.${extension}`;
component.playChannel({ ...TEST_CHANNEL, url });
const internals = readHtmlPlayerInternals(component);
expect(hlsInstances).toHaveLength(0);
expect(mpegTsInstances).toHaveLength(0);
expect(internals.hls).toBeNull();
expect(internals.mpegtsPlayer).toBeNull();
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
expect(internals.controlsSource).toEqual({ kind: 'native' });
const sources = Array.from(video.querySelectorAll('source'));
expect(sources.map((source) => source.src)).toEqual([url]);
expect(sources[0]?.getAttribute('type')).toBe(mimeHint);
}
);
});
function restoreDocumentProperty(
@@ -20,7 +20,8 @@ import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
getPlaybackMediaExtensionFromUrl,
PlaybackSourceKind,
resolvePlaybackUrlSourceKind,
} from '@iptvnator/playback/util';
import {
type LegacyPlayerShortcuts,
@@ -35,6 +36,7 @@ import { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
import { exitOwnedFullscreen } from '../web-video-support/exit-owned-fullscreen.util';
import {
clearNativeVideoSources,
resolveNativeSourceMimeType,
setNativeVideoSource,
} from '../web-video-support/web-video-native-source.util';
import { WebVideoSourceTracks } from '../web-video-support/web-video-source-tracks';
@@ -197,7 +199,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
if (channel.url) {
this.playbackIssue.emit(null);
const url = channel.url + (channel.epgParams ?? '');
const extension = getPlaybackMediaExtensionFromUrl(channel.url);
const sourceKind = resolvePlaybackUrlSourceKind(channel.url);
// The scoped Electron header override is owned by
// WebPlayerViewComponent, which configures the full header set
@@ -205,7 +207,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
// receives the channel. Re-issuing the three-header call here
// would overwrite that richer override.
if (extension === 'mpd') {
if (sourceKind === PlaybackSourceKind.Dash) {
debugHtmlPlayer(
'Using Shaka Player for DASH stream:',
channel.name,
@@ -227,7 +229,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.handlePlayOperation();
}
} else if (
(extension === 'ts' || !extension) &&
sourceKind === PlaybackSourceKind.MpegTs &&
mpegts.isSupported()
) {
debugHtmlPlayer(
@@ -257,11 +259,14 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
this.mpegtsPlayer.load();
this.handlePlayOperation();
} else if (
extension !== 'mp4' &&
extension !== 'mpv' &&
sourceKind !== PlaybackSourceKind.Native &&
Hls &&
Hls.isSupported()
) {
// HLS manifests, plus raw MPEG-TS when mpegts.js is
// unavailable (the historical engine order). Native
// containers never reach hls.js: fed an .mkv it raised a
// manifest error over media the browser plays by itself.
debugHtmlPlayer('Starting HLS playback');
const hls = new Hls();
this.hls = hls;
@@ -280,7 +285,7 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
setNativeVideoSource(
this.videoPlayer.nativeElement,
url,
'video/mp4'
resolveNativeSourceMimeType(channel.url)
);
this.bindControlsSource({ kind: 'native' });
this.videoPlayer.nativeElement.load();
@@ -1,10 +1,28 @@
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util';
/** Helpers for driving the native `<video>` element source list. */
const MP4_FAMILY_EXTENSIONS: ReadonlySet<string> = new Set(['mp4', 'm4v']);
export function clearNativeVideoSources(element: HTMLVideoElement): void {
element.removeAttribute('src');
element.replaceChildren();
}
/**
* MIME hint for a native `<source>`, given only to MP4-family files.
*
* The hint is a filter, not a description: the browser skips a `<source>`
* whose type its `canPlayType()` rejects, and a container it demuxes (mkv,
* webm, mov, avi) is not necessarily one it advertises there. Those sources
* stay unhinted so the browser sniffs the bytes instead.
*/
export function resolveNativeSourceMimeType(url: string): string | undefined {
return MP4_FAMILY_EXTENSIONS.has(getPlaybackMediaExtensionFromUrl(url))
? 'video/mp4'
: undefined;
}
/**
* Replaces the element's sources with a single `<source>` without loading it;
* callers bind their controls source first, then call `element.load()`.
@@ -12,11 +30,13 @@ export function clearNativeVideoSources(element: HTMLVideoElement): void {
export function setNativeVideoSource(
element: HTMLVideoElement,
url: string,
type: string
type?: string
): void {
clearNativeVideoSources(element);
const source = document.createElement('source');
source.src = url;
source.type = type;
if (type) {
source.type = type;
}
element.appendChild(source);
}