vidply 1.2.0 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +946 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
- package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
- package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
- package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
- package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
- package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
- package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
- package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
- package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
- package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
- package/dist/dev/vidply.esm.js +73 -34
- package/dist/dev/vidply.esm.js.map +2 -2
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
- package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
- package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
- package/dist/legacy/vidply.js +345 -32
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +66 -47
- package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
- package/dist/vidply.css +5796 -5796
- package/dist/vidply.esm.min.meta.json +148 -135
- package/package.json +7 -7
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4159 -4159
- package/src/controls/KeyboardManager.ts +383 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2915 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +217 -217
- package/src/i18n/languages/en.ts +218 -218
- package/src/i18n/languages/es.ts +214 -214
- package/src/i18n/languages/fr.ts +214 -214
- package/src/i18n/languages/ja.ts +214 -214
- package/src/icons/Icons.ts +167 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5796 -5796
- package/src/types/events.ts +124 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +185 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.js.map} +0 -0
|
@@ -1,1308 +1,1308 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sign Language Video Manager
|
|
3
|
-
* Handles picture-in-picture sign language video overlay
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
7
|
-
import { createIconElement } from '../icons/Icons.js';
|
|
8
|
-
import { i18n } from '../i18n/i18n.js';
|
|
9
|
-
import { DraggableResizable } from '../utils/DraggableResizable.js';
|
|
10
|
-
import { createLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
|
|
11
|
-
import { DraggablePanel } from '../utils/DraggablePanel.js';
|
|
12
|
-
import type { Player } from './Player.js';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Bag of player-event handlers the sign-language overlay binds to so it
|
|
16
|
-
* can mirror the main player's playback state.
|
|
17
|
-
*/
|
|
18
|
-
interface SignLanguageHandlers {
|
|
19
|
-
play: () => void;
|
|
20
|
-
pause: () => void;
|
|
21
|
-
timeupdate: () => void;
|
|
22
|
-
ratechange: () => void;
|
|
23
|
-
captionChange?: () => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface SignLanguageSettingsHandlers {
|
|
27
|
-
click: (e: MouseEvent) => void;
|
|
28
|
-
keydown: (e: KeyboardEvent) => void;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface SignLanguageInteractionHandlers {
|
|
32
|
-
draggable: DraggableResizable | null;
|
|
33
|
-
customKeyHandler: ((e: KeyboardEvent) => void) | null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export class SignLanguageManager {
|
|
37
|
-
player: Player;
|
|
38
|
-
_mainViewMutedBefore: boolean;
|
|
39
|
-
_mainViewUsingSourceSwap: boolean;
|
|
40
|
-
currentLanguage: string | null;
|
|
41
|
-
customKeyHandler: ((e: KeyboardEvent) => void) | null;
|
|
42
|
-
desiredPosition: string;
|
|
43
|
-
draggable: DraggableResizable | null;
|
|
44
|
-
handlers: SignLanguageHandlers | null;
|
|
45
|
-
header: HTMLElement | null;
|
|
46
|
-
inMainView: boolean;
|
|
47
|
-
interactionHandlers: SignLanguageInteractionHandlers | null;
|
|
48
|
-
mainViewOriginalSources: HTMLSourceElement[] | null;
|
|
49
|
-
mainViewOriginalSrc: string | null;
|
|
50
|
-
resizeHandles: HTMLElement[];
|
|
51
|
-
selector: HTMLSelectElement | null;
|
|
52
|
-
settingsButton: HTMLButtonElement | null;
|
|
53
|
-
settingsHandlers: SignLanguageSettingsHandlers | null;
|
|
54
|
-
sources: Record<string, string>;
|
|
55
|
-
src: string | null;
|
|
56
|
-
enabled: boolean;
|
|
57
|
-
video: HTMLVideoElement | null;
|
|
58
|
-
wrapper: HTMLElement | null;
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Encapsulates the settings-menu DOM, lifecycle (show/hide/outside-
|
|
62
|
-
* click/keyboard nav/positioning), and the drag/resize toggle items.
|
|
63
|
-
* Owned here but lazily created once {@link _setupSettingsButton}
|
|
64
|
-
* instantiates the button it needs to anchor from.
|
|
65
|
-
*/
|
|
66
|
-
private _panel: DraggablePanel | null = null;
|
|
67
|
-
|
|
68
|
-
// Back-compat getters for external callers (Player.ts exposes these
|
|
69
|
-
// under `signLanguageSettingsMenu` / `signLanguageSettingsMenuVisible`)
|
|
70
|
-
// and for internal readers of the shared menu-item state (which is now
|
|
71
|
-
// panel-owned). Setters are no-ops by design — the panel is the
|
|
72
|
-
// authoritative owner of these values.
|
|
73
|
-
get settingsMenu(): HTMLElement | null {
|
|
74
|
-
return this._panel?.settingsMenu ?? null;
|
|
75
|
-
}
|
|
76
|
-
set settingsMenu(_v: HTMLElement | null) {
|
|
77
|
-
// panel-owned; ignored to keep legacy API surface inert.
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
get settingsMenuVisible(): boolean {
|
|
81
|
-
return this._panel?.settingsMenuVisible ?? false;
|
|
82
|
-
}
|
|
83
|
-
set settingsMenuVisible(_v: boolean) {
|
|
84
|
-
// panel-owned.
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
get settingsMenuJustOpened(): boolean {
|
|
88
|
-
return this._panel?.justOpened ?? false;
|
|
89
|
-
}
|
|
90
|
-
set settingsMenuJustOpened(_v: boolean) {
|
|
91
|
-
// panel-owned.
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
get dragOptionButton(): HTMLElement | null {
|
|
95
|
-
return this._panel?.dragOptionButton ?? null;
|
|
96
|
-
}
|
|
97
|
-
get dragOptionText(): Element | null {
|
|
98
|
-
return this._panel?.dragOptionText ?? null;
|
|
99
|
-
}
|
|
100
|
-
get resizeOptionButton(): HTMLElement | null {
|
|
101
|
-
return this._panel?.resizeOptionButton ?? null;
|
|
102
|
-
}
|
|
103
|
-
get resizeOptionText(): Element | null {
|
|
104
|
-
return this._panel?.resizeOptionText ?? null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
constructor(player: Player) {
|
|
108
|
-
this.player = player;
|
|
109
|
-
|
|
110
|
-
// Sources
|
|
111
|
-
this.src = player.options.signLanguageSrc;
|
|
112
|
-
this.sources = player.options.signLanguageSources || {};
|
|
113
|
-
this.currentLanguage = null;
|
|
114
|
-
this.desiredPosition = player.options.signLanguagePosition || 'bottom-right';
|
|
115
|
-
|
|
116
|
-
// DOM elements
|
|
117
|
-
this.wrapper = null;
|
|
118
|
-
this.header = null;
|
|
119
|
-
this.video = null;
|
|
120
|
-
this.selector = null;
|
|
121
|
-
this.settingsButton = null;
|
|
122
|
-
this.resizeHandles = [];
|
|
123
|
-
|
|
124
|
-
// State
|
|
125
|
-
this.enabled = false;
|
|
126
|
-
this.inMainView = false; // sign language shown in main video (src swap, like audio description)
|
|
127
|
-
this.mainViewOriginalSrc = null;
|
|
128
|
-
this.mainViewOriginalSources = null;
|
|
129
|
-
this._mainViewUsingSourceSwap = false;
|
|
130
|
-
this._mainViewMutedBefore = false;
|
|
131
|
-
|
|
132
|
-
// Handlers
|
|
133
|
-
this.handlers = null;
|
|
134
|
-
this.settingsHandlers = null;
|
|
135
|
-
this.interactionHandlers = null;
|
|
136
|
-
this.draggable = null;
|
|
137
|
-
this.customKeyHandler = null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Check if sign language is available
|
|
142
|
-
*/
|
|
143
|
-
isAvailable() {
|
|
144
|
-
return Object.keys(this.sources).length > 0 || Boolean(this.src);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Enable sign language video
|
|
149
|
-
*/
|
|
150
|
-
enable() {
|
|
151
|
-
const hasMultipleSources = Object.keys(this.sources).length > 0;
|
|
152
|
-
const hasSingleSource = Boolean(this.src);
|
|
153
|
-
|
|
154
|
-
if (!hasMultipleSources && !hasSingleSource) {
|
|
155
|
-
console.warn('No sign language video source provided');
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (this.wrapper) {
|
|
160
|
-
// Already exists, just show it
|
|
161
|
-
this.wrapper.style.display = 'block';
|
|
162
|
-
this.enabled = true;
|
|
163
|
-
this.player.state.signLanguageEnabled = true;
|
|
164
|
-
this.player.emit('signlanguageenabled');
|
|
165
|
-
|
|
166
|
-
// Focus settings button
|
|
167
|
-
this.player.setManagedTimeout(() => {
|
|
168
|
-
if (this.settingsButton && document.contains(this.settingsButton)) {
|
|
169
|
-
this.settingsButton.focus({ preventScroll: true });
|
|
170
|
-
}
|
|
171
|
-
}, 150);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Determine initial language
|
|
176
|
-
let initialLang = null;
|
|
177
|
-
let initialSrc = null;
|
|
178
|
-
|
|
179
|
-
if (hasMultipleSources) {
|
|
180
|
-
initialLang = this._determineInitialLanguage();
|
|
181
|
-
initialSrc = this.sources[initialLang] ?? null;
|
|
182
|
-
this.currentLanguage = initialLang;
|
|
183
|
-
} else {
|
|
184
|
-
initialSrc = this.src;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Create UI
|
|
188
|
-
this._createWrapper();
|
|
189
|
-
this._createHeader(hasMultipleSources, initialLang);
|
|
190
|
-
this._createVideo(initialSrc);
|
|
191
|
-
this._createResizeHandles();
|
|
192
|
-
|
|
193
|
-
// Assemble. The create methods mutate this.wrapper/this.video, but
|
|
194
|
-
// TypeScript still sees them as null due to the earlier
|
|
195
|
-
// `if (this.wrapper) return` narrowing, so re-read via a typed alias.
|
|
196
|
-
const wrapper = this.wrapper as HTMLElement | null;
|
|
197
|
-
const video = this.video as HTMLVideoElement | null;
|
|
198
|
-
if (!wrapper || !video) {
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
wrapper.appendChild(this.header as Node);
|
|
202
|
-
wrapper.appendChild(video);
|
|
203
|
-
this.resizeHandles.forEach(handle => wrapper.appendChild(handle));
|
|
204
|
-
|
|
205
|
-
// Set initial size
|
|
206
|
-
this._applyInitialSize();
|
|
207
|
-
|
|
208
|
-
// Add to container
|
|
209
|
-
this.player.container.appendChild(wrapper);
|
|
210
|
-
|
|
211
|
-
// Position
|
|
212
|
-
requestAnimationFrame(() => {
|
|
213
|
-
this.constrainPosition();
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
// Sync with main video
|
|
217
|
-
video.currentTime = this.player.state.currentTime;
|
|
218
|
-
if (!this.player.state.paused) {
|
|
219
|
-
video.play();
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Setup interaction
|
|
223
|
-
this._setupInteraction();
|
|
224
|
-
|
|
225
|
-
// Setup event handlers
|
|
226
|
-
this._setupEventHandlers(hasMultipleSources);
|
|
227
|
-
|
|
228
|
-
this.enabled = true;
|
|
229
|
-
this.player.state.signLanguageEnabled = true;
|
|
230
|
-
this.player.emit('signlanguageenabled');
|
|
231
|
-
|
|
232
|
-
// Focus settings button
|
|
233
|
-
this.player.setManagedTimeout(() => {
|
|
234
|
-
if (this.settingsButton && document.contains(this.settingsButton)) {
|
|
235
|
-
this.settingsButton.focus({ preventScroll: true });
|
|
236
|
-
}
|
|
237
|
-
}, 150);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Disable sign language video
|
|
242
|
-
*/
|
|
243
|
-
disable() {
|
|
244
|
-
if (this.settingsMenuVisible) {
|
|
245
|
-
this.hideSettingsMenu({ focusButton: false });
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Remove the mode badge so it doesn't linger past the overlay
|
|
249
|
-
// (e.g. if user hides sign language while drag/resize mode was on).
|
|
250
|
-
this._hideModeBadge();
|
|
251
|
-
|
|
252
|
-
if (this.wrapper) {
|
|
253
|
-
this.wrapper.style.display = 'none';
|
|
254
|
-
}
|
|
255
|
-
this.enabled = false;
|
|
256
|
-
this.player.state.signLanguageEnabled = false;
|
|
257
|
-
this.player.emit('signlanguagedisabled');
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Toggle sign language video
|
|
262
|
-
*/
|
|
263
|
-
toggle() {
|
|
264
|
-
if (this.enabled) {
|
|
265
|
-
this.disable();
|
|
266
|
-
} else {
|
|
267
|
-
this.enable();
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Enable sign language in main view: replace main video src with sign language URL (like audio description).
|
|
273
|
-
* Same video element, different URL; no overlay.
|
|
274
|
-
*/
|
|
275
|
-
async enableInMainView() {
|
|
276
|
-
const hasMultipleSources = Object.keys(this.sources).length > 0;
|
|
277
|
-
const hasSingleSource = Boolean(this.src);
|
|
278
|
-
if (!hasMultipleSources && !hasSingleSource) return;
|
|
279
|
-
if (!this.player.element || this.player.element.tagName !== 'VIDEO') return;
|
|
280
|
-
if (this.inMainView) return;
|
|
281
|
-
|
|
282
|
-
let signSrc;
|
|
283
|
-
if (hasMultipleSources) {
|
|
284
|
-
const initialLang = this._determineInitialLanguage();
|
|
285
|
-
this.currentLanguage = initialLang;
|
|
286
|
-
signSrc = this.sources[initialLang];
|
|
287
|
-
} else {
|
|
288
|
-
signSrc = this.src;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const el = this.player.element as HTMLVideoElement;
|
|
292
|
-
const currentTime = this.player.state.currentTime;
|
|
293
|
-
const wasPlaying = this.player.state.playing;
|
|
294
|
-
const posterValue = el.poster || el.getAttribute('poster') || this.player.options.poster;
|
|
295
|
-
const shouldKeepPoster = currentTime < 0.1 && !wasPlaying;
|
|
296
|
-
|
|
297
|
-
const sourceElements: HTMLSourceElement[] = Array.from(el.querySelectorAll('source'));
|
|
298
|
-
const firstSource = sourceElements[0];
|
|
299
|
-
this.mainViewOriginalSrc = (el.currentSrc && el.currentSrc.length > 0)
|
|
300
|
-
? el.currentSrc
|
|
301
|
-
: (el.src && el.src.length > 0)
|
|
302
|
-
? el.src
|
|
303
|
-
: (firstSource && firstSource.getAttribute('src'))
|
|
304
|
-
? firstSource.getAttribute('src')
|
|
305
|
-
: '';
|
|
306
|
-
this._mainViewMutedBefore = this.player.state.muted;
|
|
307
|
-
|
|
308
|
-
if (posterValue && shouldKeepPoster && el.tagName === 'VIDEO') {
|
|
309
|
-
el.poster = posterValue;
|
|
310
|
-
}
|
|
311
|
-
if (sourceElements.length > 0) {
|
|
312
|
-
this.mainViewOriginalSources = sourceElements;
|
|
313
|
-
this.mainViewOriginalSources.forEach(source => source.remove());
|
|
314
|
-
const signSource = document.createElement('source');
|
|
315
|
-
const signSrcValue = signSrc ?? '';
|
|
316
|
-
signSource.setAttribute('src', signSrcValue);
|
|
317
|
-
const type = this._inferVideoType(signSrcValue);
|
|
318
|
-
if (type) {
|
|
319
|
-
signSource.setAttribute('type', type);
|
|
320
|
-
}
|
|
321
|
-
const trackNode = el.querySelector('track');
|
|
322
|
-
if (trackNode) {
|
|
323
|
-
el.insertBefore(signSource, trackNode);
|
|
324
|
-
} else {
|
|
325
|
-
el.appendChild(signSource);
|
|
326
|
-
}
|
|
327
|
-
this._mainViewUsingSourceSwap = true;
|
|
328
|
-
} else {
|
|
329
|
-
el.src = signSrc ?? '';
|
|
330
|
-
this._mainViewUsingSourceSwap = false;
|
|
331
|
-
}
|
|
332
|
-
el.muted = true;
|
|
333
|
-
this.player.currentSource = signSrc ?? '';
|
|
334
|
-
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
335
|
-
this.player.invalidateTrackCache();
|
|
336
|
-
}
|
|
337
|
-
el.load();
|
|
338
|
-
|
|
339
|
-
await this._waitForMediaReadyMainView(currentTime > 0 || wasPlaying);
|
|
340
|
-
|
|
341
|
-
if (currentTime > 0) {
|
|
342
|
-
this.player.seek(currentTime);
|
|
343
|
-
await new Promise(r => setTimeout(r, 100));
|
|
344
|
-
}
|
|
345
|
-
if (wasPlaying) {
|
|
346
|
-
await this.player.play();
|
|
347
|
-
} else {
|
|
348
|
-
this.player.pause();
|
|
349
|
-
if (!shouldKeepPoster && this.player.hidePosterOverlay) {
|
|
350
|
-
this.player.hidePosterOverlay();
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
this.inMainView = true;
|
|
355
|
-
this.player.state.signLanguageInMainView = true;
|
|
356
|
-
if (this.player.videoWrapper) {
|
|
357
|
-
this.player.videoWrapper.classList.add('vidply-sign-language-main-view-active');
|
|
358
|
-
}
|
|
359
|
-
this.player.emit('signlanguageinmainviewenabled');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Disable sign language in main view: restore main video src.
|
|
364
|
-
*/
|
|
365
|
-
async disableInMainView() {
|
|
366
|
-
if (!this.inMainView) return;
|
|
367
|
-
if (!this.mainViewOriginalSrc && !this.mainViewOriginalSources) {
|
|
368
|
-
this.inMainView = false;
|
|
369
|
-
this.player.state.signLanguageInMainView = false;
|
|
370
|
-
if (this.player.videoWrapper) {
|
|
371
|
-
this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
|
|
372
|
-
}
|
|
373
|
-
this.player.emit('signlanguageinmainviewdisabled');
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const el = this.player.element as HTMLVideoElement;
|
|
378
|
-
const currentTime = this.player.state.currentTime;
|
|
379
|
-
const wasPlaying = this.player.state.playing;
|
|
380
|
-
|
|
381
|
-
if (this._mainViewUsingSourceSwap && this.mainViewOriginalSources && this.mainViewOriginalSources.length > 0) {
|
|
382
|
-
(Array.from(el.querySelectorAll('source')) as HTMLSourceElement[]).forEach((source: HTMLSourceElement) => source.remove());
|
|
383
|
-
const trackNode = el.querySelector('track');
|
|
384
|
-
this.mainViewOriginalSources.forEach((source: HTMLSourceElement) => {
|
|
385
|
-
if (trackNode) {
|
|
386
|
-
el.insertBefore(source, trackNode);
|
|
387
|
-
} else {
|
|
388
|
-
el.appendChild(source);
|
|
389
|
-
}
|
|
390
|
-
});
|
|
391
|
-
this._mainViewUsingSourceSwap = false;
|
|
392
|
-
} else if (this.mainViewOriginalSrc) {
|
|
393
|
-
el.src = this.mainViewOriginalSrc;
|
|
394
|
-
}
|
|
395
|
-
el.muted = this._mainViewMutedBefore;
|
|
396
|
-
this.player.currentSource = this.mainViewOriginalSrc || el.querySelector('source')?.src || '';
|
|
397
|
-
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
398
|
-
this.player.invalidateTrackCache();
|
|
399
|
-
}
|
|
400
|
-
el.load();
|
|
401
|
-
|
|
402
|
-
await this._waitForMediaReadyMainView(currentTime > 0 || wasPlaying);
|
|
403
|
-
|
|
404
|
-
if (currentTime > 0) {
|
|
405
|
-
this.player.seek(currentTime);
|
|
406
|
-
}
|
|
407
|
-
if (wasPlaying) {
|
|
408
|
-
try {
|
|
409
|
-
await this.player.play();
|
|
410
|
-
} catch (e) {
|
|
411
|
-
this.player.log?.('Sign language main view: play after restore failed', e, 'warn');
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
this.mainViewOriginalSrc = null;
|
|
416
|
-
this.mainViewOriginalSources = null;
|
|
417
|
-
this.inMainView = false;
|
|
418
|
-
this.player.state.signLanguageInMainView = false;
|
|
419
|
-
if (this.player.videoWrapper) {
|
|
420
|
-
this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
|
|
421
|
-
}
|
|
422
|
-
this.player.emit('signlanguageinmainviewdisabled');
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Wait for media ready (like AudioDescriptionManager).
|
|
427
|
-
*/
|
|
428
|
-
async _waitForMediaReadyMainView(needSeek = false) {
|
|
429
|
-
const el = this.player.element;
|
|
430
|
-
const loadedMetaPromise = new Promise<void>((resolve) => {
|
|
431
|
-
if (el.readyState >= 1) {
|
|
432
|
-
resolve();
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
const onLoad = () => {
|
|
436
|
-
el.removeEventListener('loadedmetadata', onLoad);
|
|
437
|
-
el.removeEventListener('error', onError);
|
|
438
|
-
resolve();
|
|
439
|
-
};
|
|
440
|
-
const onError = () => {
|
|
441
|
-
el.removeEventListener('loadedmetadata', onLoad);
|
|
442
|
-
el.removeEventListener('error', onError);
|
|
443
|
-
resolve();
|
|
444
|
-
};
|
|
445
|
-
el.addEventListener('loadedmetadata', onLoad);
|
|
446
|
-
el.addEventListener('error', onError, { once: true });
|
|
447
|
-
});
|
|
448
|
-
const timeoutPromise = new Promise(r => setTimeout(r, 10000));
|
|
449
|
-
await Promise.race([loadedMetaPromise, timeoutPromise]);
|
|
450
|
-
await new Promise(r => setTimeout(r, 300));
|
|
451
|
-
if (needSeek) {
|
|
452
|
-
await new Promise<void>((resolve) => {
|
|
453
|
-
if (el.readyState >= 3) resolve();
|
|
454
|
-
else {
|
|
455
|
-
const onCanPlay = () => {
|
|
456
|
-
el.removeEventListener('canplay', onCanPlay);
|
|
457
|
-
el.removeEventListener('canplaythrough', onCanPlay);
|
|
458
|
-
resolve();
|
|
459
|
-
};
|
|
460
|
-
el.addEventListener('canplay', onCanPlay, { once: true });
|
|
461
|
-
el.addEventListener('canplaythrough', onCanPlay, { once: true });
|
|
462
|
-
setTimeout(() => {
|
|
463
|
-
el.removeEventListener('canplay', onCanPlay);
|
|
464
|
-
el.removeEventListener('canplaythrough', onCanPlay);
|
|
465
|
-
resolve();
|
|
466
|
-
}, 3000);
|
|
467
|
-
}
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Toggle sign language in main view (src swap, like audio description).
|
|
474
|
-
*/
|
|
475
|
-
toggleInMainView() {
|
|
476
|
-
if (this.inMainView) {
|
|
477
|
-
this.disableInMainView();
|
|
478
|
-
} else {
|
|
479
|
-
this.enableInMainView();
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* Switch to a different sign language
|
|
485
|
-
*/
|
|
486
|
-
switchLanguage(langCode: string) {
|
|
487
|
-
if (!this.sources[langCode]) return;
|
|
488
|
-
|
|
489
|
-
this.currentLanguage = langCode;
|
|
490
|
-
|
|
491
|
-
if (this.video) {
|
|
492
|
-
const currentTime = this.video.currentTime;
|
|
493
|
-
const wasPlaying = !this.video.paused;
|
|
494
|
-
this.video.src = this.sources[langCode];
|
|
495
|
-
this.video.currentTime = currentTime;
|
|
496
|
-
if (wasPlaying) {
|
|
497
|
-
// Autoplay can be rejected by browser policy; debug-log
|
|
498
|
-
// unknown errors instead of silently swallowing.
|
|
499
|
-
this.video.play().catch((e: unknown) => {
|
|
500
|
-
if (typeof console !== 'undefined' && console.debug) {
|
|
501
|
-
console.debug('[VidPly] sign-language play() rejected:', e);
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
if (this.inMainView && this.player.element && this.player.element.tagName === 'VIDEO') {
|
|
508
|
-
const currentTime = this.player.state.currentTime;
|
|
509
|
-
const wasPlaying = this.player.state.playing;
|
|
510
|
-
if (this._mainViewUsingSourceSwap) {
|
|
511
|
-
const signSource = this.player.element.querySelector('source');
|
|
512
|
-
if (signSource) {
|
|
513
|
-
signSource.setAttribute('src', this.sources[langCode]);
|
|
514
|
-
const type = this._inferVideoType(this.sources[langCode]);
|
|
515
|
-
if (type) {
|
|
516
|
-
signSource.setAttribute('type', type);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
} else {
|
|
520
|
-
this.player.element.src = this.sources[langCode];
|
|
521
|
-
}
|
|
522
|
-
this.player.currentSource = this.sources[langCode];
|
|
523
|
-
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
524
|
-
this.player.invalidateTrackCache();
|
|
525
|
-
}
|
|
526
|
-
this.player.element.load();
|
|
527
|
-
this._waitForMediaReadyMainView(true).then(() => {
|
|
528
|
-
if (currentTime > 0) this.player.seek(currentTime);
|
|
529
|
-
if (wasPlaying) this.player.play();
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
this.player.emit('signlanguagelanguagechanged', langCode);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
_inferVideoType(url: string): string {
|
|
537
|
-
if (!url) return '';
|
|
538
|
-
const cleanUrl = (url.split('?')[0] ?? '').toLowerCase();
|
|
539
|
-
if (cleanUrl.endsWith('.mp4')) return 'video/mp4';
|
|
540
|
-
if (cleanUrl.endsWith('.webm')) return 'video/webm';
|
|
541
|
-
if (cleanUrl.endsWith('.ogv') || cleanUrl.endsWith('.ogg')) return 'video/ogg';
|
|
542
|
-
return '';
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Get language label
|
|
547
|
-
*/
|
|
548
|
-
getLanguageLabel(langCode: string): string {
|
|
549
|
-
const langNames: Record<string, string> = {
|
|
550
|
-
'en': 'English',
|
|
551
|
-
'de': 'Deutsch',
|
|
552
|
-
'es': 'Español',
|
|
553
|
-
'fr': 'Français',
|
|
554
|
-
'it': 'Italiano',
|
|
555
|
-
'ja': '日本語',
|
|
556
|
-
'pt': 'Português',
|
|
557
|
-
'ar': 'العربية',
|
|
558
|
-
'hi': 'हिन्दी'
|
|
559
|
-
};
|
|
560
|
-
return langNames[langCode] || langCode.toUpperCase();
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Determine initial sign language
|
|
565
|
-
*/
|
|
566
|
-
_determineInitialLanguage(): string {
|
|
567
|
-
// Try caption language
|
|
568
|
-
if (this.player.captionManager && this.player.captionManager.currentTrack) {
|
|
569
|
-
const captionLang = this.player.captionManager.currentTrack.language?.toLowerCase().split('-')[0];
|
|
570
|
-
if (captionLang && this.sources[captionLang]) {
|
|
571
|
-
return captionLang;
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// Try player language
|
|
576
|
-
if (this.player.options.language) {
|
|
577
|
-
const playerLang = this.player.options.language.toLowerCase().split('-')[0];
|
|
578
|
-
if (playerLang && this.sources[playerLang]) {
|
|
579
|
-
return playerLang;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// First available
|
|
584
|
-
return Object.keys(this.sources)[0] ?? '';
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Create wrapper element
|
|
589
|
-
*/
|
|
590
|
-
_createWrapper() {
|
|
591
|
-
this.wrapper = document.createElement('div');
|
|
592
|
-
this.wrapper.className = 'vidply-sign-language-wrapper';
|
|
593
|
-
this.wrapper.setAttribute('tabindex', '0');
|
|
594
|
-
this.wrapper.setAttribute('aria-label', i18n.t('player.signLanguageDragResize'));
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
/**
|
|
598
|
-
* Create header element
|
|
599
|
-
*/
|
|
600
|
-
_createHeader(hasMultipleSources: boolean, initialLang: string | null) {
|
|
601
|
-
const classPrefix = this.player.options.classPrefix;
|
|
602
|
-
|
|
603
|
-
this.header = DOMUtils.createElement('div', {
|
|
604
|
-
className: `${classPrefix}-sign-language-header`,
|
|
605
|
-
attributes: { 'tabindex': '0' }
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
const headerLeft = DOMUtils.createElement('div', {
|
|
609
|
-
className: `${classPrefix}-sign-language-header-left`
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
const title = DOMUtils.createElement('h3', {
|
|
613
|
-
textContent: i18n.t('player.signLanguageVideo')
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
// Settings button
|
|
617
|
-
this._createSettingsButton(headerLeft);
|
|
618
|
-
|
|
619
|
-
// Language selector
|
|
620
|
-
if (hasMultipleSources) {
|
|
621
|
-
this._createLanguageSelector(headerLeft, initialLang);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
headerLeft.appendChild(title);
|
|
625
|
-
|
|
626
|
-
// Close button
|
|
627
|
-
const closeButton = this._createCloseButton();
|
|
628
|
-
|
|
629
|
-
this.header.appendChild(headerLeft);
|
|
630
|
-
this.header.appendChild(closeButton);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
/**
|
|
634
|
-
* Create settings button and wire it to a {@link DraggablePanel}
|
|
635
|
-
* that owns the drag/resize settings menu and its lifecycle.
|
|
636
|
-
*/
|
|
637
|
-
_createSettingsButton(container: HTMLElement) {
|
|
638
|
-
const classPrefix = this.player.options.classPrefix;
|
|
639
|
-
const ariaLabel = i18n.t('player.signLanguageSettings');
|
|
640
|
-
|
|
641
|
-
this.settingsButton = DOMUtils.createElement('button', {
|
|
642
|
-
className: `${classPrefix}-sign-language-settings`,
|
|
643
|
-
attributes: {
|
|
644
|
-
'type': 'button',
|
|
645
|
-
'aria-label': ariaLabel,
|
|
646
|
-
'aria-expanded': 'false'
|
|
647
|
-
}
|
|
648
|
-
}) as HTMLButtonElement;
|
|
649
|
-
this.settingsButton.appendChild(createIconElement('settings'));
|
|
650
|
-
DOMUtils.attachTooltip(this.settingsButton, ariaLabel, classPrefix);
|
|
651
|
-
|
|
652
|
-
// The panel ties the settings-menu DOM, outside-click dismissal,
|
|
653
|
-
// keyboard navigation, and drag/resize toggle items together
|
|
654
|
-
// with the shared lifecycle signal. The manager still owns the
|
|
655
|
-
// side effects that happen when a mode actually toggles (badge,
|
|
656
|
-
// announcement, focus target), so the click callbacks below run
|
|
657
|
-
// the manager's own toggle methods.
|
|
658
|
-
this._panel = new DraggablePanel({
|
|
659
|
-
player: this.player,
|
|
660
|
-
namespace: 'sign-language',
|
|
661
|
-
settingsButton: this.settingsButton,
|
|
662
|
-
getDraggable: () => this.draggable,
|
|
663
|
-
i18nKeys: {
|
|
664
|
-
enableDrag: 'player.enableSignDragMode',
|
|
665
|
-
disableDrag: 'player.disableSignDragMode',
|
|
666
|
-
enableDragAria: 'player.enableSignDragModeAria',
|
|
667
|
-
disableDragAria: 'player.disableSignDragModeAria',
|
|
668
|
-
enableResize: 'player.enableSignResizeMode',
|
|
669
|
-
disableResize: 'player.disableSignResizeMode',
|
|
670
|
-
enableResizeAria: 'player.enableSignResizeModeAria',
|
|
671
|
-
disableResizeAria: 'player.disableSignResizeModeAria',
|
|
672
|
-
closeMenu: 'transcript.closeMenu',
|
|
673
|
-
},
|
|
674
|
-
menuAlign: 'center',
|
|
675
|
-
getMenuParent: () => this.wrapper,
|
|
676
|
-
getBadgeHost: () => this.wrapper,
|
|
677
|
-
// Existing CSS uses `.vidply-sign-mode-badge` (shorter than
|
|
678
|
-
// the namespace default) — pin the class so the styling
|
|
679
|
-
// keeps applying without having to duplicate the rule.
|
|
680
|
-
badgeClass: `${this.player.options.classPrefix}-sign-mode-badge`,
|
|
681
|
-
onDragItemClick: (panel) => {
|
|
682
|
-
this.toggleKeyboardDragMode();
|
|
683
|
-
// Keep focus off the settings button so arrow keys go to
|
|
684
|
-
// the draggable overlay.
|
|
685
|
-
panel.hide({ focusButton: false });
|
|
686
|
-
if (this.draggable?.keyboardDragMode) {
|
|
687
|
-
setTimeout(() => {
|
|
688
|
-
this.wrapper?.focus?.({ preventScroll: true });
|
|
689
|
-
}, 20);
|
|
690
|
-
}
|
|
691
|
-
},
|
|
692
|
-
onResizeItemClick: (panel) => {
|
|
693
|
-
const enabled = this.toggleResizeMode({ focus: false });
|
|
694
|
-
if (enabled) {
|
|
695
|
-
panel.hide({ focusButton: false });
|
|
696
|
-
setTimeout(() => {
|
|
697
|
-
this.wrapper?.focus?.({ preventScroll: true });
|
|
698
|
-
}, 20);
|
|
699
|
-
} else {
|
|
700
|
-
panel.hide({ focusButton: true });
|
|
701
|
-
}
|
|
702
|
-
},
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
this.settingsHandlers = {
|
|
706
|
-
click: (e: MouseEvent) => {
|
|
707
|
-
e.preventDefault();
|
|
708
|
-
e.stopPropagation();
|
|
709
|
-
this._panel?.markJustOpenedForClick();
|
|
710
|
-
this._panel?.toggle();
|
|
711
|
-
},
|
|
712
|
-
keydown: (e: KeyboardEvent) => {
|
|
713
|
-
if (e.key === 'd' || e.key === 'D') {
|
|
714
|
-
e.preventDefault();
|
|
715
|
-
e.stopPropagation();
|
|
716
|
-
this.toggleKeyboardDragMode();
|
|
717
|
-
} else if (e.key === 'r' || e.key === 'R') {
|
|
718
|
-
e.preventDefault();
|
|
719
|
-
e.stopPropagation();
|
|
720
|
-
this.toggleResizeMode();
|
|
721
|
-
} else if (e.key === 'Escape' && this.settingsMenuVisible) {
|
|
722
|
-
e.preventDefault();
|
|
723
|
-
e.stopPropagation();
|
|
724
|
-
this.hideSettingsMenu();
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
};
|
|
728
|
-
|
|
729
|
-
this.settingsButton.addEventListener('click', this.settingsHandlers.click);
|
|
730
|
-
this.settingsButton.addEventListener('keydown', this.settingsHandlers.keydown);
|
|
731
|
-
container.appendChild(this.settingsButton);
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
/**
|
|
735
|
-
* Create language selector
|
|
736
|
-
*/
|
|
737
|
-
_createLanguageSelector(container: HTMLElement, initialLang: string | null) {
|
|
738
|
-
const classPrefix = this.player.options.classPrefix;
|
|
739
|
-
const selectId = `${classPrefix}-sign-language-select-${Date.now()}`;
|
|
740
|
-
|
|
741
|
-
const options = Object.keys(this.sources).map(langCode => ({
|
|
742
|
-
value: langCode,
|
|
743
|
-
text: this.getLanguageLabel(langCode),
|
|
744
|
-
selected: langCode === initialLang
|
|
745
|
-
}));
|
|
746
|
-
|
|
747
|
-
const { label, select } = createLabeledSelect({
|
|
748
|
-
classPrefix,
|
|
749
|
-
labelClass: `${classPrefix}-sign-language-label`,
|
|
750
|
-
selectClass: `${classPrefix}-sign-language-select`,
|
|
751
|
-
labelText: 'settings.language',
|
|
752
|
-
selectId,
|
|
753
|
-
options,
|
|
754
|
-
onChange: (e) => {
|
|
755
|
-
e.stopPropagation();
|
|
756
|
-
this.switchLanguage((e.target as HTMLSelectElement).value);
|
|
757
|
-
}
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
this.selector = select as HTMLSelectElement;
|
|
761
|
-
|
|
762
|
-
const selectorWrapper = DOMUtils.createElement('div', {
|
|
763
|
-
className: `${classPrefix}-sign-language-selector-wrapper`
|
|
764
|
-
});
|
|
765
|
-
selectorWrapper.appendChild(label);
|
|
766
|
-
selectorWrapper.appendChild(this.selector);
|
|
767
|
-
|
|
768
|
-
preventDragOnElement(selectorWrapper);
|
|
769
|
-
container.appendChild(selectorWrapper);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Create close button
|
|
774
|
-
*/
|
|
775
|
-
_createCloseButton() {
|
|
776
|
-
const classPrefix = this.player.options.classPrefix;
|
|
777
|
-
const ariaLabel = i18n.t('player.closeSignLanguage');
|
|
778
|
-
|
|
779
|
-
const closeButton = DOMUtils.createElement('button', {
|
|
780
|
-
className: `${classPrefix}-sign-language-close`,
|
|
781
|
-
attributes: {
|
|
782
|
-
'type': 'button',
|
|
783
|
-
'aria-label': ariaLabel
|
|
784
|
-
}
|
|
785
|
-
});
|
|
786
|
-
closeButton.appendChild(createIconElement('close'));
|
|
787
|
-
DOMUtils.attachTooltip(closeButton, ariaLabel, classPrefix);
|
|
788
|
-
|
|
789
|
-
// Bind to the player lifecycle so the listener is dropped on teardown
|
|
790
|
-
// even if the button is detached without going through cleanup().
|
|
791
|
-
closeButton.addEventListener('click', () => {
|
|
792
|
-
this.disable();
|
|
793
|
-
const signLanguageButton = this.player.controlBar?.controls?.signLanguage;
|
|
794
|
-
if (signLanguageButton) {
|
|
795
|
-
this.player.setManagedTimeout(() => {
|
|
796
|
-
signLanguageButton.focus({ preventScroll: true });
|
|
797
|
-
}, 0);
|
|
798
|
-
}
|
|
799
|
-
}, { signal: this.player.lifecycleSignal });
|
|
800
|
-
|
|
801
|
-
return closeButton;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
/**
|
|
805
|
-
* Create video element
|
|
806
|
-
*/
|
|
807
|
-
_createVideo(src: string | null) {
|
|
808
|
-
this.video = document.createElement('video');
|
|
809
|
-
this.video.className = 'vidply-sign-language-video';
|
|
810
|
-
this.video.src = src ?? '';
|
|
811
|
-
this.video.setAttribute('aria-label', i18n.t('player.signLanguage'));
|
|
812
|
-
this.video.muted = true;
|
|
813
|
-
this.video.setAttribute('playsinline', '');
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
/**
|
|
817
|
-
* Create resize handles
|
|
818
|
-
*/
|
|
819
|
-
_createResizeHandles() {
|
|
820
|
-
const classPrefix = this.player.options.classPrefix;
|
|
821
|
-
|
|
822
|
-
this.resizeHandles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].map(dir => {
|
|
823
|
-
const handle = DOMUtils.createElement('div', {
|
|
824
|
-
className: `${classPrefix}-sign-resize-handle ${classPrefix}-sign-resize-${dir}`,
|
|
825
|
-
attributes: {
|
|
826
|
-
'data-direction': dir,
|
|
827
|
-
'data-vidply-managed-resize': 'true',
|
|
828
|
-
'aria-hidden': 'true'
|
|
829
|
-
}
|
|
830
|
-
});
|
|
831
|
-
handle.style.display = 'none';
|
|
832
|
-
return handle;
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
/**
|
|
837
|
-
* Apply initial size
|
|
838
|
-
*/
|
|
839
|
-
_applyInitialSize() {
|
|
840
|
-
const wrapper = this.wrapper;
|
|
841
|
-
if (!wrapper) return;
|
|
842
|
-
const saved = this.player.storage.getSignLanguagePreferences() as { size?: { width?: string } } | null;
|
|
843
|
-
if (saved?.size?.width) {
|
|
844
|
-
wrapper.style.width = saved.size.width;
|
|
845
|
-
} else {
|
|
846
|
-
wrapper.style.width = '280px';
|
|
847
|
-
}
|
|
848
|
-
wrapper.style.height = 'auto';
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
/**
|
|
852
|
-
* Setup interaction (drag and resize)
|
|
853
|
-
*/
|
|
854
|
-
_setupInteraction() {
|
|
855
|
-
const isMobile = window.innerWidth < 768;
|
|
856
|
-
const isFullscreen = this.player.state.fullscreen;
|
|
857
|
-
|
|
858
|
-
// Historically, drag/resize was disabled on mobile unless fullscreen to avoid scroll conflicts.
|
|
859
|
-
// Now that we support touch/pointer dragging with proper `touch-action` handling, enable it
|
|
860
|
-
// by default on iOS/Android as well. Allow opting out via option.
|
|
861
|
-
if (isMobile && !isFullscreen && this.player?.options?.signLanguageDragOnMobile === false) {
|
|
862
|
-
if (this.draggable) {
|
|
863
|
-
this.draggable.destroy();
|
|
864
|
-
this.draggable = null;
|
|
865
|
-
}
|
|
866
|
-
return;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
if (this.draggable) return;
|
|
870
|
-
|
|
871
|
-
const wrapper = this.wrapper;
|
|
872
|
-
if (!wrapper) return;
|
|
873
|
-
|
|
874
|
-
const classPrefix = this.player.options.classPrefix;
|
|
875
|
-
|
|
876
|
-
this.draggable = new DraggableResizable(wrapper, {
|
|
877
|
-
// Allow dragging from anywhere on the sign-language window (better for touch).
|
|
878
|
-
// We still block dragging when interacting with controls via `onDragStart` below.
|
|
879
|
-
dragHandle: this.wrapper,
|
|
880
|
-
resizeHandles: this.resizeHandles,
|
|
881
|
-
constrainToViewport: true,
|
|
882
|
-
maintainAspectRatio: true,
|
|
883
|
-
minWidth: 150,
|
|
884
|
-
minHeight: 100,
|
|
885
|
-
classPrefix: `${classPrefix}-sign`,
|
|
886
|
-
keyboardDragKey: 'd',
|
|
887
|
-
keyboardResizeKey: 'r',
|
|
888
|
-
keyboardStep: 10,
|
|
889
|
-
keyboardStepLarge: 50,
|
|
890
|
-
pointerResizeIndicatorText: i18n.t('player.signLanguageResizeActive'),
|
|
891
|
-
onPointerResizeToggle: (enabled) => {
|
|
892
|
-
this.resizeHandles.forEach(handle => {
|
|
893
|
-
handle.style.display = enabled ? 'block' : 'none';
|
|
894
|
-
});
|
|
895
|
-
// Pair the resize-mode visual state with a translatable
|
|
896
|
-
// badge and a live-region announcement. Fired for all
|
|
897
|
-
// entry/exit paths (menu toggle, R key, Escape, Home).
|
|
898
|
-
if (enabled) {
|
|
899
|
-
this._showModeBadge(i18n.t('player.signResizeModeHint'));
|
|
900
|
-
this.player.keyboardManager?.announce(i18n.t('player.signLanguageResizeActive'));
|
|
901
|
-
} else {
|
|
902
|
-
this._hideModeBadge();
|
|
903
|
-
this.player.keyboardManager?.announce(i18n.t('player.signResizeModeDisabled'));
|
|
904
|
-
}
|
|
905
|
-
},
|
|
906
|
-
onDragStart: (e) => {
|
|
907
|
-
const target = e.target as HTMLElement;
|
|
908
|
-
if (target.closest(`.${classPrefix}-sign-language-close`) ||
|
|
909
|
-
target.closest(`.${classPrefix}-sign-language-settings`) ||
|
|
910
|
-
target.closest(`.${classPrefix}-sign-language-select`) ||
|
|
911
|
-
target.closest(`.${classPrefix}-sign-language-label`) ||
|
|
912
|
-
target.closest(`.${classPrefix}-sign-language-settings-menu`)) {
|
|
913
|
-
return false;
|
|
914
|
-
}
|
|
915
|
-
return true;
|
|
916
|
-
}
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
this._setupCustomKeyHandler();
|
|
920
|
-
|
|
921
|
-
this.interactionHandlers = {
|
|
922
|
-
draggable: this.draggable,
|
|
923
|
-
customKeyHandler: this.customKeyHandler
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
/**
|
|
928
|
-
* Setup custom keyboard handler
|
|
929
|
-
*/
|
|
930
|
-
_setupCustomKeyHandler() {
|
|
931
|
-
this.customKeyHandler = (e: KeyboardEvent) => {
|
|
932
|
-
const key = e.key.toLowerCase();
|
|
933
|
-
|
|
934
|
-
if (this.settingsMenuVisible) return;
|
|
935
|
-
|
|
936
|
-
if (key === 'home') {
|
|
937
|
-
e.preventDefault();
|
|
938
|
-
e.stopPropagation();
|
|
939
|
-
if (this.draggable) {
|
|
940
|
-
if (this.draggable.pointerResizeMode) {
|
|
941
|
-
this.draggable.disablePointerResizeMode();
|
|
942
|
-
}
|
|
943
|
-
this.draggable.manuallyPositioned = false;
|
|
944
|
-
this.constrainPosition();
|
|
945
|
-
}
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
if (key === 'r') {
|
|
950
|
-
e.preventDefault();
|
|
951
|
-
e.stopPropagation();
|
|
952
|
-
if (this.toggleResizeMode()) {
|
|
953
|
-
this.wrapper?.focus({ preventScroll: true });
|
|
954
|
-
}
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
if (key === 'escape') {
|
|
959
|
-
e.preventDefault();
|
|
960
|
-
e.stopPropagation();
|
|
961
|
-
if (this.draggable?.pointerResizeMode) {
|
|
962
|
-
// disablePointerResizeMode() fires onPointerResizeToggle,
|
|
963
|
-
// which hides the badge and announces the change.
|
|
964
|
-
this.draggable.disablePointerResizeMode();
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
if (this.draggable?.keyboardDragMode) {
|
|
968
|
-
// disableKeyboardDragMode on the draggable has no
|
|
969
|
-
// callback, so mirror the cleanup that
|
|
970
|
-
// toggleKeyboardDragMode() does when it turns the
|
|
971
|
-
// mode off.
|
|
972
|
-
this.draggable.disableKeyboardDragMode();
|
|
973
|
-
this._hideModeBadge();
|
|
974
|
-
this._updateDragOptionState();
|
|
975
|
-
this.player.keyboardManager?.announce(i18n.t('player.signDragModeDisabled'));
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
978
|
-
this.disable();
|
|
979
|
-
const signLanguageButton = this.player.controlBar?.controls?.signLanguage;
|
|
980
|
-
if (signLanguageButton) {
|
|
981
|
-
setTimeout(() => {
|
|
982
|
-
signLanguageButton.focus({ preventScroll: true });
|
|
983
|
-
}, 0);
|
|
984
|
-
}
|
|
985
|
-
}
|
|
986
|
-
};
|
|
987
|
-
|
|
988
|
-
this.wrapper?.addEventListener('keydown', this.customKeyHandler);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
/**
|
|
992
|
-
* Setup event handlers
|
|
993
|
-
*/
|
|
994
|
-
_setupEventHandlers(hasMultipleSources: boolean) {
|
|
995
|
-
this.handlers = {
|
|
996
|
-
play: () => { if (this.video) this.video.play(); },
|
|
997
|
-
pause: () => { if (this.video) this.video.pause(); },
|
|
998
|
-
timeupdate: () => {
|
|
999
|
-
if (this.video && Math.abs(this.video.currentTime - this.player.state.currentTime) > 0.5) {
|
|
1000
|
-
this.video.currentTime = this.player.state.currentTime;
|
|
1001
|
-
}
|
|
1002
|
-
},
|
|
1003
|
-
ratechange: () => {
|
|
1004
|
-
if (this.video) this.video.playbackRate = this.player.state.playbackSpeed;
|
|
1005
|
-
}
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
this.player.on('play', this.handlers.play);
|
|
1009
|
-
this.player.on('pause', this.handlers.pause);
|
|
1010
|
-
this.player.on('timeupdate', this.handlers.timeupdate);
|
|
1011
|
-
this.player.on('ratechange', this.handlers.ratechange);
|
|
1012
|
-
|
|
1013
|
-
if (hasMultipleSources) {
|
|
1014
|
-
this.handlers.captionChange = () => {
|
|
1015
|
-
if (this.player.captionManager?.currentTrack && this.selector) {
|
|
1016
|
-
const captionLang = this.player.captionManager.currentTrack.language?.toLowerCase().split('-')[0];
|
|
1017
|
-
if (captionLang && this.sources[captionLang] && this.currentLanguage !== captionLang) {
|
|
1018
|
-
this.switchLanguage(captionLang);
|
|
1019
|
-
this.selector.value = captionLang;
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
};
|
|
1023
|
-
this.player.on('captionsenabled', this.handlers.captionChange);
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
/**
|
|
1028
|
-
* Constrain position within video wrapper
|
|
1029
|
-
*/
|
|
1030
|
-
constrainPosition() {
|
|
1031
|
-
if (!this.wrapper || !this.player.videoWrapper) return;
|
|
1032
|
-
|
|
1033
|
-
if (this.draggable?.manuallyPositioned) return;
|
|
1034
|
-
|
|
1035
|
-
if (!this.wrapper.style.width) {
|
|
1036
|
-
this.wrapper.style.width = '280px';
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
const videoWrapperRect = this.player.videoWrapper.getBoundingClientRect();
|
|
1040
|
-
const containerRect = this.player.container.getBoundingClientRect();
|
|
1041
|
-
const wrapperRect = this.wrapper.getBoundingClientRect();
|
|
1042
|
-
|
|
1043
|
-
const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
|
|
1044
|
-
const videoWrapperTop = videoWrapperRect.top - containerRect.top;
|
|
1045
|
-
const videoWrapperWidth = videoWrapperRect.width;
|
|
1046
|
-
const videoWrapperHeight = videoWrapperRect.height;
|
|
1047
|
-
|
|
1048
|
-
const wrapperWidth = wrapperRect.width || 280;
|
|
1049
|
-
const wrapperHeight = wrapperRect.height || ((280 * 9) / 16);
|
|
1050
|
-
|
|
1051
|
-
let left, top;
|
|
1052
|
-
const margin = 16;
|
|
1053
|
-
const controlsHeight = 95;
|
|
1054
|
-
|
|
1055
|
-
const position = this.desiredPosition || 'bottom-right';
|
|
1056
|
-
|
|
1057
|
-
switch (position) {
|
|
1058
|
-
case 'bottom-right':
|
|
1059
|
-
left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
|
|
1060
|
-
top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
|
|
1061
|
-
break;
|
|
1062
|
-
case 'bottom-left':
|
|
1063
|
-
left = videoWrapperLeft + margin;
|
|
1064
|
-
top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
|
|
1065
|
-
break;
|
|
1066
|
-
case 'top-right':
|
|
1067
|
-
left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
|
|
1068
|
-
top = videoWrapperTop + margin;
|
|
1069
|
-
break;
|
|
1070
|
-
case 'top-left':
|
|
1071
|
-
left = videoWrapperLeft + margin;
|
|
1072
|
-
top = videoWrapperTop + margin;
|
|
1073
|
-
break;
|
|
1074
|
-
default:
|
|
1075
|
-
left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
|
|
1076
|
-
top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperWidth - wrapperWidth));
|
|
1080
|
-
top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight));
|
|
1081
|
-
|
|
1082
|
-
this.wrapper.style.left = `${left}px`;
|
|
1083
|
-
this.wrapper.style.top = `${top}px`;
|
|
1084
|
-
this.wrapper.style.right = 'auto';
|
|
1085
|
-
this.wrapper.style.bottom = 'auto';
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
/**
|
|
1089
|
-
* Show the settings menu. Delegates to the shared {@link DraggablePanel},
|
|
1090
|
-
* which owns the DOM, outside-click dismissal, keyboard navigation and
|
|
1091
|
-
* positioning. Kept as a named method so external callers (other
|
|
1092
|
-
* managers, tests) that referenced the legacy API keep working.
|
|
1093
|
-
*/
|
|
1094
|
-
showSettingsMenu() {
|
|
1095
|
-
this._panel?.show();
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/** @see {@link showSettingsMenu} */
|
|
1099
|
-
hideSettingsMenu({ focusButton = true } = {}) {
|
|
1100
|
-
this._panel?.hide({ focusButton });
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
// Badge management moved into {@link DraggablePanel}; these
|
|
1104
|
-
// delegates keep the legacy names so internal call sites and
|
|
1105
|
-
// subclassers (if any) continue to work. Announcements for
|
|
1106
|
-
// assistive tech still go through the shared KeyboardManager
|
|
1107
|
-
// live region so AT doesn't read both the badge text and the
|
|
1108
|
-
// live-region copy.
|
|
1109
|
-
_showModeBadge(text: string) {
|
|
1110
|
-
this._panel?.showBadge(text);
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
_hideModeBadge() {
|
|
1114
|
-
this._panel?.hideBadge();
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
/**
|
|
1118
|
-
* Toggle keyboard drag mode
|
|
1119
|
-
*/
|
|
1120
|
-
toggleKeyboardDragMode() {
|
|
1121
|
-
if (!this.draggable) return;
|
|
1122
|
-
|
|
1123
|
-
const wasEnabled = this.draggable.keyboardDragMode;
|
|
1124
|
-
this.draggable.toggleKeyboardDragMode();
|
|
1125
|
-
const isEnabled = this.draggable.keyboardDragMode;
|
|
1126
|
-
|
|
1127
|
-
if (!wasEnabled && isEnabled) {
|
|
1128
|
-
this._enableMoveMode();
|
|
1129
|
-
this._showModeBadge(i18n.t('player.signDragModeHint'));
|
|
1130
|
-
this.player.keyboardManager?.announce(i18n.t('player.signLanguageDragActive'));
|
|
1131
|
-
} else if (wasEnabled && !isEnabled) {
|
|
1132
|
-
this._hideModeBadge();
|
|
1133
|
-
this.player.keyboardManager?.announce(i18n.t('player.signDragModeDisabled'));
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
this._updateDragOptionState();
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
/**
|
|
1140
|
-
* Enable move mode visual feedback
|
|
1141
|
-
*/
|
|
1142
|
-
_enableMoveMode() {
|
|
1143
|
-
this.wrapper?.classList.add(`${this.player.options.classPrefix}-sign-move-mode`);
|
|
1144
|
-
this._updateResizeOptionState();
|
|
1145
|
-
setTimeout(() => {
|
|
1146
|
-
this.wrapper?.classList.remove(`${this.player.options.classPrefix}-sign-move-mode`);
|
|
1147
|
-
}, 2000);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
/**
|
|
1151
|
-
* Toggle resize mode
|
|
1152
|
-
*/
|
|
1153
|
-
toggleResizeMode({ focus = true } = {}) {
|
|
1154
|
-
if (!this.draggable) return false;
|
|
1155
|
-
|
|
1156
|
-
if (this.draggable.pointerResizeMode) {
|
|
1157
|
-
this.draggable.disablePointerResizeMode({ focus });
|
|
1158
|
-
this._updateResizeOptionState();
|
|
1159
|
-
return false;
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
this.draggable.enablePointerResizeMode({ focus });
|
|
1163
|
-
this._updateResizeOptionState();
|
|
1164
|
-
return true;
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
// Thin delegates to the panel's refreshState. Kept as named methods
|
|
1168
|
-
// so the existing internal call sites (e.g. `toggleKeyboardDragMode`)
|
|
1169
|
-
// read naturally without a double-dot chain to the panel.
|
|
1170
|
-
_updateDragOptionState() {
|
|
1171
|
-
this._panel?.refreshDragState();
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
_updateResizeOptionState() {
|
|
1175
|
-
this._panel?.refreshResizeState();
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
/**
|
|
1179
|
-
* Save preferences
|
|
1180
|
-
*/
|
|
1181
|
-
savePreferences() {
|
|
1182
|
-
if (!this.wrapper) return;
|
|
1183
|
-
|
|
1184
|
-
this.player.storage.saveSignLanguagePreferences({
|
|
1185
|
-
size: { width: this.wrapper.style.width }
|
|
1186
|
-
});
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
/**
|
|
1190
|
-
* Update sources (called when playlist changes)
|
|
1191
|
-
*/
|
|
1192
|
-
updateSources(signLanguageSrc?: string | null, signLanguageSources?: Record<string, string> | null) {
|
|
1193
|
-
this.src = signLanguageSrc || null;
|
|
1194
|
-
this.sources = signLanguageSources || {};
|
|
1195
|
-
// Reset state for new playlist item (cleanup is called separately before this)
|
|
1196
|
-
this.currentLanguage = null;
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
/**
|
|
1200
|
-
* Cleanup
|
|
1201
|
-
*/
|
|
1202
|
-
cleanup() {
|
|
1203
|
-
if (this.inMainView && this.player.element) {
|
|
1204
|
-
const el = this.player.element;
|
|
1205
|
-
if (this._mainViewUsingSourceSwap && this.mainViewOriginalSources && this.mainViewOriginalSources.length > 0) {
|
|
1206
|
-
(Array.from(el.querySelectorAll('source')) as HTMLSourceElement[]).forEach((source: HTMLSourceElement) => source.remove());
|
|
1207
|
-
const trackNode = el.querySelector('track');
|
|
1208
|
-
this.mainViewOriginalSources.forEach((source: HTMLSourceElement) => {
|
|
1209
|
-
if (trackNode) {
|
|
1210
|
-
el.insertBefore(source, trackNode);
|
|
1211
|
-
} else {
|
|
1212
|
-
el.appendChild(source);
|
|
1213
|
-
}
|
|
1214
|
-
});
|
|
1215
|
-
this._mainViewUsingSourceSwap = false;
|
|
1216
|
-
} else if (this.mainViewOriginalSrc) {
|
|
1217
|
-
el.src = this.mainViewOriginalSrc;
|
|
1218
|
-
}
|
|
1219
|
-
el.muted = this._mainViewMutedBefore;
|
|
1220
|
-
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
1221
|
-
this.player.invalidateTrackCache();
|
|
1222
|
-
}
|
|
1223
|
-
el.load();
|
|
1224
|
-
this.mainViewOriginalSrc = null;
|
|
1225
|
-
this.mainViewOriginalSources = null;
|
|
1226
|
-
this.inMainView = false;
|
|
1227
|
-
this.player.state.signLanguageInMainView = false;
|
|
1228
|
-
if (this.player.videoWrapper) {
|
|
1229
|
-
this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
|
|
1230
|
-
}
|
|
1231
|
-
this.player.emit('signlanguageinmainviewdisabled');
|
|
1232
|
-
}
|
|
1233
|
-
if (this.settingsMenuVisible) {
|
|
1234
|
-
this.hideSettingsMenu({ focusButton: false });
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
// The panel owns its outside-click listener (bound to the
|
|
1238
|
-
// player's lifecycleSignal) and will drop that listener
|
|
1239
|
-
// automatically during player teardown. We still destroy the
|
|
1240
|
-
// panel here so its DOM is removed synchronously when the
|
|
1241
|
-
// wrapper goes away.
|
|
1242
|
-
if (this._panel) {
|
|
1243
|
-
this._panel.destroy();
|
|
1244
|
-
this._panel = null;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
// Remove settings handlers
|
|
1248
|
-
if (this.settingsHandlers && this.settingsButton) {
|
|
1249
|
-
this.settingsButton.removeEventListener('click', this.settingsHandlers.click);
|
|
1250
|
-
this.settingsButton.removeEventListener('keydown', this.settingsHandlers.keydown);
|
|
1251
|
-
}
|
|
1252
|
-
this.settingsHandlers = null;
|
|
1253
|
-
|
|
1254
|
-
// Remove event handlers
|
|
1255
|
-
if (this.handlers) {
|
|
1256
|
-
this.player.off('play', this.handlers.play);
|
|
1257
|
-
this.player.off('pause', this.handlers.pause);
|
|
1258
|
-
this.player.off('timeupdate', this.handlers.timeupdate);
|
|
1259
|
-
this.player.off('ratechange', this.handlers.ratechange);
|
|
1260
|
-
if (this.handlers.captionChange) {
|
|
1261
|
-
this.player.off('captionsenabled', this.handlers.captionChange);
|
|
1262
|
-
}
|
|
1263
|
-
this.handlers = null;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
// Remove custom key handler
|
|
1267
|
-
if (this.wrapper && this.customKeyHandler) {
|
|
1268
|
-
this.wrapper.removeEventListener('keydown', this.customKeyHandler);
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
// Destroy draggable
|
|
1272
|
-
if (this.draggable) {
|
|
1273
|
-
if (this.draggable.pointerResizeMode) {
|
|
1274
|
-
this.draggable.disablePointerResizeMode();
|
|
1275
|
-
}
|
|
1276
|
-
this.draggable.destroy();
|
|
1277
|
-
this.draggable = null;
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
|
-
this.interactionHandlers = null;
|
|
1281
|
-
|
|
1282
|
-
// Remove the mode badge before tearing down the wrapper, so the
|
|
1283
|
-
// reference doesn't outlive the DOM tree it belongs to.
|
|
1284
|
-
this._hideModeBadge();
|
|
1285
|
-
|
|
1286
|
-
// Remove video and wrapper
|
|
1287
|
-
if (this.wrapper?.parentNode) {
|
|
1288
|
-
if (this.video) {
|
|
1289
|
-
this.video.pause();
|
|
1290
|
-
this.video.src = '';
|
|
1291
|
-
}
|
|
1292
|
-
this.wrapper.parentNode.removeChild(this.wrapper);
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
this.wrapper = null;
|
|
1296
|
-
this.video = null;
|
|
1297
|
-
this.settingsButton = null;
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
/**
|
|
1301
|
-
* Destroy
|
|
1302
|
-
*/
|
|
1303
|
-
destroy() {
|
|
1304
|
-
this.cleanup();
|
|
1305
|
-
this.enabled = false;
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Sign Language Video Manager
|
|
3
|
+
* Handles picture-in-picture sign language video overlay
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
7
|
+
import { createIconElement } from '../icons/Icons.js';
|
|
8
|
+
import { i18n } from '../i18n/i18n.js';
|
|
9
|
+
import { DraggableResizable } from '../utils/DraggableResizable.js';
|
|
10
|
+
import { createLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
|
|
11
|
+
import { DraggablePanel } from '../utils/DraggablePanel.js';
|
|
12
|
+
import type { Player } from './Player.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Bag of player-event handlers the sign-language overlay binds to so it
|
|
16
|
+
* can mirror the main player's playback state.
|
|
17
|
+
*/
|
|
18
|
+
interface SignLanguageHandlers {
|
|
19
|
+
play: () => void;
|
|
20
|
+
pause: () => void;
|
|
21
|
+
timeupdate: () => void;
|
|
22
|
+
ratechange: () => void;
|
|
23
|
+
captionChange?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface SignLanguageSettingsHandlers {
|
|
27
|
+
click: (e: MouseEvent) => void;
|
|
28
|
+
keydown: (e: KeyboardEvent) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SignLanguageInteractionHandlers {
|
|
32
|
+
draggable: DraggableResizable | null;
|
|
33
|
+
customKeyHandler: ((e: KeyboardEvent) => void) | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SignLanguageManager {
|
|
37
|
+
player: Player;
|
|
38
|
+
_mainViewMutedBefore: boolean;
|
|
39
|
+
_mainViewUsingSourceSwap: boolean;
|
|
40
|
+
currentLanguage: string | null;
|
|
41
|
+
customKeyHandler: ((e: KeyboardEvent) => void) | null;
|
|
42
|
+
desiredPosition: string;
|
|
43
|
+
draggable: DraggableResizable | null;
|
|
44
|
+
handlers: SignLanguageHandlers | null;
|
|
45
|
+
header: HTMLElement | null;
|
|
46
|
+
inMainView: boolean;
|
|
47
|
+
interactionHandlers: SignLanguageInteractionHandlers | null;
|
|
48
|
+
mainViewOriginalSources: HTMLSourceElement[] | null;
|
|
49
|
+
mainViewOriginalSrc: string | null;
|
|
50
|
+
resizeHandles: HTMLElement[];
|
|
51
|
+
selector: HTMLSelectElement | null;
|
|
52
|
+
settingsButton: HTMLButtonElement | null;
|
|
53
|
+
settingsHandlers: SignLanguageSettingsHandlers | null;
|
|
54
|
+
sources: Record<string, string>;
|
|
55
|
+
src: string | null;
|
|
56
|
+
enabled: boolean;
|
|
57
|
+
video: HTMLVideoElement | null;
|
|
58
|
+
wrapper: HTMLElement | null;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Encapsulates the settings-menu DOM, lifecycle (show/hide/outside-
|
|
62
|
+
* click/keyboard nav/positioning), and the drag/resize toggle items.
|
|
63
|
+
* Owned here but lazily created once {@link _setupSettingsButton}
|
|
64
|
+
* instantiates the button it needs to anchor from.
|
|
65
|
+
*/
|
|
66
|
+
private _panel: DraggablePanel | null = null;
|
|
67
|
+
|
|
68
|
+
// Back-compat getters for external callers (Player.ts exposes these
|
|
69
|
+
// under `signLanguageSettingsMenu` / `signLanguageSettingsMenuVisible`)
|
|
70
|
+
// and for internal readers of the shared menu-item state (which is now
|
|
71
|
+
// panel-owned). Setters are no-ops by design — the panel is the
|
|
72
|
+
// authoritative owner of these values.
|
|
73
|
+
get settingsMenu(): HTMLElement | null {
|
|
74
|
+
return this._panel?.settingsMenu ?? null;
|
|
75
|
+
}
|
|
76
|
+
set settingsMenu(_v: HTMLElement | null) {
|
|
77
|
+
// panel-owned; ignored to keep legacy API surface inert.
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get settingsMenuVisible(): boolean {
|
|
81
|
+
return this._panel?.settingsMenuVisible ?? false;
|
|
82
|
+
}
|
|
83
|
+
set settingsMenuVisible(_v: boolean) {
|
|
84
|
+
// panel-owned.
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get settingsMenuJustOpened(): boolean {
|
|
88
|
+
return this._panel?.justOpened ?? false;
|
|
89
|
+
}
|
|
90
|
+
set settingsMenuJustOpened(_v: boolean) {
|
|
91
|
+
// panel-owned.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get dragOptionButton(): HTMLElement | null {
|
|
95
|
+
return this._panel?.dragOptionButton ?? null;
|
|
96
|
+
}
|
|
97
|
+
get dragOptionText(): Element | null {
|
|
98
|
+
return this._panel?.dragOptionText ?? null;
|
|
99
|
+
}
|
|
100
|
+
get resizeOptionButton(): HTMLElement | null {
|
|
101
|
+
return this._panel?.resizeOptionButton ?? null;
|
|
102
|
+
}
|
|
103
|
+
get resizeOptionText(): Element | null {
|
|
104
|
+
return this._panel?.resizeOptionText ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
constructor(player: Player) {
|
|
108
|
+
this.player = player;
|
|
109
|
+
|
|
110
|
+
// Sources
|
|
111
|
+
this.src = player.options.signLanguageSrc;
|
|
112
|
+
this.sources = player.options.signLanguageSources || {};
|
|
113
|
+
this.currentLanguage = null;
|
|
114
|
+
this.desiredPosition = player.options.signLanguagePosition || 'bottom-right';
|
|
115
|
+
|
|
116
|
+
// DOM elements
|
|
117
|
+
this.wrapper = null;
|
|
118
|
+
this.header = null;
|
|
119
|
+
this.video = null;
|
|
120
|
+
this.selector = null;
|
|
121
|
+
this.settingsButton = null;
|
|
122
|
+
this.resizeHandles = [];
|
|
123
|
+
|
|
124
|
+
// State
|
|
125
|
+
this.enabled = false;
|
|
126
|
+
this.inMainView = false; // sign language shown in main video (src swap, like audio description)
|
|
127
|
+
this.mainViewOriginalSrc = null;
|
|
128
|
+
this.mainViewOriginalSources = null;
|
|
129
|
+
this._mainViewUsingSourceSwap = false;
|
|
130
|
+
this._mainViewMutedBefore = false;
|
|
131
|
+
|
|
132
|
+
// Handlers
|
|
133
|
+
this.handlers = null;
|
|
134
|
+
this.settingsHandlers = null;
|
|
135
|
+
this.interactionHandlers = null;
|
|
136
|
+
this.draggable = null;
|
|
137
|
+
this.customKeyHandler = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if sign language is available
|
|
142
|
+
*/
|
|
143
|
+
isAvailable() {
|
|
144
|
+
return Object.keys(this.sources).length > 0 || Boolean(this.src);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Enable sign language video
|
|
149
|
+
*/
|
|
150
|
+
enable() {
|
|
151
|
+
const hasMultipleSources = Object.keys(this.sources).length > 0;
|
|
152
|
+
const hasSingleSource = Boolean(this.src);
|
|
153
|
+
|
|
154
|
+
if (!hasMultipleSources && !hasSingleSource) {
|
|
155
|
+
console.warn('No sign language video source provided');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (this.wrapper) {
|
|
160
|
+
// Already exists, just show it
|
|
161
|
+
this.wrapper.style.display = 'block';
|
|
162
|
+
this.enabled = true;
|
|
163
|
+
this.player.state.signLanguageEnabled = true;
|
|
164
|
+
this.player.emit('signlanguageenabled');
|
|
165
|
+
|
|
166
|
+
// Focus settings button
|
|
167
|
+
this.player.setManagedTimeout(() => {
|
|
168
|
+
if (this.settingsButton && document.contains(this.settingsButton)) {
|
|
169
|
+
this.settingsButton.focus({ preventScroll: true });
|
|
170
|
+
}
|
|
171
|
+
}, 150);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Determine initial language
|
|
176
|
+
let initialLang = null;
|
|
177
|
+
let initialSrc = null;
|
|
178
|
+
|
|
179
|
+
if (hasMultipleSources) {
|
|
180
|
+
initialLang = this._determineInitialLanguage();
|
|
181
|
+
initialSrc = this.sources[initialLang] ?? null;
|
|
182
|
+
this.currentLanguage = initialLang;
|
|
183
|
+
} else {
|
|
184
|
+
initialSrc = this.src;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Create UI
|
|
188
|
+
this._createWrapper();
|
|
189
|
+
this._createHeader(hasMultipleSources, initialLang);
|
|
190
|
+
this._createVideo(initialSrc);
|
|
191
|
+
this._createResizeHandles();
|
|
192
|
+
|
|
193
|
+
// Assemble. The create methods mutate this.wrapper/this.video, but
|
|
194
|
+
// TypeScript still sees them as null due to the earlier
|
|
195
|
+
// `if (this.wrapper) return` narrowing, so re-read via a typed alias.
|
|
196
|
+
const wrapper = this.wrapper as HTMLElement | null;
|
|
197
|
+
const video = this.video as HTMLVideoElement | null;
|
|
198
|
+
if (!wrapper || !video) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
wrapper.appendChild(this.header as Node);
|
|
202
|
+
wrapper.appendChild(video);
|
|
203
|
+
this.resizeHandles.forEach(handle => wrapper.appendChild(handle));
|
|
204
|
+
|
|
205
|
+
// Set initial size
|
|
206
|
+
this._applyInitialSize();
|
|
207
|
+
|
|
208
|
+
// Add to container
|
|
209
|
+
this.player.container.appendChild(wrapper);
|
|
210
|
+
|
|
211
|
+
// Position
|
|
212
|
+
requestAnimationFrame(() => {
|
|
213
|
+
this.constrainPosition();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Sync with main video
|
|
217
|
+
video.currentTime = this.player.state.currentTime;
|
|
218
|
+
if (!this.player.state.paused) {
|
|
219
|
+
video.play();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Setup interaction
|
|
223
|
+
this._setupInteraction();
|
|
224
|
+
|
|
225
|
+
// Setup event handlers
|
|
226
|
+
this._setupEventHandlers(hasMultipleSources);
|
|
227
|
+
|
|
228
|
+
this.enabled = true;
|
|
229
|
+
this.player.state.signLanguageEnabled = true;
|
|
230
|
+
this.player.emit('signlanguageenabled');
|
|
231
|
+
|
|
232
|
+
// Focus settings button
|
|
233
|
+
this.player.setManagedTimeout(() => {
|
|
234
|
+
if (this.settingsButton && document.contains(this.settingsButton)) {
|
|
235
|
+
this.settingsButton.focus({ preventScroll: true });
|
|
236
|
+
}
|
|
237
|
+
}, 150);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Disable sign language video
|
|
242
|
+
*/
|
|
243
|
+
disable() {
|
|
244
|
+
if (this.settingsMenuVisible) {
|
|
245
|
+
this.hideSettingsMenu({ focusButton: false });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Remove the mode badge so it doesn't linger past the overlay
|
|
249
|
+
// (e.g. if user hides sign language while drag/resize mode was on).
|
|
250
|
+
this._hideModeBadge();
|
|
251
|
+
|
|
252
|
+
if (this.wrapper) {
|
|
253
|
+
this.wrapper.style.display = 'none';
|
|
254
|
+
}
|
|
255
|
+
this.enabled = false;
|
|
256
|
+
this.player.state.signLanguageEnabled = false;
|
|
257
|
+
this.player.emit('signlanguagedisabled');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Toggle sign language video
|
|
262
|
+
*/
|
|
263
|
+
toggle() {
|
|
264
|
+
if (this.enabled) {
|
|
265
|
+
this.disable();
|
|
266
|
+
} else {
|
|
267
|
+
this.enable();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Enable sign language in main view: replace main video src with sign language URL (like audio description).
|
|
273
|
+
* Same video element, different URL; no overlay.
|
|
274
|
+
*/
|
|
275
|
+
async enableInMainView() {
|
|
276
|
+
const hasMultipleSources = Object.keys(this.sources).length > 0;
|
|
277
|
+
const hasSingleSource = Boolean(this.src);
|
|
278
|
+
if (!hasMultipleSources && !hasSingleSource) return;
|
|
279
|
+
if (!this.player.element || this.player.element.tagName !== 'VIDEO') return;
|
|
280
|
+
if (this.inMainView) return;
|
|
281
|
+
|
|
282
|
+
let signSrc;
|
|
283
|
+
if (hasMultipleSources) {
|
|
284
|
+
const initialLang = this._determineInitialLanguage();
|
|
285
|
+
this.currentLanguage = initialLang;
|
|
286
|
+
signSrc = this.sources[initialLang];
|
|
287
|
+
} else {
|
|
288
|
+
signSrc = this.src;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const el = this.player.element as HTMLVideoElement;
|
|
292
|
+
const currentTime = this.player.state.currentTime;
|
|
293
|
+
const wasPlaying = this.player.state.playing;
|
|
294
|
+
const posterValue = el.poster || el.getAttribute('poster') || this.player.options.poster;
|
|
295
|
+
const shouldKeepPoster = currentTime < 0.1 && !wasPlaying;
|
|
296
|
+
|
|
297
|
+
const sourceElements: HTMLSourceElement[] = Array.from(el.querySelectorAll('source'));
|
|
298
|
+
const firstSource = sourceElements[0];
|
|
299
|
+
this.mainViewOriginalSrc = (el.currentSrc && el.currentSrc.length > 0)
|
|
300
|
+
? el.currentSrc
|
|
301
|
+
: (el.src && el.src.length > 0)
|
|
302
|
+
? el.src
|
|
303
|
+
: (firstSource && firstSource.getAttribute('src'))
|
|
304
|
+
? firstSource.getAttribute('src')
|
|
305
|
+
: '';
|
|
306
|
+
this._mainViewMutedBefore = this.player.state.muted;
|
|
307
|
+
|
|
308
|
+
if (posterValue && shouldKeepPoster && el.tagName === 'VIDEO') {
|
|
309
|
+
el.poster = posterValue;
|
|
310
|
+
}
|
|
311
|
+
if (sourceElements.length > 0) {
|
|
312
|
+
this.mainViewOriginalSources = sourceElements;
|
|
313
|
+
this.mainViewOriginalSources.forEach(source => source.remove());
|
|
314
|
+
const signSource = document.createElement('source');
|
|
315
|
+
const signSrcValue = signSrc ?? '';
|
|
316
|
+
signSource.setAttribute('src', signSrcValue);
|
|
317
|
+
const type = this._inferVideoType(signSrcValue);
|
|
318
|
+
if (type) {
|
|
319
|
+
signSource.setAttribute('type', type);
|
|
320
|
+
}
|
|
321
|
+
const trackNode = el.querySelector('track');
|
|
322
|
+
if (trackNode) {
|
|
323
|
+
el.insertBefore(signSource, trackNode);
|
|
324
|
+
} else {
|
|
325
|
+
el.appendChild(signSource);
|
|
326
|
+
}
|
|
327
|
+
this._mainViewUsingSourceSwap = true;
|
|
328
|
+
} else {
|
|
329
|
+
el.src = signSrc ?? '';
|
|
330
|
+
this._mainViewUsingSourceSwap = false;
|
|
331
|
+
}
|
|
332
|
+
el.muted = true;
|
|
333
|
+
this.player.currentSource = signSrc ?? '';
|
|
334
|
+
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
335
|
+
this.player.invalidateTrackCache();
|
|
336
|
+
}
|
|
337
|
+
el.load();
|
|
338
|
+
|
|
339
|
+
await this._waitForMediaReadyMainView(currentTime > 0 || wasPlaying);
|
|
340
|
+
|
|
341
|
+
if (currentTime > 0) {
|
|
342
|
+
this.player.seek(currentTime);
|
|
343
|
+
await new Promise(r => setTimeout(r, 100));
|
|
344
|
+
}
|
|
345
|
+
if (wasPlaying) {
|
|
346
|
+
await this.player.play();
|
|
347
|
+
} else {
|
|
348
|
+
this.player.pause();
|
|
349
|
+
if (!shouldKeepPoster && this.player.hidePosterOverlay) {
|
|
350
|
+
this.player.hidePosterOverlay();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.inMainView = true;
|
|
355
|
+
this.player.state.signLanguageInMainView = true;
|
|
356
|
+
if (this.player.videoWrapper) {
|
|
357
|
+
this.player.videoWrapper.classList.add('vidply-sign-language-main-view-active');
|
|
358
|
+
}
|
|
359
|
+
this.player.emit('signlanguageinmainviewenabled');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Disable sign language in main view: restore main video src.
|
|
364
|
+
*/
|
|
365
|
+
async disableInMainView() {
|
|
366
|
+
if (!this.inMainView) return;
|
|
367
|
+
if (!this.mainViewOriginalSrc && !this.mainViewOriginalSources) {
|
|
368
|
+
this.inMainView = false;
|
|
369
|
+
this.player.state.signLanguageInMainView = false;
|
|
370
|
+
if (this.player.videoWrapper) {
|
|
371
|
+
this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
|
|
372
|
+
}
|
|
373
|
+
this.player.emit('signlanguageinmainviewdisabled');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const el = this.player.element as HTMLVideoElement;
|
|
378
|
+
const currentTime = this.player.state.currentTime;
|
|
379
|
+
const wasPlaying = this.player.state.playing;
|
|
380
|
+
|
|
381
|
+
if (this._mainViewUsingSourceSwap && this.mainViewOriginalSources && this.mainViewOriginalSources.length > 0) {
|
|
382
|
+
(Array.from(el.querySelectorAll('source')) as HTMLSourceElement[]).forEach((source: HTMLSourceElement) => source.remove());
|
|
383
|
+
const trackNode = el.querySelector('track');
|
|
384
|
+
this.mainViewOriginalSources.forEach((source: HTMLSourceElement) => {
|
|
385
|
+
if (trackNode) {
|
|
386
|
+
el.insertBefore(source, trackNode);
|
|
387
|
+
} else {
|
|
388
|
+
el.appendChild(source);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
this._mainViewUsingSourceSwap = false;
|
|
392
|
+
} else if (this.mainViewOriginalSrc) {
|
|
393
|
+
el.src = this.mainViewOriginalSrc;
|
|
394
|
+
}
|
|
395
|
+
el.muted = this._mainViewMutedBefore;
|
|
396
|
+
this.player.currentSource = this.mainViewOriginalSrc || el.querySelector('source')?.src || '';
|
|
397
|
+
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
398
|
+
this.player.invalidateTrackCache();
|
|
399
|
+
}
|
|
400
|
+
el.load();
|
|
401
|
+
|
|
402
|
+
await this._waitForMediaReadyMainView(currentTime > 0 || wasPlaying);
|
|
403
|
+
|
|
404
|
+
if (currentTime > 0) {
|
|
405
|
+
this.player.seek(currentTime);
|
|
406
|
+
}
|
|
407
|
+
if (wasPlaying) {
|
|
408
|
+
try {
|
|
409
|
+
await this.player.play();
|
|
410
|
+
} catch (e) {
|
|
411
|
+
this.player.log?.('Sign language main view: play after restore failed', e, 'warn');
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.mainViewOriginalSrc = null;
|
|
416
|
+
this.mainViewOriginalSources = null;
|
|
417
|
+
this.inMainView = false;
|
|
418
|
+
this.player.state.signLanguageInMainView = false;
|
|
419
|
+
if (this.player.videoWrapper) {
|
|
420
|
+
this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
|
|
421
|
+
}
|
|
422
|
+
this.player.emit('signlanguageinmainviewdisabled');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Wait for media ready (like AudioDescriptionManager).
|
|
427
|
+
*/
|
|
428
|
+
async _waitForMediaReadyMainView(needSeek = false) {
|
|
429
|
+
const el = this.player.element;
|
|
430
|
+
const loadedMetaPromise = new Promise<void>((resolve) => {
|
|
431
|
+
if (el.readyState >= 1) {
|
|
432
|
+
resolve();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const onLoad = () => {
|
|
436
|
+
el.removeEventListener('loadedmetadata', onLoad);
|
|
437
|
+
el.removeEventListener('error', onError);
|
|
438
|
+
resolve();
|
|
439
|
+
};
|
|
440
|
+
const onError = () => {
|
|
441
|
+
el.removeEventListener('loadedmetadata', onLoad);
|
|
442
|
+
el.removeEventListener('error', onError);
|
|
443
|
+
resolve();
|
|
444
|
+
};
|
|
445
|
+
el.addEventListener('loadedmetadata', onLoad);
|
|
446
|
+
el.addEventListener('error', onError, { once: true });
|
|
447
|
+
});
|
|
448
|
+
const timeoutPromise = new Promise(r => setTimeout(r, 10000));
|
|
449
|
+
await Promise.race([loadedMetaPromise, timeoutPromise]);
|
|
450
|
+
await new Promise(r => setTimeout(r, 300));
|
|
451
|
+
if (needSeek) {
|
|
452
|
+
await new Promise<void>((resolve) => {
|
|
453
|
+
if (el.readyState >= 3) resolve();
|
|
454
|
+
else {
|
|
455
|
+
const onCanPlay = () => {
|
|
456
|
+
el.removeEventListener('canplay', onCanPlay);
|
|
457
|
+
el.removeEventListener('canplaythrough', onCanPlay);
|
|
458
|
+
resolve();
|
|
459
|
+
};
|
|
460
|
+
el.addEventListener('canplay', onCanPlay, { once: true });
|
|
461
|
+
el.addEventListener('canplaythrough', onCanPlay, { once: true });
|
|
462
|
+
setTimeout(() => {
|
|
463
|
+
el.removeEventListener('canplay', onCanPlay);
|
|
464
|
+
el.removeEventListener('canplaythrough', onCanPlay);
|
|
465
|
+
resolve();
|
|
466
|
+
}, 3000);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Toggle sign language in main view (src swap, like audio description).
|
|
474
|
+
*/
|
|
475
|
+
toggleInMainView() {
|
|
476
|
+
if (this.inMainView) {
|
|
477
|
+
this.disableInMainView();
|
|
478
|
+
} else {
|
|
479
|
+
this.enableInMainView();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Switch to a different sign language
|
|
485
|
+
*/
|
|
486
|
+
switchLanguage(langCode: string) {
|
|
487
|
+
if (!this.sources[langCode]) return;
|
|
488
|
+
|
|
489
|
+
this.currentLanguage = langCode;
|
|
490
|
+
|
|
491
|
+
if (this.video) {
|
|
492
|
+
const currentTime = this.video.currentTime;
|
|
493
|
+
const wasPlaying = !this.video.paused;
|
|
494
|
+
this.video.src = this.sources[langCode];
|
|
495
|
+
this.video.currentTime = currentTime;
|
|
496
|
+
if (wasPlaying) {
|
|
497
|
+
// Autoplay can be rejected by browser policy; debug-log
|
|
498
|
+
// unknown errors instead of silently swallowing.
|
|
499
|
+
this.video.play().catch((e: unknown) => {
|
|
500
|
+
if (typeof console !== 'undefined' && console.debug) {
|
|
501
|
+
console.debug('[VidPly] sign-language play() rejected:', e);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (this.inMainView && this.player.element && this.player.element.tagName === 'VIDEO') {
|
|
508
|
+
const currentTime = this.player.state.currentTime;
|
|
509
|
+
const wasPlaying = this.player.state.playing;
|
|
510
|
+
if (this._mainViewUsingSourceSwap) {
|
|
511
|
+
const signSource = this.player.element.querySelector('source');
|
|
512
|
+
if (signSource) {
|
|
513
|
+
signSource.setAttribute('src', this.sources[langCode]);
|
|
514
|
+
const type = this._inferVideoType(this.sources[langCode]);
|
|
515
|
+
if (type) {
|
|
516
|
+
signSource.setAttribute('type', type);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
this.player.element.src = this.sources[langCode];
|
|
521
|
+
}
|
|
522
|
+
this.player.currentSource = this.sources[langCode];
|
|
523
|
+
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
524
|
+
this.player.invalidateTrackCache();
|
|
525
|
+
}
|
|
526
|
+
this.player.element.load();
|
|
527
|
+
this._waitForMediaReadyMainView(true).then(() => {
|
|
528
|
+
if (currentTime > 0) this.player.seek(currentTime);
|
|
529
|
+
if (wasPlaying) this.player.play();
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
this.player.emit('signlanguagelanguagechanged', langCode);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
_inferVideoType(url: string): string {
|
|
537
|
+
if (!url) return '';
|
|
538
|
+
const cleanUrl = (url.split('?')[0] ?? '').toLowerCase();
|
|
539
|
+
if (cleanUrl.endsWith('.mp4')) return 'video/mp4';
|
|
540
|
+
if (cleanUrl.endsWith('.webm')) return 'video/webm';
|
|
541
|
+
if (cleanUrl.endsWith('.ogv') || cleanUrl.endsWith('.ogg')) return 'video/ogg';
|
|
542
|
+
return '';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Get language label
|
|
547
|
+
*/
|
|
548
|
+
getLanguageLabel(langCode: string): string {
|
|
549
|
+
const langNames: Record<string, string> = {
|
|
550
|
+
'en': 'English',
|
|
551
|
+
'de': 'Deutsch',
|
|
552
|
+
'es': 'Español',
|
|
553
|
+
'fr': 'Français',
|
|
554
|
+
'it': 'Italiano',
|
|
555
|
+
'ja': '日本語',
|
|
556
|
+
'pt': 'Português',
|
|
557
|
+
'ar': 'العربية',
|
|
558
|
+
'hi': 'हिन्दी'
|
|
559
|
+
};
|
|
560
|
+
return langNames[langCode] || langCode.toUpperCase();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Determine initial sign language
|
|
565
|
+
*/
|
|
566
|
+
_determineInitialLanguage(): string {
|
|
567
|
+
// Try caption language
|
|
568
|
+
if (this.player.captionManager && this.player.captionManager.currentTrack) {
|
|
569
|
+
const captionLang = this.player.captionManager.currentTrack.language?.toLowerCase().split('-')[0];
|
|
570
|
+
if (captionLang && this.sources[captionLang]) {
|
|
571
|
+
return captionLang;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Try player language
|
|
576
|
+
if (this.player.options.language) {
|
|
577
|
+
const playerLang = this.player.options.language.toLowerCase().split('-')[0];
|
|
578
|
+
if (playerLang && this.sources[playerLang]) {
|
|
579
|
+
return playerLang;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// First available
|
|
584
|
+
return Object.keys(this.sources)[0] ?? '';
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Create wrapper element
|
|
589
|
+
*/
|
|
590
|
+
_createWrapper() {
|
|
591
|
+
this.wrapper = document.createElement('div');
|
|
592
|
+
this.wrapper.className = 'vidply-sign-language-wrapper';
|
|
593
|
+
this.wrapper.setAttribute('tabindex', '0');
|
|
594
|
+
this.wrapper.setAttribute('aria-label', i18n.t('player.signLanguageDragResize'));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Create header element
|
|
599
|
+
*/
|
|
600
|
+
_createHeader(hasMultipleSources: boolean, initialLang: string | null) {
|
|
601
|
+
const classPrefix = this.player.options.classPrefix;
|
|
602
|
+
|
|
603
|
+
this.header = DOMUtils.createElement('div', {
|
|
604
|
+
className: `${classPrefix}-sign-language-header`,
|
|
605
|
+
attributes: { 'tabindex': '0' }
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const headerLeft = DOMUtils.createElement('div', {
|
|
609
|
+
className: `${classPrefix}-sign-language-header-left`
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const title = DOMUtils.createElement('h3', {
|
|
613
|
+
textContent: i18n.t('player.signLanguageVideo')
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// Settings button
|
|
617
|
+
this._createSettingsButton(headerLeft);
|
|
618
|
+
|
|
619
|
+
// Language selector
|
|
620
|
+
if (hasMultipleSources) {
|
|
621
|
+
this._createLanguageSelector(headerLeft, initialLang);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
headerLeft.appendChild(title);
|
|
625
|
+
|
|
626
|
+
// Close button
|
|
627
|
+
const closeButton = this._createCloseButton();
|
|
628
|
+
|
|
629
|
+
this.header.appendChild(headerLeft);
|
|
630
|
+
this.header.appendChild(closeButton);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Create settings button and wire it to a {@link DraggablePanel}
|
|
635
|
+
* that owns the drag/resize settings menu and its lifecycle.
|
|
636
|
+
*/
|
|
637
|
+
_createSettingsButton(container: HTMLElement) {
|
|
638
|
+
const classPrefix = this.player.options.classPrefix;
|
|
639
|
+
const ariaLabel = i18n.t('player.signLanguageSettings');
|
|
640
|
+
|
|
641
|
+
this.settingsButton = DOMUtils.createElement('button', {
|
|
642
|
+
className: `${classPrefix}-sign-language-settings`,
|
|
643
|
+
attributes: {
|
|
644
|
+
'type': 'button',
|
|
645
|
+
'aria-label': ariaLabel,
|
|
646
|
+
'aria-expanded': 'false'
|
|
647
|
+
}
|
|
648
|
+
}) as HTMLButtonElement;
|
|
649
|
+
this.settingsButton.appendChild(createIconElement('settings'));
|
|
650
|
+
DOMUtils.attachTooltip(this.settingsButton, ariaLabel, classPrefix);
|
|
651
|
+
|
|
652
|
+
// The panel ties the settings-menu DOM, outside-click dismissal,
|
|
653
|
+
// keyboard navigation, and drag/resize toggle items together
|
|
654
|
+
// with the shared lifecycle signal. The manager still owns the
|
|
655
|
+
// side effects that happen when a mode actually toggles (badge,
|
|
656
|
+
// announcement, focus target), so the click callbacks below run
|
|
657
|
+
// the manager's own toggle methods.
|
|
658
|
+
this._panel = new DraggablePanel({
|
|
659
|
+
player: this.player,
|
|
660
|
+
namespace: 'sign-language',
|
|
661
|
+
settingsButton: this.settingsButton,
|
|
662
|
+
getDraggable: () => this.draggable,
|
|
663
|
+
i18nKeys: {
|
|
664
|
+
enableDrag: 'player.enableSignDragMode',
|
|
665
|
+
disableDrag: 'player.disableSignDragMode',
|
|
666
|
+
enableDragAria: 'player.enableSignDragModeAria',
|
|
667
|
+
disableDragAria: 'player.disableSignDragModeAria',
|
|
668
|
+
enableResize: 'player.enableSignResizeMode',
|
|
669
|
+
disableResize: 'player.disableSignResizeMode',
|
|
670
|
+
enableResizeAria: 'player.enableSignResizeModeAria',
|
|
671
|
+
disableResizeAria: 'player.disableSignResizeModeAria',
|
|
672
|
+
closeMenu: 'transcript.closeMenu',
|
|
673
|
+
},
|
|
674
|
+
menuAlign: 'center',
|
|
675
|
+
getMenuParent: () => this.wrapper,
|
|
676
|
+
getBadgeHost: () => this.wrapper,
|
|
677
|
+
// Existing CSS uses `.vidply-sign-mode-badge` (shorter than
|
|
678
|
+
// the namespace default) — pin the class so the styling
|
|
679
|
+
// keeps applying without having to duplicate the rule.
|
|
680
|
+
badgeClass: `${this.player.options.classPrefix}-sign-mode-badge`,
|
|
681
|
+
onDragItemClick: (panel) => {
|
|
682
|
+
this.toggleKeyboardDragMode();
|
|
683
|
+
// Keep focus off the settings button so arrow keys go to
|
|
684
|
+
// the draggable overlay.
|
|
685
|
+
panel.hide({ focusButton: false });
|
|
686
|
+
if (this.draggable?.keyboardDragMode) {
|
|
687
|
+
setTimeout(() => {
|
|
688
|
+
this.wrapper?.focus?.({ preventScroll: true });
|
|
689
|
+
}, 20);
|
|
690
|
+
}
|
|
691
|
+
},
|
|
692
|
+
onResizeItemClick: (panel) => {
|
|
693
|
+
const enabled = this.toggleResizeMode({ focus: false });
|
|
694
|
+
if (enabled) {
|
|
695
|
+
panel.hide({ focusButton: false });
|
|
696
|
+
setTimeout(() => {
|
|
697
|
+
this.wrapper?.focus?.({ preventScroll: true });
|
|
698
|
+
}, 20);
|
|
699
|
+
} else {
|
|
700
|
+
panel.hide({ focusButton: true });
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
this.settingsHandlers = {
|
|
706
|
+
click: (e: MouseEvent) => {
|
|
707
|
+
e.preventDefault();
|
|
708
|
+
e.stopPropagation();
|
|
709
|
+
this._panel?.markJustOpenedForClick();
|
|
710
|
+
this._panel?.toggle();
|
|
711
|
+
},
|
|
712
|
+
keydown: (e: KeyboardEvent) => {
|
|
713
|
+
if (e.key === 'd' || e.key === 'D') {
|
|
714
|
+
e.preventDefault();
|
|
715
|
+
e.stopPropagation();
|
|
716
|
+
this.toggleKeyboardDragMode();
|
|
717
|
+
} else if (e.key === 'r' || e.key === 'R') {
|
|
718
|
+
e.preventDefault();
|
|
719
|
+
e.stopPropagation();
|
|
720
|
+
this.toggleResizeMode();
|
|
721
|
+
} else if (e.key === 'Escape' && this.settingsMenuVisible) {
|
|
722
|
+
e.preventDefault();
|
|
723
|
+
e.stopPropagation();
|
|
724
|
+
this.hideSettingsMenu();
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
this.settingsButton.addEventListener('click', this.settingsHandlers.click);
|
|
730
|
+
this.settingsButton.addEventListener('keydown', this.settingsHandlers.keydown);
|
|
731
|
+
container.appendChild(this.settingsButton);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Create language selector
|
|
736
|
+
*/
|
|
737
|
+
_createLanguageSelector(container: HTMLElement, initialLang: string | null) {
|
|
738
|
+
const classPrefix = this.player.options.classPrefix;
|
|
739
|
+
const selectId = `${classPrefix}-sign-language-select-${Date.now()}`;
|
|
740
|
+
|
|
741
|
+
const options = Object.keys(this.sources).map(langCode => ({
|
|
742
|
+
value: langCode,
|
|
743
|
+
text: this.getLanguageLabel(langCode),
|
|
744
|
+
selected: langCode === initialLang
|
|
745
|
+
}));
|
|
746
|
+
|
|
747
|
+
const { label, select } = createLabeledSelect({
|
|
748
|
+
classPrefix,
|
|
749
|
+
labelClass: `${classPrefix}-sign-language-label`,
|
|
750
|
+
selectClass: `${classPrefix}-sign-language-select`,
|
|
751
|
+
labelText: 'settings.language',
|
|
752
|
+
selectId,
|
|
753
|
+
options,
|
|
754
|
+
onChange: (e) => {
|
|
755
|
+
e.stopPropagation();
|
|
756
|
+
this.switchLanguage((e.target as HTMLSelectElement).value);
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
this.selector = select as HTMLSelectElement;
|
|
761
|
+
|
|
762
|
+
const selectorWrapper = DOMUtils.createElement('div', {
|
|
763
|
+
className: `${classPrefix}-sign-language-selector-wrapper`
|
|
764
|
+
});
|
|
765
|
+
selectorWrapper.appendChild(label);
|
|
766
|
+
selectorWrapper.appendChild(this.selector);
|
|
767
|
+
|
|
768
|
+
preventDragOnElement(selectorWrapper);
|
|
769
|
+
container.appendChild(selectorWrapper);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Create close button
|
|
774
|
+
*/
|
|
775
|
+
_createCloseButton() {
|
|
776
|
+
const classPrefix = this.player.options.classPrefix;
|
|
777
|
+
const ariaLabel = i18n.t('player.closeSignLanguage');
|
|
778
|
+
|
|
779
|
+
const closeButton = DOMUtils.createElement('button', {
|
|
780
|
+
className: `${classPrefix}-sign-language-close`,
|
|
781
|
+
attributes: {
|
|
782
|
+
'type': 'button',
|
|
783
|
+
'aria-label': ariaLabel
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
closeButton.appendChild(createIconElement('close'));
|
|
787
|
+
DOMUtils.attachTooltip(closeButton, ariaLabel, classPrefix);
|
|
788
|
+
|
|
789
|
+
// Bind to the player lifecycle so the listener is dropped on teardown
|
|
790
|
+
// even if the button is detached without going through cleanup().
|
|
791
|
+
closeButton.addEventListener('click', () => {
|
|
792
|
+
this.disable();
|
|
793
|
+
const signLanguageButton = this.player.controlBar?.controls?.signLanguage;
|
|
794
|
+
if (signLanguageButton) {
|
|
795
|
+
this.player.setManagedTimeout(() => {
|
|
796
|
+
signLanguageButton.focus({ preventScroll: true });
|
|
797
|
+
}, 0);
|
|
798
|
+
}
|
|
799
|
+
}, { signal: this.player.lifecycleSignal });
|
|
800
|
+
|
|
801
|
+
return closeButton;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Create video element
|
|
806
|
+
*/
|
|
807
|
+
_createVideo(src: string | null) {
|
|
808
|
+
this.video = document.createElement('video');
|
|
809
|
+
this.video.className = 'vidply-sign-language-video';
|
|
810
|
+
this.video.src = src ?? '';
|
|
811
|
+
this.video.setAttribute('aria-label', i18n.t('player.signLanguage'));
|
|
812
|
+
this.video.muted = true;
|
|
813
|
+
this.video.setAttribute('playsinline', '');
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Create resize handles
|
|
818
|
+
*/
|
|
819
|
+
_createResizeHandles() {
|
|
820
|
+
const classPrefix = this.player.options.classPrefix;
|
|
821
|
+
|
|
822
|
+
this.resizeHandles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].map(dir => {
|
|
823
|
+
const handle = DOMUtils.createElement('div', {
|
|
824
|
+
className: `${classPrefix}-sign-resize-handle ${classPrefix}-sign-resize-${dir}`,
|
|
825
|
+
attributes: {
|
|
826
|
+
'data-direction': dir,
|
|
827
|
+
'data-vidply-managed-resize': 'true',
|
|
828
|
+
'aria-hidden': 'true'
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
handle.style.display = 'none';
|
|
832
|
+
return handle;
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Apply initial size
|
|
838
|
+
*/
|
|
839
|
+
_applyInitialSize() {
|
|
840
|
+
const wrapper = this.wrapper;
|
|
841
|
+
if (!wrapper) return;
|
|
842
|
+
const saved = this.player.storage.getSignLanguagePreferences() as { size?: { width?: string } } | null;
|
|
843
|
+
if (saved?.size?.width) {
|
|
844
|
+
wrapper.style.width = saved.size.width;
|
|
845
|
+
} else {
|
|
846
|
+
wrapper.style.width = '280px';
|
|
847
|
+
}
|
|
848
|
+
wrapper.style.height = 'auto';
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Setup interaction (drag and resize)
|
|
853
|
+
*/
|
|
854
|
+
_setupInteraction() {
|
|
855
|
+
const isMobile = window.innerWidth < 768;
|
|
856
|
+
const isFullscreen = this.player.state.fullscreen;
|
|
857
|
+
|
|
858
|
+
// Historically, drag/resize was disabled on mobile unless fullscreen to avoid scroll conflicts.
|
|
859
|
+
// Now that we support touch/pointer dragging with proper `touch-action` handling, enable it
|
|
860
|
+
// by default on iOS/Android as well. Allow opting out via option.
|
|
861
|
+
if (isMobile && !isFullscreen && this.player?.options?.signLanguageDragOnMobile === false) {
|
|
862
|
+
if (this.draggable) {
|
|
863
|
+
this.draggable.destroy();
|
|
864
|
+
this.draggable = null;
|
|
865
|
+
}
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (this.draggable) return;
|
|
870
|
+
|
|
871
|
+
const wrapper = this.wrapper;
|
|
872
|
+
if (!wrapper) return;
|
|
873
|
+
|
|
874
|
+
const classPrefix = this.player.options.classPrefix;
|
|
875
|
+
|
|
876
|
+
this.draggable = new DraggableResizable(wrapper, {
|
|
877
|
+
// Allow dragging from anywhere on the sign-language window (better for touch).
|
|
878
|
+
// We still block dragging when interacting with controls via `onDragStart` below.
|
|
879
|
+
dragHandle: this.wrapper,
|
|
880
|
+
resizeHandles: this.resizeHandles,
|
|
881
|
+
constrainToViewport: true,
|
|
882
|
+
maintainAspectRatio: true,
|
|
883
|
+
minWidth: 150,
|
|
884
|
+
minHeight: 100,
|
|
885
|
+
classPrefix: `${classPrefix}-sign`,
|
|
886
|
+
keyboardDragKey: 'd',
|
|
887
|
+
keyboardResizeKey: 'r',
|
|
888
|
+
keyboardStep: 10,
|
|
889
|
+
keyboardStepLarge: 50,
|
|
890
|
+
pointerResizeIndicatorText: i18n.t('player.signLanguageResizeActive'),
|
|
891
|
+
onPointerResizeToggle: (enabled) => {
|
|
892
|
+
this.resizeHandles.forEach(handle => {
|
|
893
|
+
handle.style.display = enabled ? 'block' : 'none';
|
|
894
|
+
});
|
|
895
|
+
// Pair the resize-mode visual state with a translatable
|
|
896
|
+
// badge and a live-region announcement. Fired for all
|
|
897
|
+
// entry/exit paths (menu toggle, R key, Escape, Home).
|
|
898
|
+
if (enabled) {
|
|
899
|
+
this._showModeBadge(i18n.t('player.signResizeModeHint'));
|
|
900
|
+
this.player.keyboardManager?.announce(i18n.t('player.signLanguageResizeActive'));
|
|
901
|
+
} else {
|
|
902
|
+
this._hideModeBadge();
|
|
903
|
+
this.player.keyboardManager?.announce(i18n.t('player.signResizeModeDisabled'));
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
onDragStart: (e) => {
|
|
907
|
+
const target = e.target as HTMLElement;
|
|
908
|
+
if (target.closest(`.${classPrefix}-sign-language-close`) ||
|
|
909
|
+
target.closest(`.${classPrefix}-sign-language-settings`) ||
|
|
910
|
+
target.closest(`.${classPrefix}-sign-language-select`) ||
|
|
911
|
+
target.closest(`.${classPrefix}-sign-language-label`) ||
|
|
912
|
+
target.closest(`.${classPrefix}-sign-language-settings-menu`)) {
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
return true;
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
this._setupCustomKeyHandler();
|
|
920
|
+
|
|
921
|
+
this.interactionHandlers = {
|
|
922
|
+
draggable: this.draggable,
|
|
923
|
+
customKeyHandler: this.customKeyHandler
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Setup custom keyboard handler
|
|
929
|
+
*/
|
|
930
|
+
_setupCustomKeyHandler() {
|
|
931
|
+
this.customKeyHandler = (e: KeyboardEvent) => {
|
|
932
|
+
const key = e.key.toLowerCase();
|
|
933
|
+
|
|
934
|
+
if (this.settingsMenuVisible) return;
|
|
935
|
+
|
|
936
|
+
if (key === 'home') {
|
|
937
|
+
e.preventDefault();
|
|
938
|
+
e.stopPropagation();
|
|
939
|
+
if (this.draggable) {
|
|
940
|
+
if (this.draggable.pointerResizeMode) {
|
|
941
|
+
this.draggable.disablePointerResizeMode();
|
|
942
|
+
}
|
|
943
|
+
this.draggable.manuallyPositioned = false;
|
|
944
|
+
this.constrainPosition();
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (key === 'r') {
|
|
950
|
+
e.preventDefault();
|
|
951
|
+
e.stopPropagation();
|
|
952
|
+
if (this.toggleResizeMode()) {
|
|
953
|
+
this.wrapper?.focus({ preventScroll: true });
|
|
954
|
+
}
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (key === 'escape') {
|
|
959
|
+
e.preventDefault();
|
|
960
|
+
e.stopPropagation();
|
|
961
|
+
if (this.draggable?.pointerResizeMode) {
|
|
962
|
+
// disablePointerResizeMode() fires onPointerResizeToggle,
|
|
963
|
+
// which hides the badge and announces the change.
|
|
964
|
+
this.draggable.disablePointerResizeMode();
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (this.draggable?.keyboardDragMode) {
|
|
968
|
+
// disableKeyboardDragMode on the draggable has no
|
|
969
|
+
// callback, so mirror the cleanup that
|
|
970
|
+
// toggleKeyboardDragMode() does when it turns the
|
|
971
|
+
// mode off.
|
|
972
|
+
this.draggable.disableKeyboardDragMode();
|
|
973
|
+
this._hideModeBadge();
|
|
974
|
+
this._updateDragOptionState();
|
|
975
|
+
this.player.keyboardManager?.announce(i18n.t('player.signDragModeDisabled'));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
this.disable();
|
|
979
|
+
const signLanguageButton = this.player.controlBar?.controls?.signLanguage;
|
|
980
|
+
if (signLanguageButton) {
|
|
981
|
+
setTimeout(() => {
|
|
982
|
+
signLanguageButton.focus({ preventScroll: true });
|
|
983
|
+
}, 0);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
this.wrapper?.addEventListener('keydown', this.customKeyHandler);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Setup event handlers
|
|
993
|
+
*/
|
|
994
|
+
_setupEventHandlers(hasMultipleSources: boolean) {
|
|
995
|
+
this.handlers = {
|
|
996
|
+
play: () => { if (this.video) this.video.play(); },
|
|
997
|
+
pause: () => { if (this.video) this.video.pause(); },
|
|
998
|
+
timeupdate: () => {
|
|
999
|
+
if (this.video && Math.abs(this.video.currentTime - this.player.state.currentTime) > 0.5) {
|
|
1000
|
+
this.video.currentTime = this.player.state.currentTime;
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
ratechange: () => {
|
|
1004
|
+
if (this.video) this.video.playbackRate = this.player.state.playbackSpeed;
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
this.player.on('play', this.handlers.play);
|
|
1009
|
+
this.player.on('pause', this.handlers.pause);
|
|
1010
|
+
this.player.on('timeupdate', this.handlers.timeupdate);
|
|
1011
|
+
this.player.on('ratechange', this.handlers.ratechange);
|
|
1012
|
+
|
|
1013
|
+
if (hasMultipleSources) {
|
|
1014
|
+
this.handlers.captionChange = () => {
|
|
1015
|
+
if (this.player.captionManager?.currentTrack && this.selector) {
|
|
1016
|
+
const captionLang = this.player.captionManager.currentTrack.language?.toLowerCase().split('-')[0];
|
|
1017
|
+
if (captionLang && this.sources[captionLang] && this.currentLanguage !== captionLang) {
|
|
1018
|
+
this.switchLanguage(captionLang);
|
|
1019
|
+
this.selector.value = captionLang;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
this.player.on('captionsenabled', this.handlers.captionChange);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Constrain position within video wrapper
|
|
1029
|
+
*/
|
|
1030
|
+
constrainPosition() {
|
|
1031
|
+
if (!this.wrapper || !this.player.videoWrapper) return;
|
|
1032
|
+
|
|
1033
|
+
if (this.draggable?.manuallyPositioned) return;
|
|
1034
|
+
|
|
1035
|
+
if (!this.wrapper.style.width) {
|
|
1036
|
+
this.wrapper.style.width = '280px';
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const videoWrapperRect = this.player.videoWrapper.getBoundingClientRect();
|
|
1040
|
+
const containerRect = this.player.container.getBoundingClientRect();
|
|
1041
|
+
const wrapperRect = this.wrapper.getBoundingClientRect();
|
|
1042
|
+
|
|
1043
|
+
const videoWrapperLeft = videoWrapperRect.left - containerRect.left;
|
|
1044
|
+
const videoWrapperTop = videoWrapperRect.top - containerRect.top;
|
|
1045
|
+
const videoWrapperWidth = videoWrapperRect.width;
|
|
1046
|
+
const videoWrapperHeight = videoWrapperRect.height;
|
|
1047
|
+
|
|
1048
|
+
const wrapperWidth = wrapperRect.width || 280;
|
|
1049
|
+
const wrapperHeight = wrapperRect.height || ((280 * 9) / 16);
|
|
1050
|
+
|
|
1051
|
+
let left, top;
|
|
1052
|
+
const margin = 16;
|
|
1053
|
+
const controlsHeight = 95;
|
|
1054
|
+
|
|
1055
|
+
const position = this.desiredPosition || 'bottom-right';
|
|
1056
|
+
|
|
1057
|
+
switch (position) {
|
|
1058
|
+
case 'bottom-right':
|
|
1059
|
+
left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
|
|
1060
|
+
top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
|
|
1061
|
+
break;
|
|
1062
|
+
case 'bottom-left':
|
|
1063
|
+
left = videoWrapperLeft + margin;
|
|
1064
|
+
top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
|
|
1065
|
+
break;
|
|
1066
|
+
case 'top-right':
|
|
1067
|
+
left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
|
|
1068
|
+
top = videoWrapperTop + margin;
|
|
1069
|
+
break;
|
|
1070
|
+
case 'top-left':
|
|
1071
|
+
left = videoWrapperLeft + margin;
|
|
1072
|
+
top = videoWrapperTop + margin;
|
|
1073
|
+
break;
|
|
1074
|
+
default:
|
|
1075
|
+
left = videoWrapperLeft + videoWrapperWidth - wrapperWidth - margin;
|
|
1076
|
+
top = videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
left = Math.max(videoWrapperLeft, Math.min(left, videoWrapperLeft + videoWrapperWidth - wrapperWidth));
|
|
1080
|
+
top = Math.max(videoWrapperTop, Math.min(top, videoWrapperTop + videoWrapperHeight - wrapperHeight - controlsHeight));
|
|
1081
|
+
|
|
1082
|
+
this.wrapper.style.left = `${left}px`;
|
|
1083
|
+
this.wrapper.style.top = `${top}px`;
|
|
1084
|
+
this.wrapper.style.right = 'auto';
|
|
1085
|
+
this.wrapper.style.bottom = 'auto';
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Show the settings menu. Delegates to the shared {@link DraggablePanel},
|
|
1090
|
+
* which owns the DOM, outside-click dismissal, keyboard navigation and
|
|
1091
|
+
* positioning. Kept as a named method so external callers (other
|
|
1092
|
+
* managers, tests) that referenced the legacy API keep working.
|
|
1093
|
+
*/
|
|
1094
|
+
showSettingsMenu() {
|
|
1095
|
+
this._panel?.show();
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** @see {@link showSettingsMenu} */
|
|
1099
|
+
hideSettingsMenu({ focusButton = true } = {}) {
|
|
1100
|
+
this._panel?.hide({ focusButton });
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Badge management moved into {@link DraggablePanel}; these
|
|
1104
|
+
// delegates keep the legacy names so internal call sites and
|
|
1105
|
+
// subclassers (if any) continue to work. Announcements for
|
|
1106
|
+
// assistive tech still go through the shared KeyboardManager
|
|
1107
|
+
// live region so AT doesn't read both the badge text and the
|
|
1108
|
+
// live-region copy.
|
|
1109
|
+
_showModeBadge(text: string) {
|
|
1110
|
+
this._panel?.showBadge(text);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
_hideModeBadge() {
|
|
1114
|
+
this._panel?.hideBadge();
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Toggle keyboard drag mode
|
|
1119
|
+
*/
|
|
1120
|
+
toggleKeyboardDragMode() {
|
|
1121
|
+
if (!this.draggable) return;
|
|
1122
|
+
|
|
1123
|
+
const wasEnabled = this.draggable.keyboardDragMode;
|
|
1124
|
+
this.draggable.toggleKeyboardDragMode();
|
|
1125
|
+
const isEnabled = this.draggable.keyboardDragMode;
|
|
1126
|
+
|
|
1127
|
+
if (!wasEnabled && isEnabled) {
|
|
1128
|
+
this._enableMoveMode();
|
|
1129
|
+
this._showModeBadge(i18n.t('player.signDragModeHint'));
|
|
1130
|
+
this.player.keyboardManager?.announce(i18n.t('player.signLanguageDragActive'));
|
|
1131
|
+
} else if (wasEnabled && !isEnabled) {
|
|
1132
|
+
this._hideModeBadge();
|
|
1133
|
+
this.player.keyboardManager?.announce(i18n.t('player.signDragModeDisabled'));
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
this._updateDragOptionState();
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Enable move mode visual feedback
|
|
1141
|
+
*/
|
|
1142
|
+
_enableMoveMode() {
|
|
1143
|
+
this.wrapper?.classList.add(`${this.player.options.classPrefix}-sign-move-mode`);
|
|
1144
|
+
this._updateResizeOptionState();
|
|
1145
|
+
setTimeout(() => {
|
|
1146
|
+
this.wrapper?.classList.remove(`${this.player.options.classPrefix}-sign-move-mode`);
|
|
1147
|
+
}, 2000);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Toggle resize mode
|
|
1152
|
+
*/
|
|
1153
|
+
toggleResizeMode({ focus = true } = {}) {
|
|
1154
|
+
if (!this.draggable) return false;
|
|
1155
|
+
|
|
1156
|
+
if (this.draggable.pointerResizeMode) {
|
|
1157
|
+
this.draggable.disablePointerResizeMode({ focus });
|
|
1158
|
+
this._updateResizeOptionState();
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
this.draggable.enablePointerResizeMode({ focus });
|
|
1163
|
+
this._updateResizeOptionState();
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Thin delegates to the panel's refreshState. Kept as named methods
|
|
1168
|
+
// so the existing internal call sites (e.g. `toggleKeyboardDragMode`)
|
|
1169
|
+
// read naturally without a double-dot chain to the panel.
|
|
1170
|
+
_updateDragOptionState() {
|
|
1171
|
+
this._panel?.refreshDragState();
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
_updateResizeOptionState() {
|
|
1175
|
+
this._panel?.refreshResizeState();
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Save preferences
|
|
1180
|
+
*/
|
|
1181
|
+
savePreferences() {
|
|
1182
|
+
if (!this.wrapper) return;
|
|
1183
|
+
|
|
1184
|
+
this.player.storage.saveSignLanguagePreferences({
|
|
1185
|
+
size: { width: this.wrapper.style.width }
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Update sources (called when playlist changes)
|
|
1191
|
+
*/
|
|
1192
|
+
updateSources(signLanguageSrc?: string | null, signLanguageSources?: Record<string, string> | null) {
|
|
1193
|
+
this.src = signLanguageSrc || null;
|
|
1194
|
+
this.sources = signLanguageSources || {};
|
|
1195
|
+
// Reset state for new playlist item (cleanup is called separately before this)
|
|
1196
|
+
this.currentLanguage = null;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Cleanup
|
|
1201
|
+
*/
|
|
1202
|
+
cleanup() {
|
|
1203
|
+
if (this.inMainView && this.player.element) {
|
|
1204
|
+
const el = this.player.element;
|
|
1205
|
+
if (this._mainViewUsingSourceSwap && this.mainViewOriginalSources && this.mainViewOriginalSources.length > 0) {
|
|
1206
|
+
(Array.from(el.querySelectorAll('source')) as HTMLSourceElement[]).forEach((source: HTMLSourceElement) => source.remove());
|
|
1207
|
+
const trackNode = el.querySelector('track');
|
|
1208
|
+
this.mainViewOriginalSources.forEach((source: HTMLSourceElement) => {
|
|
1209
|
+
if (trackNode) {
|
|
1210
|
+
el.insertBefore(source, trackNode);
|
|
1211
|
+
} else {
|
|
1212
|
+
el.appendChild(source);
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
this._mainViewUsingSourceSwap = false;
|
|
1216
|
+
} else if (this.mainViewOriginalSrc) {
|
|
1217
|
+
el.src = this.mainViewOriginalSrc;
|
|
1218
|
+
}
|
|
1219
|
+
el.muted = this._mainViewMutedBefore;
|
|
1220
|
+
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
1221
|
+
this.player.invalidateTrackCache();
|
|
1222
|
+
}
|
|
1223
|
+
el.load();
|
|
1224
|
+
this.mainViewOriginalSrc = null;
|
|
1225
|
+
this.mainViewOriginalSources = null;
|
|
1226
|
+
this.inMainView = false;
|
|
1227
|
+
this.player.state.signLanguageInMainView = false;
|
|
1228
|
+
if (this.player.videoWrapper) {
|
|
1229
|
+
this.player.videoWrapper.classList.remove('vidply-sign-language-main-view-active');
|
|
1230
|
+
}
|
|
1231
|
+
this.player.emit('signlanguageinmainviewdisabled');
|
|
1232
|
+
}
|
|
1233
|
+
if (this.settingsMenuVisible) {
|
|
1234
|
+
this.hideSettingsMenu({ focusButton: false });
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// The panel owns its outside-click listener (bound to the
|
|
1238
|
+
// player's lifecycleSignal) and will drop that listener
|
|
1239
|
+
// automatically during player teardown. We still destroy the
|
|
1240
|
+
// panel here so its DOM is removed synchronously when the
|
|
1241
|
+
// wrapper goes away.
|
|
1242
|
+
if (this._panel) {
|
|
1243
|
+
this._panel.destroy();
|
|
1244
|
+
this._panel = null;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Remove settings handlers
|
|
1248
|
+
if (this.settingsHandlers && this.settingsButton) {
|
|
1249
|
+
this.settingsButton.removeEventListener('click', this.settingsHandlers.click);
|
|
1250
|
+
this.settingsButton.removeEventListener('keydown', this.settingsHandlers.keydown);
|
|
1251
|
+
}
|
|
1252
|
+
this.settingsHandlers = null;
|
|
1253
|
+
|
|
1254
|
+
// Remove event handlers
|
|
1255
|
+
if (this.handlers) {
|
|
1256
|
+
this.player.off('play', this.handlers.play);
|
|
1257
|
+
this.player.off('pause', this.handlers.pause);
|
|
1258
|
+
this.player.off('timeupdate', this.handlers.timeupdate);
|
|
1259
|
+
this.player.off('ratechange', this.handlers.ratechange);
|
|
1260
|
+
if (this.handlers.captionChange) {
|
|
1261
|
+
this.player.off('captionsenabled', this.handlers.captionChange);
|
|
1262
|
+
}
|
|
1263
|
+
this.handlers = null;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Remove custom key handler
|
|
1267
|
+
if (this.wrapper && this.customKeyHandler) {
|
|
1268
|
+
this.wrapper.removeEventListener('keydown', this.customKeyHandler);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Destroy draggable
|
|
1272
|
+
if (this.draggable) {
|
|
1273
|
+
if (this.draggable.pointerResizeMode) {
|
|
1274
|
+
this.draggable.disablePointerResizeMode();
|
|
1275
|
+
}
|
|
1276
|
+
this.draggable.destroy();
|
|
1277
|
+
this.draggable = null;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
this.interactionHandlers = null;
|
|
1281
|
+
|
|
1282
|
+
// Remove the mode badge before tearing down the wrapper, so the
|
|
1283
|
+
// reference doesn't outlive the DOM tree it belongs to.
|
|
1284
|
+
this._hideModeBadge();
|
|
1285
|
+
|
|
1286
|
+
// Remove video and wrapper
|
|
1287
|
+
if (this.wrapper?.parentNode) {
|
|
1288
|
+
if (this.video) {
|
|
1289
|
+
this.video.pause();
|
|
1290
|
+
this.video.src = '';
|
|
1291
|
+
}
|
|
1292
|
+
this.wrapper.parentNode.removeChild(this.wrapper);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
this.wrapper = null;
|
|
1296
|
+
this.video = null;
|
|
1297
|
+
this.settingsButton = null;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Destroy
|
|
1302
|
+
*/
|
|
1303
|
+
destroy() {
|
|
1304
|
+
this.cleanup();
|
|
1305
|
+
this.enabled = false;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|