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,2426 +1,2426 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Transcript Manager Component
|
|
3
|
-
* Manages transcript display and interaction
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
7
|
-
import { TimeUtils } from '../utils/TimeUtils.js';
|
|
8
|
-
import { createIconElement } from '../icons/Icons.js';
|
|
9
|
-
import { i18n } from '../i18n/i18n.js';
|
|
10
|
-
import { StorageManager } from '../utils/StorageManager.js';
|
|
11
|
-
import { focusElement } from '../utils/FocusUtils.js';
|
|
12
|
-
import { createMenuItem } from '../utils/MenuUtils.js';
|
|
13
|
-
import { DraggableResizable } from '../utils/DraggableResizable.js';
|
|
14
|
-
import { createLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
|
|
15
|
-
import { DraggablePanel } from '../utils/DraggablePanel.js';
|
|
16
|
-
import { deriveTrackLabel } from '../utils/TrackLabelUtils.js';
|
|
17
|
-
import type { Player } from '../core/Player.js';
|
|
18
|
-
import type { Renderer } from '../types/renderer.js';
|
|
19
|
-
|
|
20
|
-
type TranscriptCue = TextTrackCue;
|
|
21
|
-
type TranscriptTrack = TextTrack & { _vidplyStale?: boolean };
|
|
22
|
-
type TranscriptLanguageInfo = {
|
|
23
|
-
language: string;
|
|
24
|
-
label: string;
|
|
25
|
-
track: TranscriptTrack;
|
|
26
|
-
};
|
|
27
|
-
type TranscriptCueItem = {
|
|
28
|
-
cue: TranscriptCue;
|
|
29
|
-
type: 'caption' | 'description';
|
|
30
|
-
};
|
|
31
|
-
type TranscriptEntry = {
|
|
32
|
-
element: HTMLElement;
|
|
33
|
-
cue: TranscriptCue;
|
|
34
|
-
type: 'caption' | 'description';
|
|
35
|
-
startTime: number;
|
|
36
|
-
endTime: number;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
interface TranscriptStyleOptions {
|
|
40
|
-
fontSize: string;
|
|
41
|
-
fontFamily: string;
|
|
42
|
-
color: string;
|
|
43
|
-
backgroundColor: string;
|
|
44
|
-
opacity: number;
|
|
45
|
-
[key: string]: unknown;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
interface TranscriptHandlers {
|
|
49
|
-
timeupdate: () => void;
|
|
50
|
-
seeked: () => void;
|
|
51
|
-
audiodescriptionenabled: () => void;
|
|
52
|
-
audiodescriptiondisabled: () => void;
|
|
53
|
-
textcuesupdate: (() => void) | null;
|
|
54
|
-
resize: (() => void) | null;
|
|
55
|
-
settingsClick: ((e: MouseEvent) => void) | null;
|
|
56
|
-
settingsKeydown: ((e: KeyboardEvent) => void) | null;
|
|
57
|
-
documentClick: ((e: MouseEvent) => void) | null;
|
|
58
|
-
styleDialogKeydown: ((e: KeyboardEvent) => void) | null;
|
|
59
|
-
floatingchange: ((state: 'pinned' | 'auto' | null) => void) | null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
63
|
-
|
|
64
|
-
export class TranscriptManager {
|
|
65
|
-
player: Player;
|
|
66
|
-
_cueUpdateTimeout: TimerHandle | null;
|
|
67
|
-
autoscrollCheckbox: HTMLInputElement | null = null;
|
|
68
|
-
autoscrollEnabled: boolean;
|
|
69
|
-
availableTranscriptLanguages: TranscriptLanguageInfo[];
|
|
70
|
-
currentActiveEntry: TranscriptEntry | null;
|
|
71
|
-
currentTranscriptLanguage: string | null;
|
|
72
|
-
customKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
73
|
-
/**
|
|
74
|
-
* True once the style-dialog's outside-click listener has been
|
|
75
|
-
* attached. The settings-menu's outside-click listener is now
|
|
76
|
-
* owned by {@link DraggablePanel} and tracked there; this flag
|
|
77
|
-
* covers the dialog half of the shared `handlers.documentClick`.
|
|
78
|
-
*/
|
|
79
|
-
documentClickHandlerAdded: boolean = false;
|
|
80
|
-
draggableResizable: DraggableResizable | null;
|
|
81
|
-
handlers: TranscriptHandlers = {} as TranscriptHandlers;
|
|
82
|
-
headerLeft: HTMLElement | null = null;
|
|
83
|
-
isVisible: boolean;
|
|
84
|
-
languageLabel: HTMLElement | null;
|
|
85
|
-
languageSelector: HTMLSelectElement | null;
|
|
86
|
-
languageSelectorHandler: ((e: Event) => void) | null;
|
|
87
|
-
languageSelectorWrapper: HTMLElement | null = null;
|
|
88
|
-
liveRegion: HTMLElement | null;
|
|
89
|
-
metadataCueChangeHandler: (() => void) | null = null;
|
|
90
|
-
metadataCues: TextTrackCue[];
|
|
91
|
-
settingsButton: HTMLButtonElement | null;
|
|
92
|
-
showTimestamps: boolean;
|
|
93
|
-
showTimestampsButton: HTMLElement | null = null;
|
|
94
|
-
showTimestampsText: Element | null = null;
|
|
95
|
-
storage: StorageManager;
|
|
96
|
-
styleDialog: HTMLElement | null;
|
|
97
|
-
styleDialogJustOpened: boolean;
|
|
98
|
-
styleDialogVisible: boolean;
|
|
99
|
-
timeouts: Set<TimerHandle>;
|
|
100
|
-
transcriptContent: HTMLElement | null = null;
|
|
101
|
-
transcriptEntries: TranscriptEntry[];
|
|
102
|
-
transcriptHeader: HTMLElement | null = null;
|
|
103
|
-
transcriptResizeHandles: HTMLElement[];
|
|
104
|
-
transcriptStyle: TranscriptStyleOptions;
|
|
105
|
-
transcriptWindow: HTMLElement | null;
|
|
106
|
-
_dashActiveLang: string | null;
|
|
107
|
-
_vttCache: Map<string, TranscriptCueItem[]>;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Owns the settings-menu DOM scaffold, its outside-click
|
|
111
|
-
* dismissal, keyboard navigation, viewport-aware positioning,
|
|
112
|
-
* and the drag-mode / resize-mode toggle items. Instantiated
|
|
113
|
-
* lazily once the header is built in {@link createTranscriptHeader}.
|
|
114
|
-
*/
|
|
115
|
-
private _panel: DraggablePanel | null = null;
|
|
116
|
-
|
|
117
|
-
// Back-compat getters for panel-owned state. External callers and
|
|
118
|
-
// internal reads (e.g. `this.settingsMenuVisible` inside other
|
|
119
|
-
// transcript methods) keep reading the same properties; the
|
|
120
|
-
// setters are no-ops because the panel is now the authoritative
|
|
121
|
-
// owner of those values.
|
|
122
|
-
get settingsMenu(): HTMLElement | null {
|
|
123
|
-
return this._panel?.settingsMenu ?? null;
|
|
124
|
-
}
|
|
125
|
-
set settingsMenu(_v: HTMLElement | null) {
|
|
126
|
-
// panel-owned.
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
get settingsMenuVisible(): boolean {
|
|
130
|
-
return this._panel?.settingsMenuVisible ?? false;
|
|
131
|
-
}
|
|
132
|
-
set settingsMenuVisible(_v: boolean) {
|
|
133
|
-
// panel-owned.
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
get settingsMenuJustOpened(): boolean {
|
|
137
|
-
return this._panel?.justOpened ?? false;
|
|
138
|
-
}
|
|
139
|
-
set settingsMenuJustOpened(_v: boolean) {
|
|
140
|
-
// panel-owned.
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
get dragOptionButton(): HTMLElement | null {
|
|
144
|
-
return this._panel?.dragOptionButton ?? null;
|
|
145
|
-
}
|
|
146
|
-
get dragOptionText(): Element | null {
|
|
147
|
-
return this._panel?.dragOptionText ?? null;
|
|
148
|
-
}
|
|
149
|
-
get resizeOptionButton(): HTMLElement | null {
|
|
150
|
-
return this._panel?.resizeOptionButton ?? null;
|
|
151
|
-
}
|
|
152
|
-
get resizeOptionText(): Element | null {
|
|
153
|
-
return this._panel?.resizeOptionText ?? null;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
constructor(player: Player) {
|
|
157
|
-
this.player = player;
|
|
158
|
-
this.transcriptWindow = null;
|
|
159
|
-
this.transcriptEntries = [];
|
|
160
|
-
this.metadataCues = [];
|
|
161
|
-
this.currentActiveEntry = null;
|
|
162
|
-
this.isVisible = false;
|
|
163
|
-
|
|
164
|
-
// Storage manager
|
|
165
|
-
this.storage = new StorageManager('vidply');
|
|
166
|
-
|
|
167
|
-
// Draggable/Resizable utility
|
|
168
|
-
this.draggableResizable = null;
|
|
169
|
-
this.settingsButton = null;
|
|
170
|
-
|
|
171
|
-
// Resize mode state
|
|
172
|
-
this.transcriptResizeHandles = [];
|
|
173
|
-
this.liveRegion = null;
|
|
174
|
-
|
|
175
|
-
// Style dialog state
|
|
176
|
-
this.styleDialog = null;
|
|
177
|
-
this.styleDialogVisible = false;
|
|
178
|
-
this.styleDialogJustOpened = false;
|
|
179
|
-
|
|
180
|
-
// Language selector state
|
|
181
|
-
this.languageSelector = null;
|
|
182
|
-
this.languageLabel = null;
|
|
183
|
-
this.currentTranscriptLanguage = null;
|
|
184
|
-
this.availableTranscriptLanguages = [];
|
|
185
|
-
this.languageSelectorHandler = null;
|
|
186
|
-
|
|
187
|
-
const savedPreferences = this.storage.getTranscriptPreferences();
|
|
188
|
-
|
|
189
|
-
this.autoscrollEnabled = typeof savedPreferences?.autoscroll === 'boolean' ? savedPreferences.autoscroll : true;
|
|
190
|
-
|
|
191
|
-
this.showTimestamps = typeof savedPreferences?.showTimestamps === 'boolean' ? savedPreferences.showTimestamps : false;
|
|
192
|
-
|
|
193
|
-
const savedFontSize = typeof savedPreferences?.fontSize === 'string' ? savedPreferences.fontSize : undefined;
|
|
194
|
-
const savedFontFamily = typeof savedPreferences?.fontFamily === 'string' ? savedPreferences.fontFamily : undefined;
|
|
195
|
-
const savedColor = typeof savedPreferences?.color === 'string' ? savedPreferences.color : undefined;
|
|
196
|
-
const savedBackgroundColor = typeof savedPreferences?.backgroundColor === 'string' ? savedPreferences.backgroundColor : undefined;
|
|
197
|
-
const savedOpacity = typeof savedPreferences?.opacity === 'number' ? savedPreferences.opacity : undefined;
|
|
198
|
-
const optFontSize = typeof this.player.options.transcriptFontSize === 'string' ? this.player.options.transcriptFontSize : undefined;
|
|
199
|
-
const optFontFamily = typeof this.player.options.transcriptFontFamily === 'string' ? this.player.options.transcriptFontFamily : undefined;
|
|
200
|
-
const optColor = typeof this.player.options.transcriptColor === 'string' ? this.player.options.transcriptColor : undefined;
|
|
201
|
-
const optBackgroundColor = typeof this.player.options.transcriptBackgroundColor === 'string' ? this.player.options.transcriptBackgroundColor : undefined;
|
|
202
|
-
const optOpacity = typeof this.player.options.transcriptOpacity === 'number' ? this.player.options.transcriptOpacity : undefined;
|
|
203
|
-
this.transcriptStyle = {
|
|
204
|
-
fontSize: savedFontSize || optFontSize || '100%',
|
|
205
|
-
fontFamily: savedFontFamily || optFontFamily || 'sans-serif',
|
|
206
|
-
color: savedColor || optColor || '#ffffff',
|
|
207
|
-
backgroundColor: savedBackgroundColor || optBackgroundColor || '#1e1e1e',
|
|
208
|
-
opacity: savedOpacity ?? optOpacity ?? 0.98
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
// Store event handlers for cleanup
|
|
212
|
-
this.handlers = {
|
|
213
|
-
timeupdate: () => this.updateActiveEntry(),
|
|
214
|
-
seeked: () => this.updateActiveEntry(),
|
|
215
|
-
audiodescriptionenabled: () => {
|
|
216
|
-
if (this.isVisible) {
|
|
217
|
-
this.loadTranscriptData();
|
|
218
|
-
}
|
|
219
|
-
},
|
|
220
|
-
audiodescriptiondisabled: () => {
|
|
221
|
-
if (this.isVisible) {
|
|
222
|
-
this.loadTranscriptData();
|
|
223
|
-
}
|
|
224
|
-
},
|
|
225
|
-
textcuesupdate: null,
|
|
226
|
-
resize: null,
|
|
227
|
-
settingsClick: null,
|
|
228
|
-
settingsKeydown: null,
|
|
229
|
-
documentClick: null,
|
|
230
|
-
styleDialogKeydown: null,
|
|
231
|
-
floatingchange: null
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
this._cueUpdateTimeout = null;
|
|
235
|
-
this._dashActiveLang = null;
|
|
236
|
-
this._vttCache = new Map<string, TranscriptCueItem[]>();
|
|
237
|
-
|
|
238
|
-
// Timeout management (for cleanup)
|
|
239
|
-
this.timeouts = new Set();
|
|
240
|
-
|
|
241
|
-
this.init();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
init() {
|
|
245
|
-
// Set up metadata handling immediately (independent of transcript display)
|
|
246
|
-
this.setupMetadataHandlingOnLoad();
|
|
247
|
-
|
|
248
|
-
// Listen for time updates to highlight active transcript entry
|
|
249
|
-
this.player.on('timeupdate', this.handlers.timeupdate);
|
|
250
|
-
this.player.on('seeked', this.handlers.seeked);
|
|
251
|
-
|
|
252
|
-
// Listen for audio description changes to reload transcript
|
|
253
|
-
this.player.on('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
|
|
254
|
-
this.player.on('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
|
|
255
|
-
|
|
256
|
-
// Listen for text cue updates (cues arrive incrementally as segments load in HLS/DASH).
|
|
257
|
-
// Skip re-render when we already fetched the full VTT for the active language.
|
|
258
|
-
this.handlers.textcuesupdate = () => {
|
|
259
|
-
if (!this.isVisible) return;
|
|
260
|
-
if (this.currentTranscriptLanguage && this._vttCache.has(this.currentTranscriptLanguage)) return;
|
|
261
|
-
if (this._cueUpdateTimeout) {
|
|
262
|
-
this.clearManagedTimeout(this._cueUpdateTimeout);
|
|
263
|
-
}
|
|
264
|
-
this._cueUpdateTimeout = this.setManagedTimeout(() => {
|
|
265
|
-
this._cueUpdateTimeout = null;
|
|
266
|
-
this.loadTranscriptData();
|
|
267
|
-
}, 400);
|
|
268
|
-
};
|
|
269
|
-
this.player.on('textcuesupdate', this.handlers.textcuesupdate);
|
|
270
|
-
|
|
271
|
-
// Reposition transcript when entering/exiting fullscreen
|
|
272
|
-
this.player.on('fullscreenchange', () => {
|
|
273
|
-
if (this.isVisible) {
|
|
274
|
-
// Re-setup drag/drop when entering/exiting fullscreen on mobile devices
|
|
275
|
-
// This enables drag/resize when entering fullscreen on mobile
|
|
276
|
-
const isMobile = window.innerWidth < 768;
|
|
277
|
-
if (isMobile) {
|
|
278
|
-
this.setupDragAndDrop();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Only auto-position if user hasn't manually positioned it
|
|
282
|
-
if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
|
|
283
|
-
// Add a small delay to ensure DOM has updated after fullscreen transition
|
|
284
|
-
this.setManagedTimeout(() => this.positionTranscript(), 100);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// Collapse the transcript whenever the player enters the floating
|
|
290
|
-
// shell. The floating miniplayer is intentionally narrow and only
|
|
291
|
-
// shows a whitelisted subset of controls, so a full-size transcript
|
|
292
|
-
// popup would either overflow the shell or travel with it and break
|
|
293
|
-
// the miniplayer UX. We redock the transcript on exit by simply
|
|
294
|
-
// letting the user re-open it if desired.
|
|
295
|
-
this.handlers.floatingchange = (state) => {
|
|
296
|
-
if (state && this.isVisible) {
|
|
297
|
-
this.hideTranscript();
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
this.player.on('floatingchange', this.handlers.floatingchange);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* For streaming renderers (DASH), tell the renderer to activate the text
|
|
305
|
-
* track for `lang` so dash.js starts downloading subtitle segments and
|
|
306
|
-
* populating cues. Skips the call if the language is already active.
|
|
307
|
-
*/
|
|
308
|
-
private _requestStreamingTrack(lang: string | null) {
|
|
309
|
-
if (!lang) return;
|
|
310
|
-
const renderer = this.player.renderer as Renderer | null | undefined;
|
|
311
|
-
if (renderer?.isStreaming && typeof renderer.activateTextTrackForLanguage === 'function') {
|
|
312
|
-
if (this._dashActiveLang !== lang) {
|
|
313
|
-
this._dashActiveLang = lang;
|
|
314
|
-
renderer.activateTextTrackForLanguage(lang);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Toggle transcript window visibility
|
|
321
|
-
*/
|
|
322
|
-
toggleTranscript() {
|
|
323
|
-
if (this.isVisible) {
|
|
324
|
-
this.hideTranscript();
|
|
325
|
-
} else {
|
|
326
|
-
this.showTranscript();
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Show transcript window
|
|
332
|
-
*/
|
|
333
|
-
showTranscript() {
|
|
334
|
-
// The floating miniplayer hides the transcript control button and is
|
|
335
|
-
// too narrow to host the transcript window, so refuse to open while
|
|
336
|
-
// floating. Covers both the control-bar click (already gated by CSS)
|
|
337
|
-
// and the keyboard shortcut / public API paths.
|
|
338
|
-
if (this.player.state?.floating) {
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Always invalidate track cache to get fresh HLS subtitle tracks
|
|
343
|
-
this.player.invalidateTrackCache();
|
|
344
|
-
|
|
345
|
-
if (this.transcriptWindow) {
|
|
346
|
-
this.transcriptWindow.style.display = 'flex';
|
|
347
|
-
this.isVisible = true;
|
|
348
|
-
|
|
349
|
-
// Reload transcript data to get fresh HLS tracks
|
|
350
|
-
this.loadTranscriptData();
|
|
351
|
-
this._requestStreamingTrack(this.currentTranscriptLanguage);
|
|
352
|
-
this.updateLanguageSelector();
|
|
353
|
-
|
|
354
|
-
if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
|
|
355
|
-
this.player.controlBar.updateTranscriptButton();
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Focus the settings button for keyboard accessibility
|
|
359
|
-
focusElement(this.settingsButton, { delay: 150 });
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
this.createTranscriptWindow();
|
|
364
|
-
this.loadTranscriptData();
|
|
365
|
-
this._requestStreamingTrack(this.currentTranscriptLanguage);
|
|
366
|
-
|
|
367
|
-
const transcriptWindow = this.transcriptWindow as HTMLElement | null;
|
|
368
|
-
if (transcriptWindow) {
|
|
369
|
-
transcriptWindow.style.display = 'flex';
|
|
370
|
-
|
|
371
|
-
if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
|
|
372
|
-
this.setManagedTimeout(() => this.positionTranscript(), 0);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
focusElement(this.settingsButton, { delay: 150 });
|
|
376
|
-
}
|
|
377
|
-
this.isVisible = true;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Hide transcript window
|
|
382
|
-
*/
|
|
383
|
-
hideTranscript({ focusButton = false } = {}) {
|
|
384
|
-
if (this.transcriptWindow) {
|
|
385
|
-
this.transcriptWindow.style.display = 'none';
|
|
386
|
-
this.isVisible = false;
|
|
387
|
-
}
|
|
388
|
-
if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
|
|
389
|
-
this.draggableResizable.disablePointerResizeMode();
|
|
390
|
-
this.updateResizeOptionState();
|
|
391
|
-
}
|
|
392
|
-
this.hideResizeModeIndicator();
|
|
393
|
-
this.announceLive('');
|
|
394
|
-
|
|
395
|
-
// Update transcript button state in control bar
|
|
396
|
-
if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
|
|
397
|
-
this.player.controlBar.updateTranscriptButton();
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (focusButton) {
|
|
401
|
-
const transcriptButton = this.player.controlBar?.controls?.transcript;
|
|
402
|
-
if (transcriptButton && typeof transcriptButton.focus === 'function') {
|
|
403
|
-
transcriptButton.focus({ preventScroll: true });
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Create the transcript window UI
|
|
410
|
-
*/
|
|
411
|
-
createTranscriptWindow() {
|
|
412
|
-
this.transcriptWindow = DOMUtils.createElement('div', {
|
|
413
|
-
className: `${this.player.options.classPrefix}-transcript-window`,
|
|
414
|
-
attributes: {
|
|
415
|
-
'role': 'dialog',
|
|
416
|
-
'aria-label': i18n.t('transcript.ariaLabel'),
|
|
417
|
-
'tabindex': '-1'
|
|
418
|
-
}
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
// Header (draggable)
|
|
422
|
-
this.transcriptHeader = DOMUtils.createElement('div', {
|
|
423
|
-
className: `${this.player.options.classPrefix}-transcript-header`,
|
|
424
|
-
attributes: {
|
|
425
|
-
'tabindex': '0'
|
|
426
|
-
}
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
// Header left side (settings button + title)
|
|
430
|
-
this.headerLeft = DOMUtils.createElement('div', {
|
|
431
|
-
className: `${this.player.options.classPrefix}-transcript-header-left`
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
// Settings button
|
|
435
|
-
const settingsAriaLabel = i18n.t('transcript.settingsMenu');
|
|
436
|
-
this.settingsButton = DOMUtils.createElement('button', {
|
|
437
|
-
className: `${this.player.options.classPrefix}-transcript-settings`,
|
|
438
|
-
attributes: {
|
|
439
|
-
'type': 'button',
|
|
440
|
-
'aria-label': settingsAriaLabel,
|
|
441
|
-
'aria-expanded': 'false'
|
|
442
|
-
}
|
|
443
|
-
});
|
|
444
|
-
this.settingsButton.appendChild(createIconElement('settings'));
|
|
445
|
-
DOMUtils.attachTooltip(this.settingsButton, settingsAriaLabel, this.player.options.classPrefix);
|
|
446
|
-
|
|
447
|
-
// The panel owns the settings-menu DOM, lifecycle, and the drag
|
|
448
|
-
// + resize toggle items. Transcript-specific extras (style dialog
|
|
449
|
-
// opener and timestamps toggle) are appended via the extras
|
|
450
|
-
// builder below. The manager keeps ownership of the toggle
|
|
451
|
-
// semantics (announcements, focus target), which the panel
|
|
452
|
-
// invokes via the click callbacks.
|
|
453
|
-
this._panel = new DraggablePanel({
|
|
454
|
-
player: this.player,
|
|
455
|
-
namespace: 'transcript',
|
|
456
|
-
settingsButton: this.settingsButton,
|
|
457
|
-
getDraggable: () => this.draggableResizable,
|
|
458
|
-
i18nKeys: {
|
|
459
|
-
enableDrag: 'transcript.enableDragMode',
|
|
460
|
-
disableDrag: 'transcript.disableDragMode',
|
|
461
|
-
enableDragAria: 'transcript.enableDragModeAria',
|
|
462
|
-
disableDragAria: 'transcript.disableDragModeAria',
|
|
463
|
-
enableResize: 'transcript.enableResizeMode',
|
|
464
|
-
disableResize: 'transcript.disableResizeMode',
|
|
465
|
-
enableResizeAria: 'transcript.enableResizeModeAria',
|
|
466
|
-
disableResizeAria: 'transcript.disableResizeModeAria',
|
|
467
|
-
closeMenu: 'transcript.closeMenu',
|
|
468
|
-
},
|
|
469
|
-
menuAlign: 'left',
|
|
470
|
-
getMenuParent: () =>
|
|
471
|
-
this.headerLeft ?? this.transcriptHeader ?? this.transcriptWindow,
|
|
472
|
-
// The transcript window itself is the anchor for the mode badge
|
|
473
|
-
// so it sits above the panel regardless of where the settings
|
|
474
|
-
// menu was opened from. The default class name is derived from
|
|
475
|
-
// the namespace → `{classPrefix}-transcript-mode-badge`.
|
|
476
|
-
getBadgeHost: () => this.transcriptWindow,
|
|
477
|
-
onDragItemClick: (panel) => {
|
|
478
|
-
this.toggleKeyboardDragMode();
|
|
479
|
-
panel.hide();
|
|
480
|
-
},
|
|
481
|
-
onResizeItemClick: (panel) => {
|
|
482
|
-
const enabled = this.toggleResizeMode({ focus: false });
|
|
483
|
-
if (enabled) {
|
|
484
|
-
panel.hide({ focusButton: false });
|
|
485
|
-
setTimeout(() => {
|
|
486
|
-
this.transcriptWindow?.focus({ preventScroll: true });
|
|
487
|
-
}, 20);
|
|
488
|
-
} else {
|
|
489
|
-
panel.hide({ focusButton: true });
|
|
490
|
-
}
|
|
491
|
-
},
|
|
492
|
-
buildExtraItems: ({ menu, itemClass, classPrefix, stripInlineTooltip }) => {
|
|
493
|
-
const styleOption = createMenuItem({
|
|
494
|
-
classPrefix,
|
|
495
|
-
itemClass,
|
|
496
|
-
icon: 'settings',
|
|
497
|
-
label: 'transcript.styleTranscript',
|
|
498
|
-
onClick: (e: Event) => {
|
|
499
|
-
e.preventDefault();
|
|
500
|
-
e.stopPropagation();
|
|
501
|
-
this._panel?.hide();
|
|
502
|
-
setTimeout(() => {
|
|
503
|
-
this.showStyleDialog();
|
|
504
|
-
}, 50);
|
|
505
|
-
},
|
|
506
|
-
});
|
|
507
|
-
stripInlineTooltip(styleOption);
|
|
508
|
-
menu.appendChild(styleOption);
|
|
509
|
-
|
|
510
|
-
const timestampsOption = createMenuItem({
|
|
511
|
-
classPrefix,
|
|
512
|
-
itemClass,
|
|
513
|
-
icon: 'clock',
|
|
514
|
-
label: 'transcript.showTimestamps',
|
|
515
|
-
hasTextClass: true,
|
|
516
|
-
onClick: () => {
|
|
517
|
-
this.toggleShowTimestamps();
|
|
518
|
-
},
|
|
519
|
-
});
|
|
520
|
-
timestampsOption.setAttribute('role', 'switch');
|
|
521
|
-
timestampsOption.setAttribute(
|
|
522
|
-
'aria-checked',
|
|
523
|
-
this.showTimestamps ? 'true' : 'false'
|
|
524
|
-
);
|
|
525
|
-
stripInlineTooltip(timestampsOption);
|
|
526
|
-
this.showTimestampsButton = timestampsOption;
|
|
527
|
-
this.showTimestampsText = timestampsOption.querySelector(
|
|
528
|
-
`.${classPrefix}-settings-text`
|
|
529
|
-
);
|
|
530
|
-
this.updateShowTimestampsState();
|
|
531
|
-
menu.appendChild(timestampsOption);
|
|
532
|
-
},
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
this.handlers.settingsClick = (e: MouseEvent) => {
|
|
536
|
-
e.preventDefault();
|
|
537
|
-
e.stopPropagation();
|
|
538
|
-
this._panel?.markJustOpenedForClick();
|
|
539
|
-
this._panel?.toggle();
|
|
540
|
-
};
|
|
541
|
-
this.settingsButton.addEventListener('click', this.handlers.settingsClick);
|
|
542
|
-
|
|
543
|
-
// Keyboard handler for settings button
|
|
544
|
-
this.handlers.settingsKeydown = (e: KeyboardEvent) => {
|
|
545
|
-
// D key to toggle keyboard drag mode
|
|
546
|
-
if (e.key === 'd' || e.key === 'D') {
|
|
547
|
-
e.preventDefault();
|
|
548
|
-
e.stopPropagation();
|
|
549
|
-
this.toggleKeyboardDragMode();
|
|
550
|
-
}
|
|
551
|
-
// R key to toggle resize mode
|
|
552
|
-
else if (e.key === 'r' || e.key === 'R') {
|
|
553
|
-
e.preventDefault();
|
|
554
|
-
e.stopPropagation();
|
|
555
|
-
this.toggleResizeMode();
|
|
556
|
-
}
|
|
557
|
-
// Escape to close menu if open
|
|
558
|
-
else if (e.key === 'Escape' && this.settingsMenuVisible) {
|
|
559
|
-
e.preventDefault();
|
|
560
|
-
e.stopPropagation();
|
|
561
|
-
this.hideSettingsMenu();
|
|
562
|
-
}
|
|
563
|
-
};
|
|
564
|
-
this.settingsButton.addEventListener('keydown', this.handlers.settingsKeydown);
|
|
565
|
-
|
|
566
|
-
const title = DOMUtils.createElement('h3', {
|
|
567
|
-
textContent: `${i18n.t('transcript.title')}. ${i18n.t('transcript.dragResizePrompt')}`
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
// Autoscroll checkbox
|
|
571
|
-
const autoscrollId = `${this.player.options.classPrefix}-transcript-autoscroll-${Date.now()}`;
|
|
572
|
-
|
|
573
|
-
const autoscrollLabel = DOMUtils.createElement('label', {
|
|
574
|
-
className: `${this.player.options.classPrefix}-transcript-autoscroll-label`,
|
|
575
|
-
attributes: {
|
|
576
|
-
'for': autoscrollId
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
this.autoscrollCheckbox = DOMUtils.createElement('input', {
|
|
581
|
-
attributes: {
|
|
582
|
-
'id': autoscrollId,
|
|
583
|
-
'type': 'checkbox'
|
|
584
|
-
}
|
|
585
|
-
});
|
|
586
|
-
// Set checked property directly (boolean attribute, not "true" string)
|
|
587
|
-
if (this.autoscrollEnabled) {
|
|
588
|
-
this.autoscrollCheckbox.checked = true;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
const autoscrollText = DOMUtils.createElement('span', {
|
|
592
|
-
textContent: i18n.t('transcript.autoscroll'),
|
|
593
|
-
className: `${this.player.options.classPrefix}-transcript-autoscroll-text`
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
autoscrollLabel.appendChild(this.autoscrollCheckbox);
|
|
597
|
-
autoscrollLabel.appendChild(autoscrollText);
|
|
598
|
-
|
|
599
|
-
// Handle autoscroll checkbox change
|
|
600
|
-
this.autoscrollCheckbox.addEventListener('change', (e: Event) => {
|
|
601
|
-
this.autoscrollEnabled = (e.target as HTMLInputElement).checked;
|
|
602
|
-
this.saveAutoscrollPreference();
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
preventDragOnElement(autoscrollLabel);
|
|
606
|
-
|
|
607
|
-
this.transcriptHeader.appendChild(title);
|
|
608
|
-
this.headerLeft.appendChild(this.settingsButton);
|
|
609
|
-
this.headerLeft.appendChild(autoscrollLabel);
|
|
610
|
-
|
|
611
|
-
// Language selector (will be populated after tracks are loaded)
|
|
612
|
-
const selectId = `${this.player.options.classPrefix}-transcript-language-select-${Date.now()}`;
|
|
613
|
-
const { label: languageLabel, select: languageSelector } = createLabeledSelect({
|
|
614
|
-
classPrefix: this.player.options.classPrefix,
|
|
615
|
-
labelClass: `${this.player.options.classPrefix}-transcript-language-label`,
|
|
616
|
-
selectClass: `${this.player.options.classPrefix}-transcript-language-select`,
|
|
617
|
-
labelText: 'settings.language',
|
|
618
|
-
selectId: selectId,
|
|
619
|
-
hidden: false // Don't hide individual elements, we'll hide the wrapper instead
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
this.languageLabel = languageLabel;
|
|
623
|
-
this.languageSelector = languageSelector;
|
|
624
|
-
|
|
625
|
-
const languageSelectorWrapper = DOMUtils.createElement('div', {
|
|
626
|
-
className: `${this.player.options.classPrefix}-transcript-language-wrapper`,
|
|
627
|
-
attributes: {
|
|
628
|
-
'style': 'display: none;'
|
|
629
|
-
}
|
|
630
|
-
});
|
|
631
|
-
languageSelectorWrapper.appendChild(languageLabel);
|
|
632
|
-
languageSelectorWrapper.appendChild(languageSelector);
|
|
633
|
-
this.languageSelectorWrapper = languageSelectorWrapper;
|
|
634
|
-
|
|
635
|
-
preventDragOnElement(languageSelectorWrapper);
|
|
636
|
-
|
|
637
|
-
if (this.headerLeft) {
|
|
638
|
-
this.headerLeft.appendChild(languageSelectorWrapper);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const closeAriaLabel = i18n.t('transcript.close');
|
|
642
|
-
const closeButton = DOMUtils.createElement('button', {
|
|
643
|
-
className: `${this.player.options.classPrefix}-transcript-close`,
|
|
644
|
-
attributes: {
|
|
645
|
-
'type': 'button',
|
|
646
|
-
'aria-label': closeAriaLabel
|
|
647
|
-
}
|
|
648
|
-
});
|
|
649
|
-
closeButton.appendChild(createIconElement('close'));
|
|
650
|
-
DOMUtils.attachTooltip(closeButton, closeAriaLabel, this.player.options.classPrefix);
|
|
651
|
-
closeButton.addEventListener('click', () => this.hideTranscript({ focusButton: true }));
|
|
652
|
-
|
|
653
|
-
this.transcriptHeader.appendChild(this.headerLeft);
|
|
654
|
-
this.transcriptHeader.appendChild(closeButton);
|
|
655
|
-
|
|
656
|
-
// Content container
|
|
657
|
-
this.transcriptContent = DOMUtils.createElement('div', {
|
|
658
|
-
className: `${this.player.options.classPrefix}-transcript-content`
|
|
659
|
-
});
|
|
660
|
-
|
|
661
|
-
this.transcriptWindow.appendChild(this.transcriptHeader);
|
|
662
|
-
this.transcriptWindow.appendChild(this.transcriptContent);
|
|
663
|
-
|
|
664
|
-
this.createResizeHandles();
|
|
665
|
-
|
|
666
|
-
// Live region for announcements (screen reader feedback)
|
|
667
|
-
this.liveRegion = DOMUtils.createElement('div', {
|
|
668
|
-
className: 'vidply-sr-only',
|
|
669
|
-
attributes: {
|
|
670
|
-
'aria-live': 'polite',
|
|
671
|
-
'aria-atomic': 'true'
|
|
672
|
-
}
|
|
673
|
-
});
|
|
674
|
-
this.transcriptWindow.appendChild(this.liveRegion);
|
|
675
|
-
|
|
676
|
-
// Append to player container
|
|
677
|
-
this.player.container.appendChild(this.transcriptWindow);
|
|
678
|
-
|
|
679
|
-
// Setup drag functionality FIRST (this will restore saved position if it exists)
|
|
680
|
-
this.setupDragAndDrop();
|
|
681
|
-
|
|
682
|
-
// Then position it next to the video wrapper ONLY if user hasn't manually positioned it
|
|
683
|
-
// This ensures we don't overwrite saved positions from localStorage
|
|
684
|
-
if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
|
|
685
|
-
this.positionTranscript();
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
// Outside-click dismissal for the *style dialog* only. The
|
|
689
|
-
// settings-menu equivalent is owned by the DraggablePanel, which
|
|
690
|
-
// attaches its own listener (tied to the player's lifecycle
|
|
691
|
-
// signal) when the menu is first opened.
|
|
692
|
-
this.handlers.documentClick = (e: MouseEvent) => {
|
|
693
|
-
if (this.styleDialogJustOpened) {
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
const target = e.target as Node | null;
|
|
697
|
-
if (this.styleDialogVisible && this.styleDialog &&
|
|
698
|
-
target && !this.styleDialog.contains(target)) {
|
|
699
|
-
this.hideStyleDialog();
|
|
700
|
-
}
|
|
701
|
-
};
|
|
702
|
-
this.documentClickHandlerAdded = false;
|
|
703
|
-
|
|
704
|
-
// Re-position on window resize (debounced) - but only if not manually positioned
|
|
705
|
-
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
706
|
-
this.handlers.resize = () => {
|
|
707
|
-
if (resizeTimeout) {
|
|
708
|
-
this.clearManagedTimeout(resizeTimeout);
|
|
709
|
-
}
|
|
710
|
-
resizeTimeout = this.setManagedTimeout(() => {
|
|
711
|
-
// Only auto-position if user hasn't manually moved it
|
|
712
|
-
if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
|
|
713
|
-
this.positionTranscript();
|
|
714
|
-
}
|
|
715
|
-
}, 100);
|
|
716
|
-
};
|
|
717
|
-
window.addEventListener('resize', this.handlers.resize, {
|
|
718
|
-
signal: this.player.lifecycleSignal
|
|
719
|
-
});
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
createResizeHandles() {
|
|
723
|
-
const transcriptWindow = this.transcriptWindow;
|
|
724
|
-
if (!transcriptWindow) return;
|
|
725
|
-
|
|
726
|
-
const directions: Array<'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'> = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
|
|
727
|
-
this.transcriptResizeHandles = directions.map(direction => {
|
|
728
|
-
const handle = DOMUtils.createElement('div', {
|
|
729
|
-
className: `${this.player.options.classPrefix}-transcript-resize-handle ${this.player.options.classPrefix}-transcript-resize-${direction}`,
|
|
730
|
-
attributes: {
|
|
731
|
-
'data-direction': direction,
|
|
732
|
-
'data-vidply-managed-resize': 'true',
|
|
733
|
-
'aria-hidden': 'true'
|
|
734
|
-
}
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
handle.style.display = 'none';
|
|
738
|
-
transcriptWindow.appendChild(handle);
|
|
739
|
-
return handle;
|
|
740
|
-
});
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* Position transcript window next to video
|
|
745
|
-
*/
|
|
746
|
-
positionTranscript() {
|
|
747
|
-
const playerWithVideoWrapper = this.player as Player & { videoWrapper?: HTMLElement | null };
|
|
748
|
-
if (!this.transcriptWindow || !playerWithVideoWrapper.videoWrapper || !this.isVisible) return;
|
|
749
|
-
|
|
750
|
-
// Don't auto-position if user has manually positioned it
|
|
751
|
-
if (this.draggableResizable && this.draggableResizable.manuallyPositioned) {
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
const isMobile = window.innerWidth < 768;
|
|
756
|
-
const videoRect = playerWithVideoWrapper.videoWrapper.getBoundingClientRect();
|
|
757
|
-
|
|
758
|
-
// Check if player is in fullscreen mode
|
|
759
|
-
const isFullscreen = this.player.state.fullscreen;
|
|
760
|
-
|
|
761
|
-
if (isMobile && !isFullscreen) {
|
|
762
|
-
// Mobile: Position directly underneath the video controls as part of the layout
|
|
763
|
-
this.transcriptWindow.style.position = 'relative';
|
|
764
|
-
this.transcriptWindow.style.left = '0';
|
|
765
|
-
this.transcriptWindow.style.right = '0';
|
|
766
|
-
this.transcriptWindow.style.bottom = 'auto';
|
|
767
|
-
this.transcriptWindow.style.top = 'auto';
|
|
768
|
-
this.transcriptWindow.style.width = '100%';
|
|
769
|
-
this.transcriptWindow.style.maxWidth = '100%';
|
|
770
|
-
this.transcriptWindow.style.maxHeight = '300px';
|
|
771
|
-
this.transcriptWindow.style.height = 'auto';
|
|
772
|
-
this.transcriptWindow.style.borderRadius = '0';
|
|
773
|
-
this.transcriptWindow.style.transform = 'none';
|
|
774
|
-
this.transcriptWindow.style.border = 'none';
|
|
775
|
-
this.transcriptWindow.style.borderTop = '1px solid var(--vidply-border-light)';
|
|
776
|
-
// Remove any empty border properties that might have been set
|
|
777
|
-
this.transcriptWindow.style.removeProperty('border-right');
|
|
778
|
-
this.transcriptWindow.style.removeProperty('border-bottom');
|
|
779
|
-
this.transcriptWindow.style.removeProperty('border-left');
|
|
780
|
-
// Remove border-image properties that can cause parse errors
|
|
781
|
-
this.transcriptWindow.style.removeProperty('border-image');
|
|
782
|
-
this.transcriptWindow.style.removeProperty('border-image-source');
|
|
783
|
-
this.transcriptWindow.style.removeProperty('border-image-slice');
|
|
784
|
-
this.transcriptWindow.style.removeProperty('border-image-width');
|
|
785
|
-
this.transcriptWindow.style.removeProperty('border-image-outset');
|
|
786
|
-
this.transcriptWindow.style.removeProperty('border-image-repeat');
|
|
787
|
-
this.transcriptWindow.style.boxShadow = 'none';
|
|
788
|
-
// Disable dragging on mobile
|
|
789
|
-
if (this.transcriptHeader) {
|
|
790
|
-
this.transcriptHeader.style.cursor = 'default';
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Insert directly after video wrapper to appear right under controls
|
|
794
|
-
const videoWrapper = playerWithVideoWrapper.videoWrapper;
|
|
795
|
-
if (videoWrapper && videoWrapper.parentNode && videoWrapper.nextSibling !== this.transcriptWindow) {
|
|
796
|
-
videoWrapper.parentNode.insertBefore(this.transcriptWindow, videoWrapper.nextSibling);
|
|
797
|
-
}
|
|
798
|
-
} else if (isFullscreen) {
|
|
799
|
-
// In fullscreen: position in bottom right corner inside the video
|
|
800
|
-
this.transcriptWindow.style.position = 'fixed';
|
|
801
|
-
this.transcriptWindow.style.left = 'auto';
|
|
802
|
-
this.transcriptWindow.style.right = '20px';
|
|
803
|
-
this.transcriptWindow.style.bottom = '80px'; // Above controls
|
|
804
|
-
this.transcriptWindow.style.top = 'auto';
|
|
805
|
-
this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
|
|
806
|
-
this.transcriptWindow.style.height = 'auto';
|
|
807
|
-
const fullscreenMinWidth = 260;
|
|
808
|
-
const fullscreenAvailable = Math.max(fullscreenMinWidth, window.innerWidth - 40);
|
|
809
|
-
const fullscreenDesired = parseFloat(this.transcriptWindow.style.width) || 400;
|
|
810
|
-
const fullscreenWidth = Math.max(fullscreenMinWidth, Math.min(fullscreenDesired, fullscreenAvailable));
|
|
811
|
-
this.transcriptWindow.style.width = `${fullscreenWidth}px`;
|
|
812
|
-
this.transcriptWindow.style.maxWidth = 'none';
|
|
813
|
-
this.transcriptWindow.style.borderRadius = '8px';
|
|
814
|
-
this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
|
|
815
|
-
// Remove borderTop and any other individual border properties to avoid empty values
|
|
816
|
-
this.transcriptWindow.style.removeProperty('border-top');
|
|
817
|
-
this.transcriptWindow.style.removeProperty('border-right');
|
|
818
|
-
this.transcriptWindow.style.removeProperty('border-bottom');
|
|
819
|
-
this.transcriptWindow.style.removeProperty('border-left');
|
|
820
|
-
// Remove border-image properties that can cause parse errors
|
|
821
|
-
this.transcriptWindow.style.removeProperty('border-image');
|
|
822
|
-
this.transcriptWindow.style.removeProperty('border-image-source');
|
|
823
|
-
this.transcriptWindow.style.removeProperty('border-image-slice');
|
|
824
|
-
this.transcriptWindow.style.removeProperty('border-image-width');
|
|
825
|
-
this.transcriptWindow.style.removeProperty('border-image-outset');
|
|
826
|
-
this.transcriptWindow.style.removeProperty('border-image-repeat');
|
|
827
|
-
// Enable dragging in fullscreen (including touch devices)
|
|
828
|
-
if (this.transcriptHeader) {
|
|
829
|
-
this.transcriptHeader.style.cursor = 'move';
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// Move back to container for fullscreen
|
|
833
|
-
if (this.transcriptWindow.parentNode !== this.player.container) {
|
|
834
|
-
this.player.container.appendChild(this.transcriptWindow);
|
|
835
|
-
}
|
|
836
|
-
} else {
|
|
837
|
-
// Desktop mode: position to the right of video, or overlay if insufficient space
|
|
838
|
-
const transcriptWidth = parseFloat(this.transcriptWindow.style.width) || 400;
|
|
839
|
-
const padding = 20;
|
|
840
|
-
const minWidth = 260;
|
|
841
|
-
const containerRect = this.player.container.getBoundingClientRect();
|
|
842
|
-
|
|
843
|
-
const ensureContainerPositioned = () => {
|
|
844
|
-
const computed = window.getComputedStyle(this.player.container);
|
|
845
|
-
if (computed.position === 'static') {
|
|
846
|
-
this.player.container.style.position = 'relative';
|
|
847
|
-
}
|
|
848
|
-
};
|
|
849
|
-
|
|
850
|
-
ensureContainerPositioned();
|
|
851
|
-
|
|
852
|
-
// Calculate available space to the right of the video
|
|
853
|
-
// WCAG 1.4.10 Reflow: Avoid horizontal overflow and content being cut off
|
|
854
|
-
const availableWidth = window.innerWidth - videoRect.right - padding;
|
|
855
|
-
|
|
856
|
-
// Switch to overlay mode when:
|
|
857
|
-
// 1. Available width is less than the desired transcript width (would cause squeezing/cutoff)
|
|
858
|
-
// 2. OR available width is below minimum usable width
|
|
859
|
-
// This triggers earlier - as soon as the transcript would be squeezed
|
|
860
|
-
const wouldBeCutOff = availableWidth < transcriptWidth;
|
|
861
|
-
const hasMinimumSpace = availableWidth >= minWidth;
|
|
862
|
-
const useOverlay = wouldBeCutOff || !hasMinimumSpace;
|
|
863
|
-
|
|
864
|
-
if (!useOverlay) {
|
|
865
|
-
// Position to the right of the video (original behavior)
|
|
866
|
-
const left = (videoRect.right - containerRect.left) + padding;
|
|
867
|
-
const appliedWidth = Math.max(minWidth, Math.min(transcriptWidth, availableWidth));
|
|
868
|
-
const appliedHeight = videoRect.height;
|
|
869
|
-
|
|
870
|
-
this.transcriptWindow.style.position = 'absolute';
|
|
871
|
-
this.transcriptWindow.style.left = `${left}px`;
|
|
872
|
-
this.transcriptWindow.style.right = 'auto';
|
|
873
|
-
this.transcriptWindow.style.bottom = 'auto';
|
|
874
|
-
this.transcriptWindow.style.top = '0';
|
|
875
|
-
this.transcriptWindow.style.height = `${appliedHeight}px`;
|
|
876
|
-
this.transcriptWindow.style.maxHeight = 'none';
|
|
877
|
-
this.transcriptWindow.style.width = `${appliedWidth}px`;
|
|
878
|
-
this.transcriptWindow.style.maxWidth = 'none';
|
|
879
|
-
this.transcriptWindow.style.boxShadow = '';
|
|
880
|
-
} else {
|
|
881
|
-
// Insufficient space: fall back to compact overlay mode inside video area
|
|
882
|
-
// Positioned above controls to prevent horizontal overflow
|
|
883
|
-
const overlayMaxWidth = 320;
|
|
884
|
-
const overlayMaxHeight = 280;
|
|
885
|
-
const overlayWidth = Math.min(overlayMaxWidth, videoRect.width - 40);
|
|
886
|
-
const overlayHeight = Math.min(overlayMaxHeight, videoRect.height - 120);
|
|
887
|
-
|
|
888
|
-
// Calculate position relative to container, placing overlay inside video area
|
|
889
|
-
const videoWrapperRect = playerWithVideoWrapper.videoWrapper.getBoundingClientRect();
|
|
890
|
-
const controlsHeight = 70; // Approximate height of controls
|
|
891
|
-
|
|
892
|
-
// Position from top of container to bottom of video minus controls and overlay height
|
|
893
|
-
const overlayActualHeight = Math.max(180, overlayHeight);
|
|
894
|
-
const topPosition = (videoWrapperRect.bottom - containerRect.top) - controlsHeight - overlayActualHeight;
|
|
895
|
-
const rightPosition = (containerRect.right - videoWrapperRect.right) + 12;
|
|
896
|
-
|
|
897
|
-
this.transcriptWindow.style.position = 'absolute';
|
|
898
|
-
this.transcriptWindow.style.left = 'auto';
|
|
899
|
-
this.transcriptWindow.style.right = `${rightPosition}px`;
|
|
900
|
-
this.transcriptWindow.style.top = `${topPosition}px`;
|
|
901
|
-
this.transcriptWindow.style.bottom = 'auto';
|
|
902
|
-
this.transcriptWindow.style.width = `${Math.max(minWidth, overlayWidth)}px`;
|
|
903
|
-
this.transcriptWindow.style.maxWidth = `${overlayMaxWidth}px`;
|
|
904
|
-
this.transcriptWindow.style.height = `${overlayActualHeight}px`;
|
|
905
|
-
this.transcriptWindow.style.maxHeight = `${overlayMaxHeight}px`;
|
|
906
|
-
this.transcriptWindow.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.6)'; // Enhanced shadow for overlay visibility
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
this.transcriptWindow.style.borderRadius = '8px';
|
|
910
|
-
this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
|
|
911
|
-
// Remove borderTop and any other individual border properties to avoid empty values
|
|
912
|
-
this.transcriptWindow.style.removeProperty('border-top');
|
|
913
|
-
this.transcriptWindow.style.removeProperty('border-right');
|
|
914
|
-
this.transcriptWindow.style.removeProperty('border-bottom');
|
|
915
|
-
this.transcriptWindow.style.removeProperty('border-left');
|
|
916
|
-
// Remove border-image properties that can cause parse errors
|
|
917
|
-
this.transcriptWindow.style.removeProperty('border-image');
|
|
918
|
-
this.transcriptWindow.style.removeProperty('border-image-source');
|
|
919
|
-
this.transcriptWindow.style.removeProperty('border-image-slice');
|
|
920
|
-
this.transcriptWindow.style.removeProperty('border-image-width');
|
|
921
|
-
this.transcriptWindow.style.removeProperty('border-image-outset');
|
|
922
|
-
this.transcriptWindow.style.removeProperty('border-image-repeat');
|
|
923
|
-
// Enable dragging on desktop
|
|
924
|
-
if (this.transcriptHeader) {
|
|
925
|
-
this.transcriptHeader.style.cursor = 'move';
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// Move back to container for desktop
|
|
929
|
-
if (this.transcriptWindow.parentNode !== this.player.container) {
|
|
930
|
-
this.player.container.appendChild(this.transcriptWindow);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
/**
|
|
936
|
-
* Get available transcript languages from tracks
|
|
937
|
-
*/
|
|
938
|
-
getAvailableTranscriptLanguages() {
|
|
939
|
-
const textTracks = this.player.textTracks as TranscriptTrack[];
|
|
940
|
-
const languages = new Map<string, TranscriptLanguageInfo>();
|
|
941
|
-
|
|
942
|
-
// Collect all caption/subtitle tracks with their languages.
|
|
943
|
-
// For DASH/HLS, tracks may have placeholder labels (AdaptationSet IDs,
|
|
944
|
-
// "null", etc.); deriveTrackLabel normalises them to human-readable names.
|
|
945
|
-
textTracks.forEach((track: TranscriptTrack) => {
|
|
946
|
-
if ((track.kind === 'captions' || track.kind === 'subtitles') && !track._vidplyStale) {
|
|
947
|
-
const lang = (track.language ?? '').trim();
|
|
948
|
-
const label = deriveTrackLabel(track.label, track.language, 'player.captions');
|
|
949
|
-
const key = lang || label;
|
|
950
|
-
if (key && !languages.has(key)) {
|
|
951
|
-
languages.set(key, {
|
|
952
|
-
language: lang,
|
|
953
|
-
label,
|
|
954
|
-
track: track
|
|
955
|
-
});
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
return Array.from(languages.values());
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
/**
|
|
964
|
-
* Update language selector dropdown
|
|
965
|
-
*/
|
|
966
|
-
updateLanguageSelector() {
|
|
967
|
-
const languageSelector = this.languageSelector;
|
|
968
|
-
if (!languageSelector) return;
|
|
969
|
-
|
|
970
|
-
this.availableTranscriptLanguages = this.getAvailableTranscriptLanguages();
|
|
971
|
-
|
|
972
|
-
languageSelector.innerHTML = '';
|
|
973
|
-
|
|
974
|
-
if (this.availableTranscriptLanguages.length < 2) {
|
|
975
|
-
if (this.languageSelectorWrapper) {
|
|
976
|
-
this.languageSelectorWrapper.style.display = 'none';
|
|
977
|
-
}
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
if (this.languageSelectorWrapper) {
|
|
982
|
-
this.languageSelectorWrapper.style.display = 'flex';
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
this.availableTranscriptLanguages.forEach((langInfo: TranscriptLanguageInfo) => {
|
|
986
|
-
const attrs: Record<string, string> = {
|
|
987
|
-
'value': langInfo.language || langInfo.label
|
|
988
|
-
};
|
|
989
|
-
if (langInfo.language) {
|
|
990
|
-
attrs['lang'] = langInfo.language;
|
|
991
|
-
}
|
|
992
|
-
const option = DOMUtils.createElement('option', {
|
|
993
|
-
textContent: langInfo.label,
|
|
994
|
-
attributes: attrs
|
|
995
|
-
});
|
|
996
|
-
languageSelector.appendChild(option);
|
|
997
|
-
});
|
|
998
|
-
|
|
999
|
-
if (this.currentTranscriptLanguage) {
|
|
1000
|
-
languageSelector.value = this.currentTranscriptLanguage;
|
|
1001
|
-
} else if (this.availableTranscriptLanguages.length > 0) {
|
|
1002
|
-
const activeTrack = this.player.textTracks.find(
|
|
1003
|
-
(track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing'
|
|
1004
|
-
);
|
|
1005
|
-
const firstLang = this.availableTranscriptLanguages[0];
|
|
1006
|
-
const fallbackLang = firstLang ? firstLang.language : null;
|
|
1007
|
-
this.currentTranscriptLanguage = activeTrack ? activeTrack.language : fallbackLang;
|
|
1008
|
-
if (this.currentTranscriptLanguage) {
|
|
1009
|
-
languageSelector.value = this.currentTranscriptLanguage;
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
if (this.languageSelectorHandler) {
|
|
1014
|
-
languageSelector.removeEventListener('change', this.languageSelectorHandler);
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
// Handle language change — tell the streaming renderer to switch tracks
|
|
1018
|
-
// first so dash.js starts downloading cues for the new language, then
|
|
1019
|
-
// render whatever is available (textcuesupdate will re-render later).
|
|
1020
|
-
const handler = (e: Event) => {
|
|
1021
|
-
this.currentTranscriptLanguage = (e.target as HTMLSelectElement).value;
|
|
1022
|
-
this._requestStreamingTrack(this.currentTranscriptLanguage);
|
|
1023
|
-
this.loadTranscriptData();
|
|
1024
|
-
|
|
1025
|
-
if (this.transcriptContent && this.currentTranscriptLanguage) {
|
|
1026
|
-
this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
|
|
1027
|
-
}
|
|
1028
|
-
};
|
|
1029
|
-
this.languageSelectorHandler = handler;
|
|
1030
|
-
languageSelector.addEventListener('change', handler);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
private _parseVTT(vttText: string): TranscriptCueItem[] {
|
|
1034
|
-
const cues: TranscriptCueItem[] = [];
|
|
1035
|
-
const blocks = vttText.replace(/\r\n/g, '\n').split(/\n\n+/);
|
|
1036
|
-
for (const block of blocks) {
|
|
1037
|
-
const lines = block.trim().split('\n');
|
|
1038
|
-
let tsLine = -1;
|
|
1039
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1040
|
-
if (lines[i]?.includes('-->')) { tsLine = i; break; }
|
|
1041
|
-
}
|
|
1042
|
-
if (tsLine < 0) continue;
|
|
1043
|
-
const match = lines[tsLine]?.match(
|
|
1044
|
-
/(\d{1,2}:)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{1,2}:)?(\d{2}):(\d{2})\.(\d{3})/
|
|
1045
|
-
);
|
|
1046
|
-
if (!match) continue;
|
|
1047
|
-
const startTime =
|
|
1048
|
-
(match[1] ? parseInt(match[1]) * 3600 : 0) +
|
|
1049
|
-
parseInt(match[2] ?? '0') * 60 +
|
|
1050
|
-
parseInt(match[3] ?? '0') +
|
|
1051
|
-
parseInt(match[4] ?? '0') / 1000;
|
|
1052
|
-
const endTime =
|
|
1053
|
-
(match[5] ? parseInt(match[5]) * 3600 : 0) +
|
|
1054
|
-
parseInt(match[6] ?? '0') * 60 +
|
|
1055
|
-
parseInt(match[7] ?? '0') +
|
|
1056
|
-
parseInt(match[8] ?? '0') / 1000;
|
|
1057
|
-
const text = lines.slice(tsLine + 1).join('\n').trim();
|
|
1058
|
-
if (!text) continue;
|
|
1059
|
-
cues.push({
|
|
1060
|
-
cue: { startTime, endTime, text, id: '' } as unknown as TranscriptCue,
|
|
1061
|
-
type: 'caption'
|
|
1062
|
-
});
|
|
1063
|
-
}
|
|
1064
|
-
return cues;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
private async _loadVttTranscript(lang: string): Promise<TranscriptCueItem[] | null> {
|
|
1068
|
-
const cached = this._vttCache.get(lang);
|
|
1069
|
-
if (cached) return cached;
|
|
1070
|
-
|
|
1071
|
-
const renderer = this.player.renderer as Renderer | null | undefined;
|
|
1072
|
-
if (!renderer?.isStreaming || typeof renderer.getTextTrackURLs !== 'function') return null;
|
|
1073
|
-
|
|
1074
|
-
const urls: { lang: string; url: string }[] = renderer.getTextTrackURLs();
|
|
1075
|
-
const entry = urls.find(u =>
|
|
1076
|
-
u.lang === lang || u.lang.startsWith(lang) || lang.startsWith(u.lang)
|
|
1077
|
-
);
|
|
1078
|
-
if (!entry) return null;
|
|
1079
|
-
|
|
1080
|
-
// Build a combined AbortSignal: the player's lifecycle controller plus
|
|
1081
|
-
// a 10s timeout per individual fetch.
|
|
1082
|
-
const signal = this._buildFetchSignal(10_000);
|
|
1083
|
-
|
|
1084
|
-
try {
|
|
1085
|
-
const res = await fetch(entry.url, { signal });
|
|
1086
|
-
if (!res.ok) return null;
|
|
1087
|
-
let text = await res.text();
|
|
1088
|
-
|
|
1089
|
-
// HLS subtitle playlists are m3u8 files that reference the actual VTT.
|
|
1090
|
-
// Resolve the VTT URI and fetch it instead.
|
|
1091
|
-
if (text.trimStart().startsWith('#EXTM3U')) {
|
|
1092
|
-
const vttUri = text.split('\n')
|
|
1093
|
-
.map(l => l.trim())
|
|
1094
|
-
.find(l => l && !l.startsWith('#'));
|
|
1095
|
-
if (!vttUri) return null;
|
|
1096
|
-
const baseUrl = entry.url.substring(0, entry.url.lastIndexOf('/') + 1);
|
|
1097
|
-
const vttUrl = vttUri.startsWith('http') ? vttUri : new URL(vttUri, baseUrl).href;
|
|
1098
|
-
const vttRes = await fetch(vttUrl, { signal: this._buildFetchSignal(10_000) });
|
|
1099
|
-
if (!vttRes.ok) return null;
|
|
1100
|
-
text = await vttRes.text();
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
const cues = this._parseVTT(text);
|
|
1104
|
-
if (cues.length > 0) this._vttCache.set(lang, cues);
|
|
1105
|
-
return cues;
|
|
1106
|
-
} catch {
|
|
1107
|
-
return null;
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/**
|
|
1112
|
-
* Build an AbortSignal that fires when either the player is destroyed
|
|
1113
|
-
* or `timeoutMs` elapses, whichever happens first.
|
|
1114
|
-
*/
|
|
1115
|
-
private _buildFetchSignal(timeoutMs: number): AbortSignal | undefined {
|
|
1116
|
-
const signals: AbortSignal[] = [];
|
|
1117
|
-
const lifecycle = (this.player as { lifecycleSignal?: AbortSignal }).lifecycleSignal;
|
|
1118
|
-
if (lifecycle) signals.push(lifecycle);
|
|
1119
|
-
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
1120
|
-
signals.push(AbortSignal.timeout(timeoutMs));
|
|
1121
|
-
}
|
|
1122
|
-
if (signals.length === 0) return undefined;
|
|
1123
|
-
if (signals.length === 1) return signals[0];
|
|
1124
|
-
const anyFn = (AbortSignal as { any?: (sigs: AbortSignal[]) => AbortSignal }).any;
|
|
1125
|
-
return anyFn ? anyFn(signals) : signals[0];
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
/**
|
|
1129
|
-
* Load transcript data from caption/subtitle tracks
|
|
1130
|
-
*/
|
|
1131
|
-
loadTranscriptData() {
|
|
1132
|
-
this.transcriptEntries = [];
|
|
1133
|
-
this.currentActiveEntry = null;
|
|
1134
|
-
if (this.transcriptContent) {
|
|
1135
|
-
this.transcriptContent.innerHTML = '';
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
// Get all text tracks
|
|
1139
|
-
const textTracks = this.player.textTracks as TranscriptTrack[];
|
|
1140
|
-
|
|
1141
|
-
// Find track for selected language, or default to first available.
|
|
1142
|
-
// Safari native HLS can expose duplicate TextTrack objects for the same
|
|
1143
|
-
// language (SUBTITLES vs CLOSED-CAPTIONS); prefer the one with cues loaded.
|
|
1144
|
-
let captionTrack: TranscriptTrack | null = null;
|
|
1145
|
-
if (this.currentTranscriptLanguage) {
|
|
1146
|
-
const candidates = textTracks.filter(
|
|
1147
|
-
(track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') &&
|
|
1148
|
-
track.language === this.currentTranscriptLanguage &&
|
|
1149
|
-
!track._vidplyStale
|
|
1150
|
-
);
|
|
1151
|
-
captionTrack = candidates.find((t: TranscriptTrack) => t.cues && t.cues.length > 0) || candidates[0] || null;
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
if (!captionTrack) {
|
|
1155
|
-
const candidates = textTracks.filter(
|
|
1156
|
-
(track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') && !track._vidplyStale
|
|
1157
|
-
);
|
|
1158
|
-
captionTrack = candidates.find((t: TranscriptTrack) => t.cues && t.cues.length > 0) || candidates[0] || null;
|
|
1159
|
-
if (captionTrack) {
|
|
1160
|
-
this.currentTranscriptLanguage = captionTrack.language;
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// Find description track matching the selected language
|
|
1165
|
-
let descriptionTrack: TranscriptTrack | null = null;
|
|
1166
|
-
if (this.currentTranscriptLanguage) {
|
|
1167
|
-
descriptionTrack = textTracks.find(
|
|
1168
|
-
(track: TranscriptTrack) => track.kind === 'descriptions' && track.language === this.currentTranscriptLanguage
|
|
1169
|
-
) || null;
|
|
1170
|
-
}
|
|
1171
|
-
// Fallback to first available description track if no match found
|
|
1172
|
-
if (!descriptionTrack) {
|
|
1173
|
-
descriptionTrack = textTracks.find((track: TranscriptTrack) => track.kind === 'descriptions') || null;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
const metadataTrack = textTracks.find((track: TranscriptTrack) => track.kind === 'metadata');
|
|
1177
|
-
|
|
1178
|
-
// We need at least one track type available for display
|
|
1179
|
-
// (captions, descriptions, or metadata - though metadata is not displayed)
|
|
1180
|
-
if (!captionTrack && !descriptionTrack && !metadataTrack) {
|
|
1181
|
-
this.showNoTranscriptMessage();
|
|
1182
|
-
return;
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
// Enable all tracks to load cues so they're available for the transcript
|
|
1186
|
-
const tracksToLoad: TranscriptTrack[] = [captionTrack, descriptionTrack, metadataTrack].filter(
|
|
1187
|
-
(track): track is TranscriptTrack => Boolean(track)
|
|
1188
|
-
);
|
|
1189
|
-
tracksToLoad.forEach((track: TranscriptTrack) => {
|
|
1190
|
-
if (track.mode === 'disabled') {
|
|
1191
|
-
track.mode = 'hidden';
|
|
1192
|
-
}
|
|
1193
|
-
});
|
|
1194
|
-
|
|
1195
|
-
// For streaming renderers, fetch the complete VTT file directly rather
|
|
1196
|
-
// than relying on dash.js's partial segment-based cues.
|
|
1197
|
-
const renderer = this.player.renderer as Renderer | null | undefined;
|
|
1198
|
-
const isStreaming = renderer?.isStreaming && typeof renderer.getTextTrackURLs === 'function';
|
|
1199
|
-
const lang = this.currentTranscriptLanguage || (captionTrack?.language ?? '');
|
|
1200
|
-
|
|
1201
|
-
if (isStreaming && lang) {
|
|
1202
|
-
const loadingMessage = DOMUtils.createElement('div', {
|
|
1203
|
-
className: `${this.player.options.classPrefix}-transcript-loading`,
|
|
1204
|
-
textContent: i18n.t('transcript.loading')
|
|
1205
|
-
});
|
|
1206
|
-
this.transcriptContent?.appendChild(loadingMessage);
|
|
1207
|
-
|
|
1208
|
-
this._loadVttTranscript(lang).then(vttCues => {
|
|
1209
|
-
if (!this.isVisible) return;
|
|
1210
|
-
this._renderTranscriptCues(
|
|
1211
|
-
vttCues && vttCues.length > 0 ? vttCues : null,
|
|
1212
|
-
captionTrack,
|
|
1213
|
-
descriptionTrack,
|
|
1214
|
-
metadataTrack
|
|
1215
|
-
);
|
|
1216
|
-
});
|
|
1217
|
-
return;
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
// Non-streaming path: wait for sidecar <track> elements to load cues.
|
|
1221
|
-
const primaryTrack = captionTrack;
|
|
1222
|
-
const primaryNeedsCues = primaryTrack && (!primaryTrack.cues || primaryTrack.cues.length === 0);
|
|
1223
|
-
|
|
1224
|
-
if (primaryNeedsCues) {
|
|
1225
|
-
const loadingMessage = DOMUtils.createElement('div', {
|
|
1226
|
-
className: `${this.player.options.classPrefix}-transcript-loading`,
|
|
1227
|
-
textContent: i18n.t('transcript.loading')
|
|
1228
|
-
});
|
|
1229
|
-
this.transcriptContent?.appendChild(loadingMessage);
|
|
1230
|
-
|
|
1231
|
-
const hasSidecarElement = this.player.findTrackElement?.(primaryTrack);
|
|
1232
|
-
if (hasSidecarElement) {
|
|
1233
|
-
primaryTrack.addEventListener('load', () => {
|
|
1234
|
-
this.loadTranscriptData();
|
|
1235
|
-
}, { once: true });
|
|
1236
|
-
this.setManagedTimeout(() => { this.loadTranscriptData(); }, 1000);
|
|
1237
|
-
} else {
|
|
1238
|
-
let attempts = 0;
|
|
1239
|
-
const poll = () => {
|
|
1240
|
-
attempts++;
|
|
1241
|
-
if (!this.isVisible) return;
|
|
1242
|
-
if (primaryTrack.cues && primaryTrack.cues.length > 0) {
|
|
1243
|
-
this.loadTranscriptData();
|
|
1244
|
-
return;
|
|
1245
|
-
}
|
|
1246
|
-
if (attempts < 40) {
|
|
1247
|
-
this.setManagedTimeout(poll, 500);
|
|
1248
|
-
}
|
|
1249
|
-
};
|
|
1250
|
-
this.setManagedTimeout(poll, 300);
|
|
1251
|
-
}
|
|
1252
|
-
return;
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
this._renderTranscriptCues(null, captionTrack, descriptionTrack, metadataTrack);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
private _renderTranscriptCues(
|
|
1259
|
-
vttCues: TranscriptCueItem[] | null,
|
|
1260
|
-
captionTrack: TranscriptTrack | null,
|
|
1261
|
-
descriptionTrack: TranscriptTrack | null,
|
|
1262
|
-
metadataTrack: TranscriptTrack | undefined | null
|
|
1263
|
-
) {
|
|
1264
|
-
this.transcriptEntries = [];
|
|
1265
|
-
this.currentActiveEntry = null;
|
|
1266
|
-
const transcriptContent = this.transcriptContent;
|
|
1267
|
-
if (transcriptContent) {
|
|
1268
|
-
transcriptContent.innerHTML = '';
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
const allCues: TranscriptCueItem[] = [];
|
|
1272
|
-
|
|
1273
|
-
if (vttCues && vttCues.length > 0) {
|
|
1274
|
-
allCues.push(...vttCues);
|
|
1275
|
-
} else if (captionTrack && captionTrack.cues) {
|
|
1276
|
-
Array.from(captionTrack.cues).forEach((cue: TranscriptCue) => {
|
|
1277
|
-
allCues.push({ cue, type: 'caption' });
|
|
1278
|
-
});
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
if (descriptionTrack && descriptionTrack.cues) {
|
|
1282
|
-
Array.from(descriptionTrack.cues).forEach((cue: TranscriptCue) => {
|
|
1283
|
-
allCues.push({ cue, type: 'description' });
|
|
1284
|
-
});
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
if (metadataTrack && metadataTrack.cues) {
|
|
1288
|
-
this.metadataCues = Array.from(metadataTrack.cues) as TranscriptCue[];
|
|
1289
|
-
this.setupMetadataHandling();
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
allCues.sort((a, b) => a.cue.startTime - b.cue.startTime);
|
|
1293
|
-
|
|
1294
|
-
allCues.forEach((item, index) => {
|
|
1295
|
-
const entry = this.createTranscriptEntry(item.cue, index, item.type);
|
|
1296
|
-
this.transcriptEntries.push({
|
|
1297
|
-
element: entry,
|
|
1298
|
-
cue: item.cue,
|
|
1299
|
-
type: item.type,
|
|
1300
|
-
startTime: item.cue.startTime,
|
|
1301
|
-
endTime: item.cue.endTime
|
|
1302
|
-
});
|
|
1303
|
-
transcriptContent?.appendChild(entry);
|
|
1304
|
-
});
|
|
1305
|
-
|
|
1306
|
-
this.applyTranscriptStyles();
|
|
1307
|
-
this.updateTimestampVisibility();
|
|
1308
|
-
|
|
1309
|
-
if (this.transcriptContent && this.currentTranscriptLanguage) {
|
|
1310
|
-
this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
this.updateLanguageSelector();
|
|
1314
|
-
this.updateActiveEntry();
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
/**
|
|
1318
|
-
* Setup metadata handling on player load
|
|
1319
|
-
* This runs independently of transcript loading
|
|
1320
|
-
*/
|
|
1321
|
-
setupMetadataHandlingOnLoad() {
|
|
1322
|
-
// Wait for metadata to be loaded
|
|
1323
|
-
const setupMetadata = () => {
|
|
1324
|
-
const textTracks = this.player.textTracks;
|
|
1325
|
-
const metadataTrack = (textTracks as TranscriptTrack[]).find((track: TranscriptTrack) => track.kind === 'metadata');
|
|
1326
|
-
|
|
1327
|
-
if (metadataTrack) {
|
|
1328
|
-
// Enable the metadata track so cuechange events fire
|
|
1329
|
-
// Use 'hidden' mode so it doesn't display anything, but events still work
|
|
1330
|
-
if (metadataTrack.mode === 'disabled') {
|
|
1331
|
-
metadataTrack.mode = 'hidden';
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
// Check if we already added the listener
|
|
1335
|
-
if (this.metadataCueChangeHandler) {
|
|
1336
|
-
metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
// Add event listener for cue changes
|
|
1340
|
-
this.metadataCueChangeHandler = () => {
|
|
1341
|
-
const activeCues = Array.from(metadataTrack.activeCues || []) as TranscriptCue[];
|
|
1342
|
-
if (activeCues.length > 0) {
|
|
1343
|
-
// Debug logging (can be removed in production)
|
|
1344
|
-
if (this.player.options.debug) {
|
|
1345
|
-
console.log('[VidPly Metadata] Active cues:', activeCues.map((c) => ({
|
|
1346
|
-
start: c.startTime,
|
|
1347
|
-
end: c.endTime,
|
|
1348
|
-
text: (c as VTTCue).text
|
|
1349
|
-
})));
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
activeCues.forEach((cue: TranscriptCue) => {
|
|
1353
|
-
this.handleMetadataCue(cue);
|
|
1354
|
-
});
|
|
1355
|
-
};
|
|
1356
|
-
|
|
1357
|
-
metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
|
|
1358
|
-
|
|
1359
|
-
// Debug: Log metadata track setup
|
|
1360
|
-
if (this.player.options.debug) {
|
|
1361
|
-
const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
|
|
1362
|
-
console.log('[VidPly Metadata] Track enabled,', cueCount, 'cues available');
|
|
1363
|
-
}
|
|
1364
|
-
} else if (this.player.options.debug) {
|
|
1365
|
-
console.warn('[VidPly Metadata] No metadata track found');
|
|
1366
|
-
}
|
|
1367
|
-
};
|
|
1368
|
-
|
|
1369
|
-
// Try immediately
|
|
1370
|
-
setupMetadata();
|
|
1371
|
-
|
|
1372
|
-
// Also try after loadedmetadata event
|
|
1373
|
-
this.player.on('loadedmetadata', setupMetadata);
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
/**
|
|
1377
|
-
* Setup metadata handling
|
|
1378
|
-
* Metadata cues are not displayed but can be used programmatically
|
|
1379
|
-
* This is called when transcript data is loaded (for storing cues)
|
|
1380
|
-
*/
|
|
1381
|
-
setupMetadataHandling() {
|
|
1382
|
-
if (!this.metadataCues || this.metadataCues.length === 0) {
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
// The actual event handling is set up in setupMetadataHandlingOnLoad()
|
|
1387
|
-
// This method just stores the cues for reference
|
|
1388
|
-
if (this.player.options.debug) {
|
|
1389
|
-
console.log('[VidPly Metadata]', this.metadataCues.length, 'cues stored from transcript load');
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
/**
|
|
1394
|
-
* Handle individual metadata cues
|
|
1395
|
-
* Parses metadata text and emits events or triggers actions
|
|
1396
|
-
*/
|
|
1397
|
-
handleMetadataCue(cue: TranscriptCue) {
|
|
1398
|
-
const cueText = (cue as TextTrackCue & { text?: string }).text || '';
|
|
1399
|
-
const text = cueText.trim();
|
|
1400
|
-
|
|
1401
|
-
// Debug logging
|
|
1402
|
-
if (this.player.options.debug) {
|
|
1403
|
-
console.log('[VidPly Metadata] Processing cue:', {
|
|
1404
|
-
time: cue.startTime,
|
|
1405
|
-
text: text
|
|
1406
|
-
});
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
// Emit a generic metadata event that developers can listen to
|
|
1410
|
-
this.player.emit('metadata', {
|
|
1411
|
-
time: cue.startTime,
|
|
1412
|
-
endTime: cue.endTime,
|
|
1413
|
-
text: text,
|
|
1414
|
-
cue: cue
|
|
1415
|
-
});
|
|
1416
|
-
|
|
1417
|
-
// Parse for specific commands (examples based on wwa_meta.vtt format)
|
|
1418
|
-
if (text.includes('PAUSE')) {
|
|
1419
|
-
// Automatically pause the video
|
|
1420
|
-
if (!this.player.state.paused) {
|
|
1421
|
-
if (this.player.options.debug) {
|
|
1422
|
-
console.log('[VidPly Metadata] Pausing video at', cue.startTime);
|
|
1423
|
-
}
|
|
1424
|
-
this.player.pause();
|
|
1425
|
-
}
|
|
1426
|
-
// Also emit event for developers who want to listen
|
|
1427
|
-
this.player.emit('metadata:pause', { time: cue.startTime, text: text });
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
// Parse for focus directives
|
|
1431
|
-
const focusMatch = text.match(/FOCUS:([\w#-]+)/);
|
|
1432
|
-
if (focusMatch && focusMatch[1]) {
|
|
1433
|
-
const targetSelector = focusMatch[1];
|
|
1434
|
-
// Automatically focus the target element
|
|
1435
|
-
const targetElement = document.querySelector(targetSelector) as HTMLElement | null;
|
|
1436
|
-
if (targetElement) {
|
|
1437
|
-
if (this.player.options.debug) {
|
|
1438
|
-
console.log('[VidPly Metadata] Focusing element:', targetSelector);
|
|
1439
|
-
}
|
|
1440
|
-
// Use setTimeout to ensure DOM is ready
|
|
1441
|
-
this.setManagedTimeout(() => {
|
|
1442
|
-
targetElement.focus({ preventScroll: true });
|
|
1443
|
-
}, 10);
|
|
1444
|
-
} else if (this.player.options.debug) {
|
|
1445
|
-
console.warn('[VidPly Metadata] Element not found:', targetSelector);
|
|
1446
|
-
}
|
|
1447
|
-
// Also emit event for developers who want to listen
|
|
1448
|
-
this.player.emit('metadata:focus', {
|
|
1449
|
-
time: cue.startTime,
|
|
1450
|
-
target: targetSelector,
|
|
1451
|
-
element: targetElement,
|
|
1452
|
-
text: text
|
|
1453
|
-
});
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
// Parse for hashtag references
|
|
1457
|
-
const hashtags = text.match(/#[\w-]+/g);
|
|
1458
|
-
if (hashtags) {
|
|
1459
|
-
if (this.player.options.debug) {
|
|
1460
|
-
console.log('[VidPly Metadata] Hashtags found:', hashtags);
|
|
1461
|
-
}
|
|
1462
|
-
this.player.emit('metadata:hashtags', {
|
|
1463
|
-
time: cue.startTime,
|
|
1464
|
-
hashtags: hashtags,
|
|
1465
|
-
text: text
|
|
1466
|
-
});
|
|
1467
|
-
}
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
/**
|
|
1471
|
-
* Create a single transcript entry element
|
|
1472
|
-
*/
|
|
1473
|
-
createTranscriptEntry(cue: TranscriptCue, index: number, type: 'caption' | 'description' = 'caption') {
|
|
1474
|
-
const entryText = this.stripVTTFormatting(((cue as TextTrackCue & { text?: string }).text) || '');
|
|
1475
|
-
|
|
1476
|
-
// Use a real <button> so screen readers/AT report "button", Enter and
|
|
1477
|
-
// Space activate it natively, and forms-mode users can find it.
|
|
1478
|
-
const seekLabelTemplate = i18n.t('transcript.seekTo') || 'Seek to {time}';
|
|
1479
|
-
const ariaLabel = seekLabelTemplate
|
|
1480
|
-
.replace('{time}', TimeUtils.formatTime(cue.startTime))
|
|
1481
|
-
.replace('{text}', entryText);
|
|
1482
|
-
|
|
1483
|
-
const entry = DOMUtils.createElement('button', {
|
|
1484
|
-
className: `${this.player.options.classPrefix}-transcript-entry ${this.player.options.classPrefix}-transcript-${type}`,
|
|
1485
|
-
attributes: {
|
|
1486
|
-
'type': 'button',
|
|
1487
|
-
'data-start': String(cue.startTime),
|
|
1488
|
-
'data-end': String(cue.endTime),
|
|
1489
|
-
'data-type': type,
|
|
1490
|
-
'aria-label': `${ariaLabel} — ${entryText}`
|
|
1491
|
-
}
|
|
1492
|
-
});
|
|
1493
|
-
|
|
1494
|
-
const timestamp = DOMUtils.createElement('span', {
|
|
1495
|
-
className: `${this.player.options.classPrefix}-transcript-time`,
|
|
1496
|
-
textContent: TimeUtils.formatTime(cue.startTime),
|
|
1497
|
-
attributes: {
|
|
1498
|
-
'aria-hidden': 'true' // Hide from screen readers - decorative timestamp
|
|
1499
|
-
}
|
|
1500
|
-
});
|
|
1501
|
-
|
|
1502
|
-
const text = DOMUtils.createElement('span', {
|
|
1503
|
-
className: `${this.player.options.classPrefix}-transcript-text`,
|
|
1504
|
-
textContent: entryText
|
|
1505
|
-
});
|
|
1506
|
-
|
|
1507
|
-
entry.appendChild(timestamp);
|
|
1508
|
-
entry.appendChild(text);
|
|
1509
|
-
|
|
1510
|
-
// Click to seek. <button> elements activate on Enter/Space natively,
|
|
1511
|
-
// so a custom keydown handler is no longer needed.
|
|
1512
|
-
const seekToTime = () => {
|
|
1513
|
-
this.player.seek(cue.startTime);
|
|
1514
|
-
if (this.player.state.paused) {
|
|
1515
|
-
this.player.play();
|
|
1516
|
-
}
|
|
1517
|
-
};
|
|
1518
|
-
|
|
1519
|
-
entry.addEventListener('click', seekToTime);
|
|
1520
|
-
|
|
1521
|
-
return entry;
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
/**
|
|
1525
|
-
* Strip VTT formatting tags from text
|
|
1526
|
-
*/
|
|
1527
|
-
stripVTTFormatting(text: string) {
|
|
1528
|
-
// Remove VTT tags like <v Speaker>, <c>, etc.
|
|
1529
|
-
return text
|
|
1530
|
-
.replace(/<[^>]+>/g, '')
|
|
1531
|
-
.replace(/\n/g, ' ')
|
|
1532
|
-
.trim();
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
/**
|
|
1536
|
-
* Show message when no transcript is available
|
|
1537
|
-
*/
|
|
1538
|
-
showNoTranscriptMessage() {
|
|
1539
|
-
const message = DOMUtils.createElement('div', {
|
|
1540
|
-
className: `${this.player.options.classPrefix}-transcript-empty`,
|
|
1541
|
-
textContent: i18n.t('transcript.noTranscript')
|
|
1542
|
-
});
|
|
1543
|
-
this.transcriptContent?.appendChild(message);
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
/**
|
|
1547
|
-
* Update active transcript entry based on current time
|
|
1548
|
-
*/
|
|
1549
|
-
updateActiveEntry() {
|
|
1550
|
-
if (!this.isVisible || this.transcriptEntries.length === 0) return;
|
|
1551
|
-
|
|
1552
|
-
const currentTime = this.player.state.currentTime;
|
|
1553
|
-
|
|
1554
|
-
// Find the entry that matches current time
|
|
1555
|
-
const activeEntry = this.transcriptEntries.find(
|
|
1556
|
-
(entry: TranscriptEntry) => currentTime >= entry.startTime && currentTime < entry.endTime
|
|
1557
|
-
);
|
|
1558
|
-
|
|
1559
|
-
if (activeEntry && activeEntry !== this.currentActiveEntry) {
|
|
1560
|
-
// Remove previous active class
|
|
1561
|
-
if (this.currentActiveEntry) {
|
|
1562
|
-
this.currentActiveEntry.element.classList.remove(
|
|
1563
|
-
`${this.player.options.classPrefix}-transcript-entry-active`
|
|
1564
|
-
);
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
// Add active class to current entry
|
|
1568
|
-
activeEntry.element.classList.add(
|
|
1569
|
-
`${this.player.options.classPrefix}-transcript-entry-active`
|
|
1570
|
-
);
|
|
1571
|
-
|
|
1572
|
-
// Scroll to active entry
|
|
1573
|
-
this.scrollToEntry(activeEntry.element);
|
|
1574
|
-
|
|
1575
|
-
this.currentActiveEntry = activeEntry;
|
|
1576
|
-
} else if (!activeEntry && this.currentActiveEntry) {
|
|
1577
|
-
// No active entry, remove active class
|
|
1578
|
-
this.currentActiveEntry.element.classList.remove(
|
|
1579
|
-
`${this.player.options.classPrefix}-transcript-entry-active`
|
|
1580
|
-
);
|
|
1581
|
-
this.currentActiveEntry = null;
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
/**
|
|
1586
|
-
* Scroll transcript window to show active entry
|
|
1587
|
-
*/
|
|
1588
|
-
scrollToEntry(entryElement: HTMLElement) {
|
|
1589
|
-
if (!this.transcriptContent || !this.autoscrollEnabled) return;
|
|
1590
|
-
|
|
1591
|
-
const contentRect = this.transcriptContent.getBoundingClientRect();
|
|
1592
|
-
const entryRect = entryElement.getBoundingClientRect();
|
|
1593
|
-
|
|
1594
|
-
// Check if entry is out of view
|
|
1595
|
-
if (entryRect.top < contentRect.top || entryRect.bottom > contentRect.bottom) {
|
|
1596
|
-
// Scroll to center the entry
|
|
1597
|
-
const scrollTop = entryElement.offsetTop - (this.transcriptContent.clientHeight / 2) + (entryElement.clientHeight / 2);
|
|
1598
|
-
this.transcriptContent.scrollTo({
|
|
1599
|
-
top: scrollTop,
|
|
1600
|
-
behavior: 'smooth'
|
|
1601
|
-
});
|
|
1602
|
-
}
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
/**
|
|
1606
|
-
* Save autoscroll preference to localStorage
|
|
1607
|
-
*/
|
|
1608
|
-
saveAutoscrollPreference() {
|
|
1609
|
-
const savedPreferences = this.storage.getTranscriptPreferences() || {};
|
|
1610
|
-
savedPreferences.autoscroll = this.autoscrollEnabled;
|
|
1611
|
-
this.storage.saveTranscriptPreferences(savedPreferences);
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
/**
|
|
1615
|
-
* Setup drag and drop functionality
|
|
1616
|
-
*/
|
|
1617
|
-
setupDragAndDrop() {
|
|
1618
|
-
if (!this.transcriptHeader || !this.transcriptWindow) return;
|
|
1619
|
-
|
|
1620
|
-
// Check if we're on mobile and not in fullscreen
|
|
1621
|
-
const isMobile = window.innerWidth < 768;
|
|
1622
|
-
const isFullscreen = this.player.state.fullscreen;
|
|
1623
|
-
|
|
1624
|
-
// On mobile devices (< 768px), only enable drag/resize in fullscreen
|
|
1625
|
-
// On desktop/tablets (>= 768px), always enable drag/resize
|
|
1626
|
-
if (isMobile && !isFullscreen) {
|
|
1627
|
-
// Destroy existing instance if exiting fullscreen on mobile
|
|
1628
|
-
if (this.draggableResizable) {
|
|
1629
|
-
this.draggableResizable.destroy();
|
|
1630
|
-
this.draggableResizable = null;
|
|
1631
|
-
}
|
|
1632
|
-
return; // No drag/resize on mobile when not in fullscreen
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
// If already initialized, don't re-initialize
|
|
1636
|
-
if (this.draggableResizable) {
|
|
1637
|
-
return;
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
// Create DraggableResizable utility with touch support
|
|
1641
|
-
this.draggableResizable = new DraggableResizable(this.transcriptWindow, {
|
|
1642
|
-
dragHandle: this.transcriptHeader,
|
|
1643
|
-
resizeHandles: this.transcriptResizeHandles,
|
|
1644
|
-
constrainToViewport: true,
|
|
1645
|
-
classPrefix: `${this.player.options.classPrefix}-transcript`,
|
|
1646
|
-
keyboardDragKey: 'd',
|
|
1647
|
-
keyboardResizeKey: 'r',
|
|
1648
|
-
keyboardStep: 10,
|
|
1649
|
-
keyboardStepLarge: 50,
|
|
1650
|
-
minWidth: 300,
|
|
1651
|
-
minHeight: 200,
|
|
1652
|
-
maxWidth: () => Math.max(320, window.innerWidth - 40),
|
|
1653
|
-
maxHeight: () => Math.max(200, window.innerHeight - 120),
|
|
1654
|
-
pointerResizeIndicatorText: i18n.t('transcript.resizeModeHint'),
|
|
1655
|
-
onPointerResizeToggle: (enabled) => {
|
|
1656
|
-
// Update resize handles visibility
|
|
1657
|
-
this.transcriptResizeHandles.forEach((handle: HTMLElement) => {
|
|
1658
|
-
handle.style.display = enabled ? 'block' : 'none';
|
|
1659
|
-
});
|
|
1660
|
-
// Call the state change handler
|
|
1661
|
-
this.onPointerResizeModeChange(enabled);
|
|
1662
|
-
},
|
|
1663
|
-
onDragStart: (e: Event) => {
|
|
1664
|
-
// Don't drag if clicking on certain elements
|
|
1665
|
-
const ignoreSelectors = [
|
|
1666
|
-
`.${this.player.options.classPrefix}-transcript-close`,
|
|
1667
|
-
`.${this.player.options.classPrefix}-transcript-settings`,
|
|
1668
|
-
`.${this.player.options.classPrefix}-transcript-language-select`,
|
|
1669
|
-
`.${this.player.options.classPrefix}-transcript-language-label`,
|
|
1670
|
-
`.${this.player.options.classPrefix}-transcript-settings-menu`,
|
|
1671
|
-
`.${this.player.options.classPrefix}-transcript-style-dialog`
|
|
1672
|
-
];
|
|
1673
|
-
|
|
1674
|
-
for (const selector of ignoreSelectors) {
|
|
1675
|
-
if ((e.target as HTMLElement).closest(selector)) {
|
|
1676
|
-
return false; // Prevent drag
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
return true; // Allow drag
|
|
1681
|
-
}
|
|
1682
|
-
});
|
|
1683
|
-
|
|
1684
|
-
// Add custom keyboard handler for special keys (Escape, Home)
|
|
1685
|
-
this.customKeyHandler = (e: KeyboardEvent) => {
|
|
1686
|
-
const key = e.key.toLowerCase();
|
|
1687
|
-
const alreadyPrevented = e.defaultPrevented;
|
|
1688
|
-
|
|
1689
|
-
// Don't handle keys if settings menu or style dialog is open (let them handle keys)
|
|
1690
|
-
if (this.settingsMenuVisible || this.styleDialogVisible) {
|
|
1691
|
-
return;
|
|
1692
|
-
}
|
|
1693
|
-
|
|
1694
|
-
if (key === 'home') {
|
|
1695
|
-
e.preventDefault();
|
|
1696
|
-
e.stopPropagation();
|
|
1697
|
-
if (this.draggableResizable) {
|
|
1698
|
-
if (this.draggableResizable.pointerResizeMode) {
|
|
1699
|
-
this.draggableResizable.disablePointerResizeMode();
|
|
1700
|
-
}
|
|
1701
|
-
this.draggableResizable.manuallyPositioned = false;
|
|
1702
|
-
this.positionTranscript();
|
|
1703
|
-
this.updateResizeOptionState();
|
|
1704
|
-
this.announceLive(i18n.t('transcript.positionReset'));
|
|
1705
|
-
}
|
|
1706
|
-
return;
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
if (key === 'r') {
|
|
1710
|
-
if (alreadyPrevented) {
|
|
1711
|
-
return;
|
|
1712
|
-
}
|
|
1713
|
-
e.preventDefault();
|
|
1714
|
-
e.stopPropagation();
|
|
1715
|
-
const enabled = this.toggleResizeMode();
|
|
1716
|
-
if (enabled) {
|
|
1717
|
-
this.transcriptWindow?.focus({ preventScroll: true });
|
|
1718
|
-
}
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
if (key === 'escape') {
|
|
1723
|
-
// Check priority: resize mode > drag mode > close transcript
|
|
1724
|
-
// (settings menu and style dialog already handled by early return above)
|
|
1725
|
-
if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
|
|
1726
|
-
e.preventDefault();
|
|
1727
|
-
e.stopPropagation();
|
|
1728
|
-
this.draggableResizable.disablePointerResizeMode();
|
|
1729
|
-
return;
|
|
1730
|
-
}
|
|
1731
|
-
if (this.draggableResizable && this.draggableResizable.keyboardDragMode) {
|
|
1732
|
-
e.preventDefault();
|
|
1733
|
-
e.stopPropagation();
|
|
1734
|
-
this.draggableResizable.disableKeyboardDragMode();
|
|
1735
|
-
this.announceLive(i18n.t('transcript.dragModeDisabled'));
|
|
1736
|
-
return;
|
|
1737
|
-
}
|
|
1738
|
-
// Only close transcript if nothing else is open
|
|
1739
|
-
e.preventDefault();
|
|
1740
|
-
e.stopPropagation();
|
|
1741
|
-
this.hideTranscript({ focusButton: true });
|
|
1742
|
-
return;
|
|
1743
|
-
}
|
|
1744
|
-
};
|
|
1745
|
-
|
|
1746
|
-
const customKeyHandler = this.customKeyHandler;
|
|
1747
|
-
if (this.transcriptWindow && customKeyHandler) {
|
|
1748
|
-
this.transcriptWindow.addEventListener('keydown', customKeyHandler, {
|
|
1749
|
-
signal: this.player.lifecycleSignal
|
|
1750
|
-
});
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
/**
|
|
1756
|
-
* Toggle keyboard drag mode. Mirrors the sign-language flow: a
|
|
1757
|
-
* persistent badge is shown on the transcript window while the mode
|
|
1758
|
-
* is active, and a live-region announcement is made on each state
|
|
1759
|
-
* change.
|
|
1760
|
-
*/
|
|
1761
|
-
toggleKeyboardDragMode() {
|
|
1762
|
-
if (this.draggableResizable) {
|
|
1763
|
-
const wasEnabled = this.draggableResizable.keyboardDragMode;
|
|
1764
|
-
this.draggableResizable.toggleKeyboardDragMode();
|
|
1765
|
-
const isEnabled = this.draggableResizable.keyboardDragMode;
|
|
1766
|
-
if (!wasEnabled && isEnabled) {
|
|
1767
|
-
this.enableMoveMode();
|
|
1768
|
-
this._panel?.showBadge(i18n.t('transcript.dragModeBadge'));
|
|
1769
|
-
this.announceLive(i18n.t('transcript.dragModeEnabled'));
|
|
1770
|
-
} else if (wasEnabled && !isEnabled) {
|
|
1771
|
-
this._panel?.hideBadge();
|
|
1772
|
-
this.announceLive(i18n.t('transcript.dragModeDisabled'));
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
this.updateDragOptionState();
|
|
1776
|
-
|
|
1777
|
-
if (this.settingsMenuVisible) {
|
|
1778
|
-
this.hideSettingsMenu();
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
this.transcriptWindow?.focus({ preventScroll: true });
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
/**
|
|
1786
|
-
* Toggle settings menu visibility
|
|
1787
|
-
*/
|
|
1788
|
-
toggleSettingsMenu() {
|
|
1789
|
-
if (this.settingsMenuVisible) {
|
|
1790
|
-
this.hideSettingsMenu();
|
|
1791
|
-
} else {
|
|
1792
|
-
this.showSettingsMenu();
|
|
1793
|
-
}
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/**
|
|
1797
|
-
* Show the settings menu. Delegates to the shared {@link DraggablePanel};
|
|
1798
|
-
* kept as a named method so external callers (tests, other managers)
|
|
1799
|
-
* that referenced the legacy API keep working.
|
|
1800
|
-
*/
|
|
1801
|
-
showSettingsMenu() {
|
|
1802
|
-
this._panel?.show();
|
|
1803
|
-
}
|
|
1804
|
-
|
|
1805
|
-
/** @see {@link showSettingsMenu} */
|
|
1806
|
-
positionSettingsMenu() {
|
|
1807
|
-
this._panel?.reposition();
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
/** @see {@link showSettingsMenu} */
|
|
1811
|
-
hideSettingsMenu({ focusButton = true } = {}) {
|
|
1812
|
-
this._panel?.hide({ focusButton });
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
/**
|
|
1816
|
-
* Enable move mode (gives visual feedback)
|
|
1817
|
-
*/
|
|
1818
|
-
/**
|
|
1819
|
-
* Brief pulse animation on the transcript window to confirm entry
|
|
1820
|
-
* into keyboard drag mode. The textual hint that used to also flash
|
|
1821
|
-
* here has been replaced by a persistent {@link DraggablePanel}
|
|
1822
|
-
* badge (see `toggleKeyboardDragMode`), so this method only owns
|
|
1823
|
-
* the 1s visual cue now.
|
|
1824
|
-
*/
|
|
1825
|
-
enableMoveMode() {
|
|
1826
|
-
this.transcriptWindow?.classList.add(
|
|
1827
|
-
`${this.player.options.classPrefix}-transcript-move-mode`
|
|
1828
|
-
);
|
|
1829
|
-
|
|
1830
|
-
setTimeout(() => {
|
|
1831
|
-
this.transcriptWindow?.classList.remove(
|
|
1832
|
-
`${this.player.options.classPrefix}-transcript-move-mode`
|
|
1833
|
-
);
|
|
1834
|
-
}, 2000);
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
/**
|
|
1838
|
-
* Toggle resize mode
|
|
1839
|
-
*/
|
|
1840
|
-
toggleResizeMode({ focus = true } = {}) {
|
|
1841
|
-
if (!this.draggableResizable) {
|
|
1842
|
-
return false;
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
if (this.draggableResizable.pointerResizeMode) {
|
|
1846
|
-
this.draggableResizable.disablePointerResizeMode({ focus });
|
|
1847
|
-
return false;
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
this.draggableResizable.enablePointerResizeMode({ focus });
|
|
1851
|
-
return true;
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
// Thin delegates to the panel's refreshState. Kept as named methods
|
|
1855
|
-
// so the existing internal call sites (e.g. `toggleKeyboardDragMode`
|
|
1856
|
-
// and `toggleResizeMode`) read naturally without having to chain
|
|
1857
|
-
// through the optional panel reference every time.
|
|
1858
|
-
updateDragOptionState() {
|
|
1859
|
-
this._panel?.refreshDragState();
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
updateResizeOptionState() {
|
|
1863
|
-
this._panel?.refreshResizeState();
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
toggleShowTimestamps() {
|
|
1867
|
-
this.showTimestamps = !this.showTimestamps;
|
|
1868
|
-
this.updateShowTimestampsState();
|
|
1869
|
-
this.updateTimestampVisibility();
|
|
1870
|
-
this.saveTimestampsPreference();
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
updateShowTimestampsState() {
|
|
1874
|
-
if (!this.showTimestampsButton) {
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
const text = this.showTimestamps
|
|
1879
|
-
? i18n.t('transcript.hideTimestamps')
|
|
1880
|
-
: i18n.t('transcript.showTimestamps');
|
|
1881
|
-
const ariaLabel = this.showTimestamps
|
|
1882
|
-
? i18n.t('transcript.hideTimestampsAria')
|
|
1883
|
-
: i18n.t('transcript.showTimestampsAria');
|
|
1884
|
-
|
|
1885
|
-
this.showTimestampsButton.setAttribute('aria-checked', this.showTimestamps ? 'true' : 'false');
|
|
1886
|
-
this.showTimestampsButton.setAttribute('aria-label', ariaLabel);
|
|
1887
|
-
|
|
1888
|
-
if (this.showTimestampsText) {
|
|
1889
|
-
this.showTimestampsText.textContent = text;
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
updateTimestampVisibility() {
|
|
1894
|
-
if (!this.transcriptContent) return;
|
|
1895
|
-
|
|
1896
|
-
const timestamps = this.transcriptContent.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-time`);
|
|
1897
|
-
timestamps.forEach((timestamp) => {
|
|
1898
|
-
timestamp.style.display = this.showTimestamps ? '' : 'none';
|
|
1899
|
-
});
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
saveTimestampsPreference() {
|
|
1903
|
-
const savedPreferences = this.storage.getTranscriptPreferences() || {};
|
|
1904
|
-
savedPreferences.showTimestamps = this.showTimestamps;
|
|
1905
|
-
this.storage.saveTranscriptPreferences(savedPreferences);
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
// Legacy shims kept for any external callers that still invoke the
|
|
1909
|
-
// old transient-tooltip API. The persistent badge owned by the
|
|
1910
|
-
// {@link DraggablePanel} now replaces the 3-second indicator, so
|
|
1911
|
-
// these simply forward to the panel. Safe to remove once the next
|
|
1912
|
-
// consumer sweep confirms no external references.
|
|
1913
|
-
showResizeModeIndicator() {
|
|
1914
|
-
this._panel?.showBadge(i18n.t('transcript.resizeModeBadge'));
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
hideResizeModeIndicator() {
|
|
1918
|
-
this._panel?.hideBadge();
|
|
1919
|
-
}
|
|
1920
|
-
|
|
1921
|
-
onPointerResizeModeChange(enabled: boolean) {
|
|
1922
|
-
this.updateResizeOptionState();
|
|
1923
|
-
|
|
1924
|
-
if (enabled) {
|
|
1925
|
-
this._panel?.showBadge(i18n.t('transcript.resizeModeBadge'));
|
|
1926
|
-
this.announceLive(i18n.t('transcript.resizeModeEnabled'));
|
|
1927
|
-
} else {
|
|
1928
|
-
this._panel?.hideBadge();
|
|
1929
|
-
this.announceLive(i18n.t('transcript.resizeModeDisabled'));
|
|
1930
|
-
}
|
|
1931
|
-
}
|
|
1932
|
-
|
|
1933
|
-
/**
|
|
1934
|
-
* Show style dialog
|
|
1935
|
-
*/
|
|
1936
|
-
showStyleDialog() {
|
|
1937
|
-
// The style dialog uses an outside-click listener to dismiss
|
|
1938
|
-
// itself. Previously that listener was armed the first time the
|
|
1939
|
-
// settings menu opened (the same handler also dismissed the
|
|
1940
|
-
// settings menu). Now that the settings menu owns its own
|
|
1941
|
-
// outside-click, we arm the style-dialog listener here on first
|
|
1942
|
-
// open. Tied to `lifecycleSignal` so teardown is automatic.
|
|
1943
|
-
if (!this.documentClickHandlerAdded) {
|
|
1944
|
-
setTimeout(() => {
|
|
1945
|
-
const documentClick = this.handlers.documentClick;
|
|
1946
|
-
if (documentClick) {
|
|
1947
|
-
document.addEventListener('click', documentClick, {
|
|
1948
|
-
signal: this.player.lifecycleSignal
|
|
1949
|
-
});
|
|
1950
|
-
}
|
|
1951
|
-
this.documentClickHandlerAdded = true;
|
|
1952
|
-
}, 300);
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
// If dialog already exists, just show it
|
|
1956
|
-
if (this.styleDialog) {
|
|
1957
|
-
this.styleDialog.style.display = 'block';
|
|
1958
|
-
this.styleDialogVisible = true;
|
|
1959
|
-
|
|
1960
|
-
// Re-add keyboard handler
|
|
1961
|
-
if (this.handlers.styleDialogKeydown) {
|
|
1962
|
-
document.addEventListener('keydown', this.handlers.styleDialogKeydown, {
|
|
1963
|
-
signal: this.player.lifecycleSignal
|
|
1964
|
-
});
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
// Set flag to prevent immediate closing from document click
|
|
1968
|
-
this.styleDialogJustOpened = true;
|
|
1969
|
-
setTimeout(() => {
|
|
1970
|
-
this.styleDialogJustOpened = false;
|
|
1971
|
-
}, 350);
|
|
1972
|
-
|
|
1973
|
-
setTimeout(() => {
|
|
1974
|
-
const dialog = this.styleDialog;
|
|
1975
|
-
if (!dialog) return;
|
|
1976
|
-
const firstSelect = dialog.querySelector<HTMLElement>('select, input');
|
|
1977
|
-
if (firstSelect) {
|
|
1978
|
-
firstSelect.focus({ preventScroll: true });
|
|
1979
|
-
}
|
|
1980
|
-
}, 0);
|
|
1981
|
-
return;
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
const styleDialog = DOMUtils.createElement('div', {
|
|
1985
|
-
className: `${this.player.options.classPrefix}-transcript-style-dialog`
|
|
1986
|
-
});
|
|
1987
|
-
this.styleDialog = styleDialog;
|
|
1988
|
-
|
|
1989
|
-
const title = DOMUtils.createElement('h4', {
|
|
1990
|
-
textContent: i18n.t('transcript.styleTitle'),
|
|
1991
|
-
className: `${this.player.options.classPrefix}-transcript-style-title`
|
|
1992
|
-
});
|
|
1993
|
-
styleDialog.appendChild(title);
|
|
1994
|
-
|
|
1995
|
-
const fontSizeControl = this.createStyleSelectControl(
|
|
1996
|
-
i18n.t('captions.fontSize'),
|
|
1997
|
-
'fontSize',
|
|
1998
|
-
[
|
|
1999
|
-
{ label: i18n.t('fontSizes.small'), value: '90%' },
|
|
2000
|
-
{ label: i18n.t('fontSizes.normal'), value: '100%' },
|
|
2001
|
-
{ label: i18n.t('fontSizes.large'), value: '110%' },
|
|
2002
|
-
{ label: i18n.t('fontSizes.xlarge'), value: '120%' }
|
|
2003
|
-
]
|
|
2004
|
-
);
|
|
2005
|
-
styleDialog.appendChild(fontSizeControl);
|
|
2006
|
-
|
|
2007
|
-
const fontFamilyControl = this.createStyleSelectControl(
|
|
2008
|
-
i18n.t('captions.fontFamily'),
|
|
2009
|
-
'fontFamily',
|
|
2010
|
-
[
|
|
2011
|
-
{ label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif' },
|
|
2012
|
-
{ label: i18n.t('fontFamilies.serif'), value: 'serif' },
|
|
2013
|
-
{ label: i18n.t('fontFamilies.monospace'), value: 'monospace' }
|
|
2014
|
-
]
|
|
2015
|
-
);
|
|
2016
|
-
styleDialog.appendChild(fontFamilyControl);
|
|
2017
|
-
|
|
2018
|
-
const colorControl = this.createStyleColorControl(i18n.t('captions.color'), 'color');
|
|
2019
|
-
styleDialog.appendChild(colorControl);
|
|
2020
|
-
|
|
2021
|
-
const bgColorControl = this.createStyleColorControl(i18n.t('captions.backgroundColor'), 'backgroundColor');
|
|
2022
|
-
styleDialog.appendChild(bgColorControl);
|
|
2023
|
-
|
|
2024
|
-
const opacityControl = this.createStyleOpacityControl(i18n.t('captions.opacity'), 'opacity');
|
|
2025
|
-
styleDialog.appendChild(opacityControl);
|
|
2026
|
-
|
|
2027
|
-
const closeBtn = DOMUtils.createElement('button', {
|
|
2028
|
-
className: `${this.player.options.classPrefix}-transcript-style-close`,
|
|
2029
|
-
textContent: i18n.t('settings.close'),
|
|
2030
|
-
attributes: {
|
|
2031
|
-
'type': 'button'
|
|
2032
|
-
}
|
|
2033
|
-
});
|
|
2034
|
-
closeBtn.addEventListener('click', () => this.hideStyleDialog());
|
|
2035
|
-
styleDialog.appendChild(closeBtn);
|
|
2036
|
-
|
|
2037
|
-
const styleKeyHandler = (e: KeyboardEvent) => {
|
|
2038
|
-
if (!this.styleDialogVisible) return;
|
|
2039
|
-
|
|
2040
|
-
if (e.key === 'Escape') {
|
|
2041
|
-
e.preventDefault();
|
|
2042
|
-
e.stopPropagation();
|
|
2043
|
-
this.hideStyleDialog();
|
|
2044
|
-
return;
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
if (e.key === 'Tab') {
|
|
2048
|
-
const focusableElements = styleDialog.querySelectorAll<HTMLElement>(
|
|
2049
|
-
'select, input, button'
|
|
2050
|
-
);
|
|
2051
|
-
const firstElement = focusableElements[0];
|
|
2052
|
-
const lastElement = focusableElements[focusableElements.length - 1];
|
|
2053
|
-
if (!firstElement || !lastElement) return;
|
|
2054
|
-
|
|
2055
|
-
if (e.shiftKey && document.activeElement === firstElement) {
|
|
2056
|
-
e.preventDefault();
|
|
2057
|
-
lastElement.focus({ preventScroll: true });
|
|
2058
|
-
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
2059
|
-
e.preventDefault();
|
|
2060
|
-
firstElement.focus({ preventScroll: true });
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
};
|
|
2064
|
-
this.handlers.styleDialogKeydown = styleKeyHandler;
|
|
2065
|
-
document.addEventListener('keydown', styleKeyHandler, {
|
|
2066
|
-
signal: this.player.lifecycleSignal
|
|
2067
|
-
});
|
|
2068
|
-
|
|
2069
|
-
if (this.headerLeft) {
|
|
2070
|
-
this.headerLeft.appendChild(styleDialog);
|
|
2071
|
-
} else if (this.transcriptHeader) {
|
|
2072
|
-
this.transcriptHeader.appendChild(styleDialog);
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
this.applyTranscriptStyles();
|
|
2076
|
-
|
|
2077
|
-
this.styleDialogVisible = true;
|
|
2078
|
-
styleDialog.style.display = 'block';
|
|
2079
|
-
|
|
2080
|
-
this.styleDialogJustOpened = true;
|
|
2081
|
-
setTimeout(() => {
|
|
2082
|
-
this.styleDialogJustOpened = false;
|
|
2083
|
-
}, 350);
|
|
2084
|
-
|
|
2085
|
-
setTimeout(() => {
|
|
2086
|
-
const firstSelect = styleDialog.querySelector<HTMLElement>('select, input');
|
|
2087
|
-
if (firstSelect) {
|
|
2088
|
-
firstSelect.focus({ preventScroll: true });
|
|
2089
|
-
}
|
|
2090
|
-
}, 0);
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
/**
|
|
2094
|
-
* Hide style dialog
|
|
2095
|
-
*/
|
|
2096
|
-
hideStyleDialog() {
|
|
2097
|
-
if (this.styleDialog) {
|
|
2098
|
-
this.styleDialog.style.display = 'none';
|
|
2099
|
-
this.styleDialogVisible = false;
|
|
2100
|
-
|
|
2101
|
-
// Remove keyboard handler
|
|
2102
|
-
if (this.handlers.styleDialogKeydown) {
|
|
2103
|
-
document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
// Return focus to settings button
|
|
2107
|
-
if (this.settingsButton) {
|
|
2108
|
-
this.settingsButton.focus({ preventScroll: true });
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
/**
|
|
2114
|
-
* Create style select control
|
|
2115
|
-
*/
|
|
2116
|
-
createStyleSelectControl(label: string, property: string, options: Array<{ label: string; value: string }>) {
|
|
2117
|
-
const group = DOMUtils.createElement('div', {
|
|
2118
|
-
className: `${this.player.options.classPrefix}-transcript-style-group`
|
|
2119
|
-
});
|
|
2120
|
-
|
|
2121
|
-
// Generate unique ID for the control
|
|
2122
|
-
const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
|
|
2123
|
-
|
|
2124
|
-
const labelEl = DOMUtils.createElement('label', {
|
|
2125
|
-
textContent: label,
|
|
2126
|
-
attributes: {
|
|
2127
|
-
'for': controlId
|
|
2128
|
-
}
|
|
2129
|
-
});
|
|
2130
|
-
group.appendChild(labelEl);
|
|
2131
|
-
|
|
2132
|
-
const select = DOMUtils.createElement('select', {
|
|
2133
|
-
className: `${this.player.options.classPrefix}-transcript-style-select`,
|
|
2134
|
-
attributes: {
|
|
2135
|
-
'id': controlId
|
|
2136
|
-
}
|
|
2137
|
-
});
|
|
2138
|
-
|
|
2139
|
-
options.forEach((opt: { label: string; value: string }) => {
|
|
2140
|
-
const option = DOMUtils.createElement('option', {
|
|
2141
|
-
textContent: opt.label,
|
|
2142
|
-
attributes: {
|
|
2143
|
-
'value': opt.value
|
|
2144
|
-
}
|
|
2145
|
-
});
|
|
2146
|
-
if (this.transcriptStyle[property as 'fontSize' | 'fontFamily'] === opt.value) {
|
|
2147
|
-
option.selected = true;
|
|
2148
|
-
}
|
|
2149
|
-
select.appendChild(option);
|
|
2150
|
-
});
|
|
2151
|
-
|
|
2152
|
-
select.addEventListener('change', (e) => {
|
|
2153
|
-
this.transcriptStyle[property as 'fontSize' | 'fontFamily'] = (e.target as HTMLSelectElement).value;
|
|
2154
|
-
this.applyTranscriptStyles();
|
|
2155
|
-
this.savePreferences();
|
|
2156
|
-
});
|
|
2157
|
-
|
|
2158
|
-
group.appendChild(select);
|
|
2159
|
-
return group;
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
/**
|
|
2163
|
-
* Create style color control
|
|
2164
|
-
*/
|
|
2165
|
-
createStyleColorControl(label: string, property: string) {
|
|
2166
|
-
const group = DOMUtils.createElement('div', {
|
|
2167
|
-
className: `${this.player.options.classPrefix}-transcript-style-group`
|
|
2168
|
-
});
|
|
2169
|
-
|
|
2170
|
-
// Generate unique ID for the control
|
|
2171
|
-
const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
|
|
2172
|
-
|
|
2173
|
-
const labelEl = DOMUtils.createElement('label', {
|
|
2174
|
-
textContent: label,
|
|
2175
|
-
attributes: {
|
|
2176
|
-
'for': controlId
|
|
2177
|
-
}
|
|
2178
|
-
});
|
|
2179
|
-
group.appendChild(labelEl);
|
|
2180
|
-
|
|
2181
|
-
const input = DOMUtils.createElement('input', {
|
|
2182
|
-
attributes: {
|
|
2183
|
-
'id': controlId,
|
|
2184
|
-
'type': 'color',
|
|
2185
|
-
'value': this.transcriptStyle[property as 'color' | 'backgroundColor']
|
|
2186
|
-
},
|
|
2187
|
-
className: `${this.player.options.classPrefix}-transcript-style-color`
|
|
2188
|
-
});
|
|
2189
|
-
|
|
2190
|
-
input.addEventListener('input', (e) => {
|
|
2191
|
-
this.transcriptStyle[property as 'color' | 'backgroundColor'] = (e.target as HTMLInputElement).value;
|
|
2192
|
-
this.applyTranscriptStyles();
|
|
2193
|
-
this.savePreferences();
|
|
2194
|
-
});
|
|
2195
|
-
|
|
2196
|
-
group.appendChild(input);
|
|
2197
|
-
return group;
|
|
2198
|
-
}
|
|
2199
|
-
|
|
2200
|
-
/**
|
|
2201
|
-
* Create style opacity control
|
|
2202
|
-
*/
|
|
2203
|
-
createStyleOpacityControl(label: string, property: string) {
|
|
2204
|
-
const group = DOMUtils.createElement('div', {
|
|
2205
|
-
className: `${this.player.options.classPrefix}-transcript-style-group`
|
|
2206
|
-
});
|
|
2207
|
-
|
|
2208
|
-
// Generate unique ID for the control
|
|
2209
|
-
const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
|
|
2210
|
-
|
|
2211
|
-
const labelEl = DOMUtils.createElement('label', {
|
|
2212
|
-
textContent: label,
|
|
2213
|
-
attributes: {
|
|
2214
|
-
'for': controlId
|
|
2215
|
-
}
|
|
2216
|
-
});
|
|
2217
|
-
group.appendChild(labelEl);
|
|
2218
|
-
|
|
2219
|
-
const opacityProperty = property as 'opacity';
|
|
2220
|
-
const valueDisplay = DOMUtils.createElement('span', {
|
|
2221
|
-
textContent: Math.round(this.transcriptStyle[opacityProperty] * 100) + '%',
|
|
2222
|
-
className: `${this.player.options.classPrefix}-transcript-style-value`
|
|
2223
|
-
});
|
|
2224
|
-
|
|
2225
|
-
const input = DOMUtils.createElement('input', {
|
|
2226
|
-
attributes: {
|
|
2227
|
-
'id': controlId,
|
|
2228
|
-
'type': 'range',
|
|
2229
|
-
'min': '0',
|
|
2230
|
-
'max': '1',
|
|
2231
|
-
'step': '0.1',
|
|
2232
|
-
'value': String(this.transcriptStyle[opacityProperty])
|
|
2233
|
-
},
|
|
2234
|
-
className: `${this.player.options.classPrefix}-transcript-style-range`
|
|
2235
|
-
});
|
|
2236
|
-
|
|
2237
|
-
input.addEventListener('input', (e) => {
|
|
2238
|
-
const value = parseFloat((e.target as HTMLInputElement).value);
|
|
2239
|
-
this.transcriptStyle[opacityProperty] = value;
|
|
2240
|
-
valueDisplay.textContent = Math.round(value * 100) + '%';
|
|
2241
|
-
this.applyTranscriptStyles();
|
|
2242
|
-
this.savePreferences();
|
|
2243
|
-
});
|
|
2244
|
-
|
|
2245
|
-
const inputContainer = DOMUtils.createElement('div', {
|
|
2246
|
-
className: `${this.player.options.classPrefix}-transcript-style-range-container`
|
|
2247
|
-
});
|
|
2248
|
-
inputContainer.appendChild(input);
|
|
2249
|
-
inputContainer.appendChild(valueDisplay);
|
|
2250
|
-
|
|
2251
|
-
group.appendChild(labelEl);
|
|
2252
|
-
group.appendChild(inputContainer);
|
|
2253
|
-
return group;
|
|
2254
|
-
}
|
|
2255
|
-
|
|
2256
|
-
/**
|
|
2257
|
-
* Save transcript preferences to localStorage
|
|
2258
|
-
*/
|
|
2259
|
-
savePreferences() {
|
|
2260
|
-
this.storage.saveTranscriptPreferences(this.transcriptStyle);
|
|
2261
|
-
}
|
|
2262
|
-
|
|
2263
|
-
/**
|
|
2264
|
-
* Apply transcript styles
|
|
2265
|
-
*/
|
|
2266
|
-
applyTranscriptStyles() {
|
|
2267
|
-
if (!this.transcriptWindow) return;
|
|
2268
|
-
|
|
2269
|
-
// Apply to transcript window background
|
|
2270
|
-
this.transcriptWindow.style.backgroundColor = this.transcriptStyle.backgroundColor;
|
|
2271
|
-
this.transcriptWindow.style.opacity = String(this.transcriptStyle.opacity);
|
|
2272
|
-
|
|
2273
|
-
// Apply to content area
|
|
2274
|
-
if (this.transcriptContent) {
|
|
2275
|
-
this.transcriptContent.style.fontSize = this.transcriptStyle.fontSize;
|
|
2276
|
-
this.transcriptContent.style.fontFamily = this.transcriptStyle.fontFamily;
|
|
2277
|
-
this.transcriptContent.style.color = this.transcriptStyle.color;
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
const textEntries = this.transcriptWindow.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-text`);
|
|
2281
|
-
textEntries.forEach((entry) => {
|
|
2282
|
-
entry.style.fontSize = this.transcriptStyle.fontSize;
|
|
2283
|
-
entry.style.fontFamily = this.transcriptStyle.fontFamily;
|
|
2284
|
-
entry.style.color = this.transcriptStyle.color;
|
|
2285
|
-
});
|
|
2286
|
-
|
|
2287
|
-
const timeEntries = this.transcriptWindow.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-time`);
|
|
2288
|
-
timeEntries.forEach((entry) => {
|
|
2289
|
-
entry.style.fontFamily = this.transcriptStyle.fontFamily;
|
|
2290
|
-
});
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
/**
|
|
2294
|
-
* Set a managed timeout that will be cleaned up on destroy
|
|
2295
|
-
* @param {Function} callback - Callback function
|
|
2296
|
-
* @param {number} delay - Delay in milliseconds
|
|
2297
|
-
* @returns {number} Timeout ID
|
|
2298
|
-
*/
|
|
2299
|
-
setManagedTimeout(callback: () => void, delay: number) {
|
|
2300
|
-
const timeoutId = setTimeout(() => {
|
|
2301
|
-
this.timeouts.delete(timeoutId);
|
|
2302
|
-
callback();
|
|
2303
|
-
}, delay);
|
|
2304
|
-
this.timeouts.add(timeoutId);
|
|
2305
|
-
return timeoutId;
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
/**
|
|
2309
|
-
* Clear a managed timeout
|
|
2310
|
-
* @param {number} timeoutId - Timeout ID to clear
|
|
2311
|
-
*/
|
|
2312
|
-
clearManagedTimeout(timeoutId: ReturnType<typeof setTimeout> | null) {
|
|
2313
|
-
if (timeoutId) {
|
|
2314
|
-
clearTimeout(timeoutId);
|
|
2315
|
-
this.timeouts.delete(timeoutId);
|
|
2316
|
-
}
|
|
2317
|
-
}
|
|
2318
|
-
|
|
2319
|
-
/**
|
|
2320
|
-
* Cleanup
|
|
2321
|
-
*/
|
|
2322
|
-
destroy() {
|
|
2323
|
-
this.hideResizeModeIndicator();
|
|
2324
|
-
|
|
2325
|
-
// Tear down the settings-menu panel (removes its DOM + keyboard
|
|
2326
|
-
// listeners). The outside-click listener was attached with the
|
|
2327
|
-
// player's lifecycleSignal, so it cleans itself up as the player
|
|
2328
|
-
// is destroyed.
|
|
2329
|
-
if (this._panel) {
|
|
2330
|
-
this._panel.destroy();
|
|
2331
|
-
this._panel = null;
|
|
2332
|
-
}
|
|
2333
|
-
|
|
2334
|
-
// Destroy draggableResizable utility
|
|
2335
|
-
if (this.draggableResizable) {
|
|
2336
|
-
if (this.draggableResizable.pointerResizeMode) {
|
|
2337
|
-
this.draggableResizable.disablePointerResizeMode();
|
|
2338
|
-
this.updateResizeOptionState();
|
|
2339
|
-
}
|
|
2340
|
-
this.draggableResizable.destroy();
|
|
2341
|
-
this.draggableResizable = null;
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
// Remove custom key handler
|
|
2345
|
-
if (this.transcriptWindow && this.customKeyHandler) {
|
|
2346
|
-
this.transcriptWindow.removeEventListener('keydown', this.customKeyHandler);
|
|
2347
|
-
this.customKeyHandler = null;
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
// Remove timeupdate and seeked listeners from player
|
|
2351
|
-
if (this.handlers.timeupdate) {
|
|
2352
|
-
this.player.off('timeupdate', this.handlers.timeupdate);
|
|
2353
|
-
}
|
|
2354
|
-
if (this.handlers.seeked) {
|
|
2355
|
-
this.player.off('seeked', this.handlers.seeked);
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
|
-
// Remove audio description listeners from player
|
|
2359
|
-
if (this.handlers.audiodescriptionenabled) {
|
|
2360
|
-
this.player.off('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
|
|
2361
|
-
}
|
|
2362
|
-
if (this.handlers.audiodescriptiondisabled) {
|
|
2363
|
-
this.player.off('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
|
|
2364
|
-
}
|
|
2365
|
-
|
|
2366
|
-
// Remove text cue update listener
|
|
2367
|
-
if (this.handlers.textcuesupdate) {
|
|
2368
|
-
this.player.off('textcuesupdate', this.handlers.textcuesupdate);
|
|
2369
|
-
}
|
|
2370
|
-
|
|
2371
|
-
// Remove floating-state listener
|
|
2372
|
-
if (this.handlers.floatingchange) {
|
|
2373
|
-
this.player.off('floatingchange', this.handlers.floatingchange);
|
|
2374
|
-
}
|
|
2375
|
-
|
|
2376
|
-
// Remove settings button event listeners
|
|
2377
|
-
if (this.settingsButton) {
|
|
2378
|
-
if (this.handlers.settingsClick) {
|
|
2379
|
-
this.settingsButton.removeEventListener('click', this.handlers.settingsClick);
|
|
2380
|
-
}
|
|
2381
|
-
if (this.handlers.settingsKeydown) {
|
|
2382
|
-
this.settingsButton.removeEventListener('keydown', this.handlers.settingsKeydown);
|
|
2383
|
-
}
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
|
-
// Remove style dialog event listeners
|
|
2387
|
-
if (this.handlers.styleDialogKeydown) {
|
|
2388
|
-
document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
|
|
2389
|
-
}
|
|
2390
|
-
|
|
2391
|
-
// Remove document click listener
|
|
2392
|
-
if (this.handlers.documentClick) {
|
|
2393
|
-
document.removeEventListener('click', this.handlers.documentClick);
|
|
2394
|
-
}
|
|
2395
|
-
|
|
2396
|
-
// Remove window-level listeners
|
|
2397
|
-
if (this.handlers.resize) {
|
|
2398
|
-
window.removeEventListener('resize', this.handlers.resize);
|
|
2399
|
-
}
|
|
2400
|
-
|
|
2401
|
-
// Cleanup all managed timeouts
|
|
2402
|
-
this.timeouts.forEach((timeoutId: ReturnType<typeof setTimeout>) => clearTimeout(timeoutId));
|
|
2403
|
-
this.timeouts.clear();
|
|
2404
|
-
|
|
2405
|
-
this.handlers = {} as TranscriptHandlers;
|
|
2406
|
-
|
|
2407
|
-
// Remove DOM element
|
|
2408
|
-
if (this.transcriptWindow && this.transcriptWindow.parentNode) {
|
|
2409
|
-
this.transcriptWindow.parentNode.removeChild(this.transcriptWindow);
|
|
2410
|
-
}
|
|
2411
|
-
|
|
2412
|
-
this.transcriptWindow = null;
|
|
2413
|
-
this.transcriptHeader = null;
|
|
2414
|
-
this.transcriptContent = null;
|
|
2415
|
-
this.transcriptEntries = [];
|
|
2416
|
-
this.styleDialog = null;
|
|
2417
|
-
this.transcriptResizeHandles = [];
|
|
2418
|
-
this.liveRegion = null;
|
|
2419
|
-
this._vttCache.clear();
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
|
-
announceLive(message: string) {
|
|
2423
|
-
if (!this.liveRegion) return;
|
|
2424
|
-
this.liveRegion.textContent = message || '';
|
|
2425
|
-
}
|
|
2426
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Transcript Manager Component
|
|
3
|
+
* Manages transcript display and interaction
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
7
|
+
import { TimeUtils } from '../utils/TimeUtils.js';
|
|
8
|
+
import { createIconElement } from '../icons/Icons.js';
|
|
9
|
+
import { i18n } from '../i18n/i18n.js';
|
|
10
|
+
import { StorageManager } from '../utils/StorageManager.js';
|
|
11
|
+
import { focusElement } from '../utils/FocusUtils.js';
|
|
12
|
+
import { createMenuItem } from '../utils/MenuUtils.js';
|
|
13
|
+
import { DraggableResizable } from '../utils/DraggableResizable.js';
|
|
14
|
+
import { createLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
|
|
15
|
+
import { DraggablePanel } from '../utils/DraggablePanel.js';
|
|
16
|
+
import { deriveTrackLabel } from '../utils/TrackLabelUtils.js';
|
|
17
|
+
import type { Player } from '../core/Player.js';
|
|
18
|
+
import type { Renderer } from '../types/renderer.js';
|
|
19
|
+
|
|
20
|
+
type TranscriptCue = TextTrackCue;
|
|
21
|
+
type TranscriptTrack = TextTrack & { _vidplyStale?: boolean };
|
|
22
|
+
type TranscriptLanguageInfo = {
|
|
23
|
+
language: string;
|
|
24
|
+
label: string;
|
|
25
|
+
track: TranscriptTrack;
|
|
26
|
+
};
|
|
27
|
+
type TranscriptCueItem = {
|
|
28
|
+
cue: TranscriptCue;
|
|
29
|
+
type: 'caption' | 'description';
|
|
30
|
+
};
|
|
31
|
+
type TranscriptEntry = {
|
|
32
|
+
element: HTMLElement;
|
|
33
|
+
cue: TranscriptCue;
|
|
34
|
+
type: 'caption' | 'description';
|
|
35
|
+
startTime: number;
|
|
36
|
+
endTime: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
interface TranscriptStyleOptions {
|
|
40
|
+
fontSize: string;
|
|
41
|
+
fontFamily: string;
|
|
42
|
+
color: string;
|
|
43
|
+
backgroundColor: string;
|
|
44
|
+
opacity: number;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TranscriptHandlers {
|
|
49
|
+
timeupdate: () => void;
|
|
50
|
+
seeked: () => void;
|
|
51
|
+
audiodescriptionenabled: () => void;
|
|
52
|
+
audiodescriptiondisabled: () => void;
|
|
53
|
+
textcuesupdate: (() => void) | null;
|
|
54
|
+
resize: (() => void) | null;
|
|
55
|
+
settingsClick: ((e: MouseEvent) => void) | null;
|
|
56
|
+
settingsKeydown: ((e: KeyboardEvent) => void) | null;
|
|
57
|
+
documentClick: ((e: MouseEvent) => void) | null;
|
|
58
|
+
styleDialogKeydown: ((e: KeyboardEvent) => void) | null;
|
|
59
|
+
floatingchange: ((state: 'pinned' | 'auto' | null) => void) | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
63
|
+
|
|
64
|
+
export class TranscriptManager {
|
|
65
|
+
player: Player;
|
|
66
|
+
_cueUpdateTimeout: TimerHandle | null;
|
|
67
|
+
autoscrollCheckbox: HTMLInputElement | null = null;
|
|
68
|
+
autoscrollEnabled: boolean;
|
|
69
|
+
availableTranscriptLanguages: TranscriptLanguageInfo[];
|
|
70
|
+
currentActiveEntry: TranscriptEntry | null;
|
|
71
|
+
currentTranscriptLanguage: string | null;
|
|
72
|
+
customKeyHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
73
|
+
/**
|
|
74
|
+
* True once the style-dialog's outside-click listener has been
|
|
75
|
+
* attached. The settings-menu's outside-click listener is now
|
|
76
|
+
* owned by {@link DraggablePanel} and tracked there; this flag
|
|
77
|
+
* covers the dialog half of the shared `handlers.documentClick`.
|
|
78
|
+
*/
|
|
79
|
+
documentClickHandlerAdded: boolean = false;
|
|
80
|
+
draggableResizable: DraggableResizable | null;
|
|
81
|
+
handlers: TranscriptHandlers = {} as TranscriptHandlers;
|
|
82
|
+
headerLeft: HTMLElement | null = null;
|
|
83
|
+
isVisible: boolean;
|
|
84
|
+
languageLabel: HTMLElement | null;
|
|
85
|
+
languageSelector: HTMLSelectElement | null;
|
|
86
|
+
languageSelectorHandler: ((e: Event) => void) | null;
|
|
87
|
+
languageSelectorWrapper: HTMLElement | null = null;
|
|
88
|
+
liveRegion: HTMLElement | null;
|
|
89
|
+
metadataCueChangeHandler: (() => void) | null = null;
|
|
90
|
+
metadataCues: TextTrackCue[];
|
|
91
|
+
settingsButton: HTMLButtonElement | null;
|
|
92
|
+
showTimestamps: boolean;
|
|
93
|
+
showTimestampsButton: HTMLElement | null = null;
|
|
94
|
+
showTimestampsText: Element | null = null;
|
|
95
|
+
storage: StorageManager;
|
|
96
|
+
styleDialog: HTMLElement | null;
|
|
97
|
+
styleDialogJustOpened: boolean;
|
|
98
|
+
styleDialogVisible: boolean;
|
|
99
|
+
timeouts: Set<TimerHandle>;
|
|
100
|
+
transcriptContent: HTMLElement | null = null;
|
|
101
|
+
transcriptEntries: TranscriptEntry[];
|
|
102
|
+
transcriptHeader: HTMLElement | null = null;
|
|
103
|
+
transcriptResizeHandles: HTMLElement[];
|
|
104
|
+
transcriptStyle: TranscriptStyleOptions;
|
|
105
|
+
transcriptWindow: HTMLElement | null;
|
|
106
|
+
_dashActiveLang: string | null;
|
|
107
|
+
_vttCache: Map<string, TranscriptCueItem[]>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Owns the settings-menu DOM scaffold, its outside-click
|
|
111
|
+
* dismissal, keyboard navigation, viewport-aware positioning,
|
|
112
|
+
* and the drag-mode / resize-mode toggle items. Instantiated
|
|
113
|
+
* lazily once the header is built in {@link createTranscriptHeader}.
|
|
114
|
+
*/
|
|
115
|
+
private _panel: DraggablePanel | null = null;
|
|
116
|
+
|
|
117
|
+
// Back-compat getters for panel-owned state. External callers and
|
|
118
|
+
// internal reads (e.g. `this.settingsMenuVisible` inside other
|
|
119
|
+
// transcript methods) keep reading the same properties; the
|
|
120
|
+
// setters are no-ops because the panel is now the authoritative
|
|
121
|
+
// owner of those values.
|
|
122
|
+
get settingsMenu(): HTMLElement | null {
|
|
123
|
+
return this._panel?.settingsMenu ?? null;
|
|
124
|
+
}
|
|
125
|
+
set settingsMenu(_v: HTMLElement | null) {
|
|
126
|
+
// panel-owned.
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
get settingsMenuVisible(): boolean {
|
|
130
|
+
return this._panel?.settingsMenuVisible ?? false;
|
|
131
|
+
}
|
|
132
|
+
set settingsMenuVisible(_v: boolean) {
|
|
133
|
+
// panel-owned.
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get settingsMenuJustOpened(): boolean {
|
|
137
|
+
return this._panel?.justOpened ?? false;
|
|
138
|
+
}
|
|
139
|
+
set settingsMenuJustOpened(_v: boolean) {
|
|
140
|
+
// panel-owned.
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get dragOptionButton(): HTMLElement | null {
|
|
144
|
+
return this._panel?.dragOptionButton ?? null;
|
|
145
|
+
}
|
|
146
|
+
get dragOptionText(): Element | null {
|
|
147
|
+
return this._panel?.dragOptionText ?? null;
|
|
148
|
+
}
|
|
149
|
+
get resizeOptionButton(): HTMLElement | null {
|
|
150
|
+
return this._panel?.resizeOptionButton ?? null;
|
|
151
|
+
}
|
|
152
|
+
get resizeOptionText(): Element | null {
|
|
153
|
+
return this._panel?.resizeOptionText ?? null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
constructor(player: Player) {
|
|
157
|
+
this.player = player;
|
|
158
|
+
this.transcriptWindow = null;
|
|
159
|
+
this.transcriptEntries = [];
|
|
160
|
+
this.metadataCues = [];
|
|
161
|
+
this.currentActiveEntry = null;
|
|
162
|
+
this.isVisible = false;
|
|
163
|
+
|
|
164
|
+
// Storage manager
|
|
165
|
+
this.storage = new StorageManager('vidply');
|
|
166
|
+
|
|
167
|
+
// Draggable/Resizable utility
|
|
168
|
+
this.draggableResizable = null;
|
|
169
|
+
this.settingsButton = null;
|
|
170
|
+
|
|
171
|
+
// Resize mode state
|
|
172
|
+
this.transcriptResizeHandles = [];
|
|
173
|
+
this.liveRegion = null;
|
|
174
|
+
|
|
175
|
+
// Style dialog state
|
|
176
|
+
this.styleDialog = null;
|
|
177
|
+
this.styleDialogVisible = false;
|
|
178
|
+
this.styleDialogJustOpened = false;
|
|
179
|
+
|
|
180
|
+
// Language selector state
|
|
181
|
+
this.languageSelector = null;
|
|
182
|
+
this.languageLabel = null;
|
|
183
|
+
this.currentTranscriptLanguage = null;
|
|
184
|
+
this.availableTranscriptLanguages = [];
|
|
185
|
+
this.languageSelectorHandler = null;
|
|
186
|
+
|
|
187
|
+
const savedPreferences = this.storage.getTranscriptPreferences();
|
|
188
|
+
|
|
189
|
+
this.autoscrollEnabled = typeof savedPreferences?.autoscroll === 'boolean' ? savedPreferences.autoscroll : true;
|
|
190
|
+
|
|
191
|
+
this.showTimestamps = typeof savedPreferences?.showTimestamps === 'boolean' ? savedPreferences.showTimestamps : false;
|
|
192
|
+
|
|
193
|
+
const savedFontSize = typeof savedPreferences?.fontSize === 'string' ? savedPreferences.fontSize : undefined;
|
|
194
|
+
const savedFontFamily = typeof savedPreferences?.fontFamily === 'string' ? savedPreferences.fontFamily : undefined;
|
|
195
|
+
const savedColor = typeof savedPreferences?.color === 'string' ? savedPreferences.color : undefined;
|
|
196
|
+
const savedBackgroundColor = typeof savedPreferences?.backgroundColor === 'string' ? savedPreferences.backgroundColor : undefined;
|
|
197
|
+
const savedOpacity = typeof savedPreferences?.opacity === 'number' ? savedPreferences.opacity : undefined;
|
|
198
|
+
const optFontSize = typeof this.player.options.transcriptFontSize === 'string' ? this.player.options.transcriptFontSize : undefined;
|
|
199
|
+
const optFontFamily = typeof this.player.options.transcriptFontFamily === 'string' ? this.player.options.transcriptFontFamily : undefined;
|
|
200
|
+
const optColor = typeof this.player.options.transcriptColor === 'string' ? this.player.options.transcriptColor : undefined;
|
|
201
|
+
const optBackgroundColor = typeof this.player.options.transcriptBackgroundColor === 'string' ? this.player.options.transcriptBackgroundColor : undefined;
|
|
202
|
+
const optOpacity = typeof this.player.options.transcriptOpacity === 'number' ? this.player.options.transcriptOpacity : undefined;
|
|
203
|
+
this.transcriptStyle = {
|
|
204
|
+
fontSize: savedFontSize || optFontSize || '100%',
|
|
205
|
+
fontFamily: savedFontFamily || optFontFamily || 'sans-serif',
|
|
206
|
+
color: savedColor || optColor || '#ffffff',
|
|
207
|
+
backgroundColor: savedBackgroundColor || optBackgroundColor || '#1e1e1e',
|
|
208
|
+
opacity: savedOpacity ?? optOpacity ?? 0.98
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Store event handlers for cleanup
|
|
212
|
+
this.handlers = {
|
|
213
|
+
timeupdate: () => this.updateActiveEntry(),
|
|
214
|
+
seeked: () => this.updateActiveEntry(),
|
|
215
|
+
audiodescriptionenabled: () => {
|
|
216
|
+
if (this.isVisible) {
|
|
217
|
+
this.loadTranscriptData();
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
audiodescriptiondisabled: () => {
|
|
221
|
+
if (this.isVisible) {
|
|
222
|
+
this.loadTranscriptData();
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
textcuesupdate: null,
|
|
226
|
+
resize: null,
|
|
227
|
+
settingsClick: null,
|
|
228
|
+
settingsKeydown: null,
|
|
229
|
+
documentClick: null,
|
|
230
|
+
styleDialogKeydown: null,
|
|
231
|
+
floatingchange: null
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
this._cueUpdateTimeout = null;
|
|
235
|
+
this._dashActiveLang = null;
|
|
236
|
+
this._vttCache = new Map<string, TranscriptCueItem[]>();
|
|
237
|
+
|
|
238
|
+
// Timeout management (for cleanup)
|
|
239
|
+
this.timeouts = new Set();
|
|
240
|
+
|
|
241
|
+
this.init();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
init() {
|
|
245
|
+
// Set up metadata handling immediately (independent of transcript display)
|
|
246
|
+
this.setupMetadataHandlingOnLoad();
|
|
247
|
+
|
|
248
|
+
// Listen for time updates to highlight active transcript entry
|
|
249
|
+
this.player.on('timeupdate', this.handlers.timeupdate);
|
|
250
|
+
this.player.on('seeked', this.handlers.seeked);
|
|
251
|
+
|
|
252
|
+
// Listen for audio description changes to reload transcript
|
|
253
|
+
this.player.on('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
|
|
254
|
+
this.player.on('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
|
|
255
|
+
|
|
256
|
+
// Listen for text cue updates (cues arrive incrementally as segments load in HLS/DASH).
|
|
257
|
+
// Skip re-render when we already fetched the full VTT for the active language.
|
|
258
|
+
this.handlers.textcuesupdate = () => {
|
|
259
|
+
if (!this.isVisible) return;
|
|
260
|
+
if (this.currentTranscriptLanguage && this._vttCache.has(this.currentTranscriptLanguage)) return;
|
|
261
|
+
if (this._cueUpdateTimeout) {
|
|
262
|
+
this.clearManagedTimeout(this._cueUpdateTimeout);
|
|
263
|
+
}
|
|
264
|
+
this._cueUpdateTimeout = this.setManagedTimeout(() => {
|
|
265
|
+
this._cueUpdateTimeout = null;
|
|
266
|
+
this.loadTranscriptData();
|
|
267
|
+
}, 400);
|
|
268
|
+
};
|
|
269
|
+
this.player.on('textcuesupdate', this.handlers.textcuesupdate);
|
|
270
|
+
|
|
271
|
+
// Reposition transcript when entering/exiting fullscreen
|
|
272
|
+
this.player.on('fullscreenchange', () => {
|
|
273
|
+
if (this.isVisible) {
|
|
274
|
+
// Re-setup drag/drop when entering/exiting fullscreen on mobile devices
|
|
275
|
+
// This enables drag/resize when entering fullscreen on mobile
|
|
276
|
+
const isMobile = window.innerWidth < 768;
|
|
277
|
+
if (isMobile) {
|
|
278
|
+
this.setupDragAndDrop();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Only auto-position if user hasn't manually positioned it
|
|
282
|
+
if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
|
|
283
|
+
// Add a small delay to ensure DOM has updated after fullscreen transition
|
|
284
|
+
this.setManagedTimeout(() => this.positionTranscript(), 100);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Collapse the transcript whenever the player enters the floating
|
|
290
|
+
// shell. The floating miniplayer is intentionally narrow and only
|
|
291
|
+
// shows a whitelisted subset of controls, so a full-size transcript
|
|
292
|
+
// popup would either overflow the shell or travel with it and break
|
|
293
|
+
// the miniplayer UX. We redock the transcript on exit by simply
|
|
294
|
+
// letting the user re-open it if desired.
|
|
295
|
+
this.handlers.floatingchange = (state) => {
|
|
296
|
+
if (state && this.isVisible) {
|
|
297
|
+
this.hideTranscript();
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
this.player.on('floatingchange', this.handlers.floatingchange);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* For streaming renderers (DASH), tell the renderer to activate the text
|
|
305
|
+
* track for `lang` so dash.js starts downloading subtitle segments and
|
|
306
|
+
* populating cues. Skips the call if the language is already active.
|
|
307
|
+
*/
|
|
308
|
+
private _requestStreamingTrack(lang: string | null) {
|
|
309
|
+
if (!lang) return;
|
|
310
|
+
const renderer = this.player.renderer as Renderer | null | undefined;
|
|
311
|
+
if (renderer?.isStreaming && typeof renderer.activateTextTrackForLanguage === 'function') {
|
|
312
|
+
if (this._dashActiveLang !== lang) {
|
|
313
|
+
this._dashActiveLang = lang;
|
|
314
|
+
renderer.activateTextTrackForLanguage(lang);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Toggle transcript window visibility
|
|
321
|
+
*/
|
|
322
|
+
toggleTranscript() {
|
|
323
|
+
if (this.isVisible) {
|
|
324
|
+
this.hideTranscript();
|
|
325
|
+
} else {
|
|
326
|
+
this.showTranscript();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Show transcript window
|
|
332
|
+
*/
|
|
333
|
+
showTranscript() {
|
|
334
|
+
// The floating miniplayer hides the transcript control button and is
|
|
335
|
+
// too narrow to host the transcript window, so refuse to open while
|
|
336
|
+
// floating. Covers both the control-bar click (already gated by CSS)
|
|
337
|
+
// and the keyboard shortcut / public API paths.
|
|
338
|
+
if (this.player.state?.floating) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Always invalidate track cache to get fresh HLS subtitle tracks
|
|
343
|
+
this.player.invalidateTrackCache();
|
|
344
|
+
|
|
345
|
+
if (this.transcriptWindow) {
|
|
346
|
+
this.transcriptWindow.style.display = 'flex';
|
|
347
|
+
this.isVisible = true;
|
|
348
|
+
|
|
349
|
+
// Reload transcript data to get fresh HLS tracks
|
|
350
|
+
this.loadTranscriptData();
|
|
351
|
+
this._requestStreamingTrack(this.currentTranscriptLanguage);
|
|
352
|
+
this.updateLanguageSelector();
|
|
353
|
+
|
|
354
|
+
if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
|
|
355
|
+
this.player.controlBar.updateTranscriptButton();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Focus the settings button for keyboard accessibility
|
|
359
|
+
focusElement(this.settingsButton, { delay: 150 });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
this.createTranscriptWindow();
|
|
364
|
+
this.loadTranscriptData();
|
|
365
|
+
this._requestStreamingTrack(this.currentTranscriptLanguage);
|
|
366
|
+
|
|
367
|
+
const transcriptWindow = this.transcriptWindow as HTMLElement | null;
|
|
368
|
+
if (transcriptWindow) {
|
|
369
|
+
transcriptWindow.style.display = 'flex';
|
|
370
|
+
|
|
371
|
+
if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
|
|
372
|
+
this.setManagedTimeout(() => this.positionTranscript(), 0);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
focusElement(this.settingsButton, { delay: 150 });
|
|
376
|
+
}
|
|
377
|
+
this.isVisible = true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Hide transcript window
|
|
382
|
+
*/
|
|
383
|
+
hideTranscript({ focusButton = false } = {}) {
|
|
384
|
+
if (this.transcriptWindow) {
|
|
385
|
+
this.transcriptWindow.style.display = 'none';
|
|
386
|
+
this.isVisible = false;
|
|
387
|
+
}
|
|
388
|
+
if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
|
|
389
|
+
this.draggableResizable.disablePointerResizeMode();
|
|
390
|
+
this.updateResizeOptionState();
|
|
391
|
+
}
|
|
392
|
+
this.hideResizeModeIndicator();
|
|
393
|
+
this.announceLive('');
|
|
394
|
+
|
|
395
|
+
// Update transcript button state in control bar
|
|
396
|
+
if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
|
|
397
|
+
this.player.controlBar.updateTranscriptButton();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (focusButton) {
|
|
401
|
+
const transcriptButton = this.player.controlBar?.controls?.transcript;
|
|
402
|
+
if (transcriptButton && typeof transcriptButton.focus === 'function') {
|
|
403
|
+
transcriptButton.focus({ preventScroll: true });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Create the transcript window UI
|
|
410
|
+
*/
|
|
411
|
+
createTranscriptWindow() {
|
|
412
|
+
this.transcriptWindow = DOMUtils.createElement('div', {
|
|
413
|
+
className: `${this.player.options.classPrefix}-transcript-window`,
|
|
414
|
+
attributes: {
|
|
415
|
+
'role': 'dialog',
|
|
416
|
+
'aria-label': i18n.t('transcript.ariaLabel'),
|
|
417
|
+
'tabindex': '-1'
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Header (draggable)
|
|
422
|
+
this.transcriptHeader = DOMUtils.createElement('div', {
|
|
423
|
+
className: `${this.player.options.classPrefix}-transcript-header`,
|
|
424
|
+
attributes: {
|
|
425
|
+
'tabindex': '0'
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// Header left side (settings button + title)
|
|
430
|
+
this.headerLeft = DOMUtils.createElement('div', {
|
|
431
|
+
className: `${this.player.options.classPrefix}-transcript-header-left`
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Settings button
|
|
435
|
+
const settingsAriaLabel = i18n.t('transcript.settingsMenu');
|
|
436
|
+
this.settingsButton = DOMUtils.createElement('button', {
|
|
437
|
+
className: `${this.player.options.classPrefix}-transcript-settings`,
|
|
438
|
+
attributes: {
|
|
439
|
+
'type': 'button',
|
|
440
|
+
'aria-label': settingsAriaLabel,
|
|
441
|
+
'aria-expanded': 'false'
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
this.settingsButton.appendChild(createIconElement('settings'));
|
|
445
|
+
DOMUtils.attachTooltip(this.settingsButton, settingsAriaLabel, this.player.options.classPrefix);
|
|
446
|
+
|
|
447
|
+
// The panel owns the settings-menu DOM, lifecycle, and the drag
|
|
448
|
+
// + resize toggle items. Transcript-specific extras (style dialog
|
|
449
|
+
// opener and timestamps toggle) are appended via the extras
|
|
450
|
+
// builder below. The manager keeps ownership of the toggle
|
|
451
|
+
// semantics (announcements, focus target), which the panel
|
|
452
|
+
// invokes via the click callbacks.
|
|
453
|
+
this._panel = new DraggablePanel({
|
|
454
|
+
player: this.player,
|
|
455
|
+
namespace: 'transcript',
|
|
456
|
+
settingsButton: this.settingsButton,
|
|
457
|
+
getDraggable: () => this.draggableResizable,
|
|
458
|
+
i18nKeys: {
|
|
459
|
+
enableDrag: 'transcript.enableDragMode',
|
|
460
|
+
disableDrag: 'transcript.disableDragMode',
|
|
461
|
+
enableDragAria: 'transcript.enableDragModeAria',
|
|
462
|
+
disableDragAria: 'transcript.disableDragModeAria',
|
|
463
|
+
enableResize: 'transcript.enableResizeMode',
|
|
464
|
+
disableResize: 'transcript.disableResizeMode',
|
|
465
|
+
enableResizeAria: 'transcript.enableResizeModeAria',
|
|
466
|
+
disableResizeAria: 'transcript.disableResizeModeAria',
|
|
467
|
+
closeMenu: 'transcript.closeMenu',
|
|
468
|
+
},
|
|
469
|
+
menuAlign: 'left',
|
|
470
|
+
getMenuParent: () =>
|
|
471
|
+
this.headerLeft ?? this.transcriptHeader ?? this.transcriptWindow,
|
|
472
|
+
// The transcript window itself is the anchor for the mode badge
|
|
473
|
+
// so it sits above the panel regardless of where the settings
|
|
474
|
+
// menu was opened from. The default class name is derived from
|
|
475
|
+
// the namespace → `{classPrefix}-transcript-mode-badge`.
|
|
476
|
+
getBadgeHost: () => this.transcriptWindow,
|
|
477
|
+
onDragItemClick: (panel) => {
|
|
478
|
+
this.toggleKeyboardDragMode();
|
|
479
|
+
panel.hide();
|
|
480
|
+
},
|
|
481
|
+
onResizeItemClick: (panel) => {
|
|
482
|
+
const enabled = this.toggleResizeMode({ focus: false });
|
|
483
|
+
if (enabled) {
|
|
484
|
+
panel.hide({ focusButton: false });
|
|
485
|
+
setTimeout(() => {
|
|
486
|
+
this.transcriptWindow?.focus({ preventScroll: true });
|
|
487
|
+
}, 20);
|
|
488
|
+
} else {
|
|
489
|
+
panel.hide({ focusButton: true });
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
buildExtraItems: ({ menu, itemClass, classPrefix, stripInlineTooltip }) => {
|
|
493
|
+
const styleOption = createMenuItem({
|
|
494
|
+
classPrefix,
|
|
495
|
+
itemClass,
|
|
496
|
+
icon: 'settings',
|
|
497
|
+
label: 'transcript.styleTranscript',
|
|
498
|
+
onClick: (e: Event) => {
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
e.stopPropagation();
|
|
501
|
+
this._panel?.hide();
|
|
502
|
+
setTimeout(() => {
|
|
503
|
+
this.showStyleDialog();
|
|
504
|
+
}, 50);
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
stripInlineTooltip(styleOption);
|
|
508
|
+
menu.appendChild(styleOption);
|
|
509
|
+
|
|
510
|
+
const timestampsOption = createMenuItem({
|
|
511
|
+
classPrefix,
|
|
512
|
+
itemClass,
|
|
513
|
+
icon: 'clock',
|
|
514
|
+
label: 'transcript.showTimestamps',
|
|
515
|
+
hasTextClass: true,
|
|
516
|
+
onClick: () => {
|
|
517
|
+
this.toggleShowTimestamps();
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
timestampsOption.setAttribute('role', 'switch');
|
|
521
|
+
timestampsOption.setAttribute(
|
|
522
|
+
'aria-checked',
|
|
523
|
+
this.showTimestamps ? 'true' : 'false'
|
|
524
|
+
);
|
|
525
|
+
stripInlineTooltip(timestampsOption);
|
|
526
|
+
this.showTimestampsButton = timestampsOption;
|
|
527
|
+
this.showTimestampsText = timestampsOption.querySelector(
|
|
528
|
+
`.${classPrefix}-settings-text`
|
|
529
|
+
);
|
|
530
|
+
this.updateShowTimestampsState();
|
|
531
|
+
menu.appendChild(timestampsOption);
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
this.handlers.settingsClick = (e: MouseEvent) => {
|
|
536
|
+
e.preventDefault();
|
|
537
|
+
e.stopPropagation();
|
|
538
|
+
this._panel?.markJustOpenedForClick();
|
|
539
|
+
this._panel?.toggle();
|
|
540
|
+
};
|
|
541
|
+
this.settingsButton.addEventListener('click', this.handlers.settingsClick);
|
|
542
|
+
|
|
543
|
+
// Keyboard handler for settings button
|
|
544
|
+
this.handlers.settingsKeydown = (e: KeyboardEvent) => {
|
|
545
|
+
// D key to toggle keyboard drag mode
|
|
546
|
+
if (e.key === 'd' || e.key === 'D') {
|
|
547
|
+
e.preventDefault();
|
|
548
|
+
e.stopPropagation();
|
|
549
|
+
this.toggleKeyboardDragMode();
|
|
550
|
+
}
|
|
551
|
+
// R key to toggle resize mode
|
|
552
|
+
else if (e.key === 'r' || e.key === 'R') {
|
|
553
|
+
e.preventDefault();
|
|
554
|
+
e.stopPropagation();
|
|
555
|
+
this.toggleResizeMode();
|
|
556
|
+
}
|
|
557
|
+
// Escape to close menu if open
|
|
558
|
+
else if (e.key === 'Escape' && this.settingsMenuVisible) {
|
|
559
|
+
e.preventDefault();
|
|
560
|
+
e.stopPropagation();
|
|
561
|
+
this.hideSettingsMenu();
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
this.settingsButton.addEventListener('keydown', this.handlers.settingsKeydown);
|
|
565
|
+
|
|
566
|
+
const title = DOMUtils.createElement('h3', {
|
|
567
|
+
textContent: `${i18n.t('transcript.title')}. ${i18n.t('transcript.dragResizePrompt')}`
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// Autoscroll checkbox
|
|
571
|
+
const autoscrollId = `${this.player.options.classPrefix}-transcript-autoscroll-${Date.now()}`;
|
|
572
|
+
|
|
573
|
+
const autoscrollLabel = DOMUtils.createElement('label', {
|
|
574
|
+
className: `${this.player.options.classPrefix}-transcript-autoscroll-label`,
|
|
575
|
+
attributes: {
|
|
576
|
+
'for': autoscrollId
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
this.autoscrollCheckbox = DOMUtils.createElement('input', {
|
|
581
|
+
attributes: {
|
|
582
|
+
'id': autoscrollId,
|
|
583
|
+
'type': 'checkbox'
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
// Set checked property directly (boolean attribute, not "true" string)
|
|
587
|
+
if (this.autoscrollEnabled) {
|
|
588
|
+
this.autoscrollCheckbox.checked = true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const autoscrollText = DOMUtils.createElement('span', {
|
|
592
|
+
textContent: i18n.t('transcript.autoscroll'),
|
|
593
|
+
className: `${this.player.options.classPrefix}-transcript-autoscroll-text`
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
autoscrollLabel.appendChild(this.autoscrollCheckbox);
|
|
597
|
+
autoscrollLabel.appendChild(autoscrollText);
|
|
598
|
+
|
|
599
|
+
// Handle autoscroll checkbox change
|
|
600
|
+
this.autoscrollCheckbox.addEventListener('change', (e: Event) => {
|
|
601
|
+
this.autoscrollEnabled = (e.target as HTMLInputElement).checked;
|
|
602
|
+
this.saveAutoscrollPreference();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
preventDragOnElement(autoscrollLabel);
|
|
606
|
+
|
|
607
|
+
this.transcriptHeader.appendChild(title);
|
|
608
|
+
this.headerLeft.appendChild(this.settingsButton);
|
|
609
|
+
this.headerLeft.appendChild(autoscrollLabel);
|
|
610
|
+
|
|
611
|
+
// Language selector (will be populated after tracks are loaded)
|
|
612
|
+
const selectId = `${this.player.options.classPrefix}-transcript-language-select-${Date.now()}`;
|
|
613
|
+
const { label: languageLabel, select: languageSelector } = createLabeledSelect({
|
|
614
|
+
classPrefix: this.player.options.classPrefix,
|
|
615
|
+
labelClass: `${this.player.options.classPrefix}-transcript-language-label`,
|
|
616
|
+
selectClass: `${this.player.options.classPrefix}-transcript-language-select`,
|
|
617
|
+
labelText: 'settings.language',
|
|
618
|
+
selectId: selectId,
|
|
619
|
+
hidden: false // Don't hide individual elements, we'll hide the wrapper instead
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
this.languageLabel = languageLabel;
|
|
623
|
+
this.languageSelector = languageSelector;
|
|
624
|
+
|
|
625
|
+
const languageSelectorWrapper = DOMUtils.createElement('div', {
|
|
626
|
+
className: `${this.player.options.classPrefix}-transcript-language-wrapper`,
|
|
627
|
+
attributes: {
|
|
628
|
+
'style': 'display: none;'
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
languageSelectorWrapper.appendChild(languageLabel);
|
|
632
|
+
languageSelectorWrapper.appendChild(languageSelector);
|
|
633
|
+
this.languageSelectorWrapper = languageSelectorWrapper;
|
|
634
|
+
|
|
635
|
+
preventDragOnElement(languageSelectorWrapper);
|
|
636
|
+
|
|
637
|
+
if (this.headerLeft) {
|
|
638
|
+
this.headerLeft.appendChild(languageSelectorWrapper);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const closeAriaLabel = i18n.t('transcript.close');
|
|
642
|
+
const closeButton = DOMUtils.createElement('button', {
|
|
643
|
+
className: `${this.player.options.classPrefix}-transcript-close`,
|
|
644
|
+
attributes: {
|
|
645
|
+
'type': 'button',
|
|
646
|
+
'aria-label': closeAriaLabel
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
closeButton.appendChild(createIconElement('close'));
|
|
650
|
+
DOMUtils.attachTooltip(closeButton, closeAriaLabel, this.player.options.classPrefix);
|
|
651
|
+
closeButton.addEventListener('click', () => this.hideTranscript({ focusButton: true }));
|
|
652
|
+
|
|
653
|
+
this.transcriptHeader.appendChild(this.headerLeft);
|
|
654
|
+
this.transcriptHeader.appendChild(closeButton);
|
|
655
|
+
|
|
656
|
+
// Content container
|
|
657
|
+
this.transcriptContent = DOMUtils.createElement('div', {
|
|
658
|
+
className: `${this.player.options.classPrefix}-transcript-content`
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
this.transcriptWindow.appendChild(this.transcriptHeader);
|
|
662
|
+
this.transcriptWindow.appendChild(this.transcriptContent);
|
|
663
|
+
|
|
664
|
+
this.createResizeHandles();
|
|
665
|
+
|
|
666
|
+
// Live region for announcements (screen reader feedback)
|
|
667
|
+
this.liveRegion = DOMUtils.createElement('div', {
|
|
668
|
+
className: 'vidply-sr-only',
|
|
669
|
+
attributes: {
|
|
670
|
+
'aria-live': 'polite',
|
|
671
|
+
'aria-atomic': 'true'
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
this.transcriptWindow.appendChild(this.liveRegion);
|
|
675
|
+
|
|
676
|
+
// Append to player container
|
|
677
|
+
this.player.container.appendChild(this.transcriptWindow);
|
|
678
|
+
|
|
679
|
+
// Setup drag functionality FIRST (this will restore saved position if it exists)
|
|
680
|
+
this.setupDragAndDrop();
|
|
681
|
+
|
|
682
|
+
// Then position it next to the video wrapper ONLY if user hasn't manually positioned it
|
|
683
|
+
// This ensures we don't overwrite saved positions from localStorage
|
|
684
|
+
if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
|
|
685
|
+
this.positionTranscript();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Outside-click dismissal for the *style dialog* only. The
|
|
689
|
+
// settings-menu equivalent is owned by the DraggablePanel, which
|
|
690
|
+
// attaches its own listener (tied to the player's lifecycle
|
|
691
|
+
// signal) when the menu is first opened.
|
|
692
|
+
this.handlers.documentClick = (e: MouseEvent) => {
|
|
693
|
+
if (this.styleDialogJustOpened) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const target = e.target as Node | null;
|
|
697
|
+
if (this.styleDialogVisible && this.styleDialog &&
|
|
698
|
+
target && !this.styleDialog.contains(target)) {
|
|
699
|
+
this.hideStyleDialog();
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
this.documentClickHandlerAdded = false;
|
|
703
|
+
|
|
704
|
+
// Re-position on window resize (debounced) - but only if not manually positioned
|
|
705
|
+
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
706
|
+
this.handlers.resize = () => {
|
|
707
|
+
if (resizeTimeout) {
|
|
708
|
+
this.clearManagedTimeout(resizeTimeout);
|
|
709
|
+
}
|
|
710
|
+
resizeTimeout = this.setManagedTimeout(() => {
|
|
711
|
+
// Only auto-position if user hasn't manually moved it
|
|
712
|
+
if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
|
|
713
|
+
this.positionTranscript();
|
|
714
|
+
}
|
|
715
|
+
}, 100);
|
|
716
|
+
};
|
|
717
|
+
window.addEventListener('resize', this.handlers.resize, {
|
|
718
|
+
signal: this.player.lifecycleSignal
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
createResizeHandles() {
|
|
723
|
+
const transcriptWindow = this.transcriptWindow;
|
|
724
|
+
if (!transcriptWindow) return;
|
|
725
|
+
|
|
726
|
+
const directions: Array<'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'> = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
|
|
727
|
+
this.transcriptResizeHandles = directions.map(direction => {
|
|
728
|
+
const handle = DOMUtils.createElement('div', {
|
|
729
|
+
className: `${this.player.options.classPrefix}-transcript-resize-handle ${this.player.options.classPrefix}-transcript-resize-${direction}`,
|
|
730
|
+
attributes: {
|
|
731
|
+
'data-direction': direction,
|
|
732
|
+
'data-vidply-managed-resize': 'true',
|
|
733
|
+
'aria-hidden': 'true'
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
handle.style.display = 'none';
|
|
738
|
+
transcriptWindow.appendChild(handle);
|
|
739
|
+
return handle;
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Position transcript window next to video
|
|
745
|
+
*/
|
|
746
|
+
positionTranscript() {
|
|
747
|
+
const playerWithVideoWrapper = this.player as Player & { videoWrapper?: HTMLElement | null };
|
|
748
|
+
if (!this.transcriptWindow || !playerWithVideoWrapper.videoWrapper || !this.isVisible) return;
|
|
749
|
+
|
|
750
|
+
// Don't auto-position if user has manually positioned it
|
|
751
|
+
if (this.draggableResizable && this.draggableResizable.manuallyPositioned) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const isMobile = window.innerWidth < 768;
|
|
756
|
+
const videoRect = playerWithVideoWrapper.videoWrapper.getBoundingClientRect();
|
|
757
|
+
|
|
758
|
+
// Check if player is in fullscreen mode
|
|
759
|
+
const isFullscreen = this.player.state.fullscreen;
|
|
760
|
+
|
|
761
|
+
if (isMobile && !isFullscreen) {
|
|
762
|
+
// Mobile: Position directly underneath the video controls as part of the layout
|
|
763
|
+
this.transcriptWindow.style.position = 'relative';
|
|
764
|
+
this.transcriptWindow.style.left = '0';
|
|
765
|
+
this.transcriptWindow.style.right = '0';
|
|
766
|
+
this.transcriptWindow.style.bottom = 'auto';
|
|
767
|
+
this.transcriptWindow.style.top = 'auto';
|
|
768
|
+
this.transcriptWindow.style.width = '100%';
|
|
769
|
+
this.transcriptWindow.style.maxWidth = '100%';
|
|
770
|
+
this.transcriptWindow.style.maxHeight = '300px';
|
|
771
|
+
this.transcriptWindow.style.height = 'auto';
|
|
772
|
+
this.transcriptWindow.style.borderRadius = '0';
|
|
773
|
+
this.transcriptWindow.style.transform = 'none';
|
|
774
|
+
this.transcriptWindow.style.border = 'none';
|
|
775
|
+
this.transcriptWindow.style.borderTop = '1px solid var(--vidply-border-light)';
|
|
776
|
+
// Remove any empty border properties that might have been set
|
|
777
|
+
this.transcriptWindow.style.removeProperty('border-right');
|
|
778
|
+
this.transcriptWindow.style.removeProperty('border-bottom');
|
|
779
|
+
this.transcriptWindow.style.removeProperty('border-left');
|
|
780
|
+
// Remove border-image properties that can cause parse errors
|
|
781
|
+
this.transcriptWindow.style.removeProperty('border-image');
|
|
782
|
+
this.transcriptWindow.style.removeProperty('border-image-source');
|
|
783
|
+
this.transcriptWindow.style.removeProperty('border-image-slice');
|
|
784
|
+
this.transcriptWindow.style.removeProperty('border-image-width');
|
|
785
|
+
this.transcriptWindow.style.removeProperty('border-image-outset');
|
|
786
|
+
this.transcriptWindow.style.removeProperty('border-image-repeat');
|
|
787
|
+
this.transcriptWindow.style.boxShadow = 'none';
|
|
788
|
+
// Disable dragging on mobile
|
|
789
|
+
if (this.transcriptHeader) {
|
|
790
|
+
this.transcriptHeader.style.cursor = 'default';
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Insert directly after video wrapper to appear right under controls
|
|
794
|
+
const videoWrapper = playerWithVideoWrapper.videoWrapper;
|
|
795
|
+
if (videoWrapper && videoWrapper.parentNode && videoWrapper.nextSibling !== this.transcriptWindow) {
|
|
796
|
+
videoWrapper.parentNode.insertBefore(this.transcriptWindow, videoWrapper.nextSibling);
|
|
797
|
+
}
|
|
798
|
+
} else if (isFullscreen) {
|
|
799
|
+
// In fullscreen: position in bottom right corner inside the video
|
|
800
|
+
this.transcriptWindow.style.position = 'fixed';
|
|
801
|
+
this.transcriptWindow.style.left = 'auto';
|
|
802
|
+
this.transcriptWindow.style.right = '20px';
|
|
803
|
+
this.transcriptWindow.style.bottom = '80px'; // Above controls
|
|
804
|
+
this.transcriptWindow.style.top = 'auto';
|
|
805
|
+
this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
|
|
806
|
+
this.transcriptWindow.style.height = 'auto';
|
|
807
|
+
const fullscreenMinWidth = 260;
|
|
808
|
+
const fullscreenAvailable = Math.max(fullscreenMinWidth, window.innerWidth - 40);
|
|
809
|
+
const fullscreenDesired = parseFloat(this.transcriptWindow.style.width) || 400;
|
|
810
|
+
const fullscreenWidth = Math.max(fullscreenMinWidth, Math.min(fullscreenDesired, fullscreenAvailable));
|
|
811
|
+
this.transcriptWindow.style.width = `${fullscreenWidth}px`;
|
|
812
|
+
this.transcriptWindow.style.maxWidth = 'none';
|
|
813
|
+
this.transcriptWindow.style.borderRadius = '8px';
|
|
814
|
+
this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
|
|
815
|
+
// Remove borderTop and any other individual border properties to avoid empty values
|
|
816
|
+
this.transcriptWindow.style.removeProperty('border-top');
|
|
817
|
+
this.transcriptWindow.style.removeProperty('border-right');
|
|
818
|
+
this.transcriptWindow.style.removeProperty('border-bottom');
|
|
819
|
+
this.transcriptWindow.style.removeProperty('border-left');
|
|
820
|
+
// Remove border-image properties that can cause parse errors
|
|
821
|
+
this.transcriptWindow.style.removeProperty('border-image');
|
|
822
|
+
this.transcriptWindow.style.removeProperty('border-image-source');
|
|
823
|
+
this.transcriptWindow.style.removeProperty('border-image-slice');
|
|
824
|
+
this.transcriptWindow.style.removeProperty('border-image-width');
|
|
825
|
+
this.transcriptWindow.style.removeProperty('border-image-outset');
|
|
826
|
+
this.transcriptWindow.style.removeProperty('border-image-repeat');
|
|
827
|
+
// Enable dragging in fullscreen (including touch devices)
|
|
828
|
+
if (this.transcriptHeader) {
|
|
829
|
+
this.transcriptHeader.style.cursor = 'move';
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Move back to container for fullscreen
|
|
833
|
+
if (this.transcriptWindow.parentNode !== this.player.container) {
|
|
834
|
+
this.player.container.appendChild(this.transcriptWindow);
|
|
835
|
+
}
|
|
836
|
+
} else {
|
|
837
|
+
// Desktop mode: position to the right of video, or overlay if insufficient space
|
|
838
|
+
const transcriptWidth = parseFloat(this.transcriptWindow.style.width) || 400;
|
|
839
|
+
const padding = 20;
|
|
840
|
+
const minWidth = 260;
|
|
841
|
+
const containerRect = this.player.container.getBoundingClientRect();
|
|
842
|
+
|
|
843
|
+
const ensureContainerPositioned = () => {
|
|
844
|
+
const computed = window.getComputedStyle(this.player.container);
|
|
845
|
+
if (computed.position === 'static') {
|
|
846
|
+
this.player.container.style.position = 'relative';
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
ensureContainerPositioned();
|
|
851
|
+
|
|
852
|
+
// Calculate available space to the right of the video
|
|
853
|
+
// WCAG 1.4.10 Reflow: Avoid horizontal overflow and content being cut off
|
|
854
|
+
const availableWidth = window.innerWidth - videoRect.right - padding;
|
|
855
|
+
|
|
856
|
+
// Switch to overlay mode when:
|
|
857
|
+
// 1. Available width is less than the desired transcript width (would cause squeezing/cutoff)
|
|
858
|
+
// 2. OR available width is below minimum usable width
|
|
859
|
+
// This triggers earlier - as soon as the transcript would be squeezed
|
|
860
|
+
const wouldBeCutOff = availableWidth < transcriptWidth;
|
|
861
|
+
const hasMinimumSpace = availableWidth >= minWidth;
|
|
862
|
+
const useOverlay = wouldBeCutOff || !hasMinimumSpace;
|
|
863
|
+
|
|
864
|
+
if (!useOverlay) {
|
|
865
|
+
// Position to the right of the video (original behavior)
|
|
866
|
+
const left = (videoRect.right - containerRect.left) + padding;
|
|
867
|
+
const appliedWidth = Math.max(minWidth, Math.min(transcriptWidth, availableWidth));
|
|
868
|
+
const appliedHeight = videoRect.height;
|
|
869
|
+
|
|
870
|
+
this.transcriptWindow.style.position = 'absolute';
|
|
871
|
+
this.transcriptWindow.style.left = `${left}px`;
|
|
872
|
+
this.transcriptWindow.style.right = 'auto';
|
|
873
|
+
this.transcriptWindow.style.bottom = 'auto';
|
|
874
|
+
this.transcriptWindow.style.top = '0';
|
|
875
|
+
this.transcriptWindow.style.height = `${appliedHeight}px`;
|
|
876
|
+
this.transcriptWindow.style.maxHeight = 'none';
|
|
877
|
+
this.transcriptWindow.style.width = `${appliedWidth}px`;
|
|
878
|
+
this.transcriptWindow.style.maxWidth = 'none';
|
|
879
|
+
this.transcriptWindow.style.boxShadow = '';
|
|
880
|
+
} else {
|
|
881
|
+
// Insufficient space: fall back to compact overlay mode inside video area
|
|
882
|
+
// Positioned above controls to prevent horizontal overflow
|
|
883
|
+
const overlayMaxWidth = 320;
|
|
884
|
+
const overlayMaxHeight = 280;
|
|
885
|
+
const overlayWidth = Math.min(overlayMaxWidth, videoRect.width - 40);
|
|
886
|
+
const overlayHeight = Math.min(overlayMaxHeight, videoRect.height - 120);
|
|
887
|
+
|
|
888
|
+
// Calculate position relative to container, placing overlay inside video area
|
|
889
|
+
const videoWrapperRect = playerWithVideoWrapper.videoWrapper.getBoundingClientRect();
|
|
890
|
+
const controlsHeight = 70; // Approximate height of controls
|
|
891
|
+
|
|
892
|
+
// Position from top of container to bottom of video minus controls and overlay height
|
|
893
|
+
const overlayActualHeight = Math.max(180, overlayHeight);
|
|
894
|
+
const topPosition = (videoWrapperRect.bottom - containerRect.top) - controlsHeight - overlayActualHeight;
|
|
895
|
+
const rightPosition = (containerRect.right - videoWrapperRect.right) + 12;
|
|
896
|
+
|
|
897
|
+
this.transcriptWindow.style.position = 'absolute';
|
|
898
|
+
this.transcriptWindow.style.left = 'auto';
|
|
899
|
+
this.transcriptWindow.style.right = `${rightPosition}px`;
|
|
900
|
+
this.transcriptWindow.style.top = `${topPosition}px`;
|
|
901
|
+
this.transcriptWindow.style.bottom = 'auto';
|
|
902
|
+
this.transcriptWindow.style.width = `${Math.max(minWidth, overlayWidth)}px`;
|
|
903
|
+
this.transcriptWindow.style.maxWidth = `${overlayMaxWidth}px`;
|
|
904
|
+
this.transcriptWindow.style.height = `${overlayActualHeight}px`;
|
|
905
|
+
this.transcriptWindow.style.maxHeight = `${overlayMaxHeight}px`;
|
|
906
|
+
this.transcriptWindow.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.6)'; // Enhanced shadow for overlay visibility
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
this.transcriptWindow.style.borderRadius = '8px';
|
|
910
|
+
this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
|
|
911
|
+
// Remove borderTop and any other individual border properties to avoid empty values
|
|
912
|
+
this.transcriptWindow.style.removeProperty('border-top');
|
|
913
|
+
this.transcriptWindow.style.removeProperty('border-right');
|
|
914
|
+
this.transcriptWindow.style.removeProperty('border-bottom');
|
|
915
|
+
this.transcriptWindow.style.removeProperty('border-left');
|
|
916
|
+
// Remove border-image properties that can cause parse errors
|
|
917
|
+
this.transcriptWindow.style.removeProperty('border-image');
|
|
918
|
+
this.transcriptWindow.style.removeProperty('border-image-source');
|
|
919
|
+
this.transcriptWindow.style.removeProperty('border-image-slice');
|
|
920
|
+
this.transcriptWindow.style.removeProperty('border-image-width');
|
|
921
|
+
this.transcriptWindow.style.removeProperty('border-image-outset');
|
|
922
|
+
this.transcriptWindow.style.removeProperty('border-image-repeat');
|
|
923
|
+
// Enable dragging on desktop
|
|
924
|
+
if (this.transcriptHeader) {
|
|
925
|
+
this.transcriptHeader.style.cursor = 'move';
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Move back to container for desktop
|
|
929
|
+
if (this.transcriptWindow.parentNode !== this.player.container) {
|
|
930
|
+
this.player.container.appendChild(this.transcriptWindow);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Get available transcript languages from tracks
|
|
937
|
+
*/
|
|
938
|
+
getAvailableTranscriptLanguages() {
|
|
939
|
+
const textTracks = this.player.textTracks as TranscriptTrack[];
|
|
940
|
+
const languages = new Map<string, TranscriptLanguageInfo>();
|
|
941
|
+
|
|
942
|
+
// Collect all caption/subtitle tracks with their languages.
|
|
943
|
+
// For DASH/HLS, tracks may have placeholder labels (AdaptationSet IDs,
|
|
944
|
+
// "null", etc.); deriveTrackLabel normalises them to human-readable names.
|
|
945
|
+
textTracks.forEach((track: TranscriptTrack) => {
|
|
946
|
+
if ((track.kind === 'captions' || track.kind === 'subtitles') && !track._vidplyStale) {
|
|
947
|
+
const lang = (track.language ?? '').trim();
|
|
948
|
+
const label = deriveTrackLabel(track.label, track.language, 'player.captions');
|
|
949
|
+
const key = lang || label;
|
|
950
|
+
if (key && !languages.has(key)) {
|
|
951
|
+
languages.set(key, {
|
|
952
|
+
language: lang,
|
|
953
|
+
label,
|
|
954
|
+
track: track
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
return Array.from(languages.values());
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Update language selector dropdown
|
|
965
|
+
*/
|
|
966
|
+
updateLanguageSelector() {
|
|
967
|
+
const languageSelector = this.languageSelector;
|
|
968
|
+
if (!languageSelector) return;
|
|
969
|
+
|
|
970
|
+
this.availableTranscriptLanguages = this.getAvailableTranscriptLanguages();
|
|
971
|
+
|
|
972
|
+
languageSelector.innerHTML = '';
|
|
973
|
+
|
|
974
|
+
if (this.availableTranscriptLanguages.length < 2) {
|
|
975
|
+
if (this.languageSelectorWrapper) {
|
|
976
|
+
this.languageSelectorWrapper.style.display = 'none';
|
|
977
|
+
}
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (this.languageSelectorWrapper) {
|
|
982
|
+
this.languageSelectorWrapper.style.display = 'flex';
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
this.availableTranscriptLanguages.forEach((langInfo: TranscriptLanguageInfo) => {
|
|
986
|
+
const attrs: Record<string, string> = {
|
|
987
|
+
'value': langInfo.language || langInfo.label
|
|
988
|
+
};
|
|
989
|
+
if (langInfo.language) {
|
|
990
|
+
attrs['lang'] = langInfo.language;
|
|
991
|
+
}
|
|
992
|
+
const option = DOMUtils.createElement('option', {
|
|
993
|
+
textContent: langInfo.label,
|
|
994
|
+
attributes: attrs
|
|
995
|
+
});
|
|
996
|
+
languageSelector.appendChild(option);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
if (this.currentTranscriptLanguage) {
|
|
1000
|
+
languageSelector.value = this.currentTranscriptLanguage;
|
|
1001
|
+
} else if (this.availableTranscriptLanguages.length > 0) {
|
|
1002
|
+
const activeTrack = this.player.textTracks.find(
|
|
1003
|
+
(track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing'
|
|
1004
|
+
);
|
|
1005
|
+
const firstLang = this.availableTranscriptLanguages[0];
|
|
1006
|
+
const fallbackLang = firstLang ? firstLang.language : null;
|
|
1007
|
+
this.currentTranscriptLanguage = activeTrack ? activeTrack.language : fallbackLang;
|
|
1008
|
+
if (this.currentTranscriptLanguage) {
|
|
1009
|
+
languageSelector.value = this.currentTranscriptLanguage;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (this.languageSelectorHandler) {
|
|
1014
|
+
languageSelector.removeEventListener('change', this.languageSelectorHandler);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Handle language change — tell the streaming renderer to switch tracks
|
|
1018
|
+
// first so dash.js starts downloading cues for the new language, then
|
|
1019
|
+
// render whatever is available (textcuesupdate will re-render later).
|
|
1020
|
+
const handler = (e: Event) => {
|
|
1021
|
+
this.currentTranscriptLanguage = (e.target as HTMLSelectElement).value;
|
|
1022
|
+
this._requestStreamingTrack(this.currentTranscriptLanguage);
|
|
1023
|
+
this.loadTranscriptData();
|
|
1024
|
+
|
|
1025
|
+
if (this.transcriptContent && this.currentTranscriptLanguage) {
|
|
1026
|
+
this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
this.languageSelectorHandler = handler;
|
|
1030
|
+
languageSelector.addEventListener('change', handler);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
private _parseVTT(vttText: string): TranscriptCueItem[] {
|
|
1034
|
+
const cues: TranscriptCueItem[] = [];
|
|
1035
|
+
const blocks = vttText.replace(/\r\n/g, '\n').split(/\n\n+/);
|
|
1036
|
+
for (const block of blocks) {
|
|
1037
|
+
const lines = block.trim().split('\n');
|
|
1038
|
+
let tsLine = -1;
|
|
1039
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1040
|
+
if (lines[i]?.includes('-->')) { tsLine = i; break; }
|
|
1041
|
+
}
|
|
1042
|
+
if (tsLine < 0) continue;
|
|
1043
|
+
const match = lines[tsLine]?.match(
|
|
1044
|
+
/(\d{1,2}:)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{1,2}:)?(\d{2}):(\d{2})\.(\d{3})/
|
|
1045
|
+
);
|
|
1046
|
+
if (!match) continue;
|
|
1047
|
+
const startTime =
|
|
1048
|
+
(match[1] ? parseInt(match[1]) * 3600 : 0) +
|
|
1049
|
+
parseInt(match[2] ?? '0') * 60 +
|
|
1050
|
+
parseInt(match[3] ?? '0') +
|
|
1051
|
+
parseInt(match[4] ?? '0') / 1000;
|
|
1052
|
+
const endTime =
|
|
1053
|
+
(match[5] ? parseInt(match[5]) * 3600 : 0) +
|
|
1054
|
+
parseInt(match[6] ?? '0') * 60 +
|
|
1055
|
+
parseInt(match[7] ?? '0') +
|
|
1056
|
+
parseInt(match[8] ?? '0') / 1000;
|
|
1057
|
+
const text = lines.slice(tsLine + 1).join('\n').trim();
|
|
1058
|
+
if (!text) continue;
|
|
1059
|
+
cues.push({
|
|
1060
|
+
cue: { startTime, endTime, text, id: '' } as unknown as TranscriptCue,
|
|
1061
|
+
type: 'caption'
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
return cues;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
private async _loadVttTranscript(lang: string): Promise<TranscriptCueItem[] | null> {
|
|
1068
|
+
const cached = this._vttCache.get(lang);
|
|
1069
|
+
if (cached) return cached;
|
|
1070
|
+
|
|
1071
|
+
const renderer = this.player.renderer as Renderer | null | undefined;
|
|
1072
|
+
if (!renderer?.isStreaming || typeof renderer.getTextTrackURLs !== 'function') return null;
|
|
1073
|
+
|
|
1074
|
+
const urls: { lang: string; url: string }[] = renderer.getTextTrackURLs();
|
|
1075
|
+
const entry = urls.find(u =>
|
|
1076
|
+
u.lang === lang || u.lang.startsWith(lang) || lang.startsWith(u.lang)
|
|
1077
|
+
);
|
|
1078
|
+
if (!entry) return null;
|
|
1079
|
+
|
|
1080
|
+
// Build a combined AbortSignal: the player's lifecycle controller plus
|
|
1081
|
+
// a 10s timeout per individual fetch.
|
|
1082
|
+
const signal = this._buildFetchSignal(10_000);
|
|
1083
|
+
|
|
1084
|
+
try {
|
|
1085
|
+
const res = await fetch(entry.url, { signal });
|
|
1086
|
+
if (!res.ok) return null;
|
|
1087
|
+
let text = await res.text();
|
|
1088
|
+
|
|
1089
|
+
// HLS subtitle playlists are m3u8 files that reference the actual VTT.
|
|
1090
|
+
// Resolve the VTT URI and fetch it instead.
|
|
1091
|
+
if (text.trimStart().startsWith('#EXTM3U')) {
|
|
1092
|
+
const vttUri = text.split('\n')
|
|
1093
|
+
.map(l => l.trim())
|
|
1094
|
+
.find(l => l && !l.startsWith('#'));
|
|
1095
|
+
if (!vttUri) return null;
|
|
1096
|
+
const baseUrl = entry.url.substring(0, entry.url.lastIndexOf('/') + 1);
|
|
1097
|
+
const vttUrl = vttUri.startsWith('http') ? vttUri : new URL(vttUri, baseUrl).href;
|
|
1098
|
+
const vttRes = await fetch(vttUrl, { signal: this._buildFetchSignal(10_000) });
|
|
1099
|
+
if (!vttRes.ok) return null;
|
|
1100
|
+
text = await vttRes.text();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const cues = this._parseVTT(text);
|
|
1104
|
+
if (cues.length > 0) this._vttCache.set(lang, cues);
|
|
1105
|
+
return cues;
|
|
1106
|
+
} catch {
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Build an AbortSignal that fires when either the player is destroyed
|
|
1113
|
+
* or `timeoutMs` elapses, whichever happens first.
|
|
1114
|
+
*/
|
|
1115
|
+
private _buildFetchSignal(timeoutMs: number): AbortSignal | undefined {
|
|
1116
|
+
const signals: AbortSignal[] = [];
|
|
1117
|
+
const lifecycle = (this.player as { lifecycleSignal?: AbortSignal }).lifecycleSignal;
|
|
1118
|
+
if (lifecycle) signals.push(lifecycle);
|
|
1119
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
1120
|
+
signals.push(AbortSignal.timeout(timeoutMs));
|
|
1121
|
+
}
|
|
1122
|
+
if (signals.length === 0) return undefined;
|
|
1123
|
+
if (signals.length === 1) return signals[0];
|
|
1124
|
+
const anyFn = (AbortSignal as { any?: (sigs: AbortSignal[]) => AbortSignal }).any;
|
|
1125
|
+
return anyFn ? anyFn(signals) : signals[0];
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Load transcript data from caption/subtitle tracks
|
|
1130
|
+
*/
|
|
1131
|
+
loadTranscriptData() {
|
|
1132
|
+
this.transcriptEntries = [];
|
|
1133
|
+
this.currentActiveEntry = null;
|
|
1134
|
+
if (this.transcriptContent) {
|
|
1135
|
+
this.transcriptContent.innerHTML = '';
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Get all text tracks
|
|
1139
|
+
const textTracks = this.player.textTracks as TranscriptTrack[];
|
|
1140
|
+
|
|
1141
|
+
// Find track for selected language, or default to first available.
|
|
1142
|
+
// Safari native HLS can expose duplicate TextTrack objects for the same
|
|
1143
|
+
// language (SUBTITLES vs CLOSED-CAPTIONS); prefer the one with cues loaded.
|
|
1144
|
+
let captionTrack: TranscriptTrack | null = null;
|
|
1145
|
+
if (this.currentTranscriptLanguage) {
|
|
1146
|
+
const candidates = textTracks.filter(
|
|
1147
|
+
(track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') &&
|
|
1148
|
+
track.language === this.currentTranscriptLanguage &&
|
|
1149
|
+
!track._vidplyStale
|
|
1150
|
+
);
|
|
1151
|
+
captionTrack = candidates.find((t: TranscriptTrack) => t.cues && t.cues.length > 0) || candidates[0] || null;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (!captionTrack) {
|
|
1155
|
+
const candidates = textTracks.filter(
|
|
1156
|
+
(track: TranscriptTrack) => (track.kind === 'captions' || track.kind === 'subtitles') && !track._vidplyStale
|
|
1157
|
+
);
|
|
1158
|
+
captionTrack = candidates.find((t: TranscriptTrack) => t.cues && t.cues.length > 0) || candidates[0] || null;
|
|
1159
|
+
if (captionTrack) {
|
|
1160
|
+
this.currentTranscriptLanguage = captionTrack.language;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Find description track matching the selected language
|
|
1165
|
+
let descriptionTrack: TranscriptTrack | null = null;
|
|
1166
|
+
if (this.currentTranscriptLanguage) {
|
|
1167
|
+
descriptionTrack = textTracks.find(
|
|
1168
|
+
(track: TranscriptTrack) => track.kind === 'descriptions' && track.language === this.currentTranscriptLanguage
|
|
1169
|
+
) || null;
|
|
1170
|
+
}
|
|
1171
|
+
// Fallback to first available description track if no match found
|
|
1172
|
+
if (!descriptionTrack) {
|
|
1173
|
+
descriptionTrack = textTracks.find((track: TranscriptTrack) => track.kind === 'descriptions') || null;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const metadataTrack = textTracks.find((track: TranscriptTrack) => track.kind === 'metadata');
|
|
1177
|
+
|
|
1178
|
+
// We need at least one track type available for display
|
|
1179
|
+
// (captions, descriptions, or metadata - though metadata is not displayed)
|
|
1180
|
+
if (!captionTrack && !descriptionTrack && !metadataTrack) {
|
|
1181
|
+
this.showNoTranscriptMessage();
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Enable all tracks to load cues so they're available for the transcript
|
|
1186
|
+
const tracksToLoad: TranscriptTrack[] = [captionTrack, descriptionTrack, metadataTrack].filter(
|
|
1187
|
+
(track): track is TranscriptTrack => Boolean(track)
|
|
1188
|
+
);
|
|
1189
|
+
tracksToLoad.forEach((track: TranscriptTrack) => {
|
|
1190
|
+
if (track.mode === 'disabled') {
|
|
1191
|
+
track.mode = 'hidden';
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// For streaming renderers, fetch the complete VTT file directly rather
|
|
1196
|
+
// than relying on dash.js's partial segment-based cues.
|
|
1197
|
+
const renderer = this.player.renderer as Renderer | null | undefined;
|
|
1198
|
+
const isStreaming = renderer?.isStreaming && typeof renderer.getTextTrackURLs === 'function';
|
|
1199
|
+
const lang = this.currentTranscriptLanguage || (captionTrack?.language ?? '');
|
|
1200
|
+
|
|
1201
|
+
if (isStreaming && lang) {
|
|
1202
|
+
const loadingMessage = DOMUtils.createElement('div', {
|
|
1203
|
+
className: `${this.player.options.classPrefix}-transcript-loading`,
|
|
1204
|
+
textContent: i18n.t('transcript.loading')
|
|
1205
|
+
});
|
|
1206
|
+
this.transcriptContent?.appendChild(loadingMessage);
|
|
1207
|
+
|
|
1208
|
+
this._loadVttTranscript(lang).then(vttCues => {
|
|
1209
|
+
if (!this.isVisible) return;
|
|
1210
|
+
this._renderTranscriptCues(
|
|
1211
|
+
vttCues && vttCues.length > 0 ? vttCues : null,
|
|
1212
|
+
captionTrack,
|
|
1213
|
+
descriptionTrack,
|
|
1214
|
+
metadataTrack
|
|
1215
|
+
);
|
|
1216
|
+
});
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Non-streaming path: wait for sidecar <track> elements to load cues.
|
|
1221
|
+
const primaryTrack = captionTrack;
|
|
1222
|
+
const primaryNeedsCues = primaryTrack && (!primaryTrack.cues || primaryTrack.cues.length === 0);
|
|
1223
|
+
|
|
1224
|
+
if (primaryNeedsCues) {
|
|
1225
|
+
const loadingMessage = DOMUtils.createElement('div', {
|
|
1226
|
+
className: `${this.player.options.classPrefix}-transcript-loading`,
|
|
1227
|
+
textContent: i18n.t('transcript.loading')
|
|
1228
|
+
});
|
|
1229
|
+
this.transcriptContent?.appendChild(loadingMessage);
|
|
1230
|
+
|
|
1231
|
+
const hasSidecarElement = this.player.findTrackElement?.(primaryTrack);
|
|
1232
|
+
if (hasSidecarElement) {
|
|
1233
|
+
primaryTrack.addEventListener('load', () => {
|
|
1234
|
+
this.loadTranscriptData();
|
|
1235
|
+
}, { once: true });
|
|
1236
|
+
this.setManagedTimeout(() => { this.loadTranscriptData(); }, 1000);
|
|
1237
|
+
} else {
|
|
1238
|
+
let attempts = 0;
|
|
1239
|
+
const poll = () => {
|
|
1240
|
+
attempts++;
|
|
1241
|
+
if (!this.isVisible) return;
|
|
1242
|
+
if (primaryTrack.cues && primaryTrack.cues.length > 0) {
|
|
1243
|
+
this.loadTranscriptData();
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (attempts < 40) {
|
|
1247
|
+
this.setManagedTimeout(poll, 500);
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
this.setManagedTimeout(poll, 300);
|
|
1251
|
+
}
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
this._renderTranscriptCues(null, captionTrack, descriptionTrack, metadataTrack);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
private _renderTranscriptCues(
|
|
1259
|
+
vttCues: TranscriptCueItem[] | null,
|
|
1260
|
+
captionTrack: TranscriptTrack | null,
|
|
1261
|
+
descriptionTrack: TranscriptTrack | null,
|
|
1262
|
+
metadataTrack: TranscriptTrack | undefined | null
|
|
1263
|
+
) {
|
|
1264
|
+
this.transcriptEntries = [];
|
|
1265
|
+
this.currentActiveEntry = null;
|
|
1266
|
+
const transcriptContent = this.transcriptContent;
|
|
1267
|
+
if (transcriptContent) {
|
|
1268
|
+
transcriptContent.innerHTML = '';
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const allCues: TranscriptCueItem[] = [];
|
|
1272
|
+
|
|
1273
|
+
if (vttCues && vttCues.length > 0) {
|
|
1274
|
+
allCues.push(...vttCues);
|
|
1275
|
+
} else if (captionTrack && captionTrack.cues) {
|
|
1276
|
+
Array.from(captionTrack.cues).forEach((cue: TranscriptCue) => {
|
|
1277
|
+
allCues.push({ cue, type: 'caption' });
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (descriptionTrack && descriptionTrack.cues) {
|
|
1282
|
+
Array.from(descriptionTrack.cues).forEach((cue: TranscriptCue) => {
|
|
1283
|
+
allCues.push({ cue, type: 'description' });
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
if (metadataTrack && metadataTrack.cues) {
|
|
1288
|
+
this.metadataCues = Array.from(metadataTrack.cues) as TranscriptCue[];
|
|
1289
|
+
this.setupMetadataHandling();
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
allCues.sort((a, b) => a.cue.startTime - b.cue.startTime);
|
|
1293
|
+
|
|
1294
|
+
allCues.forEach((item, index) => {
|
|
1295
|
+
const entry = this.createTranscriptEntry(item.cue, index, item.type);
|
|
1296
|
+
this.transcriptEntries.push({
|
|
1297
|
+
element: entry,
|
|
1298
|
+
cue: item.cue,
|
|
1299
|
+
type: item.type,
|
|
1300
|
+
startTime: item.cue.startTime,
|
|
1301
|
+
endTime: item.cue.endTime
|
|
1302
|
+
});
|
|
1303
|
+
transcriptContent?.appendChild(entry);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
this.applyTranscriptStyles();
|
|
1307
|
+
this.updateTimestampVisibility();
|
|
1308
|
+
|
|
1309
|
+
if (this.transcriptContent && this.currentTranscriptLanguage) {
|
|
1310
|
+
this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
this.updateLanguageSelector();
|
|
1314
|
+
this.updateActiveEntry();
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Setup metadata handling on player load
|
|
1319
|
+
* This runs independently of transcript loading
|
|
1320
|
+
*/
|
|
1321
|
+
setupMetadataHandlingOnLoad() {
|
|
1322
|
+
// Wait for metadata to be loaded
|
|
1323
|
+
const setupMetadata = () => {
|
|
1324
|
+
const textTracks = this.player.textTracks;
|
|
1325
|
+
const metadataTrack = (textTracks as TranscriptTrack[]).find((track: TranscriptTrack) => track.kind === 'metadata');
|
|
1326
|
+
|
|
1327
|
+
if (metadataTrack) {
|
|
1328
|
+
// Enable the metadata track so cuechange events fire
|
|
1329
|
+
// Use 'hidden' mode so it doesn't display anything, but events still work
|
|
1330
|
+
if (metadataTrack.mode === 'disabled') {
|
|
1331
|
+
metadataTrack.mode = 'hidden';
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Check if we already added the listener
|
|
1335
|
+
if (this.metadataCueChangeHandler) {
|
|
1336
|
+
metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Add event listener for cue changes
|
|
1340
|
+
this.metadataCueChangeHandler = () => {
|
|
1341
|
+
const activeCues = Array.from(metadataTrack.activeCues || []) as TranscriptCue[];
|
|
1342
|
+
if (activeCues.length > 0) {
|
|
1343
|
+
// Debug logging (can be removed in production)
|
|
1344
|
+
if (this.player.options.debug) {
|
|
1345
|
+
console.log('[VidPly Metadata] Active cues:', activeCues.map((c) => ({
|
|
1346
|
+
start: c.startTime,
|
|
1347
|
+
end: c.endTime,
|
|
1348
|
+
text: (c as VTTCue).text
|
|
1349
|
+
})));
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
activeCues.forEach((cue: TranscriptCue) => {
|
|
1353
|
+
this.handleMetadataCue(cue);
|
|
1354
|
+
});
|
|
1355
|
+
};
|
|
1356
|
+
|
|
1357
|
+
metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
|
|
1358
|
+
|
|
1359
|
+
// Debug: Log metadata track setup
|
|
1360
|
+
if (this.player.options.debug) {
|
|
1361
|
+
const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
|
|
1362
|
+
console.log('[VidPly Metadata] Track enabled,', cueCount, 'cues available');
|
|
1363
|
+
}
|
|
1364
|
+
} else if (this.player.options.debug) {
|
|
1365
|
+
console.warn('[VidPly Metadata] No metadata track found');
|
|
1366
|
+
}
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
// Try immediately
|
|
1370
|
+
setupMetadata();
|
|
1371
|
+
|
|
1372
|
+
// Also try after loadedmetadata event
|
|
1373
|
+
this.player.on('loadedmetadata', setupMetadata);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Setup metadata handling
|
|
1378
|
+
* Metadata cues are not displayed but can be used programmatically
|
|
1379
|
+
* This is called when transcript data is loaded (for storing cues)
|
|
1380
|
+
*/
|
|
1381
|
+
setupMetadataHandling() {
|
|
1382
|
+
if (!this.metadataCues || this.metadataCues.length === 0) {
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// The actual event handling is set up in setupMetadataHandlingOnLoad()
|
|
1387
|
+
// This method just stores the cues for reference
|
|
1388
|
+
if (this.player.options.debug) {
|
|
1389
|
+
console.log('[VidPly Metadata]', this.metadataCues.length, 'cues stored from transcript load');
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Handle individual metadata cues
|
|
1395
|
+
* Parses metadata text and emits events or triggers actions
|
|
1396
|
+
*/
|
|
1397
|
+
handleMetadataCue(cue: TranscriptCue) {
|
|
1398
|
+
const cueText = (cue as TextTrackCue & { text?: string }).text || '';
|
|
1399
|
+
const text = cueText.trim();
|
|
1400
|
+
|
|
1401
|
+
// Debug logging
|
|
1402
|
+
if (this.player.options.debug) {
|
|
1403
|
+
console.log('[VidPly Metadata] Processing cue:', {
|
|
1404
|
+
time: cue.startTime,
|
|
1405
|
+
text: text
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Emit a generic metadata event that developers can listen to
|
|
1410
|
+
this.player.emit('metadata', {
|
|
1411
|
+
time: cue.startTime,
|
|
1412
|
+
endTime: cue.endTime,
|
|
1413
|
+
text: text,
|
|
1414
|
+
cue: cue
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
// Parse for specific commands (examples based on wwa_meta.vtt format)
|
|
1418
|
+
if (text.includes('PAUSE')) {
|
|
1419
|
+
// Automatically pause the video
|
|
1420
|
+
if (!this.player.state.paused) {
|
|
1421
|
+
if (this.player.options.debug) {
|
|
1422
|
+
console.log('[VidPly Metadata] Pausing video at', cue.startTime);
|
|
1423
|
+
}
|
|
1424
|
+
this.player.pause();
|
|
1425
|
+
}
|
|
1426
|
+
// Also emit event for developers who want to listen
|
|
1427
|
+
this.player.emit('metadata:pause', { time: cue.startTime, text: text });
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Parse for focus directives
|
|
1431
|
+
const focusMatch = text.match(/FOCUS:([\w#-]+)/);
|
|
1432
|
+
if (focusMatch && focusMatch[1]) {
|
|
1433
|
+
const targetSelector = focusMatch[1];
|
|
1434
|
+
// Automatically focus the target element
|
|
1435
|
+
const targetElement = document.querySelector(targetSelector) as HTMLElement | null;
|
|
1436
|
+
if (targetElement) {
|
|
1437
|
+
if (this.player.options.debug) {
|
|
1438
|
+
console.log('[VidPly Metadata] Focusing element:', targetSelector);
|
|
1439
|
+
}
|
|
1440
|
+
// Use setTimeout to ensure DOM is ready
|
|
1441
|
+
this.setManagedTimeout(() => {
|
|
1442
|
+
targetElement.focus({ preventScroll: true });
|
|
1443
|
+
}, 10);
|
|
1444
|
+
} else if (this.player.options.debug) {
|
|
1445
|
+
console.warn('[VidPly Metadata] Element not found:', targetSelector);
|
|
1446
|
+
}
|
|
1447
|
+
// Also emit event for developers who want to listen
|
|
1448
|
+
this.player.emit('metadata:focus', {
|
|
1449
|
+
time: cue.startTime,
|
|
1450
|
+
target: targetSelector,
|
|
1451
|
+
element: targetElement,
|
|
1452
|
+
text: text
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Parse for hashtag references
|
|
1457
|
+
const hashtags = text.match(/#[\w-]+/g);
|
|
1458
|
+
if (hashtags) {
|
|
1459
|
+
if (this.player.options.debug) {
|
|
1460
|
+
console.log('[VidPly Metadata] Hashtags found:', hashtags);
|
|
1461
|
+
}
|
|
1462
|
+
this.player.emit('metadata:hashtags', {
|
|
1463
|
+
time: cue.startTime,
|
|
1464
|
+
hashtags: hashtags,
|
|
1465
|
+
text: text
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Create a single transcript entry element
|
|
1472
|
+
*/
|
|
1473
|
+
createTranscriptEntry(cue: TranscriptCue, index: number, type: 'caption' | 'description' = 'caption') {
|
|
1474
|
+
const entryText = this.stripVTTFormatting(((cue as TextTrackCue & { text?: string }).text) || '');
|
|
1475
|
+
|
|
1476
|
+
// Use a real <button> so screen readers/AT report "button", Enter and
|
|
1477
|
+
// Space activate it natively, and forms-mode users can find it.
|
|
1478
|
+
const seekLabelTemplate = i18n.t('transcript.seekTo') || 'Seek to {time}';
|
|
1479
|
+
const ariaLabel = seekLabelTemplate
|
|
1480
|
+
.replace('{time}', TimeUtils.formatTime(cue.startTime))
|
|
1481
|
+
.replace('{text}', entryText);
|
|
1482
|
+
|
|
1483
|
+
const entry = DOMUtils.createElement('button', {
|
|
1484
|
+
className: `${this.player.options.classPrefix}-transcript-entry ${this.player.options.classPrefix}-transcript-${type}`,
|
|
1485
|
+
attributes: {
|
|
1486
|
+
'type': 'button',
|
|
1487
|
+
'data-start': String(cue.startTime),
|
|
1488
|
+
'data-end': String(cue.endTime),
|
|
1489
|
+
'data-type': type,
|
|
1490
|
+
'aria-label': `${ariaLabel} — ${entryText}`
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
const timestamp = DOMUtils.createElement('span', {
|
|
1495
|
+
className: `${this.player.options.classPrefix}-transcript-time`,
|
|
1496
|
+
textContent: TimeUtils.formatTime(cue.startTime),
|
|
1497
|
+
attributes: {
|
|
1498
|
+
'aria-hidden': 'true' // Hide from screen readers - decorative timestamp
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
const text = DOMUtils.createElement('span', {
|
|
1503
|
+
className: `${this.player.options.classPrefix}-transcript-text`,
|
|
1504
|
+
textContent: entryText
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
entry.appendChild(timestamp);
|
|
1508
|
+
entry.appendChild(text);
|
|
1509
|
+
|
|
1510
|
+
// Click to seek. <button> elements activate on Enter/Space natively,
|
|
1511
|
+
// so a custom keydown handler is no longer needed.
|
|
1512
|
+
const seekToTime = () => {
|
|
1513
|
+
this.player.seek(cue.startTime);
|
|
1514
|
+
if (this.player.state.paused) {
|
|
1515
|
+
this.player.play();
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
entry.addEventListener('click', seekToTime);
|
|
1520
|
+
|
|
1521
|
+
return entry;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Strip VTT formatting tags from text
|
|
1526
|
+
*/
|
|
1527
|
+
stripVTTFormatting(text: string) {
|
|
1528
|
+
// Remove VTT tags like <v Speaker>, <c>, etc.
|
|
1529
|
+
return text
|
|
1530
|
+
.replace(/<[^>]+>/g, '')
|
|
1531
|
+
.replace(/\n/g, ' ')
|
|
1532
|
+
.trim();
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Show message when no transcript is available
|
|
1537
|
+
*/
|
|
1538
|
+
showNoTranscriptMessage() {
|
|
1539
|
+
const message = DOMUtils.createElement('div', {
|
|
1540
|
+
className: `${this.player.options.classPrefix}-transcript-empty`,
|
|
1541
|
+
textContent: i18n.t('transcript.noTranscript')
|
|
1542
|
+
});
|
|
1543
|
+
this.transcriptContent?.appendChild(message);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Update active transcript entry based on current time
|
|
1548
|
+
*/
|
|
1549
|
+
updateActiveEntry() {
|
|
1550
|
+
if (!this.isVisible || this.transcriptEntries.length === 0) return;
|
|
1551
|
+
|
|
1552
|
+
const currentTime = this.player.state.currentTime;
|
|
1553
|
+
|
|
1554
|
+
// Find the entry that matches current time
|
|
1555
|
+
const activeEntry = this.transcriptEntries.find(
|
|
1556
|
+
(entry: TranscriptEntry) => currentTime >= entry.startTime && currentTime < entry.endTime
|
|
1557
|
+
);
|
|
1558
|
+
|
|
1559
|
+
if (activeEntry && activeEntry !== this.currentActiveEntry) {
|
|
1560
|
+
// Remove previous active class
|
|
1561
|
+
if (this.currentActiveEntry) {
|
|
1562
|
+
this.currentActiveEntry.element.classList.remove(
|
|
1563
|
+
`${this.player.options.classPrefix}-transcript-entry-active`
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// Add active class to current entry
|
|
1568
|
+
activeEntry.element.classList.add(
|
|
1569
|
+
`${this.player.options.classPrefix}-transcript-entry-active`
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
// Scroll to active entry
|
|
1573
|
+
this.scrollToEntry(activeEntry.element);
|
|
1574
|
+
|
|
1575
|
+
this.currentActiveEntry = activeEntry;
|
|
1576
|
+
} else if (!activeEntry && this.currentActiveEntry) {
|
|
1577
|
+
// No active entry, remove active class
|
|
1578
|
+
this.currentActiveEntry.element.classList.remove(
|
|
1579
|
+
`${this.player.options.classPrefix}-transcript-entry-active`
|
|
1580
|
+
);
|
|
1581
|
+
this.currentActiveEntry = null;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* Scroll transcript window to show active entry
|
|
1587
|
+
*/
|
|
1588
|
+
scrollToEntry(entryElement: HTMLElement) {
|
|
1589
|
+
if (!this.transcriptContent || !this.autoscrollEnabled) return;
|
|
1590
|
+
|
|
1591
|
+
const contentRect = this.transcriptContent.getBoundingClientRect();
|
|
1592
|
+
const entryRect = entryElement.getBoundingClientRect();
|
|
1593
|
+
|
|
1594
|
+
// Check if entry is out of view
|
|
1595
|
+
if (entryRect.top < contentRect.top || entryRect.bottom > contentRect.bottom) {
|
|
1596
|
+
// Scroll to center the entry
|
|
1597
|
+
const scrollTop = entryElement.offsetTop - (this.transcriptContent.clientHeight / 2) + (entryElement.clientHeight / 2);
|
|
1598
|
+
this.transcriptContent.scrollTo({
|
|
1599
|
+
top: scrollTop,
|
|
1600
|
+
behavior: 'smooth'
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Save autoscroll preference to localStorage
|
|
1607
|
+
*/
|
|
1608
|
+
saveAutoscrollPreference() {
|
|
1609
|
+
const savedPreferences = this.storage.getTranscriptPreferences() || {};
|
|
1610
|
+
savedPreferences.autoscroll = this.autoscrollEnabled;
|
|
1611
|
+
this.storage.saveTranscriptPreferences(savedPreferences);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Setup drag and drop functionality
|
|
1616
|
+
*/
|
|
1617
|
+
setupDragAndDrop() {
|
|
1618
|
+
if (!this.transcriptHeader || !this.transcriptWindow) return;
|
|
1619
|
+
|
|
1620
|
+
// Check if we're on mobile and not in fullscreen
|
|
1621
|
+
const isMobile = window.innerWidth < 768;
|
|
1622
|
+
const isFullscreen = this.player.state.fullscreen;
|
|
1623
|
+
|
|
1624
|
+
// On mobile devices (< 768px), only enable drag/resize in fullscreen
|
|
1625
|
+
// On desktop/tablets (>= 768px), always enable drag/resize
|
|
1626
|
+
if (isMobile && !isFullscreen) {
|
|
1627
|
+
// Destroy existing instance if exiting fullscreen on mobile
|
|
1628
|
+
if (this.draggableResizable) {
|
|
1629
|
+
this.draggableResizable.destroy();
|
|
1630
|
+
this.draggableResizable = null;
|
|
1631
|
+
}
|
|
1632
|
+
return; // No drag/resize on mobile when not in fullscreen
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// If already initialized, don't re-initialize
|
|
1636
|
+
if (this.draggableResizable) {
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Create DraggableResizable utility with touch support
|
|
1641
|
+
this.draggableResizable = new DraggableResizable(this.transcriptWindow, {
|
|
1642
|
+
dragHandle: this.transcriptHeader,
|
|
1643
|
+
resizeHandles: this.transcriptResizeHandles,
|
|
1644
|
+
constrainToViewport: true,
|
|
1645
|
+
classPrefix: `${this.player.options.classPrefix}-transcript`,
|
|
1646
|
+
keyboardDragKey: 'd',
|
|
1647
|
+
keyboardResizeKey: 'r',
|
|
1648
|
+
keyboardStep: 10,
|
|
1649
|
+
keyboardStepLarge: 50,
|
|
1650
|
+
minWidth: 300,
|
|
1651
|
+
minHeight: 200,
|
|
1652
|
+
maxWidth: () => Math.max(320, window.innerWidth - 40),
|
|
1653
|
+
maxHeight: () => Math.max(200, window.innerHeight - 120),
|
|
1654
|
+
pointerResizeIndicatorText: i18n.t('transcript.resizeModeHint'),
|
|
1655
|
+
onPointerResizeToggle: (enabled) => {
|
|
1656
|
+
// Update resize handles visibility
|
|
1657
|
+
this.transcriptResizeHandles.forEach((handle: HTMLElement) => {
|
|
1658
|
+
handle.style.display = enabled ? 'block' : 'none';
|
|
1659
|
+
});
|
|
1660
|
+
// Call the state change handler
|
|
1661
|
+
this.onPointerResizeModeChange(enabled);
|
|
1662
|
+
},
|
|
1663
|
+
onDragStart: (e: Event) => {
|
|
1664
|
+
// Don't drag if clicking on certain elements
|
|
1665
|
+
const ignoreSelectors = [
|
|
1666
|
+
`.${this.player.options.classPrefix}-transcript-close`,
|
|
1667
|
+
`.${this.player.options.classPrefix}-transcript-settings`,
|
|
1668
|
+
`.${this.player.options.classPrefix}-transcript-language-select`,
|
|
1669
|
+
`.${this.player.options.classPrefix}-transcript-language-label`,
|
|
1670
|
+
`.${this.player.options.classPrefix}-transcript-settings-menu`,
|
|
1671
|
+
`.${this.player.options.classPrefix}-transcript-style-dialog`
|
|
1672
|
+
];
|
|
1673
|
+
|
|
1674
|
+
for (const selector of ignoreSelectors) {
|
|
1675
|
+
if ((e.target as HTMLElement).closest(selector)) {
|
|
1676
|
+
return false; // Prevent drag
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
return true; // Allow drag
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
// Add custom keyboard handler for special keys (Escape, Home)
|
|
1685
|
+
this.customKeyHandler = (e: KeyboardEvent) => {
|
|
1686
|
+
const key = e.key.toLowerCase();
|
|
1687
|
+
const alreadyPrevented = e.defaultPrevented;
|
|
1688
|
+
|
|
1689
|
+
// Don't handle keys if settings menu or style dialog is open (let them handle keys)
|
|
1690
|
+
if (this.settingsMenuVisible || this.styleDialogVisible) {
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
if (key === 'home') {
|
|
1695
|
+
e.preventDefault();
|
|
1696
|
+
e.stopPropagation();
|
|
1697
|
+
if (this.draggableResizable) {
|
|
1698
|
+
if (this.draggableResizable.pointerResizeMode) {
|
|
1699
|
+
this.draggableResizable.disablePointerResizeMode();
|
|
1700
|
+
}
|
|
1701
|
+
this.draggableResizable.manuallyPositioned = false;
|
|
1702
|
+
this.positionTranscript();
|
|
1703
|
+
this.updateResizeOptionState();
|
|
1704
|
+
this.announceLive(i18n.t('transcript.positionReset'));
|
|
1705
|
+
}
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
if (key === 'r') {
|
|
1710
|
+
if (alreadyPrevented) {
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
e.preventDefault();
|
|
1714
|
+
e.stopPropagation();
|
|
1715
|
+
const enabled = this.toggleResizeMode();
|
|
1716
|
+
if (enabled) {
|
|
1717
|
+
this.transcriptWindow?.focus({ preventScroll: true });
|
|
1718
|
+
}
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
if (key === 'escape') {
|
|
1723
|
+
// Check priority: resize mode > drag mode > close transcript
|
|
1724
|
+
// (settings menu and style dialog already handled by early return above)
|
|
1725
|
+
if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
|
|
1726
|
+
e.preventDefault();
|
|
1727
|
+
e.stopPropagation();
|
|
1728
|
+
this.draggableResizable.disablePointerResizeMode();
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
if (this.draggableResizable && this.draggableResizable.keyboardDragMode) {
|
|
1732
|
+
e.preventDefault();
|
|
1733
|
+
e.stopPropagation();
|
|
1734
|
+
this.draggableResizable.disableKeyboardDragMode();
|
|
1735
|
+
this.announceLive(i18n.t('transcript.dragModeDisabled'));
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
// Only close transcript if nothing else is open
|
|
1739
|
+
e.preventDefault();
|
|
1740
|
+
e.stopPropagation();
|
|
1741
|
+
this.hideTranscript({ focusButton: true });
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
};
|
|
1745
|
+
|
|
1746
|
+
const customKeyHandler = this.customKeyHandler;
|
|
1747
|
+
if (this.transcriptWindow && customKeyHandler) {
|
|
1748
|
+
this.transcriptWindow.addEventListener('keydown', customKeyHandler, {
|
|
1749
|
+
signal: this.player.lifecycleSignal
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
/**
|
|
1756
|
+
* Toggle keyboard drag mode. Mirrors the sign-language flow: a
|
|
1757
|
+
* persistent badge is shown on the transcript window while the mode
|
|
1758
|
+
* is active, and a live-region announcement is made on each state
|
|
1759
|
+
* change.
|
|
1760
|
+
*/
|
|
1761
|
+
toggleKeyboardDragMode() {
|
|
1762
|
+
if (this.draggableResizable) {
|
|
1763
|
+
const wasEnabled = this.draggableResizable.keyboardDragMode;
|
|
1764
|
+
this.draggableResizable.toggleKeyboardDragMode();
|
|
1765
|
+
const isEnabled = this.draggableResizable.keyboardDragMode;
|
|
1766
|
+
if (!wasEnabled && isEnabled) {
|
|
1767
|
+
this.enableMoveMode();
|
|
1768
|
+
this._panel?.showBadge(i18n.t('transcript.dragModeBadge'));
|
|
1769
|
+
this.announceLive(i18n.t('transcript.dragModeEnabled'));
|
|
1770
|
+
} else if (wasEnabled && !isEnabled) {
|
|
1771
|
+
this._panel?.hideBadge();
|
|
1772
|
+
this.announceLive(i18n.t('transcript.dragModeDisabled'));
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
this.updateDragOptionState();
|
|
1776
|
+
|
|
1777
|
+
if (this.settingsMenuVisible) {
|
|
1778
|
+
this.hideSettingsMenu();
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
this.transcriptWindow?.focus({ preventScroll: true });
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* Toggle settings menu visibility
|
|
1787
|
+
*/
|
|
1788
|
+
toggleSettingsMenu() {
|
|
1789
|
+
if (this.settingsMenuVisible) {
|
|
1790
|
+
this.hideSettingsMenu();
|
|
1791
|
+
} else {
|
|
1792
|
+
this.showSettingsMenu();
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* Show the settings menu. Delegates to the shared {@link DraggablePanel};
|
|
1798
|
+
* kept as a named method so external callers (tests, other managers)
|
|
1799
|
+
* that referenced the legacy API keep working.
|
|
1800
|
+
*/
|
|
1801
|
+
showSettingsMenu() {
|
|
1802
|
+
this._panel?.show();
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/** @see {@link showSettingsMenu} */
|
|
1806
|
+
positionSettingsMenu() {
|
|
1807
|
+
this._panel?.reposition();
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/** @see {@link showSettingsMenu} */
|
|
1811
|
+
hideSettingsMenu({ focusButton = true } = {}) {
|
|
1812
|
+
this._panel?.hide({ focusButton });
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Enable move mode (gives visual feedback)
|
|
1817
|
+
*/
|
|
1818
|
+
/**
|
|
1819
|
+
* Brief pulse animation on the transcript window to confirm entry
|
|
1820
|
+
* into keyboard drag mode. The textual hint that used to also flash
|
|
1821
|
+
* here has been replaced by a persistent {@link DraggablePanel}
|
|
1822
|
+
* badge (see `toggleKeyboardDragMode`), so this method only owns
|
|
1823
|
+
* the 1s visual cue now.
|
|
1824
|
+
*/
|
|
1825
|
+
enableMoveMode() {
|
|
1826
|
+
this.transcriptWindow?.classList.add(
|
|
1827
|
+
`${this.player.options.classPrefix}-transcript-move-mode`
|
|
1828
|
+
);
|
|
1829
|
+
|
|
1830
|
+
setTimeout(() => {
|
|
1831
|
+
this.transcriptWindow?.classList.remove(
|
|
1832
|
+
`${this.player.options.classPrefix}-transcript-move-mode`
|
|
1833
|
+
);
|
|
1834
|
+
}, 2000);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* Toggle resize mode
|
|
1839
|
+
*/
|
|
1840
|
+
toggleResizeMode({ focus = true } = {}) {
|
|
1841
|
+
if (!this.draggableResizable) {
|
|
1842
|
+
return false;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if (this.draggableResizable.pointerResizeMode) {
|
|
1846
|
+
this.draggableResizable.disablePointerResizeMode({ focus });
|
|
1847
|
+
return false;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
this.draggableResizable.enablePointerResizeMode({ focus });
|
|
1851
|
+
return true;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// Thin delegates to the panel's refreshState. Kept as named methods
|
|
1855
|
+
// so the existing internal call sites (e.g. `toggleKeyboardDragMode`
|
|
1856
|
+
// and `toggleResizeMode`) read naturally without having to chain
|
|
1857
|
+
// through the optional panel reference every time.
|
|
1858
|
+
updateDragOptionState() {
|
|
1859
|
+
this._panel?.refreshDragState();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
updateResizeOptionState() {
|
|
1863
|
+
this._panel?.refreshResizeState();
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
toggleShowTimestamps() {
|
|
1867
|
+
this.showTimestamps = !this.showTimestamps;
|
|
1868
|
+
this.updateShowTimestampsState();
|
|
1869
|
+
this.updateTimestampVisibility();
|
|
1870
|
+
this.saveTimestampsPreference();
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
updateShowTimestampsState() {
|
|
1874
|
+
if (!this.showTimestampsButton) {
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const text = this.showTimestamps
|
|
1879
|
+
? i18n.t('transcript.hideTimestamps')
|
|
1880
|
+
: i18n.t('transcript.showTimestamps');
|
|
1881
|
+
const ariaLabel = this.showTimestamps
|
|
1882
|
+
? i18n.t('transcript.hideTimestampsAria')
|
|
1883
|
+
: i18n.t('transcript.showTimestampsAria');
|
|
1884
|
+
|
|
1885
|
+
this.showTimestampsButton.setAttribute('aria-checked', this.showTimestamps ? 'true' : 'false');
|
|
1886
|
+
this.showTimestampsButton.setAttribute('aria-label', ariaLabel);
|
|
1887
|
+
|
|
1888
|
+
if (this.showTimestampsText) {
|
|
1889
|
+
this.showTimestampsText.textContent = text;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
updateTimestampVisibility() {
|
|
1894
|
+
if (!this.transcriptContent) return;
|
|
1895
|
+
|
|
1896
|
+
const timestamps = this.transcriptContent.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-time`);
|
|
1897
|
+
timestamps.forEach((timestamp) => {
|
|
1898
|
+
timestamp.style.display = this.showTimestamps ? '' : 'none';
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
saveTimestampsPreference() {
|
|
1903
|
+
const savedPreferences = this.storage.getTranscriptPreferences() || {};
|
|
1904
|
+
savedPreferences.showTimestamps = this.showTimestamps;
|
|
1905
|
+
this.storage.saveTranscriptPreferences(savedPreferences);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// Legacy shims kept for any external callers that still invoke the
|
|
1909
|
+
// old transient-tooltip API. The persistent badge owned by the
|
|
1910
|
+
// {@link DraggablePanel} now replaces the 3-second indicator, so
|
|
1911
|
+
// these simply forward to the panel. Safe to remove once the next
|
|
1912
|
+
// consumer sweep confirms no external references.
|
|
1913
|
+
showResizeModeIndicator() {
|
|
1914
|
+
this._panel?.showBadge(i18n.t('transcript.resizeModeBadge'));
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
hideResizeModeIndicator() {
|
|
1918
|
+
this._panel?.hideBadge();
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
onPointerResizeModeChange(enabled: boolean) {
|
|
1922
|
+
this.updateResizeOptionState();
|
|
1923
|
+
|
|
1924
|
+
if (enabled) {
|
|
1925
|
+
this._panel?.showBadge(i18n.t('transcript.resizeModeBadge'));
|
|
1926
|
+
this.announceLive(i18n.t('transcript.resizeModeEnabled'));
|
|
1927
|
+
} else {
|
|
1928
|
+
this._panel?.hideBadge();
|
|
1929
|
+
this.announceLive(i18n.t('transcript.resizeModeDisabled'));
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* Show style dialog
|
|
1935
|
+
*/
|
|
1936
|
+
showStyleDialog() {
|
|
1937
|
+
// The style dialog uses an outside-click listener to dismiss
|
|
1938
|
+
// itself. Previously that listener was armed the first time the
|
|
1939
|
+
// settings menu opened (the same handler also dismissed the
|
|
1940
|
+
// settings menu). Now that the settings menu owns its own
|
|
1941
|
+
// outside-click, we arm the style-dialog listener here on first
|
|
1942
|
+
// open. Tied to `lifecycleSignal` so teardown is automatic.
|
|
1943
|
+
if (!this.documentClickHandlerAdded) {
|
|
1944
|
+
setTimeout(() => {
|
|
1945
|
+
const documentClick = this.handlers.documentClick;
|
|
1946
|
+
if (documentClick) {
|
|
1947
|
+
document.addEventListener('click', documentClick, {
|
|
1948
|
+
signal: this.player.lifecycleSignal
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
this.documentClickHandlerAdded = true;
|
|
1952
|
+
}, 300);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
// If dialog already exists, just show it
|
|
1956
|
+
if (this.styleDialog) {
|
|
1957
|
+
this.styleDialog.style.display = 'block';
|
|
1958
|
+
this.styleDialogVisible = true;
|
|
1959
|
+
|
|
1960
|
+
// Re-add keyboard handler
|
|
1961
|
+
if (this.handlers.styleDialogKeydown) {
|
|
1962
|
+
document.addEventListener('keydown', this.handlers.styleDialogKeydown, {
|
|
1963
|
+
signal: this.player.lifecycleSignal
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// Set flag to prevent immediate closing from document click
|
|
1968
|
+
this.styleDialogJustOpened = true;
|
|
1969
|
+
setTimeout(() => {
|
|
1970
|
+
this.styleDialogJustOpened = false;
|
|
1971
|
+
}, 350);
|
|
1972
|
+
|
|
1973
|
+
setTimeout(() => {
|
|
1974
|
+
const dialog = this.styleDialog;
|
|
1975
|
+
if (!dialog) return;
|
|
1976
|
+
const firstSelect = dialog.querySelector<HTMLElement>('select, input');
|
|
1977
|
+
if (firstSelect) {
|
|
1978
|
+
firstSelect.focus({ preventScroll: true });
|
|
1979
|
+
}
|
|
1980
|
+
}, 0);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
const styleDialog = DOMUtils.createElement('div', {
|
|
1985
|
+
className: `${this.player.options.classPrefix}-transcript-style-dialog`
|
|
1986
|
+
});
|
|
1987
|
+
this.styleDialog = styleDialog;
|
|
1988
|
+
|
|
1989
|
+
const title = DOMUtils.createElement('h4', {
|
|
1990
|
+
textContent: i18n.t('transcript.styleTitle'),
|
|
1991
|
+
className: `${this.player.options.classPrefix}-transcript-style-title`
|
|
1992
|
+
});
|
|
1993
|
+
styleDialog.appendChild(title);
|
|
1994
|
+
|
|
1995
|
+
const fontSizeControl = this.createStyleSelectControl(
|
|
1996
|
+
i18n.t('captions.fontSize'),
|
|
1997
|
+
'fontSize',
|
|
1998
|
+
[
|
|
1999
|
+
{ label: i18n.t('fontSizes.small'), value: '90%' },
|
|
2000
|
+
{ label: i18n.t('fontSizes.normal'), value: '100%' },
|
|
2001
|
+
{ label: i18n.t('fontSizes.large'), value: '110%' },
|
|
2002
|
+
{ label: i18n.t('fontSizes.xlarge'), value: '120%' }
|
|
2003
|
+
]
|
|
2004
|
+
);
|
|
2005
|
+
styleDialog.appendChild(fontSizeControl);
|
|
2006
|
+
|
|
2007
|
+
const fontFamilyControl = this.createStyleSelectControl(
|
|
2008
|
+
i18n.t('captions.fontFamily'),
|
|
2009
|
+
'fontFamily',
|
|
2010
|
+
[
|
|
2011
|
+
{ label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif' },
|
|
2012
|
+
{ label: i18n.t('fontFamilies.serif'), value: 'serif' },
|
|
2013
|
+
{ label: i18n.t('fontFamilies.monospace'), value: 'monospace' }
|
|
2014
|
+
]
|
|
2015
|
+
);
|
|
2016
|
+
styleDialog.appendChild(fontFamilyControl);
|
|
2017
|
+
|
|
2018
|
+
const colorControl = this.createStyleColorControl(i18n.t('captions.color'), 'color');
|
|
2019
|
+
styleDialog.appendChild(colorControl);
|
|
2020
|
+
|
|
2021
|
+
const bgColorControl = this.createStyleColorControl(i18n.t('captions.backgroundColor'), 'backgroundColor');
|
|
2022
|
+
styleDialog.appendChild(bgColorControl);
|
|
2023
|
+
|
|
2024
|
+
const opacityControl = this.createStyleOpacityControl(i18n.t('captions.opacity'), 'opacity');
|
|
2025
|
+
styleDialog.appendChild(opacityControl);
|
|
2026
|
+
|
|
2027
|
+
const closeBtn = DOMUtils.createElement('button', {
|
|
2028
|
+
className: `${this.player.options.classPrefix}-transcript-style-close`,
|
|
2029
|
+
textContent: i18n.t('settings.close'),
|
|
2030
|
+
attributes: {
|
|
2031
|
+
'type': 'button'
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
closeBtn.addEventListener('click', () => this.hideStyleDialog());
|
|
2035
|
+
styleDialog.appendChild(closeBtn);
|
|
2036
|
+
|
|
2037
|
+
const styleKeyHandler = (e: KeyboardEvent) => {
|
|
2038
|
+
if (!this.styleDialogVisible) return;
|
|
2039
|
+
|
|
2040
|
+
if (e.key === 'Escape') {
|
|
2041
|
+
e.preventDefault();
|
|
2042
|
+
e.stopPropagation();
|
|
2043
|
+
this.hideStyleDialog();
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
if (e.key === 'Tab') {
|
|
2048
|
+
const focusableElements = styleDialog.querySelectorAll<HTMLElement>(
|
|
2049
|
+
'select, input, button'
|
|
2050
|
+
);
|
|
2051
|
+
const firstElement = focusableElements[0];
|
|
2052
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
2053
|
+
if (!firstElement || !lastElement) return;
|
|
2054
|
+
|
|
2055
|
+
if (e.shiftKey && document.activeElement === firstElement) {
|
|
2056
|
+
e.preventDefault();
|
|
2057
|
+
lastElement.focus({ preventScroll: true });
|
|
2058
|
+
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
|
2059
|
+
e.preventDefault();
|
|
2060
|
+
firstElement.focus({ preventScroll: true });
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
this.handlers.styleDialogKeydown = styleKeyHandler;
|
|
2065
|
+
document.addEventListener('keydown', styleKeyHandler, {
|
|
2066
|
+
signal: this.player.lifecycleSignal
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
if (this.headerLeft) {
|
|
2070
|
+
this.headerLeft.appendChild(styleDialog);
|
|
2071
|
+
} else if (this.transcriptHeader) {
|
|
2072
|
+
this.transcriptHeader.appendChild(styleDialog);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
this.applyTranscriptStyles();
|
|
2076
|
+
|
|
2077
|
+
this.styleDialogVisible = true;
|
|
2078
|
+
styleDialog.style.display = 'block';
|
|
2079
|
+
|
|
2080
|
+
this.styleDialogJustOpened = true;
|
|
2081
|
+
setTimeout(() => {
|
|
2082
|
+
this.styleDialogJustOpened = false;
|
|
2083
|
+
}, 350);
|
|
2084
|
+
|
|
2085
|
+
setTimeout(() => {
|
|
2086
|
+
const firstSelect = styleDialog.querySelector<HTMLElement>('select, input');
|
|
2087
|
+
if (firstSelect) {
|
|
2088
|
+
firstSelect.focus({ preventScroll: true });
|
|
2089
|
+
}
|
|
2090
|
+
}, 0);
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
/**
|
|
2094
|
+
* Hide style dialog
|
|
2095
|
+
*/
|
|
2096
|
+
hideStyleDialog() {
|
|
2097
|
+
if (this.styleDialog) {
|
|
2098
|
+
this.styleDialog.style.display = 'none';
|
|
2099
|
+
this.styleDialogVisible = false;
|
|
2100
|
+
|
|
2101
|
+
// Remove keyboard handler
|
|
2102
|
+
if (this.handlers.styleDialogKeydown) {
|
|
2103
|
+
document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Return focus to settings button
|
|
2107
|
+
if (this.settingsButton) {
|
|
2108
|
+
this.settingsButton.focus({ preventScroll: true });
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
/**
|
|
2114
|
+
* Create style select control
|
|
2115
|
+
*/
|
|
2116
|
+
createStyleSelectControl(label: string, property: string, options: Array<{ label: string; value: string }>) {
|
|
2117
|
+
const group = DOMUtils.createElement('div', {
|
|
2118
|
+
className: `${this.player.options.classPrefix}-transcript-style-group`
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
// Generate unique ID for the control
|
|
2122
|
+
const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
|
|
2123
|
+
|
|
2124
|
+
const labelEl = DOMUtils.createElement('label', {
|
|
2125
|
+
textContent: label,
|
|
2126
|
+
attributes: {
|
|
2127
|
+
'for': controlId
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2130
|
+
group.appendChild(labelEl);
|
|
2131
|
+
|
|
2132
|
+
const select = DOMUtils.createElement('select', {
|
|
2133
|
+
className: `${this.player.options.classPrefix}-transcript-style-select`,
|
|
2134
|
+
attributes: {
|
|
2135
|
+
'id': controlId
|
|
2136
|
+
}
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
options.forEach((opt: { label: string; value: string }) => {
|
|
2140
|
+
const option = DOMUtils.createElement('option', {
|
|
2141
|
+
textContent: opt.label,
|
|
2142
|
+
attributes: {
|
|
2143
|
+
'value': opt.value
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
if (this.transcriptStyle[property as 'fontSize' | 'fontFamily'] === opt.value) {
|
|
2147
|
+
option.selected = true;
|
|
2148
|
+
}
|
|
2149
|
+
select.appendChild(option);
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
select.addEventListener('change', (e) => {
|
|
2153
|
+
this.transcriptStyle[property as 'fontSize' | 'fontFamily'] = (e.target as HTMLSelectElement).value;
|
|
2154
|
+
this.applyTranscriptStyles();
|
|
2155
|
+
this.savePreferences();
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
group.appendChild(select);
|
|
2159
|
+
return group;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* Create style color control
|
|
2164
|
+
*/
|
|
2165
|
+
createStyleColorControl(label: string, property: string) {
|
|
2166
|
+
const group = DOMUtils.createElement('div', {
|
|
2167
|
+
className: `${this.player.options.classPrefix}-transcript-style-group`
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
// Generate unique ID for the control
|
|
2171
|
+
const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
|
|
2172
|
+
|
|
2173
|
+
const labelEl = DOMUtils.createElement('label', {
|
|
2174
|
+
textContent: label,
|
|
2175
|
+
attributes: {
|
|
2176
|
+
'for': controlId
|
|
2177
|
+
}
|
|
2178
|
+
});
|
|
2179
|
+
group.appendChild(labelEl);
|
|
2180
|
+
|
|
2181
|
+
const input = DOMUtils.createElement('input', {
|
|
2182
|
+
attributes: {
|
|
2183
|
+
'id': controlId,
|
|
2184
|
+
'type': 'color',
|
|
2185
|
+
'value': this.transcriptStyle[property as 'color' | 'backgroundColor']
|
|
2186
|
+
},
|
|
2187
|
+
className: `${this.player.options.classPrefix}-transcript-style-color`
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
input.addEventListener('input', (e) => {
|
|
2191
|
+
this.transcriptStyle[property as 'color' | 'backgroundColor'] = (e.target as HTMLInputElement).value;
|
|
2192
|
+
this.applyTranscriptStyles();
|
|
2193
|
+
this.savePreferences();
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
group.appendChild(input);
|
|
2197
|
+
return group;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
/**
|
|
2201
|
+
* Create style opacity control
|
|
2202
|
+
*/
|
|
2203
|
+
createStyleOpacityControl(label: string, property: string) {
|
|
2204
|
+
const group = DOMUtils.createElement('div', {
|
|
2205
|
+
className: `${this.player.options.classPrefix}-transcript-style-group`
|
|
2206
|
+
});
|
|
2207
|
+
|
|
2208
|
+
// Generate unique ID for the control
|
|
2209
|
+
const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
|
|
2210
|
+
|
|
2211
|
+
const labelEl = DOMUtils.createElement('label', {
|
|
2212
|
+
textContent: label,
|
|
2213
|
+
attributes: {
|
|
2214
|
+
'for': controlId
|
|
2215
|
+
}
|
|
2216
|
+
});
|
|
2217
|
+
group.appendChild(labelEl);
|
|
2218
|
+
|
|
2219
|
+
const opacityProperty = property as 'opacity';
|
|
2220
|
+
const valueDisplay = DOMUtils.createElement('span', {
|
|
2221
|
+
textContent: Math.round(this.transcriptStyle[opacityProperty] * 100) + '%',
|
|
2222
|
+
className: `${this.player.options.classPrefix}-transcript-style-value`
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
const input = DOMUtils.createElement('input', {
|
|
2226
|
+
attributes: {
|
|
2227
|
+
'id': controlId,
|
|
2228
|
+
'type': 'range',
|
|
2229
|
+
'min': '0',
|
|
2230
|
+
'max': '1',
|
|
2231
|
+
'step': '0.1',
|
|
2232
|
+
'value': String(this.transcriptStyle[opacityProperty])
|
|
2233
|
+
},
|
|
2234
|
+
className: `${this.player.options.classPrefix}-transcript-style-range`
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
input.addEventListener('input', (e) => {
|
|
2238
|
+
const value = parseFloat((e.target as HTMLInputElement).value);
|
|
2239
|
+
this.transcriptStyle[opacityProperty] = value;
|
|
2240
|
+
valueDisplay.textContent = Math.round(value * 100) + '%';
|
|
2241
|
+
this.applyTranscriptStyles();
|
|
2242
|
+
this.savePreferences();
|
|
2243
|
+
});
|
|
2244
|
+
|
|
2245
|
+
const inputContainer = DOMUtils.createElement('div', {
|
|
2246
|
+
className: `${this.player.options.classPrefix}-transcript-style-range-container`
|
|
2247
|
+
});
|
|
2248
|
+
inputContainer.appendChild(input);
|
|
2249
|
+
inputContainer.appendChild(valueDisplay);
|
|
2250
|
+
|
|
2251
|
+
group.appendChild(labelEl);
|
|
2252
|
+
group.appendChild(inputContainer);
|
|
2253
|
+
return group;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
/**
|
|
2257
|
+
* Save transcript preferences to localStorage
|
|
2258
|
+
*/
|
|
2259
|
+
savePreferences() {
|
|
2260
|
+
this.storage.saveTranscriptPreferences(this.transcriptStyle);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/**
|
|
2264
|
+
* Apply transcript styles
|
|
2265
|
+
*/
|
|
2266
|
+
applyTranscriptStyles() {
|
|
2267
|
+
if (!this.transcriptWindow) return;
|
|
2268
|
+
|
|
2269
|
+
// Apply to transcript window background
|
|
2270
|
+
this.transcriptWindow.style.backgroundColor = this.transcriptStyle.backgroundColor;
|
|
2271
|
+
this.transcriptWindow.style.opacity = String(this.transcriptStyle.opacity);
|
|
2272
|
+
|
|
2273
|
+
// Apply to content area
|
|
2274
|
+
if (this.transcriptContent) {
|
|
2275
|
+
this.transcriptContent.style.fontSize = this.transcriptStyle.fontSize;
|
|
2276
|
+
this.transcriptContent.style.fontFamily = this.transcriptStyle.fontFamily;
|
|
2277
|
+
this.transcriptContent.style.color = this.transcriptStyle.color;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
const textEntries = this.transcriptWindow.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-text`);
|
|
2281
|
+
textEntries.forEach((entry) => {
|
|
2282
|
+
entry.style.fontSize = this.transcriptStyle.fontSize;
|
|
2283
|
+
entry.style.fontFamily = this.transcriptStyle.fontFamily;
|
|
2284
|
+
entry.style.color = this.transcriptStyle.color;
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2287
|
+
const timeEntries = this.transcriptWindow.querySelectorAll<HTMLElement>(`.${this.player.options.classPrefix}-transcript-time`);
|
|
2288
|
+
timeEntries.forEach((entry) => {
|
|
2289
|
+
entry.style.fontFamily = this.transcriptStyle.fontFamily;
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/**
|
|
2294
|
+
* Set a managed timeout that will be cleaned up on destroy
|
|
2295
|
+
* @param {Function} callback - Callback function
|
|
2296
|
+
* @param {number} delay - Delay in milliseconds
|
|
2297
|
+
* @returns {number} Timeout ID
|
|
2298
|
+
*/
|
|
2299
|
+
setManagedTimeout(callback: () => void, delay: number) {
|
|
2300
|
+
const timeoutId = setTimeout(() => {
|
|
2301
|
+
this.timeouts.delete(timeoutId);
|
|
2302
|
+
callback();
|
|
2303
|
+
}, delay);
|
|
2304
|
+
this.timeouts.add(timeoutId);
|
|
2305
|
+
return timeoutId;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/**
|
|
2309
|
+
* Clear a managed timeout
|
|
2310
|
+
* @param {number} timeoutId - Timeout ID to clear
|
|
2311
|
+
*/
|
|
2312
|
+
clearManagedTimeout(timeoutId: ReturnType<typeof setTimeout> | null) {
|
|
2313
|
+
if (timeoutId) {
|
|
2314
|
+
clearTimeout(timeoutId);
|
|
2315
|
+
this.timeouts.delete(timeoutId);
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* Cleanup
|
|
2321
|
+
*/
|
|
2322
|
+
destroy() {
|
|
2323
|
+
this.hideResizeModeIndicator();
|
|
2324
|
+
|
|
2325
|
+
// Tear down the settings-menu panel (removes its DOM + keyboard
|
|
2326
|
+
// listeners). The outside-click listener was attached with the
|
|
2327
|
+
// player's lifecycleSignal, so it cleans itself up as the player
|
|
2328
|
+
// is destroyed.
|
|
2329
|
+
if (this._panel) {
|
|
2330
|
+
this._panel.destroy();
|
|
2331
|
+
this._panel = null;
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// Destroy draggableResizable utility
|
|
2335
|
+
if (this.draggableResizable) {
|
|
2336
|
+
if (this.draggableResizable.pointerResizeMode) {
|
|
2337
|
+
this.draggableResizable.disablePointerResizeMode();
|
|
2338
|
+
this.updateResizeOptionState();
|
|
2339
|
+
}
|
|
2340
|
+
this.draggableResizable.destroy();
|
|
2341
|
+
this.draggableResizable = null;
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// Remove custom key handler
|
|
2345
|
+
if (this.transcriptWindow && this.customKeyHandler) {
|
|
2346
|
+
this.transcriptWindow.removeEventListener('keydown', this.customKeyHandler);
|
|
2347
|
+
this.customKeyHandler = null;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Remove timeupdate and seeked listeners from player
|
|
2351
|
+
if (this.handlers.timeupdate) {
|
|
2352
|
+
this.player.off('timeupdate', this.handlers.timeupdate);
|
|
2353
|
+
}
|
|
2354
|
+
if (this.handlers.seeked) {
|
|
2355
|
+
this.player.off('seeked', this.handlers.seeked);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// Remove audio description listeners from player
|
|
2359
|
+
if (this.handlers.audiodescriptionenabled) {
|
|
2360
|
+
this.player.off('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
|
|
2361
|
+
}
|
|
2362
|
+
if (this.handlers.audiodescriptiondisabled) {
|
|
2363
|
+
this.player.off('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// Remove text cue update listener
|
|
2367
|
+
if (this.handlers.textcuesupdate) {
|
|
2368
|
+
this.player.off('textcuesupdate', this.handlers.textcuesupdate);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
// Remove floating-state listener
|
|
2372
|
+
if (this.handlers.floatingchange) {
|
|
2373
|
+
this.player.off('floatingchange', this.handlers.floatingchange);
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// Remove settings button event listeners
|
|
2377
|
+
if (this.settingsButton) {
|
|
2378
|
+
if (this.handlers.settingsClick) {
|
|
2379
|
+
this.settingsButton.removeEventListener('click', this.handlers.settingsClick);
|
|
2380
|
+
}
|
|
2381
|
+
if (this.handlers.settingsKeydown) {
|
|
2382
|
+
this.settingsButton.removeEventListener('keydown', this.handlers.settingsKeydown);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// Remove style dialog event listeners
|
|
2387
|
+
if (this.handlers.styleDialogKeydown) {
|
|
2388
|
+
document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
// Remove document click listener
|
|
2392
|
+
if (this.handlers.documentClick) {
|
|
2393
|
+
document.removeEventListener('click', this.handlers.documentClick);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// Remove window-level listeners
|
|
2397
|
+
if (this.handlers.resize) {
|
|
2398
|
+
window.removeEventListener('resize', this.handlers.resize);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// Cleanup all managed timeouts
|
|
2402
|
+
this.timeouts.forEach((timeoutId: ReturnType<typeof setTimeout>) => clearTimeout(timeoutId));
|
|
2403
|
+
this.timeouts.clear();
|
|
2404
|
+
|
|
2405
|
+
this.handlers = {} as TranscriptHandlers;
|
|
2406
|
+
|
|
2407
|
+
// Remove DOM element
|
|
2408
|
+
if (this.transcriptWindow && this.transcriptWindow.parentNode) {
|
|
2409
|
+
this.transcriptWindow.parentNode.removeChild(this.transcriptWindow);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
this.transcriptWindow = null;
|
|
2413
|
+
this.transcriptHeader = null;
|
|
2414
|
+
this.transcriptContent = null;
|
|
2415
|
+
this.transcriptEntries = [];
|
|
2416
|
+
this.styleDialog = null;
|
|
2417
|
+
this.transcriptResizeHandles = [];
|
|
2418
|
+
this.liveRegion = null;
|
|
2419
|
+
this._vttCache.clear();
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
announceLive(message: string) {
|
|
2423
|
+
if (!this.liveRegion) return;
|
|
2424
|
+
this.liveRegion.textContent = message || '';
|
|
2425
|
+
}
|
|
2426
|
+
}
|