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.
- package/README.md +974 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
- package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
- package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
- package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
- package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
- package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
- package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
- package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
- package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
- package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
- package/dist/dev/vidply.esm.js +648 -35
- package/dist/dev/vidply.esm.js.map +3 -3
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
- package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
- package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
- package/dist/legacy/vidply.js +1062 -34
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +127 -55
- package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
- package/dist/vidply.css +5852 -5796
- package/dist/vidply.esm.min.meta.json +204 -149
- package/dist/vidply.min.css +1 -1
- package/package.json +11 -11
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4191 -4159
- package/src/controls/KeyboardHelp.ts +326 -0
- package/src/controls/KeyboardManager.ts +387 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MediaSessionManager.ts +341 -0
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2973 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +244 -217
- package/src/i18n/languages/en.ts +245 -218
- package/src/i18n/languages/es.ts +241 -214
- package/src/i18n/languages/fr.ts +241 -214
- package/src/i18n/languages/ja.ts +241 -214
- package/src/icons/Icons.ts +168 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5852 -5796
- package/src/types/events.ts +128 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +199 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
- /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
|
+
}
|