Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
947cbd8
Parse keyid from multi-key widevine PSSH (#7415)
yajin2021 Aug 28, 2025
4a334e9
Fix PlayReady key endianness (#7510)
ShubhamSharma2311 Aug 29, 2025
5a62280
Error handling for PlayReady key with no status change (#7527)
robwalch Sep 5, 2025
a3c63bd
Multivariant Playlist parsing fixes (#7523)
robwalch Sep 6, 2025
0638d9a
Setup as audio-only when main segment has no video
robwalch Sep 8, 2025
0919731
Merge pull request #7530 from video-dev/bugfix/audio-only-main
robwalch Sep 9, 2025
c4d9bf3
Fix "Missing format identifier #EXTM3U" playlist parsing errors
robwalch Sep 9, 2025
e49270c
Use GitHub `release` env for npm publish (#7544)
tjenkinson Sep 18, 2025
785c0a5
Update npm to v11 before publish
tjenkinson Sep 18, 2025
6f91d50
Use Widevine KEYID or parse Playready when level keys are present
robwalch Sep 18, 2025
b282f03
Roll initPTS forward when needed by earlier segments
robwalch Sep 11, 2025
abb6945
Get KEYID from init segment 'tenc' when not found elsewhere
robwalch Sep 19, 2025
8f605bb
fix(interstitials): guard media.play() before chaining .catch() on le…
malickyeu Oct 24, 2025
837b242
Resolve setMediaKeys only once media is attached
robwalch Oct 31, 2025
5585cf9
Fix switching back to main audio after end-of-stream buffered
robwalch Nov 14, 2025
36000c6
Fix issue where some devices not works with playready (#7631)
KunXi-Fox Nov 14, 2025
074ad8d
Resolve end-of-stream with empty or gap segments at end of playlist
robwalch Oct 29, 2025
609ace6
Fix audio TS segment duration calculation
robwalch Nov 17, 2025
a2a8500
Fixed issue with progressive loading of subtitles by disabling progre…
krseager Nov 17, 2025
0b7e493
Do not reset interstitial when seeking between assets
robwalch Nov 18, 2025
2457e86
Fix fallback to primary on live start
robwalch Nov 18, 2025
ac19106
Fix FairPlay key ID patching
robwalch Nov 18, 2025
5fc166e
Merge upstream tag v1.6.15
hongfeih-es Mar 2, 2026
549c00d
chore: version patch
hongfeih-es Mar 2, 2026
2476595
fix:add missing changes
hongfeih-es Mar 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

release_npm:
# npm oidc is configured for this environment
# https://docs.npmjs.com/trusted-publishers#for-github-actions
environment: release
needs: [config, test_unit]
if: needs.config.outputs.tag || needs.config.outputs.isMainBranch == 'true'
runs-on: ubuntu-latest
Expand Down Expand Up @@ -318,12 +321,15 @@ jobs:
env:
CI: true

# Publishing with OICD needs >= 11.5.1
- name: Update npm to v11
run: npm install -g npm@11

- name: publish to npm
run: |
./scripts/publish-npm.sh
env:
CI: true
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
TAG: ${{ needs.config.outputs.tag }}

test_functional_required:
Expand Down
6 changes: 5 additions & 1 deletion api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3355,11 +3355,15 @@ export class LevelDetails {
export class LevelKey implements DecryptData {
constructor(method: string, uri: string, format: string, formatversions?: number[], iv?: Uint8Array<ArrayBuffer> | null, keyId?: string);
// (undocumented)
static addKeyIdForUri(uri: string): Uint8Array<ArrayBuffer>;
// (undocumented)
static clearKeyUriToKeyIdMap(): void;
// (undocumented)
readonly encrypted: boolean;
// (undocumented)
getDecryptData(sn: number | 'initSegment'): LevelKey | null;
getDecryptData(sn: number | 'initSegment', levelKeys?: {
[key: string]: LevelKey | undefined;
}): LevelKey | null;
// (undocumented)
readonly isCommonEncryption: boolean;
// (undocumented)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hls.js",
"version": "1.6.14",
"version": "1.6.16",
"license": "Apache-2.0",
"description": "JavaScript HLS client using MediaSourceExtension",
"homepage": "https://github.com/video-dev/hls.js",
Expand Down
3 changes: 0 additions & 3 deletions scripts/publish-npm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@
set -e

if [[ $(node ./scripts/check-already-published.js) = "not published" ]]; then
# write the token to config
# see https://docs.npmjs.com/private-modules/ci-server-config
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc
if [[ -z "$TAG" ]]; then
npm publish --provenance --tag canary
echo "Published canary."
Expand Down
13 changes: 9 additions & 4 deletions src/controller/base-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,7 +974,8 @@ export default class BaseStreamController
this.state = State.FRAG_LOADING;

// Load key before streaming fragment data
const dataOnProgress = this.config.progressive;
const dataOnProgress =
this.config.progressive && frag.type !== PlaylistLevelType.SUBTITLE;
let result: Promise<PartsLoadedData | FragLoadedData | null>;
if (dataOnProgress && keyLoadingPromise) {
result = keyLoadingPromise
Expand Down Expand Up @@ -2149,11 +2150,15 @@ export default class BaseStreamController
false,
);
if (!parsed) {
if (level.fragmentError === 0) {
// Mark and track the odd empty segment as a gap to avoid reloading
const mediaNotFound = this.transmuxer?.error === null;
if (
level.fragmentError === 0 ||
(mediaNotFound && (level.fragmentError < 2 || frag.endList))
) {
// Mark and track the odd (or last) empty segment as a gap to avoid reloading
this.treatAsGap(frag, level);
}
if (this.transmuxer?.error === null) {
if (mediaNotFound) {
const error = new Error(
`Found no media in fragment ${frag.sn} of ${this.playlistLabel()} ${frag.level} resetting transmuxer to fallback to playlist timing`,
);
Expand Down
12 changes: 12 additions & 0 deletions src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,18 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
if (this.sourceBufferCount) {
return;
}

if (
this.bufferCodecEventsTotal > 1 &&
!this.tracks.video &&
!data.video &&
data.audio?.id === 'main'
) {
// MVP is missing CODECS and only audio was found in main segment (#7524)
this.log(`Main audio-only`);
this.bufferCodecEventsTotal = 1;
}

if (this.mediaSourceOpenOrEnded) {
this.checkPendingTracks();
}
Expand Down
44 changes: 37 additions & 7 deletions src/controller/eme-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class EMEController extends Logger implements ComponentAPI {
keyLoadPolicy: LoadPolicy;
};
private media: HTMLMediaElement | null = null;
private mediaResolved?: () => void;
private keyFormatPromise: Promise<KeySystemFormats> | null = null;
private keySystemAccessPromises: {
[keysystem: string]: KeySystemAccessPromises | undefined;
Expand Down Expand Up @@ -704,6 +705,7 @@ class EMEController extends Logger implements ComponentAPI {
keySystem: KeySystems,
mediaKeys: MediaKeys,
): Promise<void> {
this.mediaResolved = undefined;
if (this.mediaKeys === mediaKeys) {
return Promise.resolve();
}
Expand All @@ -714,10 +716,20 @@ class EMEController extends Logger implements ComponentAPI {
// can be queued for execution for multiple key sessions.
const setMediaKeysPromise = Promise.all(queue).then(() => {
if (!this.media) {
this.mediaKeys = null;
throw new Error(
'Attempted to set mediaKeys without media element attached',
);
return new Promise((resolve: (value?: void) => void, reject) => {
this.mediaResolved = () => {
this.mediaResolved = undefined;
if (!this.media) {
return reject(
new Error(
'Attempted to set mediaKeys without media element attached',
),
);
}
this.mediaKeys = mediaKeys;
this.media.setMediaKeys(mediaKeys).then(resolve).catch(reject);
};
});
}
return this.media.setMediaKeys(mediaKeys);
});
Expand Down Expand Up @@ -877,7 +889,7 @@ class EMEController extends Logger implements ComponentAPI {
handleKeyStatus(keyStatus, context);
} else {
// Timeout key-status
const timeout = 0;
const timeout = 1000;
context.keyStatusTimeouts ||= {};
context.keyStatusTimeouts[keyId] ||= self.setTimeout(() => {
if ((!context.mediaKeysSession as any) || !this.mediaKeys) {
Expand Down Expand Up @@ -968,10 +980,17 @@ class EMEController extends Logger implements ComponentAPI {
'buffer' in keyId
? new Uint8Array(keyId.buffer, keyId.byteOffset, keyId.byteLength)
: new Uint8Array(keyId);

if (
mediaKeySessionContext.keySystem === KeySystems.PLAYREADY &&
keyIdArray.length === 16
) {
// On some devices, the key ID has already been converted for endianness.
// In such cases, this key ID is the one we need to cache.
const originKeyIdWithStatusChange = arrayToHex(keyIdArray);
// Cache the original key IDs to ensure compatibility across all cases.
keyStatuses[originKeyIdWithStatusChange] = status;

changeEndianness(keyIdArray);
}
const keyIdWithStatusChange = arrayToHex(keyIdArray);
Expand All @@ -982,6 +1001,7 @@ class EMEController extends Logger implements ComponentAPI {
this.log(
`key status change "${status}" for keyStatuses keyId: ${keyIdWithStatusChange} key-session "${mediaKeySessionContext.mediaKeysSession.sessionId}"`,
);

keyStatuses[keyIdWithStatusChange] = status;
},
);
Expand Down Expand Up @@ -1344,6 +1364,13 @@ class EMEController extends Logger implements ComponentAPI {

addEventListener(media, 'encrypted', this.onMediaEncrypted);
addEventListener(media, 'waitingforkey', this.onWaitingForKey);

const mediaResolved = this.mediaResolved;
if (mediaResolved) {
mediaResolved();
} else {
this.mediaKeys = media.mediaKeys;
}
}

private onMediaDetached() {
Expand All @@ -1361,6 +1388,10 @@ class EMEController extends Logger implements ComponentAPI {
this._requestLicenseFailureCount = 0;
this.keyIdToKeySessionPromise = {};
this.bannedKeyIds = {};
const mediaResolved = this.mediaResolved;
if (mediaResolved) {
mediaResolved();
}
if (!this.mediaKeys && !this.mediaKeySessions.length) {
return;
}
Expand Down Expand Up @@ -1414,8 +1445,7 @@ class EMEController extends Logger implements ComponentAPI {
}

private onManifestLoading() {
this.keyFormatPromise = null;
this.bannedKeyIds = {};
this._clear();
}

private onManifestLoaded(
Expand Down
18 changes: 11 additions & 7 deletions src/controller/fragment-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,7 @@ export class FragmentTracker implements ComponentAPI {
});
fragmentEntity.loaded = null;
if (Object.keys(fragmentEntity.range).length) {
fragmentEntity.buffered = true;
const endList = (fragmentEntity.body.endList =
frag.endList || fragmentEntity.body.endList);
if (endList) {
this.endListFragments[fragmentEntity.body.type] = fragmentEntity;
}
this.bufferedEnd(fragmentEntity, frag);
if (!isPartial(fragmentEntity)) {
// Remove older fragment parts from lookup after frag is tracked as buffered
this.removeParts(frag.sn - 1, frag.type);
Expand All @@ -247,6 +242,15 @@ export class FragmentTracker implements ComponentAPI {
}
}

private bufferedEnd(fragmentEntity: FragmentEntity, frag: MediaFragment) {
fragmentEntity.buffered = true;
const endList = (fragmentEntity.body.endList =
frag.endList || fragmentEntity.body.endList);
if (endList) {
this.endListFragments[fragmentEntity.body.type] = fragmentEntity;
}
}

private removeParts(snToKeep: number, levelType: PlaylistLevelType) {
const activeParts = this.activePartLists[levelType];
if (!activeParts) {
Expand Down Expand Up @@ -275,7 +279,7 @@ export class FragmentTracker implements ComponentAPI {
}
if (fragmentEntity) {
fragmentEntity.loaded = null;
fragmentEntity.buffered = true;
this.bufferedEnd(fragmentEntity, frag);
}
}

Expand Down
39 changes: 26 additions & 13 deletions src/controller/interstitials-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export type PlayheadTimes = {
};

function playWithCatch(media: HTMLMediaElement | null) {
media?.play().catch(() => {
(media?.play() as Promise<void> | undefined)?.catch(() => {
/* no-op */
});
}
Expand Down Expand Up @@ -968,7 +968,8 @@ export default class InterstitialsController
currentTime >= start + duration
) {
if (playingItem.event?.appendInPlace) {
this.clearInterstitial(playingItem.event, playingItem);
// Return SourceBuffer(s) to primary player and flush
this.clearAssetPlayers(playingItem.event, playingItem);
this.flushFrontBuffer(currentTime);
}
this.setScheduleToAssetAtTime(currentTime, playingAsset);
Expand Down Expand Up @@ -1533,6 +1534,9 @@ export default class InterstitialsController
return;
}
const main = this.hls.levels[data.level];
if (!main.details) {
return;
}
const currentSelection = {
...(this.mediaSelection || this.altSelection),
main,
Expand Down Expand Up @@ -2475,12 +2479,19 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
private clearInterstitial(
interstitial: InterstitialEvent,
toSegment: InterstitialScheduleItem | null,
) {
this.clearAssetPlayers(interstitial, toSegment);
// Remove asset list and resolved duration
interstitial.reset();
}

private clearAssetPlayers(
interstitial: InterstitialEvent,
toSegment: InterstitialScheduleItem | null,
) {
interstitial.assetList.forEach((asset) => {
this.clearAssetPlayer(asset.identifier, toSegment);
});
// Remove asset list and resolved duration
interstitial.reset();
}

private resetAssetPlayer(assetId: InterstitialAssetId) {
Expand Down Expand Up @@ -2738,16 +2749,16 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
// Fallback to Primary by on current or future events by updating schedule to skip errored interstitials/assets
const flushStart = interstitial.timelineStart;
const playingItem = this.effectivePlayingItem;
let timelinePos = this.timelinePos;
// Update schedule now that interstitial/assets are flagged with `error` for fallback
if (playingItem) {
this.log(
`Fallback to primary from event "${interstitial.identifier}" start: ${
flushStart
} pos: ${this.timelinePos} playing: ${segmentToString(
} pos: ${timelinePos} playing: ${segmentToString(
playingItem,
)} error: ${interstitial.error}`,
);
let timelinePos = this.timelinePos;
if (timelinePos === -1) {
timelinePos = this.hls.startPosition;
}
Expand All @@ -2759,14 +2770,15 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
this.attachPrimary(flushStart, null);
this.flushFrontBuffer(flushStart);
}
if (!this.schedule) {
return;
}
const scheduleIndex = this.schedule.findItemIndexAtTime(timelinePos);
this.setSchedulePosition(scheduleIndex);
} else {
} else if (timelinePos === -1) {
this.checkStart();
return;
}
if (!this.schedule) {
return;
}
const scheduleIndex = this.schedule.findItemIndexAtTime(timelinePos);
this.setSchedulePosition(scheduleIndex);
}

// Asset List loading
Expand Down Expand Up @@ -2819,8 +2831,9 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
const index = this.schedule.findItemIndexAtTime(this.timelinePos);
if (index !== scheduleIndex) {
interstitial.error = new Error(
`Interstitial no longer within playback range ${this.timelinePos} ${interstitial}`,
`Interstitial ${assets.length ? 'no longer within playback range' : 'asset-list is empty'} ${this.timelinePos} ${interstitial}`,
);
this.log(interstitial.error.message);
this.updateSchedule(true);
this.primaryFallback(interstitial);
return;
Expand Down
Loading
Loading