diff --git a/build/types/core b/build/types/core
index 91d42a374..7f9ffc03d 100644
--- a/build/types/core
+++ b/build/types/core
@@ -7,6 +7,7 @@
+../../lib/config/codec_switching_strategy.js
+../../lib/config/cross_boundary_strategy.js
++../../lib/config/position_area.js
+../../lib/config/repeat_mode.js
+../../lib/debug/asserts.js
diff --git a/build/types/ui b/build/types/ui
index 568417a86..6a591b77b 100644
--- a/build/types/ui
+++ b/build/types/ui
@@ -46,6 +46,7 @@
+../../ui/skip_previous_button.js
+../../ui/spacer.js
+../../ui/statistics_button.js
++../../ui/text_position.js
+../../ui/text_selection.js
+../../ui/toggle_stereoscopic.js
+../../ui/ui.js
diff --git a/demo/config.js b/demo/config.js
index 80358cf99..2c66cdba3 100644
--- a/demo/config.js
+++ b/demo/config.js
@@ -343,6 +343,20 @@ shakaDemo.Config = class {
/** @private */
addTextDisplayerSection_() {
+ const positionAreaOptions = shaka.config.PositionArea;
+ const positionAreaOptionNames = {
+ 'DEFAULT': 'Default',
+ 'TOP_LEFT': 'top left',
+ 'TOP_CENTER': 'top center',
+ 'TOP_RIGHT': 'top right',
+ 'CENTER_LEFT': 'center left',
+ 'CENTER': 'center',
+ 'CENTER_RIGHT': 'center right',
+ 'BOTTOM_LEFT': 'bottom left',
+ 'BOTTOM_CENTER': 'bottom center',
+ 'BOTTOM_RIGHT': 'bottom right',
+ };
+
const docLink = this.resolveExternLink_('.TextDisplayerConfiguration');
this.addSection_('Text displayer', docLink)
.addNumberInput_('Captions update period',
@@ -350,7 +364,11 @@ shakaDemo.Config = class {
/* canBeDecimal= */ true)
.addNumberInput_('Font scale factor',
'textDisplayer.fontScaleFactor',
- /* canBeDecimal= */ true);
+ /* canBeDecimal= */ true)
+ .addSelectInput_('Position area',
+ 'textDisplayer.positionArea',
+ positionAreaOptions,
+ positionAreaOptionNames);
}
/** @private */
diff --git a/docs/tutorials/text-displayer.md b/docs/tutorials/text-displayer.md
index f44918043..87505404c 100644
--- a/docs/tutorials/text-displayer.md
+++ b/docs/tutorials/text-displayer.md
@@ -43,6 +43,45 @@ player.configure({
});
```
+Note: Only supported on UITextDisplayer.
+
+##### Overriding Subtitle Position
+
+Shaka Player allows applications to override the default subtitle placement and render captions in predefined regions of the video viewport. This is useful when subtitles need to avoid UI overlays, follow accessibility guidelines, or provide a consistent layout across different content.
+
+By setting the `textDisplayer.positionArea` configuration option, applications can:
+
+- Explicitly control where subtitles are rendered on the screen
+- Override the automatic or cue-defined positioning
+- Update subtitle placement dynamically at runtime
+
+The following enum defines the supported subtitle placement areas:
+
+| Value | Screen Position |
+|------|-----------------|
+| `DEFAULT` | Default player behavior |
+| `TOP_LEFT` | Top left |
+| `TOP_CENTER` | Top center |
+| `TOP_RIGHT` | Top right |
+| `CENTER_LEFT` | Center left |
+| `CENTER` | Center of the screen |
+| `CENTER_RIGHT` | Center right |
+| `BOTTOM_LEFT` | Bottom left |
+| `BOTTOM_CENTER` | Bottom center |
+| `BOTTOM_RIGHT` | Bottom right |
+
+Example configuration:
+
+```js
+player.configure({
+ textDisplayer: {
+ positionArea: shaka.config.PositionArea.BOTTOM_CENTER,
+ },
+});
+```
+
+Note: Only supported on UITextDisplayer.
+
### Text displayer configuration
Additional configuration for the text displayer can be passed by calling:
diff --git a/docs/tutorials/ui-customization.md b/docs/tutorials/ui-customization.md
index 64017ebc6..2bc99aa46 100644
--- a/docs/tutorials/ui-customization.md
+++ b/docs/tutorials/ui-customization.md
@@ -80,6 +80,8 @@ The following elements can be added to the UI bar using this configuration value
* toggle_stereoscopic: adds a button that toggle between monoscopic and stereoscopic. The button
is visible only if playing a VR content.
* chapter: adds a button that controls the chapter selection.
+* captions-position: adds a button that controls the position of the captions.
+ The button is visible only if the content has at least one text track.
[Document Picture-in-Picture API]: https://developer.chrome.com/docs/web-platform/document-picture-in-picture/
@@ -111,6 +113,8 @@ The following buttons can be added to the overflow menu:
* save_video_frame: adds a button to save the current video frame.
* chapter: adds a button that controls the chapter selection.
* mute: adds a button that mutes/unmutes the video on click.
+* captions-position: adds a button that controls the position of the captions.
+ The button is visible only if the content has at least one text track.
Example:
diff --git a/externs/shaka/player.js b/externs/shaka/player.js
index a89592e39..ada2ed86e 100644
--- a/externs/shaka/player.js
+++ b/externs/shaka/player.js
@@ -2821,7 +2821,8 @@ shaka.extern.OfflineConfiguration;
/**
* @typedef {{
* captionsUpdatePeriod: number,
- * fontScaleFactor: number
+ * fontScaleFactor: number,
+ * positionArea: shaka.config.PositionArea,
* }}
*
* @description
@@ -2835,6 +2836,13 @@ shaka.extern.OfflineConfiguration;
* The font scale factor used to increase or decrease the font size.
*
* Defaults to 1.
+ * @property {shaka.config.PositionArea} positionArea
+ * The region within the viewing area where the subtitles are to be
+ * positioned. The default value indicates that they are positioned where
+ * the subtitle defines it, otherwise they are overwritten with the given
+ * position.
+ *
+ * Defaults to ''.
* @exportDoc
*/
shaka.extern.TextDisplayerConfiguration;
diff --git a/lib/config/position_area.js b/lib/config/position_area.js
new file mode 100644
index 000000000..24816ea5f
--- /dev/null
+++ b/lib/config/position_area.js
@@ -0,0 +1,24 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+goog.provide('shaka.config.PositionArea');
+
+/**
+ * @enum {number}
+ * @export
+ */
+shaka.config.PositionArea = {
+ 'DEFAULT': 0,
+ 'TOP_LEFT': 1,
+ 'TOP_CENTER': 2,
+ 'TOP_RIGHT': 3,
+ 'CENTER_LEFT': 4,
+ 'CENTER': 5,
+ 'CENTER_RIGHT': 6,
+ 'BOTTOM_LEFT': 7,
+ 'BOTTOM_CENTER': 8,
+ 'BOTTOM_RIGHT': 9,
+};
diff --git a/lib/text/cue.js b/lib/text/cue.js
index 494229eec..4b1c95e67 100644
--- a/lib/text/cue.js
+++ b/lib/text/cue.js
@@ -344,15 +344,29 @@ shaka.text.Cue = class {
clone() {
const clone = new shaka.text.Cue(0, 0, '');
- for (const k in this) {
- clone[k] = this[k];
-
- // Make copies of array fields, but only one level deep. That way, if we
- // change, for instance, textDecoration on the clone, we don't affect the
- // original.
- if (Array.isArray(clone[k])) {
- clone[k] = /** @type {!Array} */(clone[k]).slice();
+ /**
+ * Deep clone helper
+ * @param {*} value
+ * @return {*}
+ */
+ const deepClone = (value) => {
+ if (value === null || typeof value !== 'object') {
+ return value;
}
+
+ if (Array.isArray(value)) {
+ return value.map(deepClone);
+ }
+
+ const result = {};
+ for (const key in value) {
+ result[key] = deepClone(value[key]);
+ }
+ return result;
+ };
+
+ for (const k in this) {
+ clone[k] = deepClone(this[k]);
}
return clone;
diff --git a/lib/text/text_utils.js b/lib/text/text_utils.js
index bd0dea4d0..c49cf4987 100644
--- a/lib/text/text_utils.js
+++ b/lib/text/text_utils.js
@@ -425,4 +425,36 @@ shaka.text.Utils = class {
texTrack.mode = 'disabled';
}
}
+
+ /**
+ * Reset all positioning-related properties of a cue, including all nested
+ * cues.
+ *
+ * @param {!shaka.text.Cue} cue
+ */
+ static resetCuePositioning(cue) {
+ // Cue dummy to obtain the real default values
+ const defaultCue = new shaka.text.Cue(0, 0, '');
+
+ /**
+ * Copy default positioning values recursively
+ *
+ * @param {!shaka.text.Cue} target
+ */
+ const reset = (target) => {
+ target.line = defaultCue.line;
+ target.lineAlign = defaultCue.lineAlign;
+ target.position = defaultCue.position;
+ target.positionAlign = defaultCue.positionAlign;
+ target.size = defaultCue.size;
+ target.displayAlign = defaultCue.displayAlign;
+ target.region = defaultCue.region;
+
+ for (const nested of target.nestedCues) {
+ reset(nested);
+ }
+ };
+
+ reset(cue);
+ }
};
diff --git a/lib/text/ui_text_displayer.js b/lib/text/ui_text_displayer.js
index ef495271c..6c496e11b 100644
--- a/lib/text/ui_text_displayer.js
+++ b/lib/text/ui_text_displayer.js
@@ -8,11 +8,13 @@
goog.provide('shaka.text.UITextDisplayer');
goog.require('goog.asserts');
+goog.require('shaka.config.PositionArea');
goog.require('shaka.text.Cue');
goog.require('shaka.text.CueRegion');
goog.require('shaka.text.Utils');
goog.require('shaka.util.Dom');
goog.require('shaka.util.EventManager');
+goog.require('shaka.util.Lazy');
goog.require('shaka.util.Timer');
goog.requireType('shaka.Player');
@@ -331,7 +333,18 @@ shaka.text.UITextDisplayer = class {
for (const cue of cues) {
parents.push(cue);
+ let cueKey = cue;
+
let cueRegistry = this.currentCuesMap_.get(cue);
+ if (!cueRegistry && this.config_ &&
+ this.config_.positionArea != shaka.config.PositionArea.DEFAULT) {
+ for (const key of this.currentCuesMap_.keys()) {
+ if (shaka.text.Cue.equal(cue, key)) {
+ cueKey = key;
+ cueRegistry = this.currentCuesMap_.get(key);
+ }
+ }
+ }
const shouldBeDisplayed =
cue.startTime <= currentTime && cue.endTime > currentTime;
let wrapper = cueRegistry ? cueRegistry.wrapper : null;
@@ -350,7 +363,7 @@ shaka.text.UITextDisplayer = class {
if (!shouldBeDisplayed) {
// Since something has to be removed, we will need to update the DOM.
updateDOM = true;
- this.currentCuesMap_.delete(cue);
+ this.currentCuesMap_.delete(cueKey);
cueRegistry = null;
}
}
@@ -360,7 +373,7 @@ shaka.text.UITextDisplayer = class {
if (!cueRegistry) {
// The cue has to be made!
this.createCue_(cue, parents);
- cueRegistry = this.currentCuesMap_.get(cue);
+ cueRegistry = this.currentCuesMap_.get(cueKey);
wrapper = cueRegistry.wrapper;
updateDOM = true;
} else if (!this.isElementUnderTextContainer_(wrapper)) {
@@ -399,7 +412,15 @@ shaka.text.UITextDisplayer = class {
}
});
for (const cue of toPlant) {
- const cueRegistry = this.currentCuesMap_.get(cue);
+ let cueRegistry = this.currentCuesMap_.get(cue);
+ if (!cueRegistry &&
+ this.config_.positionArea != shaka.config.PositionArea.DEFAULT) {
+ for (const key of this.currentCuesMap_.keys()) {
+ if (shaka.text.Cue.equal(cue, key)) {
+ cueRegistry = this.currentCuesMap_.get(key);
+ }
+ }
+ }
goog.asserts.assert(cueRegistry, 'cueRegistry should exist.');
if (cueRegistry.regionElement) {
if (cueRegistry.regionElement.contains(container)) {
@@ -445,9 +466,15 @@ shaka.text.UITextDisplayer = class {
}
}
+ let cues = this.cues_;
+ if (this.config_ &&
+ this.config_.positionArea != shaka.config.PositionArea.DEFAULT) {
+ cues = cues.map((cue) => this.processCueStyle_(cue));
+ }
+
// Update the cues.
this.updateCuesRecursive_(
- this.cues_, this.textContainer_, currentTime, /* parents= */ []);
+ cues, this.textContainer_, currentTime, /* parents= */ []);
if (goog.DEBUG) {
// Previously, we had an issue (#2076) where cues sometimes were not
@@ -466,6 +493,55 @@ shaka.text.UITextDisplayer = class {
}
}
+ /** @private */
+ processCueStyle_(cue) {
+ goog.asserts.assert(
+ this.config_.positionArea !== shaka.config.PositionArea.DEFAULT,
+ 'processCueStyle_ is intended to use on non default positioning');
+ const modifiedCue = cue.clone();
+ shaka.text.Utils.resetCuePositioning(modifiedCue);
+ modifiedCue.region = shaka.text.UITextDisplayer.CustomRegion_.value();
+ switch (this.config_.positionArea) {
+ case shaka.config.PositionArea.TOP_LEFT:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.LEFT;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.BEFORE;
+ break;
+ case shaka.config.PositionArea.TOP_CENTER:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.CENTER;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.BEFORE;
+ break;
+ case shaka.config.PositionArea.TOP_RIGHT:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.RIGHT;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.BEFORE;
+ break;
+ case shaka.config.PositionArea.CENTER_LEFT:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.LEFT;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.CENTER;
+ break;
+ case shaka.config.PositionArea.CENTER:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.CENTER;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.CENTER;
+ break;
+ case shaka.config.PositionArea.CENTER_RIGHT:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.RIGHT;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.CENTER;
+ break;
+ case shaka.config.PositionArea.BOTTOM_LEFT:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.LEFT;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.AFTER;
+ break;
+ case shaka.config.PositionArea.BOTTOM_CENTER:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.CENTER;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.AFTER;
+ break;
+ case shaka.config.PositionArea.BOTTOM_RIGHT:
+ modifiedCue.textAlign = shaka.text.Cue.textAlign.RIGHT;
+ modifiedCue.displayAlign = shaka.text.Cue.displayAlign.AFTER;
+ break;
+ }
+ return modifiedCue;
+ }
+
/**
* Compute a unique internal id:
* Regions can reuse the id but have different dimensions, we need to
@@ -1037,3 +1113,16 @@ shaka.text.UITextDisplayer = class {
return null;
}
};
+
+/**
+ * @private {!shaka.util.Lazy}
+ */
+shaka.text.UITextDisplayer.CustomRegion_ = new shaka.util.Lazy(() => {
+ const region = new shaka.text.CueRegion();
+ region.id = 'shaka-custom-region';
+ region.height = 90;
+ region.width = 90;
+ region.viewportAnchorX = 5;
+ region.viewportAnchorY = 5;
+ return region;
+});
diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js
index 94c32e846..52c2cf6d8 100644
--- a/lib/util/player_configuration.js
+++ b/lib/util/player_configuration.js
@@ -10,6 +10,7 @@ goog.require('goog.asserts');
goog.require('shaka.abr.SimpleAbrManager');
goog.require('shaka.config.CodecSwitchingStrategy');
goog.require('shaka.config.CrossBoundaryStrategy');
+goog.require('shaka.config.PositionArea');
goog.require('shaka.config.RepeatMode');
goog.require('shaka.device.DeviceFactory');
goog.require('shaka.drm.DrmUtils');
@@ -392,6 +393,7 @@ shaka.util.PlayerConfiguration = class {
const textDisplayer = {
captionsUpdatePeriod: 0.25,
fontScaleFactor: 1,
+ positionArea: shaka.config.PositionArea.DEFAULT,
};
const queue = {
diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js
index 9dc2ba67a..f90dc4c2d 100644
--- a/shaka-player.uncompiled.js
+++ b/shaka-player.uncompiled.js
@@ -124,6 +124,7 @@ goog.require('shaka.ui.SkipPreviousButton');
goog.require('shaka.ui.SmallPlayButton');
goog.require('shaka.ui.Spacer');
goog.require('shaka.ui.StatisticsButton');
+goog.require('shaka.ui.TextPosition');
goog.require('shaka.ui.TextSelection');
goog.require('shaka.ui.ToggleStereoscopicButton');
goog.require('shaka.ui.VideoTypeSelection');
diff --git a/test/text/cue_unit.js b/test/text/cue_unit.js
new file mode 100644
index 000000000..48586b994
--- /dev/null
+++ b/test/text/cue_unit.js
@@ -0,0 +1,104 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+describe('Cue', () => {
+ describe('clone', () => {
+ /** @type {shaka.text.Cue} */
+ let cue;
+
+ beforeEach(() => {
+ cue = new shaka.text.Cue(10, 20, 'Hello world');
+
+ cue.direction = shaka.text.Cue.direction.HORIZONTAL_LEFT_TO_RIGHT;
+ cue.writingMode = shaka.text.Cue.writingMode.HORIZONTAL_TOP_TO_BOTTOM;
+ cue.lineAlign = shaka.text.Cue.lineAlign.CENTER;
+ cue.positionAlign = shaka.text.Cue.positionAlign.CENTER;
+ cue.textAlign = shaka.text.Cue.textAlign.CENTER;
+ cue.size = 80;
+ cue.line = 5;
+ cue.position = 50;
+ cue.fontSize = '16px';
+ cue.fontWeight = shaka.text.Cue.fontWeight.BOLD;
+ cue.color = 'white';
+ cue.backgroundColor = 'black';
+ cue.region = new shaka.text.CueRegion();
+ cue.region.id = 'region-1';
+ cue.region.width = 80;
+ cue.region.height = 40;
+ cue.region.viewportAnchorX = 10;
+ cue.region.viewportAnchorY = 20;
+ });
+
+ it('should return a new Cue instance', () => {
+ const clone = cue.clone();
+
+ expect(clone).toBeDefined();
+ expect(clone instanceof shaka.text.Cue).toBe(true);
+ expect(clone).not.toBe(cue);
+ });
+
+ it('should clone basic cue timing and payload', () => {
+ const clone = cue.clone();
+
+ expect(clone.startTime).toBe(10);
+ expect(clone.endTime).toBe(20);
+ expect(clone.payload).toBe('Hello world');
+ });
+
+ it('should copy primitive Cue properties correctly', () => {
+ const clone = cue.clone();
+
+ expect(clone.direction).toBe(cue.direction);
+ expect(clone.writingMode).toBe(cue.writingMode);
+ expect(clone.lineAlign).toBe(cue.lineAlign);
+ expect(clone.positionAlign).toBe(cue.positionAlign);
+ expect(clone.textAlign).toBe(cue.textAlign);
+ expect(clone.size).toBe(cue.size);
+ expect(clone.line).toBe(cue.line);
+ expect(clone.position).toBe(cue.position);
+ });
+
+ it('should copy style-related properties correctly', () => {
+ const clone = cue.clone();
+
+ expect(clone.fontSize).toBe(cue.fontSize);
+ expect(clone.fontWeight).toBe(cue.fontWeight);
+ expect(clone.color).toBe(cue.color);
+ expect(clone.backgroundColor).toBe(cue.backgroundColor);
+ });
+
+ it('should deep clone the region object', () => {
+ const clone = cue.clone();
+
+ expect(clone.region).toBeDefined();
+ expect(clone.region).not.toBe(cue.region);
+ expect(clone.region.id).toBe(cue.region.id);
+ expect(clone.region.width).toBe(cue.region.width);
+ expect(clone.region.height).toBe(cue.region.height);
+ expect(clone.region.viewportAnchorX).toBe(cue.region.viewportAnchorX);
+ expect(clone.region.viewportAnchorY).toBe(cue.region.viewportAnchorY);
+ });
+
+ // eslint-disable-next-line @stylistic/max-len
+ it('should not share references for region between original and clone', () => {
+ const clone = cue.clone();
+
+ clone.region.width = 100;
+ clone.region.viewportAnchorX = 999;
+
+ expect(cue.region.width).toBe(80);
+ expect(cue.region.viewportAnchorX).toBe(10);
+ });
+
+ it('should correctly clone null region', () => {
+ cue.region = null;
+
+ const clone = cue.clone();
+
+ expect(clone.region).toBeNull();
+ });
+ });
+});
diff --git a/test/text/text_utils_unit.js b/test/text/text_utils_unit.js
index ffb730462..b44d23e83 100644
--- a/test/text/text_utils_unit.js
+++ b/test/text/text_utils_unit.js
@@ -83,4 +83,102 @@ describe('TextUtils', () => {
.toBe(shaka.text.CueRegion.units.PERCENTAGE);
});
});
+
+ describe('shaka.text.Cue.resetCuePositioning', () => {
+ /** @type {shaka.text.Cue} */
+ let defaultCue;
+
+ beforeEach(() => {
+ defaultCue = new shaka.text.Cue(0, 0, '');
+ });
+
+ /**
+ * @return {!shaka.text.Cue}
+ */
+ const createPositionedCue = () => {
+ const cue = new shaka.text.Cue(5, 10, 'text');
+
+ cue.line = 5;
+ cue.lineAlign = shaka.text.Cue.lineAlign.END;
+ cue.position = 75;
+ cue.positionAlign = shaka.text.Cue.positionAlign.RIGHT;
+ cue.size = 50;
+ cue.displayAlign = shaka.text.Cue.displayAlign.AFTER;
+
+ cue.region = new shaka.text.CueRegion();
+ cue.region.id = 'region';
+
+ return cue;
+ };
+
+ it('resets positioning properties to default values', () => {
+ const cue = createPositionedCue();
+
+ shaka.text.Utils.resetCuePositioning(cue);
+
+ expect(cue.line).toBe(defaultCue.line);
+ expect(cue.lineAlign).toBe(defaultCue.lineAlign);
+ expect(cue.position).toBe(defaultCue.position);
+ expect(cue.positionAlign).toBe(defaultCue.positionAlign);
+ expect(cue.size).toBe(defaultCue.size);
+ expect(cue.displayAlign).toBe(defaultCue.displayAlign);
+ expect(cue.region).toEqual(defaultCue.region);
+ });
+
+ it('does not modify non-positioning properties', () => {
+ const cue = createPositionedCue();
+
+ cue.startTime = 123;
+ cue.endTime = 456;
+ cue.payload = 'original text';
+
+ shaka.text.Utils.resetCuePositioning(cue);
+
+ expect(cue.startTime).toBe(123);
+ expect(cue.endTime).toBe(456);
+ expect(cue.payload).toBe('original text');
+ });
+
+ it('resets positioning recursively for nested cues', () => {
+ const parent = createPositionedCue();
+ const child1 = createPositionedCue();
+ const child2 = createPositionedCue();
+
+ parent.nestedCues.push(child1);
+ child1.nestedCues.push(child2);
+
+ shaka.text.Utils.resetCuePositioning(parent);
+
+ // Parent
+ expect(parent.line).toBe(defaultCue.line);
+ expect(parent.position).toBe(defaultCue.position);
+
+ // First level
+ expect(child1.line).toBe(defaultCue.line);
+ expect(child1.position).toBe(defaultCue.position);
+
+ // Second level
+ expect(child2.line).toBe(defaultCue.line);
+ expect(child2.position).toBe(defaultCue.position);
+ });
+
+ it('resets region to the default cue region', () => {
+ const cue = createPositionedCue();
+
+ shaka.text.Utils.resetCuePositioning(cue);
+
+ expect(cue.region).toEqual(defaultCue.region);
+ });
+
+ it('handles cues without nested cues', () => {
+ const cue = createPositionedCue();
+
+ expect(cue.nestedCues.length).toBe(0);
+
+ shaka.text.Utils.resetCuePositioning(cue);
+
+ expect(cue.line).toBe(defaultCue.line);
+ expect(cue.positionAlign).toBe(defaultCue.positionAlign);
+ });
+ });
});
diff --git a/test/text/ui_text_displayer_unit.js b/test/text/ui_text_displayer_unit.js
index 5915d1cb4..c89cba5d0 100644
--- a/test/text/ui_text_displayer_unit.js
+++ b/test/text/ui_text_displayer_unit.js
@@ -630,4 +630,58 @@ describe('UITextDisplayer', () => {
expect(videoContainer.childNodes.length).toBe(0);
});
+
+ it('positions cue at top-left when positionArea=TOP_LEFT', () => {
+ /** @type {!shaka.text.Cue} */
+ const cue = new shaka.text.Cue(0, 100, 'Top-Left');
+
+ textDisplayer.setTextVisibility(true);
+ const player = new shaka.Player();
+ const config = player.getConfiguration().textDisplayer;
+ config.positionArea = shaka.config.PositionArea.TOP_LEFT;
+ textDisplayer.configure(config);
+
+ textDisplayer.append([cue]);
+ updateCaptions();
+
+ /** @type {Element} */
+ const textContainer = videoContainer.querySelector('.shaka-text-container');
+
+ // Top-level cue should be a DIV
+ const cueElement = textContainer.querySelector('div');
+
+ // The custom region (CustomRegion) should be created and wrap the cue
+ const regionElement = textContainer.querySelector('.shaka-text-region');
+
+ // --- Region validations (CustomRegion: 90% x 90% at top/left 5%) ---
+ const regionCss = parseCssText(regionElement.style.cssText);
+ expect(regionCss).toEqual(jasmine.objectContaining({
+ 'position': 'absolute',
+ 'height': '90%',
+ 'width': '90%',
+ 'top': '5%',
+ 'left': '5%',
+ 'display': 'flex',
+ 'flex-direction': 'column',
+ 'align-items': 'center',
+ // displayAlign BEFORE => justifyContent 'flex-start'
+ 'justify-content': 'flex-start',
+ }));
+
+ // --- Cue validations (LEFT + BEFORE) ---
+ const cueCss = parseCssText(cueElement.style.cssText);
+ expect(cueCss).toEqual(jasmine.objectContaining({
+ 'display': 'flex',
+ 'flex-direction': 'column',
+ // textAlign LEFT => alignItems 'start' and width 100% (setCaptionStyles_)
+ 'align-items': 'start',
+ 'width': '100%',
+ // displayAlign BEFORE => justifyContent 'flex-start'
+ 'justify-content': 'flex-start',
+ 'text-align': 'left',
+ }));
+
+ // Text content should be present
+ expect(cueElement.textContent).toBe('Top-Left');
+ });
});
diff --git a/ui/enums.js b/ui/enums.js
index aa613fd1f..3e92fb5fa 100644
--- a/ui/enums.js
+++ b/ui/enums.js
@@ -22,6 +22,7 @@ shaka.ui.Enums.MaterialDesignSVGIcons = {
'CLOSE': 'M480-424 284-228q-11 11-28 11t-28-11q-11-11-11-28t11-28l196-196-196-196q-11-11-11-28t11-28q11-11 28-11t28 11l196 196 196-196q11-11 28-11t28 11q11 11 11 28t-11 28L536-480l196 196q11 11 11 28t-11 28q-11 11-28 11t-28-11L480-424Z',
'CLOSED_CAPTIONS': 'M200-160q-33 0-56.5-23.5T120-240v-480q0-33 23.5-56.5T200-800h560q33 0 56.5 23.5T840-720v480q0 33-23.5 56.5T760-160H200Zm80-200h120q17 0 28.5-11.5T440-400v-20q0-9-6-15t-15-6h-18q-9 0-15 6t-6 15h-80v-120h80q0 9 6 15t15 6h18q9 0 15-6t6-15v-20q0-17-11.5-28.5T400-600H280q-17 0-28.5 11.5T240-560v160q0 17 11.5 28.5T280-360Zm400-240H560q-17 0-28.5 11.5T520-560v160q0 17 11.5 28.5T560-360h120q17 0 28.5-11.5T720-400v-20q0-9-6-15t-15-6h-18q-9 0-15 6t-6 15h-80v-120h80q0 9 6 15t15 6h18q9 0 15-6t6-15v-20q0-17-11.5-28.5T680-600Z',
'CLOSED_CAPTIONS_OFF': 'M791-57 687-160H200q-33 0-56.5-23.5T120-240v-487l-65-65q-12-12-12-28.5T55-849q12-12 28.5-12t28.5 12l736 736q12 12 12 28t-12 28q-12 12-28.5 12T791-57ZM280-360h120q17 0 28.5-11.5T440-400v-25q0-8-6-14t-14-6h-20q-8 0-14 6t-6 14v5h-80v-127l-45-45v1q-7 5-11 13t-4 18v160q0 17 11.5 28.5T280-360Zm560-360v388q0 27-24.5 37.5T772-303l-66-66q7-5 10.5-13.5T720-400v-20q0-8-6-14t-14-6h-20q-8 0-14 6t-6 14h-5l-75-75v-45h80v5q0 8 6 14t14 6h20q8 0 14-6t6-14v-25q0-17-11.5-28.5T680-600H560q-17 0-28.5 11.5T520-560v5L343-732q-19-19-8.5-43.5T372-800h388q33 0 56.5 23.5T840-720Z',
+ 'CLOSED_CAPTIONS_POSITION': 'M160-240v-480 480Zm160-240v-80h-80v80h80Zm108 0q11-23 25.5-43t32.5-37h-86v80h28Zm-24 160q-2-10-2.5-19.5T401-360q0-11 .5-20.5T404-400H240v80h164Zm81 160H160q-33 0-56.5-23.5T80-240v-480q0-33 23.5-56.5T160-800h640q33 0 56.5 23.5T880-720v166q-17-18-37-32.5T800-612v-108H160v480h268q11 23 25 43t32 37Zm235 0h-80l-12-60q-12-5-22.5-10.5T584-244l-58 18-40-68 46-40q-2-13-2-26t2-26l-46-40 40-68 58 18q11-8 21.5-13.5T628-500l12-60h80l12 60q12 5 23 11.5t21 14.5l58-20 40 70-46 40q2 13 2 25t-2 25l46 40-40 68-58-18q-11 8-21.5 13.5T732-220l-12 60Zm-40-120q33 0 56.5-23.5T760-360q0-33-23.5-56.5T680-440q-33 0-56.5 23.5T600-360q0 33 23.5 56.5T680-280Z',
'CHECKMARK': 'm382-354 339-339q12-12 28-12t28 12q12 12 12 28.5T777-636L410-268q-12 12-28 12t-28-12L182-440q-12-12-11.5-28.5T183-497q12-12 28.5-12t28.5 12l142 143Z',
'LANGUAGE': 'M480-80q-82 0-155-31.5t-127.5-86Q143-252 111.5-325T80-480q0-83 31.5-155.5t86-127Q252-817 325-848.5T480-880q83 0 155.5 31.5t127 86q54.5 54.5 86 127T880-480q0 82-31.5 155t-86 127.5q-54.5 54.5-127 86T480-80Zm0-82q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z',
'PIP': 'M120-520q-17 0-28.5-11.5T80-560q0-17 11.5-28.5T120-600h104L80-743q-12-12-12-28.5T80-800q12-12 28.5-12t28.5 12l143 144v-104q0-17 11.5-28.5T320-800q17 0 28.5 11.5T360-760v200q0 17-11.5 28.5T320-520H120Zm40 360q-33 0-56.5-23.5T80-240v-160q0-17 11.5-28.5T120-440q17 0 28.5 11.5T160-400v160h280q17 0 28.5 11.5T480-200q0 17-11.5 28.5T440-160H160Zm680-280q-17 0-28.5-11.5T800-480v-240H480q-17 0-28.5-11.5T440-760q0-17 11.5-28.5T480-800h320q33 0 56.5 23.5T880-720v240q0 17-11.5 28.5T840-440ZM600-160q-17 0-28.5-11.5T560-200v-120q0-17 11.5-28.5T600-360h240q17 0 28.5 11.5T880-320v120q0 17-11.5 28.5T840-160H600Z',
diff --git a/ui/locales/de.json b/ui/locales/de.json
index ffc1c72a3..add3de7c6 100644
--- a/ui/locales/de.json
+++ b/ui/locales/de.json
@@ -8,9 +8,16 @@
"AUTO_GENERATED": "Automatisch erzeugt",
"AUTO_QUALITY": "Automatisch",
"BACK": "Zurück",
+ "BOTTOM_CENTER": "Unten mittig",
+ "BOTTOM_LEFT": "Unten links",
+ "BOTTOM_RIGHT": "Unten rechts",
"CAPTIONS": "Untertitel",
"CAST": "Streamen...",
+ "CENTER": "Mitte",
+ "CENTER_LEFT": "Mittig links",
+ "CENTER_RIGHT": "Mittig rechts",
"CHAPTERS": "Kapitel",
+ "DEFAULT": "Standard",
"DOWNLOAD_VIDEO_FRAME": "Videoframe speichern",
"ENTER_LOOP_MODE": "In Endlosschleife spielen",
"ENTER_PICTURE_IN_PICTURE": "Bild im Bild aktivieren",
@@ -43,8 +50,12 @@
"SKIP_TO_LIVE": "Zum Live-Videostream wechseln",
"STATISTICS": "Statistiken",
"SUBTITLE_FORCED": "Erzwungen",
+ "SUBTITLE_POSITION": "Untertitelposition",
"SURROUND": "Surround",
"TOGGLE_STEREOSCOPIC": "Einstellung „stereoskopisch“ ein‑/ausschalten",
+ "TOP_CENTER": "Oben mittig",
+ "TOP_LEFT": "Oben links",
+ "TOP_RIGHT": "Oben rechts",
"UNDETERMINED_LANGUAGE": "Unbestimmt",
"UNMUTE": "Stummschaltung aufheben",
"UNRECOGNIZED_LANGUAGE": "Unbekannt",
diff --git a/ui/locales/en.json b/ui/locales/en.json
index b9a7b61b6..b098bb7cb 100644
--- a/ui/locales/en.json
+++ b/ui/locales/en.json
@@ -8,10 +8,17 @@
"AUTO_GENERATED": "Auto generated",
"AUTO_QUALITY": "Auto",
"BACK": "Back",
+ "BOTTOM_CENTER": "Bottom center",
+ "BOTTOM_LEFT": "Bottom left",
+ "BOTTOM_RIGHT": "Bottom right",
"CAPTIONS": "Captions",
"CAST": "Cast...",
+ "CENTER": "Center",
+ "CENTER_LEFT": "Center left",
+ "CENTER_RIGHT": "Center right",
"CHAPTERS": "Chapters",
"COPY_VIDEO_FRAME": "Copy video frame",
+ "DEFAULT": "Default",
"DOWNLOAD_VIDEO_FRAME": "Save video frame",
"ENTER_LOOP_MODE": "Loop the current video",
"ENTER_PICTURE_IN_PICTURE": "Enter Picture-in-Picture",
@@ -46,8 +53,12 @@
"SKIP_PREVIOUS": "Previous",
"STATISTICS": "Statistics",
"SUBTITLE_FORCED": "Forced",
+ "SUBTITLE_POSITION": "Subtitle position",
"SURROUND": "Surround",
"TOGGLE_STEREOSCOPIC": "Toggle stereoscopic",
+ "TOP_CENTER": "Top center",
+ "TOP_LEFT": "Top left",
+ "TOP_RIGHT": "Top right",
"UNDETERMINED_LANGUAGE": "Undetermined",
"UNMUTE": "Unmute",
"UNRECOGNIZED_LANGUAGE": "Unrecognized",
diff --git a/ui/locales/es.json b/ui/locales/es.json
index 82ea155f2..1d91dd102 100644
--- a/ui/locales/es.json
+++ b/ui/locales/es.json
@@ -8,10 +8,17 @@
"AUTO_GENERATED": "Generados automáticamente",
"AUTO_QUALITY": "Automático",
"BACK": "Atrás",
+ "BOTTOM_CENTER": "Abajo al centro",
+ "BOTTOM_LEFT": "Abajo a la izquierda",
+ "BOTTOM_RIGHT": "Abajo a la derecha",
"CAPTIONS": "Subtítulos",
"CAST": "Enviar...",
+ "CENTER": "Centro",
+ "CENTER_LEFT": "Centro a la izquierda",
+ "CENTER_RIGHT": "Centro a la derecha",
"CHAPTERS": "Capítulos",
"COPY_VIDEO_FRAME": "Copiar fotograma",
+ "DEFAULT": "Automático",
"DOWNLOAD_VIDEO_FRAME": "Guardar fotograma",
"ENTER_LOOP_MODE": "Reproducir en bucle el vídeo actual",
"ENTER_PICTURE_IN_PICTURE": "Activar el modo imagen en imagen",
@@ -46,8 +53,12 @@
"SKIP_PREVIOUS": "Anterior",
"STATISTICS": "Estadísticas",
"SUBTITLE_FORCED": "Forzado",
+ "SUBTITLE_POSITION": "Posición de los subtítulos",
"SURROUND": "Envolvente",
"TOGGLE_STEREOSCOPIC": "Activar/Desactivar estereoscópica",
+ "TOP_CENTER": "Arriba al centro",
+ "TOP_LEFT": "Arriba a la izquierda",
+ "TOP_RIGHT": "Arriba a la derecha",
"UNDETERMINED_LANGUAGE": "Sin especificar",
"UNMUTE": "Activar sonido",
"UNRECOGNIZED_LANGUAGE": "No reconocida",
diff --git a/ui/locales/fr.json b/ui/locales/fr.json
index f4da9c6ab..612aafa0a 100644
--- a/ui/locales/fr.json
+++ b/ui/locales/fr.json
@@ -8,9 +8,16 @@
"AUTO_GENERATED": "Générés automatiquement",
"AUTO_QUALITY": "Automatique",
"BACK": "Retour",
+ "BOTTOM_CENTER": "En bas au centre",
+ "BOTTOM_LEFT": "En bas à gauche",
+ "BOTTOM_RIGHT": "En bas à droite",
"CAPTIONS": "Sous-titres",
"CAST": "Caster…",
+ "CENTER": "Au centre",
+ "CENTER_LEFT": "Au centre à gauche",
+ "CENTER_RIGHT": "Au centre à droite",
"CHAPTERS": "Chapitres",
+ "DEFAULT": "Automatique",
"DOWNLOAD_VIDEO_FRAME": "Télécharger l'image de la vidéo",
"ENTER_LOOP_MODE": "Lire en boucle la vidéo en cours",
"ENTER_PICTURE_IN_PICTURE": "Utiliser le mode Picture-in-Picture",
@@ -45,8 +52,12 @@
"SKIP_PREVIOUS": "Précédente",
"STATISTICS": "Statistiques",
"SUBTITLE_FORCED": "Forcé",
+ "SUBTITLE_POSITION": "Position des sous-titres",
"SURROUND": "Surround",
"TOGGLE_STEREOSCOPIC": "Activer/Désactiver le mode stéréoscopique",
+ "TOP_CENTER": "En haut au centre",
+ "TOP_LEFT": "En haut à gauche",
+ "TOP_RIGHT": "En haut à droite",
"UNDETERMINED_LANGUAGE": "Langue indéterminée",
"UNMUTE": "Réactiver le son",
"UNRECOGNIZED_LANGUAGE": "Non reconnu",
diff --git a/ui/locales/nl.json b/ui/locales/nl.json
index 5b8c4a77b..eb2cb41ed 100644
--- a/ui/locales/nl.json
+++ b/ui/locales/nl.json
@@ -8,9 +8,16 @@
"AUTO_GENERATED": "Automatisch gegenereerd",
"AUTO_QUALITY": "Automatisch",
"BACK": "Terug",
+ "BOTTOM_CENTER": "Onder midden",
+ "BOTTOM_LEFT": "Linksonder",
+ "BOTTOM_RIGHT": "Rechtsonder",
"CAPTIONS": "Ondertiteling",
"CAST": "Casten...",
+ "CENTER": "Midden",
+ "CENTER_LEFT": "Midden links",
+ "CENTER_RIGHT": "Midden rechts",
"CHAPTERS": "Hoofdstukken",
+ "DEFAULT": "Standaard",
"DOWNLOAD_VIDEO_FRAME": "Videoframe opslaan",
"ENTER_LOOP_MODE": "De huidige video lussen",
"ENTER_PICTURE_IN_PICTURE": "Scherm-in-scherm openen",
@@ -43,8 +50,12 @@
"SKIP_TO_LIVE": "Doorgaan naar live",
"STATISTICS": "Statistieken",
"SUBTITLE_FORCED": "Afgedwongen",
+ "SUBTITLE_POSITION": "Positie van ondertitels",
"SURROUND": "Surround",
"TOGGLE_STEREOSCOPIC": "Stereoscopisch aan-/uitzetten",
+ "TOP_CENTER": "Boven midden",
+ "TOP_LEFT": "Linksboven",
+ "TOP_RIGHT": "Rechtsboven",
"UNDETERMINED_LANGUAGE": "Onbepaald",
"UNMUTE": "Geluid aanzetten",
"UNRECOGNIZED_LANGUAGE": "Onbekend",
diff --git a/ui/locales/pl.json b/ui/locales/pl.json
index 8b87f20f7..3fd362b22 100644
--- a/ui/locales/pl.json
+++ b/ui/locales/pl.json
@@ -8,9 +8,16 @@
"AUTO_GENERATED": "Wygenerowane automatycznie",
"AUTO_QUALITY": "Automatycznie",
"BACK": "Wstecz",
+ "BOTTOM_CENTER": "Na dole pośrodku",
+ "BOTTOM_LEFT": "Na dole po lewej",
+ "BOTTOM_RIGHT": "Na dole po prawej",
"CAPTIONS": "Napisy",
"CAST": "Przesyłaj...",
+ "CENTER": "Pośrodku",
+ "CENTER_LEFT": "Pośrodku po lewej",
+ "CENTER_RIGHT": "Pośrodku po prawej",
"CHAPTERS": "Rozdziały",
+ "DEFAULT": "Domyślnie",
"DOWNLOAD_VIDEO_FRAME": "Zapisz klatkę filmu",
"ENTER_LOOP_MODE": "Odtwarzaj bieżący film w pętli",
"ENTER_PICTURE_IN_PICTURE": "Włącz tryb obrazu w obrazie",
@@ -45,8 +52,12 @@
"SKIP_TO_LIVE": "Przejdź do transmisji na żywo",
"STATISTICS": "Statystyki",
"SUBTITLE_FORCED": "Wymuszone",
+ "SUBTITLE_POSITION": "Pozycja napisów",
"SURROUND": "Przestrzenny",
"TOGGLE_STEREOSCOPIC": "Przełącz tryb stereoskopowy",
+ "TOP_CENTER": "Na górze pośrodku",
+ "TOP_LEFT": "Na górze po lewej",
+ "TOP_RIGHT": "Na górze po prawej",
"UNDETERMINED_LANGUAGE": "Nie określono",
"UNMUTE": "Wyłącz wyciszenie",
"UNRECOGNIZED_LANGUAGE": "Nierozpoznany",
diff --git a/ui/locales/source.json b/ui/locales/source.json
index aebb03626..6c8a889bc 100644
--- a/ui/locales/source.json
+++ b/ui/locales/source.json
@@ -37,6 +37,18 @@
"meaning": "Return to previous menu",
"message": "Back"
},
+ "BOTTOM_CENTER": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Bottom center"
+ },
+ "BOTTOM_LEFT": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Bottom left"
+ },
+ "BOTTOM_RIGHT": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Bottom right"
+ },
"CAPTIONS": {
"description": "Label for a button used to navigate to a submenu to choose captions/subtitles in the video player.",
"message": "Captions"
@@ -45,6 +57,18 @@
"description": "Label for a button used to open the native Cast dialog in the browser and select a destination to Cast to.",
"message": "Cast..."
},
+ "CENTER": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Center"
+ },
+ "CENTER_LEFT": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Center left"
+ },
+ "CENTER_RIGHT": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Center right"
+ },
"CHAPTERS": {
"description": "Label for a button used to open a submenu to choose a chapter.",
"message": "Chapters"
@@ -53,6 +77,10 @@
"description": "Label for a button used to copy the current video frame to the clipboard.",
"message": "Copy video frame"
},
+ "DEFAULT": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Default"
+ },
"DOWNLOAD_VIDEO_FRAME": {
"description": "Label for a button used to download the current video frame.",
"message": "Save video frame"
@@ -191,6 +219,10 @@
"description": "Label used to identify a subtitle track that is forced to be shown.",
"message": "Forced"
},
+ "SUBTITLE_POSITION": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Subtitle position"
+ },
"SURROUND": {
"description": "Label used to identify a audio track that has surround audio.",
"message": "Surround"
@@ -199,6 +231,18 @@
"description": "Label for a button that toggle between monoscopic and stereoscopic.",
"message": "Toggle stereoscopic"
},
+ "TOP_CENTER": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Top center"
+ },
+ "TOP_LEFT": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Top left"
+ },
+ "TOP_RIGHT": {
+ "description": "Label for a button used to indicate a subtitle position.",
+ "message": "Top right"
+ },
"UNDETERMINED_LANGUAGE": {
"description": "Label for a button used to select an audio track whose language is undetermined or unknown.",
"message": "Undetermined"
diff --git a/ui/text_position.js b/ui/text_position.js
new file mode 100644
index 000000000..439a9d340
--- /dev/null
+++ b/ui/text_position.js
@@ -0,0 +1,232 @@
+/*! @license
+ * Shaka Player
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+
+goog.provide('shaka.ui.TextPosition');
+
+goog.require('shaka.config.PositionArea');
+goog.require('shaka.ui.Controls');
+goog.require('shaka.ui.Enums');
+goog.require('shaka.ui.Locales');
+goog.require('shaka.ui.Localization');
+goog.require('shaka.ui.OverflowMenu');
+goog.require('shaka.ui.SettingsMenu');
+goog.require('shaka.ui.Utils');
+goog.require('shaka.util.Dom');
+goog.requireType('shaka.ui.Controls');
+
+
+/**
+ * @extends {shaka.ui.SettingsMenu}
+ * @final
+ * @export
+ */
+shaka.ui.TextPosition = class extends shaka.ui.SettingsMenu {
+ /**
+ * @param {!HTMLElement} parent
+ * @param {!shaka.ui.Controls} controls
+ */
+ constructor(parent, controls) {
+ super(parent, controls,
+ shaka.ui.Enums.MaterialDesignSVGIcons['CLOSED_CAPTIONS_POSITION']);
+
+ this.button.classList.add('shaka-caption-position-button');
+ this.button.classList.add('shaka-tooltip-status');
+ this.menu.classList.add('shaka-text-positions');
+
+ this.eventManager.listen(
+ this.localization, shaka.ui.Localization.LOCALE_UPDATED, () => {
+ this.updateLocalizedStrings_();
+ });
+
+ this.eventManager.listen(
+ this.localization, shaka.ui.Localization.LOCALE_CHANGED, () => {
+ this.updateLocalizedStrings_();
+ });
+
+ this.eventManager.listen(this.player, 'loading', () => {
+ this.updateTextPositionSelection_();
+ this.checkAvailability_();
+ });
+
+ this.eventManager.listen(this.player, 'loaded', () => {
+ this.updateTextPositionSelection_();
+ this.checkAvailability_();
+ });
+
+ this.eventManager.listen(this.player, 'unloading', () => {
+ this.updateTextPositionSelection_();
+ this.checkAvailability_();
+ });
+
+ this.eventManager.listen(this.player, 'textchanged', () => {
+ this.updateTextPositionSelection_();
+ this.checkAvailability_();
+ });
+
+ this.eventManager.listen(this.player, 'trackschanged', () => {
+ this.updateTextPositionSelection_();
+ this.checkAvailability_();
+ });
+
+ if (this.isSubMenu) {
+ this.eventManager.listen(this.controls, 'submenuopen', () => {
+ this.checkAvailability_();
+ });
+ this.eventManager.listen(this.controls, 'submenuclose', () => {
+ this.checkAvailability_();
+ });
+ }
+
+ // Set up all the strings in the user's preferred language.
+ this.updateLocalizedStrings_();
+
+ this.checkAvailability_();
+ this.addTextPositions_();
+ this.updateTextPositionSelection_();
+ }
+
+ /** @private */
+ checkAvailability_() {
+ const tracks = this.player.getTextTracks() || [];
+ const hasTrack = tracks.some((track) => track.active);
+ shaka.ui.Utils.setDisplay(this.button, hasTrack && !this.isSubMenuOpened);
+ if (hasTrack && !this.isSubMenuOpened) {
+ this.button.ariaPressed = 'true';
+ } else {
+ this.button.ariaPressed = 'false';
+ }
+ }
+
+ /**
+ * @private
+ */
+ updateLocalizedStrings_() {
+ const LocIds = shaka.ui.Locales.Ids;
+
+ this.button.ariaLabel = this.localization.resolve(LocIds.SUBTITLE_POSITION);
+ this.backButton.ariaLabel = this.localization.resolve(LocIds.BACK);
+ this.nameSpan.textContent =
+ this.localization.resolve(LocIds.SUBTITLE_POSITION);
+ this.backSpan.textContent =
+ this.localization.resolve(LocIds.SUBTITLE_POSITION);
+
+ this.addTextPositions_();
+ }
+
+ /** @private */
+ addTextPositions_() {
+ // Remove old shaka-resolutions
+ // 1. Save the back to menu button
+ const backButton = shaka.ui.Utils.getFirstDescendantWithClassName(
+ this.menu, 'shaka-back-to-overflow-button');
+
+ // 2. Remove everything
+ shaka.util.Dom.removeAllChildren(this.menu);
+
+ // 3. Add the backTo Menu button back
+ this.menu.appendChild(backButton);
+
+ // 4. Add new items
+ for (const position of Object.values(shaka.config.PositionArea)) {
+ const button = shaka.util.Dom.createButton();
+ const span = shaka.util.Dom.createHTMLElement('span');
+ span.textContent = this.getNameOfPosition_(position);
+ button.appendChild(span);
+
+ this.eventManager.listen(button, 'click', () => {
+ this.player.configure('textDisplayer.positionArea', position);
+ this.updateTextPositionSelection_();
+ });
+
+ this.menu.appendChild(button);
+ }
+ this.updateTextPositionSelection_();
+ shaka.ui.Utils.focusOnTheChosenItem(this.menu);
+ }
+
+ /** @private */
+ updateTextPositionSelection_() {
+ // Remove the old checkmark icon and related tags and classes if it exists.
+ const checkmarkIcon = shaka.ui.Utils.getDescendantIfExists(
+ this.menu, 'shaka-ui-icon shaka-chosen-item');
+ if (checkmarkIcon) {
+ const previouslySelectedButton = checkmarkIcon.parentElement;
+ previouslySelectedButton.removeAttribute('aria-selected');
+ const previouslySelectedSpan =
+ previouslySelectedButton.getElementsByTagName('span')[0];
+ if (previouslySelectedSpan) {
+ previouslySelectedSpan.classList.remove('shaka-chosen-item');
+ }
+ previouslySelectedButton.removeChild(checkmarkIcon);
+ }
+ const positionArea =
+ this.player.getConfiguration().textDisplayer.positionArea;
+ const positionAreaName = this.getNameOfPosition_(positionArea);
+ // Add the checkmark icon, related tags and classes to the newly selected
+ // button.
+ const span = Array.from(this.menu.querySelectorAll('span')).find((el) => {
+ return el.textContent === positionAreaName;
+ });
+ if (span) {
+ const button = span.parentElement;
+ button.appendChild(shaka.ui.Utils.checkmarkIcon());
+ button.ariaSelected = 'true';
+ span.classList.add('shaka-chosen-item');
+ }
+ this.currentSelection.textContent = positionAreaName;
+ }
+
+ /**
+ * @param {!shaka.config.PositionArea} position
+ * @return {string}
+ * @private
+ */
+ getNameOfPosition_(position) {
+ const LocIds = shaka.ui.Locales.Ids;
+ switch (position) {
+ case shaka.config.PositionArea.DEFAULT:
+ return this.localization.resolve(LocIds.DEFAULT);
+ case shaka.config.PositionArea.TOP_LEFT:
+ return this.localization.resolve(LocIds.TOP_LEFT);
+ case shaka.config.PositionArea.TOP_CENTER:
+ return this.localization.resolve(LocIds.TOP_CENTER);
+ case shaka.config.PositionArea.TOP_RIGHT:
+ return this.localization.resolve(LocIds.TOP_RIGHT);
+ case shaka.config.PositionArea.CENTER_LEFT:
+ return this.localization.resolve(LocIds.CENTER_LEFT);
+ case shaka.config.PositionArea.CENTER:
+ return this.localization.resolve(LocIds.CENTER);
+ case shaka.config.PositionArea.CENTER_RIGHT:
+ return this.localization.resolve(LocIds.CENTER_RIGHT);
+ case shaka.config.PositionArea.BOTTOM_LEFT:
+ return this.localization.resolve(LocIds.BOTTOM_LEFT);
+ case shaka.config.PositionArea.BOTTOM_CENTER:
+ return this.localization.resolve(LocIds.BOTTOM_CENTER);
+ case shaka.config.PositionArea.BOTTOM_RIGHT:
+ return this.localization.resolve(LocIds.BOTTOM_RIGHT);
+ }
+ return '';
+ }
+};
+
+
+/**
+ * @implements {shaka.extern.IUIElement.Factory}
+ * @final
+ */
+shaka.ui.TextPosition.Factory = class {
+ /** @override */
+ create(rootElement, controls) {
+ return new shaka.ui.TextPosition(rootElement, controls);
+ }
+};
+
+shaka.ui.OverflowMenu.registerElement(
+ 'captions-position', new shaka.ui.TextPosition.Factory());
+
+shaka.ui.Controls.registerElement(
+ 'captions-position', new shaka.ui.TextPosition.Factory());
diff --git a/ui/ui.js b/ui/ui.js
index b34f3a96b..9bf01a2c7 100644
--- a/ui/ui.js
+++ b/ui/ui.js
@@ -310,6 +310,7 @@ shaka.ui.Overlay = class {
],
overflowMenuButtons: [
'captions',
+ 'captions-position',
'quality',
'video_type',
'language',