UNPKG

shaka-player

Version:
629 lines (549 loc) 17.2 kB
/*! @license * Shaka Player * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview */ goog.provide('shaka.text.NativeTextDisplayer'); goog.require('mozilla.LanguageMapping'); goog.require('shaka.device.DeviceFactory'); goog.require('shaka.device.IDevice'); goog.require('shaka.text.Utils'); goog.require('shaka.util.EventManager'); goog.require('shaka.util.FakeEvent'); goog.require('shaka.util.LanguageUtils'); goog.require('shaka.util.ManifestParserUtils'); goog.require('shaka.util.Timer'); goog.requireType('shaka.Player'); /** * A text displayer plugin using the browser's native VTTCue interface. * * @implements {shaka.extern.TextDisplayer} * @export */ shaka.text.NativeTextDisplayer = class { /** * @param {shaka.Player} player */ constructor(player) { /** @private {?shaka.Player} */ this.player_ = player; /** @private {shaka.util.EventManager} */ this.eventManager_ = new shaka.util.EventManager(); /** @private {shaka.util.EventManager} */ this.loadEventManager_ = new shaka.util.EventManager(); /** @private {?shaka.extern.TextDisplayerConfiguration} */ this.config_ = null; /** @private {?HTMLMediaElement} */ this.video_ = null; /** @private {Map<number, !HTMLTrackElement>} */ this.trackNodes_ = new Map(); /** * ID of the currently active text track. -1 means no track is active. * @private {number} */ this.trackId_ = -1; /** @private {boolean} */ this.visible_ = false; /** * Timer used to debounce the textTracks 'change' event. * @private {?shaka.util.Timer} */ this.timer_ = null; this.eventManager_.listen(player, shaka.util.FakeEvent.EventName.Loaded, () => this.checkMsePlayback_()); this.checkMsePlayback_(); } /** * @override * @export */ configure(config) { this.config_ = config; } /** * Removes cues whose time range overlaps with [start, end). * Returns false only if this instance has already been destroyed. * * @override * @export */ remove(start, end) { if (!this.player_) { return false; } const activeTrack = this.getActiveTrack_(); if (activeTrack) { shaka.text.Utils.removeCuesFromTextTrack( activeTrack, (cue) => cue.startTime < end && cue.endTime > start); } return true; } /** * Appends cues to the active track, applying the subtitle delay if set. * * @override * @export */ append(cues) { const activeTrack = this.getActiveTrack_(); if (!activeTrack) { return; } const delay = this.config_?.subtitleDelay ?? 0; const adjustedCues = delay !== 0 ? cues.map((cue) => { const shifted = cue.clone(); shifted.startTime = Math.max(0, shifted.startTime + delay); shifted.endTime = Math.max(0, shifted.endTime + delay); return shifted; }) : cues; shaka.text.Utils.appendCuesToTextTrack(activeTrack, adjustedCues); } /** * @override * @export */ destroy() { if (this.player_) { if (this.video_) { this.onUnloading_(); } this.player_ = null; } this.timer_?.stop(); this.timer_ = null; this.eventManager_?.release(); this.eventManager_ = null; this.loadEventManager_?.release(); this.loadEventManager_ = null; return Promise.resolve(); } /** * @override * @export */ isTextVisible() { return this.visible_; } /** * Shows or hides subtitles. Handles both MSE and SRC_EQUALS playback modes. * * @override * @export */ setTextVisibility(on) { this.visible_ = on; const activeTrack = this.getActiveTrack_(); if (activeTrack) { this.applyVisibilityToTrack_(activeTrack, on); return; } if (this.isSrcEqualsMode_()) { this.applyVisibilityToSrcEqualsTracks_(on); } } /** * @override * @export */ setTextLanguage(_language) { // unused } /** * Cleans up internal state when the player starts unloading content. * Registered with listenOnce, so it fires at most once per playback session. * * @private */ onUnloading_() { this.timer_?.stop(); this.timer_ = null; this.loadEventManager_?.removeAll(); for (const trackNode of this.trackNodes_.values()) { trackNode.remove(); } this.trackNodes_.clear(); this.trackId_ = -1; this.video_ = null; } /** * Synchronises the DOM <track> elements with the player's track list. * Creates elements for new tracks, reuses existing ones, and removes * any that are no longer present. * * @private */ onTextChanged_() { /** @type {Map<number, !HTMLTrackElement>} */ const newTrackNodes = new Map(); const tracks = this.player_.getTextTracks(); for (const track of tracks) { const trackNode = this.trackNodes_.has(track.id) ? this.reuseTrackNode_(track) : this.createTrackNode_(track); newTrackNodes.set(track.id, trackNode); if (track.active) { this.trackId_ = track.id; } } // Remove from the DOM any tracks no longer in the player's list. for (const trackNode of this.trackNodes_.values()) { trackNode.remove(); } this.trackNodes_ = newTrackNodes; this.activateCurrentTrack_(); } /** * Handles manual changes to the video's textTracks (e.g. the user enables a * track through the browser's native subtitle menu). Applies debounce because * the 'change' event can fire multiple times in quick succession. * * @private */ onChange_() { if (this.timer_) { // A tick is already queued; the debounce absorbs additional events. return; } // Snapshot the current video reference so we can detect if it changes // while the timer is pending (e.g. an unload happens in the meantime). const videoSnapshot = this.video_; this.timer_ = new shaka.util.Timer(() => { this.timer_ = null; if (this.video_ !== videoSnapshot) { return; } const resolvedTrackId = this.resolveActiveTrackId_(); this.disableAllTracksExcept_(resolvedTrackId); if (this.trackId_ !== resolvedTrackId) { this.trackId_ = resolvedTrackId; this.syncTrackSelectionWithPlayer_(resolvedTrackId); } }).tickAfter(0); } /** * Returns the active TextTrack, or null if none is currently active. * * @return {?TextTrack} * @private */ getActiveTrack_() { return this.trackNodes_.has(this.trackId_) ? this.trackNodes_.get(this.trackId_).track : null; } /** * Applies the visibility mode to a specific track without touching tracks * that are already 'disabled' (e.g. manually turned off by the user). * * @param {TextTrack} track * @param {boolean} visible * @private */ applyVisibilityToTrack_(track, visible) { if (track.mode === 'disabled') { return; } const targetMode = visible ? 'showing' : 'hidden'; if (track.mode !== targetMode) { track.mode = targetMode; } } /** * Returns true if the player is currently in SRC_EQUALS mode. * * @return {boolean} * @private */ isSrcEqualsMode_() { if (!this.player_) { return false; } const LoadMode = shaka.text.NativeTextDisplayer.LoadMode; return this.player_.getLoadMode() === LoadMode.SRC_EQUALS; } /** * Manages subtitle visibility in SRC_EQUALS mode, where tracks are controlled * directly by the HTMLMediaElement rather than MSE. * * @param {boolean} on * @private */ applyVisibilityToSrcEqualsTracks_(on) { const textTracks = Array.from(this.player_.getMediaElement().textTracks) .filter((track) => ['captions', 'subtitles', 'forced'].includes(track.kind)); if (on) { // If a track is already 'showing', do nothing to avoid disrupting state. const alreadyShowing = textTracks.some((t) => t.mode === 'showing'); if (!alreadyShowing) { const firstHidden = textTracks.find((t) => t.mode === 'hidden'); if (firstHidden) { firstHidden.mode = 'showing'; } } } else { for (const track of textTracks) { if (track.mode === 'showing') { track.mode = 'hidden'; } } } } /** * Reuses an existing <track> DOM node for a known track. * Disables the node if the track is no longer active, and removes the entry * from the original map so that onTextChanged_ can detect orphaned nodes. * * @param {!shaka.extern.TextTrack} track * @return {!HTMLTrackElement} * @private */ reuseTrackNode_(track) { const trackNode = this.trackNodes_.get(track.id); if (!track.active && trackNode.track.mode !== 'disabled') { trackNode.track.mode = 'disabled'; } this.trackNodes_.delete(track.id); return trackNode; } /** * Creates a new <track> DOM element for the given track and appends it to * the video element. * * @param {!shaka.extern.TextTrack} track * @return {!HTMLTrackElement} * @private */ createTrackNode_(track) { const trackNode = /** @type {!HTMLTrackElement} */ ( this.video_.ownerDocument.createElement('track')); trackNode.kind = this.getTrackKind_(track); trackNode.label = this.getTrackLabel_(track); trackNode.srclang = this.resolveTrackLanguage_(track); // Chrome may refuse to list tracks without a src in its built-in caption // menu. In Safari, toggling a track from 'disabled'/'hidden' back to // 'showing' without a src causes a visible flash. The minimal WEBVTT data // URL prevents both issues. trackNode.src = 'data:,WEBVTT'; trackNode.track.mode = 'disabled'; this.video_.appendChild(trackNode); return trackNode; } /** * Resolves the appropriate srclang value for a track based on its declared * language. Falls back to 'und' (undetermined) if the language is unknown. * * @param {!shaka.extern.TextTrack} track * @return {string} * @private */ resolveTrackLanguage_(track) { if (!track.language) { return 'und'; } if (track.language in mozilla.LanguageMapping) { return track.language; } return shaka.util.LanguageUtils.getBase(track.language) ?? 'und'; } /** * Activates the track identified by this.trackId_ among the newly built * nodes, respecting the current mode if it was changed manually by the user. * * @private */ activateCurrentTrack_() { if (this.trackId_ <= -1) { return; } if (!this.trackNodes_.has(this.trackId_)) { this.trackId_ = -1; return; } const track = this.trackNodes_.get(this.trackId_).track; // Only update the mode when the track is 'disabled'. If the user changed // it manually (e.g. hid it), we respect that choice; onChange_ will update // visible_ accordingly. if (track.mode === 'disabled') { track.mode = this.visible_ ? 'showing' : 'hidden'; } } /** * Determines which track should be active after a 'change' event. * Prefers the previously selected track; otherwise picks the first 'showing' * track, and falls back to the first 'hidden' track. * * @return {number} The ID of the track to activate, or -1 if none. * @private */ resolveActiveTrackId_() { let trackId = -1; // Prefer the previously active track. if (this.trackNodes_.has(this.trackId_)) { const mode = this.trackNodes_.get(this.trackId_).track.mode; if (mode === 'showing') { return this.trackId_; } if (mode === 'hidden') { trackId = this.trackId_; } } // Fallback: find any 'showing' track, or the first 'hidden' one. for (const id of this.trackNodes_.keys()) { const trackNode = /** @type {!HTMLTrackElement} */ ( this.trackNodes_.get(id)); if (trackNode.track.mode === 'showing') { return id; } if (trackId < 0 && trackNode.track.mode === 'hidden') { trackId = id; } } return trackId; } /** * Sets all tracks except the specified one to 'disabled', avoiding * unnecessary change events on tracks that are already disabled. * * @param {number} keepTrackId * @private */ disableAllTracksExcept_(keepTrackId) { const keepNode = this.trackNodes_.get(keepTrackId); for (const trackNode of this.trackNodes_.values()) { if (trackNode !== keepNode && trackNode.track.mode !== 'disabled') { trackNode.track.mode = 'disabled'; } } } /** * Notifies the player of the newly selected track, or clears the selection * if trackId is -1. * * @param {number} trackId * @private */ syncTrackSelectionWithPlayer_(trackId) { if (trackId > -1) { const textTrack = this.player_.getTextTracks().find((t) => t.id === trackId); if (textTrack) { this.player_.selectTextTrack(textTrack); return; } } this.player_.selectTextTrack(null); } /** * Initialises MSE integration if the player is already in MEDIA_SOURCE mode. * Called from the constructor and again on each 'Loaded' event. * * @private */ checkMsePlayback_() { if (this.video_ || !this.player_) { return; } const LoadMode = shaka.text.NativeTextDisplayer.LoadMode; if (this.player_.getLoadMode() !== LoadMode.MEDIA_SOURCE) { return; } this.video_ = this.player_.getMediaElement(); const EventName = shaka.util.FakeEvent.EventName; this.eventManager_.listenOnce(this.player_, EventName.Unloading, () => this.onUnloading_()); this.loadEventManager_.listen(this.player_, EventName.TextChanged, () => this.onTextChanged_()); this.loadEventManager_.listen(this.video_.textTracks, 'change', () => this.onChange_()); this.onTextChanged_(); } /** * Returns the appropriate `kind` value for a <track> element. * WebKit requires the 'forced' kind for forced tracks; other browsers use * 'captions' for closed captions and 'subtitles' as the default. * * @param {!shaka.extern.TextTrack} track * @return {string} * @private */ getTrackKind_(track) { const device = shaka.device.DeviceFactory.getDevice(); if (track.forced && device.getBrowserEngine() === shaka.device.IDevice.BrowserEngine.WEBKIT) { return 'forced'; } const ManifestParserUtils = shaka.util.ManifestParserUtils; if (track.kind === ManifestParserUtils.TextStreamKind.CLOSED_CAPTION) { return 'captions'; } return 'subtitles'; } /** * Builds a human-readable label for a track. Priority order: * 1. track.label (if explicitly set) * 2. Intl.DisplayNames resolution (when available) * 3. Full language name from LanguageMapping (exact match) * 4. Base language name from LanguageMapping with variant in parentheses * 5. originalTextId with the language code in parentheses if they differ * * @param {!shaka.extern.TextTrack} track * @return {string} * @private */ getTrackLabel_(track) { if (track.label) { return track.label; } if (track.language) { const base = shaka.util.LanguageUtils.getBase(track.language); // 1. Intl.DisplayNames — preferred when available: provides OS-level // resolution for any valid BCP-47 tag in the user's UI locale without // relying on a hand-maintained mapping. if (window.Intl && 'DisplayNames' in Intl) { try { const displayNames = new Intl.DisplayNames(track.language, {type: 'language', languageDisplay: 'standard'}); const displayName = displayNames.of(track.language); // Only prefer it when it's reliable if (displayName && displayName.toLowerCase() != track.language.toLowerCase()) { return displayName.charAt(0).toUpperCase() + displayName.slice(1); } } catch (_e) { // Intl.DisplayNames may throw for malformed tags; fall through. } } // 2. Exact match in mozilla.LanguageMapping. const exactMatch = mozilla.LanguageMapping[track.language]; if (exactMatch) { return exactMatch; } // 3. Base-language match in mozilla.LanguageMapping, with the full tag // shown in parentheses so the variant is still visible to the user. const baseMatch = base && mozilla.LanguageMapping[base]; if (baseMatch) { return base === track.language ? baseMatch : `${baseMatch} (${track.language})`; } } // Last resort: use originalTextId, coercing nullish values to an empty // string. const fallback = String(track.originalTextId ?? ''); if (track.language && track.language !== track.originalTextId) { return `${fallback} (${track.language})`; } return fallback; } }; /** * Named constants mirroring shaka.Player.LoadMode to avoid magic numbers. * @enum {number} */ shaka.text.NativeTextDisplayer.LoadMode = { MEDIA_SOURCE: 2, SRC_EQUALS: 3, };