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.
Files changed (164) hide show
  1. package/README.md +946 -946
  2. package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-6RFVF27Z.js} +275 -19
  3. package/dist/dev/vidply.AudioDescriptionManager-6RFVF27Z.js.map +7 -0
  4. package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-R2SCQ6VP.js} +4 -4
  5. package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-EOVEW73H.js} +3 -3
  6. package/dist/dev/vidply.DASHRenderer-EOVEW73H.js.map +7 -0
  7. package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-V5XQWS75.js} +5 -5
  8. package/dist/dev/vidply.FloatingPlayerManager-V5XQWS75.js.map +7 -0
  9. package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZBEFVH5W.js} +4 -4
  10. package/dist/dev/vidply.HLSRenderer-ZBEFVH5W.js.map +7 -0
  11. package/dist/dev/vidply.HTML5Renderer-P7ICKYU4.js +12 -0
  12. package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-7IAJOVF6.js} +8 -8
  13. package/dist/dev/vidply.SignLanguageManager-7IAJOVF6.js.map +7 -0
  14. package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-OA7QQYGN.js} +3 -3
  15. package/dist/dev/vidply.SoundCloudRenderer-OA7QQYGN.js.map +7 -0
  16. package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-BPM3T23L.js} +10 -10
  17. package/dist/dev/vidply.TranscriptManager-BPM3T23L.js.map +7 -0
  18. package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-BRHFYPNJ.js} +3 -3
  19. package/dist/dev/vidply.VimeoRenderer-BRHFYPNJ.js.map +7 -0
  20. package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-HRTN4EJ5.js} +3 -3
  21. package/dist/dev/vidply.YouTubeRenderer-HRTN4EJ5.js.map +7 -0
  22. package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-5IK3IC5V.js} +2 -2
  23. package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-C55OBT5H.js} +2 -2
  24. package/dist/dev/vidply.chunk-C55OBT5H.js.map +7 -0
  25. package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-HOYEMJSD.js} +2 -2
  26. package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-JAKZNGOR.js} +2 -2
  27. package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-LDVFAFGC.js} +5 -5
  28. package/dist/dev/vidply.chunk-LDVFAFGC.js.map +7 -0
  29. package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-NSMAZCMB.js} +6 -6
  30. package/dist/dev/vidply.chunk-NSMAZCMB.js.map +7 -0
  31. package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-RQG3MA7W.js} +2 -2
  32. package/dist/dev/vidply.chunk-RQG3MA7W.js.map +7 -0
  33. package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-UHMVDBDB.js} +3 -3
  34. package/dist/dev/vidply.chunk-UHMVDBDB.js.map +7 -0
  35. package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-W5BR65JY.js} +6 -6
  36. package/dist/dev/vidply.chunk-W5BR65JY.js.map +7 -0
  37. package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-XAK3DPTO.js} +2 -2
  38. package/dist/dev/vidply.chunk-XAK3DPTO.js.map +7 -0
  39. package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-YVCYQZJI.js} +3 -3
  40. package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-5W5LBFSJ.js} +2 -2
  41. package/dist/dev/vidply.de-5W5LBFSJ.js.map +7 -0
  42. package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-SHDAT6L6.js} +2 -2
  43. package/dist/dev/vidply.es-SHDAT6L6.js.map +7 -0
  44. package/dist/dev/vidply.esm.js +73 -34
  45. package/dist/dev/vidply.esm.js.map +2 -2
  46. package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-2JANOMNB.js} +2 -2
  47. package/dist/dev/vidply.fr-2JANOMNB.js.map +7 -0
  48. package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-WWMSTEJH.js} +2 -2
  49. package/dist/dev/vidply.ja-WWMSTEJH.js.map +7 -0
  50. package/dist/legacy/vidply.js +345 -32
  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 +66 -47
  54. package/dist/prod/vidply.AudioDescriptionManager-YTDVDQBO.min.js +6 -0
  55. package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-2AFF3HF2.min.js} +2 -2
  56. package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-URDOTOKS.min.js} +2 -2
  57. package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-QUBFK7CQ.min.js} +2 -2
  58. package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-46KNUVAB.min.js} +2 -2
  59. package/dist/prod/vidply.HTML5Renderer-7TQDMIGY.min.js +6 -0
  60. package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-H4C36EVL.min.js} +2 -2
  61. package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-OYSWMW7D.min.js} +2 -2
  62. package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-BA3DAS63.min.js} +2 -2
  63. package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-NF3COTHU.min.js} +2 -2
  64. package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-UCQKYIJ4.min.js} +2 -2
  65. package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-2HNCP64N.min.js} +1 -1
  66. package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-6ECBIYVI.min.js} +2 -2
  67. package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-7S5AFTST.min.js} +2 -2
  68. package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-EUOA2DJK.min.js} +1 -1
  69. package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-G6KGXNEW.min.js} +1 -1
  70. package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-JPMAK3NS.min.js} +1 -1
  71. package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-L4O44K63.min.js} +1 -1
  72. package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-M76ABMKB.min.js} +1 -1
  73. package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-WRVDVD33.min.js} +2 -2
  74. package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-X2FQEQYR.min.js} +2 -2
  75. package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-XXUH7ADU.min.js} +2 -2
  76. package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-JMCATTKZ.min.js} +1 -1
  77. package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-YVG37V7Z.min.js} +1 -1
  78. package/dist/prod/vidply.esm.min.js +2 -2
  79. package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-OGKGN2R4.min.js} +1 -1
  80. package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-Z6DS7QLS.min.js} +1 -1
  81. package/dist/vidply.css +5796 -5796
  82. package/dist/vidply.esm.min.meta.json +148 -135
  83. package/package.json +7 -7
  84. package/src/controls/CaptionManager.ts +598 -598
  85. package/src/controls/ControlBar.ts +4159 -4159
  86. package/src/controls/KeyboardManager.ts +383 -383
  87. package/src/controls/SettingsDialog.ts +488 -488
  88. package/src/controls/TranscriptManager.ts +2426 -2426
  89. package/src/core/AudioDescriptionManager.ts +863 -774
  90. package/src/core/DescriptionSpeechManager.ts +232 -0
  91. package/src/core/FloatingPlayerManager.ts +674 -674
  92. package/src/core/LazyInit.ts +80 -80
  93. package/src/core/MetadataAlertsManager.ts +514 -514
  94. package/src/core/Player.ts +2915 -2872
  95. package/src/core/PosterManager.ts +169 -169
  96. package/src/core/PseudoFullscreen.ts +179 -179
  97. package/src/core/ResponsiveManager.ts +231 -231
  98. package/src/core/ResumeManager.ts +236 -236
  99. package/src/core/SignLanguageManager.ts +1308 -1308
  100. package/src/core/ThemeManager.ts +157 -157
  101. package/src/features/PlaylistManager.ts +1838 -1827
  102. package/src/i18n/i18n.ts +223 -223
  103. package/src/i18n/languages/de.ts +217 -217
  104. package/src/i18n/languages/en.ts +218 -218
  105. package/src/i18n/languages/es.ts +214 -214
  106. package/src/i18n/languages/fr.ts +214 -214
  107. package/src/i18n/languages/ja.ts +214 -214
  108. package/src/icons/Icons.ts +167 -167
  109. package/src/index.ts +196 -193
  110. package/src/renderers/DASHRenderer.ts +1077 -1077
  111. package/src/renderers/HLSRenderer.ts +944 -944
  112. package/src/renderers/HTML5Renderer.ts +389 -389
  113. package/src/renderers/SoundCloudRenderer.ts +437 -437
  114. package/src/renderers/VimeoRenderer.ts +298 -298
  115. package/src/renderers/YouTubeRenderer.ts +290 -290
  116. package/src/styles/vidply.css +5796 -5796
  117. package/src/types/events.ts +124 -122
  118. package/src/types/globals.d.ts +313 -313
  119. package/src/types/options.ts +185 -182
  120. package/src/types/renderer.ts +58 -58
  121. package/src/utils/DOMUtils.ts +281 -281
  122. package/src/utils/DownloadInfo.ts +171 -171
  123. package/src/utils/DraggablePanel.ts +539 -539
  124. package/src/utils/DraggablePanelMenu.ts +195 -195
  125. package/src/utils/DraggableResizable.ts +997 -997
  126. package/src/utils/EventEmitter.ts +59 -59
  127. package/src/utils/FormUtils.ts +95 -95
  128. package/src/utils/MenuFactory.ts +483 -483
  129. package/src/utils/Sanitize.ts +70 -70
  130. package/src/utils/SettingsMenuFactory.ts +289 -289
  131. package/src/utils/StorageManager.ts +287 -287
  132. package/src/utils/TrackLabelUtils.ts +52 -52
  133. package/src/utils/UrlSafe.ts +69 -69
  134. package/src/utils/VideoFrameCapture.ts +114 -114
  135. package/src/utils/WindowComponents.ts +250 -250
  136. package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
  137. package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
  138. package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
  139. package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
  140. package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
  141. package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
  142. package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
  143. package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
  144. package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
  145. package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
  146. package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
  147. package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
  148. package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
  149. package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
  150. package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
  151. package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
  152. package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
  153. package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
  154. package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
  155. package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
  156. package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
  157. package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
  158. package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
  159. /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-R2SCQ6VP.js.map} +0 -0
  160. /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-P7ICKYU4.js.map} +0 -0
  161. /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-5IK3IC5V.js.map} +0 -0
  162. /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-HOYEMJSD.js.map} +0 -0
  163. /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-JAKZNGOR.js.map} +0 -0
  164. /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-YVCYQZJI.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
+ }