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,1827 +1,1838 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VidPly Playlist Manager
|
|
3
|
-
* Manages playlists for audio and video content
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
7
|
-
import { createIconElement } from '../icons/Icons.js';
|
|
8
|
-
import { i18n } from '../i18n/i18n.js';
|
|
9
|
-
import { TimeUtils } from '../utils/TimeUtils.js';
|
|
10
|
-
import { sanitizePosterUrl, toCssBackgroundImage } from '../utils/UrlSafe.js';
|
|
11
|
-
import { reducedMotionScrollOptions } from '../utils/PerformanceUtils.js';
|
|
12
|
-
import type { Player } from '../core/Player.js';
|
|
13
|
-
|
|
14
|
-
type PlaylistTextTrack = {
|
|
15
|
-
src?: string;
|
|
16
|
-
kind?: string;
|
|
17
|
-
srclang?: string;
|
|
18
|
-
label?: string;
|
|
19
|
-
default?: boolean;
|
|
20
|
-
describedSrc?: string;
|
|
21
|
-
[key: string]: unknown;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
type PlaylistTrack = {
|
|
25
|
-
src?: string;
|
|
26
|
-
type?: string;
|
|
27
|
-
poster?: string;
|
|
28
|
-
tracks?: PlaylistTextTrack[];
|
|
29
|
-
audioDescriptionSrc?: string | null;
|
|
30
|
-
audioDescriptionDuration?: number | string | null;
|
|
31
|
-
signLanguageSrc?: string | null;
|
|
32
|
-
signLanguageSources?: Record<string, string>;
|
|
33
|
-
duration?: number | string | null;
|
|
34
|
-
title?: string;
|
|
35
|
-
artist?: string;
|
|
36
|
-
description?: string;
|
|
37
|
-
[key: string]: unknown;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Construction signature for the host Player class. The playlist
|
|
42
|
-
* manager receives this via options so it can recreate players when
|
|
43
|
-
* tracks of incompatible media types (e.g. audio after video) load.
|
|
44
|
-
*/
|
|
45
|
-
type PlayerConstructor = new (
|
|
46
|
-
element: string | HTMLElement,
|
|
47
|
-
options?: Record<string, unknown>
|
|
48
|
-
) => Player;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Internal options bag for the manager. Extra keys from the caller
|
|
52
|
-
* (we accept `Record<string, unknown>`) are merged in via the
|
|
53
|
-
* `[key: string]: unknown` index so consumers can pass through
|
|
54
|
-
* additional player options without losing typing on the known ones.
|
|
55
|
-
*/
|
|
56
|
-
interface PlaylistManagerOptions {
|
|
57
|
-
autoAdvance: boolean;
|
|
58
|
-
autoPlayFirst: boolean;
|
|
59
|
-
loop: boolean;
|
|
60
|
-
showPanel: boolean;
|
|
61
|
-
recreatePlayers: boolean;
|
|
62
|
-
hostElement?: HTMLElement | null;
|
|
63
|
-
PlayerClass?: PlayerConstructor | null;
|
|
64
|
-
tracks?: PlaylistTrack[];
|
|
65
|
-
[key: string]: unknown;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Static counter for unique IDs
|
|
69
|
-
let playlistInstanceCounter = 0;
|
|
70
|
-
|
|
71
|
-
export class PlaylistManager {
|
|
72
|
-
player: Player;
|
|
73
|
-
container: HTMLElement | null;
|
|
74
|
-
currentIndex: number;
|
|
75
|
-
hostElement: HTMLElement | null;
|
|
76
|
-
initialTracks: PlaylistTrack[];
|
|
77
|
-
instanceId: number;
|
|
78
|
-
isChangingTrack: boolean;
|
|
79
|
-
isPanelVisible: boolean;
|
|
80
|
-
navigationFeedback: HTMLElement | null;
|
|
81
|
-
options: PlaylistManagerOptions;
|
|
82
|
-
PlayerClass: PlayerConstructor | null;
|
|
83
|
-
playlistPanel: HTMLElement | null;
|
|
84
|
-
trackArtworkElement: HTMLElement | null;
|
|
85
|
-
trackInfoElement: HTMLElement | null;
|
|
86
|
-
tracks: PlaylistTrack[];
|
|
87
|
-
uniqueId: string;
|
|
88
|
-
|
|
89
|
-
constructor(player: Player, options: Record<string, unknown> = {}) {
|
|
90
|
-
this.player = player;
|
|
91
|
-
this.tracks = [];
|
|
92
|
-
this.initialTracks = Array.isArray(options.tracks) ? (options.tracks as PlaylistTrack[]) : [];
|
|
93
|
-
this.currentIndex = -1;
|
|
94
|
-
|
|
95
|
-
// Generate unique instance ID for this playlist
|
|
96
|
-
this.instanceId = ++playlistInstanceCounter;
|
|
97
|
-
this.uniqueId = `vidply-playlist-${this.instanceId}`;
|
|
98
|
-
|
|
99
|
-
// Options. Spread the caller's bag last so we still support arbitrary
|
|
100
|
-
// pass-through keys, but defaults stay typed.
|
|
101
|
-
this.options = {
|
|
102
|
-
...options,
|
|
103
|
-
autoAdvance: options.autoAdvance !== false, // Default true
|
|
104
|
-
autoPlayFirst: options.autoPlayFirst !== false, // Default true - auto-play first track on load
|
|
105
|
-
loop: Boolean(options.loop) || false,
|
|
106
|
-
showPanel: options.showPanel !== false, // Default true
|
|
107
|
-
recreatePlayers: Boolean(options.recreatePlayers) || false,
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
// UI elements
|
|
111
|
-
this.container = null;
|
|
112
|
-
this.playlistPanel = null;
|
|
113
|
-
this.trackInfoElement = null;
|
|
114
|
-
this.trackArtworkElement = null;
|
|
115
|
-
this.navigationFeedback = null; // Live region for keyboard navigation feedback
|
|
116
|
-
this.isPanelVisible = this.options.showPanel !== false;
|
|
117
|
-
|
|
118
|
-
// Track change guard to prevent cascade of next() calls
|
|
119
|
-
this.isChangingTrack = false;
|
|
120
|
-
|
|
121
|
-
// Store the host element for player recreation
|
|
122
|
-
this.hostElement = (options.hostElement as HTMLElement | null | undefined) ?? null;
|
|
123
|
-
this.PlayerClass = (options.PlayerClass as PlayerConstructor | null | undefined) ?? null;
|
|
124
|
-
|
|
125
|
-
// Bind methods once so the same reference is used for both on() and off().
|
|
126
|
-
// Binding inline at registration time (e.g. `.bind(this)` in init()) would
|
|
127
|
-
// create a fresh function each call that off() can never match, leaking the
|
|
128
|
-
// listener after destroy().
|
|
129
|
-
this.handleTrackEnd = this.handleTrackEnd.bind(this);
|
|
130
|
-
this.handleTrackError = this.handleTrackError.bind(this);
|
|
131
|
-
this.handlePlaybackStateChange = this.handlePlaybackStateChange.bind(this);
|
|
132
|
-
this.handleFullscreenChange = this.handleFullscreenChange.bind(this);
|
|
133
|
-
this.handleAudioDescriptionChange = this.handleAudioDescriptionChange.bind(this);
|
|
134
|
-
|
|
135
|
-
// Register this playlist manager with the player
|
|
136
|
-
this.player.playlistManager = this;
|
|
137
|
-
|
|
138
|
-
// Initialize
|
|
139
|
-
this.init();
|
|
140
|
-
|
|
141
|
-
// Update controls to add playlist buttons
|
|
142
|
-
this.updatePlayerControls();
|
|
143
|
-
|
|
144
|
-
// Load tracks if provided in options (after UI is ready)
|
|
145
|
-
if (this.initialTracks.length > 0) {
|
|
146
|
-
this.loadPlaylist(this.initialTracks);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Determine the media type for a track
|
|
152
|
-
* @param {Object} track - Track object
|
|
153
|
-
* @returns {string} - 'audio', 'video', 'youtube', 'vimeo', 'soundcloud', 'hls', 'dash'
|
|
154
|
-
*/
|
|
155
|
-
getTrackMediaType(track: PlaylistTrack) {
|
|
156
|
-
const src = track.src || '';
|
|
157
|
-
|
|
158
|
-
if (src.includes('youtube.com') || src.includes('youtu.be')) {
|
|
159
|
-
return 'youtube';
|
|
160
|
-
}
|
|
161
|
-
if (src.includes('vimeo.com')) {
|
|
162
|
-
return 'vimeo';
|
|
163
|
-
}
|
|
164
|
-
if (src.includes('soundcloud.com') || src.includes('api.soundcloud.com')) {
|
|
165
|
-
return 'soundcloud';
|
|
166
|
-
}
|
|
167
|
-
if (src.includes('.m3u8')) {
|
|
168
|
-
return 'hls';
|
|
169
|
-
}
|
|
170
|
-
if (src.includes('.mpd')) {
|
|
171
|
-
return 'dash';
|
|
172
|
-
}
|
|
173
|
-
if (track.type && track.type.startsWith('audio/')) {
|
|
174
|
-
return 'audio';
|
|
175
|
-
}
|
|
176
|
-
// Default to video for video types or unknown
|
|
177
|
-
return 'video';
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Recreate the player with the appropriate element type for the track
|
|
182
|
-
* @param {Object} track - Track to load
|
|
183
|
-
* @param {boolean} autoPlay - Whether to auto-play after creation
|
|
184
|
-
*/
|
|
185
|
-
async recreatePlayerForTrack(track: PlaylistTrack, autoPlay = false) {
|
|
186
|
-
if (!this.hostElement || !this.PlayerClass) {
|
|
187
|
-
console.warn('VidPly Playlist: Cannot recreate player - missing hostElement or PlayerClass');
|
|
188
|
-
return false;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const mediaType = this.getTrackMediaType(track);
|
|
192
|
-
// SoundCloud uses an iframe widget, so it doesn't need an audio element
|
|
193
|
-
// Only local audio files need an actual <audio> element
|
|
194
|
-
const elementType = (mediaType === 'audio') ? 'audio' : 'video';
|
|
195
|
-
|
|
196
|
-
// Store playlist panel state
|
|
197
|
-
const wasVisible = this.isPanelVisible;
|
|
198
|
-
const savedTracks = [...this.tracks]; // Keep track data
|
|
199
|
-
const savedIndex = this.currentIndex;
|
|
200
|
-
|
|
201
|
-
// Detach all playlist UI elements from DOM (keep references)
|
|
202
|
-
// These will be reattached to the new player container
|
|
203
|
-
if (this.trackArtworkElement && this.trackArtworkElement.parentNode) {
|
|
204
|
-
this.trackArtworkElement.parentNode.removeChild(this.trackArtworkElement);
|
|
205
|
-
}
|
|
206
|
-
if (this.trackInfoElement && this.trackInfoElement.parentNode) {
|
|
207
|
-
this.trackInfoElement.parentNode.removeChild(this.trackInfoElement);
|
|
208
|
-
}
|
|
209
|
-
if (this.navigationFeedback && this.navigationFeedback.parentNode) {
|
|
210
|
-
this.navigationFeedback.parentNode.removeChild(this.navigationFeedback);
|
|
211
|
-
}
|
|
212
|
-
if (this.playlistPanel && this.playlistPanel.parentNode) {
|
|
213
|
-
this.playlistPanel.parentNode.removeChild(this.playlistPanel);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Preserve existing player options so recreated players behave
|
|
217
|
-
// consistently. We use a loose `Record<string, unknown>` because
|
|
218
|
-
// PlayerOptions includes optional fields and an open string-indexed
|
|
219
|
-
// bag of pass-through values that the spread flattens.
|
|
220
|
-
const preservedPlayerOptions: Record<string, unknown> =
|
|
221
|
-
this.player?.options ? { ...this.player.options } : {};
|
|
222
|
-
|
|
223
|
-
// Remove event listeners before destroying
|
|
224
|
-
if (this.player) {
|
|
225
|
-
this.player.off('ended', this.handleTrackEnd);
|
|
226
|
-
this.player.off('error', this.handleTrackError);
|
|
227
|
-
// Detach the back-reference *before* destroying the player.
|
|
228
|
-
// Player.destroy() cascade-destroys its `playlistManager`, but here
|
|
229
|
-
// we're swapping the player out and the PlaylistManager owns a UI
|
|
230
|
-
// (panel, track info, artwork) that must survive the swap. Without
|
|
231
|
-
// this detach, the cascade would clear() the panel and the playlist
|
|
232
|
-
// visually disappears when the user clicks an audio track in a
|
|
233
|
-
// mixed playlist.
|
|
234
|
-
this.player.playlistManager = null;
|
|
235
|
-
this.player.destroy();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Clear the host element
|
|
239
|
-
this.hostElement.innerHTML = '';
|
|
240
|
-
|
|
241
|
-
// Create new media element with appropriate type
|
|
242
|
-
const mediaElement = document.createElement(elementType);
|
|
243
|
-
// Respect configured preload (playlists should behave like single videos even with deferLoad)
|
|
244
|
-
const preloadValue = (preservedPlayerOptions.preload as string | undefined) || 'metadata';
|
|
245
|
-
mediaElement.setAttribute('preload', preloadValue);
|
|
246
|
-
|
|
247
|
-
// For video elements with local media, set poster
|
|
248
|
-
if (elementType === 'video' && track.poster &&
|
|
249
|
-
(mediaType === 'video' || mediaType === 'hls' || mediaType === 'dash')) {
|
|
250
|
-
mediaElement.setAttribute('poster', track.poster);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// For external renderers (YouTube, Vimeo, SoundCloud, HLS, DASH), don't add source
|
|
254
|
-
// The renderer will handle the source directly
|
|
255
|
-
const isExternalRenderer = ['youtube', 'vimeo', 'soundcloud', 'hls', 'dash'].includes(mediaType);
|
|
256
|
-
|
|
257
|
-
if (!isExternalRenderer) {
|
|
258
|
-
// Add source for HTML5 media
|
|
259
|
-
const source = document.createElement('source');
|
|
260
|
-
source.src = track.src || '';
|
|
261
|
-
if (track.type) {
|
|
262
|
-
source.type = track.type;
|
|
263
|
-
}
|
|
264
|
-
mediaElement.appendChild(source);
|
|
265
|
-
|
|
266
|
-
// Add tracks (captions, chapters, etc.)
|
|
267
|
-
if (track.tracks && track.tracks.length > 0) {
|
|
268
|
-
track.tracks.forEach((trackConfig: PlaylistTextTrack) => {
|
|
269
|
-
const trackEl = document.createElement('track');
|
|
270
|
-
trackEl.src = trackConfig.src || '';
|
|
271
|
-
trackEl.kind = trackConfig.kind || 'captions';
|
|
272
|
-
trackEl.srclang = trackConfig.srclang || 'en';
|
|
273
|
-
trackEl.label = trackConfig.label || trackConfig.srclang || '';
|
|
274
|
-
if (trackConfig.default) {
|
|
275
|
-
trackEl.default = true;
|
|
276
|
-
}
|
|
277
|
-
mediaElement.appendChild(trackEl);
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
this.hostElement.appendChild(mediaElement);
|
|
283
|
-
|
|
284
|
-
// Create new player with the media element
|
|
285
|
-
// Pass the source for external renderers via options
|
|
286
|
-
const playerOptions = {
|
|
287
|
-
mediaType: elementType,
|
|
288
|
-
poster: track.poster,
|
|
289
|
-
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
290
|
-
audioDescriptionDuration: track.audioDescriptionDuration || null,
|
|
291
|
-
signLanguageSrc: track.signLanguageSrc || null
|
|
292
|
-
};
|
|
293
|
-
// Merge back preserved options (so deferLoad/preload/etc remain active)
|
|
294
|
-
Object.assign(playerOptions, preservedPlayerOptions);
|
|
295
|
-
|
|
296
|
-
this.player = new this.PlayerClass(mediaElement, playerOptions);
|
|
297
|
-
|
|
298
|
-
// Re-register playlist manager
|
|
299
|
-
this.player.playlistManager = this;
|
|
300
|
-
|
|
301
|
-
// Wait for player to be ready
|
|
302
|
-
await new Promise<void>(resolve => {
|
|
303
|
-
this.player.on('ready', () => resolve());
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
// Re-attach event listeners
|
|
307
|
-
this.player.on('ended', this.handleTrackEnd);
|
|
308
|
-
this.player.on('error', this.handleTrackError);
|
|
309
|
-
|
|
310
|
-
// Re-attach all playlist UI elements to the new player's container
|
|
311
|
-
if (this.player.container) {
|
|
312
|
-
// Track artwork goes before video wrapper
|
|
313
|
-
if (this.trackArtworkElement) {
|
|
314
|
-
const videoWrapper = this.player.container.querySelector('.vidply-video-wrapper');
|
|
315
|
-
if (videoWrapper) {
|
|
316
|
-
this.player.container.insertBefore(this.trackArtworkElement, videoWrapper);
|
|
317
|
-
} else {
|
|
318
|
-
this.player.container.appendChild(this.trackArtworkElement);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
// Track info
|
|
322
|
-
if (this.trackInfoElement) {
|
|
323
|
-
this.player.container.appendChild(this.trackInfoElement);
|
|
324
|
-
}
|
|
325
|
-
// Navigation feedback (screen reader only)
|
|
326
|
-
if (this.navigationFeedback) {
|
|
327
|
-
this.player.container.appendChild(this.navigationFeedback);
|
|
328
|
-
}
|
|
329
|
-
// Playlist panel
|
|
330
|
-
if (this.playlistPanel) {
|
|
331
|
-
this.player.container.appendChild(this.playlistPanel);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Update container reference
|
|
336
|
-
this.container = this.player.container;
|
|
337
|
-
|
|
338
|
-
// Update controls (adds playlist prev/next buttons)
|
|
339
|
-
this.updatePlayerControls();
|
|
340
|
-
|
|
341
|
-
// Restore tracks data (we kept it during recreation)
|
|
342
|
-
this.tracks = savedTracks;
|
|
343
|
-
this.currentIndex = savedIndex;
|
|
344
|
-
|
|
345
|
-
// Update playlist UI to reflect current state
|
|
346
|
-
this.updatePlaylistUI();
|
|
347
|
-
|
|
348
|
-
// Restore playlist panel visibility
|
|
349
|
-
this.isPanelVisible = wasVisible;
|
|
350
|
-
if (this.playlistPanel) {
|
|
351
|
-
this.playlistPanel.style.display = wasVisible ? '' : 'none';
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// For external renderers, load the track via player.load()
|
|
355
|
-
// For HTML5, the source is already set on the element
|
|
356
|
-
if (isExternalRenderer) {
|
|
357
|
-
this.player.load({
|
|
358
|
-
src: track.src ?? '',
|
|
359
|
-
type: track.type,
|
|
360
|
-
poster: track.poster,
|
|
361
|
-
tracks: track.tracks || [],
|
|
362
|
-
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
363
|
-
signLanguageSrc: track.signLanguageSrc || null
|
|
364
|
-
});
|
|
365
|
-
} else {
|
|
366
|
-
this.player.load({
|
|
367
|
-
src: track.src ?? '',
|
|
368
|
-
type: track.type,
|
|
369
|
-
poster: track.poster,
|
|
370
|
-
tracks: track.tracks || [],
|
|
371
|
-
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
372
|
-
signLanguageSrc: track.signLanguageSrc || null
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Auto-play if requested
|
|
377
|
-
if (autoPlay) {
|
|
378
|
-
setTimeout(() => {
|
|
379
|
-
this.player.play();
|
|
380
|
-
}, 100);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
return true;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
init() {
|
|
387
|
-
// Listen for track end
|
|
388
|
-
this.player.on('ended', this.handleTrackEnd);
|
|
389
|
-
this.player.on('error', this.handleTrackError);
|
|
390
|
-
|
|
391
|
-
// Listen for playback state changes to show/hide playlist in fullscreen.
|
|
392
|
-
// Handlers are pre-bound in the constructor so destroy() can detach them.
|
|
393
|
-
this.player.on('play', this.handlePlaybackStateChange);
|
|
394
|
-
this.player.on('pause', this.handlePlaybackStateChange);
|
|
395
|
-
this.player.on('ended', this.handlePlaybackStateChange);
|
|
396
|
-
// Use fullscreenchange event which is what the player actually emits
|
|
397
|
-
this.player.on('fullscreenchange', this.handleFullscreenChange);
|
|
398
|
-
|
|
399
|
-
// Listen for audio description state changes to update duration displays
|
|
400
|
-
this.player.on('audiodescriptionenabled', this.handleAudioDescriptionChange);
|
|
401
|
-
this.player.on('audiodescriptiondisabled', this.handleAudioDescriptionChange);
|
|
402
|
-
|
|
403
|
-
// Create UI if needed
|
|
404
|
-
if (this.options.showPanel) {
|
|
405
|
-
this.createUI();
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Check for data-playlist attribute on player container (only if tracks weren't provided in options)
|
|
409
|
-
if (this.tracks.length === 0 && this.initialTracks.length === 0) {
|
|
410
|
-
this.loadPlaylistFromAttribute();
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Load playlist from data-playlist attribute if present
|
|
416
|
-
*/
|
|
417
|
-
loadPlaylistFromAttribute() {
|
|
418
|
-
// Check the original wrapper element for data-playlist
|
|
419
|
-
// Structure: #audio-player -> .vidply-player -> .vidply-video-wrapper -> <audio>
|
|
420
|
-
// So we need to go up 3 levels
|
|
421
|
-
if (!this.player.element || !this.player.element.parentElement) {
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const videoWrapper = this.player.element.parentElement; // .vidply-video-wrapper
|
|
426
|
-
const playerContainer = videoWrapper.parentElement; // .vidply-player
|
|
427
|
-
const originalElement = playerContainer ? playerContainer.parentElement : null; // #audio-player (original div)
|
|
428
|
-
|
|
429
|
-
if (!originalElement) {
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Load playlist options from data attributes
|
|
434
|
-
this.loadOptionsFromAttributes(originalElement);
|
|
435
|
-
|
|
436
|
-
const playlistData = originalElement.getAttribute('data-playlist');
|
|
437
|
-
if (!playlistData) {
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
try {
|
|
442
|
-
const tracks = JSON.parse(playlistData);
|
|
443
|
-
if (Array.isArray(tracks) && tracks.length > 0) {
|
|
444
|
-
this.loadPlaylist(tracks);
|
|
445
|
-
} else {
|
|
446
|
-
console.warn('VidPly Playlist: data-playlist is not a valid array or is empty');
|
|
447
|
-
}
|
|
448
|
-
} catch (error) {
|
|
449
|
-
console.error('VidPly Playlist: Failed to parse data-playlist attribute', error);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Load playlist options from data attributes
|
|
455
|
-
* @param {HTMLElement} element - Element to read attributes from
|
|
456
|
-
*/
|
|
457
|
-
loadOptionsFromAttributes(element: HTMLElement) {
|
|
458
|
-
// data-playlist-auto-advance
|
|
459
|
-
const autoAdvance = element.getAttribute('data-playlist-auto-advance');
|
|
460
|
-
if (autoAdvance !== null) {
|
|
461
|
-
this.options.autoAdvance = autoAdvance === 'true';
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// data-playlist-auto-play-first
|
|
465
|
-
const autoPlayFirst = element.getAttribute('data-playlist-auto-play-first');
|
|
466
|
-
if (autoPlayFirst !== null) {
|
|
467
|
-
this.options.autoPlayFirst = autoPlayFirst === 'true';
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// data-playlist-loop
|
|
471
|
-
const loop = element.getAttribute('data-playlist-loop');
|
|
472
|
-
if (loop !== null) {
|
|
473
|
-
this.options.loop = loop === 'true';
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// data-playlist-show-panel
|
|
477
|
-
const showPanel = element.getAttribute('data-playlist-show-panel');
|
|
478
|
-
if (showPanel !== null) {
|
|
479
|
-
this.options.showPanel = showPanel === 'true';
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* Update player controls to add playlist navigation buttons
|
|
485
|
-
*/
|
|
486
|
-
updatePlayerControls() {
|
|
487
|
-
if (!this.player.controlBar) return;
|
|
488
|
-
|
|
489
|
-
const controlBar = this.player.controlBar;
|
|
490
|
-
|
|
491
|
-
// Clear existing controls content (except the element itself)
|
|
492
|
-
controlBar.element.innerHTML = '';
|
|
493
|
-
|
|
494
|
-
// Recreate controls with playlist buttons now available
|
|
495
|
-
controlBar.createControls();
|
|
496
|
-
|
|
497
|
-
// Reattach events for the new controls
|
|
498
|
-
controlBar.attachEvents();
|
|
499
|
-
controlBar.setupAutoHide();
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* Load a playlist
|
|
504
|
-
* @param {Array} tracks - Array of track objects
|
|
505
|
-
*/
|
|
506
|
-
loadPlaylist(tracks: PlaylistTrack[]) {
|
|
507
|
-
this.tracks = tracks;
|
|
508
|
-
this.currentIndex = -1;
|
|
509
|
-
|
|
510
|
-
// Add playlist class to container
|
|
511
|
-
if (this.container) {
|
|
512
|
-
this.container.classList.add('vidply-has-playlist');
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Update UI
|
|
516
|
-
if (this.playlistPanel) {
|
|
517
|
-
this.renderPlaylist();
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// Auto-play first track (if enabled)
|
|
521
|
-
if (tracks.length > 0) {
|
|
522
|
-
if (this.options.autoPlayFirst) {
|
|
523
|
-
this.play(0);
|
|
524
|
-
} else {
|
|
525
|
-
// Behave like a single video: load the first track (metadata/manifest)
|
|
526
|
-
// but do not start playback.
|
|
527
|
-
void this.loadTrack(0).catch(() => {
|
|
528
|
-
// ignore
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Update visibility based on current state
|
|
534
|
-
this.updatePlaylistVisibilityInFullscreen();
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Load a track without playing
|
|
539
|
-
* This is the playlist equivalent of a "single video initialized but not started yet":
|
|
540
|
-
* it updates UI selection and loads the media into the player so metadata/manifests
|
|
541
|
-
* and feature managers can be ready, but it does not start playback.
|
|
542
|
-
* @param {number} index - Track index
|
|
543
|
-
*/
|
|
544
|
-
async loadTrack(index: number) {
|
|
545
|
-
if (index < 0 || index >= this.tracks.length) {
|
|
546
|
-
console.warn('VidPly Playlist: Invalid track index', index);
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const track = this.tracks[index];
|
|
551
|
-
if (!track) return;
|
|
552
|
-
|
|
553
|
-
// Always update UI immediately (poster, buttons, duration, etc.).
|
|
554
|
-
// Note: this is UI-only; actual media loading is performed by player.load() below.
|
|
555
|
-
this.selectTrack(index);
|
|
556
|
-
|
|
557
|
-
// Set guard flag to prevent cascade of next() calls during track change
|
|
558
|
-
this.isChangingTrack = true;
|
|
559
|
-
|
|
560
|
-
// Check if we should recreate the player for this track type
|
|
561
|
-
if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
|
|
562
|
-
const currentMediaType = this.player ?
|
|
563
|
-
(this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
|
|
564
|
-
const newMediaType = this.getTrackMediaType(track);
|
|
565
|
-
const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
|
|
566
|
-
|
|
567
|
-
// Recreate if element type is different
|
|
568
|
-
if (currentMediaType !== newElementType) {
|
|
569
|
-
await this.recreatePlayerForTrack(track, false);
|
|
570
|
-
// Re-apply selection to the newly created player (poster/tracks/buttons)
|
|
571
|
-
this.selectTrack(index);
|
|
572
|
-
|
|
573
|
-
// Emit event
|
|
574
|
-
this.player.emit('playlisttrackchange', {
|
|
575
|
-
index: index,
|
|
576
|
-
item: track,
|
|
577
|
-
total: this.tracks.length
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
// Clear guard flag
|
|
581
|
-
setTimeout(() => {
|
|
582
|
-
this.isChangingTrack = false;
|
|
583
|
-
}, 150);
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
const loadPromise = this.player.load({
|
|
589
|
-
src: track.src ?? '',
|
|
590
|
-
type: track.type,
|
|
591
|
-
poster: track.poster,
|
|
592
|
-
tracks: track.tracks || [],
|
|
593
|
-
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
594
|
-
signLanguageSrc: track.signLanguageSrc || null,
|
|
595
|
-
signLanguageSources: track.signLanguageSources || {}
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
// For playlist UX parity with single videos: fetch metadata/manifest now,
|
|
599
|
-
// but do not start playback.
|
|
600
|
-
if (this.player?.options?.deferLoad && typeof this.player.ensureLoaded === 'function') {
|
|
601
|
-
Promise.resolve(loadPromise)
|
|
602
|
-
.then(() => this.player?.ensureLoaded?.())
|
|
603
|
-
.catch(() => {
|
|
604
|
-
// ignore
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Emit event
|
|
609
|
-
this.player.emit('playlisttrackchange', {
|
|
610
|
-
index: index,
|
|
611
|
-
item: track,
|
|
612
|
-
total: this.tracks.length
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
// Clear guard flag after a short delay to ensure track is loaded
|
|
616
|
-
setTimeout(() => {
|
|
617
|
-
this.isChangingTrack = false;
|
|
618
|
-
}, 150);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Select a track (UI/selection only; does NOT set the media src / does NOT initialize renderer)
|
|
623
|
-
*
|
|
624
|
-
* In "B always" playlist mode, you typically want `loadTrack()` on selection so the
|
|
625
|
-
* selected item behaves like a single video (metadata/manifest loaded, features ready)
|
|
626
|
-
* without auto-playing.
|
|
627
|
-
* @param {number} index - Track index
|
|
628
|
-
*/
|
|
629
|
-
selectTrack(index: number) {
|
|
630
|
-
if (index < 0 || index >= this.tracks.length) {
|
|
631
|
-
console.warn('VidPly Playlist: Invalid track index', index);
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
const track = this.tracks[index];
|
|
636
|
-
if (!track) return;
|
|
637
|
-
this.currentIndex = index;
|
|
638
|
-
|
|
639
|
-
// Apply per-track metadata without touching the media source.
|
|
640
|
-
// This ensures poster + feature buttons (chapters/captions/transcript/sign-language)
|
|
641
|
-
// can be updated instantly even before any media network activity happens.
|
|
642
|
-
try {
|
|
643
|
-
// Poster for video element — validate the URL from the manifest
|
|
644
|
-
// before we hand it to `<video>.poster`. Without the allow-list an
|
|
645
|
-
// attacker-controlled `javascript:` or `data:text/html` value could
|
|
646
|
-
// reach the attribute.
|
|
647
|
-
if (this.player?.element?.tagName === 'VIDEO') {
|
|
648
|
-
if (track.poster) {
|
|
649
|
-
const resolved: string = typeof this.player.resolvePosterPath === 'function'
|
|
650
|
-
? this.player.resolvePosterPath(track.poster)
|
|
651
|
-
: track.poster;
|
|
652
|
-
const posterUrl = sanitizePosterUrl(resolved);
|
|
653
|
-
if (posterUrl) {
|
|
654
|
-
(this.player.element as HTMLVideoElement).poster = posterUrl;
|
|
655
|
-
(this.player as Player & { applyPosterAspectRatio?: (url: string) => void }).applyPosterAspectRatio?.(posterUrl);
|
|
656
|
-
} else {
|
|
657
|
-
this.player.element.removeAttribute('poster');
|
|
658
|
-
}
|
|
659
|
-
} else {
|
|
660
|
-
this.player.element.removeAttribute('poster');
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Update sign language / audio description sources (used for button visibility)
|
|
665
|
-
this.player.audioDescriptionSrc = track.audioDescriptionSrc || null;
|
|
666
|
-
this.player.signLanguageSrc = track.signLanguageSrc || null;
|
|
667
|
-
this.player.signLanguageSources = track.signLanguageSources || {};
|
|
668
|
-
|
|
669
|
-
// Fill duration early for UI (progress/time display) without loading media
|
|
670
|
-
if (track.duration && Number(track.duration) > 0) {
|
|
671
|
-
this.player.state.duration = Number(track.duration);
|
|
672
|
-
}
|
|
673
|
-
// Also sync feature managers (they keep their own copy of sources)
|
|
674
|
-
if (this.player.audioDescriptionManager) {
|
|
675
|
-
this.player.audioDescriptionManager.src = track.audioDescriptionSrc || null;
|
|
676
|
-
// Remember original (non-described) source for switching back later
|
|
677
|
-
this.player.audioDescriptionManager.originalSource = track.src || this.player.originalSrc || null;
|
|
678
|
-
}
|
|
679
|
-
if (this.player.signLanguageManager) {
|
|
680
|
-
this.player.signLanguageManager.src = track.signLanguageSrc || null;
|
|
681
|
-
this.player.signLanguageManager.sources = track.signLanguageSources || {};
|
|
682
|
-
this.player.signLanguageManager.currentLanguage = null;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// For audio description switching, remember original source even before first play
|
|
686
|
-
if (track.src && !this.player.originalSrc) {
|
|
687
|
-
this.player.originalSrc = track.src;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
// Replace <track> elements so captions/chapters/transcript can be detected/loaded
|
|
691
|
-
const existing: HTMLElement[] = Array.from(this.player.element.querySelectorAll('track'));
|
|
692
|
-
existing.forEach(t => t.remove());
|
|
693
|
-
|
|
694
|
-
if (Array.isArray(track.tracks)) {
|
|
695
|
-
track.tracks.forEach((tc: PlaylistTextTrack) => {
|
|
696
|
-
if (!tc?.src) return;
|
|
697
|
-
const el = document.createElement('track');
|
|
698
|
-
el.src = tc.src;
|
|
699
|
-
el.kind = tc.kind || 'captions';
|
|
700
|
-
el.srclang = tc.srclang || 'en';
|
|
701
|
-
el.label = tc.label || tc.srclang || 'Track';
|
|
702
|
-
if (tc.default) el.default = true;
|
|
703
|
-
if (tc.describedSrc) {
|
|
704
|
-
el.setAttribute('data-desc-src', tc.describedSrc);
|
|
705
|
-
}
|
|
706
|
-
this.player.element.appendChild(el);
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
711
|
-
this.player.invalidateTrackCache();
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Re-scan described-track metadata for AudioDescriptionManager
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
//
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
this.
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
//
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
if (
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
this.
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
*
|
|
961
|
-
*/
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
this
|
|
1090
|
-
|
|
1091
|
-
// Create
|
|
1092
|
-
this.
|
|
1093
|
-
className: 'vidply-
|
|
1094
|
-
attributes: {
|
|
1095
|
-
role: 'status'
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
this.container.appendChild(this.
|
|
1101
|
-
|
|
1102
|
-
// Create
|
|
1103
|
-
this.
|
|
1104
|
-
className: 'vidply-
|
|
1105
|
-
attributes: {
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
'aria-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
this.
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
const
|
|
1135
|
-
const
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
:
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
const
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
</
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
this.trackInfoElement.
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
//
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
//
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
// Create
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
'
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
if (
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
//
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
//
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
//
|
|
1398
|
-
if (track.
|
|
1399
|
-
const
|
|
1400
|
-
className: 'vidply-playlist-item-
|
|
1401
|
-
});
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
//
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
e.
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
e.
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
e.
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
e.
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
e.
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
if (
|
|
1570
|
-
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
if (
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
button.setAttribute('
|
|
1624
|
-
|
|
1625
|
-
//
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
/**
|
|
1683
|
-
*
|
|
1684
|
-
*/
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
this.
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
if (this.
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
this.
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
if
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
this.
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
// Remove
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1
|
+
/**
|
|
2
|
+
* VidPly Playlist Manager
|
|
3
|
+
* Manages playlists for audio and video content
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DOMUtils } from '../utils/DOMUtils.js';
|
|
7
|
+
import { createIconElement } from '../icons/Icons.js';
|
|
8
|
+
import { i18n } from '../i18n/i18n.js';
|
|
9
|
+
import { TimeUtils } from '../utils/TimeUtils.js';
|
|
10
|
+
import { sanitizePosterUrl, toCssBackgroundImage } from '../utils/UrlSafe.js';
|
|
11
|
+
import { reducedMotionScrollOptions } from '../utils/PerformanceUtils.js';
|
|
12
|
+
import type { Player } from '../core/Player.js';
|
|
13
|
+
|
|
14
|
+
type PlaylistTextTrack = {
|
|
15
|
+
src?: string;
|
|
16
|
+
kind?: string;
|
|
17
|
+
srclang?: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
default?: boolean;
|
|
20
|
+
describedSrc?: string;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type PlaylistTrack = {
|
|
25
|
+
src?: string;
|
|
26
|
+
type?: string;
|
|
27
|
+
poster?: string;
|
|
28
|
+
tracks?: PlaylistTextTrack[];
|
|
29
|
+
audioDescriptionSrc?: string | null;
|
|
30
|
+
audioDescriptionDuration?: number | string | null;
|
|
31
|
+
signLanguageSrc?: string | null;
|
|
32
|
+
signLanguageSources?: Record<string, string>;
|
|
33
|
+
duration?: number | string | null;
|
|
34
|
+
title?: string;
|
|
35
|
+
artist?: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Construction signature for the host Player class. The playlist
|
|
42
|
+
* manager receives this via options so it can recreate players when
|
|
43
|
+
* tracks of incompatible media types (e.g. audio after video) load.
|
|
44
|
+
*/
|
|
45
|
+
type PlayerConstructor = new (
|
|
46
|
+
element: string | HTMLElement,
|
|
47
|
+
options?: Record<string, unknown>
|
|
48
|
+
) => Player;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Internal options bag for the manager. Extra keys from the caller
|
|
52
|
+
* (we accept `Record<string, unknown>`) are merged in via the
|
|
53
|
+
* `[key: string]: unknown` index so consumers can pass through
|
|
54
|
+
* additional player options without losing typing on the known ones.
|
|
55
|
+
*/
|
|
56
|
+
interface PlaylistManagerOptions {
|
|
57
|
+
autoAdvance: boolean;
|
|
58
|
+
autoPlayFirst: boolean;
|
|
59
|
+
loop: boolean;
|
|
60
|
+
showPanel: boolean;
|
|
61
|
+
recreatePlayers: boolean;
|
|
62
|
+
hostElement?: HTMLElement | null;
|
|
63
|
+
PlayerClass?: PlayerConstructor | null;
|
|
64
|
+
tracks?: PlaylistTrack[];
|
|
65
|
+
[key: string]: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Static counter for unique IDs
|
|
69
|
+
let playlistInstanceCounter = 0;
|
|
70
|
+
|
|
71
|
+
export class PlaylistManager {
|
|
72
|
+
player: Player;
|
|
73
|
+
container: HTMLElement | null;
|
|
74
|
+
currentIndex: number;
|
|
75
|
+
hostElement: HTMLElement | null;
|
|
76
|
+
initialTracks: PlaylistTrack[];
|
|
77
|
+
instanceId: number;
|
|
78
|
+
isChangingTrack: boolean;
|
|
79
|
+
isPanelVisible: boolean;
|
|
80
|
+
navigationFeedback: HTMLElement | null;
|
|
81
|
+
options: PlaylistManagerOptions;
|
|
82
|
+
PlayerClass: PlayerConstructor | null;
|
|
83
|
+
playlistPanel: HTMLElement | null;
|
|
84
|
+
trackArtworkElement: HTMLElement | null;
|
|
85
|
+
trackInfoElement: HTMLElement | null;
|
|
86
|
+
tracks: PlaylistTrack[];
|
|
87
|
+
uniqueId: string;
|
|
88
|
+
|
|
89
|
+
constructor(player: Player, options: Record<string, unknown> = {}) {
|
|
90
|
+
this.player = player;
|
|
91
|
+
this.tracks = [];
|
|
92
|
+
this.initialTracks = Array.isArray(options.tracks) ? (options.tracks as PlaylistTrack[]) : [];
|
|
93
|
+
this.currentIndex = -1;
|
|
94
|
+
|
|
95
|
+
// Generate unique instance ID for this playlist
|
|
96
|
+
this.instanceId = ++playlistInstanceCounter;
|
|
97
|
+
this.uniqueId = `vidply-playlist-${this.instanceId}`;
|
|
98
|
+
|
|
99
|
+
// Options. Spread the caller's bag last so we still support arbitrary
|
|
100
|
+
// pass-through keys, but defaults stay typed.
|
|
101
|
+
this.options = {
|
|
102
|
+
...options,
|
|
103
|
+
autoAdvance: options.autoAdvance !== false, // Default true
|
|
104
|
+
autoPlayFirst: options.autoPlayFirst !== false, // Default true - auto-play first track on load
|
|
105
|
+
loop: Boolean(options.loop) || false,
|
|
106
|
+
showPanel: options.showPanel !== false, // Default true
|
|
107
|
+
recreatePlayers: Boolean(options.recreatePlayers) || false,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// UI elements
|
|
111
|
+
this.container = null;
|
|
112
|
+
this.playlistPanel = null;
|
|
113
|
+
this.trackInfoElement = null;
|
|
114
|
+
this.trackArtworkElement = null;
|
|
115
|
+
this.navigationFeedback = null; // Live region for keyboard navigation feedback
|
|
116
|
+
this.isPanelVisible = this.options.showPanel !== false;
|
|
117
|
+
|
|
118
|
+
// Track change guard to prevent cascade of next() calls
|
|
119
|
+
this.isChangingTrack = false;
|
|
120
|
+
|
|
121
|
+
// Store the host element for player recreation
|
|
122
|
+
this.hostElement = (options.hostElement as HTMLElement | null | undefined) ?? null;
|
|
123
|
+
this.PlayerClass = (options.PlayerClass as PlayerConstructor | null | undefined) ?? null;
|
|
124
|
+
|
|
125
|
+
// Bind methods once so the same reference is used for both on() and off().
|
|
126
|
+
// Binding inline at registration time (e.g. `.bind(this)` in init()) would
|
|
127
|
+
// create a fresh function each call that off() can never match, leaking the
|
|
128
|
+
// listener after destroy().
|
|
129
|
+
this.handleTrackEnd = this.handleTrackEnd.bind(this);
|
|
130
|
+
this.handleTrackError = this.handleTrackError.bind(this);
|
|
131
|
+
this.handlePlaybackStateChange = this.handlePlaybackStateChange.bind(this);
|
|
132
|
+
this.handleFullscreenChange = this.handleFullscreenChange.bind(this);
|
|
133
|
+
this.handleAudioDescriptionChange = this.handleAudioDescriptionChange.bind(this);
|
|
134
|
+
|
|
135
|
+
// Register this playlist manager with the player
|
|
136
|
+
this.player.playlistManager = this;
|
|
137
|
+
|
|
138
|
+
// Initialize
|
|
139
|
+
this.init();
|
|
140
|
+
|
|
141
|
+
// Update controls to add playlist buttons
|
|
142
|
+
this.updatePlayerControls();
|
|
143
|
+
|
|
144
|
+
// Load tracks if provided in options (after UI is ready)
|
|
145
|
+
if (this.initialTracks.length > 0) {
|
|
146
|
+
this.loadPlaylist(this.initialTracks);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Determine the media type for a track
|
|
152
|
+
* @param {Object} track - Track object
|
|
153
|
+
* @returns {string} - 'audio', 'video', 'youtube', 'vimeo', 'soundcloud', 'hls', 'dash'
|
|
154
|
+
*/
|
|
155
|
+
getTrackMediaType(track: PlaylistTrack) {
|
|
156
|
+
const src = track.src || '';
|
|
157
|
+
|
|
158
|
+
if (src.includes('youtube.com') || src.includes('youtu.be')) {
|
|
159
|
+
return 'youtube';
|
|
160
|
+
}
|
|
161
|
+
if (src.includes('vimeo.com')) {
|
|
162
|
+
return 'vimeo';
|
|
163
|
+
}
|
|
164
|
+
if (src.includes('soundcloud.com') || src.includes('api.soundcloud.com')) {
|
|
165
|
+
return 'soundcloud';
|
|
166
|
+
}
|
|
167
|
+
if (src.includes('.m3u8')) {
|
|
168
|
+
return 'hls';
|
|
169
|
+
}
|
|
170
|
+
if (src.includes('.mpd')) {
|
|
171
|
+
return 'dash';
|
|
172
|
+
}
|
|
173
|
+
if (track.type && track.type.startsWith('audio/')) {
|
|
174
|
+
return 'audio';
|
|
175
|
+
}
|
|
176
|
+
// Default to video for video types or unknown
|
|
177
|
+
return 'video';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Recreate the player with the appropriate element type for the track
|
|
182
|
+
* @param {Object} track - Track to load
|
|
183
|
+
* @param {boolean} autoPlay - Whether to auto-play after creation
|
|
184
|
+
*/
|
|
185
|
+
async recreatePlayerForTrack(track: PlaylistTrack, autoPlay = false) {
|
|
186
|
+
if (!this.hostElement || !this.PlayerClass) {
|
|
187
|
+
console.warn('VidPly Playlist: Cannot recreate player - missing hostElement or PlayerClass');
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const mediaType = this.getTrackMediaType(track);
|
|
192
|
+
// SoundCloud uses an iframe widget, so it doesn't need an audio element
|
|
193
|
+
// Only local audio files need an actual <audio> element
|
|
194
|
+
const elementType = (mediaType === 'audio') ? 'audio' : 'video';
|
|
195
|
+
|
|
196
|
+
// Store playlist panel state
|
|
197
|
+
const wasVisible = this.isPanelVisible;
|
|
198
|
+
const savedTracks = [...this.tracks]; // Keep track data
|
|
199
|
+
const savedIndex = this.currentIndex;
|
|
200
|
+
|
|
201
|
+
// Detach all playlist UI elements from DOM (keep references)
|
|
202
|
+
// These will be reattached to the new player container
|
|
203
|
+
if (this.trackArtworkElement && this.trackArtworkElement.parentNode) {
|
|
204
|
+
this.trackArtworkElement.parentNode.removeChild(this.trackArtworkElement);
|
|
205
|
+
}
|
|
206
|
+
if (this.trackInfoElement && this.trackInfoElement.parentNode) {
|
|
207
|
+
this.trackInfoElement.parentNode.removeChild(this.trackInfoElement);
|
|
208
|
+
}
|
|
209
|
+
if (this.navigationFeedback && this.navigationFeedback.parentNode) {
|
|
210
|
+
this.navigationFeedback.parentNode.removeChild(this.navigationFeedback);
|
|
211
|
+
}
|
|
212
|
+
if (this.playlistPanel && this.playlistPanel.parentNode) {
|
|
213
|
+
this.playlistPanel.parentNode.removeChild(this.playlistPanel);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Preserve existing player options so recreated players behave
|
|
217
|
+
// consistently. We use a loose `Record<string, unknown>` because
|
|
218
|
+
// PlayerOptions includes optional fields and an open string-indexed
|
|
219
|
+
// bag of pass-through values that the spread flattens.
|
|
220
|
+
const preservedPlayerOptions: Record<string, unknown> =
|
|
221
|
+
this.player?.options ? { ...this.player.options } : {};
|
|
222
|
+
|
|
223
|
+
// Remove event listeners before destroying
|
|
224
|
+
if (this.player) {
|
|
225
|
+
this.player.off('ended', this.handleTrackEnd);
|
|
226
|
+
this.player.off('error', this.handleTrackError);
|
|
227
|
+
// Detach the back-reference *before* destroying the player.
|
|
228
|
+
// Player.destroy() cascade-destroys its `playlistManager`, but here
|
|
229
|
+
// we're swapping the player out and the PlaylistManager owns a UI
|
|
230
|
+
// (panel, track info, artwork) that must survive the swap. Without
|
|
231
|
+
// this detach, the cascade would clear() the panel and the playlist
|
|
232
|
+
// visually disappears when the user clicks an audio track in a
|
|
233
|
+
// mixed playlist.
|
|
234
|
+
this.player.playlistManager = null;
|
|
235
|
+
this.player.destroy();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Clear the host element
|
|
239
|
+
this.hostElement.innerHTML = '';
|
|
240
|
+
|
|
241
|
+
// Create new media element with appropriate type
|
|
242
|
+
const mediaElement = document.createElement(elementType);
|
|
243
|
+
// Respect configured preload (playlists should behave like single videos even with deferLoad)
|
|
244
|
+
const preloadValue = (preservedPlayerOptions.preload as string | undefined) || 'metadata';
|
|
245
|
+
mediaElement.setAttribute('preload', preloadValue);
|
|
246
|
+
|
|
247
|
+
// For video elements with local media, set poster
|
|
248
|
+
if (elementType === 'video' && track.poster &&
|
|
249
|
+
(mediaType === 'video' || mediaType === 'hls' || mediaType === 'dash')) {
|
|
250
|
+
mediaElement.setAttribute('poster', track.poster);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// For external renderers (YouTube, Vimeo, SoundCloud, HLS, DASH), don't add source
|
|
254
|
+
// The renderer will handle the source directly
|
|
255
|
+
const isExternalRenderer = ['youtube', 'vimeo', 'soundcloud', 'hls', 'dash'].includes(mediaType);
|
|
256
|
+
|
|
257
|
+
if (!isExternalRenderer) {
|
|
258
|
+
// Add source for HTML5 media
|
|
259
|
+
const source = document.createElement('source');
|
|
260
|
+
source.src = track.src || '';
|
|
261
|
+
if (track.type) {
|
|
262
|
+
source.type = track.type;
|
|
263
|
+
}
|
|
264
|
+
mediaElement.appendChild(source);
|
|
265
|
+
|
|
266
|
+
// Add tracks (captions, chapters, etc.)
|
|
267
|
+
if (track.tracks && track.tracks.length > 0) {
|
|
268
|
+
track.tracks.forEach((trackConfig: PlaylistTextTrack) => {
|
|
269
|
+
const trackEl = document.createElement('track');
|
|
270
|
+
trackEl.src = trackConfig.src || '';
|
|
271
|
+
trackEl.kind = trackConfig.kind || 'captions';
|
|
272
|
+
trackEl.srclang = trackConfig.srclang || 'en';
|
|
273
|
+
trackEl.label = trackConfig.label || trackConfig.srclang || '';
|
|
274
|
+
if (trackConfig.default) {
|
|
275
|
+
trackEl.default = true;
|
|
276
|
+
}
|
|
277
|
+
mediaElement.appendChild(trackEl);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.hostElement.appendChild(mediaElement);
|
|
283
|
+
|
|
284
|
+
// Create new player with the media element
|
|
285
|
+
// Pass the source for external renderers via options
|
|
286
|
+
const playerOptions = {
|
|
287
|
+
mediaType: elementType,
|
|
288
|
+
poster: track.poster,
|
|
289
|
+
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
290
|
+
audioDescriptionDuration: track.audioDescriptionDuration || null,
|
|
291
|
+
signLanguageSrc: track.signLanguageSrc || null
|
|
292
|
+
};
|
|
293
|
+
// Merge back preserved options (so deferLoad/preload/etc remain active)
|
|
294
|
+
Object.assign(playerOptions, preservedPlayerOptions);
|
|
295
|
+
|
|
296
|
+
this.player = new this.PlayerClass(mediaElement, playerOptions);
|
|
297
|
+
|
|
298
|
+
// Re-register playlist manager
|
|
299
|
+
this.player.playlistManager = this;
|
|
300
|
+
|
|
301
|
+
// Wait for player to be ready
|
|
302
|
+
await new Promise<void>(resolve => {
|
|
303
|
+
this.player.on('ready', () => resolve());
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Re-attach event listeners
|
|
307
|
+
this.player.on('ended', this.handleTrackEnd);
|
|
308
|
+
this.player.on('error', this.handleTrackError);
|
|
309
|
+
|
|
310
|
+
// Re-attach all playlist UI elements to the new player's container
|
|
311
|
+
if (this.player.container) {
|
|
312
|
+
// Track artwork goes before video wrapper
|
|
313
|
+
if (this.trackArtworkElement) {
|
|
314
|
+
const videoWrapper = this.player.container.querySelector('.vidply-video-wrapper');
|
|
315
|
+
if (videoWrapper) {
|
|
316
|
+
this.player.container.insertBefore(this.trackArtworkElement, videoWrapper);
|
|
317
|
+
} else {
|
|
318
|
+
this.player.container.appendChild(this.trackArtworkElement);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Track info
|
|
322
|
+
if (this.trackInfoElement) {
|
|
323
|
+
this.player.container.appendChild(this.trackInfoElement);
|
|
324
|
+
}
|
|
325
|
+
// Navigation feedback (screen reader only)
|
|
326
|
+
if (this.navigationFeedback) {
|
|
327
|
+
this.player.container.appendChild(this.navigationFeedback);
|
|
328
|
+
}
|
|
329
|
+
// Playlist panel
|
|
330
|
+
if (this.playlistPanel) {
|
|
331
|
+
this.player.container.appendChild(this.playlistPanel);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Update container reference
|
|
336
|
+
this.container = this.player.container;
|
|
337
|
+
|
|
338
|
+
// Update controls (adds playlist prev/next buttons)
|
|
339
|
+
this.updatePlayerControls();
|
|
340
|
+
|
|
341
|
+
// Restore tracks data (we kept it during recreation)
|
|
342
|
+
this.tracks = savedTracks;
|
|
343
|
+
this.currentIndex = savedIndex;
|
|
344
|
+
|
|
345
|
+
// Update playlist UI to reflect current state
|
|
346
|
+
this.updatePlaylistUI();
|
|
347
|
+
|
|
348
|
+
// Restore playlist panel visibility
|
|
349
|
+
this.isPanelVisible = wasVisible;
|
|
350
|
+
if (this.playlistPanel) {
|
|
351
|
+
this.playlistPanel.style.display = wasVisible ? '' : 'none';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// For external renderers, load the track via player.load()
|
|
355
|
+
// For HTML5, the source is already set on the element
|
|
356
|
+
if (isExternalRenderer) {
|
|
357
|
+
this.player.load({
|
|
358
|
+
src: track.src ?? '',
|
|
359
|
+
type: track.type,
|
|
360
|
+
poster: track.poster,
|
|
361
|
+
tracks: track.tracks || [],
|
|
362
|
+
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
363
|
+
signLanguageSrc: track.signLanguageSrc || null
|
|
364
|
+
});
|
|
365
|
+
} else {
|
|
366
|
+
this.player.load({
|
|
367
|
+
src: track.src ?? '',
|
|
368
|
+
type: track.type,
|
|
369
|
+
poster: track.poster,
|
|
370
|
+
tracks: track.tracks || [],
|
|
371
|
+
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
372
|
+
signLanguageSrc: track.signLanguageSrc || null
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Auto-play if requested
|
|
377
|
+
if (autoPlay) {
|
|
378
|
+
setTimeout(() => {
|
|
379
|
+
this.player.play();
|
|
380
|
+
}, 100);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
init() {
|
|
387
|
+
// Listen for track end
|
|
388
|
+
this.player.on('ended', this.handleTrackEnd);
|
|
389
|
+
this.player.on('error', this.handleTrackError);
|
|
390
|
+
|
|
391
|
+
// Listen for playback state changes to show/hide playlist in fullscreen.
|
|
392
|
+
// Handlers are pre-bound in the constructor so destroy() can detach them.
|
|
393
|
+
this.player.on('play', this.handlePlaybackStateChange);
|
|
394
|
+
this.player.on('pause', this.handlePlaybackStateChange);
|
|
395
|
+
this.player.on('ended', this.handlePlaybackStateChange);
|
|
396
|
+
// Use fullscreenchange event which is what the player actually emits
|
|
397
|
+
this.player.on('fullscreenchange', this.handleFullscreenChange);
|
|
398
|
+
|
|
399
|
+
// Listen for audio description state changes to update duration displays
|
|
400
|
+
this.player.on('audiodescriptionenabled', this.handleAudioDescriptionChange);
|
|
401
|
+
this.player.on('audiodescriptiondisabled', this.handleAudioDescriptionChange);
|
|
402
|
+
|
|
403
|
+
// Create UI if needed
|
|
404
|
+
if (this.options.showPanel) {
|
|
405
|
+
this.createUI();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Check for data-playlist attribute on player container (only if tracks weren't provided in options)
|
|
409
|
+
if (this.tracks.length === 0 && this.initialTracks.length === 0) {
|
|
410
|
+
this.loadPlaylistFromAttribute();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Load playlist from data-playlist attribute if present
|
|
416
|
+
*/
|
|
417
|
+
loadPlaylistFromAttribute() {
|
|
418
|
+
// Check the original wrapper element for data-playlist
|
|
419
|
+
// Structure: #audio-player -> .vidply-player -> .vidply-video-wrapper -> <audio>
|
|
420
|
+
// So we need to go up 3 levels
|
|
421
|
+
if (!this.player.element || !this.player.element.parentElement) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const videoWrapper = this.player.element.parentElement; // .vidply-video-wrapper
|
|
426
|
+
const playerContainer = videoWrapper.parentElement; // .vidply-player
|
|
427
|
+
const originalElement = playerContainer ? playerContainer.parentElement : null; // #audio-player (original div)
|
|
428
|
+
|
|
429
|
+
if (!originalElement) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Load playlist options from data attributes
|
|
434
|
+
this.loadOptionsFromAttributes(originalElement);
|
|
435
|
+
|
|
436
|
+
const playlistData = originalElement.getAttribute('data-playlist');
|
|
437
|
+
if (!playlistData) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const tracks = JSON.parse(playlistData);
|
|
443
|
+
if (Array.isArray(tracks) && tracks.length > 0) {
|
|
444
|
+
this.loadPlaylist(tracks);
|
|
445
|
+
} else {
|
|
446
|
+
console.warn('VidPly Playlist: data-playlist is not a valid array or is empty');
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
console.error('VidPly Playlist: Failed to parse data-playlist attribute', error);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Load playlist options from data attributes
|
|
455
|
+
* @param {HTMLElement} element - Element to read attributes from
|
|
456
|
+
*/
|
|
457
|
+
loadOptionsFromAttributes(element: HTMLElement) {
|
|
458
|
+
// data-playlist-auto-advance
|
|
459
|
+
const autoAdvance = element.getAttribute('data-playlist-auto-advance');
|
|
460
|
+
if (autoAdvance !== null) {
|
|
461
|
+
this.options.autoAdvance = autoAdvance === 'true';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// data-playlist-auto-play-first
|
|
465
|
+
const autoPlayFirst = element.getAttribute('data-playlist-auto-play-first');
|
|
466
|
+
if (autoPlayFirst !== null) {
|
|
467
|
+
this.options.autoPlayFirst = autoPlayFirst === 'true';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// data-playlist-loop
|
|
471
|
+
const loop = element.getAttribute('data-playlist-loop');
|
|
472
|
+
if (loop !== null) {
|
|
473
|
+
this.options.loop = loop === 'true';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// data-playlist-show-panel
|
|
477
|
+
const showPanel = element.getAttribute('data-playlist-show-panel');
|
|
478
|
+
if (showPanel !== null) {
|
|
479
|
+
this.options.showPanel = showPanel === 'true';
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Update player controls to add playlist navigation buttons
|
|
485
|
+
*/
|
|
486
|
+
updatePlayerControls() {
|
|
487
|
+
if (!this.player.controlBar) return;
|
|
488
|
+
|
|
489
|
+
const controlBar = this.player.controlBar;
|
|
490
|
+
|
|
491
|
+
// Clear existing controls content (except the element itself)
|
|
492
|
+
controlBar.element.innerHTML = '';
|
|
493
|
+
|
|
494
|
+
// Recreate controls with playlist buttons now available
|
|
495
|
+
controlBar.createControls();
|
|
496
|
+
|
|
497
|
+
// Reattach events for the new controls
|
|
498
|
+
controlBar.attachEvents();
|
|
499
|
+
controlBar.setupAutoHide();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Load a playlist
|
|
504
|
+
* @param {Array} tracks - Array of track objects
|
|
505
|
+
*/
|
|
506
|
+
loadPlaylist(tracks: PlaylistTrack[]) {
|
|
507
|
+
this.tracks = tracks;
|
|
508
|
+
this.currentIndex = -1;
|
|
509
|
+
|
|
510
|
+
// Add playlist class to container
|
|
511
|
+
if (this.container) {
|
|
512
|
+
this.container.classList.add('vidply-has-playlist');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Update UI
|
|
516
|
+
if (this.playlistPanel) {
|
|
517
|
+
this.renderPlaylist();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Auto-play first track (if enabled)
|
|
521
|
+
if (tracks.length > 0) {
|
|
522
|
+
if (this.options.autoPlayFirst) {
|
|
523
|
+
this.play(0);
|
|
524
|
+
} else {
|
|
525
|
+
// Behave like a single video: load the first track (metadata/manifest)
|
|
526
|
+
// but do not start playback.
|
|
527
|
+
void this.loadTrack(0).catch(() => {
|
|
528
|
+
// ignore
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Update visibility based on current state
|
|
534
|
+
this.updatePlaylistVisibilityInFullscreen();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Load a track without playing
|
|
539
|
+
* This is the playlist equivalent of a "single video initialized but not started yet":
|
|
540
|
+
* it updates UI selection and loads the media into the player so metadata/manifests
|
|
541
|
+
* and feature managers can be ready, but it does not start playback.
|
|
542
|
+
* @param {number} index - Track index
|
|
543
|
+
*/
|
|
544
|
+
async loadTrack(index: number) {
|
|
545
|
+
if (index < 0 || index >= this.tracks.length) {
|
|
546
|
+
console.warn('VidPly Playlist: Invalid track index', index);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const track = this.tracks[index];
|
|
551
|
+
if (!track) return;
|
|
552
|
+
|
|
553
|
+
// Always update UI immediately (poster, buttons, duration, etc.).
|
|
554
|
+
// Note: this is UI-only; actual media loading is performed by player.load() below.
|
|
555
|
+
this.selectTrack(index);
|
|
556
|
+
|
|
557
|
+
// Set guard flag to prevent cascade of next() calls during track change
|
|
558
|
+
this.isChangingTrack = true;
|
|
559
|
+
|
|
560
|
+
// Check if we should recreate the player for this track type
|
|
561
|
+
if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
|
|
562
|
+
const currentMediaType = this.player ?
|
|
563
|
+
(this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
|
|
564
|
+
const newMediaType = this.getTrackMediaType(track);
|
|
565
|
+
const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
|
|
566
|
+
|
|
567
|
+
// Recreate if element type is different
|
|
568
|
+
if (currentMediaType !== newElementType) {
|
|
569
|
+
await this.recreatePlayerForTrack(track, false);
|
|
570
|
+
// Re-apply selection to the newly created player (poster/tracks/buttons)
|
|
571
|
+
this.selectTrack(index);
|
|
572
|
+
|
|
573
|
+
// Emit event
|
|
574
|
+
this.player.emit('playlisttrackchange', {
|
|
575
|
+
index: index,
|
|
576
|
+
item: track,
|
|
577
|
+
total: this.tracks.length
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Clear guard flag
|
|
581
|
+
setTimeout(() => {
|
|
582
|
+
this.isChangingTrack = false;
|
|
583
|
+
}, 150);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const loadPromise = this.player.load({
|
|
589
|
+
src: track.src ?? '',
|
|
590
|
+
type: track.type,
|
|
591
|
+
poster: track.poster,
|
|
592
|
+
tracks: track.tracks || [],
|
|
593
|
+
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
594
|
+
signLanguageSrc: track.signLanguageSrc || null,
|
|
595
|
+
signLanguageSources: track.signLanguageSources || {}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// For playlist UX parity with single videos: fetch metadata/manifest now,
|
|
599
|
+
// but do not start playback.
|
|
600
|
+
if (this.player?.options?.deferLoad && typeof this.player.ensureLoaded === 'function') {
|
|
601
|
+
Promise.resolve(loadPromise)
|
|
602
|
+
.then(() => this.player?.ensureLoaded?.())
|
|
603
|
+
.catch(() => {
|
|
604
|
+
// ignore
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Emit event
|
|
609
|
+
this.player.emit('playlisttrackchange', {
|
|
610
|
+
index: index,
|
|
611
|
+
item: track,
|
|
612
|
+
total: this.tracks.length
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
// Clear guard flag after a short delay to ensure track is loaded
|
|
616
|
+
setTimeout(() => {
|
|
617
|
+
this.isChangingTrack = false;
|
|
618
|
+
}, 150);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Select a track (UI/selection only; does NOT set the media src / does NOT initialize renderer)
|
|
623
|
+
*
|
|
624
|
+
* In "B always" playlist mode, you typically want `loadTrack()` on selection so the
|
|
625
|
+
* selected item behaves like a single video (metadata/manifest loaded, features ready)
|
|
626
|
+
* without auto-playing.
|
|
627
|
+
* @param {number} index - Track index
|
|
628
|
+
*/
|
|
629
|
+
selectTrack(index: number) {
|
|
630
|
+
if (index < 0 || index >= this.tracks.length) {
|
|
631
|
+
console.warn('VidPly Playlist: Invalid track index', index);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const track = this.tracks[index];
|
|
636
|
+
if (!track) return;
|
|
637
|
+
this.currentIndex = index;
|
|
638
|
+
|
|
639
|
+
// Apply per-track metadata without touching the media source.
|
|
640
|
+
// This ensures poster + feature buttons (chapters/captions/transcript/sign-language)
|
|
641
|
+
// can be updated instantly even before any media network activity happens.
|
|
642
|
+
try {
|
|
643
|
+
// Poster for video element — validate the URL from the manifest
|
|
644
|
+
// before we hand it to `<video>.poster`. Without the allow-list an
|
|
645
|
+
// attacker-controlled `javascript:` or `data:text/html` value could
|
|
646
|
+
// reach the attribute.
|
|
647
|
+
if (this.player?.element?.tagName === 'VIDEO') {
|
|
648
|
+
if (track.poster) {
|
|
649
|
+
const resolved: string = typeof this.player.resolvePosterPath === 'function'
|
|
650
|
+
? this.player.resolvePosterPath(track.poster)
|
|
651
|
+
: track.poster;
|
|
652
|
+
const posterUrl = sanitizePosterUrl(resolved);
|
|
653
|
+
if (posterUrl) {
|
|
654
|
+
(this.player.element as HTMLVideoElement).poster = posterUrl;
|
|
655
|
+
(this.player as Player & { applyPosterAspectRatio?: (url: string) => void }).applyPosterAspectRatio?.(posterUrl);
|
|
656
|
+
} else {
|
|
657
|
+
this.player.element.removeAttribute('poster');
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
this.player.element.removeAttribute('poster');
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Update sign language / audio description sources (used for button visibility)
|
|
665
|
+
this.player.audioDescriptionSrc = track.audioDescriptionSrc || null;
|
|
666
|
+
this.player.signLanguageSrc = track.signLanguageSrc || null;
|
|
667
|
+
this.player.signLanguageSources = track.signLanguageSources || {};
|
|
668
|
+
|
|
669
|
+
// Fill duration early for UI (progress/time display) without loading media
|
|
670
|
+
if (track.duration && Number(track.duration) > 0) {
|
|
671
|
+
this.player.state.duration = Number(track.duration);
|
|
672
|
+
}
|
|
673
|
+
// Also sync feature managers (they keep their own copy of sources)
|
|
674
|
+
if (this.player.audioDescriptionManager) {
|
|
675
|
+
this.player.audioDescriptionManager.src = track.audioDescriptionSrc || null;
|
|
676
|
+
// Remember original (non-described) source for switching back later
|
|
677
|
+
this.player.audioDescriptionManager.originalSource = track.src || this.player.originalSrc || null;
|
|
678
|
+
}
|
|
679
|
+
if (this.player.signLanguageManager) {
|
|
680
|
+
this.player.signLanguageManager.src = track.signLanguageSrc || null;
|
|
681
|
+
this.player.signLanguageManager.sources = track.signLanguageSources || {};
|
|
682
|
+
this.player.signLanguageManager.currentLanguage = null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// For audio description switching, remember original source even before first play
|
|
686
|
+
if (track.src && !this.player.originalSrc) {
|
|
687
|
+
this.player.originalSrc = track.src;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Replace <track> elements so captions/chapters/transcript can be detected/loaded
|
|
691
|
+
const existing: HTMLElement[] = Array.from(this.player.element.querySelectorAll('track'));
|
|
692
|
+
existing.forEach(t => t.remove());
|
|
693
|
+
|
|
694
|
+
if (Array.isArray(track.tracks)) {
|
|
695
|
+
track.tracks.forEach((tc: PlaylistTextTrack) => {
|
|
696
|
+
if (!tc?.src) return;
|
|
697
|
+
const el = document.createElement('track');
|
|
698
|
+
el.src = tc.src;
|
|
699
|
+
el.kind = tc.kind || 'captions';
|
|
700
|
+
el.srclang = tc.srclang || 'en';
|
|
701
|
+
el.label = tc.label || tc.srclang || 'Track';
|
|
702
|
+
if (tc.default) el.default = true;
|
|
703
|
+
if (tc.describedSrc) {
|
|
704
|
+
el.setAttribute('data-desc-src', tc.describedSrc);
|
|
705
|
+
}
|
|
706
|
+
this.player.element.appendChild(el);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (typeof this.player.invalidateTrackCache === 'function') {
|
|
711
|
+
this.player.invalidateTrackCache();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Re-scan described-track metadata for the AudioDescriptionManager. The
|
|
715
|
+
// manager is no longer eagerly loaded on content-less players, so if this
|
|
716
|
+
// track is the first to introduce described content, pull the chunk in
|
|
717
|
+
// lazily before re-scanning.
|
|
718
|
+
const reinitAudioDescription = (adm: Player['audioDescriptionManager']) => {
|
|
719
|
+
if (!adm || typeof adm.initFromSourceElements !== 'function') return;
|
|
720
|
+
try {
|
|
721
|
+
adm.captionTracks = [];
|
|
722
|
+
adm.initFromSourceElements(this.player.sourceElements, this.player.trackElements);
|
|
723
|
+
} catch {
|
|
724
|
+
// ignore
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
if (this.player.audioDescriptionManager) {
|
|
728
|
+
reinitAudioDescription(this.player.audioDescriptionManager);
|
|
729
|
+
} else if (this.player.hasAudioDescriptionContent?.()) {
|
|
730
|
+
void this.player.ensureAudioDescriptionManager()
|
|
731
|
+
.then(reinitAudioDescription)
|
|
732
|
+
.catch(() => { /* ignore */ });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Refresh caption/transcript managers so menus reflect newly injected <track> elements
|
|
736
|
+
// (important when we defer MP4/MP3 loading but still want VTT-based UI to work).
|
|
737
|
+
if (this.player.captionManager && typeof this.player.captionManager.loadTracks === 'function') {
|
|
738
|
+
try {
|
|
739
|
+
this.player.captionManager.tracks = [];
|
|
740
|
+
this.player.captionManager.currentTrack = null;
|
|
741
|
+
this.player.captionManager.loadTracks();
|
|
742
|
+
} catch {
|
|
743
|
+
// ignore
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// TranscriptManager reads from TextTracks too; it will be correct after media starts.
|
|
748
|
+
// For now, we just ensure control bar is rebuilt so the button is present.
|
|
749
|
+
|
|
750
|
+
// Rebuild controls so feature buttons appear immediately
|
|
751
|
+
if (typeof this.player.updateControlBar === 'function') {
|
|
752
|
+
this.player.updateControlBar();
|
|
753
|
+
}
|
|
754
|
+
} catch {
|
|
755
|
+
// ignore preview errors; selection should still work
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
this.updateTrackInfo(track);
|
|
759
|
+
this.updatePlaylistUI();
|
|
760
|
+
|
|
761
|
+
this.player.emit('playlisttrackselect', {
|
|
762
|
+
index,
|
|
763
|
+
item: track,
|
|
764
|
+
total: this.tracks.length
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Play a specific track
|
|
770
|
+
* @param {number} index - Track index
|
|
771
|
+
* @param {boolean} userInitiated - Whether this was triggered by user action (default: false)
|
|
772
|
+
*/
|
|
773
|
+
async play(index: number, _userInitiated = false) {
|
|
774
|
+
if (index < 0 || index >= this.tracks.length) {
|
|
775
|
+
console.warn('VidPly Playlist: Invalid track index', index);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const track = this.tracks[index];
|
|
780
|
+
if (!track) return;
|
|
781
|
+
|
|
782
|
+
// Set guard flag to prevent cascade of next() calls during track change
|
|
783
|
+
this.isChangingTrack = true;
|
|
784
|
+
|
|
785
|
+
// Update current index
|
|
786
|
+
this.currentIndex = index;
|
|
787
|
+
|
|
788
|
+
// Check if we should recreate the player for this track type
|
|
789
|
+
if (this.options.recreatePlayers && this.hostElement && this.PlayerClass) {
|
|
790
|
+
const currentMediaType = this.player ?
|
|
791
|
+
(this.player.element.tagName === 'AUDIO' ? 'audio' : 'video') : null;
|
|
792
|
+
const newMediaType = this.getTrackMediaType(track);
|
|
793
|
+
const newElementType = (newMediaType === 'audio' || newMediaType === 'soundcloud') ? 'audio' : 'video';
|
|
794
|
+
|
|
795
|
+
// Recreate if element type is different
|
|
796
|
+
if (currentMediaType !== newElementType) {
|
|
797
|
+
await this.recreatePlayerForTrack(track, true); // true = autoPlay
|
|
798
|
+
// Update UI after recreation
|
|
799
|
+
this.updateTrackInfo(track);
|
|
800
|
+
this.updatePlaylistUI();
|
|
801
|
+
|
|
802
|
+
// Emit event
|
|
803
|
+
this.player.emit('playlisttrackchange', {
|
|
804
|
+
index: index,
|
|
805
|
+
item: track,
|
|
806
|
+
total: this.tracks.length
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Clear guard flag
|
|
810
|
+
setTimeout(() => {
|
|
811
|
+
this.isChangingTrack = false;
|
|
812
|
+
}, 150);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Load track into player (normal path)
|
|
818
|
+
// If audio description was toggled before the first play, load the described source directly.
|
|
819
|
+
let srcToLoad = track.src;
|
|
820
|
+
if (this.player?.audioDescriptionManager?.desiredState && track.audioDescriptionSrc) {
|
|
821
|
+
this.player.originalSrc = track.src ?? null;
|
|
822
|
+
this.player.audioDescriptionManager.originalSource = track.src ?? null;
|
|
823
|
+
this.player.audioDescriptionManager.src = track.audioDescriptionSrc;
|
|
824
|
+
srcToLoad = track.audioDescriptionSrc;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
this.player.load({
|
|
828
|
+
src: srcToLoad ?? '',
|
|
829
|
+
type: track.type,
|
|
830
|
+
poster: track.poster,
|
|
831
|
+
tracks: track.tracks || [],
|
|
832
|
+
audioDescriptionSrc: track.audioDescriptionSrc || null,
|
|
833
|
+
signLanguageSrc: track.signLanguageSrc || null,
|
|
834
|
+
signLanguageSources: track.signLanguageSources || {}
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
// Update UI
|
|
838
|
+
this.updateTrackInfo(track);
|
|
839
|
+
this.updatePlaylistUI();
|
|
840
|
+
|
|
841
|
+
// Emit event
|
|
842
|
+
this.player.emit('playlisttrackchange', {
|
|
843
|
+
index: index,
|
|
844
|
+
item: track,
|
|
845
|
+
total: this.tracks.length
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
// Auto-play and clear guard flag after playback starts
|
|
849
|
+
setTimeout(() => {
|
|
850
|
+
this.player.play();
|
|
851
|
+
// Clear guard flag after a short delay to ensure track has started
|
|
852
|
+
setTimeout(() => {
|
|
853
|
+
this.isChangingTrack = false;
|
|
854
|
+
}, 50);
|
|
855
|
+
}, 100);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Play next track
|
|
860
|
+
*/
|
|
861
|
+
next() {
|
|
862
|
+
let nextIndex = this.currentIndex + 1;
|
|
863
|
+
|
|
864
|
+
if (nextIndex >= this.tracks.length) {
|
|
865
|
+
if (this.options.loop) {
|
|
866
|
+
nextIndex = 0;
|
|
867
|
+
} else {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
this.play(nextIndex);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Play previous track
|
|
877
|
+
*/
|
|
878
|
+
previous() {
|
|
879
|
+
let prevIndex = this.currentIndex - 1;
|
|
880
|
+
|
|
881
|
+
if (prevIndex < 0) {
|
|
882
|
+
if (this.options.loop) {
|
|
883
|
+
prevIndex = this.tracks.length - 1;
|
|
884
|
+
} else {
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
this.play(prevIndex);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Handle track end
|
|
894
|
+
*/
|
|
895
|
+
handleTrackEnd() {
|
|
896
|
+
// Don't auto-advance if we're already in the process of changing tracks
|
|
897
|
+
// This prevents a cascade of next() calls when loading a new track triggers an 'ended' event
|
|
898
|
+
if (this.isChangingTrack) {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
if (this.options.autoAdvance) {
|
|
903
|
+
this.next();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Check if a source URL requires an external renderer
|
|
909
|
+
* @param {string} src - Source URL
|
|
910
|
+
* @returns {boolean}
|
|
911
|
+
*/
|
|
912
|
+
isExternalRendererUrl(src: string | null | undefined) {
|
|
913
|
+
if (!src) return false;
|
|
914
|
+
return src.includes('youtube.com') ||
|
|
915
|
+
src.includes('youtu.be') ||
|
|
916
|
+
src.includes('vimeo.com') ||
|
|
917
|
+
src.includes('soundcloud.com') ||
|
|
918
|
+
src.includes('api.soundcloud.com') ||
|
|
919
|
+
src.includes('.m3u8') ||
|
|
920
|
+
src.includes('.mpd');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Handle track error
|
|
925
|
+
*/
|
|
926
|
+
handleTrackError(e: unknown) {
|
|
927
|
+
// Don't auto-advance for external renderer tracks
|
|
928
|
+
// External renderers (YouTube, Vimeo, SoundCloud, HLS, DASH) may trigger HTML5 errors
|
|
929
|
+
// that should be ignored since the external renderer handles playback
|
|
930
|
+
const currentTrack = this.getCurrentTrack();
|
|
931
|
+
if (currentTrack && currentTrack.src && this.isExternalRendererUrl(currentTrack.src)) {
|
|
932
|
+
// Silently ignore errors for external renderer tracks
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Don't auto-advance if we're in the process of changing tracks
|
|
937
|
+
// This prevents a cascade of next() calls when switching between renderer types
|
|
938
|
+
if (this.isChangingTrack) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
console.error('VidPly Playlist: Track error', e);
|
|
943
|
+
|
|
944
|
+
// Try next track
|
|
945
|
+
if (this.options.autoAdvance) {
|
|
946
|
+
setTimeout(() => {
|
|
947
|
+
this.next();
|
|
948
|
+
}, 1000);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Handle playback state changes (for fullscreen playlist visibility)
|
|
954
|
+
*/
|
|
955
|
+
handlePlaybackStateChange() {
|
|
956
|
+
this.updatePlaylistVisibilityInFullscreen();
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Handle fullscreen state changes
|
|
961
|
+
*/
|
|
962
|
+
handleFullscreenChange() {
|
|
963
|
+
// Use a small delay to ensure fullscreen state is fully applied
|
|
964
|
+
setTimeout(() => {
|
|
965
|
+
this.updatePlaylistVisibilityInFullscreen();
|
|
966
|
+
}, 50);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Handle audio description state changes
|
|
971
|
+
* Updates duration displays to show audio-described version duration when AD is enabled
|
|
972
|
+
*/
|
|
973
|
+
handleAudioDescriptionChange() {
|
|
974
|
+
const currentTrack = this.getCurrentTrack();
|
|
975
|
+
if (!currentTrack) return;
|
|
976
|
+
|
|
977
|
+
// Update the track info display with the appropriate duration
|
|
978
|
+
this.updateTrackInfo(currentTrack);
|
|
979
|
+
|
|
980
|
+
// Update the playlist UI to reflect duration changes (aria-labels)
|
|
981
|
+
this.updatePlaylistUI();
|
|
982
|
+
|
|
983
|
+
// Update visual duration elements in playlist panel
|
|
984
|
+
this.updatePlaylistDurations();
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Update the visual duration displays in the playlist panel
|
|
989
|
+
* Called when audio description state changes
|
|
990
|
+
*/
|
|
991
|
+
updatePlaylistDurations() {
|
|
992
|
+
if (!this.playlistPanel) return;
|
|
993
|
+
|
|
994
|
+
const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
|
|
995
|
+
|
|
996
|
+
items.forEach((item: Element, index: number) => {
|
|
997
|
+
const track = this.tracks[index];
|
|
998
|
+
if (!track) return;
|
|
999
|
+
|
|
1000
|
+
const effectiveDuration = this.getEffectiveDuration(track);
|
|
1001
|
+
const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
|
|
1002
|
+
|
|
1003
|
+
// Update duration badge on thumbnail (if exists)
|
|
1004
|
+
const durationBadge = item.querySelector('.vidply-playlist-duration-badge');
|
|
1005
|
+
if (durationBadge) {
|
|
1006
|
+
durationBadge.textContent = trackDuration;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Update inline duration (if exists)
|
|
1010
|
+
const inlineDuration = item.querySelector('.vidply-playlist-item-duration');
|
|
1011
|
+
if (inlineDuration) {
|
|
1012
|
+
inlineDuration.textContent = trackDuration;
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Get the effective duration for a track based on audio description state
|
|
1019
|
+
* @param {Object} track - Track object
|
|
1020
|
+
* @returns {number|null} - Duration in seconds or null if not available
|
|
1021
|
+
*/
|
|
1022
|
+
getEffectiveDuration(track: PlaylistTrack) {
|
|
1023
|
+
if (!track) return null;
|
|
1024
|
+
|
|
1025
|
+
const isAudioDescriptionEnabled = this.player.state.audioDescriptionEnabled;
|
|
1026
|
+
|
|
1027
|
+
// If audio description is enabled and track has audioDescriptionDuration, use it
|
|
1028
|
+
if (isAudioDescriptionEnabled && track.audioDescriptionDuration) {
|
|
1029
|
+
return track.audioDescriptionDuration;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Otherwise use regular duration
|
|
1033
|
+
return track.duration || null;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Update playlist visibility based on fullscreen and playback state
|
|
1038
|
+
* In fullscreen: show when paused/not started, hide when playing
|
|
1039
|
+
* Outside fullscreen: respect original panel visibility setting
|
|
1040
|
+
*/
|
|
1041
|
+
updatePlaylistVisibilityInFullscreen() {
|
|
1042
|
+
const playlistPanel = this.playlistPanel;
|
|
1043
|
+
if (!playlistPanel || !this.tracks.length) return;
|
|
1044
|
+
|
|
1045
|
+
const isFullscreen = this.player.state.fullscreen;
|
|
1046
|
+
const isPlaying = this.player.state.playing;
|
|
1047
|
+
|
|
1048
|
+
if (isFullscreen) {
|
|
1049
|
+
// In fullscreen: show only when not playing (paused or not started)
|
|
1050
|
+
// Check playing state explicitly since paused might not be set initially
|
|
1051
|
+
if (!isPlaying) {
|
|
1052
|
+
playlistPanel.classList.add('vidply-playlist-fullscreen-visible');
|
|
1053
|
+
playlistPanel.style.display = 'block';
|
|
1054
|
+
} else {
|
|
1055
|
+
playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
|
|
1056
|
+
// Add a smooth fade out with delay to match CSS transition
|
|
1057
|
+
setTimeout(() => {
|
|
1058
|
+
// Double-check state hasn't changed before hiding
|
|
1059
|
+
if (this.player.state.playing && this.player.state.fullscreen) {
|
|
1060
|
+
playlistPanel.style.display = 'none';
|
|
1061
|
+
}
|
|
1062
|
+
}, 300); // Match CSS transition duration
|
|
1063
|
+
}
|
|
1064
|
+
} else {
|
|
1065
|
+
// Outside fullscreen: restore original behavior
|
|
1066
|
+
playlistPanel.classList.remove('vidply-playlist-fullscreen-visible');
|
|
1067
|
+
if (this.isPanelVisible && this.tracks.length > 0) {
|
|
1068
|
+
playlistPanel.style.display = 'block';
|
|
1069
|
+
} else {
|
|
1070
|
+
playlistPanel.style.display = 'none';
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Create playlist UI
|
|
1077
|
+
*/
|
|
1078
|
+
createUI() {
|
|
1079
|
+
// Find player container
|
|
1080
|
+
this.container = this.player.container;
|
|
1081
|
+
|
|
1082
|
+
if (!this.container) {
|
|
1083
|
+
console.warn('VidPly Playlist: No container found');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Track artwork element (shows album art/poster for audio tracks).
|
|
1088
|
+
// Important: in mixed playlists the player may start as <video> and later recreate to <audio>,
|
|
1089
|
+
// so we create this lazily in `updateTrackArtwork()` when we actually have an audio element.
|
|
1090
|
+
|
|
1091
|
+
// Create track info element (shows current track)
|
|
1092
|
+
this.trackInfoElement = DOMUtils.createElement('div', {
|
|
1093
|
+
className: 'vidply-track-info',
|
|
1094
|
+
attributes: {
|
|
1095
|
+
role: 'status'
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
this.trackInfoElement.style.display = 'none';
|
|
1099
|
+
|
|
1100
|
+
this.container.appendChild(this.trackInfoElement);
|
|
1101
|
+
|
|
1102
|
+
// Create navigation feedback live region
|
|
1103
|
+
this.navigationFeedback = DOMUtils.createElement('div', {
|
|
1104
|
+
className: 'vidply-sr-only',
|
|
1105
|
+
attributes: {
|
|
1106
|
+
role: 'status',
|
|
1107
|
+
'aria-live': 'polite',
|
|
1108
|
+
'aria-atomic': 'true'
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
this.container.appendChild(this.navigationFeedback);
|
|
1112
|
+
|
|
1113
|
+
// Create playlist panel with proper landmark
|
|
1114
|
+
this.playlistPanel = DOMUtils.createElement('div', {
|
|
1115
|
+
className: 'vidply-playlist-panel',
|
|
1116
|
+
attributes: {
|
|
1117
|
+
id: `${this.uniqueId}-panel`,
|
|
1118
|
+
role: 'region',
|
|
1119
|
+
'aria-label': i18n.t('playlist.title'),
|
|
1120
|
+
'aria-labelledby': `${this.uniqueId}-heading`
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
this.playlistPanel.style.display = this.isPanelVisible ? 'none' : 'none'; // Will be shown when playlist is loaded
|
|
1124
|
+
|
|
1125
|
+
this.container.appendChild(this.playlistPanel);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Update track info display
|
|
1130
|
+
*/
|
|
1131
|
+
updateTrackInfo(track: PlaylistTrack) {
|
|
1132
|
+
if (!this.trackInfoElement) return;
|
|
1133
|
+
|
|
1134
|
+
const trackNumber = this.currentIndex + 1;
|
|
1135
|
+
const totalTracks = this.tracks.length;
|
|
1136
|
+
const trackTitle = track.title || i18n.t('playlist.untitled');
|
|
1137
|
+
const trackArtist = track.artist || '';
|
|
1138
|
+
|
|
1139
|
+
// Use effective duration (audio description duration when AD is enabled)
|
|
1140
|
+
const effectiveDuration = this.getEffectiveDuration(track);
|
|
1141
|
+
const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
|
|
1142
|
+
const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
|
|
1143
|
+
|
|
1144
|
+
// Screen reader announcement - include duration if available
|
|
1145
|
+
const artistPart = trackArtist ? i18n.t('playlist.by') + trackArtist : '';
|
|
1146
|
+
const durationPart = trackDurationReadable ? `. ${trackDurationReadable}` : '';
|
|
1147
|
+
const announcement = i18n.t('playlist.nowPlaying', {
|
|
1148
|
+
current: trackNumber,
|
|
1149
|
+
total: totalTracks,
|
|
1150
|
+
title: trackTitle,
|
|
1151
|
+
artist: artistPart
|
|
1152
|
+
}) + durationPart;
|
|
1153
|
+
|
|
1154
|
+
const trackOfText = i18n.t('playlist.trackOf', {
|
|
1155
|
+
current: trackNumber,
|
|
1156
|
+
total: totalTracks
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
// Build duration HTML if available
|
|
1160
|
+
const durationHtml = trackDuration
|
|
1161
|
+
? `<span class="vidply-track-duration" aria-hidden="true">${DOMUtils.escapeHTML(trackDuration)}</span>`
|
|
1162
|
+
: '';
|
|
1163
|
+
|
|
1164
|
+
// Get description if available
|
|
1165
|
+
const trackDescription = track.description || '';
|
|
1166
|
+
|
|
1167
|
+
this.trackInfoElement.innerHTML = `
|
|
1168
|
+
<span class="vidply-sr-only">${DOMUtils.escapeHTML(announcement)}</span>
|
|
1169
|
+
<div class="vidply-track-header" aria-hidden="true">
|
|
1170
|
+
<span class="vidply-track-number">${DOMUtils.escapeHTML(trackOfText)}</span>
|
|
1171
|
+
${durationHtml}
|
|
1172
|
+
</div>
|
|
1173
|
+
<div class="vidply-track-title" aria-hidden="true">${DOMUtils.escapeHTML(trackTitle)}</div>
|
|
1174
|
+
${trackArtist ? `<div class="vidply-track-artist" aria-hidden="true">${DOMUtils.escapeHTML(trackArtist)}</div>` : ''}
|
|
1175
|
+
${trackDescription ? `<div class="vidply-track-description" aria-hidden="true">${DOMUtils.escapeHTML(trackDescription)}</div>` : ''}
|
|
1176
|
+
`;
|
|
1177
|
+
|
|
1178
|
+
this.trackInfoElement.style.display = 'block';
|
|
1179
|
+
|
|
1180
|
+
// Update track artwork if available (for audio playlists)
|
|
1181
|
+
this.updateTrackArtwork(track);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Update track artwork display (for audio playlists)
|
|
1186
|
+
*/
|
|
1187
|
+
updateTrackArtwork(track: PlaylistTrack) {
|
|
1188
|
+
// Only show artwork for audio players.
|
|
1189
|
+
// In mixed playlists we may recreate from <video> -> <audio> later, so ensure the element exists lazily.
|
|
1190
|
+
if (this.player?.element?.tagName !== 'AUDIO') {
|
|
1191
|
+
if (this.trackArtworkElement) {
|
|
1192
|
+
this.trackArtworkElement.style.display = 'none';
|
|
1193
|
+
}
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Lazily create artwork element once we have an audio element/container.
|
|
1198
|
+
if (!this.trackArtworkElement && this.container) {
|
|
1199
|
+
this.trackArtworkElement = DOMUtils.createElement('div', {
|
|
1200
|
+
className: 'vidply-track-artwork',
|
|
1201
|
+
attributes: {
|
|
1202
|
+
'aria-hidden': 'true'
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
this.trackArtworkElement.style.display = 'none';
|
|
1206
|
+
|
|
1207
|
+
// Insert before video wrapper (if present) for consistent layout.
|
|
1208
|
+
const videoWrapper = this.container.querySelector('.vidply-video-wrapper');
|
|
1209
|
+
if (videoWrapper) {
|
|
1210
|
+
this.container.insertBefore(this.trackArtworkElement, videoWrapper);
|
|
1211
|
+
} else {
|
|
1212
|
+
this.container.appendChild(this.trackArtworkElement);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (!this.trackArtworkElement) return;
|
|
1217
|
+
|
|
1218
|
+
// A track manifest is attacker-influenced data — a value like
|
|
1219
|
+
// `x); background: url(evil.svg` must not be allowed to break out
|
|
1220
|
+
// of the declaration. `toCssBackgroundImage` returns null when the
|
|
1221
|
+
// URL fails validation, so we leave the element hidden instead of
|
|
1222
|
+
// assigning a dangerous value.
|
|
1223
|
+
const safeBackground = track.poster ? toCssBackgroundImage(track.poster) : null;
|
|
1224
|
+
if (safeBackground) {
|
|
1225
|
+
this.trackArtworkElement.style.backgroundImage = safeBackground;
|
|
1226
|
+
this.trackArtworkElement.style.display = 'block';
|
|
1227
|
+
} else {
|
|
1228
|
+
this.trackArtworkElement.style.backgroundImage = '';
|
|
1229
|
+
this.trackArtworkElement.style.display = 'none';
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Render playlist
|
|
1235
|
+
*/
|
|
1236
|
+
renderPlaylist() {
|
|
1237
|
+
if (!this.playlistPanel) return;
|
|
1238
|
+
|
|
1239
|
+
// Clear existing
|
|
1240
|
+
this.playlistPanel.innerHTML = '';
|
|
1241
|
+
|
|
1242
|
+
// Create header
|
|
1243
|
+
const header = DOMUtils.createElement('h2', {
|
|
1244
|
+
className: 'vidply-playlist-header',
|
|
1245
|
+
attributes: {
|
|
1246
|
+
id: `${this.uniqueId}-heading`
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
header.textContent = `${i18n.t('playlist.title')} (${this.tracks.length})`;
|
|
1250
|
+
this.playlistPanel.appendChild(header);
|
|
1251
|
+
|
|
1252
|
+
// Add keyboard instructions (visually hidden)
|
|
1253
|
+
const instructions = DOMUtils.createElement('div', {
|
|
1254
|
+
className: 'vidply-sr-only',
|
|
1255
|
+
attributes: {
|
|
1256
|
+
id: `${this.uniqueId}-keyboard-instructions`
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
instructions.textContent = i18n.t('playlist.keyboardInstructions');
|
|
1260
|
+
this.playlistPanel.appendChild(instructions);
|
|
1261
|
+
|
|
1262
|
+
// Create list (proper ul element)
|
|
1263
|
+
// Note: role="listbox" requires option children. We use role="none" on <li> elements
|
|
1264
|
+
// so the buttons with role="option" become the semantic children of the listbox.
|
|
1265
|
+
const list = DOMUtils.createElement('ul', {
|
|
1266
|
+
className: 'vidply-playlist-list',
|
|
1267
|
+
attributes: {
|
|
1268
|
+
role: 'listbox',
|
|
1269
|
+
'aria-labelledby': `${this.uniqueId}-heading`,
|
|
1270
|
+
'aria-describedby': `${this.uniqueId}-keyboard-instructions`
|
|
1271
|
+
}
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
this.tracks.forEach((track, index) => {
|
|
1275
|
+
const item = this.createPlaylistItem(track, index);
|
|
1276
|
+
list.appendChild(item);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
this.playlistPanel.appendChild(list);
|
|
1280
|
+
|
|
1281
|
+
// Show panel if it should be visible
|
|
1282
|
+
if (this.isPanelVisible) {
|
|
1283
|
+
this.playlistPanel.style.display = 'block';
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Create playlist item element
|
|
1289
|
+
*/
|
|
1290
|
+
createPlaylistItem(track: PlaylistTrack, index: number) {
|
|
1291
|
+
const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
|
|
1292
|
+
const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
|
|
1293
|
+
|
|
1294
|
+
// Use effective duration (audio description duration when AD is enabled)
|
|
1295
|
+
const effectiveDuration = this.getEffectiveDuration(track);
|
|
1296
|
+
const trackDuration = effectiveDuration ? TimeUtils.formatTime(effectiveDuration as number) : '';
|
|
1297
|
+
const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
|
|
1298
|
+
const isActive = index === this.currentIndex;
|
|
1299
|
+
|
|
1300
|
+
// Build accessible label for screen readers
|
|
1301
|
+
// With role="option" and aria-checked, screen reader will announce selection state
|
|
1302
|
+
// Position is already announced via aria-posinset/aria-setsize
|
|
1303
|
+
// Format: "Title by Artist. 3 minutes, 45 seconds."
|
|
1304
|
+
let ariaLabel = `${trackTitle}${trackArtist}`;
|
|
1305
|
+
if (trackDurationReadable) {
|
|
1306
|
+
ariaLabel += `. ${trackDurationReadable}`;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Create list item container
|
|
1310
|
+
// role="none" removes the <li> from the accessibility tree so the button with
|
|
1311
|
+
// role="option" becomes the direct semantic child of the listbox (required by ARIA).
|
|
1312
|
+
const item = DOMUtils.createElement('li', {
|
|
1313
|
+
className: isActive ? 'vidply-playlist-item vidply-playlist-item-active' : 'vidply-playlist-item',
|
|
1314
|
+
attributes: {
|
|
1315
|
+
'data-playlist-index': String(index),
|
|
1316
|
+
role: 'none'
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
// Create button wrapper for interactive content
|
|
1321
|
+
const button = DOMUtils.createElement('button', {
|
|
1322
|
+
className: 'vidply-playlist-item-button',
|
|
1323
|
+
attributes: {
|
|
1324
|
+
type: 'button',
|
|
1325
|
+
role: 'option',
|
|
1326
|
+
tabIndex: index === 0 ? '0' : '-1',
|
|
1327
|
+
'aria-label': ariaLabel,
|
|
1328
|
+
'aria-posinset': String(index + 1),
|
|
1329
|
+
'aria-setsize': String(this.tracks.length),
|
|
1330
|
+
'aria-checked': isActive ? 'true' : 'false'
|
|
1331
|
+
}
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
// Add aria-current if active
|
|
1335
|
+
if (isActive) {
|
|
1336
|
+
button.setAttribute('aria-current', 'true');
|
|
1337
|
+
button.setAttribute('tabIndex', '0'); // Active item should always be tabbable
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Thumbnail container with optional duration badge
|
|
1341
|
+
const thumbnailContainer = DOMUtils.createElement('span', {
|
|
1342
|
+
className: 'vidply-playlist-thumbnail-container',
|
|
1343
|
+
attributes: {
|
|
1344
|
+
'aria-hidden': 'true'
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
// Thumbnail or icon
|
|
1349
|
+
const thumbnail = DOMUtils.createElement('span', {
|
|
1350
|
+
className: 'vidply-playlist-thumbnail'
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
// Same rule as trackArtworkElement below: only set backgroundImage
|
|
1354
|
+
// when the manifest URL passes the allow-list so a hostile feed
|
|
1355
|
+
// cannot break out of the CSS `url(...)` declaration.
|
|
1356
|
+
const safeThumbnail = track.poster ? toCssBackgroundImage(track.poster) : null;
|
|
1357
|
+
if (safeThumbnail) {
|
|
1358
|
+
thumbnail.style.backgroundImage = safeThumbnail;
|
|
1359
|
+
} else {
|
|
1360
|
+
const icon = createIconElement('music');
|
|
1361
|
+
icon.classList.add('vidply-playlist-thumbnail-icon');
|
|
1362
|
+
thumbnail.appendChild(icon);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
thumbnailContainer.appendChild(thumbnail);
|
|
1366
|
+
|
|
1367
|
+
// Duration badge on thumbnail (like YouTube) - only show if there's a poster
|
|
1368
|
+
if (trackDuration && track.poster) {
|
|
1369
|
+
const durationBadge = DOMUtils.createElement('span', {
|
|
1370
|
+
className: 'vidply-playlist-duration-badge'
|
|
1371
|
+
});
|
|
1372
|
+
durationBadge.textContent = trackDuration;
|
|
1373
|
+
thumbnailContainer.appendChild(durationBadge);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
button.appendChild(thumbnailContainer);
|
|
1377
|
+
|
|
1378
|
+
// Info section (title, artist, description)
|
|
1379
|
+
const info = DOMUtils.createElement('span', {
|
|
1380
|
+
className: 'vidply-playlist-item-info',
|
|
1381
|
+
attributes: {
|
|
1382
|
+
'aria-hidden': 'true'
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
// Title row with optional inline duration (for when no thumbnail)
|
|
1387
|
+
const titleRow = DOMUtils.createElement('span', {
|
|
1388
|
+
className: 'vidply-playlist-item-title-row'
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
const title = DOMUtils.createElement('span', {
|
|
1392
|
+
className: 'vidply-playlist-item-title'
|
|
1393
|
+
});
|
|
1394
|
+
title.textContent = trackTitle;
|
|
1395
|
+
titleRow.appendChild(title);
|
|
1396
|
+
|
|
1397
|
+
// Inline duration (shown when no poster/thumbnail)
|
|
1398
|
+
if (trackDuration && !track.poster) {
|
|
1399
|
+
const inlineDuration = DOMUtils.createElement('span', {
|
|
1400
|
+
className: 'vidply-playlist-item-duration'
|
|
1401
|
+
});
|
|
1402
|
+
inlineDuration.textContent = trackDuration;
|
|
1403
|
+
titleRow.appendChild(inlineDuration);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
info.appendChild(titleRow);
|
|
1407
|
+
|
|
1408
|
+
// Artist
|
|
1409
|
+
if (track.artist) {
|
|
1410
|
+
const artist = DOMUtils.createElement('span', {
|
|
1411
|
+
className: 'vidply-playlist-item-artist'
|
|
1412
|
+
});
|
|
1413
|
+
artist.textContent = track.artist;
|
|
1414
|
+
info.appendChild(artist);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Description (truncated)
|
|
1418
|
+
if (track.description) {
|
|
1419
|
+
const description = DOMUtils.createElement('span', {
|
|
1420
|
+
className: 'vidply-playlist-item-description'
|
|
1421
|
+
});
|
|
1422
|
+
description.textContent = track.description;
|
|
1423
|
+
info.appendChild(description);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
button.appendChild(info);
|
|
1427
|
+
|
|
1428
|
+
// Play icon
|
|
1429
|
+
const playIcon = createIconElement('play');
|
|
1430
|
+
playIcon.classList.add('vidply-playlist-item-icon');
|
|
1431
|
+
playIcon.setAttribute('aria-hidden', 'true');
|
|
1432
|
+
button.appendChild(playIcon);
|
|
1433
|
+
|
|
1434
|
+
// Click handler
|
|
1435
|
+
button.addEventListener('click', () => {
|
|
1436
|
+
const track = this.tracks[index];
|
|
1437
|
+
const isExternalRenderer = this.isExternalRendererUrl(track?.src);
|
|
1438
|
+
|
|
1439
|
+
// Exit fullscreen for external renderer tracks (YouTube, Vimeo, SoundCloud)
|
|
1440
|
+
// These have their own native controls that work better outside vidply's fullscreen
|
|
1441
|
+
if (isExternalRenderer && this.player.state.fullscreen) {
|
|
1442
|
+
this.player.exitFullscreen();
|
|
1443
|
+
// Small delay to let fullscreen exit before loading
|
|
1444
|
+
setTimeout(() => {
|
|
1445
|
+
this.play(index, true);
|
|
1446
|
+
}, 100);
|
|
1447
|
+
} else {
|
|
1448
|
+
this.play(index, true); // User-initiated
|
|
1449
|
+
}
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
// Keyboard handler
|
|
1453
|
+
button.addEventListener('keydown', (e) => {
|
|
1454
|
+
this.handlePlaylistItemKeydown(e, index);
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
// Append button to list item
|
|
1458
|
+
item.appendChild(button);
|
|
1459
|
+
|
|
1460
|
+
return item;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Handle keyboard navigation in playlist items
|
|
1465
|
+
*/
|
|
1466
|
+
handlePlaylistItemKeydown(e: KeyboardEvent, index: number) {
|
|
1467
|
+
if (!this.playlistPanel) return;
|
|
1468
|
+
const buttons: HTMLElement[] = Array.from(this.playlistPanel.querySelectorAll('.vidply-playlist-item-button'));
|
|
1469
|
+
let newIndex = -1;
|
|
1470
|
+
let announcement = '';
|
|
1471
|
+
|
|
1472
|
+
switch(e.key) {
|
|
1473
|
+
case 'Enter':
|
|
1474
|
+
case ' ':
|
|
1475
|
+
e.preventDefault();
|
|
1476
|
+
e.stopPropagation();
|
|
1477
|
+
{
|
|
1478
|
+
const track = this.tracks[index];
|
|
1479
|
+
const isExternalRenderer = this.isExternalRendererUrl(track?.src);
|
|
1480
|
+
|
|
1481
|
+
// Exit fullscreen for external renderer tracks (YouTube, Vimeo, SoundCloud)
|
|
1482
|
+
if (isExternalRenderer && this.player.state.fullscreen) {
|
|
1483
|
+
this.player.exitFullscreen();
|
|
1484
|
+
setTimeout(() => {
|
|
1485
|
+
this.play(index, true);
|
|
1486
|
+
}, 100);
|
|
1487
|
+
} else {
|
|
1488
|
+
this.play(index, true); // User-initiated
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
return; // No need to move focus
|
|
1492
|
+
|
|
1493
|
+
case 'ArrowDown':
|
|
1494
|
+
e.preventDefault();
|
|
1495
|
+
e.stopPropagation();
|
|
1496
|
+
// Move to next item
|
|
1497
|
+
if (index < buttons.length - 1) {
|
|
1498
|
+
newIndex = index + 1;
|
|
1499
|
+
} else {
|
|
1500
|
+
// At the end, announce boundary
|
|
1501
|
+
announcement = i18n.t('playlist.endOfPlaylist', { current: buttons.length, total: buttons.length });
|
|
1502
|
+
}
|
|
1503
|
+
break;
|
|
1504
|
+
|
|
1505
|
+
case 'ArrowUp':
|
|
1506
|
+
e.preventDefault();
|
|
1507
|
+
e.stopPropagation();
|
|
1508
|
+
// Move to previous item
|
|
1509
|
+
if (index > 0) {
|
|
1510
|
+
newIndex = index - 1;
|
|
1511
|
+
} else {
|
|
1512
|
+
// At the beginning, announce boundary
|
|
1513
|
+
announcement = i18n.t('playlist.beginningOfPlaylist', { total: buttons.length });
|
|
1514
|
+
}
|
|
1515
|
+
break;
|
|
1516
|
+
|
|
1517
|
+
case 'PageDown':
|
|
1518
|
+
e.preventDefault();
|
|
1519
|
+
e.stopPropagation();
|
|
1520
|
+
// Move 5 items down (or to end)
|
|
1521
|
+
newIndex = Math.min(index + 5, buttons.length - 1);
|
|
1522
|
+
if (newIndex === buttons.length - 1 && index !== newIndex) {
|
|
1523
|
+
announcement = i18n.t('playlist.jumpedToLastTrack', { current: newIndex + 1, total: buttons.length });
|
|
1524
|
+
}
|
|
1525
|
+
break;
|
|
1526
|
+
|
|
1527
|
+
case 'PageUp':
|
|
1528
|
+
e.preventDefault();
|
|
1529
|
+
e.stopPropagation();
|
|
1530
|
+
// Move 5 items up (or to beginning)
|
|
1531
|
+
newIndex = Math.max(index - 5, 0);
|
|
1532
|
+
if (newIndex === 0 && index !== newIndex) {
|
|
1533
|
+
announcement = i18n.t('playlist.jumpedToFirstTrack', { total: buttons.length });
|
|
1534
|
+
}
|
|
1535
|
+
break;
|
|
1536
|
+
|
|
1537
|
+
case 'Home':
|
|
1538
|
+
e.preventDefault();
|
|
1539
|
+
e.stopPropagation();
|
|
1540
|
+
// Move to first item
|
|
1541
|
+
newIndex = 0;
|
|
1542
|
+
if (index !== 0) {
|
|
1543
|
+
announcement = i18n.t('playlist.firstTrack', { total: buttons.length });
|
|
1544
|
+
}
|
|
1545
|
+
break;
|
|
1546
|
+
|
|
1547
|
+
case 'End':
|
|
1548
|
+
e.preventDefault();
|
|
1549
|
+
e.stopPropagation();
|
|
1550
|
+
// Move to last item
|
|
1551
|
+
newIndex = buttons.length - 1;
|
|
1552
|
+
if (index !== buttons.length - 1) {
|
|
1553
|
+
announcement = i18n.t('playlist.lastTrack', { current: buttons.length, total: buttons.length });
|
|
1554
|
+
}
|
|
1555
|
+
break;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Update tab indices for roving tabindex pattern
|
|
1559
|
+
if (newIndex !== -1 && newIndex !== index) {
|
|
1560
|
+
const currentButton = buttons[index];
|
|
1561
|
+
const newButton = buttons[newIndex];
|
|
1562
|
+
if (currentButton && newButton) {
|
|
1563
|
+
currentButton.setAttribute('tabIndex', '-1');
|
|
1564
|
+
newButton.setAttribute('tabIndex', '0');
|
|
1565
|
+
newButton.focus({ preventScroll: false });
|
|
1566
|
+
|
|
1567
|
+
// Scroll the focused item into view (same behavior as mouse interaction)
|
|
1568
|
+
const item = newButton.closest('.vidply-playlist-item');
|
|
1569
|
+
if (item) {
|
|
1570
|
+
item.scrollIntoView(reducedMotionScrollOptions('nearest'));
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Announce navigation feedback
|
|
1576
|
+
if (announcement && this.navigationFeedback) {
|
|
1577
|
+
this.navigationFeedback.textContent = announcement;
|
|
1578
|
+
// Clear after a short delay to allow for repeated announcements
|
|
1579
|
+
setTimeout(() => {
|
|
1580
|
+
if (this.navigationFeedback) {
|
|
1581
|
+
this.navigationFeedback.textContent = '';
|
|
1582
|
+
}
|
|
1583
|
+
}, 1000);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Update playlist UI (highlight current track)
|
|
1589
|
+
*/
|
|
1590
|
+
updatePlaylistUI() {
|
|
1591
|
+
if (!this.playlistPanel) return;
|
|
1592
|
+
|
|
1593
|
+
const items = this.playlistPanel.querySelectorAll('.vidply-playlist-item');
|
|
1594
|
+
const buttons = this.playlistPanel.querySelectorAll('.vidply-playlist-item-button');
|
|
1595
|
+
|
|
1596
|
+
items.forEach((item: Element, index: number) => {
|
|
1597
|
+
const button = buttons[index];
|
|
1598
|
+
if (!button) return;
|
|
1599
|
+
|
|
1600
|
+
const track = this.tracks[index];
|
|
1601
|
+
if (!track) return;
|
|
1602
|
+
const trackTitle = track.title || i18n.t('playlist.trackUntitled', { number: index + 1 });
|
|
1603
|
+
const trackArtist = track.artist ? i18n.t('playlist.by') + track.artist : '';
|
|
1604
|
+
|
|
1605
|
+
// Use effective duration (audio description duration when AD is enabled)
|
|
1606
|
+
const effectiveDuration = this.getEffectiveDuration(track);
|
|
1607
|
+
const trackDurationReadable = effectiveDuration ? TimeUtils.formatDuration(effectiveDuration as number) : '';
|
|
1608
|
+
|
|
1609
|
+
if (index === this.currentIndex) {
|
|
1610
|
+
// Update list item styling
|
|
1611
|
+
item.classList.add('vidply-playlist-item-active');
|
|
1612
|
+
|
|
1613
|
+
// Update button ARIA attributes
|
|
1614
|
+
button.setAttribute('aria-current', 'true');
|
|
1615
|
+
button.setAttribute('aria-checked', 'true');
|
|
1616
|
+
button.setAttribute('tabIndex', '0'); // Active item should be tabbable
|
|
1617
|
+
|
|
1618
|
+
// Simplified aria-label - status and actions are announced via ARIA roles
|
|
1619
|
+
let ariaLabel = `${trackTitle}${trackArtist}`;
|
|
1620
|
+
if (trackDurationReadable) {
|
|
1621
|
+
ariaLabel += `. ${trackDurationReadable}`;
|
|
1622
|
+
}
|
|
1623
|
+
button.setAttribute('aria-label', ariaLabel);
|
|
1624
|
+
|
|
1625
|
+
// Scroll into view within playlist panel (uses 'nearest' to minimize page scroll)
|
|
1626
|
+
item.scrollIntoView(reducedMotionScrollOptions('nearest'));
|
|
1627
|
+
} else {
|
|
1628
|
+
// Update list item styling
|
|
1629
|
+
item.classList.remove('vidply-playlist-item-active');
|
|
1630
|
+
|
|
1631
|
+
// Update button ARIA attributes
|
|
1632
|
+
button.removeAttribute('aria-current');
|
|
1633
|
+
button.setAttribute('aria-checked', 'false');
|
|
1634
|
+
button.setAttribute('tabIndex', '-1'); // Remove from tab order (use arrow keys)
|
|
1635
|
+
|
|
1636
|
+
// Simplified aria-label - status and actions are announced via ARIA roles
|
|
1637
|
+
let ariaLabel = `${trackTitle}${trackArtist}`;
|
|
1638
|
+
if (trackDurationReadable) {
|
|
1639
|
+
ariaLabel += `. ${trackDurationReadable}`;
|
|
1640
|
+
}
|
|
1641
|
+
button.setAttribute('aria-label', ariaLabel);
|
|
1642
|
+
}
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Get current track
|
|
1648
|
+
*/
|
|
1649
|
+
getCurrentTrack() {
|
|
1650
|
+
return this.tracks[this.currentIndex] || null;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Get playlist info
|
|
1655
|
+
*/
|
|
1656
|
+
getPlaylistInfo() {
|
|
1657
|
+
return {
|
|
1658
|
+
currentIndex: this.currentIndex,
|
|
1659
|
+
totalTracks: this.tracks.length,
|
|
1660
|
+
currentTrack: this.getCurrentTrack(),
|
|
1661
|
+
hasNext: this.hasNext(),
|
|
1662
|
+
hasPrevious: this.hasPrevious()
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* Check if there is a next track
|
|
1668
|
+
*/
|
|
1669
|
+
hasNext() {
|
|
1670
|
+
if (this.options.loop) return true;
|
|
1671
|
+
return this.currentIndex < this.tracks.length - 1;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Check if there is a previous track
|
|
1676
|
+
*/
|
|
1677
|
+
hasPrevious() {
|
|
1678
|
+
if (this.options.loop) return true;
|
|
1679
|
+
return this.currentIndex > 0;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Add track to playlist
|
|
1684
|
+
*/
|
|
1685
|
+
addTrack(track: PlaylistTrack) {
|
|
1686
|
+
this.tracks.push(track);
|
|
1687
|
+
|
|
1688
|
+
if (this.playlistPanel) {
|
|
1689
|
+
this.renderPlaylist();
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* Remove track from playlist
|
|
1695
|
+
*/
|
|
1696
|
+
removeTrack(index: number) {
|
|
1697
|
+
if (index < 0 || index >= this.tracks.length) return;
|
|
1698
|
+
|
|
1699
|
+
this.tracks.splice(index, 1);
|
|
1700
|
+
|
|
1701
|
+
// Adjust current index if needed
|
|
1702
|
+
if (index < this.currentIndex) {
|
|
1703
|
+
this.currentIndex--;
|
|
1704
|
+
} else if (index === this.currentIndex) {
|
|
1705
|
+
// Current track was removed, play next or stop
|
|
1706
|
+
if (this.currentIndex >= this.tracks.length) {
|
|
1707
|
+
this.currentIndex = this.tracks.length - 1;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (this.currentIndex >= 0) {
|
|
1711
|
+
this.play(this.currentIndex);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (this.playlistPanel) {
|
|
1716
|
+
this.renderPlaylist();
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Clear playlist
|
|
1722
|
+
*/
|
|
1723
|
+
clear() {
|
|
1724
|
+
this.tracks = [];
|
|
1725
|
+
this.currentIndex = -1;
|
|
1726
|
+
|
|
1727
|
+
if (this.playlistPanel) {
|
|
1728
|
+
this.playlistPanel.innerHTML = '';
|
|
1729
|
+
this.playlistPanel.style.display = 'none';
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (this.trackInfoElement) {
|
|
1733
|
+
this.trackInfoElement.innerHTML = '';
|
|
1734
|
+
this.trackInfoElement.style.display = 'none';
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (this.trackArtworkElement) {
|
|
1738
|
+
this.trackArtworkElement.style.backgroundImage = '';
|
|
1739
|
+
this.trackArtworkElement.style.display = 'none';
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
/**
|
|
1744
|
+
* Toggle playlist panel visibility
|
|
1745
|
+
* @param {boolean} show - Optional: force show (true) or hide (false)
|
|
1746
|
+
* @returns {boolean} - New visibility state
|
|
1747
|
+
*/
|
|
1748
|
+
togglePanel(show?: boolean) {
|
|
1749
|
+
const playlistPanel = this.playlistPanel;
|
|
1750
|
+
if (!playlistPanel) return false;
|
|
1751
|
+
|
|
1752
|
+
// Determine new state
|
|
1753
|
+
const shouldShow = show !== undefined ? show : playlistPanel.style.display === 'none';
|
|
1754
|
+
|
|
1755
|
+
if (shouldShow) {
|
|
1756
|
+
playlistPanel.style.display = 'block';
|
|
1757
|
+
this.isPanelVisible = true;
|
|
1758
|
+
|
|
1759
|
+
// Focus first item if playlist has tracks
|
|
1760
|
+
if (this.tracks.length > 0) {
|
|
1761
|
+
setTimeout(() => {
|
|
1762
|
+
const firstItem = playlistPanel.querySelector<HTMLElement>('.vidply-playlist-item[tabindex="0"]');
|
|
1763
|
+
if (firstItem) {
|
|
1764
|
+
firstItem.focus({ preventScroll: true });
|
|
1765
|
+
}
|
|
1766
|
+
}, 100);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
// Update toggle button state if it exists
|
|
1770
|
+
if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
|
|
1771
|
+
this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'true');
|
|
1772
|
+
this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'true');
|
|
1773
|
+
}
|
|
1774
|
+
} else {
|
|
1775
|
+
playlistPanel.style.display = 'none';
|
|
1776
|
+
this.isPanelVisible = false;
|
|
1777
|
+
|
|
1778
|
+
// Update toggle button state if it exists
|
|
1779
|
+
if (this.player.controlBar && this.player.controlBar.controls.playlistToggle) {
|
|
1780
|
+
this.player.controlBar.controls.playlistToggle.setAttribute('aria-expanded', 'false');
|
|
1781
|
+
this.player.controlBar.controls.playlistToggle.setAttribute('aria-pressed', 'false');
|
|
1782
|
+
|
|
1783
|
+
// Return focus to toggle button
|
|
1784
|
+
this.player.controlBar.controls.playlistToggle.focus({ preventScroll: true });
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
return this.isPanelVisible;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* Show playlist panel
|
|
1793
|
+
*/
|
|
1794
|
+
showPanel() {
|
|
1795
|
+
return this.togglePanel(true);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/**
|
|
1799
|
+
* Hide playlist panel
|
|
1800
|
+
*/
|
|
1801
|
+
hidePanel() {
|
|
1802
|
+
return this.togglePanel(false);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* Destroy playlist manager
|
|
1807
|
+
*/
|
|
1808
|
+
destroy() {
|
|
1809
|
+
// Remove every listener registered in init(). All handlers are pre-bound
|
|
1810
|
+
// in the constructor, so these references match what on() registered.
|
|
1811
|
+
this.player.off('ended', this.handleTrackEnd);
|
|
1812
|
+
this.player.off('error', this.handleTrackError);
|
|
1813
|
+
this.player.off('play', this.handlePlaybackStateChange);
|
|
1814
|
+
this.player.off('pause', this.handlePlaybackStateChange);
|
|
1815
|
+
this.player.off('ended', this.handlePlaybackStateChange);
|
|
1816
|
+
this.player.off('fullscreenchange', this.handleFullscreenChange);
|
|
1817
|
+
this.player.off('audiodescriptionenabled', this.handleAudioDescriptionChange);
|
|
1818
|
+
this.player.off('audiodescriptiondisabled', this.handleAudioDescriptionChange);
|
|
1819
|
+
|
|
1820
|
+
// Remove UI
|
|
1821
|
+
if (this.trackArtworkElement) {
|
|
1822
|
+
this.trackArtworkElement.remove();
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
if (this.trackInfoElement) {
|
|
1826
|
+
this.trackInfoElement.remove();
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
if (this.playlistPanel) {
|
|
1830
|
+
this.playlistPanel.remove();
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// Clear data
|
|
1834
|
+
this.clear();
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
export default PlaylistManager;
|