@shortkitsdk/web 0.3.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.
@@ -0,0 +1,3751 @@
1
+ class IdentityManager {
2
+ constructor({ userId, onResolve } = {}) {
3
+ this._onResolve = onResolve || null;
4
+ const savedAnon = localStorage.getItem("sk_anonymous_id");
5
+ if (savedAnon) {
6
+ this._anonymousId = savedAnon;
7
+ } else {
8
+ this._anonymousId = generateUUID();
9
+ localStorage.setItem("sk_anonymous_id", this._anonymousId);
10
+ }
11
+ this._userId = localStorage.getItem("sk_user_id") || null;
12
+ if (userId) {
13
+ this.setUserId(userId);
14
+ }
15
+ }
16
+ get anonymousId() {
17
+ return this._anonymousId;
18
+ }
19
+ get userId() {
20
+ return this._userId;
21
+ }
22
+ get effectiveId() {
23
+ return this._userId ?? this._anonymousId;
24
+ }
25
+ setUserId(id) {
26
+ const previousAnonId = this._anonymousId;
27
+ this._userId = id;
28
+ localStorage.setItem("sk_user_id", id);
29
+ if (this._onResolve) {
30
+ this._onResolve(id, previousAnonId);
31
+ }
32
+ }
33
+ clearUserId() {
34
+ this._userId = null;
35
+ localStorage.removeItem("sk_user_id");
36
+ this._anonymousId = generateUUID();
37
+ localStorage.setItem("sk_anonymous_id", this._anonymousId);
38
+ }
39
+ }
40
+ function generateUUID() {
41
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
42
+ return crypto.randomUUID();
43
+ }
44
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
45
+ const r = Math.random() * 16 | 0;
46
+ return (c === "x" ? r : r & 3 | 8).toString(16);
47
+ });
48
+ }
49
+ const VALID_CLICK_ACTIONS = ["feed", "mute", "article", "none"];
50
+ const VALID_FEED_SOURCES = ["algorithmic", "custom"];
51
+ function createFeedConfig(overrides = {}) {
52
+ const config = {
53
+ feedHeight: "fullscreen",
54
+ muteOnStart: true,
55
+ autoplay: true,
56
+ feedSource: "algorithmic",
57
+ filter: null,
58
+ overlay: null,
59
+ preload: null,
60
+ ...overrides
61
+ };
62
+ if (config.feedSource && !VALID_FEED_SOURCES.includes(config.feedSource)) {
63
+ throw new Error(`Invalid feedSource: ${config.feedSource}`);
64
+ }
65
+ if (config.feedHeight !== "fullscreen" && !(config.feedHeight?.type === "percentage" && typeof config.feedHeight?.value === "number")) {
66
+ throw new Error(`Invalid feedHeight: ${JSON.stringify(config.feedHeight)}`);
67
+ }
68
+ return config;
69
+ }
70
+ function createPlayerConfig(overrides = {}) {
71
+ const config = {
72
+ cornerRadius: 12,
73
+ clickAction: "feed",
74
+ autoplay: true,
75
+ loop: true,
76
+ muteOnStart: true,
77
+ overlay: null,
78
+ ...overrides
79
+ };
80
+ if (!VALID_CLICK_ACTIONS.includes(config.clickAction)) {
81
+ throw new Error(`Invalid clickAction: ${config.clickAction}`);
82
+ }
83
+ return config;
84
+ }
85
+ function createWidgetConfig(overrides = {}) {
86
+ const config = {
87
+ cardCount: 3,
88
+ cardSpacing: 8,
89
+ cornerRadius: 12,
90
+ autoplay: true,
91
+ muteOnStart: true,
92
+ loop: true,
93
+ rotationInterval: 1e4,
94
+ clickAction: "feed",
95
+ overlay: null,
96
+ ...overrides
97
+ };
98
+ if (!VALID_CLICK_ACTIONS.includes(config.clickAction)) {
99
+ throw new Error(`Invalid clickAction: ${config.clickAction}`);
100
+ }
101
+ return config;
102
+ }
103
+ function createFeedFilter(opts = {}) {
104
+ return {
105
+ tags: opts.tags || null,
106
+ section: opts.section || null,
107
+ author: opts.author || null,
108
+ contentType: opts.contentType || null,
109
+ metadata: opts.metadata || null,
110
+ toQueryParams() {
111
+ const params = [];
112
+ if (this.tags?.length) params.push(["tags", this.tags.join(",")]);
113
+ if (this.section) params.push(["section", this.section]);
114
+ if (this.author) params.push(["author", this.author]);
115
+ if (this.contentType) params.push(["content_type", this.contentType]);
116
+ if (this.metadata) {
117
+ for (const [key, value] of Object.entries(this.metadata).sort(([a], [b]) => a.localeCompare(b))) {
118
+ params.push([`metadata.${key}`, value]);
119
+ }
120
+ }
121
+ return params;
122
+ }
123
+ };
124
+ }
125
+ class APIClient {
126
+ constructor({ baseUrl, apiKey, getEffectiveId }) {
127
+ this._baseUrl = baseUrl;
128
+ this._apiKey = apiKey;
129
+ this._getEffectiveId = getEffectiveId;
130
+ }
131
+ _headers() {
132
+ return {
133
+ "X-API-Key": this._apiKey,
134
+ "X-User-Id": this._getEffectiveId(),
135
+ "Content-Type": "application/json"
136
+ };
137
+ }
138
+ async fetchFeed({ limit = 10, cursor, filter } = {}) {
139
+ let url;
140
+ const params = new URLSearchParams({ limit: String(limit) });
141
+ if (cursor) params.set("cursor", cursor);
142
+ if (filter) {
143
+ const f = filter.toQueryParams ? filter : createFeedFilter(filter);
144
+ for (const [key, value] of f.toQueryParams()) {
145
+ params.set(key, value);
146
+ }
147
+ url = `${this._baseUrl}/v1/feed/filter?${params}`;
148
+ } else {
149
+ url = `${this._baseUrl}/v1/feed?${params}`;
150
+ }
151
+ const res = await fetch(url, { headers: this._headers() });
152
+ if (!res.ok) throw new Error(`Feed API ${res.status}: ${res.statusText}`);
153
+ const json = await res.json();
154
+ const nextCursor = json.meta?.next_cursor || null;
155
+ const items = (json.data || []).filter((item) => !item.type || item.type === "content").map((item) => parseContentItem(item));
156
+ return { items, nextCursor, hasMore: !!nextCursor };
157
+ }
158
+ async resolveIdentity(userId, anonymousId) {
159
+ const res = await fetch(`${this._baseUrl}/v1/identity/resolve`, {
160
+ method: "POST",
161
+ headers: this._headers(),
162
+ body: JSON.stringify({ userId, anonymousId })
163
+ });
164
+ if (!res.ok) {
165
+ console.warn(`Identity resolve failed: ${res.status}`);
166
+ }
167
+ }
168
+ async postEvents(events) {
169
+ const res = await fetch(`${this._baseUrl}/v1/events`, {
170
+ method: "POST",
171
+ headers: this._headers(),
172
+ body: JSON.stringify({ events })
173
+ });
174
+ if (!res.ok) throw new Error(`Events API ${res.status}`);
175
+ }
176
+ beaconEvents(events) {
177
+ const blob = new Blob(
178
+ [JSON.stringify({
179
+ events,
180
+ apiKey: this._apiKey,
181
+ userId: this._getEffectiveId()
182
+ })],
183
+ { type: "application/json" }
184
+ );
185
+ navigator.sendBeacon(`${this._baseUrl}/v1/events`, blob);
186
+ }
187
+ }
188
+ function parseContentItem(raw) {
189
+ return {
190
+ id: raw.id,
191
+ playbackId: raw.playbackId || null,
192
+ title: raw.title || "",
193
+ description: raw.description || null,
194
+ duration: raw.duration || 0,
195
+ streamingUrl: raw.streamingUrl || "",
196
+ thumbnailUrl: raw.thumbnailUrl || "",
197
+ captionTracks: (raw.captionTracks || []).map((t) => ({
198
+ language: t.language,
199
+ label: t.label || t.language,
200
+ source: t.source || "external",
201
+ url: t.url || null
202
+ })),
203
+ customMetadata: raw.customMetadata || null,
204
+ author: raw.author || null,
205
+ articleUrl: raw.articleUrl || raw.publisherUrl || null,
206
+ commentCount: raw.commentCount ?? null,
207
+ fallbackUrl: raw.fallbackUrl || null
208
+ };
209
+ }
210
+ class ViewSession {
211
+ constructor(contentId) {
212
+ this.contentId = contentId;
213
+ this.startTime = Date.now();
214
+ this.watchDuration = 0;
215
+ this.loopCount = 0;
216
+ this.completed = false;
217
+ }
218
+ addWatchTime(seconds) {
219
+ this.watchDuration += seconds;
220
+ }
221
+ recordLoop() {
222
+ this.loopCount++;
223
+ }
224
+ markCompleted() {
225
+ this.completed = true;
226
+ }
227
+ toSummary() {
228
+ return {
229
+ contentId: this.contentId,
230
+ startTime: this.startTime,
231
+ watchDuration: this.watchDuration,
232
+ loopCount: this.loopCount,
233
+ completed: this.completed
234
+ };
235
+ }
236
+ }
237
+ class EngagementTracker {
238
+ constructor({ onEvent, sessionId }) {
239
+ this._onEvent = onEvent;
240
+ this._sessionId = sessionId || crypto.randomUUID?.() || Math.random().toString(36).slice(2);
241
+ this.activeSession = null;
242
+ }
243
+ _emit(type, contentId, data = {}) {
244
+ this._onEvent({
245
+ type,
246
+ contentId: contentId || null,
247
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
248
+ sessionId: this._sessionId,
249
+ data
250
+ });
251
+ }
252
+ trackFeedEntry() {
253
+ this._emit("feedEntry");
254
+ }
255
+ trackFeedExit() {
256
+ this._emit("feedExit");
257
+ }
258
+ trackImpression(contentId) {
259
+ this._emit("impression", contentId);
260
+ }
261
+ trackPlayStart(contentId) {
262
+ this._emit("playStart", contentId);
263
+ }
264
+ trackFirstFrame(contentId) {
265
+ this._emit("firstFrame", contentId);
266
+ }
267
+ trackWatchProgress(contentId, seconds) {
268
+ this._emit("watchProgress", contentId, { seconds });
269
+ }
270
+ trackCompletion(contentId) {
271
+ this._emit("completion", contentId);
272
+ }
273
+ trackSwipe(fromId, toId, direction) {
274
+ this._emit("swipe", fromId, { toId, direction });
275
+ }
276
+ trackRebuffer(contentId) {
277
+ this._emit("rebuffer", contentId);
278
+ }
279
+ trackQualityChange(contentId, data) {
280
+ this._emit("qualityChange", contentId, data);
281
+ }
282
+ trackError(contentId, message) {
283
+ this._emit("error", contentId, { message });
284
+ }
285
+ trackInteraction(contentId, action) {
286
+ this._emit("interaction", contentId, { action });
287
+ }
288
+ trackContentSignal(contentId, signal) {
289
+ this._emit("contentSignal", contentId, { signal });
290
+ }
291
+ trackPlaybackFallback(contentId) {
292
+ this._emit("playbackFallback", contentId);
293
+ }
294
+ trackViewEnd(contentId, sessionData) {
295
+ this._emit("viewEnd", contentId, sessionData);
296
+ }
297
+ activateContent(contentId) {
298
+ if (this.activeSession) this.deactivateContent();
299
+ this.activeSession = new ViewSession(contentId);
300
+ this.trackImpression(contentId);
301
+ }
302
+ deactivateContent() {
303
+ if (!this.activeSession) return;
304
+ this.trackViewEnd(this.activeSession.contentId, this.activeSession.toSummary());
305
+ this.activeSession = null;
306
+ }
307
+ }
308
+ class EventBatcher {
309
+ constructor({ postEvents, beaconEvents, flushIntervalMs = 3e4 }) {
310
+ this._postEvents = postEvents;
311
+ this._beaconEvents = beaconEvents;
312
+ this._flushIntervalMs = flushIntervalMs;
313
+ this._queue = [];
314
+ this._timerId = null;
315
+ this._started = false;
316
+ this._visibilityHandler = null;
317
+ }
318
+ get pending() {
319
+ return this._queue.length;
320
+ }
321
+ add(event) {
322
+ this._queue.push(event);
323
+ if (this._started && this._timerId === null) {
324
+ this._scheduleFlush();
325
+ }
326
+ }
327
+ start() {
328
+ this._started = true;
329
+ this._scheduleFlush();
330
+ this._visibilityHandler = () => {
331
+ if (document.hidden) this.flushBeacon();
332
+ };
333
+ document.addEventListener("visibilitychange", this._visibilityHandler);
334
+ }
335
+ _scheduleFlush() {
336
+ this._timerId = setTimeout(async () => {
337
+ this._timerId = null;
338
+ await this.flush();
339
+ }, this._flushIntervalMs);
340
+ }
341
+ async flush() {
342
+ if (this._queue.length === 0) return;
343
+ const batch = this._queue.splice(0);
344
+ for (let attempt = 0; attempt < 3; attempt++) {
345
+ try {
346
+ await this._postEvents(batch);
347
+ return;
348
+ } catch {
349
+ }
350
+ }
351
+ console.warn(`ShortKit: dropped ${batch.length} analytics events after 3 retries`);
352
+ }
353
+ flushBeacon() {
354
+ if (this._queue.length === 0) return;
355
+ const batch = this._queue.splice(0);
356
+ this._beaconEvents(batch);
357
+ }
358
+ destroy() {
359
+ this._started = false;
360
+ if (this._timerId) {
361
+ clearTimeout(this._timerId);
362
+ this._timerId = null;
363
+ }
364
+ if (this._visibilityHandler) {
365
+ document.removeEventListener("visibilitychange", this._visibilityHandler);
366
+ this._visibilityHandler = null;
367
+ }
368
+ this.flushBeacon();
369
+ }
370
+ }
371
+ class EventEmitter {
372
+ constructor() {
373
+ this._listeners = /* @__PURE__ */ new Map();
374
+ }
375
+ on(event, fn) {
376
+ if (!this._listeners.has(event)) {
377
+ this._listeners.set(event, []);
378
+ }
379
+ this._listeners.get(event).push(fn);
380
+ }
381
+ off(event, fn) {
382
+ const fns = this._listeners.get(event);
383
+ if (!fns) return;
384
+ const idx = fns.indexOf(fn);
385
+ if (idx !== -1) fns.splice(idx, 1);
386
+ }
387
+ emit(event, data) {
388
+ const fns = this._listeners.get(event);
389
+ if (!fns) return;
390
+ const snapshot = [...fns];
391
+ for (const fn of snapshot) {
392
+ fn(data);
393
+ }
394
+ }
395
+ removeAllListeners() {
396
+ this._listeners.clear();
397
+ }
398
+ }
399
+ const STATE_EVENTS = {
400
+ playerState: "stateChange",
401
+ currentItem: "itemChange",
402
+ isMuted: "mutedChange",
403
+ playbackRate: "playbackRateChange",
404
+ captionsEnabled: "captionsChange",
405
+ activeCaptionTrack: "captionTrackChange",
406
+ activeCue: "cueChange",
407
+ feedScrollPhase: "feedScrollPhase",
408
+ prefetchedAheadCount: "prefetchedAheadCountChange",
409
+ remainingContentCount: "remainingContentCountChange"
410
+ };
411
+ const COMMANDS = [
412
+ "play",
413
+ "pause",
414
+ "seek",
415
+ "seekAndPlay",
416
+ "setMuted",
417
+ "skipToNext",
418
+ "skipToPrevious",
419
+ "setPlaybackRate",
420
+ "setCaptionsEnabled",
421
+ "selectCaptionTrack",
422
+ "sendContentSignal",
423
+ "setMaxBitrate",
424
+ "seekThumbnail"
425
+ ];
426
+ class ShortKitPlayer {
427
+ constructor() {
428
+ this._emitter = new EventEmitter();
429
+ this._surface = null;
430
+ this._state = {
431
+ playerState: "idle",
432
+ currentItem: null,
433
+ time: { current: 0, duration: 0, buffered: 0 },
434
+ isMuted: true,
435
+ playbackRate: 1,
436
+ captionsEnabled: false,
437
+ activeCaptionTrack: null,
438
+ activeCue: null,
439
+ feedScrollPhase: null,
440
+ prefetchedAheadCount: 0,
441
+ remainingContentCount: 0
442
+ };
443
+ for (const cmd of COMMANDS) {
444
+ this[cmd] = (...args) => {
445
+ if (this._surface && typeof this._surface[cmd] === "function") {
446
+ return this._surface[cmd](...args);
447
+ }
448
+ };
449
+ }
450
+ }
451
+ get playerState() {
452
+ return this._state.playerState;
453
+ }
454
+ get currentItem() {
455
+ return this._state.currentItem;
456
+ }
457
+ get time() {
458
+ return this._state.time;
459
+ }
460
+ get isMuted() {
461
+ return this._state.isMuted;
462
+ }
463
+ get playbackRate() {
464
+ return this._state.playbackRate;
465
+ }
466
+ get captionsEnabled() {
467
+ return this._state.captionsEnabled;
468
+ }
469
+ get activeCaptionTrack() {
470
+ return this._state.activeCaptionTrack;
471
+ }
472
+ get activeCue() {
473
+ return this._state.activeCue;
474
+ }
475
+ get feedScrollPhase() {
476
+ return this._state.feedScrollPhase;
477
+ }
478
+ get prefetchedAheadCount() {
479
+ return this._state.prefetchedAheadCount;
480
+ }
481
+ get remainingContentCount() {
482
+ return this._state.remainingContentCount;
483
+ }
484
+ on(event, fn) {
485
+ this._emitter.on(event, fn);
486
+ }
487
+ off(event, fn) {
488
+ this._emitter.off(event, fn);
489
+ }
490
+ registerSurface(surface) {
491
+ this._surface = surface;
492
+ }
493
+ unregisterSurface(surface) {
494
+ if (this._surface === surface) this._surface = null;
495
+ }
496
+ _pushState(updates) {
497
+ for (const [key, value] of Object.entries(updates)) {
498
+ if (key === "time") {
499
+ this._state.time = value;
500
+ this._emitter.emit("timeUpdate", value);
501
+ } else if (this._state[key] !== value) {
502
+ this._state[key] = value;
503
+ const eventName = STATE_EVENTS[key];
504
+ if (eventName) this._emitter.emit(eventName, value);
505
+ }
506
+ }
507
+ }
508
+ emitEvent(event, data) {
509
+ this._emitter.emit(event, data);
510
+ }
511
+ destroy() {
512
+ this._surface = null;
513
+ this._emitter.removeAllListeners();
514
+ }
515
+ }
516
+ class PlayerPool {
517
+ // HLS.js config for preload/speculative players: low buffer, lowest quality start
518
+ static PRELOAD_HLS_CONFIG = { startLevel: 0, capLevelToPlayerSize: true, maxBufferLength: 2, maxMaxBufferLength: 4 };
519
+ // HLS.js config for the active (visible) player: ABR picks quality, generous buffer
520
+ static ACTIVE_HLS_CONFIG = { startLevel: -1, capLevelToPlayerSize: true, maxBufferLength: 8, maxMaxBufferLength: 15 };
521
+ /**
522
+ * @param {object} [options]
523
+ * @param {number} [options.poolSize=3] Number of video elements to pre-create.
524
+ * @param {Function} [options.Hls] HLS.js constructor (defaults to global Hls).
525
+ */
526
+ constructor({ poolSize = 3, Hls: HlsClass } = {}) {
527
+ this._Hls = HlsClass ?? (typeof Hls !== "undefined" ? Hls : null);
528
+ this.players = Array.from({ length: poolSize }, () => this._createPlayer());
529
+ this.assignments = /* @__PURE__ */ new Map();
530
+ this.hlsInstances = /* @__PURE__ */ new Map();
531
+ }
532
+ _createPlayer() {
533
+ const video = document.createElement("video");
534
+ video.playsInline = true;
535
+ video.preload = "auto";
536
+ video.loop = true;
537
+ video.muted = true;
538
+ video.setAttribute("webkit-playsinline", "");
539
+ return video;
540
+ }
541
+ /** Get or recycle a player for an item. */
542
+ acquire(itemId) {
543
+ if (this.assignments.has(itemId)) return this.assignments.get(itemId);
544
+ const assigned = new Set(this.assignments.values());
545
+ let player = this.players.find((p) => !assigned.has(p));
546
+ if (!player) {
547
+ const oldest = this.assignments.keys().next().value;
548
+ player = this.assignments.get(oldest);
549
+ player.pause();
550
+ this._destroyHls(oldest);
551
+ player.removeAttribute("src");
552
+ player.load();
553
+ if (player.parentNode) player.parentNode.removeChild(player);
554
+ this.assignments.delete(oldest);
555
+ }
556
+ this.assignments.set(itemId, player);
557
+ return player;
558
+ }
559
+ /**
560
+ * Attach an HLS stream (or plain URL) to the player for this item.
561
+ * Pass { isActive: true } for the currently-playing item to use generous
562
+ * buffer/ABR settings; preload players default to conservative config.
563
+ *
564
+ * @param {string} itemId
565
+ * @param {string} streamingUrl
566
+ * @param {{ isActive?: boolean }} [options]
567
+ */
568
+ attachStream(itemId, streamingUrl, { isActive = false } = {}) {
569
+ const player = this.assignments.get(itemId);
570
+ if (!player) return;
571
+ if (player._skCurrentUrl === streamingUrl) return;
572
+ this._destroyHls(itemId);
573
+ const HlsClass = this._Hls;
574
+ if (streamingUrl.includes(".m3u8") && HlsClass && HlsClass.isSupported()) {
575
+ const hlsConfig = isActive ? PlayerPool.ACTIVE_HLS_CONFIG : PlayerPool.PRELOAD_HLS_CONFIG;
576
+ const hls = new HlsClass(hlsConfig);
577
+ hls.loadSource(streamingUrl);
578
+ hls.attachMedia(player);
579
+ hls.on(HlsClass.Events.ERROR, (_event, data) => {
580
+ if (!data.fatal) return;
581
+ switch (data.type) {
582
+ case HlsClass.ErrorTypes.MEDIA_ERROR:
583
+ hls.recoverMediaError();
584
+ break;
585
+ case HlsClass.ErrorTypes.NETWORK_ERROR:
586
+ setTimeout(() => {
587
+ if (!hls.destroyed) hls.startLoad();
588
+ }, 2e3);
589
+ break;
590
+ default:
591
+ hls.destroy();
592
+ break;
593
+ }
594
+ });
595
+ this.hlsInstances.set(itemId, hls);
596
+ } else {
597
+ player.src = streamingUrl;
598
+ }
599
+ player._skCurrentUrl = streamingUrl;
600
+ }
601
+ /**
602
+ * Promote a preload player to active: raise buffer limits and uncap ABR.
603
+ * Safe to call even if the HLS instance was already created with active config.
604
+ */
605
+ promoteToActive(itemId) {
606
+ const hls = this.hlsInstances.get(itemId);
607
+ if (hls) {
608
+ hls.config.maxBufferLength = PlayerPool.ACTIVE_HLS_CONFIG.maxBufferLength;
609
+ hls.config.maxMaxBufferLength = PlayerPool.ACTIVE_HLS_CONFIG.maxMaxBufferLength;
610
+ hls.autoLevelCapping = -1;
611
+ hls.nextAutoLevel = -1;
612
+ }
613
+ }
614
+ /** Pause, destroy HLS, and remove player from DOM and pool tracking. */
615
+ release(itemId) {
616
+ const player = this.assignments.get(itemId);
617
+ if (player) {
618
+ player.pause();
619
+ this._destroyHls(itemId);
620
+ player._skCurrentUrl = null;
621
+ if (player.parentNode) player.parentNode.removeChild(player);
622
+ this.assignments.delete(itemId);
623
+ }
624
+ }
625
+ /** Look up the video element for an item, or null if not assigned. */
626
+ getPlayer(itemId) {
627
+ return this.assignments.get(itemId) || null;
628
+ }
629
+ /**
630
+ * Remove a video+HLS from pool tracking WITHOUT destroying or pausing.
631
+ * Use this when transferring a player to another surface (e.g. widget → feed).
632
+ * Returns { video, hls } or null if not found.
633
+ * A fresh replacement video element is inserted into the pool slot.
634
+ */
635
+ ejectPlayer(itemId) {
636
+ const video = this.assignments.get(itemId);
637
+ const hls = this.hlsInstances.get(itemId);
638
+ if (!video) return null;
639
+ if (video.parentNode) video.parentNode.removeChild(video);
640
+ this.assignments.delete(itemId);
641
+ this.hlsInstances.delete(itemId);
642
+ const idx = this.players.indexOf(video);
643
+ if (idx >= 0) this.players[idx] = this._createPlayer();
644
+ return { video, hls };
645
+ }
646
+ /**
647
+ * Re-inject a previously ejected (or externally created) video+HLS into
648
+ * this pool. If itemId is already assigned, it is released first.
649
+ *
650
+ * @param {string} itemId
651
+ * @param {HTMLVideoElement} video
652
+ * @param {object|null} hls HLS.js instance, or null for native playback.
653
+ * @param {string|null} url The streaming URL currently loaded into the player.
654
+ */
655
+ injectPlayer(itemId, video, hls, url) {
656
+ if (this.assignments.has(itemId)) this.release(itemId);
657
+ this.assignments.set(itemId, video);
658
+ if (hls) this.hlsInstances.set(itemId, hls);
659
+ video._skCurrentUrl = url || null;
660
+ if (!this.players.includes(video)) this.players.push(video);
661
+ }
662
+ /** Destroy all HLS instances and release all players. Call from sk.destroy(). */
663
+ destroyAll() {
664
+ for (const itemId of [...this.assignments.keys()]) {
665
+ this.release(itemId);
666
+ }
667
+ }
668
+ _destroyHls(itemId) {
669
+ const hls = this.hlsInstances.get(itemId);
670
+ if (hls) {
671
+ hls.destroy();
672
+ this.hlsInstances.delete(itemId);
673
+ }
674
+ }
675
+ }
676
+ class StoryboardCache {
677
+ constructor() {
678
+ this._cache = /* @__PURE__ */ new Map();
679
+ this._fetching = /* @__PURE__ */ new Map();
680
+ }
681
+ static extractPlaybackId(thumbnailUrl) {
682
+ try {
683
+ const url = new URL(thumbnailUrl);
684
+ if (url.host !== "image.media.shortkit.dev" && url.host !== "image.mux.com") return null;
685
+ return url.pathname.split("/")[1] || null;
686
+ } catch {
687
+ return null;
688
+ }
689
+ }
690
+ async get(playbackId) {
691
+ if (this._cache.has(playbackId)) return this._cache.get(playbackId);
692
+ if (this._fetching.has(playbackId)) return this._fetching.get(playbackId);
693
+ const promise = (async () => {
694
+ try {
695
+ const res = await fetch(`https://image.media.shortkit.dev/${playbackId}/storyboard.json`);
696
+ if (!res.ok) return null;
697
+ const meta = await res.json();
698
+ const img = new Image();
699
+ img.src = meta.url;
700
+ let sheetWidth = meta.tile_width;
701
+ let sheetHeight = meta.tile_height;
702
+ for (const t of meta.tiles) {
703
+ sheetWidth = Math.max(sheetWidth, t.x + meta.tile_width);
704
+ sheetHeight = Math.max(sheetHeight, t.y + meta.tile_height);
705
+ }
706
+ const entry = {
707
+ spriteSheetUrl: meta.url,
708
+ tileWidth: meta.tile_width,
709
+ tileHeight: meta.tile_height,
710
+ sheetWidth,
711
+ sheetHeight,
712
+ duration: meta.duration,
713
+ tiles: meta.tiles
714
+ };
715
+ this._cache.set(playbackId, entry);
716
+ return entry;
717
+ } catch {
718
+ return null;
719
+ } finally {
720
+ this._fetching.delete(playbackId);
721
+ }
722
+ })();
723
+ this._fetching.set(playbackId, promise);
724
+ return promise;
725
+ }
726
+ /** Find the tile for a given seek time. Returns CSS background props or null. */
727
+ tileAt(playbackId, seekTime) {
728
+ const entry = this._cache.get(playbackId);
729
+ if (!entry || !entry.tiles.length) return null;
730
+ let best = entry.tiles[0];
731
+ for (const tile of entry.tiles) {
732
+ if (tile.start <= seekTime) best = tile;
733
+ else break;
734
+ }
735
+ const scale = 80 / entry.tileWidth;
736
+ return {
737
+ backgroundImage: `url(${entry.spriteSheetUrl})`,
738
+ backgroundPosition: `-${best.x * scale}px -${best.y * scale}px`,
739
+ backgroundSize: `${entry.sheetWidth * scale}px ${entry.sheetHeight * scale}px`
740
+ };
741
+ }
742
+ }
743
+ function parseVTT(text) {
744
+ const cues = [];
745
+ const blocks = text.split(/\n\n+/);
746
+ for (const block of blocks) {
747
+ const lines = block.split("\n").filter((l) => l.trim());
748
+ const timingIdx = lines.findIndex((l) => l.includes(" --> "));
749
+ if (timingIdx < 0) continue;
750
+ const [startStr, endStr] = lines[timingIdx].split(" --> ");
751
+ const start = parseVTTTime(startStr.trim());
752
+ const end = parseVTTTime(endStr.trim());
753
+ if (start == null || end == null) continue;
754
+ const cueText = lines.slice(timingIdx + 1).join("\n").trim();
755
+ if (!cueText) continue;
756
+ cues.push({ startTime: start, endTime: end, text: cueText });
757
+ }
758
+ return cues;
759
+ }
760
+ function parseVTTTime(str) {
761
+ const parts = str.split(":");
762
+ if (parts.length === 3) {
763
+ return +parts[0] * 3600 + +parts[1] * 60 + parseFloat(parts[2]);
764
+ } else if (parts.length === 2) {
765
+ return +parts[0] * 60 + parseFloat(parts[1]);
766
+ }
767
+ return null;
768
+ }
769
+ function createFeedItem(item) {
770
+ const el = document.createElement("div");
771
+ el.className = "sk-feed-item";
772
+ el.dataset.itemId = item.id;
773
+ const thumbStyle = item.thumbnailUrl ? `background-image: url('${item.thumbnailUrl}'); background-size: cover; background-position: center;` : "";
774
+ el.innerHTML = `
775
+ <div class="sk-video-container" data-ref="videoContainer" style="${thumbStyle}">
776
+ <div class="sk-spinner" data-ref="spinner"></div>
777
+ </div>
778
+ <div class="sk-tap-zone" data-ref="tapZone"></div>
779
+ <div class="sk-overlay" data-ref="overlay"></div>
780
+ `;
781
+ return el;
782
+ }
783
+ class FeedManager {
784
+ /**
785
+ * @param {HTMLElement} containerEl The .sk-feed element
786
+ * @param {import('../core/shortkit.js').ShortKit} shortKit
787
+ * @param {object} [options]
788
+ * @param {object} [options.config] FeedConfig from createFeedConfig()
789
+ * @param {string} [options.startAtItemId]
790
+ * @param {Function} [options.onFeedReady]
791
+ * @param {Function} [options.onDismiss]
792
+ * @param {Function} [options.onContentTapped]
793
+ * @param {Function} [options.onRemainingContentCountChange]
794
+ * @param {Function} [options.onDidFetchContentItems]
795
+ * @param {Function} [options.onLoop]
796
+ * @param {Function} [options.onFeedTransition]
797
+ * @param {Function} [options.onFormatChange]
798
+ */
799
+ constructor(containerEl, shortKit, options = {}) {
800
+ this.container = containerEl;
801
+ this._sk = shortKit;
802
+ this._options = options;
803
+ this._config = options.config || {};
804
+ this.pool = shortKit._playerPool;
805
+ this.storyboardCache = shortKit._storyboardCache;
806
+ this.items = [];
807
+ this._cursor = null;
808
+ this._hasMore = true;
809
+ this.activeItemId = null;
810
+ this.activeIndex = 0;
811
+ this.isMuted = this._config.muteOnStart !== false;
812
+ this.playbackRate = 1;
813
+ this.itemEls = /* @__PURE__ */ new Map();
814
+ this.rafIds = /* @__PURE__ */ new Map();
815
+ this._centerTimeout = null;
816
+ this._loadingMore = false;
817
+ this._wasPlayingBeforeHidden = false;
818
+ this._speculativeItemId = null;
819
+ this._speculativePlayer = null;
820
+ this._lastPredictedIndex = -1;
821
+ this._captionsOn = false;
822
+ this._captionCues = /* @__PURE__ */ new Map();
823
+ this._captionFetches = /* @__PURE__ */ new Map();
824
+ this._prefetchedThumbs = /* @__PURE__ */ new Set();
825
+ this._destroyed = false;
826
+ this._overlayContainers = /* @__PURE__ */ new Map();
827
+ this.feedWrapper = containerEl.parentElement;
828
+ }
829
+ async init() {
830
+ const isCustom = this._config.feedSource === "custom";
831
+ if (!isCustom) {
832
+ const result = await this._fetchFeed(10);
833
+ this._appendItems(result.items);
834
+ this._prefetchThumbnails(0);
835
+ if (this._options.onDidFetchContentItems) this._options.onDidFetchContentItems(result.items);
836
+ if (this._sk._onDidFetchContentItems) this._sk._onDidFetchContentItems(result.items);
837
+ }
838
+ this._setupObserver();
839
+ this._setupScrollEnd();
840
+ this._setupKeyboard();
841
+ this._setupVisibilityHandler();
842
+ this._setupCellHeightObserver();
843
+ this._sk.player.registerSurface(this);
844
+ this._sk._tracker.trackFeedEntry();
845
+ this._pushPlayerState();
846
+ if (this._options.onFeedReady) this._options.onFeedReady();
847
+ }
848
+ destroy() {
849
+ if (this._destroyed) return;
850
+ this._destroyed = true;
851
+ this._sk._tracker.trackFeedExit();
852
+ this._sk._tracker.deactivateContent();
853
+ this._sk.player.unregisterSurface(this);
854
+ this._sk._unregisterSurface(this);
855
+ for (const [id] of this.rafIds) this._stopTimeLoop(id);
856
+ if (this.activeItemId) this._deactivateItem(this.activeItemId);
857
+ for (const [id] of [...this.pool.assignments]) this.pool.release(id);
858
+ if (this.observer) {
859
+ this.observer.disconnect();
860
+ this.observer = null;
861
+ }
862
+ if (this._resizeObserver) {
863
+ this._resizeObserver.disconnect();
864
+ this._resizeObserver = null;
865
+ }
866
+ this.container.innerHTML = "";
867
+ this.itemEls.clear();
868
+ this.items = [];
869
+ this.activeItemId = null;
870
+ this.activeIndex = 0;
871
+ this._speculativeItemId = null;
872
+ this._speculativePlayer = null;
873
+ }
874
+ // ── Surface command interface (forwarded from ShortKitPlayer) ──
875
+ play() {
876
+ const player = this.pool.getPlayer(this.activeItemId);
877
+ if (player) {
878
+ player.play().catch(() => {
879
+ });
880
+ this._pushPlayerState({ playerState: "playing" });
881
+ }
882
+ }
883
+ pause() {
884
+ const player = this.pool.getPlayer(this.activeItemId);
885
+ if (player) {
886
+ player.pause();
887
+ this._pushPlayerState({ playerState: "paused" });
888
+ }
889
+ }
890
+ seek(time) {
891
+ const player = this.pool.getPlayer(this.activeItemId);
892
+ if (player && player.duration) {
893
+ player.currentTime = time;
894
+ }
895
+ }
896
+ seekAndPlay(time) {
897
+ const player = this.pool.getPlayer(this.activeItemId);
898
+ if (player && player.duration) {
899
+ player.currentTime = time;
900
+ player.play().catch(() => {
901
+ });
902
+ this._pushPlayerState({ playerState: "playing" });
903
+ }
904
+ }
905
+ setMuted(muted) {
906
+ this.isMuted = muted;
907
+ for (const [, player] of this.pool._players) {
908
+ player.muted = muted;
909
+ }
910
+ this._pushPlayerState({ isMuted: muted });
911
+ }
912
+ skipToNext() {
913
+ this.navigateDown();
914
+ }
915
+ skipToPrevious() {
916
+ this.navigateUp();
917
+ }
918
+ setPlaybackRate(rate) {
919
+ this.playbackRate = rate;
920
+ for (const [, player] of this.pool._players) {
921
+ player.playbackRate = rate;
922
+ }
923
+ this._pushPlayerState({ playbackRate: rate });
924
+ }
925
+ setCaptionsEnabled(enabled) {
926
+ this._captionsOn = enabled;
927
+ this._pushPlayerState({ captionsEnabled: enabled });
928
+ if (enabled) {
929
+ const item = this.items[this.activeIndex];
930
+ if (item?.captionTracks?.length) this._fetchCaptionVTT(item);
931
+ }
932
+ if (!enabled) {
933
+ this._sk.player._pushState({ activeCue: null });
934
+ }
935
+ }
936
+ selectCaptionTrack(track) {
937
+ this._pushPlayerState({ activeCaptionTrack: track });
938
+ }
939
+ sendContentSignal(signal) {
940
+ if (this.activeItemId) {
941
+ this._sk._tracker.trackContentSignal(this.activeItemId, signal);
942
+ }
943
+ }
944
+ setMaxBitrate(_bitrate) {
945
+ }
946
+ seekThumbnail(time) {
947
+ if (!this.activeItemId) return null;
948
+ const item = this.items[this.activeIndex];
949
+ if (!item) return null;
950
+ const playbackId = StoryboardCache.extractPlaybackId(item.thumbnailUrl);
951
+ if (!playbackId) return null;
952
+ return this.storyboardCache.tileAt(playbackId, time);
953
+ }
954
+ // ── Custom feed mode methods ──
955
+ /**
956
+ * Replace all feed items with the provided list.
957
+ * @param {Array<{type: string, playbackId?: string, fallbackUrl?: string}|object>} items
958
+ * FeedInput objects or pre-resolved ContentItem objects.
959
+ */
960
+ setFeedItems(items) {
961
+ if (this._config.feedSource !== "custom") {
962
+ console.warn("[ShortKit] setFeedItems() called on a non-custom feed — ignored");
963
+ return;
964
+ }
965
+ if (this.activeItemId) {
966
+ this._deactivateItem(this.activeItemId);
967
+ this.activeItemId = null;
968
+ this.activeIndex = 0;
969
+ }
970
+ if (this.observer) this.observer.disconnect();
971
+ this.container.innerHTML = "";
972
+ this.itemEls.clear();
973
+ this.items = [];
974
+ const resolved = this._resolveFeedInputs(items);
975
+ this._appendItems(resolved);
976
+ this._setupObserver();
977
+ }
978
+ /**
979
+ * Append items to the existing feed without disrupting current playback.
980
+ * @param {Array<{type: string, playbackId?: string, fallbackUrl?: string}|object>} items
981
+ */
982
+ appendFeedItems(items) {
983
+ if (this._config.feedSource !== "custom") {
984
+ console.warn("[ShortKit] appendFeedItems() called on a non-custom feed — ignored");
985
+ return;
986
+ }
987
+ const resolved = this._resolveFeedInputs(items);
988
+ this._appendItems(resolved);
989
+ }
990
+ /**
991
+ * Apply a filter to the feed.
992
+ * For algorithmic feeds: clears and re-fetches with the new filter.
993
+ * For custom feeds: no-op (log a warning).
994
+ * @param {object} filter
995
+ */
996
+ applyFilter(filter) {
997
+ if (this._config.feedSource === "custom") {
998
+ console.warn("[ShortKit] applyFilter() is a no-op for custom feeds — use setFeedItems() to update content");
999
+ return;
1000
+ }
1001
+ this._config.filter = filter;
1002
+ this._cursor = null;
1003
+ this._hasMore = true;
1004
+ if (this.activeItemId) {
1005
+ this._deactivateItem(this.activeItemId);
1006
+ this.activeItemId = null;
1007
+ this.activeIndex = 0;
1008
+ }
1009
+ if (this.observer) this.observer.disconnect();
1010
+ this.container.innerHTML = "";
1011
+ this.itemEls.clear();
1012
+ this.items = [];
1013
+ this._fetchFeed(10).then((result) => {
1014
+ this._appendItems(result.items);
1015
+ this._setupObserver();
1016
+ this._prefetchThumbnails(0);
1017
+ if (this._options.onDidFetchContentItems) this._options.onDidFetchContentItems(result.items);
1018
+ if (this._sk._onDidFetchContentItems) this._sk._onDidFetchContentItems(result.items);
1019
+ }).catch(() => {
1020
+ });
1021
+ }
1022
+ /**
1023
+ * Convert FeedInput objects to ContentItem objects suitable for _appendItems().
1024
+ * If the input already looks like a ContentItem (has streamingUrl), it's passed through.
1025
+ * For { type: 'video', playbackId } inputs, URLs are derived from Mux URL conventions.
1026
+ * @param {Array} inputs
1027
+ * @returns {Array} ContentItems
1028
+ */
1029
+ _resolveFeedInputs(inputs) {
1030
+ return inputs.map((input) => {
1031
+ if (input.streamingUrl) return input;
1032
+ if (input.type === "video" && input.playbackId) {
1033
+ return {
1034
+ id: input.playbackId,
1035
+ streamingUrl: `https://stream.mux.com/${input.playbackId}.m3u8`,
1036
+ thumbnailUrl: `https://image.mux.com/${input.playbackId}/thumbnail.jpg`,
1037
+ title: input.title || "",
1038
+ description: input.description || "",
1039
+ author: input.author || "",
1040
+ section: input.section || "",
1041
+ articleUrl: input.articleUrl || null,
1042
+ duration: input.duration || 0,
1043
+ captionTracks: input.captionTracks || []
1044
+ };
1045
+ }
1046
+ if (input.fallbackUrl) {
1047
+ return {
1048
+ id: input.id || input.fallbackUrl,
1049
+ streamingUrl: input.fallbackUrl,
1050
+ thumbnailUrl: input.thumbnailUrl || "",
1051
+ title: input.title || "",
1052
+ description: input.description || "",
1053
+ author: input.author || "",
1054
+ section: input.section || "",
1055
+ articleUrl: input.articleUrl || null,
1056
+ duration: input.duration || 0,
1057
+ captionTracks: input.captionTracks || []
1058
+ };
1059
+ }
1060
+ console.warn("[ShortKit] Unresolvable FeedInput — skipping:", input);
1061
+ return null;
1062
+ }).filter(Boolean);
1063
+ }
1064
+ // ── Internal: API wrapper ──
1065
+ async _fetchFeed(limit = 10) {
1066
+ const result = await this._sk._apiClient.fetchFeed({
1067
+ limit,
1068
+ cursor: this._cursor,
1069
+ filter: this._config.filter || void 0
1070
+ });
1071
+ this._cursor = result.nextCursor;
1072
+ this._hasMore = result.hasMore;
1073
+ const items = result.items.map((item) => ({
1074
+ id: item.id,
1075
+ streamingUrl: item.streamingUrl,
1076
+ thumbnailUrl: item.thumbnailUrl,
1077
+ title: item.title || "",
1078
+ description: item.description || "",
1079
+ author: item.author || "",
1080
+ section: item.customMetadata?.category?.toUpperCase() || item.customMetadata?.section?.toUpperCase() || "",
1081
+ articleUrl: item.articleUrl || null,
1082
+ duration: item.duration || 0,
1083
+ captionTracks: item.captionTracks || []
1084
+ }));
1085
+ return { items, nextCursor: result.nextCursor, hasMore: result.hasMore };
1086
+ }
1087
+ // ── Internal: Push state to ShortKitPlayer ──
1088
+ _pushPlayerState(updates = {}) {
1089
+ const player = this.pool.getPlayer(this.activeItemId);
1090
+ const item = this.items[this.activeIndex] || null;
1091
+ const state = {
1092
+ currentItem: item,
1093
+ isMuted: this.isMuted,
1094
+ playbackRate: this.playbackRate,
1095
+ captionsEnabled: this._captionsOn,
1096
+ activeCaptionTrack: null,
1097
+ activeCue: null,
1098
+ prefetchedAheadCount: Math.max(0, this.items.length - this.activeIndex - 1),
1099
+ remainingContentCount: this.items.length - this.activeIndex - 1,
1100
+ ...updates
1101
+ };
1102
+ if (player) {
1103
+ state.time = {
1104
+ current: player.currentTime || 0,
1105
+ duration: player.duration || 0,
1106
+ buffered: player.buffered?.length > 0 ? player.buffered.end(player.buffered.length - 1) : 0
1107
+ };
1108
+ if (!updates.playerState) {
1109
+ state.playerState = player.paused ? "paused" : "playing";
1110
+ }
1111
+ }
1112
+ this._sk.player._pushState(state);
1113
+ }
1114
+ // --- Build ---
1115
+ _appendItems(newItems) {
1116
+ for (const item of newItems) {
1117
+ if (this.itemEls.has(item.id)) continue;
1118
+ this.items.push(item);
1119
+ const el = createFeedItem(item);
1120
+ this.container.appendChild(el);
1121
+ this.itemEls.set(item.id, el);
1122
+ this._bindControls(item, el);
1123
+ this._createOverlayContainer(item.id, el);
1124
+ this.observer?.observe(el);
1125
+ }
1126
+ this._pushPlayerState({
1127
+ remainingContentCount: this.items.length - this.activeIndex - 1,
1128
+ prefetchedAheadCount: Math.max(0, this.items.length - this.activeIndex - 1)
1129
+ });
1130
+ if (this._options.onRemainingContentCountChange) {
1131
+ this._options.onRemainingContentCountChange(this.items.length - this.activeIndex - 1);
1132
+ }
1133
+ }
1134
+ async _loadMore() {
1135
+ if (this._loadingMore || !this._hasMore) return;
1136
+ this._loadingMore = true;
1137
+ try {
1138
+ const result = await this._fetchFeed(10);
1139
+ this._appendItems(result.items);
1140
+ if (this._options.onDidFetchContentItems) this._options.onDidFetchContentItems(result.items);
1141
+ if (this._sk._onDidFetchContentItems) this._sk._onDidFetchContentItems(result.items);
1142
+ } finally {
1143
+ this._loadingMore = false;
1144
+ }
1145
+ }
1146
+ // --- Navigation ---
1147
+ navigateTo(index) {
1148
+ const clamped = Math.max(0, Math.min(this.items.length - 1, index));
1149
+ const el = this.itemEls.get(this.items[clamped].id);
1150
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
1151
+ }
1152
+ navigateUp() {
1153
+ this.navigateTo(this.activeIndex - 1);
1154
+ }
1155
+ navigateDown() {
1156
+ this.navigateTo(this.activeIndex + 1);
1157
+ }
1158
+ // --- Keyboard (arrow keys) ---
1159
+ _setupKeyboard() {
1160
+ document.addEventListener("keydown", (e) => {
1161
+ if (e.key === "ArrowUp") {
1162
+ e.preventDefault();
1163
+ this.navigateUp();
1164
+ }
1165
+ if (e.key === "ArrowDown") {
1166
+ e.preventDefault();
1167
+ this.navigateDown();
1168
+ }
1169
+ });
1170
+ }
1171
+ // --- Tab Visibility ---
1172
+ _setupVisibilityHandler() {
1173
+ document.addEventListener("visibilitychange", () => {
1174
+ if (document.hidden) {
1175
+ this._handleTabHidden();
1176
+ } else {
1177
+ this._handleTabVisible();
1178
+ }
1179
+ });
1180
+ }
1181
+ _handleTabHidden() {
1182
+ this._cancelSpeculation();
1183
+ if (!this.activeItemId) return;
1184
+ const player = this.pool.getPlayer(this.activeItemId);
1185
+ if (player && !player.paused) {
1186
+ this._wasPlayingBeforeHidden = true;
1187
+ player.pause();
1188
+ } else {
1189
+ this._wasPlayingBeforeHidden = false;
1190
+ }
1191
+ this._stopTimeLoop(this.activeItemId);
1192
+ }
1193
+ _handleTabVisible() {
1194
+ if (!this.activeItemId) return;
1195
+ if (this._wasPlayingBeforeHidden) {
1196
+ const player2 = this.pool.getPlayer(this.activeItemId);
1197
+ if (player2) player2.play().catch(() => {
1198
+ });
1199
+ this._wasPlayingBeforeHidden = false;
1200
+ }
1201
+ const el = this.itemEls.get(this.activeItemId);
1202
+ const player = this.pool.getPlayer(this.activeItemId);
1203
+ if (el && player) this._startTimeLoop(this.activeItemId, el, player);
1204
+ }
1205
+ // --- Intersection Observer ---
1206
+ _setupObserver() {
1207
+ this.observer = new IntersectionObserver((entries) => {
1208
+ for (const entry of entries) {
1209
+ const id = entry.target.dataset.itemId;
1210
+ if (entry.isIntersecting && entry.intersectionRatio > 0.6) {
1211
+ this._activateItem(id);
1212
+ }
1213
+ }
1214
+ }, { root: this.container, threshold: [0, 0.6, 1] });
1215
+ for (const el of this.itemEls.values()) {
1216
+ this.observer.observe(el);
1217
+ }
1218
+ }
1219
+ // --- Stable cell height for scroll prediction ---
1220
+ _setupCellHeightObserver() {
1221
+ this._cellHeight = 0;
1222
+ const measure = () => {
1223
+ const first = this.container.firstElementChild;
1224
+ if (first) {
1225
+ this._cellHeight = first.offsetHeight + parseFloat(getComputedStyle(first).marginBottom || "0");
1226
+ }
1227
+ };
1228
+ measure();
1229
+ this._resizeObserver = new ResizeObserver(measure);
1230
+ this._resizeObserver.observe(this.container);
1231
+ }
1232
+ // --- Pre-warm adjacent on scroll end + scroll prediction ---
1233
+ _setupScrollEnd() {
1234
+ let scrollTimer;
1235
+ this.container.addEventListener("scroll", () => {
1236
+ this._predictAndSpeculate();
1237
+ clearTimeout(scrollTimer);
1238
+ scrollTimer = setTimeout(() => this._onScrollEnd(), 100);
1239
+ }, { passive: true });
1240
+ this.container.addEventListener("pointerdown", () => {
1241
+ this._cancelSpeculation();
1242
+ }, { passive: true });
1243
+ this.container.addEventListener("touchstart", () => {
1244
+ this._cancelSpeculation();
1245
+ }, { passive: true });
1246
+ }
1247
+ _onScrollEnd() {
1248
+ const idx = this.items.findIndex((i) => i.id === this.activeItemId);
1249
+ if (idx < 0) return;
1250
+ const adjacent = [this.items[idx - 1], this.items[idx + 1]].filter(Boolean);
1251
+ for (const item of adjacent) {
1252
+ const player = this.pool.acquire(item.id);
1253
+ this.pool.attachStream(item.id, item.streamingUrl);
1254
+ player.currentTime = 0;
1255
+ if (this._speculativeItemId !== item.id) {
1256
+ player.muted = true;
1257
+ const primingItemId = item.id;
1258
+ const p = player.play();
1259
+ if (p) p.then(() => {
1260
+ if (this.activeItemId !== primingItemId) player.pause();
1261
+ }).catch(() => {
1262
+ });
1263
+ }
1264
+ }
1265
+ this._prefetchThumbnails(idx);
1266
+ for (const adj of adjacent) {
1267
+ if (adj.captionTracks?.length) this._fetchCaptionVTT(adj);
1268
+ }
1269
+ if (idx >= this.items.length - 3) {
1270
+ this._loadMore();
1271
+ }
1272
+ }
1273
+ // --- Scroll Prediction + Speculative Playback ---
1274
+ _predictAndSpeculate() {
1275
+ const containerHeight = this._cellHeight || this.container.clientHeight;
1276
+ if (containerHeight <= 0 || this.items.length === 0) return;
1277
+ const scrollTop = this.container.scrollTop;
1278
+ const predictedIndex = Math.round(scrollTop / containerHeight);
1279
+ const clamped = Math.max(0, Math.min(this.items.length - 1, predictedIndex));
1280
+ if (clamped === this._lastPredictedIndex) return;
1281
+ this._lastPredictedIndex = clamped;
1282
+ if (clamped === this.activeIndex) {
1283
+ this._cancelSpeculation();
1284
+ return;
1285
+ }
1286
+ if (Math.abs(clamped - this.activeIndex) > 1) return;
1287
+ const item = this.items[clamped];
1288
+ if (!item) return;
1289
+ if (this._speculativeItemId === item.id) return;
1290
+ this._cancelSpeculation();
1291
+ const player = this.pool.acquire(item.id);
1292
+ this.pool.attachStream(item.id, item.streamingUrl);
1293
+ player.currentTime = 0;
1294
+ player.muted = true;
1295
+ const el = this.itemEls.get(item.id);
1296
+ if (el) {
1297
+ const container = el.querySelector('[data-ref="videoContainer"]');
1298
+ player.style.opacity = "0";
1299
+ if (!container.contains(player)) container.insertBefore(player, container.firstChild);
1300
+ }
1301
+ const p = player.play();
1302
+ if (p) p.catch(() => {
1303
+ });
1304
+ this._speculativeItemId = item.id;
1305
+ this._speculativePlayer = player;
1306
+ this._pushPlayerState({ feedScrollPhase: "decelerating" });
1307
+ }
1308
+ /** Cancel any in-flight speculative playback. */
1309
+ _cancelSpeculation() {
1310
+ if (!this._speculativeItemId) return;
1311
+ const player = this.pool.getPlayer(this._speculativeItemId);
1312
+ if (player) {
1313
+ player.pause();
1314
+ player.style.opacity = "0";
1315
+ }
1316
+ this._speculativeItemId = null;
1317
+ this._speculativePlayer = null;
1318
+ this._lastPredictedIndex = -1;
1319
+ }
1320
+ // --- Activation ---
1321
+ _activateItem(id) {
1322
+ if (this.activeItemId === id) return;
1323
+ if (this._speculativeItemId && this._speculativeItemId !== id) {
1324
+ this._cancelSpeculation();
1325
+ }
1326
+ this._speculativeItemId = null;
1327
+ this._speculativePlayer = null;
1328
+ this._lastPredictedIndex = -1;
1329
+ const previousId = this.activeItemId;
1330
+ const previousIndex = this.activeIndex;
1331
+ if (this.activeItemId) this._deactivateItem(this.activeItemId);
1332
+ this.activeItemId = id;
1333
+ this.activeIndex = this.items.findIndex((i) => i.id === id);
1334
+ const item = this.items[this.activeIndex];
1335
+ if (previousId) {
1336
+ const direction = this.activeIndex > previousIndex ? "down" : "up";
1337
+ this._sk._tracker.trackSwipe(previousId, id, direction);
1338
+ }
1339
+ this._sk._tracker.activateContent(id);
1340
+ if (this._options.onFeedTransition) {
1341
+ this._options.onFeedTransition({ fromIndex: previousIndex, toIndex: this.activeIndex, item });
1342
+ }
1343
+ const el = this.itemEls.get(id);
1344
+ const player = this.pool.acquire(id);
1345
+ player.style.opacity = "0";
1346
+ const container = el.querySelector('[data-ref="videoContainer"]');
1347
+ if (!container.contains(player)) container.insertBefore(player, container.firstChild);
1348
+ this.pool.attachStream(id, item.streamingUrl, { isActive: true });
1349
+ this.pool.promoteToActive(id);
1350
+ player.muted = this.isMuted;
1351
+ player.playbackRate = this.playbackRate;
1352
+ player.currentTime = 0;
1353
+ const spinner = el.querySelector('[data-ref="spinner"]');
1354
+ const REVEAL_EVENTS = ["loadeddata", "seeked", "canplay", "timeupdate"];
1355
+ let spinnerTimeout = null;
1356
+ const revealOnFirstFrame = () => {
1357
+ if (player._skRevealedFor === id) return;
1358
+ if (player.readyState < 2) return;
1359
+ player._skRevealedFor = id;
1360
+ player.style.opacity = "1";
1361
+ clearTimeout(spinnerTimeout);
1362
+ spinner.classList.remove("visible");
1363
+ REVEAL_EVENTS.forEach((e) => player.removeEventListener(e, revealOnFirstFrame));
1364
+ this._sk._tracker.trackFirstFrame(id);
1365
+ };
1366
+ if (player.readyState >= 2 && !player.seeking) {
1367
+ player._skRevealedFor = id;
1368
+ player.style.opacity = "1";
1369
+ this._sk._tracker.trackFirstFrame(id);
1370
+ } else {
1371
+ spinnerTimeout = setTimeout(() => spinner.classList.add("visible"), 1e3);
1372
+ REVEAL_EVENTS.forEach((e) => player.addEventListener(e, revealOnFirstFrame));
1373
+ }
1374
+ const playPromise = player.play();
1375
+ if (playPromise) {
1376
+ playPromise.catch(() => {
1377
+ if (player.readyState >= 2) revealOnFirstFrame();
1378
+ });
1379
+ }
1380
+ this._sk._tracker.trackPlayStart(id);
1381
+ this._startTimeLoop(id, el, player);
1382
+ this._invokeOverlay(id, item);
1383
+ this._prefetchThumbnails(this.activeIndex);
1384
+ if (item.captionTracks?.length) this._fetchCaptionVTT(item);
1385
+ const playbackId = StoryboardCache.extractPlaybackId(item.thumbnailUrl);
1386
+ if (playbackId) this.storyboardCache.get(playbackId);
1387
+ this._pushPlayerState({
1388
+ playerState: "playing",
1389
+ currentItem: item,
1390
+ feedScrollPhase: "idle"
1391
+ });
1392
+ }
1393
+ _deactivateItem(id) {
1394
+ const player = this.pool.getPlayer(id);
1395
+ if (player) {
1396
+ player.pause();
1397
+ player.style.opacity = "0";
1398
+ player._skRevealedFor = null;
1399
+ }
1400
+ this._stopTimeLoop(id);
1401
+ this._clearOverlay(id);
1402
+ this._sk._tracker.deactivateContent();
1403
+ }
1404
+ // --- Time loop ---
1405
+ _startTimeLoop(id, el, player) {
1406
+ this._stopTimeLoop(id);
1407
+ const tick = () => {
1408
+ if (this._destroyed) return;
1409
+ if (!player || player.paused) {
1410
+ this.rafIds.set(id, requestAnimationFrame(tick));
1411
+ return;
1412
+ }
1413
+ const current = player.currentTime || 0;
1414
+ const duration = player.duration || 0;
1415
+ const buffered = player.buffered?.length > 0 ? player.buffered.end(player.buffered.length - 1) : 0;
1416
+ this._sk.player._pushState({
1417
+ time: { current, duration, buffered },
1418
+ playerState: "playing"
1419
+ });
1420
+ if (this._captionsOn) {
1421
+ const cues = this._captionCues.get(id);
1422
+ if (cues) {
1423
+ const cue = cues.find((c) => current >= c.startTime && current <= c.endTime) || null;
1424
+ const prev = this._sk.player.activeCue;
1425
+ if (cue !== prev) {
1426
+ this._sk.player._pushState({ activeCue: cue });
1427
+ }
1428
+ }
1429
+ }
1430
+ if (player.ended) {
1431
+ player.currentTime = 0;
1432
+ player.play().catch(() => {
1433
+ });
1434
+ }
1435
+ this.rafIds.set(id, requestAnimationFrame(tick));
1436
+ };
1437
+ this.rafIds.set(id, requestAnimationFrame(tick));
1438
+ }
1439
+ _stopTimeLoop(id) {
1440
+ const rafId = this.rafIds.get(id);
1441
+ if (rafId) {
1442
+ cancelAnimationFrame(rafId);
1443
+ this.rafIds.delete(id);
1444
+ }
1445
+ }
1446
+ // --- Controls ---
1447
+ _bindControls(item, el) {
1448
+ const tapZone = el.querySelector('[data-ref="tapZone"]');
1449
+ if (tapZone) {
1450
+ tapZone.addEventListener("click", () => {
1451
+ const player = this.pool.getPlayer(item.id);
1452
+ if (!player) return;
1453
+ if (player.paused) {
1454
+ player.play().catch(() => {
1455
+ });
1456
+ this._pushPlayerState({ playerState: "playing" });
1457
+ } else {
1458
+ player.pause();
1459
+ this._pushPlayerState({ playerState: "paused" });
1460
+ }
1461
+ if (this._sk._onContentTapped) {
1462
+ this._sk._onContentTapped(item);
1463
+ }
1464
+ });
1465
+ }
1466
+ }
1467
+ // --- Caption VTT fetch (deduped + cached) ---
1468
+ _fetchCaptionVTT(item) {
1469
+ if (this._captionCues.has(item.id)) return;
1470
+ if (this._captionFetches.has(item.id)) return;
1471
+ const track = item.captionTracks[0];
1472
+ if (!track?.url) return;
1473
+ const promise = fetch(track.url).then((res) => res.ok ? res.text() : null).then((text) => {
1474
+ if (text) this._captionCues.set(item.id, parseVTT(text));
1475
+ }).catch(() => {
1476
+ }).finally(() => this._captionFetches.delete(item.id));
1477
+ this._captionFetches.set(item.id, promise);
1478
+ }
1479
+ // --- Thumbnail prefetch for +/-3 items ---
1480
+ _prefetchThumbnails(centerIndex) {
1481
+ const radius = 3;
1482
+ const start = Math.max(0, centerIndex - radius);
1483
+ const end = Math.min(this.items.length, centerIndex + radius + 1);
1484
+ for (let i = start; i < end; i++) {
1485
+ const url = this.items[i].thumbnailUrl;
1486
+ if (!url || this._prefetchedThumbs.has(url)) continue;
1487
+ this._prefetchedThumbs.add(url);
1488
+ const img = new Image();
1489
+ img.src = url;
1490
+ }
1491
+ }
1492
+ // --- Custom overlay helpers ---
1493
+ /** Create and register the overlay container element for a feed cell. */
1494
+ _createOverlayContainer(itemId, cellEl) {
1495
+ const overlayEl = cellEl.querySelector('[data-ref="overlay"]');
1496
+ if (!overlayEl) return;
1497
+ this._overlayContainers.set(itemId, { el: overlayEl, unsub: null });
1498
+ }
1499
+ /** Invoke the developer overlay callback for the given item. */
1500
+ _invokeOverlay(itemId, item) {
1501
+ if (!this._config.overlay) return;
1502
+ const entry = this._overlayContainers.get(itemId);
1503
+ if (!entry) return;
1504
+ const player = this._sk.player;
1505
+ const surface = this;
1506
+ const listeners = [];
1507
+ const scopedPlayer = {
1508
+ get isMuted() {
1509
+ return player.isMuted;
1510
+ },
1511
+ get playbackRate() {
1512
+ return player.playbackRate;
1513
+ },
1514
+ get currentTime() {
1515
+ return player.time.current;
1516
+ },
1517
+ get duration() {
1518
+ return player.time.duration;
1519
+ },
1520
+ get captionsEnabled() {
1521
+ return player.captionsEnabled;
1522
+ },
1523
+ on(event, fn) {
1524
+ player.on(event, fn);
1525
+ listeners.push({ event, fn });
1526
+ },
1527
+ off(event, fn) {
1528
+ player.off(event, fn);
1529
+ const idx = listeners.findIndex((l) => l.event === event && l.fn === fn);
1530
+ if (idx >= 0) listeners.splice(idx, 1);
1531
+ },
1532
+ play() {
1533
+ surface.play();
1534
+ },
1535
+ pause() {
1536
+ surface.pause();
1537
+ },
1538
+ seek(time) {
1539
+ surface.seek(time);
1540
+ },
1541
+ setMuted(muted) {
1542
+ surface.setMuted(muted);
1543
+ },
1544
+ setPlaybackRate(rate) {
1545
+ surface.setPlaybackRate(rate);
1546
+ },
1547
+ setCaptionsEnabled(enabled) {
1548
+ surface.setCaptionsEnabled(enabled);
1549
+ },
1550
+ selectCaptionTrack(track) {
1551
+ surface.selectCaptionTrack(track);
1552
+ },
1553
+ sendContentSignal(signal) {
1554
+ surface.sendContentSignal(signal);
1555
+ },
1556
+ skipToNext() {
1557
+ surface.skipToNext();
1558
+ },
1559
+ skipToPrevious() {
1560
+ surface.skipToPrevious();
1561
+ },
1562
+ seekThumbnail(time) {
1563
+ return surface.seekThumbnail(time);
1564
+ }
1565
+ };
1566
+ const feed = {
1567
+ dismiss: () => {
1568
+ if (surface._feedDismiss) surface._feedDismiss();
1569
+ },
1570
+ navigateUp: () => surface.navigateUp(),
1571
+ navigateDown: () => surface.navigateDown()
1572
+ };
1573
+ entry.unsub = () => {
1574
+ for (const { event, fn } of listeners) player.off(event, fn);
1575
+ listeners.length = 0;
1576
+ };
1577
+ try {
1578
+ this._config.overlay(entry.el, { item, player: scopedPlayer, feed });
1579
+ } catch (e) {
1580
+ }
1581
+ }
1582
+ /** Clear the overlay container's DOM and unsubscribe tracked listeners. */
1583
+ _clearOverlay(itemId) {
1584
+ const entry = this._overlayContainers.get(itemId);
1585
+ if (!entry) return;
1586
+ if (entry.unsub) {
1587
+ entry.unsub();
1588
+ entry.unsub = null;
1589
+ }
1590
+ entry.el.innerHTML = "";
1591
+ }
1592
+ }
1593
+ class EmbeddedFeedManager {
1594
+ /**
1595
+ * @param {HTMLElement} containerEl The feed scroll container
1596
+ * @param {import('../core/shortkit.js').ShortKit} shortKit
1597
+ * @param {object} [options]
1598
+ * @param {string} [options.idPrefix] Element ID prefix (default 'skp')
1599
+ */
1600
+ constructor(containerEl, shortKit, options = {}) {
1601
+ this.container = containerEl;
1602
+ this._sk = shortKit;
1603
+ this._options = options;
1604
+ this._idPrefix = options.idPrefix || "skp";
1605
+ this.pool = shortKit._playerPool;
1606
+ this.storyboardCache = shortKit._storyboardCache;
1607
+ this.items = [];
1608
+ this._cursor = null;
1609
+ this._hasMore = true;
1610
+ this.activeItemId = null;
1611
+ this.activeIndex = 0;
1612
+ this.isMuted = true;
1613
+ this.playbackRate = 1;
1614
+ this.itemEls = /* @__PURE__ */ new Map();
1615
+ this.rafIds = /* @__PURE__ */ new Map();
1616
+ this._loadingMore = false;
1617
+ this._wasPlayingBeforeHidden = false;
1618
+ this._speculativeItemId = null;
1619
+ this._speculativePlayer = null;
1620
+ this._lastPredictedIndex = -1;
1621
+ this._captionsOn = false;
1622
+ this._captionCues = /* @__PURE__ */ new Map();
1623
+ this._captionFetches = /* @__PURE__ */ new Map();
1624
+ this._prefetchedThumbs = /* @__PURE__ */ new Set();
1625
+ this._destroyed = false;
1626
+ this._overlayContainers = /* @__PURE__ */ new Map();
1627
+ this._feedDismiss = null;
1628
+ }
1629
+ _id(suffix) {
1630
+ return `${this._idPrefix}${suffix}`;
1631
+ }
1632
+ async initWithItems(items, startIndex = 0, { deferActivation = false } = {}) {
1633
+ this._appendItems(items);
1634
+ this._prefetchThumbnails(0);
1635
+ if (!deferActivation) this._setupObserver();
1636
+ this._setupScrollEnd();
1637
+ this._setupVisibilityHandler();
1638
+ this._setupCellHeightObserver();
1639
+ if (startIndex > 0 && startIndex < items.length) {
1640
+ const targetEl = this.itemEls.get(items[startIndex].id);
1641
+ if (targetEl) targetEl.scrollIntoView({ behavior: "instant", block: "start" });
1642
+ }
1643
+ }
1644
+ startObserver() {
1645
+ if (!this.observer) this._setupObserver();
1646
+ }
1647
+ destroy() {
1648
+ this._destroyed = true;
1649
+ for (const [id] of this.rafIds) this._stopTimeLoop(id);
1650
+ if (this.activeItemId) this._deactivateItem(this.activeItemId);
1651
+ for (const [id] of [...this.pool.assignments]) this.pool.release(id);
1652
+ if (this.observer) {
1653
+ this.observer.disconnect();
1654
+ this.observer = null;
1655
+ }
1656
+ if (this._resizeObserver) {
1657
+ this._resizeObserver.disconnect();
1658
+ this._resizeObserver = null;
1659
+ }
1660
+ if (this._visHandler) document.removeEventListener("visibilitychange", this._visHandler);
1661
+ this.container.innerHTML = "";
1662
+ this.itemEls.clear();
1663
+ this.items = [];
1664
+ this.activeItemId = null;
1665
+ this.activeIndex = 0;
1666
+ this._speculativeItemId = null;
1667
+ this._speculativePlayer = null;
1668
+ }
1669
+ getActiveVideoElement() {
1670
+ if (!this.activeItemId) return null;
1671
+ return this.pool.getPlayer(this.activeItemId);
1672
+ }
1673
+ getActiveItemRect() {
1674
+ if (!this.activeItemId) return null;
1675
+ const el = this.itemEls.get(this.activeItemId);
1676
+ if (!el) return null;
1677
+ const container = el.querySelector('[data-ref="videoContainer"]');
1678
+ return container ? container.getBoundingClientRect() : el.getBoundingClientRect();
1679
+ }
1680
+ // ── Surface command interface (forwarded from ShortKitPlayer) ──
1681
+ play() {
1682
+ const player = this.pool.getPlayer(this.activeItemId);
1683
+ if (player) {
1684
+ player.play().catch(() => {
1685
+ });
1686
+ this._pushPlayerState({ playerState: "playing" });
1687
+ }
1688
+ }
1689
+ pause() {
1690
+ const player = this.pool.getPlayer(this.activeItemId);
1691
+ if (player) {
1692
+ player.pause();
1693
+ this._pushPlayerState({ playerState: "paused" });
1694
+ }
1695
+ }
1696
+ seek(time) {
1697
+ const player = this.pool.getPlayer(this.activeItemId);
1698
+ if (player && player.duration) {
1699
+ player.currentTime = time;
1700
+ }
1701
+ }
1702
+ setMuted(muted) {
1703
+ this.isMuted = muted;
1704
+ for (const [, player] of this.pool._players) {
1705
+ player.muted = muted;
1706
+ }
1707
+ this._pushPlayerState({ isMuted: muted });
1708
+ }
1709
+ skipToNext() {
1710
+ this.navigateDown();
1711
+ }
1712
+ skipToPrevious() {
1713
+ this.navigateUp();
1714
+ }
1715
+ setPlaybackRate(rate) {
1716
+ this.playbackRate = rate;
1717
+ for (const [, player] of this.pool._players) {
1718
+ player.playbackRate = rate;
1719
+ }
1720
+ this._pushPlayerState({ playbackRate: rate });
1721
+ }
1722
+ setCaptionsEnabled(enabled) {
1723
+ this._captionsOn = enabled;
1724
+ this._pushPlayerState({ captionsEnabled: enabled });
1725
+ if (enabled) {
1726
+ const item = this.items[this.activeIndex];
1727
+ if (item?.captionTracks?.length) this._fetchCaptionVTT(item);
1728
+ }
1729
+ if (!enabled) {
1730
+ this._sk.player._pushState({ activeCue: null });
1731
+ }
1732
+ }
1733
+ selectCaptionTrack(track) {
1734
+ this._pushPlayerState({ activeCaptionTrack: track });
1735
+ }
1736
+ sendContentSignal(signal) {
1737
+ if (this.activeItemId) {
1738
+ this._sk._tracker.trackContentSignal(this.activeItemId, signal);
1739
+ }
1740
+ }
1741
+ seekThumbnail(time) {
1742
+ if (!this.activeItemId) return null;
1743
+ const item = this.items[this.activeIndex];
1744
+ if (!item) return null;
1745
+ const playbackId = StoryboardCache.extractPlaybackId(item.thumbnailUrl);
1746
+ if (!playbackId) return null;
1747
+ return this.storyboardCache.tileAt(playbackId, time);
1748
+ }
1749
+ // ── Internal: API wrapper ──
1750
+ async _fetchFeed(limit = 10) {
1751
+ const result = await this._sk._apiClient.fetchFeed({
1752
+ limit,
1753
+ cursor: this._cursor
1754
+ });
1755
+ this._cursor = result.nextCursor;
1756
+ this._hasMore = result.hasMore;
1757
+ const items = result.items.map((item) => ({
1758
+ id: item.id,
1759
+ streamingUrl: item.streamingUrl,
1760
+ thumbnailUrl: item.thumbnailUrl,
1761
+ title: item.title || "",
1762
+ description: item.description || "",
1763
+ author: item.author || "",
1764
+ section: item.customMetadata?.category?.toUpperCase() || item.customMetadata?.section?.toUpperCase() || "",
1765
+ articleUrl: item.articleUrl || null,
1766
+ duration: item.duration || 0,
1767
+ captionTracks: item.captionTracks || []
1768
+ }));
1769
+ return { items, nextCursor: result.nextCursor, hasMore: result.hasMore };
1770
+ }
1771
+ _appendItems(newItems) {
1772
+ for (const item of newItems) {
1773
+ if (this.itemEls.has(item.id)) continue;
1774
+ this.items.push(item);
1775
+ const el = createFeedItem(item);
1776
+ this.container.appendChild(el);
1777
+ this.itemEls.set(item.id, el);
1778
+ this._bindControls(item, el);
1779
+ this._createOverlayContainer(item.id, el);
1780
+ this.observer?.observe(el);
1781
+ }
1782
+ }
1783
+ async _loadMore() {
1784
+ if (this._loadingMore || !this._hasMore) return;
1785
+ this._loadingMore = true;
1786
+ try {
1787
+ const result = await this._fetchFeed(10);
1788
+ this._appendItems(result.items);
1789
+ } finally {
1790
+ this._loadingMore = false;
1791
+ }
1792
+ }
1793
+ navigateTo(index) {
1794
+ const clamped = Math.max(0, Math.min(this.items.length - 1, index));
1795
+ const el = this.itemEls.get(this.items[clamped].id);
1796
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
1797
+ }
1798
+ navigateUp() {
1799
+ this.navigateTo(this.activeIndex - 1);
1800
+ }
1801
+ navigateDown() {
1802
+ this.navigateTo(this.activeIndex + 1);
1803
+ }
1804
+ _setupVisibilityHandler() {
1805
+ this._visHandler = () => {
1806
+ if (document.hidden) this._handleTabHidden();
1807
+ else this._handleTabVisible();
1808
+ };
1809
+ document.addEventListener("visibilitychange", this._visHandler);
1810
+ }
1811
+ _handleTabHidden() {
1812
+ this._cancelSpeculation();
1813
+ if (!this.activeItemId) return;
1814
+ const p = this.pool.getPlayer(this.activeItemId);
1815
+ if (p && !p.paused) {
1816
+ this._wasPlayingBeforeHidden = true;
1817
+ p.pause();
1818
+ } else {
1819
+ this._wasPlayingBeforeHidden = false;
1820
+ }
1821
+ this._stopTimeLoop(this.activeItemId);
1822
+ }
1823
+ _handleTabVisible() {
1824
+ if (!this.activeItemId) return;
1825
+ if (this._wasPlayingBeforeHidden) {
1826
+ const p2 = this.pool.getPlayer(this.activeItemId);
1827
+ if (p2) p2.play().catch(() => {
1828
+ });
1829
+ this._wasPlayingBeforeHidden = false;
1830
+ }
1831
+ const el = this.itemEls.get(this.activeItemId);
1832
+ const p = this.pool.getPlayer(this.activeItemId);
1833
+ if (el && p) this._startTimeLoop(this.activeItemId, el, p);
1834
+ }
1835
+ _setupObserver() {
1836
+ this.observer = new IntersectionObserver((entries) => {
1837
+ for (const entry of entries) {
1838
+ const id = entry.target.dataset.itemId;
1839
+ if (entry.isIntersecting && entry.intersectionRatio > 0.6) this._activateItem(id);
1840
+ }
1841
+ }, { root: this.container, threshold: [0, 0.6, 1] });
1842
+ for (const el of this.itemEls.values()) this.observer.observe(el);
1843
+ }
1844
+ _setupCellHeightObserver() {
1845
+ this._cellHeight = 0;
1846
+ const measure = () => {
1847
+ const first = this.container.firstElementChild;
1848
+ if (first) this._cellHeight = first.offsetHeight + parseFloat(getComputedStyle(first).marginBottom || "0");
1849
+ };
1850
+ measure();
1851
+ this._resizeObserver = new ResizeObserver(measure);
1852
+ this._resizeObserver.observe(this.container);
1853
+ }
1854
+ _setupScrollEnd() {
1855
+ let scrollTimer;
1856
+ this.container.addEventListener("scroll", () => {
1857
+ this._predictAndSpeculate();
1858
+ clearTimeout(scrollTimer);
1859
+ scrollTimer = setTimeout(() => this._onScrollEnd(), 100);
1860
+ }, { passive: true });
1861
+ this.container.addEventListener("pointerdown", () => this._cancelSpeculation(), { passive: true });
1862
+ this.container.addEventListener("touchstart", () => this._cancelSpeculation(), { passive: true });
1863
+ }
1864
+ _onScrollEnd() {
1865
+ const idx = this.items.findIndex((i) => i.id === this.activeItemId);
1866
+ if (idx < 0) return;
1867
+ const adjacent = [this.items[idx - 1], this.items[idx + 1]].filter(Boolean);
1868
+ for (const item of adjacent) {
1869
+ const p = this.pool.acquire(item.id);
1870
+ this.pool.attachStream(item.id, item.streamingUrl);
1871
+ p.currentTime = 0;
1872
+ if (this._speculativeItemId !== item.id) {
1873
+ p.muted = true;
1874
+ const pid = item.id;
1875
+ const pp = p.play();
1876
+ if (pp) pp.then(() => {
1877
+ if (this.activeItemId !== pid) p.pause();
1878
+ }).catch(() => {
1879
+ });
1880
+ }
1881
+ }
1882
+ this._prefetchThumbnails(idx);
1883
+ for (const adj of adjacent) {
1884
+ if (adj.captionTracks?.length) this._fetchCaptionVTT(adj);
1885
+ }
1886
+ if (idx >= this.items.length - 3) this._loadMore();
1887
+ }
1888
+ _predictAndSpeculate() {
1889
+ const containerHeight = this._cellHeight || this.container.clientHeight;
1890
+ if (containerHeight <= 0 || this.items.length === 0) return;
1891
+ const predictedIndex = Math.round(this.container.scrollTop / containerHeight);
1892
+ const clamped = Math.max(0, Math.min(this.items.length - 1, predictedIndex));
1893
+ if (clamped === this._lastPredictedIndex) return;
1894
+ this._lastPredictedIndex = clamped;
1895
+ if (clamped === this.activeIndex) {
1896
+ this._cancelSpeculation();
1897
+ return;
1898
+ }
1899
+ if (Math.abs(clamped - this.activeIndex) > 1) return;
1900
+ const item = this.items[clamped];
1901
+ if (!item || this._speculativeItemId === item.id) return;
1902
+ this._cancelSpeculation();
1903
+ const player = this.pool.acquire(item.id);
1904
+ this.pool.attachStream(item.id, item.streamingUrl);
1905
+ player.currentTime = 0;
1906
+ player.muted = true;
1907
+ const el = this.itemEls.get(item.id);
1908
+ if (el) {
1909
+ const c = el.querySelector('[data-ref="videoContainer"]');
1910
+ player.style.opacity = "0";
1911
+ if (!c.contains(player)) c.insertBefore(player, c.firstChild);
1912
+ }
1913
+ const p = player.play();
1914
+ if (p) p.catch(() => {
1915
+ });
1916
+ this._speculativeItemId = item.id;
1917
+ this._speculativePlayer = player;
1918
+ }
1919
+ _cancelSpeculation() {
1920
+ if (!this._speculativeItemId) return;
1921
+ const p = this.pool.getPlayer(this._speculativeItemId);
1922
+ if (p) {
1923
+ p.pause();
1924
+ p.style.opacity = "0";
1925
+ }
1926
+ this._speculativeItemId = null;
1927
+ this._speculativePlayer = null;
1928
+ this._lastPredictedIndex = -1;
1929
+ }
1930
+ _activateItem(id) {
1931
+ if (this.activeItemId === id) return;
1932
+ if (this._speculativeItemId && this._speculativeItemId !== id) this._cancelSpeculation();
1933
+ this._speculativeItemId = null;
1934
+ this._speculativePlayer = null;
1935
+ this._lastPredictedIndex = -1;
1936
+ if (this.activeItemId) this._deactivateItem(this.activeItemId);
1937
+ this.activeItemId = id;
1938
+ this.activeIndex = this.items.findIndex((i) => i.id === id);
1939
+ const item = this.items[this.activeIndex];
1940
+ const el = this.itemEls.get(id);
1941
+ const player = this.pool.acquire(id);
1942
+ const isTransfer = !!player._skFeedTransfer;
1943
+ delete player._skFeedTransfer;
1944
+ if (!isTransfer) player.style.opacity = "0";
1945
+ const container = el.querySelector('[data-ref="videoContainer"]');
1946
+ if (!container.contains(player)) container.insertBefore(player, container.firstChild);
1947
+ this.pool.attachStream(id, item.streamingUrl, { isActive: true });
1948
+ this.pool.promoteToActive(id);
1949
+ player.muted = this.isMuted;
1950
+ player.playbackRate = this.playbackRate;
1951
+ if (!isTransfer) player.currentTime = 0;
1952
+ const spinner = el.querySelector('[data-ref="spinner"]');
1953
+ const REVEAL_EVENTS = ["loadeddata", "seeked", "canplay", "timeupdate"];
1954
+ let spinnerTimeout = null;
1955
+ const revealOnFirstFrame = () => {
1956
+ if (player._skRevealedFor === id) return;
1957
+ if (player.readyState < 2) return;
1958
+ player._skRevealedFor = id;
1959
+ player.style.opacity = "1";
1960
+ clearTimeout(spinnerTimeout);
1961
+ spinner.classList.remove("visible");
1962
+ REVEAL_EVENTS.forEach((e) => player.removeEventListener(e, revealOnFirstFrame));
1963
+ };
1964
+ if (isTransfer) {
1965
+ player._skRevealedFor = id;
1966
+ player.style.opacity = "1";
1967
+ } else if (player.readyState >= 2 && !player.seeking) {
1968
+ player._skRevealedFor = id;
1969
+ player.style.opacity = "1";
1970
+ } else {
1971
+ spinnerTimeout = setTimeout(() => spinner.classList.add("visible"), 1e3);
1972
+ REVEAL_EVENTS.forEach((e) => player.addEventListener(e, revealOnFirstFrame));
1973
+ }
1974
+ const playPromise = player.play();
1975
+ if (playPromise) playPromise.catch(() => {
1976
+ if (player.readyState >= 2) revealOnFirstFrame();
1977
+ });
1978
+ this._startTimeLoop(id, el, player);
1979
+ this._invokeOverlay(id, item);
1980
+ this._prefetchThumbnails(this.activeIndex);
1981
+ if (item.captionTracks?.length) this._fetchCaptionVTT(item);
1982
+ const playbackId = StoryboardCache.extractPlaybackId(item.thumbnailUrl);
1983
+ if (playbackId) this.storyboardCache.get(playbackId);
1984
+ }
1985
+ _deactivateItem(id) {
1986
+ const p = this.pool.getPlayer(id);
1987
+ if (p) {
1988
+ p.pause();
1989
+ p.style.opacity = "0";
1990
+ p._skRevealedFor = null;
1991
+ }
1992
+ this._stopTimeLoop(id);
1993
+ this._clearOverlay(id);
1994
+ }
1995
+ // --- Time loop (state emission only, no DOM updates) ---
1996
+ _startTimeLoop(id, el, player) {
1997
+ this._stopTimeLoop(id);
1998
+ const tick = () => {
1999
+ if (this._destroyed) return;
2000
+ if (!player || player.paused) {
2001
+ this.rafIds.set(id, requestAnimationFrame(tick));
2002
+ return;
2003
+ }
2004
+ const current = player.currentTime || 0;
2005
+ const duration = player.duration || 0;
2006
+ const buffered = player.buffered?.length > 0 ? player.buffered.end(player.buffered.length - 1) : 0;
2007
+ this._sk.player._pushState({
2008
+ time: { current, duration, buffered },
2009
+ playerState: "playing"
2010
+ });
2011
+ if (this._captionsOn) {
2012
+ const cues = this._captionCues.get(id);
2013
+ if (cues) {
2014
+ const cue = cues.find((c) => current >= c.startTime && current <= c.endTime) || null;
2015
+ const prev = this._sk.player.activeCue;
2016
+ if (cue !== prev) {
2017
+ this._sk.player._pushState({ activeCue: cue });
2018
+ }
2019
+ }
2020
+ }
2021
+ if (player.ended) {
2022
+ player.currentTime = 0;
2023
+ player.play().catch(() => {
2024
+ });
2025
+ }
2026
+ this.rafIds.set(id, requestAnimationFrame(tick));
2027
+ };
2028
+ this.rafIds.set(id, requestAnimationFrame(tick));
2029
+ }
2030
+ _stopTimeLoop(id) {
2031
+ const r = this.rafIds.get(id);
2032
+ if (r) {
2033
+ cancelAnimationFrame(r);
2034
+ this.rafIds.delete(id);
2035
+ }
2036
+ }
2037
+ // --- Controls ---
2038
+ _bindControls(item, el) {
2039
+ const tapZone = el.querySelector('[data-ref="tapZone"]');
2040
+ if (tapZone) {
2041
+ tapZone.addEventListener("click", () => {
2042
+ const player = this.pool.getPlayer(item.id);
2043
+ if (!player) return;
2044
+ if (player.paused) {
2045
+ player.play().catch(() => {
2046
+ });
2047
+ this._pushPlayerState({ playerState: "playing" });
2048
+ } else {
2049
+ player.pause();
2050
+ this._pushPlayerState({ playerState: "paused" });
2051
+ }
2052
+ });
2053
+ }
2054
+ }
2055
+ // --- Push state to ShortKitPlayer ---
2056
+ _pushPlayerState(updates = {}) {
2057
+ const player = this.pool.getPlayer(this.activeItemId);
2058
+ const item = this.items[this.activeIndex] || null;
2059
+ const state = {
2060
+ currentItem: item,
2061
+ isMuted: this.isMuted,
2062
+ playbackRate: this.playbackRate,
2063
+ captionsEnabled: this._captionsOn,
2064
+ activeCaptionTrack: null,
2065
+ activeCue: null,
2066
+ prefetchedAheadCount: Math.max(0, this.items.length - this.activeIndex - 1),
2067
+ remainingContentCount: this.items.length - this.activeIndex - 1,
2068
+ ...updates
2069
+ };
2070
+ if (player) {
2071
+ state.time = {
2072
+ current: player.currentTime || 0,
2073
+ duration: player.duration || 0,
2074
+ buffered: player.buffered?.length > 0 ? player.buffered.end(player.buffered.length - 1) : 0
2075
+ };
2076
+ if (!updates.playerState) {
2077
+ state.playerState = player.paused ? "paused" : "playing";
2078
+ }
2079
+ }
2080
+ this._sk.player._pushState(state);
2081
+ }
2082
+ // --- Caption VTT fetch (deduped + cached) ---
2083
+ _fetchCaptionVTT(item) {
2084
+ if (this._captionCues.has(item.id) || this._captionFetches.has(item.id)) return;
2085
+ const track = item.captionTracks[0];
2086
+ if (!track?.url) return;
2087
+ const promise = fetch(track.url).then((res) => res.ok ? res.text() : null).then((text) => {
2088
+ if (text) this._captionCues.set(item.id, parseVTT(text));
2089
+ }).catch(() => {
2090
+ }).finally(() => this._captionFetches.delete(item.id));
2091
+ this._captionFetches.set(item.id, promise);
2092
+ }
2093
+ _prefetchThumbnails(centerIndex) {
2094
+ const start = Math.max(0, centerIndex - 3);
2095
+ const end = Math.min(this.items.length, centerIndex + 4);
2096
+ for (let i = start; i < end; i++) {
2097
+ const url = this.items[i].thumbnailUrl;
2098
+ if (!url || this._prefetchedThumbs.has(url)) continue;
2099
+ this._prefetchedThumbs.add(url);
2100
+ const img = new Image();
2101
+ img.src = url;
2102
+ }
2103
+ }
2104
+ // --- Custom overlay helpers ---
2105
+ _createOverlayContainer(itemId, cellEl) {
2106
+ const overlayEl = cellEl.querySelector('[data-ref="overlay"]');
2107
+ if (!overlayEl) return;
2108
+ this._overlayContainers.set(itemId, { el: overlayEl, unsub: null });
2109
+ }
2110
+ _invokeOverlay(itemId, item) {
2111
+ if (!this._options?.config?.overlay) return;
2112
+ const entry = this._overlayContainers.get(itemId);
2113
+ if (!entry) return;
2114
+ const player = this._sk.player;
2115
+ const surface = this;
2116
+ const listeners = [];
2117
+ const scopedPlayer = {
2118
+ get isMuted() {
2119
+ return player.isMuted;
2120
+ },
2121
+ get playbackRate() {
2122
+ return player.playbackRate;
2123
+ },
2124
+ get currentTime() {
2125
+ return player.time.current;
2126
+ },
2127
+ get duration() {
2128
+ return player.time.duration;
2129
+ },
2130
+ get captionsEnabled() {
2131
+ return player.captionsEnabled;
2132
+ },
2133
+ on(event, fn) {
2134
+ player.on(event, fn);
2135
+ listeners.push({ event, fn });
2136
+ },
2137
+ off(event, fn) {
2138
+ player.off(event, fn);
2139
+ const idx = listeners.findIndex((l) => l.event === event && l.fn === fn);
2140
+ if (idx >= 0) listeners.splice(idx, 1);
2141
+ },
2142
+ play() {
2143
+ surface.play();
2144
+ },
2145
+ pause() {
2146
+ surface.pause();
2147
+ },
2148
+ seek(time) {
2149
+ surface.seek(time);
2150
+ },
2151
+ setMuted(muted) {
2152
+ surface.setMuted(muted);
2153
+ },
2154
+ setPlaybackRate(rate) {
2155
+ surface.setPlaybackRate(rate);
2156
+ },
2157
+ setCaptionsEnabled(enabled) {
2158
+ surface.setCaptionsEnabled(enabled);
2159
+ },
2160
+ selectCaptionTrack(track) {
2161
+ surface.selectCaptionTrack(track);
2162
+ },
2163
+ sendContentSignal(signal) {
2164
+ surface.sendContentSignal(signal);
2165
+ },
2166
+ skipToNext() {
2167
+ surface.skipToNext();
2168
+ },
2169
+ skipToPrevious() {
2170
+ surface.skipToPrevious();
2171
+ },
2172
+ seekThumbnail(time) {
2173
+ return surface.seekThumbnail(time);
2174
+ }
2175
+ };
2176
+ const feed = {
2177
+ dismiss: () => {
2178
+ if (surface._feedDismiss) surface._feedDismiss();
2179
+ },
2180
+ navigateUp: () => surface.navigateUp(),
2181
+ navigateDown: () => surface.navigateDown()
2182
+ };
2183
+ entry.unsub = () => {
2184
+ for (const { event, fn } of listeners) player.off(event, fn);
2185
+ listeners.length = 0;
2186
+ };
2187
+ try {
2188
+ this._options.config.overlay(entry.el, { item, player: scopedPlayer, feed });
2189
+ } catch (e) {
2190
+ }
2191
+ }
2192
+ _clearOverlay(itemId) {
2193
+ const entry = this._overlayContainers.get(itemId);
2194
+ if (!entry) return;
2195
+ if (entry.unsub) {
2196
+ entry.unsub();
2197
+ entry.unsub = null;
2198
+ }
2199
+ entry.el.innerHTML = "";
2200
+ }
2201
+ }
2202
+ const MuteOnSvg = '<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>';
2203
+ const MuteOffSvg = '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>';
2204
+ class SinglePlayer {
2205
+ /**
2206
+ * @param {HTMLElement} containerEl The player container (.skp-player)
2207
+ * @param {import('../core/shortkit.js').ShortKit} shortKit
2208
+ * @param {object} [options]
2209
+ * @param {object} [options.config] PlayerConfig from createPlayerConfig()
2210
+ * @param {number} [options.scale] Visual scale multiplier (default 1.4)
2211
+ * @param {number} [options.padding] Inner padding (default 0)
2212
+ * @param {string} [options.overlayElId] ID of the feed overlay element (default 'skpFeedOverlay')
2213
+ * @param {Function} [options.onTap]
2214
+ */
2215
+ constructor(containerEl, shortKit, options = {}) {
2216
+ this.container = containerEl;
2217
+ this._sk = shortKit;
2218
+ this._options = options;
2219
+ this._config = options.config || {};
2220
+ this._scale = options.scale ?? 1.4;
2221
+ this._padding = options.padding ?? 0;
2222
+ this._cornerRadius = this._config.cornerRadius ?? 12;
2223
+ this._clickAction = this._config.clickAction || "feed";
2224
+ this._autoplay = this._config.autoplay !== false;
2225
+ this._loop = this._config.loop !== false;
2226
+ this._overlayElId = options.overlayElId || "skpFeedOverlay";
2227
+ this.videoEl = null;
2228
+ this.hlsInstance = null;
2229
+ this.item = null;
2230
+ this.items = [];
2231
+ this._playOverlay = null;
2232
+ this._muteIconEl = null;
2233
+ this._muteFlashTimer = null;
2234
+ this._feedView = null;
2235
+ this._cursor = null;
2236
+ this._hasMore = true;
2237
+ this._destroyed = false;
2238
+ this._overlayEl = null;
2239
+ this._overlayUnsub = null;
2240
+ }
2241
+ // ── Public API ──
2242
+ async init() {
2243
+ const fetchCount = this._clickAction === "feed" ? 10 : 1;
2244
+ const result = await this._sk._apiClient.fetchFeed({ limit: fetchCount });
2245
+ if (!result.items.length) return;
2246
+ this._cursor = result.nextCursor;
2247
+ this._hasMore = result.hasMore;
2248
+ this.items = result.items.map((item) => this._normaliseItem(item));
2249
+ this.item = this.items[0];
2250
+ this._build();
2251
+ }
2252
+ /** Initialize with pre-fetched items instead of calling the API. */
2253
+ initWithItems(items) {
2254
+ this.items = items;
2255
+ this.item = items[0];
2256
+ this._build();
2257
+ }
2258
+ /** Update scale live (dashboard use). */
2259
+ setScale(scale) {
2260
+ this._scale = scale;
2261
+ this.container.style.maxWidth = `${Math.round(240 * scale)}px`;
2262
+ }
2263
+ /** Update padding live (dashboard use). */
2264
+ setPadding(padding) {
2265
+ this._padding = padding;
2266
+ this.container.style.setProperty("--skp-padding", `${padding}px`);
2267
+ }
2268
+ /** Update corner radius live (dashboard use). */
2269
+ setCornerRadius(radius) {
2270
+ this._cornerRadius = radius;
2271
+ this.container.style.setProperty("--skp-radius", `${radius}px`);
2272
+ this.container.style.borderRadius = `${radius}px`;
2273
+ }
2274
+ /** Update click action live (dashboard use). */
2275
+ setClickAction(action) {
2276
+ this._clickAction = action;
2277
+ }
2278
+ /** Update loop live (dashboard use). */
2279
+ setLoop(loop) {
2280
+ this._loop = loop;
2281
+ if (this.videoEl) this.videoEl.loop = loop;
2282
+ }
2283
+ destroy() {
2284
+ this._destroyed = true;
2285
+ clearTimeout(this._muteFlashTimer);
2286
+ this._clearOverlay();
2287
+ if (this.hlsInstance) {
2288
+ this.hlsInstance.destroy();
2289
+ this.hlsInstance = null;
2290
+ }
2291
+ this.container.innerHTML = "";
2292
+ this.videoEl = null;
2293
+ this.item = null;
2294
+ this._playOverlay = null;
2295
+ this._muteIconEl = null;
2296
+ this._overlayEl = null;
2297
+ if (this._feedView) {
2298
+ this._feedView = null;
2299
+ }
2300
+ this._sk._unregisterSurface(this);
2301
+ }
2302
+ // ── Internal: item normalisation ──
2303
+ _normaliseItem(item) {
2304
+ return {
2305
+ id: item.id,
2306
+ streamingUrl: item.streamingUrl,
2307
+ thumbnailUrl: item.thumbnailUrl,
2308
+ title: item.title || "",
2309
+ description: item.description || "",
2310
+ author: item.author || "",
2311
+ section: item.customMetadata?.category?.toUpperCase() || item.customMetadata?.section?.toUpperCase() || "",
2312
+ articleUrl: item.articleUrl || null,
2313
+ duration: item.duration || 0,
2314
+ captionTracks: item.captionTracks || []
2315
+ };
2316
+ }
2317
+ // ── Internal: DOM build ──
2318
+ _build() {
2319
+ this.container.innerHTML = "";
2320
+ this.container.style.setProperty("--skp-radius", `${this._cornerRadius}px`);
2321
+ this.container.style.maxWidth = `${Math.round(240 * this._scale)}px`;
2322
+ this.container.style.setProperty("--skp-padding", `${this._padding}px`);
2323
+ this.container.style.borderRadius = `${this._cornerRadius}px`;
2324
+ this.videoEl = document.createElement("video");
2325
+ this.videoEl.setAttribute("playsinline", "");
2326
+ this.videoEl.setAttribute("webkit-playsinline", "");
2327
+ this.videoEl.muted = true;
2328
+ this.videoEl.loop = this._loop;
2329
+ if (this.item.thumbnailUrl) this.videoEl.poster = this.item.thumbnailUrl;
2330
+ this.container.appendChild(this.videoEl);
2331
+ const meta = document.createElement("div");
2332
+ meta.className = "skp-meta";
2333
+ meta.innerHTML = `
2334
+ ${this.item.section ? `<div class="skp-meta-section">${this.item.section}</div>` : ""}
2335
+ <div class="skp-meta-title">${this.item.title}</div>
2336
+ `;
2337
+ this.container.appendChild(meta);
2338
+ this._muteIconEl = document.createElement("div");
2339
+ this._muteIconEl.className = "skp-mute-icon";
2340
+ this._muteIconEl.innerHTML = MuteOnSvg;
2341
+ this.container.appendChild(this._muteIconEl);
2342
+ this._playOverlay = document.createElement("div");
2343
+ this._playOverlay.className = "skp-play-overlay";
2344
+ if (this._autoplay) this._playOverlay.classList.add("skp-hidden");
2345
+ this._playOverlay.innerHTML = '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
2346
+ this._playOverlay.addEventListener("click", (e) => {
2347
+ e.stopPropagation();
2348
+ this._playOverlay.classList.add("skp-hidden");
2349
+ this.videoEl.play().catch(() => {
2350
+ });
2351
+ });
2352
+ this.container.appendChild(this._playOverlay);
2353
+ if (this._config.overlay) {
2354
+ this._overlayEl = document.createElement("div");
2355
+ this._overlayEl.style.cssText = "position:absolute;inset:0;z-index:6;pointer-events:auto;";
2356
+ this._overlayEl.dataset.ref = "customOverlay";
2357
+ this.container.appendChild(this._overlayEl);
2358
+ }
2359
+ this.container.addEventListener("click", (e) => {
2360
+ e.preventDefault();
2361
+ e.stopPropagation();
2362
+ this._handleClick(e);
2363
+ });
2364
+ this._attachStream();
2365
+ if (this._config.overlay && this._overlayEl) {
2366
+ this._invokeOverlay();
2367
+ }
2368
+ if (this._clickAction === "feed") {
2369
+ const overlayEl = document.getElementById(this._overlayElId);
2370
+ if (overlayEl) {
2371
+ this._feedView = new PlayerFeedView(overlayEl, this._sk, this._overlayElId);
2372
+ }
2373
+ }
2374
+ }
2375
+ /** Invoke the developer overlay callback with a scoped subscriber. */
2376
+ _invokeOverlay() {
2377
+ this._clearOverlay();
2378
+ if (!this._overlayEl || !this._config.overlay || !this.item) return;
2379
+ const player = this._sk.player;
2380
+ const listeners = [];
2381
+ const scopedPlayer = {
2382
+ on(event, fn) {
2383
+ player.on(event, fn);
2384
+ listeners.push({ event, fn });
2385
+ }
2386
+ };
2387
+ this._overlayUnsub = () => {
2388
+ for (const { event, fn } of listeners) player.off(event, fn);
2389
+ listeners.length = 0;
2390
+ };
2391
+ try {
2392
+ this._config.overlay(this._overlayEl, { item: this.item, player: scopedPlayer });
2393
+ } catch (e) {
2394
+ }
2395
+ }
2396
+ /** Clear overlay content and unsubscribe tracked listeners. */
2397
+ _clearOverlay() {
2398
+ if (this._overlayUnsub) {
2399
+ this._overlayUnsub();
2400
+ this._overlayUnsub = null;
2401
+ }
2402
+ if (this._overlayEl) {
2403
+ this._overlayEl.innerHTML = "";
2404
+ }
2405
+ }
2406
+ _attachStream() {
2407
+ const url = this.item.streamingUrl;
2408
+ if (!url) return;
2409
+ const Hls2 = typeof window !== "undefined" && window.Hls;
2410
+ if (url.includes(".m3u8") && Hls2 && Hls2.isSupported()) {
2411
+ const hls = new Hls2({
2412
+ startLevel: -1,
2413
+ capLevelToPlayerSize: true,
2414
+ maxBufferLength: 8,
2415
+ maxMaxBufferLength: 15
2416
+ });
2417
+ hls.loadSource(url);
2418
+ hls.attachMedia(this.videoEl);
2419
+ hls.on(Hls2.Events.MANIFEST_PARSED, () => {
2420
+ if (this._autoplay) this.videoEl.play().catch(() => {
2421
+ });
2422
+ });
2423
+ hls.on(Hls2.Events.ERROR, (_, data) => {
2424
+ if (data.fatal) {
2425
+ switch (data.type) {
2426
+ case Hls2.ErrorTypes.NETWORK_ERROR:
2427
+ hls.startLoad();
2428
+ break;
2429
+ default:
2430
+ hls.destroy();
2431
+ break;
2432
+ }
2433
+ }
2434
+ });
2435
+ this.hlsInstance = hls;
2436
+ } else {
2437
+ this.videoEl.src = url;
2438
+ if (this._autoplay) this.videoEl.play().catch(() => {
2439
+ });
2440
+ }
2441
+ }
2442
+ _handleClick(_e) {
2443
+ if (this._playOverlay && !this._playOverlay.classList.contains("skp-hidden")) return;
2444
+ const action = this._clickAction;
2445
+ if (action === "none") return;
2446
+ if (action === "article") {
2447
+ if (this.item?.articleUrl) window.open(this.item.articleUrl, "_blank", "noopener");
2448
+ return;
2449
+ }
2450
+ if (action === "mute") {
2451
+ this.videoEl.muted = !this.videoEl.muted;
2452
+ this._updateMuteIcon();
2453
+ this.container.classList.add("skp-mute-visible");
2454
+ clearTimeout(this._muteFlashTimer);
2455
+ this._muteFlashTimer = setTimeout(() => {
2456
+ this.container.classList.remove("skp-mute-visible");
2457
+ }, 1500);
2458
+ return;
2459
+ }
2460
+ if (action === "feed") {
2461
+ this._openFeed();
2462
+ return;
2463
+ }
2464
+ }
2465
+ _openFeed() {
2466
+ if (!this._feedView || this._feedView._isOpen) return;
2467
+ if (this.videoEl) this.videoEl.pause();
2468
+ this._feedView.open({
2469
+ sourceEl: this.container,
2470
+ video: this.videoEl,
2471
+ hlsInstance: this.hlsInstance,
2472
+ item: this.item,
2473
+ startIndex: 0,
2474
+ items: [...this.items],
2475
+ cursor: this._cursor,
2476
+ hasMore: this._hasMore,
2477
+ onClose: (action, data) => {
2478
+ if (action === "getSourceRect") {
2479
+ return this.container.getBoundingClientRect();
2480
+ }
2481
+ if (action === "closed") {
2482
+ let video = data?.transferVideo;
2483
+ let hls = data?.transferHls;
2484
+ if (!video && this._feedView.feedManager) {
2485
+ const ejected = this._feedView.feedManager.pool.ejectPlayer(this.item.id);
2486
+ if (ejected) {
2487
+ video = ejected.video;
2488
+ hls = ejected.hls;
2489
+ }
2490
+ }
2491
+ if (video) {
2492
+ this.videoEl = video;
2493
+ this.hlsInstance = hls;
2494
+ this.videoEl.loop = this._loop;
2495
+ this.videoEl.muted = true;
2496
+ if (!this.container.contains(this.videoEl)) {
2497
+ this.container.insertBefore(this.videoEl, this.container.firstChild);
2498
+ }
2499
+ this.videoEl.play().catch(() => {
2500
+ });
2501
+ } else {
2502
+ this._rebuildVideo();
2503
+ }
2504
+ }
2505
+ }
2506
+ });
2507
+ }
2508
+ _rebuildVideo() {
2509
+ this.videoEl = document.createElement("video");
2510
+ this.videoEl.setAttribute("playsinline", "");
2511
+ this.videoEl.setAttribute("webkit-playsinline", "");
2512
+ this.videoEl.muted = true;
2513
+ this.videoEl.loop = this._loop;
2514
+ if (this.item.thumbnailUrl) this.videoEl.poster = this.item.thumbnailUrl;
2515
+ this.container.insertBefore(this.videoEl, this.container.firstChild);
2516
+ this.hlsInstance = null;
2517
+ this._attachStream();
2518
+ }
2519
+ _updateMuteIcon() {
2520
+ if (!this._muteIconEl) return;
2521
+ this._muteIconEl.innerHTML = this.videoEl.muted ? MuteOnSvg : MuteOffSvg;
2522
+ }
2523
+ }
2524
+ class PlayerFeedView {
2525
+ /**
2526
+ * @param {HTMLElement} overlayEl
2527
+ * @param {import('../core/shortkit.js').ShortKit} shortKit
2528
+ * @param {string} [idPrefix] Element ID prefix (default 'skp')
2529
+ */
2530
+ constructor(overlayEl, shortKit, idPrefix = "skp") {
2531
+ this.overlayEl = overlayEl;
2532
+ this._sk = shortKit;
2533
+ this._prefix = idPrefix;
2534
+ this.backdropEl = overlayEl.querySelector(".skp-feed-backdrop");
2535
+ this.closeBtnEl = document.getElementById(`${idPrefix}FeedCloseBtn`);
2536
+ this.feedEl = document.getElementById(`${idPrefix}Feed`);
2537
+ this.feedManager = null;
2538
+ this._isOpen = false;
2539
+ this._onClose = null;
2540
+ this._escHandler = null;
2541
+ this._openItemId = null;
2542
+ this.closeBtnEl?.addEventListener("click", () => this.close());
2543
+ }
2544
+ async open({ sourceEl, video, hlsInstance, item, startIndex, items, cursor, hasMore, onClose }) {
2545
+ if (this._isOpen) return;
2546
+ this._isOpen = true;
2547
+ this._onClose = onClose;
2548
+ this._openItemId = item.id;
2549
+ const sourceRect = sourceEl.getBoundingClientRect();
2550
+ const sourceRadius = parseFloat(getComputedStyle(sourceEl).borderRadius) || 12;
2551
+ this.overlayEl.classList.add("skp-active");
2552
+ const feedPool = new PlayerPool();
2553
+ const feedShortKit = Object.create(this._sk);
2554
+ feedShortKit._playerPool = feedPool;
2555
+ feedShortKit._storyboardCache = this._sk._storyboardCache;
2556
+ this.feedManager = new EmbeddedFeedManager(this.feedEl, feedShortKit, {
2557
+ idPrefix: this._prefix
2558
+ });
2559
+ if (cursor) this.feedManager._cursor = cursor;
2560
+ if (hasMore !== void 0) this.feedManager._hasMore = hasMore;
2561
+ await this.feedManager.initWithItems([...items], startIndex, { deferActivation: true });
2562
+ if (video && hlsInstance) {
2563
+ this.feedManager.pool.injectPlayer(item.id, video, hlsInstance, item.streamingUrl);
2564
+ video._skFeedTransfer = true;
2565
+ video.loop = true;
2566
+ }
2567
+ const feedItemEl = this.feedManager.itemEls.get(item.id);
2568
+ if (feedItemEl && video) {
2569
+ const c = feedItemEl.querySelector('[data-ref="videoContainer"]');
2570
+ video.style.opacity = "1";
2571
+ if (!c.contains(video)) c.insertBefore(video, c.firstChild);
2572
+ }
2573
+ const feedEl = this.feedEl;
2574
+ feedEl.getBoundingClientRect();
2575
+ const feedRect = feedEl.getBoundingClientRect();
2576
+ const targetRadius = parseFloat(getComputedStyle(feedEl).borderRadius) || 16;
2577
+ const scaleX = sourceRect.width / feedRect.width;
2578
+ const scaleY = sourceRect.height / feedRect.height;
2579
+ const translateX = sourceRect.left - feedRect.left;
2580
+ const translateY = sourceRect.top - feedRect.top;
2581
+ const startRadius = sourceRadius / Math.min(scaleX, scaleY);
2582
+ feedEl.style.transformOrigin = "0 0";
2583
+ feedEl.style.overflow = "hidden";
2584
+ feedEl.style.scrollSnapType = "none";
2585
+ feedEl.style.borderRadius = `${startRadius}px`;
2586
+ feedEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`;
2587
+ feedEl.getBoundingClientRect();
2588
+ feedEl.classList.add("skp-flip-animating");
2589
+ requestAnimationFrame(() => {
2590
+ feedEl.style.transform = "none";
2591
+ feedEl.style.borderRadius = `${targetRadius}px`;
2592
+ });
2593
+ await this._waitForTransitionEnd(feedEl, 450);
2594
+ feedEl.classList.remove("skp-flip-animating");
2595
+ feedEl.style.transformOrigin = "";
2596
+ feedEl.style.overflow = "";
2597
+ feedEl.style.scrollSnapType = "";
2598
+ feedEl.style.transform = "";
2599
+ feedEl.style.borderRadius = "";
2600
+ this.feedManager.startObserver();
2601
+ this.feedManager._activateItem(item.id);
2602
+ this.overlayEl.classList.add("skp-feed-ready");
2603
+ this._escHandler = (e) => {
2604
+ if (e.key === "Escape") this.close();
2605
+ };
2606
+ document.addEventListener("keydown", this._escHandler);
2607
+ }
2608
+ async close() {
2609
+ if (!this._isOpen) return;
2610
+ this._isOpen = false;
2611
+ const feedEl = this.feedEl;
2612
+ const feedWrapper = feedEl.parentNode;
2613
+ const feedRect = feedEl.getBoundingClientRect();
2614
+ const activeItemId = this.feedManager?.activeItemId;
2615
+ let transferBack = false, transferVideo = null, transferHls = null;
2616
+ if (activeItemId === this._openItemId) {
2617
+ const ejected = this.feedManager?.pool.ejectPlayer(activeItemId);
2618
+ if (ejected) {
2619
+ transferBack = true;
2620
+ transferVideo = ejected.video;
2621
+ transferHls = ejected.hls;
2622
+ }
2623
+ }
2624
+ let targetRect = null;
2625
+ if (this._onClose) targetRect = this._onClose("getSourceRect");
2626
+ if (feedRect && targetRect) {
2627
+ if (this.feedManager?.activeItemId && !transferBack) {
2628
+ this.feedManager._deactivateItem(this.feedManager.activeItemId);
2629
+ }
2630
+ feedEl.style.position = "fixed";
2631
+ feedEl.style.left = `${feedRect.left}px`;
2632
+ feedEl.style.top = `${feedRect.top}px`;
2633
+ feedEl.style.width = `${feedRect.width}px`;
2634
+ feedEl.style.height = `${feedRect.height}px`;
2635
+ feedEl.style.zIndex = "9999";
2636
+ feedEl.style.margin = "0";
2637
+ feedEl.style.flex = "none";
2638
+ feedEl.style.aspectRatio = "unset";
2639
+ feedEl.style.maxHeight = "none";
2640
+ document.body.appendChild(feedEl);
2641
+ this.overlayEl.classList.remove("skp-active", "skp-feed-ready");
2642
+ const scaleX = targetRect.width / feedRect.width;
2643
+ const scaleY = targetRect.height / feedRect.height;
2644
+ const translateX = targetRect.left - feedRect.left;
2645
+ const translateY = targetRect.top - feedRect.top;
2646
+ const sourceRadius = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--skp-radius").trim()) || 12;
2647
+ const endRadius = sourceRadius / Math.min(scaleX, scaleY);
2648
+ feedEl.style.transformOrigin = "0 0";
2649
+ feedEl.style.overflow = "hidden";
2650
+ feedEl.style.scrollSnapType = "none";
2651
+ feedEl.getBoundingClientRect();
2652
+ feedEl.classList.add("skp-flip-animating");
2653
+ requestAnimationFrame(() => {
2654
+ feedEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`;
2655
+ feedEl.style.borderRadius = `${endRadius}px`;
2656
+ });
2657
+ await this._waitForTransitionEnd(feedEl, 450);
2658
+ feedEl.style.visibility = "hidden";
2659
+ feedEl.classList.remove("skp-flip-animating");
2660
+ ["position", "left", "top", "width", "height", "zIndex", "margin", "flex", "aspectRatio", "maxHeight", "transformOrigin", "overflow", "scrollSnapType", "transform", "borderRadius"].forEach((p) => feedEl.style[p] = "");
2661
+ const sidebarEl = feedWrapper.querySelector(".sk-sidebar");
2662
+ feedWrapper.insertBefore(feedEl, sidebarEl);
2663
+ feedEl.style.visibility = "";
2664
+ } else {
2665
+ this.overlayEl.classList.remove("skp-active", "skp-feed-ready");
2666
+ }
2667
+ if (this._onClose) {
2668
+ this._onClose("closed", {
2669
+ transferVideo: transferBack ? transferVideo : null,
2670
+ transferHls: transferBack ? transferHls : null,
2671
+ transferItemId: transferBack ? this._openItemId : null
2672
+ });
2673
+ this._onClose = null;
2674
+ }
2675
+ if (this.feedManager) {
2676
+ this.feedManager.destroy();
2677
+ this.feedManager = null;
2678
+ }
2679
+ if (this._escHandler) {
2680
+ document.removeEventListener("keydown", this._escHandler);
2681
+ this._escHandler = null;
2682
+ }
2683
+ this.overlayEl.classList.remove("skp-active", "skp-feed-ready", "skp-closing");
2684
+ }
2685
+ _waitForTransitionEnd(el, maxMs) {
2686
+ return new Promise((resolve) => {
2687
+ const timeout = setTimeout(resolve, maxMs);
2688
+ el.addEventListener("transitionend", function handler(e) {
2689
+ if (e.target === el) {
2690
+ clearTimeout(timeout);
2691
+ el.removeEventListener("transitionend", handler);
2692
+ resolve();
2693
+ }
2694
+ });
2695
+ });
2696
+ }
2697
+ }
2698
+ const _isPreview = typeof window !== "undefined" && new URLSearchParams(window.location.search).has("preview");
2699
+ class FeedView {
2700
+ /**
2701
+ * @param {HTMLElement} overlayEl The .skw-feed-overlay element
2702
+ * @param {import('../core/shortkit.js').ShortKit} shortKit
2703
+ * @param {object} [options]
2704
+ * @param {object} [options.storyboardCache]
2705
+ */
2706
+ constructor(overlayEl, shortKit, options = {}) {
2707
+ this.overlayEl = overlayEl;
2708
+ this._sk = shortKit;
2709
+ this.backdropEl = overlayEl.querySelector(".skw-feed-backdrop");
2710
+ this.feedEl = overlayEl.querySelector(".sk-feed") || document.getElementById("skwFeed");
2711
+ this.storyboardCache = options.storyboardCache || shortKit._storyboardCache;
2712
+ this.feedManager = null;
2713
+ this._isOpen = false;
2714
+ this._onClose = null;
2715
+ this._escHandler = null;
2716
+ this._openSlotIndex = -1;
2717
+ this._openItemId = null;
2718
+ }
2719
+ async open({ slotEl, video, hlsInstance, item, startIndex, items, cursor, hasMore, onClose, overlayConfig }) {
2720
+ if (this._isOpen) return;
2721
+ this._isOpen = true;
2722
+ this._onClose = onClose;
2723
+ this._openSlotIndex = startIndex;
2724
+ this._openItemId = item.id;
2725
+ const slotRect = slotEl.getBoundingClientRect();
2726
+ const slotRadius = parseFloat(getComputedStyle(slotEl).borderRadius) || 12;
2727
+ if (_isPreview) {
2728
+ const widgetEl = document.getElementById("widget");
2729
+ if (widgetEl) widgetEl.style.visibility = "hidden";
2730
+ }
2731
+ this.overlayEl.classList.add("skw-active");
2732
+ this.feedManager = new EmbeddedFeedManager(this.feedEl, this._sk, {
2733
+ idPrefix: "skw",
2734
+ config: overlayConfig ? { overlay: overlayConfig } : void 0
2735
+ });
2736
+ this.feedManager._feedDismiss = () => this.close();
2737
+ this.feedManager._cursor = cursor || null;
2738
+ this.feedManager._hasMore = hasMore !== false;
2739
+ await this.feedManager.initWithItems([...items], startIndex, { deferActivation: true });
2740
+ if (video && hlsInstance) {
2741
+ this.feedManager.pool.injectPlayer(item.id, video, hlsInstance, item.streamingUrl);
2742
+ video._skFeedTransfer = true;
2743
+ video.loop = true;
2744
+ }
2745
+ const feedItemEl = this.feedManager.itemEls.get(item.id);
2746
+ if (feedItemEl && video) {
2747
+ const container = feedItemEl.querySelector('[data-ref="videoContainer"]');
2748
+ video.style.opacity = "1";
2749
+ if (!container.contains(video)) container.insertBefore(video, container.firstChild);
2750
+ }
2751
+ const feedEl = this.feedEl;
2752
+ feedEl.getBoundingClientRect();
2753
+ const feedRect = feedEl.getBoundingClientRect();
2754
+ const targetRadius = parseFloat(getComputedStyle(feedEl).borderRadius) || 16;
2755
+ const scaleX = slotRect.width / feedRect.width;
2756
+ const scaleY = slotRect.height / feedRect.height;
2757
+ const translateX = slotRect.left - feedRect.left;
2758
+ const translateY = slotRect.top - feedRect.top;
2759
+ const startRadius = slotRadius / Math.min(scaleX, scaleY);
2760
+ feedEl.style.transformOrigin = "0 0";
2761
+ feedEl.style.overflow = "hidden";
2762
+ feedEl.style.scrollSnapType = "none";
2763
+ feedEl.style.borderRadius = `${startRadius}px`;
2764
+ feedEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`;
2765
+ feedEl.getBoundingClientRect();
2766
+ feedEl.classList.add("skw-flip-animating");
2767
+ requestAnimationFrame(() => {
2768
+ feedEl.style.transform = "none";
2769
+ feedEl.style.borderRadius = `${targetRadius}px`;
2770
+ });
2771
+ await this._waitForTransitionEnd(feedEl, 450);
2772
+ feedEl.classList.remove("skw-flip-animating");
2773
+ feedEl.style.transformOrigin = "";
2774
+ feedEl.style.overflow = "";
2775
+ feedEl.style.scrollSnapType = "";
2776
+ feedEl.style.transform = "";
2777
+ feedEl.style.borderRadius = "";
2778
+ this.feedManager.startObserver();
2779
+ this.feedManager._activateItem(item.id);
2780
+ this.overlayEl.classList.add("skw-feed-ready");
2781
+ this._escHandler = (e) => {
2782
+ if (e.key === "Escape") this.close();
2783
+ };
2784
+ document.addEventListener("keydown", this._escHandler);
2785
+ }
2786
+ dismiss() {
2787
+ this.close();
2788
+ }
2789
+ async close() {
2790
+ if (!this._isOpen) return;
2791
+ this._isOpen = false;
2792
+ if (_isPreview) {
2793
+ const widgetEl = document.getElementById("widget");
2794
+ if (widgetEl) widgetEl.style.visibility = "visible";
2795
+ }
2796
+ const feedEl = this.feedEl;
2797
+ const feedWrapper = feedEl.parentNode;
2798
+ const feedRect = feedEl.getBoundingClientRect();
2799
+ const activeItemId = this.feedManager?.activeItemId;
2800
+ const canTransfer = activeItemId === this._openItemId;
2801
+ let targetSlotRect = null;
2802
+ if (this._onClose) targetSlotRect = this._onClose("getSlotRect");
2803
+ if (feedRect && targetSlotRect) {
2804
+ if (this.feedManager?.activeItemId && !canTransfer) {
2805
+ this.feedManager._deactivateItem(this.feedManager.activeItemId);
2806
+ }
2807
+ feedEl.style.position = "fixed";
2808
+ feedEl.style.left = `${feedRect.left}px`;
2809
+ feedEl.style.top = `${feedRect.top}px`;
2810
+ feedEl.style.width = `${feedRect.width}px`;
2811
+ feedEl.style.height = `${feedRect.height}px`;
2812
+ feedEl.style.zIndex = String(getComputedStyle(this.overlayEl).zIndex || 9999);
2813
+ feedEl.style.margin = "0";
2814
+ feedEl.style.flex = "none";
2815
+ feedEl.style.aspectRatio = "unset";
2816
+ feedEl.style.maxHeight = "none";
2817
+ document.body.appendChild(feedEl);
2818
+ this.overlayEl.classList.remove("skw-active", "skw-feed-ready");
2819
+ const slotRadius = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--skw-radius").trim()) || 12;
2820
+ const scaleX = targetSlotRect.width / feedRect.width;
2821
+ const scaleY = targetSlotRect.height / feedRect.height;
2822
+ const translateX = targetSlotRect.left - feedRect.left;
2823
+ const translateY = targetSlotRect.top - feedRect.top;
2824
+ const endRadius = slotRadius / Math.min(scaleX, scaleY);
2825
+ feedEl.style.transformOrigin = "0 0";
2826
+ feedEl.style.overflow = "hidden";
2827
+ feedEl.style.scrollSnapType = "none";
2828
+ feedEl.getBoundingClientRect();
2829
+ feedEl.classList.add("skw-flip-animating");
2830
+ requestAnimationFrame(() => {
2831
+ feedEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})`;
2832
+ feedEl.style.borderRadius = `${endRadius}px`;
2833
+ });
2834
+ await this._waitForTransitionEnd(feedEl, 450);
2835
+ let transferVideo = null;
2836
+ let transferHls = null;
2837
+ if (canTransfer) {
2838
+ const ejected = this.feedManager?.pool.ejectPlayer(this._openItemId);
2839
+ if (ejected) {
2840
+ transferVideo = ejected.video;
2841
+ transferHls = ejected.hls;
2842
+ }
2843
+ }
2844
+ feedEl.style.visibility = "hidden";
2845
+ feedEl.classList.remove("skw-flip-animating");
2846
+ feedEl.style.position = "";
2847
+ feedEl.style.left = "";
2848
+ feedEl.style.top = "";
2849
+ feedEl.style.width = "";
2850
+ feedEl.style.height = "";
2851
+ feedEl.style.zIndex = "";
2852
+ feedEl.style.margin = "";
2853
+ feedEl.style.flex = "";
2854
+ feedEl.style.aspectRatio = "";
2855
+ feedEl.style.maxHeight = "";
2856
+ feedEl.style.transformOrigin = "";
2857
+ feedEl.style.overflow = "";
2858
+ feedEl.style.scrollSnapType = "";
2859
+ feedEl.style.transform = "";
2860
+ feedEl.style.borderRadius = "";
2861
+ feedWrapper.insertBefore(feedEl, feedWrapper.firstChild);
2862
+ feedEl.style.visibility = "";
2863
+ if (this._onClose) {
2864
+ this._onClose("closed", {
2865
+ transferVideo,
2866
+ transferHls,
2867
+ transferItemId: transferVideo ? this._openItemId : null
2868
+ });
2869
+ this._onClose = null;
2870
+ }
2871
+ } else {
2872
+ this.overlayEl.classList.remove("skw-active", "skw-feed-ready");
2873
+ if (this._onClose) {
2874
+ let transferVideo = null;
2875
+ let transferHls = null;
2876
+ if (canTransfer) {
2877
+ const ejected = this.feedManager?.pool.ejectPlayer(this._openItemId);
2878
+ if (ejected) {
2879
+ transferVideo = ejected.video;
2880
+ transferHls = ejected.hls;
2881
+ }
2882
+ }
2883
+ this._onClose("closed", {
2884
+ transferVideo,
2885
+ transferHls,
2886
+ transferItemId: transferVideo ? this._openItemId : null
2887
+ });
2888
+ this._onClose = null;
2889
+ }
2890
+ }
2891
+ if (this.feedManager) {
2892
+ if (this.feedManager._keyHandler) document.removeEventListener("keydown", this.feedManager._keyHandler);
2893
+ if (this.feedManager._speedCloseHandler) document.removeEventListener("click", this.feedManager._speedCloseHandler);
2894
+ if (this.feedManager._visHandler) document.removeEventListener("visibilitychange", this.feedManager._visHandler);
2895
+ this.feedManager.destroy();
2896
+ this.feedManager = null;
2897
+ }
2898
+ if (this._escHandler) {
2899
+ document.removeEventListener("keydown", this._escHandler);
2900
+ this._escHandler = null;
2901
+ }
2902
+ this.overlayEl.classList.remove("skw-active", "skw-feed-ready", "skw-closing");
2903
+ }
2904
+ _waitForTransitionEnd(el, maxMs) {
2905
+ return new Promise((resolve) => {
2906
+ const timeout = setTimeout(resolve, maxMs);
2907
+ el.addEventListener("transitionend", function handler(e) {
2908
+ if (e.target === el) {
2909
+ clearTimeout(timeout);
2910
+ el.removeEventListener("transitionend", handler);
2911
+ resolve();
2912
+ }
2913
+ });
2914
+ });
2915
+ }
2916
+ }
2917
+ const _isIOSSafari = typeof navigator !== "undefined" && (/iPad|iPhone|iPod/.test(navigator.userAgent) || navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
2918
+ class WidgetPlayerPool {
2919
+ static PRELOAD_HLS_CONFIG = { startLevel: 0, capLevelToPlayerSize: true, maxBufferLength: 2, maxMaxBufferLength: 4 };
2920
+ static ACTIVE_HLS_CONFIG = { startLevel: -1, capLevelToPlayerSize: true, maxBufferLength: 8, maxMaxBufferLength: 15 };
2921
+ constructor(poolSize = 2, { Hls: HlsClass } = {}) {
2922
+ this._Hls = HlsClass ?? (typeof Hls !== "undefined" ? Hls : null);
2923
+ this.players = Array.from({ length: poolSize }, () => this._createPlayer());
2924
+ this.assignments = /* @__PURE__ */ new Map();
2925
+ this.hlsInstances = /* @__PURE__ */ new Map();
2926
+ }
2927
+ _createPlayer() {
2928
+ const video = document.createElement("video");
2929
+ video.playsInline = true;
2930
+ video.preload = _isIOSSafari ? "metadata" : "auto";
2931
+ video.loop = false;
2932
+ video.muted = true;
2933
+ video.setAttribute("webkit-playsinline", "");
2934
+ return video;
2935
+ }
2936
+ acquire(itemId) {
2937
+ if (this.assignments.has(itemId)) return this.assignments.get(itemId);
2938
+ const assigned = new Set(this.assignments.values());
2939
+ let player = this.players.find((p) => !assigned.has(p));
2940
+ if (!player) {
2941
+ const oldest = this.assignments.keys().next().value;
2942
+ player = this.assignments.get(oldest);
2943
+ player.pause();
2944
+ this._destroyHls(oldest);
2945
+ player.removeAttribute("src");
2946
+ player.load();
2947
+ if (player.parentNode) player.parentNode.removeChild(player);
2948
+ this.assignments.delete(oldest);
2949
+ }
2950
+ this.assignments.set(itemId, player);
2951
+ return player;
2952
+ }
2953
+ attachStream(itemId, streamingUrl, { isActive = false } = {}) {
2954
+ const player = this.assignments.get(itemId);
2955
+ if (!player) return;
2956
+ if (player._skCurrentUrl === streamingUrl) return;
2957
+ this._destroyHls(itemId);
2958
+ const HlsClass = this._Hls;
2959
+ if (streamingUrl.includes(".m3u8") && HlsClass && HlsClass.isSupported()) {
2960
+ const hlsConfig = isActive ? WidgetPlayerPool.ACTIVE_HLS_CONFIG : WidgetPlayerPool.PRELOAD_HLS_CONFIG;
2961
+ const hls = new HlsClass(hlsConfig);
2962
+ hls.loadSource(streamingUrl);
2963
+ hls.attachMedia(player);
2964
+ hls.on(HlsClass.Events.ERROR, (_event, data) => {
2965
+ if (!data.fatal) return;
2966
+ switch (data.type) {
2967
+ case HlsClass.ErrorTypes.MEDIA_ERROR:
2968
+ hls.recoverMediaError();
2969
+ break;
2970
+ case HlsClass.ErrorTypes.NETWORK_ERROR:
2971
+ setTimeout(() => {
2972
+ if (!hls.destroyed) hls.startLoad();
2973
+ }, 2e3);
2974
+ break;
2975
+ default:
2976
+ hls.destroy();
2977
+ break;
2978
+ }
2979
+ });
2980
+ this.hlsInstances.set(itemId, hls);
2981
+ } else {
2982
+ player.src = streamingUrl;
2983
+ }
2984
+ player._skCurrentUrl = streamingUrl;
2985
+ }
2986
+ promoteToActive(itemId) {
2987
+ const hls = this.hlsInstances.get(itemId);
2988
+ if (hls) {
2989
+ hls.config.maxBufferLength = WidgetPlayerPool.ACTIVE_HLS_CONFIG.maxBufferLength;
2990
+ hls.config.maxMaxBufferLength = WidgetPlayerPool.ACTIVE_HLS_CONFIG.maxMaxBufferLength;
2991
+ hls.autoLevelCapping = -1;
2992
+ hls.nextAutoLevel = -1;
2993
+ }
2994
+ }
2995
+ _destroyHls(itemId) {
2996
+ const hls = this.hlsInstances.get(itemId);
2997
+ if (hls) {
2998
+ hls.destroy();
2999
+ this.hlsInstances.delete(itemId);
3000
+ }
3001
+ }
3002
+ release(itemId) {
3003
+ const player = this.assignments.get(itemId);
3004
+ if (player) {
3005
+ player.pause();
3006
+ this._destroyHls(itemId);
3007
+ player._skCurrentUrl = null;
3008
+ if (player.parentNode) player.parentNode.removeChild(player);
3009
+ this.assignments.delete(itemId);
3010
+ }
3011
+ }
3012
+ getPlayer(itemId) {
3013
+ return this.assignments.get(itemId) || null;
3014
+ }
3015
+ /** Remove video+HLS from pool WITHOUT destroying or pausing. Returns {video, hls} or null. */
3016
+ ejectPlayer(itemId) {
3017
+ const video = this.assignments.get(itemId);
3018
+ const hls = this.hlsInstances.get(itemId);
3019
+ if (!video) return null;
3020
+ if (video.parentNode) video.parentNode.removeChild(video);
3021
+ this.assignments.delete(itemId);
3022
+ this.hlsInstances.delete(itemId);
3023
+ const idx = this.players.indexOf(video);
3024
+ if (idx >= 0) this.players[idx] = this._createPlayer();
3025
+ return { video, hls };
3026
+ }
3027
+ /** Re-inject a previously ejected video+HLS back into the pool. */
3028
+ injectPlayer(itemId, video, hls, streamingUrl) {
3029
+ if (this.assignments.has(itemId)) this.release(itemId);
3030
+ this.assignments.set(itemId, video);
3031
+ if (hls) this.hlsInstances.set(itemId, hls);
3032
+ video._skCurrentUrl = streamingUrl || null;
3033
+ if (!this.players.includes(video)) this.players.push(video);
3034
+ }
3035
+ }
3036
+ class WidgetManager {
3037
+ /**
3038
+ * @param {HTMLElement} containerEl The widget container element
3039
+ * @param {import('../core/shortkit.js').ShortKit} shortKit
3040
+ * @param {object} [options]
3041
+ * @param {object} [options.config] Widget configuration (from createWidgetConfig)
3042
+ */
3043
+ constructor(containerEl, shortKit, options = {}) {
3044
+ this._sk = shortKit;
3045
+ const cfg = options.config || {};
3046
+ this.config = {
3047
+ count: cfg.cardCount || 3,
3048
+ visibleCount: cfg.visibleCount || cfg.cardCount || 3,
3049
+ scrollable: cfg.scrollable || false,
3050
+ autoRotate: cfg.autoRotate !== void 0 ? cfg.autoRotate : true,
3051
+ rotationPause: cfg.rotationInterval || 500,
3052
+ highlight: cfg.highlight === true,
3053
+ slotScale: cfg.slotScale || 1.4,
3054
+ slotGap: cfg.cardSpacing ?? 10,
3055
+ slotRadius: cfg.cornerRadius ?? 12,
3056
+ clickAction: cfg.clickAction || "feed",
3057
+ autoplay: cfg.autoplay !== false,
3058
+ // Custom overlay callback (accepts either key name)
3059
+ overlay: cfg.overlay || cfg.cardOverlay || null
3060
+ };
3061
+ this.container = containerEl;
3062
+ this.trackEl = containerEl.querySelector(".skw-track") || containerEl.querySelector('[data-ref="track"]');
3063
+ this.dotsEl = containerEl.querySelector(".skw-dots") || containerEl.querySelector('[data-ref="dots"]');
3064
+ this.pool = new WidgetPlayerPool(this.config.count + 1);
3065
+ this.storyboardCache = shortKit._storyboardCache;
3066
+ this.items = [];
3067
+ this.slotEls = [];
3068
+ this.activeIndex = -1;
3069
+ this.rotationTimer = null;
3070
+ this.isModalOpen = false;
3071
+ this._slotWidth = 200;
3072
+ this._hasActivated = false;
3073
+ this._wasPlaying = false;
3074
+ this._isHovering = false;
3075
+ this._feedOverlayEl = null;
3076
+ this.feedView = null;
3077
+ this._slotOverlays = /* @__PURE__ */ new Map();
3078
+ }
3079
+ async init() {
3080
+ const fetchCount = this.config.scrollable ? this.config.count * 3 : this.config.count;
3081
+ const result = await this._sk._apiClient.fetchFeed({ limit: Math.max(fetchCount, 10) });
3082
+ this.items = this._mapItems(result.items);
3083
+ this._cursor = result.nextCursor;
3084
+ this._hasMore = result.hasMore;
3085
+ this._computeSlotWidth();
3086
+ this._buildSlots();
3087
+ this._buildDots();
3088
+ this.container.style.setProperty("--skw-slot-gap", `${this.config.slotGap}px`);
3089
+ this.container.style.setProperty("--skw-radius", `${this.config.slotRadius}px`);
3090
+ this.container.style.setProperty("--skw-slot-scale", this.config.slotScale);
3091
+ if (this.config.highlight) {
3092
+ this.container.classList.add("skw-highlight");
3093
+ }
3094
+ const needsScroll = this.config.scrollable || this.config.count > this.config.visibleCount;
3095
+ if (needsScroll) {
3096
+ this.container.classList.add("skw-scrollable");
3097
+ this._setupCarousel();
3098
+ }
3099
+ this._setupResize();
3100
+ this._setupVisibility();
3101
+ this._prewarmSlots();
3102
+ if (this.items.length > 0) {
3103
+ if (this.config.autoplay) {
3104
+ this._activateSlot(0);
3105
+ } else {
3106
+ this.activeIndex = 0;
3107
+ this._hasActivated = true;
3108
+ this.slotEls[0]?.classList.add("skw-active");
3109
+ const dots = this.dotsEl.querySelectorAll(".skw-dot");
3110
+ if (dots[0]) dots[0].classList.add("skw-dot-active");
3111
+ }
3112
+ }
3113
+ }
3114
+ _mapItems(rawItems) {
3115
+ return rawItems.filter((item) => !item.type || item.type === "content").map((item) => ({
3116
+ id: item.id,
3117
+ streamingUrl: item.streamingUrl,
3118
+ thumbnailUrl: item.thumbnailUrl,
3119
+ title: item.title || "",
3120
+ description: item.description || "",
3121
+ author: item.author || "",
3122
+ section: item.customMetadata?.category?.toUpperCase() || item.customMetadata?.section?.toUpperCase() || "",
3123
+ articleUrl: item.articleUrl || item.publisherUrl || null,
3124
+ duration: item.duration || 0,
3125
+ captionTracks: item.captionTracks || []
3126
+ }));
3127
+ }
3128
+ /** Pre-attach HLS streams and prime decoders for all visible slots. */
3129
+ _prewarmSlots() {
3130
+ const count = Math.min(this.slotEls.length, this.items.length);
3131
+ const warmCount = _isIOSSafari ? Math.min(count, 2) : count;
3132
+ for (let i = 0; i < warmCount; i++) {
3133
+ const item = this.items[i];
3134
+ const slotEl = this.slotEls[i];
3135
+ const player = this.pool.acquire(item.id);
3136
+ this.pool.attachStream(item.id, item.streamingUrl);
3137
+ const thumbContainer = slotEl.querySelector(".skw-slot-thumb");
3138
+ player.style.opacity = "0";
3139
+ if (!thumbContainer.contains(player)) {
3140
+ thumbContainer.appendChild(player);
3141
+ }
3142
+ player.muted = true;
3143
+ player.currentTime = 0;
3144
+ if (this.config.autoplay) {
3145
+ if (_isIOSSafari && i !== 0) {
3146
+ player.preload = "auto";
3147
+ } else {
3148
+ const p = player.play();
3149
+ if (p) p.then(() => {
3150
+ if (i !== 0) player.pause();
3151
+ }).catch(() => {
3152
+ });
3153
+ }
3154
+ }
3155
+ }
3156
+ }
3157
+ _computeSlotWidth() {
3158
+ const trackWidth = this.trackEl.clientWidth || this.container.clientWidth;
3159
+ const trackStyle = getComputedStyle(this.trackEl);
3160
+ const padding = parseFloat(trackStyle.paddingLeft || 0) + parseFloat(trackStyle.paddingRight || 0);
3161
+ const gapValue = this.config.slotGap;
3162
+ const scale = this.config.slotScale;
3163
+ const totalGap = this.config.count > 1 ? (this.config.count - 1) * gapValue : 0;
3164
+ this._slotWidth = Math.max(100, (trackWidth - padding - totalGap) / (this.config.count * scale));
3165
+ }
3166
+ _buildSlots() {
3167
+ this.trackEl.innerHTML = "";
3168
+ this.slotEls = [];
3169
+ const visibleCount = this.config.scrollable ? this.items.length : Math.min(this.config.count, this.items.length);
3170
+ for (let i = 0; i < visibleCount; i++) {
3171
+ const item = this.items[i];
3172
+ const slot = this._createSlotEl(item, i);
3173
+ this.trackEl.appendChild(slot);
3174
+ this.slotEls.push(slot);
3175
+ }
3176
+ }
3177
+ _createSlotEl(item, index) {
3178
+ const slot = document.createElement("div");
3179
+ slot.className = "skw-slot";
3180
+ slot.dataset.index = index;
3181
+ slot.style.setProperty("--skw-slot-width", `${this._slotWidth}px`);
3182
+ const thumbStyle = item.thumbnailUrl ? `background-image: url('${item.thumbnailUrl}');` : "";
3183
+ slot.innerHTML = `<div class="skw-slot-thumb" style="${thumbStyle}"></div>`;
3184
+ const overlayEl = document.createElement("div");
3185
+ overlayEl.className = "skw-slot-overlay";
3186
+ overlayEl.dataset.ref = "customOverlay";
3187
+ slot.appendChild(overlayEl);
3188
+ this._slotOverlays.set(index, { el: overlayEl, unsub: null });
3189
+ slot.addEventListener("click", () => this._handleSlotClick(index));
3190
+ slot.addEventListener("mouseenter", () => {
3191
+ if (index === this.activeIndex) return;
3192
+ this._isHovering = true;
3193
+ clearTimeout(this.rotationTimer);
3194
+ this._activateSlot(index, { scroll: false });
3195
+ });
3196
+ slot.addEventListener("mouseleave", () => {
3197
+ this._isHovering = false;
3198
+ if (this.config.autoRotate && !this.isModalOpen) {
3199
+ const player = this.pool.getPlayer(this.items[this.activeIndex]?.id);
3200
+ if (player && player.ended) {
3201
+ this._onPreviewEnded();
3202
+ }
3203
+ }
3204
+ });
3205
+ return slot;
3206
+ }
3207
+ _buildDots() {
3208
+ this.dotsEl.innerHTML = "";
3209
+ const dotCount = this.config.scrollable ? this.items.length : Math.min(this.config.count, this.items.length);
3210
+ for (let i = 0; i < dotCount; i++) {
3211
+ const dot = document.createElement("button");
3212
+ dot.className = "skw-dot";
3213
+ dot.setAttribute("aria-label", `Go to video ${i + 1}`);
3214
+ dot.addEventListener("click", (e) => {
3215
+ e.stopPropagation();
3216
+ clearTimeout(this.rotationTimer);
3217
+ this._activateSlot(i);
3218
+ });
3219
+ this.dotsEl.appendChild(dot);
3220
+ }
3221
+ }
3222
+ _activateSlot(index, { scroll = true } = {}) {
3223
+ if (index === this.activeIndex && this._hasActivated) return;
3224
+ if (this._hasActivated) {
3225
+ this._deactivateSlot(this.activeIndex);
3226
+ }
3227
+ this.activeIndex = index;
3228
+ this._hasActivated = true;
3229
+ const item = this.items[index];
3230
+ const slotEl = this.slotEls[index];
3231
+ this.slotEls.forEach((el, i) => el.classList.toggle("skw-active", i === index));
3232
+ const dots = this.dotsEl.querySelectorAll(".skw-dot");
3233
+ dots.forEach((dot, i) => dot.classList.toggle("skw-dot-active", i === index));
3234
+ const player = this.pool.acquire(item.id);
3235
+ const isTransferBack = !!player._skFeedTransfer;
3236
+ delete player._skFeedTransfer;
3237
+ player.loop = false;
3238
+ player.muted = true;
3239
+ if (!isTransferBack && (player.ended || player.currentTime === 0)) {
3240
+ player.currentTime = 0;
3241
+ }
3242
+ if (!isTransferBack) player.style.opacity = "0";
3243
+ const thumbContainer = slotEl.querySelector(".skw-slot-thumb");
3244
+ if (!thumbContainer.contains(player)) {
3245
+ thumbContainer.appendChild(player);
3246
+ }
3247
+ this.pool.attachStream(item.id, item.streamingUrl, { isActive: true });
3248
+ const REVEAL_EVENTS = ["loadeddata", "seeked", "canplay", "timeupdate"];
3249
+ const reveal = () => {
3250
+ if (player._skwRevealed === item.id) return;
3251
+ if (player.readyState < 2) return;
3252
+ player._skwRevealed = item.id;
3253
+ player.style.opacity = "1";
3254
+ REVEAL_EVENTS.forEach((e) => player.removeEventListener(e, reveal));
3255
+ };
3256
+ if (isTransferBack || player.readyState >= 2 && !player.seeking && player._skCurrentUrl === item.streamingUrl) {
3257
+ player._skwRevealed = item.id;
3258
+ player.style.opacity = "1";
3259
+ } else {
3260
+ REVEAL_EVENTS.forEach((e) => player.addEventListener(e, reveal));
3261
+ }
3262
+ player.onended = () => this._onPreviewEnded();
3263
+ player.play().catch(() => {
3264
+ });
3265
+ if (this.config.scrollable && scroll) {
3266
+ slotEl.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
3267
+ }
3268
+ if (this.config.overlay) {
3269
+ this._invokeSlotOverlay(index, item);
3270
+ }
3271
+ }
3272
+ _deactivateSlot(index) {
3273
+ const item = this.items[index];
3274
+ if (!item) return;
3275
+ const player = this.pool.getPlayer(item.id);
3276
+ if (player) {
3277
+ player.pause();
3278
+ player.onended = null;
3279
+ player.style.opacity = "0";
3280
+ player._skwRevealed = null;
3281
+ }
3282
+ this._clearSlotOverlay(index);
3283
+ }
3284
+ /** Invoke the developer overlay callback for a widget slot. */
3285
+ _invokeSlotOverlay(index, item) {
3286
+ const entry = this._slotOverlays.get(index);
3287
+ if (!entry) return;
3288
+ if (!this.config.overlay) return;
3289
+ this._clearSlotOverlay(index);
3290
+ const player = this._sk.player;
3291
+ const listeners = [];
3292
+ const scopedPlayer = {
3293
+ get isMuted() {
3294
+ return player.isMuted;
3295
+ },
3296
+ get playbackRate() {
3297
+ return player.playbackRate;
3298
+ },
3299
+ get currentTime() {
3300
+ return player.time.current;
3301
+ },
3302
+ get duration() {
3303
+ return player.time.duration;
3304
+ },
3305
+ get captionsEnabled() {
3306
+ return player.captionsEnabled;
3307
+ },
3308
+ on(event, fn) {
3309
+ player.on(event, fn);
3310
+ listeners.push({ event, fn });
3311
+ },
3312
+ off(event, fn) {
3313
+ player.off(event, fn);
3314
+ const idx = listeners.findIndex((l) => l.event === event && l.fn === fn);
3315
+ if (idx >= 0) listeners.splice(idx, 1);
3316
+ }
3317
+ };
3318
+ entry.unsub = () => {
3319
+ for (const { event, fn } of listeners) player.off(event, fn);
3320
+ listeners.length = 0;
3321
+ };
3322
+ try {
3323
+ this.config.overlay(entry.el, { item, player: scopedPlayer });
3324
+ } catch (e) {
3325
+ }
3326
+ }
3327
+ /** Clear a slot's overlay content and unsubscribe tracked listeners. */
3328
+ _clearSlotOverlay(index) {
3329
+ const entry = this._slotOverlays.get(index);
3330
+ if (!entry) return;
3331
+ if (entry.unsub) {
3332
+ entry.unsub();
3333
+ entry.unsub = null;
3334
+ }
3335
+ entry.el.innerHTML = "";
3336
+ }
3337
+ _onPreviewEnded() {
3338
+ if (!this.config.autoRotate || this.isModalOpen || this._isHovering) return;
3339
+ this.rotationTimer = setTimeout(() => {
3340
+ this._advanceRotation();
3341
+ }, this.config.rotationPause);
3342
+ }
3343
+ _advanceRotation() {
3344
+ const nextIndex = (this.activeIndex + 1) % this.slotEls.length;
3345
+ this._activateSlot(nextIndex);
3346
+ }
3347
+ _setupCarousel() {
3348
+ let scrollTimer;
3349
+ this.trackEl.addEventListener("scroll", () => {
3350
+ clearTimeout(scrollTimer);
3351
+ scrollTimer = setTimeout(() => this._onCarouselScrollEnd(), 100);
3352
+ }, { passive: true });
3353
+ }
3354
+ _onCarouselScrollEnd() {
3355
+ if (this._isHovering) return;
3356
+ const trackRect = this.trackEl.getBoundingClientRect();
3357
+ const centerX = trackRect.left + trackRect.width / 2;
3358
+ let closestIndex = 0;
3359
+ let closestDist = Infinity;
3360
+ this.slotEls.forEach((el, i) => {
3361
+ const rect = el.getBoundingClientRect();
3362
+ const slotCenter = rect.left + rect.width / 2;
3363
+ const dist = Math.abs(slotCenter - centerX);
3364
+ if (dist < closestDist) {
3365
+ closestDist = dist;
3366
+ closestIndex = i;
3367
+ }
3368
+ });
3369
+ if (closestIndex !== this.activeIndex) {
3370
+ clearTimeout(this.rotationTimer);
3371
+ this._activateSlot(closestIndex);
3372
+ }
3373
+ if (closestIndex >= this.items.length - 3) {
3374
+ this._loadMore();
3375
+ }
3376
+ }
3377
+ async _loadMore() {
3378
+ if (this._loadingMore || !this._hasMore) return;
3379
+ this._loadingMore = true;
3380
+ try {
3381
+ const result = await this._sk._apiClient.fetchFeed({
3382
+ limit: 10,
3383
+ cursor: this._cursor
3384
+ });
3385
+ this._cursor = result.nextCursor;
3386
+ this._hasMore = result.hasMore;
3387
+ const newItems = this._mapItems(result.items);
3388
+ for (const item of newItems) {
3389
+ if (this.items.some((existing) => existing.id === item.id)) continue;
3390
+ this.items.push(item);
3391
+ const i = this.items.length - 1;
3392
+ const slot = this._createSlotEl(item, i);
3393
+ this.trackEl.appendChild(slot);
3394
+ this.slotEls.push(slot);
3395
+ const dot = document.createElement("button");
3396
+ dot.className = "skw-dot";
3397
+ dot.setAttribute("aria-label", `Go to video ${i + 1}`);
3398
+ dot.addEventListener("click", (e) => {
3399
+ e.stopPropagation();
3400
+ clearTimeout(this.rotationTimer);
3401
+ this._activateSlot(i);
3402
+ });
3403
+ this.dotsEl.appendChild(dot);
3404
+ }
3405
+ } finally {
3406
+ this._loadingMore = false;
3407
+ }
3408
+ }
3409
+ _setupResize() {
3410
+ this._resizeObserver = new ResizeObserver(() => {
3411
+ this._computeSlotWidth();
3412
+ this.slotEls.forEach((el) => {
3413
+ el.style.setProperty("--skw-slot-width", `${this._slotWidth}px`);
3414
+ });
3415
+ });
3416
+ this._resizeObserver.observe(this.container);
3417
+ }
3418
+ _setupVisibility() {
3419
+ this._visHandler = () => {
3420
+ if (document.hidden) {
3421
+ clearTimeout(this.rotationTimer);
3422
+ const item = this.items[this.activeIndex];
3423
+ if (item) {
3424
+ const player = this.pool.getPlayer(item.id);
3425
+ if (player && !player.paused) {
3426
+ this._wasPlaying = true;
3427
+ player.pause();
3428
+ }
3429
+ }
3430
+ } else {
3431
+ if (this._wasPlaying && !this.isModalOpen) {
3432
+ const item = this.items[this.activeIndex];
3433
+ if (item) {
3434
+ const player = this.pool.getPlayer(item.id);
3435
+ if (player) player.play().catch(() => {
3436
+ });
3437
+ }
3438
+ this._wasPlaying = false;
3439
+ }
3440
+ }
3441
+ };
3442
+ document.addEventListener("visibilitychange", this._visHandler);
3443
+ }
3444
+ destroy() {
3445
+ clearTimeout(this.rotationTimer);
3446
+ if (this._hasActivated) {
3447
+ this._deactivateSlot(this.activeIndex);
3448
+ }
3449
+ for (const [itemId] of this.pool.assignments) {
3450
+ this.pool.release(itemId);
3451
+ }
3452
+ if (this._resizeObserver) {
3453
+ this._resizeObserver.disconnect();
3454
+ this._resizeObserver = null;
3455
+ }
3456
+ if (this._visHandler) {
3457
+ document.removeEventListener("visibilitychange", this._visHandler);
3458
+ this._visHandler = null;
3459
+ }
3460
+ if (this._feedOverlayEl && this._feedOverlayEl.parentNode) {
3461
+ this._feedOverlayEl.parentNode.removeChild(this._feedOverlayEl);
3462
+ this._feedOverlayEl = null;
3463
+ }
3464
+ this.trackEl.innerHTML = "";
3465
+ this.dotsEl.innerHTML = "";
3466
+ this.container.classList.remove("skw-scrollable", "skw-highlight");
3467
+ this.container.removeAttribute("style");
3468
+ this.items = [];
3469
+ this.slotEls = [];
3470
+ this.activeIndex = -1;
3471
+ this._hasActivated = false;
3472
+ }
3473
+ _handleSlotClick(index) {
3474
+ const action = this.config.clickAction;
3475
+ if (action === "none") return;
3476
+ if (action === "article") {
3477
+ const item = this.items[index];
3478
+ if (item?.articleUrl) window.open(item.articleUrl, "_blank", "noopener");
3479
+ return;
3480
+ }
3481
+ if (action === "mute") {
3482
+ const item = this.items[index];
3483
+ const player = this.pool.getPlayer(item?.id);
3484
+ if (player) player.muted = !player.muted;
3485
+ return;
3486
+ }
3487
+ this._openFeedOverlay(index);
3488
+ }
3489
+ _ensureFeedView() {
3490
+ if (this.feedView) return;
3491
+ const overlay = document.createElement("div");
3492
+ overlay.className = "skw-feed-overlay";
3493
+ overlay.innerHTML = `
3494
+ <div class="skw-feed-backdrop"></div>
3495
+ <div class="sk-page">
3496
+ <div class="sk-feed-wrapper">
3497
+ <div class="sk-feed"></div>
3498
+ </div>
3499
+ </div>
3500
+ `;
3501
+ document.body.appendChild(overlay);
3502
+ this._feedOverlayEl = overlay;
3503
+ this.feedView = new FeedView(this._feedOverlayEl, this._sk, {
3504
+ storyboardCache: this.storyboardCache
3505
+ });
3506
+ }
3507
+ _openFeedOverlay(index) {
3508
+ this._ensureFeedView();
3509
+ if (!this.feedView) return;
3510
+ clearTimeout(this.rotationTimer);
3511
+ this.isModalOpen = true;
3512
+ const item = this.items[index];
3513
+ const activeItem = this.items[this.activeIndex];
3514
+ if (activeItem && activeItem.id !== item.id) {
3515
+ const player = this.pool.getPlayer(activeItem.id);
3516
+ if (player) {
3517
+ player.pause();
3518
+ player.onended = null;
3519
+ }
3520
+ }
3521
+ const clickedPlayer = this.pool.getPlayer(item.id);
3522
+ if (clickedPlayer) clickedPlayer.onended = null;
3523
+ const slotEl = this.slotEls[index];
3524
+ const ejected = this.pool.ejectPlayer(item.id);
3525
+ this.feedView.open({
3526
+ slotEl,
3527
+ video: ejected?.video || null,
3528
+ hlsInstance: ejected?.hls || null,
3529
+ item,
3530
+ overlayConfig: this.config.overlay || null,
3531
+ startIndex: index,
3532
+ items: [...this.items],
3533
+ cursor: this._cursor,
3534
+ hasMore: this._hasMore,
3535
+ onClose: (action, data) => {
3536
+ if (action === "getSlotRect") {
3537
+ if (this.activeIndex !== index) {
3538
+ this._deactivateSlot(this.activeIndex);
3539
+ this.slotEls.forEach((el, i) => el.classList.toggle("skw-active", i === index));
3540
+ const dots = this.dotsEl.querySelectorAll(".skw-dot");
3541
+ dots.forEach((dot, i) => dot.classList.toggle("skw-dot-active", i === index));
3542
+ this.activeIndex = index;
3543
+ }
3544
+ const targetSlot = this.slotEls[index];
3545
+ if (targetSlot) {
3546
+ targetSlot.scrollIntoView({ behavior: "instant", inline: "center", block: "nearest" });
3547
+ return targetSlot.getBoundingClientRect();
3548
+ }
3549
+ return null;
3550
+ }
3551
+ if (action === "closed") {
3552
+ this.isModalOpen = false;
3553
+ let video = data?.transferVideo;
3554
+ let hls = data?.transferHls;
3555
+ if (!video && this.feedView.feedManager) {
3556
+ const ejected2 = this.feedView.feedManager.pool.ejectPlayer(item.id);
3557
+ if (ejected2) {
3558
+ video = ejected2.video;
3559
+ hls = ejected2.hls;
3560
+ }
3561
+ }
3562
+ if (video) {
3563
+ video.loop = false;
3564
+ video.muted = true;
3565
+ video._skFeedTransfer = true;
3566
+ this.pool.injectPlayer(item.id, video, hls, item.streamingUrl);
3567
+ const slotThumb = this.slotEls[index]?.querySelector(".skw-slot-thumb");
3568
+ if (slotThumb && video) {
3569
+ video.style.opacity = "1";
3570
+ if (!slotThumb.contains(video)) slotThumb.appendChild(video);
3571
+ }
3572
+ }
3573
+ this._hasActivated = false;
3574
+ this._activateSlot(this.activeIndex);
3575
+ }
3576
+ }
3577
+ });
3578
+ }
3579
+ }
3580
+ let injected = false;
3581
+ const CSS = `
3582
+ /* ShortKit SDK — functional layout CSS (injected at runtime) */
3583
+
3584
+ /* Feed page layout */
3585
+ .sk-page{display:flex;justify-content:center;align-items:center;height:100%;background:#000;padding:12px 16px;overflow:hidden}
3586
+ .sk-feed-wrapper{display:flex;align-items:center;gap:12px;height:100%;max-height:100%}
3587
+
3588
+ /* Feed container */
3589
+ .sk-feed{position:relative;height:100%;aspect-ratio:9/16;overflow-y:scroll;scroll-snap-type:y mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;background:#000;border-radius:16px}
3590
+ .sk-feed::-webkit-scrollbar{display:none}
3591
+
3592
+ /* Feed item */
3593
+ .sk-feed-item{position:relative;height:calc(100% - 6px);width:100%;scroll-snap-align:start;scroll-snap-stop:always;overflow:hidden;background:#0a0a0a;border-radius:16px;margin-bottom:6px}
3594
+
3595
+ /* Video container */
3596
+ .sk-video-container{position:absolute;inset:0;width:100%;height:100%;overflow:hidden;background-size:cover;background-position:center}
3597
+ .sk-video-container video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;transition:opacity .15s ease}
3598
+
3599
+ /* Tap zone */
3600
+ .sk-tap-zone{position:absolute;inset:0;z-index:3;cursor:pointer}
3601
+
3602
+ /* Overlay container */
3603
+ .sk-overlay{position:absolute;inset:0;z-index:4;pointer-events:none}
3604
+ .sk-overlay>*{pointer-events:auto}
3605
+
3606
+ /* Spinner */
3607
+ .sk-spinner{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:32px;height:32px;border:3px solid rgba(255,255,255,.2);border-top-color:#fff;border-radius:50%;opacity:0;pointer-events:none;z-index:2}
3608
+ .sk-spinner.visible{opacity:1;animation:sk-spin .8s linear infinite}
3609
+ @keyframes sk-spin{to{transform:translate(-50%,-50%) rotate(360deg)}}
3610
+
3611
+ /* Widget slot */
3612
+ .skw-slot{position:relative;overflow:hidden;cursor:pointer}
3613
+ .skw-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;transition:opacity .2s ease}
3614
+ .skw-slot-thumb{position:absolute;inset:0;background-size:cover;background-position:center}
3615
+ .skw-slot-overlay{position:absolute;inset:0;z-index:4;pointer-events:none}
3616
+ .skw-slot-overlay>*{pointer-events:auto}
3617
+
3618
+ /* FLIP animation */
3619
+ .sk-feed.skw-flip-animating{transition:transform .4s cubic-bezier(.32,.72,0,1),border-radius .4s cubic-bezier(.32,.72,0,1);will-change:transform}
3620
+
3621
+ /* Feed overlay states */
3622
+ .skw-feed-overlay{position:fixed;inset:0;z-index:9999;display:none;flex-direction:column}
3623
+ .skw-feed-overlay.skw-active{display:flex}
3624
+ .skw-feed-overlay .skw-feed-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.5);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);opacity:0;transition:opacity .35s ease;pointer-events:none}
3625
+ .skw-feed-overlay.skw-active .skw-feed-backdrop{opacity:1}
3626
+ .skw-feed-overlay .sk-page{position:relative;z-index:1;flex:1;pointer-events:none}
3627
+ .skw-feed-overlay.skw-feed-ready .sk-page{pointer-events:auto}
3628
+ .skw-feed-overlay.skw-closing .skw-feed-backdrop{opacity:0;transition:none}
3629
+ `;
3630
+ function injectStyles() {
3631
+ if (injected) return;
3632
+ injected = true;
3633
+ const style = document.createElement("style");
3634
+ style.setAttribute("data-shortkit", "");
3635
+ style.textContent = CSS;
3636
+ (document.head || document.documentElement).appendChild(style);
3637
+ }
3638
+ const ShortKitVersion = "0.3.0";
3639
+ class ShortKit {
3640
+ constructor(options = {}) {
3641
+ injectStyles();
3642
+ if (!options.apiKey) throw new Error("ShortKit: apiKey is required");
3643
+ this._destroyed = false;
3644
+ this._options = options;
3645
+ this._surfaces = /* @__PURE__ */ new Set();
3646
+ this._loadingView = options.loadingView || null;
3647
+ this._clientAppName = options.clientAppName || null;
3648
+ this._clientAppVersion = options.clientAppVersion || null;
3649
+ this._customDimensions = options.customDimensions || null;
3650
+ this._onContentTapped = options.onContentTapped || null;
3651
+ this._onRefreshRequested = options.onRefreshRequested || null;
3652
+ this._onDidFetchContentItems = options.onDidFetchContentItems || null;
3653
+ this._identity = new IdentityManager({
3654
+ userId: options.userId,
3655
+ onResolve: (userId, anonId) => {
3656
+ this._apiClient.resolveIdentity(userId, anonId);
3657
+ }
3658
+ });
3659
+ this._apiClient = new APIClient({
3660
+ baseUrl: options.apiBase || "https://api.shortkit.dev",
3661
+ apiKey: options.apiKey,
3662
+ getEffectiveId: () => this._identity.effectiveId
3663
+ });
3664
+ this.player = new ShortKitPlayer();
3665
+ this._playerPool = new PlayerPool();
3666
+ this._storyboardCache = new StoryboardCache();
3667
+ this._batcher = new EventBatcher({
3668
+ postEvents: (events) => this._apiClient.postEvents(events),
3669
+ beaconEvents: (events) => this._apiClient.beaconEvents(events)
3670
+ });
3671
+ this._tracker = new EngagementTracker({
3672
+ onEvent: (event) => this._batcher.add(event)
3673
+ });
3674
+ this._batcher.start();
3675
+ }
3676
+ // Identity
3677
+ setUserId(id) {
3678
+ this._identity.setUserId(id);
3679
+ }
3680
+ clearUserId() {
3681
+ this._identity.clearUserId();
3682
+ }
3683
+ // Content
3684
+ async fetchContent({ limit = 10, filter } = {}) {
3685
+ const result = await this._apiClient.fetchFeed({ limit, filter });
3686
+ return result.items;
3687
+ }
3688
+ async preloadFeed({ filter, limit = 10 } = {}) {
3689
+ const result = await this._apiClient.fetchFeed({ limit, filter });
3690
+ if (result.items.length > 0) {
3691
+ const first = result.items[0];
3692
+ if (first.thumbnailUrl) new Image().src = first.thumbnailUrl;
3693
+ if (first.streamingUrl) fetch(first.streamingUrl).catch(() => {
3694
+ });
3695
+ }
3696
+ return result;
3697
+ }
3698
+ // Surface factories
3699
+ createFeed(containerEl, options = {}) {
3700
+ const config = createFeedConfig(options.config);
3701
+ const feed = new FeedManager(containerEl, this, { ...options, config });
3702
+ this._registerSurface(feed);
3703
+ feed.init();
3704
+ return feed;
3705
+ }
3706
+ createPlayer(containerEl, options = {}) {
3707
+ const config = createPlayerConfig(options.config);
3708
+ const player = new SinglePlayer(containerEl, this, { ...options, config });
3709
+ this._registerSurface(player);
3710
+ return player;
3711
+ }
3712
+ createWidget(containerEl, options = {}) {
3713
+ const config = createWidgetConfig(options.config);
3714
+ const widget = new WidgetManager(containerEl, this, { ...options, config });
3715
+ this._registerSurface(widget);
3716
+ return widget;
3717
+ }
3718
+ // Teardown
3719
+ destroy() {
3720
+ if (this._destroyed) return;
3721
+ this._destroyed = true;
3722
+ for (const surface of this._surfaces) {
3723
+ if (typeof surface.destroy === "function") surface.destroy();
3724
+ }
3725
+ this._surfaces.clear();
3726
+ this._batcher.destroy();
3727
+ if (this._playerPool.destroyAll) this._playerPool.destroyAll();
3728
+ this.player.destroy();
3729
+ }
3730
+ // Internal: surface registration
3731
+ _registerSurface(surface) {
3732
+ this._surfaces.add(surface);
3733
+ }
3734
+ _unregisterSurface(surface) {
3735
+ this._surfaces.delete(surface);
3736
+ }
3737
+ }
3738
+ export {
3739
+ EmbeddedFeedManager,
3740
+ FeedManager,
3741
+ FeedView,
3742
+ ShortKit,
3743
+ ShortKitVersion,
3744
+ SinglePlayer,
3745
+ WidgetManager,
3746
+ createFeedConfig,
3747
+ createFeedFilter,
3748
+ createFeedItem,
3749
+ createPlayerConfig,
3750
+ createWidgetConfig
3751
+ };