ts-video-player 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +438 -0
  3. package/dist/cdn.d.ts +10 -0
  4. package/dist/cdn.d.ts.map +1 -0
  5. package/dist/cdn.min.js +1046 -0
  6. package/dist/cdn.min.js.map +54 -0
  7. package/dist/cdn.test.d.ts +2 -0
  8. package/dist/cdn.test.d.ts.map +1 -0
  9. package/dist/chunk-36x4yhjn.js +765 -0
  10. package/dist/chunk-36x4yhjn.js.map +12 -0
  11. package/dist/chunk-eajts2jv.js +4243 -0
  12. package/dist/chunk-eajts2jv.js.map +42 -0
  13. package/dist/chunk-z1ehbz9f.js +380 -0
  14. package/dist/chunk-z1ehbz9f.js.map +19 -0
  15. package/dist/config.d.ts +9 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/core/audio-gain.d.ts +67 -0
  18. package/dist/core/audio-gain.d.ts.map +1 -0
  19. package/dist/core/caption-styles.d.ts +121 -0
  20. package/dist/core/caption-styles.d.ts.map +1 -0
  21. package/dist/core/chapters.d.ts +150 -0
  22. package/dist/core/chapters.d.ts.map +1 -0
  23. package/dist/core/error-overlay.d.ts +71 -0
  24. package/dist/core/error-overlay.d.ts.map +1 -0
  25. package/dist/core/events.d.ts +119 -0
  26. package/dist/core/events.d.ts.map +1 -0
  27. package/dist/core/features.d.ts +64 -0
  28. package/dist/core/features.d.ts.map +1 -0
  29. package/dist/core/i18n.d.ts +132 -0
  30. package/dist/core/i18n.d.ts.map +1 -0
  31. package/dist/core/index.d.ts +37 -0
  32. package/dist/core/index.d.ts.map +1 -0
  33. package/dist/core/live.d.ts +130 -0
  34. package/dist/core/live.d.ts.map +1 -0
  35. package/dist/core/media-session.d.ts +38 -0
  36. package/dist/core/media-session.d.ts.map +1 -0
  37. package/dist/core/orientation.d.ts +22 -0
  38. package/dist/core/orientation.d.ts.map +1 -0
  39. package/dist/core/popover.d.ts +42 -0
  40. package/dist/core/popover.d.ts.map +1 -0
  41. package/dist/core/remote-playback.d.ts +195 -0
  42. package/dist/core/remote-playback.d.ts.map +1 -0
  43. package/dist/core/settings-menu.d.ts +132 -0
  44. package/dist/core/settings-menu.d.ts.map +1 -0
  45. package/dist/core/spinner.d.ts +67 -0
  46. package/dist/core/spinner.d.ts.map +1 -0
  47. package/dist/core/state.d.ts +142 -0
  48. package/dist/core/state.d.ts.map +1 -0
  49. package/dist/core/text-tracks.d.ts +162 -0
  50. package/dist/core/text-tracks.d.ts.map +1 -0
  51. package/dist/core/themes.d.ts +213 -0
  52. package/dist/core/themes.d.ts.map +1 -0
  53. package/dist/core/thumbnails.d.ts +124 -0
  54. package/dist/core/thumbnails.d.ts.map +1 -0
  55. package/dist/core/tooltips.d.ts +100 -0
  56. package/dist/core/tooltips.d.ts.map +1 -0
  57. package/dist/elements/base.d.ts +3 -0
  58. package/dist/elements/base.d.ts.map +1 -0
  59. package/dist/elements/index.d.ts +33 -0
  60. package/dist/elements/index.d.ts.map +1 -0
  61. package/dist/elements/index.js +55 -0
  62. package/dist/elements/index.js.map +9 -0
  63. package/dist/elements/media-airplay-button.d.ts +13 -0
  64. package/dist/elements/media-airplay-button.d.ts.map +1 -0
  65. package/dist/elements/media-error-display.d.ts +13 -0
  66. package/dist/elements/media-error-display.d.ts.map +1 -0
  67. package/dist/elements/media-fullscreen-button.d.ts +13 -0
  68. package/dist/elements/media-fullscreen-button.d.ts.map +1 -0
  69. package/dist/elements/media-live-button.d.ts +13 -0
  70. package/dist/elements/media-live-button.d.ts.map +1 -0
  71. package/dist/elements/media-mute-button.d.ts +13 -0
  72. package/dist/elements/media-mute-button.d.ts.map +1 -0
  73. package/dist/elements/media-pip-button.d.ts +13 -0
  74. package/dist/elements/media-pip-button.d.ts.map +1 -0
  75. package/dist/elements/media-play-button.d.ts +14 -0
  76. package/dist/elements/media-play-button.d.ts.map +1 -0
  77. package/dist/elements/media-playback-rate-button.d.ts +10 -0
  78. package/dist/elements/media-playback-rate-button.d.ts.map +1 -0
  79. package/dist/elements/media-preview-time.d.ts +18 -0
  80. package/dist/elements/media-preview-time.d.ts.map +1 -0
  81. package/dist/elements/media-progress-bar.d.ts +16 -0
  82. package/dist/elements/media-progress-bar.d.ts.map +1 -0
  83. package/dist/elements/media-remote-playback-button.d.ts +16 -0
  84. package/dist/elements/media-remote-playback-button.d.ts.map +1 -0
  85. package/dist/elements/media-seek-button.d.ts +9 -0
  86. package/dist/elements/media-seek-button.d.ts.map +1 -0
  87. package/dist/elements/media-settings-menu.d.ts +32 -0
  88. package/dist/elements/media-settings-menu.d.ts.map +1 -0
  89. package/dist/elements/media-time-display.d.ts +21 -0
  90. package/dist/elements/media-time-display.d.ts.map +1 -0
  91. package/dist/elements/media-time-group.d.ts +22 -0
  92. package/dist/elements/media-time-group.d.ts.map +1 -0
  93. package/dist/elements/media-time-separator.d.ts +13 -0
  94. package/dist/elements/media-time-separator.d.ts.map +1 -0
  95. package/dist/elements/media-volume-slider.d.ts +16 -0
  96. package/dist/elements/media-volume-slider.d.ts.map +1 -0
  97. package/dist/elements/utils.d.ts +27 -0
  98. package/dist/elements/utils.d.ts.map +1 -0
  99. package/dist/elements/video-player.d.ts +23 -0
  100. package/dist/elements/video-player.d.ts.map +1 -0
  101. package/dist/elements/video-skin.d.ts +23 -0
  102. package/dist/elements/video-skin.d.ts.map +1 -0
  103. package/dist/features/captions.d.ts +8 -0
  104. package/dist/features/captions.d.ts.map +1 -0
  105. package/dist/features/create-player.d.ts +45 -0
  106. package/dist/features/create-player.d.ts.map +1 -0
  107. package/dist/features/fullscreen.d.ts +8 -0
  108. package/dist/features/fullscreen.d.ts.map +1 -0
  109. package/dist/features/gestures.d.ts +8 -0
  110. package/dist/features/gestures.d.ts.map +1 -0
  111. package/dist/features/index.d.ts +26 -0
  112. package/dist/features/index.d.ts.map +1 -0
  113. package/dist/features/index.js +31 -0
  114. package/dist/features/index.js.map +9 -0
  115. package/dist/features/keyboard.d.ts +8 -0
  116. package/dist/features/keyboard.d.ts.map +1 -0
  117. package/dist/features/pip.d.ts +8 -0
  118. package/dist/features/pip.d.ts.map +1 -0
  119. package/dist/features/playback.d.ts +8 -0
  120. package/dist/features/playback.d.ts.map +1 -0
  121. package/dist/features/quality.d.ts +8 -0
  122. package/dist/features/quality.d.ts.map +1 -0
  123. package/dist/features/types.d.ts +26 -0
  124. package/dist/features/types.d.ts.map +1 -0
  125. package/dist/features/volume.d.ts +8 -0
  126. package/dist/features/volume.d.ts.map +1 -0
  127. package/dist/index.d.ts +84 -0
  128. package/dist/index.d.ts.map +1 -0
  129. package/dist/index.js +8149 -0
  130. package/dist/index.js.map +31 -0
  131. package/dist/layouts/index.d.ts +58 -0
  132. package/dist/layouts/index.d.ts.map +1 -0
  133. package/dist/player.d.ts +90 -0
  134. package/dist/player.d.ts.map +1 -0
  135. package/dist/plugins/ads.d.ts +311 -0
  136. package/dist/plugins/ads.d.ts.map +1 -0
  137. package/dist/plugins/analytics.d.ts +203 -0
  138. package/dist/plugins/analytics.d.ts.map +1 -0
  139. package/dist/plugins/end-screen.d.ts +184 -0
  140. package/dist/plugins/end-screen.d.ts.map +1 -0
  141. package/dist/plugins/index.d.ts +13 -0
  142. package/dist/plugins/index.d.ts.map +1 -0
  143. package/dist/plugins/index.js +20 -0
  144. package/dist/plugins/index.js.map +9 -0
  145. package/dist/plugins/skip-segments.d.ts +159 -0
  146. package/dist/plugins/skip-segments.d.ts.map +1 -0
  147. package/dist/plugins/watermark.d.ts +157 -0
  148. package/dist/plugins/watermark.d.ts.map +1 -0
  149. package/dist/providers/base.d.ts +56 -0
  150. package/dist/providers/base.d.ts.map +1 -0
  151. package/dist/providers/dash.d.ts +94 -0
  152. package/dist/providers/dash.d.ts.map +1 -0
  153. package/dist/providers/hls.d.ts +182 -0
  154. package/dist/providers/hls.d.ts.map +1 -0
  155. package/dist/providers/html5.d.ts +64 -0
  156. package/dist/providers/html5.d.ts.map +1 -0
  157. package/dist/providers/index.d.ts +41 -0
  158. package/dist/providers/index.d.ts.map +1 -0
  159. package/dist/providers/vimeo.d.ts +157 -0
  160. package/dist/providers/vimeo.d.ts.map +1 -0
  161. package/dist/providers/youtube.d.ts +134 -0
  162. package/dist/providers/youtube.d.ts.map +1 -0
  163. package/dist/stx/index.d.ts +59 -0
  164. package/dist/stx/index.d.ts.map +1 -0
  165. package/dist/stx/index.js +480 -0
  166. package/dist/stx/index.js.map +11 -0
  167. package/dist/types.d.ts +724 -0
  168. package/dist/types.d.ts.map +1 -0
  169. package/dist/ui/components.d.ts +121 -0
  170. package/dist/ui/components.d.ts.map +1 -0
  171. package/dist/ui/index.d.ts +9 -0
  172. package/dist/ui/index.d.ts.map +1 -0
  173. package/dist/ui/index.js +16 -0
  174. package/dist/ui/index.js.map +9 -0
  175. package/package.json +109 -0
  176. package/src/cdn.test.ts +10 -0
  177. package/src/cdn.ts +60 -0
  178. package/src/config.ts +25 -0
  179. package/src/core/audio-gain.ts +171 -0
  180. package/src/core/caption-styles.ts +568 -0
  181. package/src/core/chapters.ts +551 -0
  182. package/src/core/error-overlay.ts +354 -0
  183. package/src/core/events.ts +552 -0
  184. package/src/core/features.ts +287 -0
  185. package/src/core/i18n.ts +590 -0
  186. package/src/core/index.ts +249 -0
  187. package/src/core/live.ts +472 -0
  188. package/src/core/media-session.ts +137 -0
  189. package/src/core/orientation.ts +57 -0
  190. package/src/core/popover.ts +143 -0
  191. package/src/core/remote-playback.ts +661 -0
  192. package/src/core/settings-menu.ts +630 -0
  193. package/src/core/spinner.ts +310 -0
  194. package/src/core/state.ts +476 -0
  195. package/src/core/text-tracks.ts +599 -0
  196. package/src/core/themes.ts +607 -0
  197. package/src/core/thumbnails.ts +457 -0
  198. package/src/core/tooltips.ts +370 -0
  199. package/src/elements/base.ts +2 -0
  200. package/src/elements/index.ts +82 -0
  201. package/src/elements/media-airplay-button.ts +78 -0
  202. package/src/elements/media-error-display.ts +47 -0
  203. package/src/elements/media-fullscreen-button.ts +72 -0
  204. package/src/elements/media-live-button.ts +65 -0
  205. package/src/elements/media-mute-button.ts +69 -0
  206. package/src/elements/media-pip-button.ts +66 -0
  207. package/src/elements/media-play-button.ts +69 -0
  208. package/src/elements/media-playback-rate-button.ts +59 -0
  209. package/src/elements/media-preview-time.ts +75 -0
  210. package/src/elements/media-progress-bar.ts +200 -0
  211. package/src/elements/media-remote-playback-button.ts +108 -0
  212. package/src/elements/media-seek-button.ts +50 -0
  213. package/src/elements/media-settings-menu.ts +302 -0
  214. package/src/elements/media-time-display.ts +99 -0
  215. package/src/elements/media-time-group.ts +41 -0
  216. package/src/elements/media-time-separator.ts +35 -0
  217. package/src/elements/media-volume-slider.ts +140 -0
  218. package/src/elements/utils.ts +82 -0
  219. package/src/elements/video-player.ts +175 -0
  220. package/src/elements/video-skin.ts +127 -0
  221. package/src/features/captions.ts +56 -0
  222. package/src/features/create-player.ts +91 -0
  223. package/src/features/fullscreen.ts +44 -0
  224. package/src/features/gestures.ts +44 -0
  225. package/src/features/index.ts +44 -0
  226. package/src/features/keyboard.ts +89 -0
  227. package/src/features/pip.ts +47 -0
  228. package/src/features/playback.ts +68 -0
  229. package/src/features/quality.ts +19 -0
  230. package/src/features/types.ts +28 -0
  231. package/src/features/volume.ts +46 -0
  232. package/src/index.ts +635 -0
  233. package/src/layouts/index.ts +672 -0
  234. package/src/player.ts +821 -0
  235. package/src/plugins/ads.ts +1045 -0
  236. package/src/plugins/analytics.ts +673 -0
  237. package/src/plugins/end-screen.ts +793 -0
  238. package/src/plugins/index.ts +61 -0
  239. package/src/plugins/skip-segments.ts +537 -0
  240. package/src/plugins/watermark.ts +537 -0
  241. package/src/providers/base.ts +197 -0
  242. package/src/providers/dash.ts +588 -0
  243. package/src/providers/hls.ts +624 -0
  244. package/src/providers/html5.ts +473 -0
  245. package/src/providers/index.ts +97 -0
  246. package/src/providers/vimeo.ts +533 -0
  247. package/src/providers/youtube.ts +542 -0
  248. package/src/stx/index.ts +606 -0
  249. package/src/types.ts +874 -0
  250. package/src/ui/components.ts +910 -0
  251. package/src/ui/index.ts +29 -0
@@ -0,0 +1,793 @@
1
+ /**
2
+ * End Screen Plugin
3
+ *
4
+ * Display recommendations and actions at video end.
5
+ *
6
+ * @module plugins/end-screen
7
+ */
8
+
9
+ import type { Player } from '../player'
10
+
11
+ // =============================================================================
12
+ // Types
13
+ // =============================================================================
14
+
15
+ export interface EndScreenConfig {
16
+ /** Show end screen on video end */
17
+ enabled?: boolean
18
+ /** Auto-show delay after video ends (ms) */
19
+ showDelay?: number
20
+ /** Recommendations */
21
+ recommendations?: EndScreenRecommendation[]
22
+ /** Custom actions */
23
+ actions?: EndScreenAction[]
24
+ /** Replay button */
25
+ showReplay?: boolean
26
+ /** Share button */
27
+ showShare?: boolean
28
+ /** Countdown to next video (0 = disabled) */
29
+ autoplayCountdown?: number
30
+ /** Title text */
31
+ title?: string
32
+ /** Custom template */
33
+ template?: string
34
+ /** Background style */
35
+ backgroundStyle?: 'blur' | 'dark' | 'gradient' | 'none'
36
+ }
37
+
38
+ export interface EndScreenRecommendation {
39
+ /** Unique ID */
40
+ id: string
41
+ /** Title */
42
+ title: string
43
+ /** Description */
44
+ description?: string
45
+ /** Thumbnail URL */
46
+ thumbnail: string
47
+ /** Duration string (e.g., "12:34") */
48
+ duration?: string
49
+ /** Video URL or callback */
50
+ url?: string
51
+ /** Click handler */
52
+ onClick?: () => void
53
+ /** Channel/creator name */
54
+ channel?: string
55
+ /** View count */
56
+ views?: string
57
+ /** Upload date */
58
+ date?: string
59
+ }
60
+
61
+ export interface EndScreenAction {
62
+ /** Button text */
63
+ label: string
64
+ /** Icon (SVG or emoji) */
65
+ icon?: string
66
+ /** Click handler */
67
+ onClick: () => void
68
+ /** Button style */
69
+ style?: 'primary' | 'secondary' | 'outline'
70
+ }
71
+
72
+ export interface EndScreenState {
73
+ /** Is visible */
74
+ visible: boolean
75
+ /** Autoplay countdown */
76
+ countdown: number
77
+ /** Is countdown active */
78
+ isCountingDown: boolean
79
+ /** Selected recommendation index */
80
+ selectedIndex: number
81
+ }
82
+
83
+ // =============================================================================
84
+ // End Screen Manager
85
+ // =============================================================================
86
+
87
+ export class EndScreenManager {
88
+ private player: Player | null = null
89
+ private config: Required<EndScreenConfig>
90
+ private state: EndScreenState
91
+ private container: HTMLElement | null = null
92
+ private element: HTMLElement | null = null
93
+ private countdownTimer: ReturnType<typeof setInterval> | null = null
94
+ private showTimer: ReturnType<typeof setTimeout> | null = null
95
+ private unsubscribers: Array<() => void> = []
96
+
97
+ // Event handlers
98
+ // eslint-disable-next-line pickier/no-unused-vars
99
+ private onRecommendationClick: ((_rec: EndScreenRecommendation) => void) | null = null
100
+ private onReplay: (() => void) | null = null
101
+ // eslint-disable-next-line pickier/no-unused-vars
102
+ private onAutoplay: ((_rec: EndScreenRecommendation) => void) | null = null
103
+ private onShow: (() => void) | null = null
104
+ private onHide: (() => void) | null = null
105
+
106
+ constructor(config: EndScreenConfig = {}) {
107
+ this.config = {
108
+ enabled: config.enabled ?? true,
109
+ showDelay: config.showDelay ?? 0,
110
+ recommendations: config.recommendations || [],
111
+ actions: config.actions || [],
112
+ showReplay: config.showReplay ?? true,
113
+ showShare: config.showShare ?? false,
114
+ autoplayCountdown: config.autoplayCountdown ?? 0,
115
+ title: config.title || '',
116
+ template: config.template || '',
117
+ backgroundStyle: config.backgroundStyle || 'blur',
118
+ }
119
+
120
+ this.state = {
121
+ visible: false,
122
+ countdown: 0,
123
+ isCountingDown: false,
124
+ selectedIndex: 0,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Attach to player
130
+ */
131
+ attach(player: Player, container: HTMLElement): void {
132
+ this.player = player
133
+ this.container = container
134
+
135
+ this.createElement()
136
+ this.attachEventListeners()
137
+ }
138
+
139
+ /**
140
+ * Create end screen element
141
+ */
142
+ private createElement(): void {
143
+ if (!this.container) return
144
+
145
+ this.element = document.createElement('div')
146
+ this.element.className = 'tsvp-end-screen'
147
+
148
+ this.element.style.cssText = `
149
+ position: absolute;
150
+ inset: 0;
151
+ display: none;
152
+ flex-direction: column;
153
+ align-items: center;
154
+ justify-content: center;
155
+ z-index: 40;
156
+ font-family: var(--tsvp-font-family, sans-serif);
157
+ color: var(--tsvp-color-text, #ffffff);
158
+ padding: 24px;
159
+ overflow: auto;
160
+ `
161
+
162
+ this.applyBackgroundStyle()
163
+ this.container.appendChild(this.element)
164
+ }
165
+
166
+ /**
167
+ * Apply background style
168
+ */
169
+ private applyBackgroundStyle(): void {
170
+ if (!this.element) return
171
+
172
+ switch (this.config.backgroundStyle) {
173
+ case 'blur':
174
+ this.element.style.background = 'rgba(0, 0, 0, 0.85)'
175
+ this.element.style.backdropFilter = 'blur(10px)'
176
+ break
177
+ case 'dark':
178
+ this.element.style.background = 'rgba(0, 0, 0, 0.95)'
179
+ break
180
+ case 'gradient':
181
+ this.element.style.background = 'linear-gradient(135deg, rgba(0,0,0,0.9) 0%, rgba(20,20,40,0.95) 100%)'
182
+ break
183
+ case 'none':
184
+ this.element.style.background = 'transparent'
185
+ break
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Render end screen content
191
+ */
192
+ private render(): void {
193
+ if (!this.element) return
194
+
195
+ // Use custom template if provided
196
+ if (this.config.template) {
197
+ this.element.innerHTML = this.config.template
198
+ return
199
+ }
200
+
201
+ this.element.innerHTML = ''
202
+
203
+ // Title
204
+ if (this.config.title) {
205
+ const title = document.createElement('h2')
206
+ title.className = 'tsvp-end-screen-title'
207
+ title.textContent = this.config.title
208
+ title.style.cssText = `
209
+ margin: 0 0 24px;
210
+ font-size: 24px;
211
+ font-weight: 600;
212
+ text-align: center;
213
+ `
214
+ this.element.appendChild(title)
215
+ }
216
+
217
+ // Recommendations grid
218
+ if (this.config.recommendations.length > 0) {
219
+ const grid = document.createElement('div')
220
+ grid.className = 'tsvp-end-screen-grid'
221
+ grid.style.cssText = `
222
+ display: grid;
223
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
224
+ gap: 16px;
225
+ max-width: 900px;
226
+ width: 100%;
227
+ margin-bottom: 24px;
228
+ `
229
+
230
+ this.config.recommendations.forEach((rec, index) => {
231
+ const card = this.createRecommendationCard(rec, index)
232
+ grid.appendChild(card)
233
+ })
234
+
235
+ this.element.appendChild(grid)
236
+ }
237
+
238
+ // Actions row
239
+ const actionsRow = document.createElement('div')
240
+ actionsRow.className = 'tsvp-end-screen-actions'
241
+ actionsRow.style.cssText = `
242
+ display: flex;
243
+ gap: 12px;
244
+ flex-wrap: wrap;
245
+ justify-content: center;
246
+ `
247
+
248
+ // Replay button
249
+ if (this.config.showReplay) {
250
+ const replayBtn = this.createActionButton({
251
+ label: 'Replay',
252
+ icon: `<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
253
+ <path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
254
+ </svg>`,
255
+ style: 'secondary',
256
+ onClick: () => this.replay(),
257
+ })
258
+ actionsRow.appendChild(replayBtn)
259
+ }
260
+
261
+ // Share button
262
+ if (this.config.showShare) {
263
+ const shareBtn = this.createActionButton({
264
+ label: 'Share',
265
+ icon: `<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
266
+ <path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92s2.92-1.31 2.92-2.92-1.31-2.92-2.92-2.92z"/>
267
+ </svg>`,
268
+ style: 'outline',
269
+ onClick: () => this.share(),
270
+ })
271
+ actionsRow.appendChild(shareBtn)
272
+ }
273
+
274
+ // Custom actions
275
+ for (const action of this.config.actions) {
276
+ const btn = this.createActionButton(action)
277
+ actionsRow.appendChild(btn)
278
+ }
279
+
280
+ this.element.appendChild(actionsRow)
281
+
282
+ // Autoplay countdown
283
+ if (this.state.isCountingDown && this.config.recommendations.length > 0) {
284
+ const countdown = document.createElement('div')
285
+ countdown.className = 'tsvp-end-screen-countdown'
286
+ countdown.style.cssText = `
287
+ margin-top: 24px;
288
+ text-align: center;
289
+ font-size: 14px;
290
+ opacity: 0.8;
291
+ `
292
+ countdown.innerHTML = `
293
+ Next video in <strong>${this.state.countdown}</strong> seconds
294
+ <button class="tsvp-end-screen-cancel" style="
295
+ margin-left: 12px;
296
+ padding: 4px 12px;
297
+ border: 1px solid rgba(255,255,255,0.3);
298
+ border-radius: 4px;
299
+ background: transparent;
300
+ color: inherit;
301
+ cursor: pointer;
302
+ ">Cancel</button>
303
+ `
304
+
305
+ const cancelBtn = countdown.querySelector('.tsvp-end-screen-cancel')
306
+ cancelBtn?.addEventListener('click', () => this.cancelAutoplay())
307
+
308
+ this.element.appendChild(countdown)
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Create recommendation card
314
+ */
315
+ private createRecommendationCard(rec: EndScreenRecommendation, index: number): HTMLElement {
316
+ const card = document.createElement('div')
317
+ card.className = 'tsvp-end-screen-card'
318
+ card.tabIndex = 0
319
+
320
+ const isSelected = index === this.state.selectedIndex && this.state.isCountingDown
321
+
322
+ card.style.cssText = `
323
+ background: rgba(255, 255, 255, 0.1);
324
+ border-radius: 8px;
325
+ overflow: hidden;
326
+ cursor: pointer;
327
+ transition: transform 150ms ease, background 150ms ease;
328
+ ${isSelected ? 'outline: 2px solid var(--tsvp-color-primary, #00a8ff); outline-offset: 2px;' : ''}
329
+ `
330
+
331
+ // Thumbnail
332
+ const thumbnail = document.createElement('div')
333
+ thumbnail.className = 'tsvp-end-screen-thumbnail'
334
+ thumbnail.style.cssText = `
335
+ position: relative;
336
+ aspect-ratio: 16 / 9;
337
+ background: url(${rec.thumbnail}) center/cover;
338
+ `
339
+
340
+ // Duration badge
341
+ if (rec.duration) {
342
+ const duration = document.createElement('span')
343
+ duration.className = 'tsvp-end-screen-duration'
344
+ duration.textContent = rec.duration
345
+ duration.style.cssText = `
346
+ position: absolute;
347
+ bottom: 4px;
348
+ right: 4px;
349
+ padding: 2px 6px;
350
+ background: rgba(0, 0, 0, 0.8);
351
+ border-radius: 4px;
352
+ font-size: 12px;
353
+ font-weight: 500;
354
+ `
355
+ thumbnail.appendChild(duration)
356
+ }
357
+
358
+ card.appendChild(thumbnail)
359
+
360
+ // Info
361
+ const info = document.createElement('div')
362
+ info.className = 'tsvp-end-screen-info'
363
+ info.style.cssText = `
364
+ padding: 12px;
365
+ `
366
+
367
+ const title = document.createElement('h3')
368
+ title.className = 'tsvp-end-screen-card-title'
369
+ title.textContent = rec.title
370
+ title.style.cssText = `
371
+ margin: 0 0 4px;
372
+ font-size: 14px;
373
+ font-weight: 500;
374
+ overflow: hidden;
375
+ text-overflow: ellipsis;
376
+ display: -webkit-box;
377
+ -webkit-line-clamp: 2;
378
+ -webkit-box-orient: vertical;
379
+ `
380
+ info.appendChild(title)
381
+
382
+ if (rec.channel) {
383
+ const channel = document.createElement('p')
384
+ channel.className = 'tsvp-end-screen-channel'
385
+ channel.textContent = rec.channel
386
+ channel.style.cssText = `
387
+ margin: 0;
388
+ font-size: 12px;
389
+ opacity: 0.7;
390
+ `
391
+ info.appendChild(channel)
392
+ }
393
+
394
+ if (rec.views || rec.date) {
395
+ const meta = document.createElement('p')
396
+ meta.className = 'tsvp-end-screen-meta'
397
+ meta.textContent = [rec.views, rec.date].filter(Boolean).join(' • ')
398
+ meta.style.cssText = `
399
+ margin: 4px 0 0;
400
+ font-size: 12px;
401
+ opacity: 0.6;
402
+ `
403
+ info.appendChild(meta)
404
+ }
405
+
406
+ card.appendChild(info)
407
+
408
+ // Hover effects
409
+ card.addEventListener('mouseenter', () => {
410
+ card.style.transform = 'scale(1.03)'
411
+ card.style.background = 'rgba(255, 255, 255, 0.15)'
412
+ })
413
+
414
+ card.addEventListener('mouseleave', () => {
415
+ card.style.transform = 'scale(1)'
416
+ card.style.background = 'rgba(255, 255, 255, 0.1)'
417
+ })
418
+
419
+ // Click handler
420
+ card.addEventListener('click', () => {
421
+ this.onRecommendationClick?.(rec)
422
+ if (rec.onClick) {
423
+ rec.onClick()
424
+ }
425
+ else if (rec.url) {
426
+ window.location.href = rec.url
427
+ }
428
+ })
429
+
430
+ return card
431
+ }
432
+
433
+ /**
434
+ * Create action button
435
+ */
436
+ private createActionButton(action: EndScreenAction): HTMLButtonElement {
437
+ const btn = document.createElement('button')
438
+ btn.className = `tsvp-end-screen-btn tsvp-end-screen-btn--${action.style || 'secondary'}`
439
+ btn.type = 'button'
440
+
441
+ let bgColor = 'rgba(255, 255, 255, 0.2)'
442
+ let borderColor = 'transparent'
443
+
444
+ switch (action.style) {
445
+ case 'primary':
446
+ bgColor = 'var(--tsvp-color-primary, #00a8ff)'
447
+ break
448
+ case 'outline':
449
+ bgColor = 'transparent'
450
+ borderColor = 'rgba(255, 255, 255, 0.5)'
451
+ break
452
+ }
453
+
454
+ btn.style.cssText = `
455
+ display: flex;
456
+ align-items: center;
457
+ gap: 8px;
458
+ padding: 10px 20px;
459
+ background: ${bgColor};
460
+ border: 1px solid ${borderColor};
461
+ border-radius: 6px;
462
+ color: #fff;
463
+ font-size: 14px;
464
+ font-weight: 500;
465
+ font-family: inherit;
466
+ cursor: pointer;
467
+ transition: transform 150ms ease, opacity 150ms ease;
468
+ `
469
+
470
+ if (action.icon) {
471
+ const icon = document.createElement('span')
472
+ icon.innerHTML = action.icon
473
+ icon.style.display = 'flex'
474
+ btn.appendChild(icon)
475
+ }
476
+
477
+ const label = document.createElement('span')
478
+ label.textContent = action.label
479
+ btn.appendChild(label)
480
+
481
+ btn.addEventListener('mouseenter', () => {
482
+ btn.style.transform = 'scale(1.05)'
483
+ })
484
+
485
+ btn.addEventListener('mouseleave', () => {
486
+ btn.style.transform = 'scale(1)'
487
+ })
488
+
489
+ btn.addEventListener('click', action.onClick)
490
+
491
+ return btn
492
+ }
493
+
494
+ /**
495
+ * Attach event listeners
496
+ */
497
+ private attachEventListeners(): void {
498
+ if (!this.player) return
499
+
500
+ const handleEnded = () => {
501
+ if (this.config.enabled) {
502
+ this.showTimer = setTimeout(() => {
503
+ this.show()
504
+ }, this.config.showDelay)
505
+ }
506
+ }
507
+
508
+ this.player.on('ended', handleEnded)
509
+ this.unsubscribers.push(() => this.player?.off('ended', handleEnded))
510
+
511
+ // Hide on play
512
+ const handlePlay = () => {
513
+ if (this.state.visible) {
514
+ this.hide()
515
+ }
516
+ }
517
+
518
+ this.player.on('play', handlePlay)
519
+ this.unsubscribers.push(() => this.player?.off('play', handlePlay))
520
+
521
+ // Keyboard navigation
522
+ const handleKeydown = (e: KeyboardEvent) => {
523
+ if (!this.state.visible) return
524
+
525
+ switch (e.key) {
526
+ case 'Escape':
527
+ this.hide()
528
+ break
529
+ case 'ArrowLeft':
530
+ this.selectPrevious()
531
+ break
532
+ case 'ArrowRight':
533
+ this.selectNext()
534
+ break
535
+ case 'Enter':
536
+ this.selectCurrent()
537
+ break
538
+ }
539
+ }
540
+
541
+ document.addEventListener('keydown', handleKeydown)
542
+ this.unsubscribers.push(() => document.removeEventListener('keydown', handleKeydown))
543
+ }
544
+
545
+ /**
546
+ * Show end screen
547
+ */
548
+ show(): void {
549
+ if (!this.element || this.state.visible) return
550
+
551
+ this.state.visible = true
552
+ this.state.selectedIndex = 0
553
+
554
+ // Start autoplay countdown
555
+ if (this.config.autoplayCountdown > 0 && this.config.recommendations.length > 0) {
556
+ this.startAutoplayCountdown()
557
+ }
558
+
559
+ this.render()
560
+
561
+ this.element.style.display = 'flex'
562
+ this.element.style.opacity = '0'
563
+ requestAnimationFrame(() => {
564
+ this.element!.style.transition = 'opacity 300ms ease'
565
+ this.element!.style.opacity = '1'
566
+ })
567
+
568
+ this.onShow?.()
569
+ }
570
+
571
+ /**
572
+ * Hide end screen
573
+ */
574
+ hide(): void {
575
+ if (!this.element || !this.state.visible) return
576
+
577
+ this.state.visible = false
578
+ this.cancelAutoplay()
579
+
580
+ this.element.style.opacity = '0'
581
+ setTimeout(() => {
582
+ if (this.element && !this.state.visible) {
583
+ this.element.style.display = 'none'
584
+ }
585
+ }, 300)
586
+
587
+ this.onHide?.()
588
+ }
589
+
590
+ /**
591
+ * Start autoplay countdown
592
+ */
593
+ private startAutoplayCountdown(): void {
594
+ this.state.isCountingDown = true
595
+ this.state.countdown = this.config.autoplayCountdown
596
+
597
+ this.countdownTimer = setInterval(() => {
598
+ this.state.countdown--
599
+ this.render()
600
+
601
+ if (this.state.countdown <= 0) {
602
+ this.playNextVideo()
603
+ }
604
+ }, 1000)
605
+ }
606
+
607
+ /**
608
+ * Cancel autoplay
609
+ */
610
+ cancelAutoplay(): void {
611
+ this.state.isCountingDown = false
612
+ this.state.countdown = 0
613
+
614
+ if (this.countdownTimer) {
615
+ clearInterval(this.countdownTimer)
616
+ this.countdownTimer = null
617
+ }
618
+
619
+ if (this.state.visible) {
620
+ this.render()
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Play next video
626
+ */
627
+ private playNextVideo(): void {
628
+ const rec = this.config.recommendations[this.state.selectedIndex]
629
+ if (rec) {
630
+ this.onAutoplay?.(rec)
631
+ if (rec.onClick) {
632
+ rec.onClick()
633
+ }
634
+ else if (rec.url) {
635
+ window.location.href = rec.url
636
+ }
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Select previous recommendation
642
+ */
643
+ private selectPrevious(): void {
644
+ if (this.config.recommendations.length === 0) return
645
+ this.state.selectedIndex = (this.state.selectedIndex - 1 + this.config.recommendations.length)
646
+ % this.config.recommendations.length
647
+ this.render()
648
+ }
649
+
650
+ /**
651
+ * Select next recommendation
652
+ */
653
+ private selectNext(): void {
654
+ if (this.config.recommendations.length === 0) return
655
+ this.state.selectedIndex = (this.state.selectedIndex + 1) % this.config.recommendations.length
656
+ this.render()
657
+ }
658
+
659
+ /**
660
+ * Select current recommendation
661
+ */
662
+ private selectCurrent(): void {
663
+ const rec = this.config.recommendations[this.state.selectedIndex]
664
+ if (rec) {
665
+ this.onRecommendationClick?.(rec)
666
+ if (rec.onClick) {
667
+ rec.onClick()
668
+ }
669
+ else if (rec.url) {
670
+ window.location.href = rec.url
671
+ }
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Replay video
677
+ */
678
+ private replay(): void {
679
+ this.hide()
680
+ this.player?.seekTo(0)
681
+ this.player?.play()
682
+ this.onReplay?.()
683
+ }
684
+
685
+ /**
686
+ * Share video
687
+ */
688
+ private share(): void {
689
+ if (navigator.share) {
690
+ navigator.share({
691
+ title: document.title,
692
+ url: window.location.href,
693
+ })
694
+ }
695
+ else {
696
+ // Fallback: copy to clipboard
697
+ navigator.clipboard?.writeText(window.location.href)
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Update recommendations
703
+ */
704
+ setRecommendations(recommendations: EndScreenRecommendation[]): void {
705
+ this.config.recommendations = recommendations
706
+ if (this.state.visible) {
707
+ this.render()
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Get state
713
+ */
714
+ getState(): EndScreenState {
715
+ return { ...this.state }
716
+ }
717
+
718
+ /**
719
+ * Set event handlers
720
+ */
721
+ on(event: 'recommendationClick', handler: (rec: EndScreenRecommendation) => void): void
722
+ on(event: 'replay', handler: () => void): void
723
+ on(event: 'autoplay', handler: (rec: EndScreenRecommendation) => void): void
724
+ on(event: 'show' | 'hide', handler: () => void): void
725
+ on(event: string, handler: (...args: any[]) => void): void {
726
+ switch (event) {
727
+ case 'recommendationClick':
728
+ this.onRecommendationClick = handler
729
+ break
730
+ case 'replay':
731
+ this.onReplay = handler
732
+ break
733
+ case 'autoplay':
734
+ this.onAutoplay = handler
735
+ break
736
+ case 'show':
737
+ this.onShow = handler
738
+ break
739
+ case 'hide':
740
+ this.onHide = handler
741
+ break
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Destroy
747
+ */
748
+ destroy(): void {
749
+ if (this.countdownTimer) {
750
+ clearInterval(this.countdownTimer)
751
+ }
752
+ if (this.showTimer) {
753
+ clearTimeout(this.showTimer)
754
+ }
755
+
756
+ this.unsubscribers.forEach(unsub => unsub())
757
+
758
+ this.element?.remove()
759
+
760
+ this.player = null
761
+ this.container = null
762
+ }
763
+ }
764
+
765
+ // =============================================================================
766
+ // Factory
767
+ // =============================================================================
768
+
769
+ export function createEndScreen(config?: EndScreenConfig): EndScreenManager {
770
+ return new EndScreenManager(config)
771
+ }
772
+
773
+ /**
774
+ * End screen plugin for Player
775
+ */
776
+ export function endScreenPlugin(config: EndScreenConfig): { name: string, install: (player: Player, container: HTMLElement) => () => void } {
777
+ return {
778
+ name: 'endScreen',
779
+
780
+ install(player: Player, container: HTMLElement) {
781
+ const endScreen = createEndScreen(config)
782
+ endScreen.attach(player, container)
783
+
784
+ // Expose on player
785
+ ;(player as any).endScreen = endScreen
786
+
787
+ return () => {
788
+ endScreen.destroy()
789
+ delete (player as any).endScreen
790
+ }
791
+ },
792
+ }
793
+ }