vidply 1.2.0 → 1.2.3

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.
Files changed (167) hide show
  1. package/README.md +974 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
  23. package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
  24. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
  25. package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
  26. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
  27. package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
  28. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
  30. package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
  32. package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
  34. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
  35. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
  36. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
  37. package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
  38. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
  39. package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
  41. package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
  43. package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +648 -35
  45. package/dist/dev/vidply.esm.js.map +3 -3
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
  47. package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
  49. package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
  50. package/dist/legacy/vidply.js +1062 -34
  51. package/dist/legacy/vidply.js.map +3 -3
  52. package/dist/legacy/vidply.min.js +2 -2
  53. package/dist/legacy/vidply.min.meta.json +127 -55
  54. package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
  66. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
  67. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
  69. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
  72. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
  75. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
  81. package/dist/vidply.css +5852 -5796
  82. package/dist/vidply.esm.min.meta.json +204 -149
  83. package/dist/vidply.min.css +1 -1
  84. package/package.json +11 -11
  85. package/src/controls/CaptionManager.ts +598 -598
  86. package/src/controls/ControlBar.ts +4191 -4159
  87. package/src/controls/KeyboardHelp.ts +326 -0
  88. package/src/controls/KeyboardManager.ts +387 -383
  89. package/src/controls/SettingsDialog.ts +488 -488
  90. package/src/controls/TranscriptManager.ts +2426 -2426
  91. package/src/core/AudioDescriptionManager.ts +863 -774
  92. package/src/core/DescriptionSpeechManager.ts +232 -0
  93. package/src/core/FloatingPlayerManager.ts +674 -674
  94. package/src/core/LazyInit.ts +80 -80
  95. package/src/core/MediaSessionManager.ts +341 -0
  96. package/src/core/MetadataAlertsManager.ts +514 -514
  97. package/src/core/Player.ts +2973 -2872
  98. package/src/core/PosterManager.ts +169 -169
  99. package/src/core/PseudoFullscreen.ts +179 -179
  100. package/src/core/ResponsiveManager.ts +231 -231
  101. package/src/core/ResumeManager.ts +236 -236
  102. package/src/core/SignLanguageManager.ts +1308 -1308
  103. package/src/core/ThemeManager.ts +157 -157
  104. package/src/features/PlaylistManager.ts +1838 -1827
  105. package/src/i18n/i18n.ts +223 -223
  106. package/src/i18n/languages/de.ts +244 -217
  107. package/src/i18n/languages/en.ts +245 -218
  108. package/src/i18n/languages/es.ts +241 -214
  109. package/src/i18n/languages/fr.ts +241 -214
  110. package/src/i18n/languages/ja.ts +241 -214
  111. package/src/icons/Icons.ts +168 -167
  112. package/src/index.ts +196 -193
  113. package/src/renderers/DASHRenderer.ts +1077 -1077
  114. package/src/renderers/HLSRenderer.ts +944 -944
  115. package/src/renderers/HTML5Renderer.ts +389 -389
  116. package/src/renderers/SoundCloudRenderer.ts +437 -437
  117. package/src/renderers/VimeoRenderer.ts +298 -298
  118. package/src/renderers/YouTubeRenderer.ts +290 -290
  119. package/src/styles/vidply.css +5852 -5796
  120. package/src/types/events.ts +128 -122
  121. package/src/types/globals.d.ts +313 -313
  122. package/src/types/options.ts +199 -182
  123. package/src/types/renderer.ts +58 -58
  124. package/src/utils/DOMUtils.ts +281 -281
  125. package/src/utils/DownloadInfo.ts +171 -171
  126. package/src/utils/DraggablePanel.ts +539 -539
  127. package/src/utils/DraggablePanelMenu.ts +195 -195
  128. package/src/utils/DraggableResizable.ts +997 -997
  129. package/src/utils/EventEmitter.ts +59 -59
  130. package/src/utils/FormUtils.ts +95 -95
  131. package/src/utils/MenuFactory.ts +483 -483
  132. package/src/utils/Sanitize.ts +70 -70
  133. package/src/utils/SettingsMenuFactory.ts +289 -289
  134. package/src/utils/StorageManager.ts +287 -287
  135. package/src/utils/TrackLabelUtils.ts +52 -52
  136. package/src/utils/UrlSafe.ts +69 -69
  137. package/src/utils/VideoFrameCapture.ts +114 -114
  138. package/src/utils/WindowComponents.ts +250 -250
  139. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  140. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  141. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  142. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  143. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  144. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  145. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  146. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  147. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  148. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  149. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  151. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  152. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  153. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  154. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  155. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  156. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  157. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  158. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  159. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  160. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  161. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  162. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
  163. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
  165. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
  166. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
  167. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.js.map} +0 -0
@@ -1,674 +1,674 @@
1
- /**
2
- * Floating Player Manager
3
- *
4
- * Custom in-page Picture-in-Picture. Moves the entire player.container
5
- * into a position:fixed, draggable and resizable shell that lives outside
6
- * the document flow (appended to document.body). A transparent placeholder
7
- * preserves layout and, while floating, serves as the IntersectionObserver
8
- * sentinel so scroll-based auto-float/redock decisions stay anchored to
9
- * the original slot.
10
- *
11
- * Triggers:
12
- * - pinned: the user clicks the control-bar PiP button (only when
13
- * options.floating is true; otherwise the button uses native PiP).
14
- * Pinned floating ignores scroll-based docking.
15
- * - auto: the video is playing and the original slot has scrolled out
16
- * of the viewport. Auto-float redocks automatically when the slot
17
- * scrolls back in.
18
- *
19
- * Close button: pauses playback, exits the floating shell and marks the
20
- * current play session as dismissed so auto-float will not re-trigger
21
- * until the user presses play again (matches YouTube miniplayer UX).
22
- *
23
- * Only one player is allowed to float at a time across a page; enter()
24
- * broadcasts a 'vidply:floating-claim' CustomEvent that other managers
25
- * listen for and auto-exit on.
26
- */
27
-
28
- import { DOMUtils } from '../utils/DOMUtils.js';
29
- import { DraggableResizable } from '../utils/DraggableResizable.js';
30
- import { createIconElement } from '../icons/Icons.js';
31
- import { i18n } from '../i18n/i18n.js';
32
- import type { Player } from './Player.js';
33
-
34
- type FloatingState = 'pinned' | 'auto';
35
- type ExitReason = 'manual' | 'auto' | 'dismiss' | 'claim' | 'destroy';
36
-
37
- const FLOATING_CLAIM_EVENT = 'vidply:floating-claim';
38
- const DEFAULT_WIDTH = 400;
39
- const MIN_WIDTH = 240;
40
- const EDGE_MARGIN = 16;
41
-
42
- interface FloatingPrefs {
43
- width?: number;
44
- height?: number;
45
- left?: number;
46
- top?: number;
47
- }
48
-
49
- export class FloatingPlayerManager {
50
- player: Player;
51
- classPrefix: string;
52
-
53
- shell: HTMLElement | null;
54
- dragHandle: HTMLElement | null;
55
- closeButton: HTMLButtonElement | null;
56
- resizeHandles: HTMLElement[];
57
- placeholder: HTMLElement | null;
58
- draggable: DraggableResizable | null;
59
-
60
- originalParent: HTMLElement | null;
61
- originalNextSibling: Node | null;
62
-
63
- intersectionObserver: IntersectionObserver | null;
64
- observerTarget: HTMLElement | null;
65
- lastRatio: number;
66
-
67
- _autoDismissedThisPlay: boolean;
68
- _playListenerAttached: boolean;
69
- _onPlayAfterDismiss: (() => void) | null;
70
- _onClaim: ((event: Event) => void) | null;
71
- _onResize: (() => void) | null;
72
- _onKeyDown: ((event: KeyboardEvent) => void) | null;
73
- _onEnterFullscreen: (() => void) | null;
74
- _destroyed: boolean;
75
- _triggerFocusEl: HTMLElement | null;
76
- _claimId: string;
77
- _lastAutoExitTime: number;
78
-
79
- constructor(player: Player) {
80
- this.player = player;
81
- this.classPrefix = player.options.classPrefix || 'vidply';
82
-
83
- this.shell = null;
84
- this.dragHandle = null;
85
- this.closeButton = null;
86
- this.resizeHandles = [];
87
- this.placeholder = null;
88
- this.draggable = null;
89
-
90
- this.originalParent = null;
91
- this.originalNextSibling = null;
92
-
93
- this.intersectionObserver = null;
94
- this.observerTarget = null;
95
- this.lastRatio = 1;
96
-
97
- this._autoDismissedThisPlay = false;
98
- this._playListenerAttached = false;
99
- this._onPlayAfterDismiss = null;
100
- this._onClaim = null;
101
- this._onResize = null;
102
- this._onKeyDown = null;
103
- this._onEnterFullscreen = null;
104
- this._destroyed = false;
105
- this._triggerFocusEl = null;
106
- this._claimId = `floating-${player.instanceId}-${Date.now()}`;
107
- this._lastAutoExitTime = 0;
108
-
109
- this._setupClaimListener();
110
- this._setupFullscreenGuard();
111
- this._startObserving();
112
- }
113
-
114
- // ---------------------------------------------------------------
115
- // Public API
116
- // ---------------------------------------------------------------
117
-
118
- togglePinned(triggerEl?: HTMLElement | null) {
119
- if (this._destroyed) return;
120
- if (this.player.state.floating === 'pinned') {
121
- // Manual unpin: suppress auto-float for the remainder of the
122
- // current play session so scrolling doesn't immediately re-pop
123
- // the floating shell. A fresh 'play' event clears the flag.
124
- this._autoDismissedThisPlay = true;
125
- this._armPlayListenerToClearDismiss();
126
- this.exit('manual');
127
- return;
128
- }
129
- // If the user pins while in 'auto' mode, that's an explicit request
130
- // to float; any previous auto-dismiss flag should be cleared so the
131
- // pinned state behaves naturally.
132
- this._autoDismissedThisPlay = false;
133
- this._triggerFocusEl = triggerEl || this._activeElement();
134
- this.enter('pinned');
135
- }
136
-
137
- enter(reason: FloatingState) {
138
- if (this._destroyed) return;
139
- if (this.player.state.floating === reason) return;
140
-
141
- if (!this._canFloat(reason)) {
142
- return;
143
- }
144
-
145
- // Upgrade 'auto' -> 'pinned' without rebuilding the shell
146
- if (this.player.state.floating && this.player.state.floating !== reason) {
147
- this.player.state.floating = reason;
148
- this.player.emit('floatingchange', reason);
149
- return;
150
- }
151
-
152
- this._claimSingleton();
153
-
154
- this._ensureShell();
155
- this._mountIntoShell();
156
- this._applyInitialGeometry();
157
-
158
- this.player.state.floating = reason;
159
- this.player.emit('floatingchange', reason);
160
-
161
- // Focus the close button after a microtask so screen readers announce
162
- // the dialog opening.
163
- queueMicrotask(() => {
164
- if (this.closeButton && this.player.state.floating) {
165
- try { this.closeButton.focus({ preventScroll: true }); } catch { /* ignore */ }
166
- }
167
- });
168
- }
169
-
170
- exit(reason: ExitReason = 'manual') {
171
- if (this._destroyed && reason !== 'destroy') return;
172
- if (!this.player.state.floating) return;
173
-
174
- if (reason === 'auto') {
175
- // Record when an auto-exit occurred so the IntersectionObserver
176
- // callback can apply a cooldown before re-entering auto-float.
177
- this._lastAutoExitTime = Date.now();
178
- }
179
-
180
- this._unmountFromShell();
181
- this._teardownShell();
182
-
183
- const priorTrigger = this._triggerFocusEl;
184
- this._triggerFocusEl = null;
185
-
186
- this.player.state.floating = null;
187
- this.player.emit('floatingchange', null);
188
-
189
- // Restore focus to the element that triggered floating (pinned flow).
190
- // For scroll-based exits we leave focus untouched.
191
- if ((reason === 'manual' || reason === 'dismiss') && priorTrigger) {
192
- try { priorTrigger.focus({ preventScroll: true }); } catch { /* ignore */ }
193
- }
194
- }
195
-
196
- /**
197
- * Close button: pause, dismiss, and prevent auto-float until the next
198
- * user-initiated play event.
199
- */
200
- dismiss() {
201
- if (this._destroyed) return;
202
- this._autoDismissedThisPlay = true;
203
- this._armPlayListenerToClearDismiss();
204
-
205
- try {
206
- this.player.pause();
207
- } catch { /* ignore */ }
208
-
209
- this.exit('dismiss');
210
- }
211
-
212
- destroy() {
213
- if (this._destroyed) return;
214
- this._destroyed = true;
215
-
216
- if (this.player.state && this.player.state.floating) {
217
- try { this.exit('destroy'); } catch { /* ignore */ }
218
- }
219
-
220
- if (this.intersectionObserver) {
221
- try { this.intersectionObserver.disconnect(); } catch { /* ignore */ }
222
- this.intersectionObserver = null;
223
- }
224
- this.observerTarget = null;
225
-
226
- if (this._onClaim) {
227
- window.removeEventListener(FLOATING_CLAIM_EVENT, this._onClaim as EventListener);
228
- this._onClaim = null;
229
- }
230
- if (this._onResize) {
231
- window.removeEventListener('resize', this._onResize);
232
- this._onResize = null;
233
- }
234
- const enterFs = this._onEnterFullscreen;
235
- if (enterFs) {
236
- this.player.off('enterfullscreen', enterFs);
237
- this._onEnterFullscreen = null;
238
- }
239
- const playAfterDismiss = this._onPlayAfterDismiss;
240
- if (playAfterDismiss && this._playListenerAttached) {
241
- this.player.off('play', playAfterDismiss);
242
- this._playListenerAttached = false;
243
- this._onPlayAfterDismiss = null;
244
- }
245
- }
246
-
247
- // ---------------------------------------------------------------
248
- // Internal: guards
249
- // ---------------------------------------------------------------
250
-
251
- _canFloat(reason: FloatingState): boolean {
252
- if (!this.player.options.floating) return false;
253
- if (!this.player.container) return false;
254
- if (!this.player.element || this.player.element.tagName !== 'VIDEO') return false;
255
- if (this.player.state.fullscreen) return false;
256
-
257
- // Playlist players move through multiple tracks and manage their own
258
- // UI; floating support for playlists is out of scope for this v1.
259
- if (this.player.playlistManager) return false;
260
-
261
- const minWidth = this.player.options.floatingMinViewportWidth ?? 768;
262
- if (window.innerWidth < minWidth) return false;
263
-
264
- // Auto-float only engages when the user is actively watching.
265
- if (reason === 'auto') {
266
- if (this._autoDismissedThisPlay) return false;
267
- if (this.player.state.paused) return false;
268
- // Never auto-float before the user has started playback; stops
269
- // auto-entry from firing while the page is still below the fold
270
- // on initial load.
271
- if (!this.player.state.hasStartedPlayback) return false;
272
- }
273
-
274
- return true;
275
- }
276
-
277
- _claimSingleton() {
278
- try {
279
- window.dispatchEvent(new CustomEvent(FLOATING_CLAIM_EVENT, {
280
- detail: { claimId: this._claimId }
281
- }));
282
- } catch { /* ignore (older browsers) */ }
283
- }
284
-
285
- _setupClaimListener() {
286
- this._onClaim = (event: Event) => {
287
- const detail = (event as CustomEvent).detail;
288
- if (!detail || detail.claimId === this._claimId) return;
289
- if (this.player.state.floating) {
290
- this.exit('claim');
291
- }
292
- };
293
- const signal = this.player.lifecycleSignal;
294
- window.addEventListener(FLOATING_CLAIM_EVENT, this._onClaim as EventListener, { signal });
295
-
296
- this._onResize = () => {
297
- const minWidth = this.player.options.floatingMinViewportWidth ?? 768;
298
- if (this.player.state.floating && window.innerWidth < minWidth) {
299
- this.exit('auto');
300
- }
301
- };
302
- window.addEventListener('resize', this._onResize, { signal });
303
- }
304
-
305
- _setupFullscreenGuard() {
306
- const onEnterFullscreen = () => {
307
- if (this.player.state.floating) {
308
- this.exit('manual');
309
- }
310
- };
311
- this._onEnterFullscreen = onEnterFullscreen;
312
- this.player.on('enterfullscreen', onEnterFullscreen);
313
- }
314
-
315
- _armPlayListenerToClearDismiss() {
316
- if (this._playListenerAttached) return;
317
- const onPlayAfterDismiss = () => {
318
- this._autoDismissedThisPlay = false;
319
- const handler = this._onPlayAfterDismiss;
320
- if (handler) {
321
- this.player.off('play', handler);
322
- }
323
- this._playListenerAttached = false;
324
- this._onPlayAfterDismiss = null;
325
- };
326
- this._onPlayAfterDismiss = onPlayAfterDismiss;
327
- this.player.on('play', onPlayAfterDismiss);
328
- this._playListenerAttached = true;
329
- }
330
-
331
- // ---------------------------------------------------------------
332
- // Internal: IntersectionObserver for scroll-triggered auto-float
333
- // ---------------------------------------------------------------
334
-
335
- _startObserving() {
336
- if (!('IntersectionObserver' in window)) return;
337
- if (!this.player.container) return;
338
-
339
- this.observerTarget = this.player.container;
340
- this.intersectionObserver = new IntersectionObserver((entries) => {
341
- const entry = entries[entries.length - 1];
342
- if (!entry) return;
343
- this.lastRatio = entry.intersectionRatio;
344
-
345
- if (this.player.options.debug) {
346
- try {
347
- console.log('[vidply:floating] intersection', {
348
- ratio: Number(entry.intersectionRatio.toFixed(3)),
349
- state: this.player.state.floating,
350
- paused: this.player.state.paused,
351
- hasStartedPlayback: this.player.state.hasStartedPlayback,
352
- dismissed: this._autoDismissedThisPlay
353
- });
354
- } catch { /* ignore */ }
355
- }
356
-
357
- if (this.player.state.floating === 'auto') {
358
- if (entry.intersectionRatio >= 0.5) {
359
- this.exit('auto');
360
- }
361
- return;
362
- }
363
-
364
- // Pinned state: scroll is a no-op, the user has taken control.
365
- if (this.player.state.floating === 'pinned') {
366
- return;
367
- }
368
-
369
- if (entry.intersectionRatio < 0.1 && this._canFloat('auto')) {
370
- // Brief cooldown after an auto-exit prevents the layout reflow that
371
- // accompanies the placeholder→container swap from immediately
372
- // re-triggering auto-float (oscillation guard).
373
- const AUTO_EXIT_COOLDOWN_MS = 500;
374
- if (Date.now() - this._lastAutoExitTime < AUTO_EXIT_COOLDOWN_MS) return;
375
- this.enter('auto');
376
- }
377
- }, { threshold: [0, 0.1, 0.5, 0.9] });
378
-
379
- this.intersectionObserver.observe(this.observerTarget);
380
- }
381
-
382
- _retargetObserver(target: HTMLElement) {
383
- if (!this.intersectionObserver) return;
384
- if (this.observerTarget) {
385
- try { this.intersectionObserver.unobserve(this.observerTarget); } catch { /* ignore */ }
386
- }
387
- this.observerTarget = target;
388
- try { this.intersectionObserver.observe(target); } catch { /* ignore */ }
389
- }
390
-
391
- // ---------------------------------------------------------------
392
- // Internal: shell DOM
393
- // ---------------------------------------------------------------
394
-
395
- _ensureShell() {
396
- if (this.shell) return;
397
-
398
- this.shell = DOMUtils.createElement('div', {
399
- className: `${this.classPrefix}-floating-shell`,
400
- attributes: {
401
- 'role': 'dialog',
402
- 'aria-modal': 'false',
403
- 'aria-label': i18n.t('player.floatingPlayer'),
404
- 'data-vidply-floating': 'true',
405
- 'tabindex': '-1'
406
- }
407
- });
408
-
409
- this.dragHandle = DOMUtils.createElement('div', {
410
- className: `${this.classPrefix}-floating-drag-handle`,
411
- attributes: { 'aria-hidden': 'true' }
412
- });
413
- this.shell.appendChild(this.dragHandle);
414
-
415
- this.closeButton = DOMUtils.createElement('button', {
416
- className: `${this.classPrefix}-floating-close`,
417
- attributes: {
418
- 'type': 'button',
419
- 'aria-label': i18n.t('player.floatingPlayerClose'),
420
- 'title': i18n.t('player.floatingPlayerClose')
421
- }
422
- }) as HTMLButtonElement;
423
- this.closeButton.appendChild(createIconElement('close'));
424
- this.closeButton.addEventListener('click', (event) => {
425
- event.stopPropagation();
426
- this.dismiss();
427
- });
428
- this.shell.appendChild(this.closeButton);
429
-
430
- this._createResizeHandles();
431
- const shell = this.shell;
432
- this.resizeHandles.forEach(handle => shell.appendChild(handle));
433
-
434
- this._onKeyDown = (event: KeyboardEvent) => {
435
- if (event.key === 'Escape') {
436
- // If the user is in keyboard drag / resize (or pointer-resize)
437
- // mode, Escape must exit that mode first — not dismiss the
438
- // whole floating player. This listener is registered before the
439
- // DraggableResizable one, so we let the event fall through to
440
- // it (no dismiss, no stopPropagation) while a mode is active
441
- // (WCAG 2.1.1). Only a "neutral" Escape dismisses.
442
- const d = this.draggable;
443
- const inEditMode = !!d && (d.keyboardDragMode || d.keyboardResizeMode || d.pointerResizeMode);
444
- if (inEditMode) {
445
- return;
446
- }
447
- event.stopPropagation();
448
- this.dismiss();
449
- }
450
- };
451
- this.shell.addEventListener('keydown', this._onKeyDown as EventListener);
452
- }
453
-
454
- _createResizeHandles() {
455
- // Resize handles are always-on for the floating shell. Do NOT set
456
- // `data-vidply-managed-resize="true"`: that flag is used by the
457
- // transcript / sign-language overlays to gate the handles behind
458
- // an "R" keypress, and DraggableResizable hides managed handles
459
- // during init() — which would leave the shell with no visible or
460
- // hittable edges/corners.
461
- const dirs = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
462
- this.resizeHandles = dirs.map(dir => DOMUtils.createElement('div', {
463
- className: `${this.classPrefix}-floating-resize-handle ${this.classPrefix}-floating-resize-${dir}`,
464
- attributes: {
465
- 'data-direction': dir,
466
- 'aria-hidden': 'true'
467
- }
468
- }));
469
- }
470
-
471
- _teardownShell() {
472
- if (this.draggable) {
473
- try { this.draggable.destroy(); } catch { /* ignore */ }
474
- this.draggable = null;
475
- }
476
- if (this.shell) {
477
- if (this._onKeyDown) {
478
- this.shell.removeEventListener('keydown', this._onKeyDown as EventListener);
479
- this._onKeyDown = null;
480
- }
481
- if (this.shell.parentNode) {
482
- this.shell.parentNode.removeChild(this.shell);
483
- }
484
- }
485
- this.shell = null;
486
- this.dragHandle = null;
487
- this.closeButton = null;
488
- this.resizeHandles = [];
489
- }
490
-
491
- // ---------------------------------------------------------------
492
- // Internal: mount / unmount the player.container
493
- // ---------------------------------------------------------------
494
-
495
- _mountIntoShell() {
496
- const container = this.player.container;
497
- if (!container || !container.parentNode) return;
498
- if (!this.shell) return;
499
-
500
- const rect = container.getBoundingClientRect();
501
- this.originalParent = container.parentNode as HTMLElement;
502
- this.originalNextSibling = container.nextSibling;
503
-
504
- this.placeholder = DOMUtils.createElement('div', {
505
- className: `${this.classPrefix}-floating-placeholder`,
506
- attributes: { 'aria-hidden': 'true' }
507
- });
508
- this.placeholder.style.width = `${Math.max(1, rect.width)}px`;
509
- this.placeholder.style.height = `${Math.max(1, rect.height)}px`;
510
-
511
- // Centred PiP glyph that signals "video popped out" in the slot the
512
- // player used to occupy. Lives as a regular child so it can inherit
513
- // currentColor and scale with the placeholder; both the icon and
514
- // the placeholder itself are removed wholesale on _unmountFromShell().
515
- const placeholderIcon = createIconElement('pip', `${this.classPrefix}-floating-placeholder-icon`);
516
- this.placeholder.appendChild(placeholderIcon);
517
-
518
- this.originalParent.insertBefore(this.placeholder, container);
519
-
520
- this.shell.appendChild(container);
521
- document.body.appendChild(this.shell);
522
-
523
- container.classList.add(`${this.classPrefix}-is-floating`);
524
-
525
- // Observe the placeholder while floating so scroll-back decisions are
526
- // anchored to the original slot instead of the moved container.
527
- this._retargetObserver(this.placeholder);
528
- }
529
-
530
- _unmountFromShell() {
531
- const container = this.player.container;
532
-
533
- if (container) {
534
- container.classList.remove(`${this.classPrefix}-is-floating`);
535
- // Clear inline sizes applied while floating
536
- container.style.removeProperty('width');
537
- container.style.removeProperty('height');
538
- }
539
-
540
- if (this.placeholder && this.placeholder.parentNode) {
541
- if (container) {
542
- this.placeholder.parentNode.insertBefore(container, this.placeholder);
543
- }
544
- this.placeholder.parentNode.removeChild(this.placeholder);
545
- } else if (container && this.originalParent) {
546
- if (this.originalNextSibling && this.originalNextSibling.parentNode === this.originalParent) {
547
- this.originalParent.insertBefore(container, this.originalNextSibling);
548
- } else {
549
- this.originalParent.appendChild(container);
550
- }
551
- }
552
-
553
- this.placeholder = null;
554
- this.originalParent = null;
555
- this.originalNextSibling = null;
556
-
557
- // Retarget the observer to the docked container
558
- if (container) {
559
- this._retargetObserver(container);
560
- }
561
- }
562
-
563
- // ---------------------------------------------------------------
564
- // Internal: initial geometry + drag/resize wiring
565
- // ---------------------------------------------------------------
566
-
567
- _applyInitialGeometry() {
568
- if (!this.shell) return;
569
-
570
- const prefs = (this.player.storage?.getFloatingPreferences?.() || {}) as FloatingPrefs;
571
-
572
- const vw = window.innerWidth;
573
- const vh = window.innerHeight;
574
-
575
- let width = prefs.width && prefs.width >= MIN_WIDTH ? prefs.width : DEFAULT_WIDTH;
576
- width = Math.min(width, Math.max(MIN_WIDTH, vw - EDGE_MARGIN * 2));
577
-
578
- // If we know the source container's aspect ratio, match it by default.
579
- const containerRect = this.player.container?.getBoundingClientRect();
580
- const aspect = containerRect && containerRect.height > 0
581
- ? containerRect.width / containerRect.height
582
- : 16 / 9;
583
- const defaultHeight = Math.round(width / aspect);
584
- let height = prefs.height && prefs.height >= 100 ? prefs.height : defaultHeight;
585
- height = Math.min(height, Math.max(100, vh - EDGE_MARGIN * 2));
586
-
587
- let left: number;
588
- let top: number;
589
-
590
- if (typeof prefs.left === 'number' && typeof prefs.top === 'number') {
591
- left = Math.max(EDGE_MARGIN, Math.min(prefs.left, vw - width - EDGE_MARGIN));
592
- top = Math.max(EDGE_MARGIN, Math.min(prefs.top, vh - height - EDGE_MARGIN));
593
- } else {
594
- const pos = this.player.options.floatingPosition || 'bottom-right';
595
- switch (pos) {
596
- case 'bottom-left':
597
- left = EDGE_MARGIN;
598
- top = vh - height - EDGE_MARGIN;
599
- break;
600
- case 'top-right':
601
- left = vw - width - EDGE_MARGIN;
602
- top = EDGE_MARGIN;
603
- break;
604
- case 'top-left':
605
- left = EDGE_MARGIN;
606
- top = EDGE_MARGIN;
607
- break;
608
- case 'bottom-right':
609
- default:
610
- left = vw - width - EDGE_MARGIN;
611
- top = vh - height - EDGE_MARGIN;
612
- break;
613
- }
614
- }
615
-
616
- this.shell.style.width = `${width}px`;
617
- this.shell.style.height = `${height}px`;
618
- this.shell.style.left = `${left}px`;
619
- this.shell.style.top = `${top}px`;
620
-
621
- this._initDraggable();
622
- }
623
-
624
- _initDraggable() {
625
- if (!this.shell) return;
626
- if (this.draggable) return;
627
-
628
- this.draggable = new DraggableResizable(this.shell, {
629
- dragHandle: this.dragHandle,
630
- resizeHandles: this.resizeHandles,
631
- constrainToViewport: true,
632
- maintainAspectRatio: true,
633
- minWidth: MIN_WIDTH,
634
- minHeight: 100,
635
- maxWidth: () => Math.max(MIN_WIDTH, window.innerWidth - EDGE_MARGIN * 2),
636
- maxHeight: () => Math.max(100, window.innerHeight - EDGE_MARGIN * 2),
637
- classPrefix: `${this.classPrefix}-floating`,
638
- keyboardDragKey: 'd',
639
- keyboardResizeKey: 'r',
640
- keyboardStep: 10,
641
- keyboardStepLarge: 50,
642
- pointerResizeIndicatorText: i18n.t('player.floatingPlayerDialog'),
643
- onDragEnd: () => this._savePrefs(),
644
- onResizeEnd: () => this._savePrefs(),
645
- onDragStart: (event: Event) => {
646
- const target = event.target as HTMLElement;
647
- if (!target) return true;
648
- // Don't start drag when interacting with the close button or
649
- // with any control inside the embedded player (play/pause,
650
- // captions menu, etc.).
651
- if (target.closest(`.${this.classPrefix}-floating-close`)) return false;
652
- if (target.closest(`.${this.classPrefix}-controls`)) return false;
653
- if (target.closest(`.${this.classPrefix}-floating-resize-handle`)) return false;
654
- return true;
655
- }
656
- });
657
- }
658
-
659
- _savePrefs() {
660
- if (!this.shell || !this.player.storage?.saveFloatingPreferences) return;
661
- const rect = this.shell.getBoundingClientRect();
662
- this.player.storage.saveFloatingPreferences({
663
- width: Math.round(rect.width),
664
- height: Math.round(rect.height),
665
- left: Math.round(rect.left),
666
- top: Math.round(rect.top)
667
- });
668
- }
669
-
670
- _activeElement(): HTMLElement | null {
671
- const active = document.activeElement;
672
- return active && active instanceof HTMLElement ? active : null;
673
- }
674
- }
1
+ /**
2
+ * Floating Player Manager
3
+ *
4
+ * Custom in-page Picture-in-Picture. Moves the entire player.container
5
+ * into a position:fixed, draggable and resizable shell that lives outside
6
+ * the document flow (appended to document.body). A transparent placeholder
7
+ * preserves layout and, while floating, serves as the IntersectionObserver
8
+ * sentinel so scroll-based auto-float/redock decisions stay anchored to
9
+ * the original slot.
10
+ *
11
+ * Triggers:
12
+ * - pinned: the user clicks the control-bar PiP button (only when
13
+ * options.floating is true; otherwise the button uses native PiP).
14
+ * Pinned floating ignores scroll-based docking.
15
+ * - auto: the video is playing and the original slot has scrolled out
16
+ * of the viewport. Auto-float redocks automatically when the slot
17
+ * scrolls back in.
18
+ *
19
+ * Close button: pauses playback, exits the floating shell and marks the
20
+ * current play session as dismissed so auto-float will not re-trigger
21
+ * until the user presses play again (matches YouTube miniplayer UX).
22
+ *
23
+ * Only one player is allowed to float at a time across a page; enter()
24
+ * broadcasts a 'vidply:floating-claim' CustomEvent that other managers
25
+ * listen for and auto-exit on.
26
+ */
27
+
28
+ import { DOMUtils } from '../utils/DOMUtils.js';
29
+ import { DraggableResizable } from '../utils/DraggableResizable.js';
30
+ import { createIconElement } from '../icons/Icons.js';
31
+ import { i18n } from '../i18n/i18n.js';
32
+ import type { Player } from './Player.js';
33
+
34
+ type FloatingState = 'pinned' | 'auto';
35
+ type ExitReason = 'manual' | 'auto' | 'dismiss' | 'claim' | 'destroy';
36
+
37
+ const FLOATING_CLAIM_EVENT = 'vidply:floating-claim';
38
+ const DEFAULT_WIDTH = 400;
39
+ const MIN_WIDTH = 240;
40
+ const EDGE_MARGIN = 16;
41
+
42
+ interface FloatingPrefs {
43
+ width?: number;
44
+ height?: number;
45
+ left?: number;
46
+ top?: number;
47
+ }
48
+
49
+ export class FloatingPlayerManager {
50
+ player: Player;
51
+ classPrefix: string;
52
+
53
+ shell: HTMLElement | null;
54
+ dragHandle: HTMLElement | null;
55
+ closeButton: HTMLButtonElement | null;
56
+ resizeHandles: HTMLElement[];
57
+ placeholder: HTMLElement | null;
58
+ draggable: DraggableResizable | null;
59
+
60
+ originalParent: HTMLElement | null;
61
+ originalNextSibling: Node | null;
62
+
63
+ intersectionObserver: IntersectionObserver | null;
64
+ observerTarget: HTMLElement | null;
65
+ lastRatio: number;
66
+
67
+ _autoDismissedThisPlay: boolean;
68
+ _playListenerAttached: boolean;
69
+ _onPlayAfterDismiss: (() => void) | null;
70
+ _onClaim: ((event: Event) => void) | null;
71
+ _onResize: (() => void) | null;
72
+ _onKeyDown: ((event: KeyboardEvent) => void) | null;
73
+ _onEnterFullscreen: (() => void) | null;
74
+ _destroyed: boolean;
75
+ _triggerFocusEl: HTMLElement | null;
76
+ _claimId: string;
77
+ _lastAutoExitTime: number;
78
+
79
+ constructor(player: Player) {
80
+ this.player = player;
81
+ this.classPrefix = player.options.classPrefix || 'vidply';
82
+
83
+ this.shell = null;
84
+ this.dragHandle = null;
85
+ this.closeButton = null;
86
+ this.resizeHandles = [];
87
+ this.placeholder = null;
88
+ this.draggable = null;
89
+
90
+ this.originalParent = null;
91
+ this.originalNextSibling = null;
92
+
93
+ this.intersectionObserver = null;
94
+ this.observerTarget = null;
95
+ this.lastRatio = 1;
96
+
97
+ this._autoDismissedThisPlay = false;
98
+ this._playListenerAttached = false;
99
+ this._onPlayAfterDismiss = null;
100
+ this._onClaim = null;
101
+ this._onResize = null;
102
+ this._onKeyDown = null;
103
+ this._onEnterFullscreen = null;
104
+ this._destroyed = false;
105
+ this._triggerFocusEl = null;
106
+ this._claimId = `floating-${player.instanceId}-${Date.now()}`;
107
+ this._lastAutoExitTime = 0;
108
+
109
+ this._setupClaimListener();
110
+ this._setupFullscreenGuard();
111
+ this._startObserving();
112
+ }
113
+
114
+ // ---------------------------------------------------------------
115
+ // Public API
116
+ // ---------------------------------------------------------------
117
+
118
+ togglePinned(triggerEl?: HTMLElement | null) {
119
+ if (this._destroyed) return;
120
+ if (this.player.state.floating === 'pinned') {
121
+ // Manual unpin: suppress auto-float for the remainder of the
122
+ // current play session so scrolling doesn't immediately re-pop
123
+ // the floating shell. A fresh 'play' event clears the flag.
124
+ this._autoDismissedThisPlay = true;
125
+ this._armPlayListenerToClearDismiss();
126
+ this.exit('manual');
127
+ return;
128
+ }
129
+ // If the user pins while in 'auto' mode, that's an explicit request
130
+ // to float; any previous auto-dismiss flag should be cleared so the
131
+ // pinned state behaves naturally.
132
+ this._autoDismissedThisPlay = false;
133
+ this._triggerFocusEl = triggerEl || this._activeElement();
134
+ this.enter('pinned');
135
+ }
136
+
137
+ enter(reason: FloatingState) {
138
+ if (this._destroyed) return;
139
+ if (this.player.state.floating === reason) return;
140
+
141
+ if (!this._canFloat(reason)) {
142
+ return;
143
+ }
144
+
145
+ // Upgrade 'auto' -> 'pinned' without rebuilding the shell
146
+ if (this.player.state.floating && this.player.state.floating !== reason) {
147
+ this.player.state.floating = reason;
148
+ this.player.emit('floatingchange', reason);
149
+ return;
150
+ }
151
+
152
+ this._claimSingleton();
153
+
154
+ this._ensureShell();
155
+ this._mountIntoShell();
156
+ this._applyInitialGeometry();
157
+
158
+ this.player.state.floating = reason;
159
+ this.player.emit('floatingchange', reason);
160
+
161
+ // Focus the close button after a microtask so screen readers announce
162
+ // the dialog opening.
163
+ queueMicrotask(() => {
164
+ if (this.closeButton && this.player.state.floating) {
165
+ try { this.closeButton.focus({ preventScroll: true }); } catch { /* ignore */ }
166
+ }
167
+ });
168
+ }
169
+
170
+ exit(reason: ExitReason = 'manual') {
171
+ if (this._destroyed && reason !== 'destroy') return;
172
+ if (!this.player.state.floating) return;
173
+
174
+ if (reason === 'auto') {
175
+ // Record when an auto-exit occurred so the IntersectionObserver
176
+ // callback can apply a cooldown before re-entering auto-float.
177
+ this._lastAutoExitTime = Date.now();
178
+ }
179
+
180
+ this._unmountFromShell();
181
+ this._teardownShell();
182
+
183
+ const priorTrigger = this._triggerFocusEl;
184
+ this._triggerFocusEl = null;
185
+
186
+ this.player.state.floating = null;
187
+ this.player.emit('floatingchange', null);
188
+
189
+ // Restore focus to the element that triggered floating (pinned flow).
190
+ // For scroll-based exits we leave focus untouched.
191
+ if ((reason === 'manual' || reason === 'dismiss') && priorTrigger) {
192
+ try { priorTrigger.focus({ preventScroll: true }); } catch { /* ignore */ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Close button: pause, dismiss, and prevent auto-float until the next
198
+ * user-initiated play event.
199
+ */
200
+ dismiss() {
201
+ if (this._destroyed) return;
202
+ this._autoDismissedThisPlay = true;
203
+ this._armPlayListenerToClearDismiss();
204
+
205
+ try {
206
+ this.player.pause();
207
+ } catch { /* ignore */ }
208
+
209
+ this.exit('dismiss');
210
+ }
211
+
212
+ destroy() {
213
+ if (this._destroyed) return;
214
+ this._destroyed = true;
215
+
216
+ if (this.player.state && this.player.state.floating) {
217
+ try { this.exit('destroy'); } catch { /* ignore */ }
218
+ }
219
+
220
+ if (this.intersectionObserver) {
221
+ try { this.intersectionObserver.disconnect(); } catch { /* ignore */ }
222
+ this.intersectionObserver = null;
223
+ }
224
+ this.observerTarget = null;
225
+
226
+ if (this._onClaim) {
227
+ window.removeEventListener(FLOATING_CLAIM_EVENT, this._onClaim as EventListener);
228
+ this._onClaim = null;
229
+ }
230
+ if (this._onResize) {
231
+ window.removeEventListener('resize', this._onResize);
232
+ this._onResize = null;
233
+ }
234
+ const enterFs = this._onEnterFullscreen;
235
+ if (enterFs) {
236
+ this.player.off('enterfullscreen', enterFs);
237
+ this._onEnterFullscreen = null;
238
+ }
239
+ const playAfterDismiss = this._onPlayAfterDismiss;
240
+ if (playAfterDismiss && this._playListenerAttached) {
241
+ this.player.off('play', playAfterDismiss);
242
+ this._playListenerAttached = false;
243
+ this._onPlayAfterDismiss = null;
244
+ }
245
+ }
246
+
247
+ // ---------------------------------------------------------------
248
+ // Internal: guards
249
+ // ---------------------------------------------------------------
250
+
251
+ _canFloat(reason: FloatingState): boolean {
252
+ if (!this.player.options.floating) return false;
253
+ if (!this.player.container) return false;
254
+ if (!this.player.element || this.player.element.tagName !== 'VIDEO') return false;
255
+ if (this.player.state.fullscreen) return false;
256
+
257
+ // Playlist players move through multiple tracks and manage their own
258
+ // UI; floating support for playlists is out of scope for this v1.
259
+ if (this.player.playlistManager) return false;
260
+
261
+ const minWidth = this.player.options.floatingMinViewportWidth ?? 768;
262
+ if (window.innerWidth < minWidth) return false;
263
+
264
+ // Auto-float only engages when the user is actively watching.
265
+ if (reason === 'auto') {
266
+ if (this._autoDismissedThisPlay) return false;
267
+ if (this.player.state.paused) return false;
268
+ // Never auto-float before the user has started playback; stops
269
+ // auto-entry from firing while the page is still below the fold
270
+ // on initial load.
271
+ if (!this.player.state.hasStartedPlayback) return false;
272
+ }
273
+
274
+ return true;
275
+ }
276
+
277
+ _claimSingleton() {
278
+ try {
279
+ window.dispatchEvent(new CustomEvent(FLOATING_CLAIM_EVENT, {
280
+ detail: { claimId: this._claimId }
281
+ }));
282
+ } catch { /* ignore (older browsers) */ }
283
+ }
284
+
285
+ _setupClaimListener() {
286
+ this._onClaim = (event: Event) => {
287
+ const detail = (event as CustomEvent).detail;
288
+ if (!detail || detail.claimId === this._claimId) return;
289
+ if (this.player.state.floating) {
290
+ this.exit('claim');
291
+ }
292
+ };
293
+ const signal = this.player.lifecycleSignal;
294
+ window.addEventListener(FLOATING_CLAIM_EVENT, this._onClaim as EventListener, { signal });
295
+
296
+ this._onResize = () => {
297
+ const minWidth = this.player.options.floatingMinViewportWidth ?? 768;
298
+ if (this.player.state.floating && window.innerWidth < minWidth) {
299
+ this.exit('auto');
300
+ }
301
+ };
302
+ window.addEventListener('resize', this._onResize, { signal });
303
+ }
304
+
305
+ _setupFullscreenGuard() {
306
+ const onEnterFullscreen = () => {
307
+ if (this.player.state.floating) {
308
+ this.exit('manual');
309
+ }
310
+ };
311
+ this._onEnterFullscreen = onEnterFullscreen;
312
+ this.player.on('enterfullscreen', onEnterFullscreen);
313
+ }
314
+
315
+ _armPlayListenerToClearDismiss() {
316
+ if (this._playListenerAttached) return;
317
+ const onPlayAfterDismiss = () => {
318
+ this._autoDismissedThisPlay = false;
319
+ const handler = this._onPlayAfterDismiss;
320
+ if (handler) {
321
+ this.player.off('play', handler);
322
+ }
323
+ this._playListenerAttached = false;
324
+ this._onPlayAfterDismiss = null;
325
+ };
326
+ this._onPlayAfterDismiss = onPlayAfterDismiss;
327
+ this.player.on('play', onPlayAfterDismiss);
328
+ this._playListenerAttached = true;
329
+ }
330
+
331
+ // ---------------------------------------------------------------
332
+ // Internal: IntersectionObserver for scroll-triggered auto-float
333
+ // ---------------------------------------------------------------
334
+
335
+ _startObserving() {
336
+ if (!('IntersectionObserver' in window)) return;
337
+ if (!this.player.container) return;
338
+
339
+ this.observerTarget = this.player.container;
340
+ this.intersectionObserver = new IntersectionObserver((entries) => {
341
+ const entry = entries[entries.length - 1];
342
+ if (!entry) return;
343
+ this.lastRatio = entry.intersectionRatio;
344
+
345
+ if (this.player.options.debug) {
346
+ try {
347
+ console.log('[vidply:floating] intersection', {
348
+ ratio: Number(entry.intersectionRatio.toFixed(3)),
349
+ state: this.player.state.floating,
350
+ paused: this.player.state.paused,
351
+ hasStartedPlayback: this.player.state.hasStartedPlayback,
352
+ dismissed: this._autoDismissedThisPlay
353
+ });
354
+ } catch { /* ignore */ }
355
+ }
356
+
357
+ if (this.player.state.floating === 'auto') {
358
+ if (entry.intersectionRatio >= 0.5) {
359
+ this.exit('auto');
360
+ }
361
+ return;
362
+ }
363
+
364
+ // Pinned state: scroll is a no-op, the user has taken control.
365
+ if (this.player.state.floating === 'pinned') {
366
+ return;
367
+ }
368
+
369
+ if (entry.intersectionRatio < 0.1 && this._canFloat('auto')) {
370
+ // Brief cooldown after an auto-exit prevents the layout reflow that
371
+ // accompanies the placeholder→container swap from immediately
372
+ // re-triggering auto-float (oscillation guard).
373
+ const AUTO_EXIT_COOLDOWN_MS = 500;
374
+ if (Date.now() - this._lastAutoExitTime < AUTO_EXIT_COOLDOWN_MS) return;
375
+ this.enter('auto');
376
+ }
377
+ }, { threshold: [0, 0.1, 0.5, 0.9] });
378
+
379
+ this.intersectionObserver.observe(this.observerTarget);
380
+ }
381
+
382
+ _retargetObserver(target: HTMLElement) {
383
+ if (!this.intersectionObserver) return;
384
+ if (this.observerTarget) {
385
+ try { this.intersectionObserver.unobserve(this.observerTarget); } catch { /* ignore */ }
386
+ }
387
+ this.observerTarget = target;
388
+ try { this.intersectionObserver.observe(target); } catch { /* ignore */ }
389
+ }
390
+
391
+ // ---------------------------------------------------------------
392
+ // Internal: shell DOM
393
+ // ---------------------------------------------------------------
394
+
395
+ _ensureShell() {
396
+ if (this.shell) return;
397
+
398
+ this.shell = DOMUtils.createElement('div', {
399
+ className: `${this.classPrefix}-floating-shell`,
400
+ attributes: {
401
+ 'role': 'dialog',
402
+ 'aria-modal': 'false',
403
+ 'aria-label': i18n.t('player.floatingPlayer'),
404
+ 'data-vidply-floating': 'true',
405
+ 'tabindex': '-1'
406
+ }
407
+ });
408
+
409
+ this.dragHandle = DOMUtils.createElement('div', {
410
+ className: `${this.classPrefix}-floating-drag-handle`,
411
+ attributes: { 'aria-hidden': 'true' }
412
+ });
413
+ this.shell.appendChild(this.dragHandle);
414
+
415
+ this.closeButton = DOMUtils.createElement('button', {
416
+ className: `${this.classPrefix}-floating-close`,
417
+ attributes: {
418
+ 'type': 'button',
419
+ 'aria-label': i18n.t('player.floatingPlayerClose'),
420
+ 'title': i18n.t('player.floatingPlayerClose')
421
+ }
422
+ }) as HTMLButtonElement;
423
+ this.closeButton.appendChild(createIconElement('close'));
424
+ this.closeButton.addEventListener('click', (event) => {
425
+ event.stopPropagation();
426
+ this.dismiss();
427
+ });
428
+ this.shell.appendChild(this.closeButton);
429
+
430
+ this._createResizeHandles();
431
+ const shell = this.shell;
432
+ this.resizeHandles.forEach(handle => shell.appendChild(handle));
433
+
434
+ this._onKeyDown = (event: KeyboardEvent) => {
435
+ if (event.key === 'Escape') {
436
+ // If the user is in keyboard drag / resize (or pointer-resize)
437
+ // mode, Escape must exit that mode first — not dismiss the
438
+ // whole floating player. This listener is registered before the
439
+ // DraggableResizable one, so we let the event fall through to
440
+ // it (no dismiss, no stopPropagation) while a mode is active
441
+ // (WCAG 2.1.1). Only a "neutral" Escape dismisses.
442
+ const d = this.draggable;
443
+ const inEditMode = !!d && (d.keyboardDragMode || d.keyboardResizeMode || d.pointerResizeMode);
444
+ if (inEditMode) {
445
+ return;
446
+ }
447
+ event.stopPropagation();
448
+ this.dismiss();
449
+ }
450
+ };
451
+ this.shell.addEventListener('keydown', this._onKeyDown as EventListener);
452
+ }
453
+
454
+ _createResizeHandles() {
455
+ // Resize handles are always-on for the floating shell. Do NOT set
456
+ // `data-vidply-managed-resize="true"`: that flag is used by the
457
+ // transcript / sign-language overlays to gate the handles behind
458
+ // an "R" keypress, and DraggableResizable hides managed handles
459
+ // during init() — which would leave the shell with no visible or
460
+ // hittable edges/corners.
461
+ const dirs = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
462
+ this.resizeHandles = dirs.map(dir => DOMUtils.createElement('div', {
463
+ className: `${this.classPrefix}-floating-resize-handle ${this.classPrefix}-floating-resize-${dir}`,
464
+ attributes: {
465
+ 'data-direction': dir,
466
+ 'aria-hidden': 'true'
467
+ }
468
+ }));
469
+ }
470
+
471
+ _teardownShell() {
472
+ if (this.draggable) {
473
+ try { this.draggable.destroy(); } catch { /* ignore */ }
474
+ this.draggable = null;
475
+ }
476
+ if (this.shell) {
477
+ if (this._onKeyDown) {
478
+ this.shell.removeEventListener('keydown', this._onKeyDown as EventListener);
479
+ this._onKeyDown = null;
480
+ }
481
+ if (this.shell.parentNode) {
482
+ this.shell.parentNode.removeChild(this.shell);
483
+ }
484
+ }
485
+ this.shell = null;
486
+ this.dragHandle = null;
487
+ this.closeButton = null;
488
+ this.resizeHandles = [];
489
+ }
490
+
491
+ // ---------------------------------------------------------------
492
+ // Internal: mount / unmount the player.container
493
+ // ---------------------------------------------------------------
494
+
495
+ _mountIntoShell() {
496
+ const container = this.player.container;
497
+ if (!container || !container.parentNode) return;
498
+ if (!this.shell) return;
499
+
500
+ const rect = container.getBoundingClientRect();
501
+ this.originalParent = container.parentNode as HTMLElement;
502
+ this.originalNextSibling = container.nextSibling;
503
+
504
+ this.placeholder = DOMUtils.createElement('div', {
505
+ className: `${this.classPrefix}-floating-placeholder`,
506
+ attributes: { 'aria-hidden': 'true' }
507
+ });
508
+ this.placeholder.style.width = `${Math.max(1, rect.width)}px`;
509
+ this.placeholder.style.height = `${Math.max(1, rect.height)}px`;
510
+
511
+ // Centred PiP glyph that signals "video popped out" in the slot the
512
+ // player used to occupy. Lives as a regular child so it can inherit
513
+ // currentColor and scale with the placeholder; both the icon and
514
+ // the placeholder itself are removed wholesale on _unmountFromShell().
515
+ const placeholderIcon = createIconElement('pip', `${this.classPrefix}-floating-placeholder-icon`);
516
+ this.placeholder.appendChild(placeholderIcon);
517
+
518
+ this.originalParent.insertBefore(this.placeholder, container);
519
+
520
+ this.shell.appendChild(container);
521
+ document.body.appendChild(this.shell);
522
+
523
+ container.classList.add(`${this.classPrefix}-is-floating`);
524
+
525
+ // Observe the placeholder while floating so scroll-back decisions are
526
+ // anchored to the original slot instead of the moved container.
527
+ this._retargetObserver(this.placeholder);
528
+ }
529
+
530
+ _unmountFromShell() {
531
+ const container = this.player.container;
532
+
533
+ if (container) {
534
+ container.classList.remove(`${this.classPrefix}-is-floating`);
535
+ // Clear inline sizes applied while floating
536
+ container.style.removeProperty('width');
537
+ container.style.removeProperty('height');
538
+ }
539
+
540
+ if (this.placeholder && this.placeholder.parentNode) {
541
+ if (container) {
542
+ this.placeholder.parentNode.insertBefore(container, this.placeholder);
543
+ }
544
+ this.placeholder.parentNode.removeChild(this.placeholder);
545
+ } else if (container && this.originalParent) {
546
+ if (this.originalNextSibling && this.originalNextSibling.parentNode === this.originalParent) {
547
+ this.originalParent.insertBefore(container, this.originalNextSibling);
548
+ } else {
549
+ this.originalParent.appendChild(container);
550
+ }
551
+ }
552
+
553
+ this.placeholder = null;
554
+ this.originalParent = null;
555
+ this.originalNextSibling = null;
556
+
557
+ // Retarget the observer to the docked container
558
+ if (container) {
559
+ this._retargetObserver(container);
560
+ }
561
+ }
562
+
563
+ // ---------------------------------------------------------------
564
+ // Internal: initial geometry + drag/resize wiring
565
+ // ---------------------------------------------------------------
566
+
567
+ _applyInitialGeometry() {
568
+ if (!this.shell) return;
569
+
570
+ const prefs = (this.player.storage?.getFloatingPreferences?.() || {}) as FloatingPrefs;
571
+
572
+ const vw = window.innerWidth;
573
+ const vh = window.innerHeight;
574
+
575
+ let width = prefs.width && prefs.width >= MIN_WIDTH ? prefs.width : DEFAULT_WIDTH;
576
+ width = Math.min(width, Math.max(MIN_WIDTH, vw - EDGE_MARGIN * 2));
577
+
578
+ // If we know the source container's aspect ratio, match it by default.
579
+ const containerRect = this.player.container?.getBoundingClientRect();
580
+ const aspect = containerRect && containerRect.height > 0
581
+ ? containerRect.width / containerRect.height
582
+ : 16 / 9;
583
+ const defaultHeight = Math.round(width / aspect);
584
+ let height = prefs.height && prefs.height >= 100 ? prefs.height : defaultHeight;
585
+ height = Math.min(height, Math.max(100, vh - EDGE_MARGIN * 2));
586
+
587
+ let left: number;
588
+ let top: number;
589
+
590
+ if (typeof prefs.left === 'number' && typeof prefs.top === 'number') {
591
+ left = Math.max(EDGE_MARGIN, Math.min(prefs.left, vw - width - EDGE_MARGIN));
592
+ top = Math.max(EDGE_MARGIN, Math.min(prefs.top, vh - height - EDGE_MARGIN));
593
+ } else {
594
+ const pos = this.player.options.floatingPosition || 'bottom-right';
595
+ switch (pos) {
596
+ case 'bottom-left':
597
+ left = EDGE_MARGIN;
598
+ top = vh - height - EDGE_MARGIN;
599
+ break;
600
+ case 'top-right':
601
+ left = vw - width - EDGE_MARGIN;
602
+ top = EDGE_MARGIN;
603
+ break;
604
+ case 'top-left':
605
+ left = EDGE_MARGIN;
606
+ top = EDGE_MARGIN;
607
+ break;
608
+ case 'bottom-right':
609
+ default:
610
+ left = vw - width - EDGE_MARGIN;
611
+ top = vh - height - EDGE_MARGIN;
612
+ break;
613
+ }
614
+ }
615
+
616
+ this.shell.style.width = `${width}px`;
617
+ this.shell.style.height = `${height}px`;
618
+ this.shell.style.left = `${left}px`;
619
+ this.shell.style.top = `${top}px`;
620
+
621
+ this._initDraggable();
622
+ }
623
+
624
+ _initDraggable() {
625
+ if (!this.shell) return;
626
+ if (this.draggable) return;
627
+
628
+ this.draggable = new DraggableResizable(this.shell, {
629
+ dragHandle: this.dragHandle,
630
+ resizeHandles: this.resizeHandles,
631
+ constrainToViewport: true,
632
+ maintainAspectRatio: true,
633
+ minWidth: MIN_WIDTH,
634
+ minHeight: 100,
635
+ maxWidth: () => Math.max(MIN_WIDTH, window.innerWidth - EDGE_MARGIN * 2),
636
+ maxHeight: () => Math.max(100, window.innerHeight - EDGE_MARGIN * 2),
637
+ classPrefix: `${this.classPrefix}-floating`,
638
+ keyboardDragKey: 'd',
639
+ keyboardResizeKey: 'r',
640
+ keyboardStep: 10,
641
+ keyboardStepLarge: 50,
642
+ pointerResizeIndicatorText: i18n.t('player.floatingPlayerDialog'),
643
+ onDragEnd: () => this._savePrefs(),
644
+ onResizeEnd: () => this._savePrefs(),
645
+ onDragStart: (event: Event) => {
646
+ const target = event.target as HTMLElement;
647
+ if (!target) return true;
648
+ // Don't start drag when interacting with the close button or
649
+ // with any control inside the embedded player (play/pause,
650
+ // captions menu, etc.).
651
+ if (target.closest(`.${this.classPrefix}-floating-close`)) return false;
652
+ if (target.closest(`.${this.classPrefix}-controls`)) return false;
653
+ if (target.closest(`.${this.classPrefix}-floating-resize-handle`)) return false;
654
+ return true;
655
+ }
656
+ });
657
+ }
658
+
659
+ _savePrefs() {
660
+ if (!this.shell || !this.player.storage?.saveFloatingPreferences) return;
661
+ const rect = this.shell.getBoundingClientRect();
662
+ this.player.storage.saveFloatingPreferences({
663
+ width: Math.round(rect.width),
664
+ height: Math.round(rect.height),
665
+ left: Math.round(rect.left),
666
+ top: Math.round(rect.top)
667
+ });
668
+ }
669
+
670
+ _activeElement(): HTMLElement | null {
671
+ const active = document.activeElement;
672
+ return active && active instanceof HTMLElement ? active : null;
673
+ }
674
+ }