Skip to content

Commit 2e3a962

Browse files
committed
Prefer a codec continuity if possible
Current track choice behavior ----------------------------- The track-choice API of the RxPlayer was until now very simple in terms of behavior: - Either the application gives an explicit choice, usually by listening to the `newAvailablePeriods` event (whose first purpose is to indicate that new tracks/qualities choices can be made) and by calling API like `setAudioTrack` in this handler. - Either it doesn't do that and the RxPlayer just defines an order purely based on the content. The content itself can indicate priorities explicitly (e.g. the `selectionPriority` attributes), implicitly (`AdaptatationSet` with `role` set to `"main"`) or a third case that I would call "very implicitly": just the order in which they are defined in the Manifest. That's the sole rules the RxPlayer used for initial track selection, in order of importance (from the most to the least). Issue ----- We encountered cases where some multi-Periods Manifest do not indicate a preferred track priority and the application select none. In that case I written earlier we just rely on declaration order in the Manifest (first declared is preferred). However, thoses Manifest were not regular in that order, for some Periods in their case you would have AAC, then ec-3 declared, and for other Periods in the same content you would have the reverse. This led to probably unwanted codec switching, even leading to many case to "reloading" phases, where the screen would switch to black and rebuffer temporarily. My solution ----------- I propose here a very simple solution both in terms of logic and conceptually: if a track choice has already been made in that content, **and** if the application did not choose a track for that Period, just stay consistent with the last chosen track of the same type in terms of codec: if we last chose `ec-3` we continue with `ec-3`. There is however a big question and risk with this feature, it is that it supersedes even explicit Manifest hints like DASH' `AdaptationSet@selectionPriority`. I'm not sure 100% is what we want here. I'll have to check the spec and see how other players decide before being sure this is the right way.
1 parent 8b9b5e2 commit 2e3a962

1 file changed

Lines changed: 167 additions & 72 deletions

File tree

src/main_thread/tracks_store/tracks_store.ts

Lines changed: 167 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,37 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
113113
text: "error" | "continue";
114114
};
115115

116+
/**
117+
* Optionally indicate a preferred "codec family" for the next audio and
118+
* video track we encounter. The "codec family" is here just the string
119+
* indicating the codec identifier without the profile/level part if it exists
120+
* (e.g.: mp4a.40.2 => mp4a, avc1.64001e => avc1 etc.).
121+
*
122+
* Reasoning: For future audio / video periods without explicit settings, we
123+
* have to make our own choices for the initial track.
124+
*
125+
* Historically, we went from an order deduced only from the content (the
126+
* Manifest attributes and / or ordering), but there are also legitimate
127+
* reasons to also decide based on codec compatibility: e.g. to allow codec
128+
* continuity (with the possible other Periods we played until now with that
129+
* content).
130+
*
131+
* Doing this allows to reduce the probability of having decoding glitches,
132+
* temporary reloading, weird user experience changes etc.
133+
*/
134+
private _preferredCodecFamily: {
135+
/**
136+
* The preferred audio "codec family", e.g. `ec-3`, `mp4a` etc.
137+
* `null` if there's no preference.
138+
*/
139+
audio: string | null;
140+
/**
141+
* The preferred video "codec family", e.g. `avc1`, `hev1`, `vp8` etc.
142+
* `null` if there's no preference.
143+
*/
144+
video: string | null;
145+
};
146+
116147
constructor(args: {
117148
preferTrickModeTracks: boolean;
118149
defaultAudioTrackSwitchingMode: IAudioTrackSwitchingMode | undefined;
@@ -133,6 +164,10 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
133164
args.defaultAudioTrackSwitchingMode ??
134165
config.getCurrent().DEFAULT_AUDIO_TRACK_SWITCHING_MODE;
135166
this.onTracksNotPlayableForType = args.onTracksNotPlayableForType;
167+
this._preferredCodecFamily = {
168+
audio: null,
169+
video: null,
170+
};
136171
}
137172

138173
/**
@@ -386,12 +421,8 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
386421
}
387422

388423
const periodItem = getPeriodItem(this._storedPeriodInfo, periodInfo.period.id);
389-
if (
390-
periodItem !== undefined &&
391-
periodItem.isPeriodAdvertised &&
392-
periodItem[type].storedSettings === null
393-
) {
394-
periodItem[type].dispatcher?.updateTrack(null);
424+
if (periodItem !== undefined && periodItem.isPeriodAdvertised) {
425+
this._dispatchTrackSetting(periodItem[type], null);
395426
}
396427
}
397428

@@ -543,14 +574,50 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
543574
!trackObj.dispatcher.hasSetTrack() &&
544575
trackObj.storedSettings !== undefined
545576
) {
546-
trackObj.dispatcher.updateTrack(trackObj.storedSettings);
577+
// Just ensure we set it
578+
this._dispatchTrackSetting(trackObj, trackObj.storedSettings);
547579
}
548580
if (this._isDisposed) {
549581
return;
550582
}
551583
}
552584
}
553585

586+
/**
587+
* Update the track chosen by signaling it to the rest of the RxPlayer.
588+
*
589+
* Takes a `checkedSettings` argument as a safeguard: there's many API and
590+
* re-entrancy potential that easily lead to state desynchonization in our track
591+
* handling logic, so the idea is to re-check with what you expect the "settings"
592+
* to be: if not equal it will be assumed that another piece of code already took
593+
* care of track update since.
594+
*
595+
* @param {Object} periodInfo - The track object for the corresponding Period/type.
596+
* @param {Object} checkedSetting - The setting expected to be set. `null` for no
597+
* track. Will be re-checked against the state.
598+
*/
599+
private _dispatchTrackSetting(
600+
periodInfo: IVideoPeriodInfo | IAudioPeriodInfo | ITextPeriodInfo,
601+
checkedSetting:
602+
IVideoStoredSettings | IAudioStoredSettings | ITextStoredSettings | null,
603+
): void {
604+
if (
605+
this._isDisposed ||
606+
periodInfo.dispatcher === null ||
607+
periodInfo.storedSettings !== checkedSetting
608+
) {
609+
return;
610+
}
611+
612+
if (periodInfo.type === "video" || periodInfo.type === "audio") {
613+
this._preferredCodecFamily[periodInfo.type] =
614+
checkedSetting === null
615+
? null
616+
: (getCodecFamily(checkedSetting.adaptation) ?? null);
617+
}
618+
periodInfo.dispatcher.updateTrack(checkedSetting);
619+
}
620+
554621
/**
555622
* Throws an error if neither audio nor video tracks are selected for the given period.
556623
*
@@ -642,11 +709,7 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
642709
if (this._isDisposed) {
643710
return; // Someone disposed the `TracksStore` on the previous side-effect
644711
}
645-
646-
// Check again that no track change occurred in the meantime
647-
if (typeInfo.storedSettings === storedSettings) {
648-
typeInfo.dispatcher?.updateTrack(storedSettings);
649-
}
712+
this._dispatchTrackSetting(typeInfo, storedSettings);
650713
} else if (fallbackTrack === null && !noSourceMedia) {
651714
this.trigger("noPlayableTrack", {
652715
trackType,
@@ -676,14 +739,7 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
676739
reason: "no-playable-representation",
677740
});
678741
}
679-
if (typeInfo.storedSettings !== null || this._isDisposed) {
680-
// The previous "trackUpdate" event might have caused changes,
681-
// so we re-check to see if the selected track has been updated.
682-
// If it has, we exit early because the API consumer likely adjusted the settings,
683-
// and throwing an error now would be out of sync with their changes.
684-
} else {
685-
typeInfo.dispatcher?.updateTrack(null);
686-
}
742+
this._dispatchTrackSetting(typeInfo, null);
687743
} else if (fallbackBehavior === "error") {
688744
const noRepErr = new MediaError(
689745
"NO_PLAYABLE_REPRESENTATION",
@@ -707,14 +763,7 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
707763
reason: "no-playable-representation",
708764
});
709765
}
710-
if (typeInfo.storedSettings !== null || this._isDisposed) {
711-
// The previous "trackUpdate" event might have caused changes,
712-
// so we re-check to see if the selected track has been updated.
713-
// If it has, we exit early because the API consumer likely adjusted the settings,
714-
// and throwing an error now would be out of sync with their changes.
715-
} else {
716-
typeInfo.dispatcher?.updateTrack(null);
717-
}
766+
this._dispatchTrackSetting(typeInfo, null);
718767
}
719768

720769
// The previous event trigger could have had side-effects, so we
@@ -994,11 +1043,9 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
9941043
return; // Someone disposed the `TracksStore` on the previous side-effect
9951044
}
9961045
const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
997-
if (
998-
newPeriodItem !== undefined &&
999-
newPeriodItem[bufferType].storedSettings === storedSettings
1000-
) {
1001-
newPeriodItem[bufferType].dispatcher?.updateTrack(storedSettings);
1046+
const trackObj = newPeriodItem?.[bufferType];
1047+
if (trackObj !== undefined) {
1048+
this._dispatchTrackSetting(trackObj, storedSettings);
10021049
}
10031050
}
10041051

@@ -1086,11 +1133,8 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
10861133
return; // Someone disposed the `TracksStore` on the previous side-effect
10871134
}
10881135
const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
1089-
if (
1090-
newPeriodItem !== undefined &&
1091-
newPeriodItem.video.storedSettings === storedSettings
1092-
) {
1093-
newPeriodItem.video.dispatcher?.updateTrack(storedSettings);
1136+
if (newPeriodItem !== undefined) {
1137+
this._dispatchTrackSetting(newPeriodItem.video, storedSettings);
10941138
}
10951139
}
10961140

@@ -1131,11 +1175,9 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
11311175
return; // Someone disposed the `TracksStore` on the previous side-effect
11321176
}
11331177
const newPeriodItem = getPeriodItem(this._storedPeriodInfo, periodObj.period.id);
1134-
if (
1135-
newPeriodItem !== undefined &&
1136-
newPeriodItem[bufferType].storedSettings === null
1137-
) {
1138-
newPeriodItem[bufferType].dispatcher?.updateTrack(null);
1178+
const trackObj = newPeriodItem?.[bufferType];
1179+
if (trackObj !== undefined) {
1180+
this._dispatchTrackSetting(trackObj, null);
11391181
}
11401182

11411183
if (newPeriodItem !== undefined) {
@@ -1451,12 +1493,8 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
14511493
return; // Someone disposed the `TracksStore` on the previous side-effect
14521494
}
14531495
const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
1454-
if (
1455-
newPeriodItem !== undefined &&
1456-
newPeriodItem.isPeriodAdvertised &&
1457-
newPeriodItem.video.storedSettings === storedSettings
1458-
) {
1459-
newPeriodItem.video.dispatcher?.updateTrack(storedSettings);
1496+
if (newPeriodItem !== undefined && newPeriodItem.isPeriodAdvertised) {
1497+
this._dispatchTrackSetting(newPeriodItem.video, storedSettings);
14601498
}
14611499
}
14621500
}
@@ -1527,10 +1565,11 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
15271565
continue;
15281566
}
15291567

1530-
const audioAdaptation: IAdaptationMetadata | undefined = getSupportedAdaptations(
1568+
const audioAdaptation = getInitialAdaptation(
15311569
period,
15321570
"audio",
1533-
)[0];
1571+
this._preferredCodecFamily.audio,
1572+
);
15341573
if (audioAdaptation === undefined) {
15351574
trackStorePeriod.audio.storedSettings = null;
15361575
this.handleMissingOrUnplayableTrack(period, "audio", true);
@@ -1545,8 +1584,11 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
15451584
};
15461585
}
15471586

1548-
const baseVideoAdaptation: IAdaptationMetadata | undefined =
1549-
getSupportedAdaptations(period, "video")[0];
1587+
const baseVideoAdaptation = getInitialAdaptation(
1588+
period,
1589+
"video",
1590+
this._preferredCodecFamily.video,
1591+
);
15501592
if (baseVideoAdaptation === undefined) {
15511593
trackStorePeriod.video.storedSettings = null;
15521594
this.handleMissingOrUnplayableTrack(period, "video", true);
@@ -1623,7 +1665,7 @@ export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
16231665
trackInfo.storedSettings !== undefined &&
16241666
!trackInfo.dispatcher.hasSetTrack()
16251667
) {
1626-
trackInfo.dispatcher.updateTrack(trackInfo.storedSettings);
1668+
this._dispatchTrackSetting(trackInfo, trackInfo.storedSettings);
16271669
if (this._isDisposed) {
16281670
return;
16291671
}
@@ -1725,9 +1767,9 @@ function generatePeriodInfo(
17251767
inManifest,
17261768
isPeriodAdvertised: false,
17271769
isRemoved: false,
1728-
audio: { storedSettings: undefined, dispatcher: null },
1729-
video: { storedSettings: undefined, dispatcher: null },
1730-
text: { storedSettings: undefined, dispatcher: null },
1770+
audio: { type: "audio", storedSettings: undefined, dispatcher: null },
1771+
video: { type: "video", storedSettings: undefined, dispatcher: null },
1772+
text: { type: "text", storedSettings: undefined, dispatcher: null },
17311773
};
17321774
}
17331775

@@ -1833,6 +1875,7 @@ export interface ITSPeriodObject {
18331875
* the Manifest.
18341876
*/
18351877
interface IAudioPeriodInfo {
1878+
type: "audio";
18361879
/**
18371880
* Information on the last audio track settings wanted by the user.
18381881
* `null` if no audio track is wanted.
@@ -1869,26 +1912,13 @@ interface IAudioStoredSettings {
18691912
* the Manifest.
18701913
*/
18711914
export interface ITextPeriodInfo {
1915+
type: "text";
18721916
/**
18731917
* Information on the last text track settings wanted.
18741918
* `null` if no text track is wanted.
18751919
* `undefined` if not set yet.
18761920
*/
1877-
storedSettings:
1878-
| {
1879-
/** Contains the last `Adaptation` wanted by the user. */
1880-
adaptation: IAdaptationMetadata;
1881-
/** "Switching mode" in which the track switch should happen. */
1882-
switchingMode: "direct";
1883-
/**
1884-
* Contains the last locked `Representation`s for this `Adaptation` wanted
1885-
* by the user.
1886-
* `null` if no Representation is locked.
1887-
*/
1888-
lockedRepresentations: SharedReference<IRepresentationsChoice | null>;
1889-
}
1890-
| null
1891-
| undefined;
1921+
storedSettings: ITextStoredSettings | null | undefined;
18921922
/**
18931923
* Tracks are internally emitted through RxJS's `Subject`s.
18941924
* A `TrackDispatcher` allows to facilitate and centralize the management of
@@ -1901,11 +1931,25 @@ export interface ITextPeriodInfo {
19011931
dispatcher: TrackDispatcher | null;
19021932
}
19031933

1934+
interface ITextStoredSettings {
1935+
/** Contains the last `Adaptation` wanted by the user. */
1936+
adaptation: IAdaptationMetadata;
1937+
/** "Switching mode" in which the track switch should happen. */
1938+
switchingMode: "direct";
1939+
/**
1940+
* Contains the last locked `Representation`s for this `Adaptation` wanted
1941+
* by the user.
1942+
* `null` if no Representation is locked.
1943+
*/
1944+
lockedRepresentations: SharedReference<IRepresentationsChoice | null>;
1945+
}
1946+
19041947
/**
19051948
* Internal representation of video track preferences for a given `Period` of
19061949
* the Manifest.
19071950
*/
19081951
export interface IVideoPeriodInfo {
1952+
type: "video";
19091953
/**
19101954
* Information on the `id` of the last video track settings wanted.
19111955
* `null` if no video track is wanted.
@@ -1967,3 +2011,54 @@ export interface IVideoRepresentationsLockSettings {
19672011
representations: string[];
19682012
switchingMode?: IVideoRepresentationsSwitchingMode | undefined;
19692013
}
2014+
2015+
/**
2016+
* Determine a good initial Adaptation to start from in the given Period when
2017+
* you don't have a preference.
2018+
* @param {Object} period - The period Object on which `Adaptation` objects are
2019+
* announced.
2020+
* @param {string} trackType - e.g. "audio" or "video".
2021+
* @param {string|null} preferredCodecFamily - If set, you would prefer the
2022+
* Adaptation to have the given "codec family" (initial part of the codec
2023+
* identifier, e.g. `avc1`), but no obligation.
2024+
* @returns {Object|undefined} - The selected initial track. If `undefined`,
2025+
* there's no playable track in that Period for that type.
2026+
*/
2027+
function getInitialAdaptation(
2028+
period: IPeriodMetadata,
2029+
trackType: ITrackType,
2030+
preferredCodecFamily: string | null,
2031+
): IAdaptationMetadata | undefined {
2032+
const supportedAdaptations = getSupportedAdaptations(period, trackType);
2033+
2034+
let adaptation: IAdaptationMetadata | undefined;
2035+
if (preferredCodecFamily !== null) {
2036+
adaptation = arrayFind(
2037+
supportedAdaptations,
2038+
(a) => getCodecFamily(a) === preferredCodecFamily,
2039+
);
2040+
}
2041+
return adaptation ?? supportedAdaptations[0];
2042+
}
2043+
2044+
/**
2045+
* From the given track, returns what we will call its "codec family".
2046+
* This allows to return e.g. `avc1`, `hvc1`, `vp9` etc. without profile and
2047+
* level considerations.
2048+
* Returns `undefined` if this cannot be determined.
2049+
*
2050+
* The logic is only a naive algorithm and may produce false negatives
2051+
* @param {Object} a - The track to extract the codec family from.
2052+
* @returns {string|undefined}
2053+
*/
2054+
function getCodecFamily(a: IAdaptationMetadata): string | undefined {
2055+
const codec = a.representations[0]?.chosenCodec;
2056+
if (codec === undefined || codec === "") {
2057+
return undefined;
2058+
}
2059+
const initialPart = codec.split(".")[0];
2060+
if (initialPart === undefined || initialPart === "") {
2061+
return undefined;
2062+
}
2063+
return initialPart;
2064+
}

0 commit comments

Comments
 (0)