@thewhateverapp/tile-sdk 0.12.22 → 0.13.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.
package/dist/index.d.ts CHANGED
@@ -4,7 +4,8 @@ export { useTileNavigation } from './react/useTileNavigation';
4
4
  export { useKeyboard } from './react/useKeyboard';
5
5
  export { TileContainer } from './react/TileContainer';
6
6
  export { withTile } from './react/withTile';
7
- export { VideoPlayer, useVideoState, Slideshow, useSlideshowState, OverlaySlot, FullOverlay, GradientOverlay, } from './react/overlay';
7
+ export { VideoPlayer, useVideoState, useVideo, // Alias for useVideoState
8
+ Slideshow, useSlideshowState, OverlaySlot, FullOverlay, GradientOverlay, } from './react/overlay';
8
9
  export type { VideoState, VideoControls, VideoContextValue, VideoPlayerProps, SlideImage, SlideshowState, SlideshowControls, SlideshowContextValue, SlideshowProps, SlotPosition, OverlaySlotProps, FullOverlayProps, GradientOverlayProps, } from './react/overlay';
9
10
  export { getTileBridge, TileBridge } from './bridge/TileBridge';
10
11
  export type { TileMessage, TileConfig, TileTokenData, KeyboardState, VisibilityState } from './bridge/TileBridge';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC1C,OAAO,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAG5C,OAAO,EAEL,WAAW,EACX,aAAa,EAEb,SAAS,EACT,iBAAiB,EAEjB,WAAW,EACX,WAAW,EACX,eAAe,GAChB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAEV,UAAU,EACV,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAEhB,UAAU,EACV,cAAc,EACd,iBAAiB,EACjB,qBAAqB,EACrB,cAAc,EAEd,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAChE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAGlH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGnE,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC1C,OAAO,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAC9D,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACtD,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAG5C,OAAO,EAEL,WAAW,EACX,aAAa,EACb,QAAQ,EAAE,0BAA0B;AAEpC,SAAS,EACT,iBAAiB,EAEjB,WAAW,EACX,WAAW,EACX,eAAe,GAChB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAEV,UAAU,EACV,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAEhB,UAAU,EACV,cAAc,EACd,iBAAiB,EACjB,qBAAqB,EACrB,cAAc,EAEd,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAChE,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAGlH,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAClD,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGnE,cAAc,SAAS,CAAC;AAGxB,cAAc,SAAS,CAAC;AAGxB,cAAc,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ export { TileContainer } from './react/TileContainer';
7
7
  export { withTile } from './react/withTile';
8
8
  // Overlay components for hybrid tiles (video/image with interactive overlays)
9
9
  export {
10
- // Video player
11
- VideoPlayer, useVideoState,
10
+ // Video player (with route persistence)
11
+ VideoPlayer, useVideoState, useVideo, // Alias for useVideoState
12
12
  // Slideshow
13
13
  Slideshow, useSlideshowState,
14
14
  // Positioning components
@@ -1,4 +1,11 @@
1
1
  import React, { type ReactNode } from 'react';
2
+ declare global {
3
+ interface Window {
4
+ __videoElement?: HTMLVideoElement;
5
+ __videoHls?: HlsInstance;
6
+ __videoSingleton?: VideoSingletonClass;
7
+ }
8
+ }
2
9
  interface HlsInstance {
3
10
  loadSource: (url: string) => void;
4
11
  attachMedia: (video: HTMLVideoElement) => void;
@@ -37,6 +44,55 @@ export interface VideoState {
37
44
  isLoading: boolean;
38
45
  error: string | null;
39
46
  }
47
+ type StateCallback = (state: VideoState) => void;
48
+ /**
49
+ * VideoSingleton - Manages a persistent video element across route changes.
50
+ *
51
+ * The video element is stored on `window.__videoElement` and survives
52
+ * Next.js soft navigations. When navigating from /tile to /page,
53
+ * the video keeps playing seamlessly.
54
+ */
55
+ declare class VideoSingletonClass {
56
+ private stateCallbacks;
57
+ private currentState;
58
+ private currentSrc;
59
+ private hlsScriptLoaded;
60
+ private visibilitySetup;
61
+ /**
62
+ * Get or create the persistent video element
63
+ */
64
+ getVideo(): HTMLVideoElement;
65
+ /**
66
+ * Load a video source (supports HLS and regular video)
67
+ */
68
+ loadSource(src: string, poster?: string): Promise<void>;
69
+ /**
70
+ * Load HLS stream using hls.js
71
+ */
72
+ private loadHls;
73
+ /**
74
+ * Set up visibility handling via TileBridge
75
+ */
76
+ setupVisibilityHandling(): void;
77
+ /**
78
+ * Attach video element to a container (moves existing element)
79
+ */
80
+ attachTo(container: HTMLElement): void;
81
+ /**
82
+ * Get video element reference (for external use)
83
+ */
84
+ getVideoRef(): HTMLVideoElement;
85
+ play(): void;
86
+ pause(): void;
87
+ toggle(): void;
88
+ seek(time: number): void;
89
+ setMuted(muted: boolean): void;
90
+ setVolume(volume: number): void;
91
+ setLoop(loop: boolean): void;
92
+ getState(): VideoState;
93
+ onStateChange(callback: StateCallback): () => void;
94
+ private updateState;
95
+ }
40
96
  export interface VideoControls {
41
97
  play: () => void;
42
98
  pause: () => void;
@@ -51,46 +107,24 @@ export interface VideoContextValue {
51
107
  videoRef: React.RefObject<HTMLVideoElement>;
52
108
  }
53
109
  export interface CuePoint {
54
- /** Time in seconds when the cue should trigger */
55
110
  time: number;
56
- /** Unique identifier for the cue point */
57
111
  id: string;
58
- /** Optional data to pass to the callback */
59
112
  data?: unknown;
60
113
  }
61
114
  export interface VideoPlayerProps {
62
- /** Children rendered as overlay */
63
115
  children?: ReactNode;
64
- /** Additional class names */
65
116
  className?: string;
66
- /** Video wrapper class names */
67
117
  videoClassName?: string;
68
- /** Show native controls (default: false) */
69
118
  controls?: boolean;
70
- /** Cue points for time-based triggers */
71
119
  cuePoints?: CuePoint[];
72
- /** Callback when a cue point is reached */
73
120
  onCuePoint?: (cuePoint: CuePoint) => void;
74
- /** Callback on time update (fires ~4 times per second) */
75
121
  onTimeUpdate?: (currentTime: number, duration: number) => void;
76
122
  }
77
123
  /**
78
- * VideoPlayer component with HLS streaming support.
79
- * Provides video state and controls to child overlays via context.
80
- *
81
- * Features:
82
- * - Video URL is injected at build time via NEXT_PUBLIC env vars (no API call!)
83
- * - HLS streaming with automatic quality adaptation
84
- * - Falls back to original video URL if transcoding not complete
85
- * - Time-based cue points for triggering overlays
86
- * - Visibility-aware playback (plays when visible, pauses when hidden)
87
- * - Auto-loops by default (can be disabled via tile metadata)
124
+ * VideoPlayer component with HLS streaming support and route persistence.
88
125
  *
89
- * Environment Variables (set at build time by tile-deploy):
90
- * - NEXT_PUBLIC_VIDEO_HLS_URL - HLS streaming URL
91
- * - NEXT_PUBLIC_VIDEO_ORIGINAL_URL - Original video URL (fallback)
92
- * - NEXT_PUBLIC_VIDEO_THUMBNAIL - Poster image
93
- * - NEXT_PUBLIC_VIDEO_AUTOPLAY/LOOP/MUTED - Playback settings
126
+ * The video element persists across Next.js route changes (tile ↔ page).
127
+ * Video URL is read from NEXT_PUBLIC env vars (set at build time).
94
128
  *
95
129
  * Usage:
96
130
  * ```tsx
@@ -102,82 +136,29 @@ export interface VideoPlayerProps {
102
136
  export declare function VideoPlayer({ controls, children, className, videoClassName, cuePoints, onCuePoint, onTimeUpdate, }: VideoPlayerProps): React.JSX.Element;
103
137
  /**
104
138
  * Hook to access video state and controls from within VideoPlayer children.
139
+ * Also aliased as useVideo for compatibility.
105
140
  */
106
141
  export declare function useVideoState(): VideoContextValue;
142
+ export declare const useVideo: typeof useVideoState;
107
143
  /**
108
144
  * Hook to trigger an action when a specific time is reached.
109
- * Returns true when the video has reached or passed the specified time.
110
- *
111
- * @param triggerTime - Time in seconds when to trigger
112
- * @param options - Configuration options
113
- * @returns boolean indicating if the trigger time has been reached
114
- *
115
- * @example
116
- * ```tsx
117
- * function PollOverlay() {
118
- * const showPoll = useCuePoint(5); // Show after 5 seconds
119
- * return showPoll ? <Poll /> : null;
120
- * }
121
- * ```
122
145
  */
123
146
  export declare function useCuePoint(triggerTime: number, options?: {
124
- /** Reset trigger when video loops (default: true) */
125
147
  resetOnLoop?: boolean;
126
- /** Trigger once or every time the time is crossed (default: 'once') */
127
148
  mode?: 'once' | 'every-loop';
128
149
  }): boolean;
129
150
  /**
130
151
  * Hook to manage multiple cue points with callbacks.
131
- * More flexible than useCuePoint for complex time-based interactions.
132
- *
133
- * @param cuePoints - Array of cue points with times and callbacks
134
- * @param options - Configuration options
135
- *
136
- * @example
137
- * ```tsx
138
- * function InteractiveOverlay() {
139
- * const [showPoll, setShowPoll] = useState(false);
140
- * const [showCTA, setShowCTA] = useState(false);
141
- *
142
- * useCuePoints([
143
- * { time: 5, onTrigger: () => setShowPoll(true) },
144
- * { time: 10, onTrigger: () => setShowCTA(true) },
145
- * { time: 15, onTrigger: () => { setShowPoll(false); setShowCTA(false); } },
146
- * ]);
147
- *
148
- * return (
149
- * <>
150
- * {showPoll && <Poll />}
151
- * {showCTA && <CTAButton />}
152
- * </>
153
- * );
154
- * }
155
- * ```
156
152
  */
157
153
  export declare function useCuePoints(cuePoints: Array<{
158
- /** Time in seconds when to trigger */
159
154
  time: number;
160
- /** Callback to execute when time is reached */
161
155
  onTrigger: () => void;
162
- /** Optional unique ID (defaults to index) */
163
156
  id?: string;
164
157
  }>, options?: {
165
- /** Reset triggers when video loops (default: true) */
166
158
  resetOnLoop?: boolean;
167
159
  }): void;
168
160
  /**
169
161
  * Hook to get the current video progress as a percentage (0-100).
170
- * Useful for progress bars or time-based animations.
171
- *
172
- * @returns Progress percentage (0-100)
173
- *
174
- * @example
175
- * ```tsx
176
- * function ProgressBar() {
177
- * const progress = useVideoProgress();
178
- * return <div style={{ width: `${progress}%` }} className="h-1 bg-white" />;
179
- * }
180
- * ```
181
162
  */
182
163
  export declare function useVideoProgress(): number;
183
164
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"VideoPlayer.d.ts","sourceRoot":"","sources":["../../../src/react/overlay/VideoPlayer.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,EAOZ,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AA6Ff,UAAU,WAAW;IACnB,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,WAAW,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC/C,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAI,KAAK,IAAI,CAAC;IACpE,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,iBAAiB,EAAE,MAAM,IAAI,CAAC;IAC9B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED,UAAU,SAAS;IACjB,WAAW,EAAE,MAAM,OAAO,CAAC;IAC3B,MAAM,EAAE;QACN,eAAe,EAAE,MAAM,CAAC;QACxB,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;IACF,UAAU,EAAE;QACV,aAAa,EAAE,MAAM,CAAC;QACtB,WAAW,EAAE,MAAM,CAAC;KACrB,CAAC;IACF,KAAK,MAAM,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE,OAAO,CAAC;QAAC,cAAc,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,WAAW,CAAC;CAClF;AAGD,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,GAAG,EAAE,SAAS,CAAC;KAChB;CACF;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,IAAI,CAAC;IACjB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,aAAa,CAAC;IACxB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;CAC7C;AAID,MAAM,WAAW,QAAQ;IACvB,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAC;IACb,0CAA0C;IAC1C,EAAE,EAAE,MAAM,CAAC;IACX,4CAA4C;IAC5C,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,mCAAmC;IACnC,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gCAAgC;IAChC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,yCAAyC;IACzC,SAAS,CAAC,EAAE,QAAQ,EAAE,CAAC;IACvB,2CAA2C;IAC3C,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,CAAC;IAC1C,0DAA0D;IAC1D,YAAY,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAChE;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAgB,EAChB,QAAQ,EACR,SAAc,EACd,cAAmB,EACnB,SAAc,EACd,UAAU,EACV,YAAY,GACb,EAAE,gBAAgB,qBAkblB;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,iBAAiB,CAMjD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CACzB,WAAW,EAAE,MAAM,EACnB,OAAO,GAAE;IACP,qDAAqD;IACrD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,GAAG,YAAY,CAAC;CACzB,GACL,OAAO,CAyBT;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,KAAK,CAAC;IACf,sCAAsC;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,+CAA+C;IAC/C,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,6CAA6C;IAC7C,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,CAAC,EACF,OAAO,GAAE;IACP,sDAAsD;IACtD,WAAW,CAAC,EAAE,OAAO,CAAC;CAClB,GACL,IAAI,CAgCN;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAMzC"}
1
+ {"version":3,"file":"VideoPlayer.d.ts","sourceRoot":"","sources":["../../../src/react/overlay/VideoPlayer.tsx"],"names":[],"mappings":"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"}
@@ -2,23 +2,282 @@
2
2
  import React, { createContext, useContext, useEffect, useRef, useState, useCallback, } from 'react';
3
3
  import { getTileBridge } from '../../bridge/TileBridge';
4
4
  /**
5
- * Get video metadata from NEXT_PUBLIC environment variables.
6
- * These are injected at build time by tile-deploy for video tiles.
5
+ * VideoSingleton - Manages a persistent video element across route changes.
7
6
  *
8
- * Environment variables used:
9
- * - NEXT_PUBLIC_TILE_ID - Tile identifier
10
- * - NEXT_PUBLIC_VIDEO_HLS_URL - HLS streaming URL (preferred)
11
- * - NEXT_PUBLIC_VIDEO_ORIGINAL_URL - Original video URL (fallback)
12
- * - NEXT_PUBLIC_VIDEO_THUMBNAIL - Thumbnail image URL
13
- * - NEXT_PUBLIC_VIDEO_DURATION - Video duration in seconds
14
- * - NEXT_PUBLIC_VIDEO_TITLE - Video title
15
- * - NEXT_PUBLIC_VIDEO_AUTOPLAY - Auto-play setting (default: true)
16
- * - NEXT_PUBLIC_VIDEO_LOOP - Loop setting (default: true)
17
- * - NEXT_PUBLIC_VIDEO_MUTED - Muted setting (default: true)
18
- * - NEXT_PUBLIC_VIDEO_TRANSCODING_COMPLETE - Transcoding status
7
+ * The video element is stored on `window.__videoElement` and survives
8
+ * Next.js soft navigations. When navigating from /tile to /page,
9
+ * the video keeps playing seamlessly.
19
10
  */
11
+ class VideoSingletonClass {
12
+ constructor() {
13
+ this.stateCallbacks = new Set();
14
+ this.currentState = {
15
+ isPlaying: false,
16
+ currentTime: 0,
17
+ duration: 0,
18
+ buffered: 0,
19
+ volume: 1,
20
+ muted: true,
21
+ isLoading: true,
22
+ error: null,
23
+ };
24
+ this.currentSrc = null;
25
+ this.hlsScriptLoaded = false;
26
+ this.visibilitySetup = false;
27
+ }
28
+ /**
29
+ * Get or create the persistent video element
30
+ */
31
+ getVideo() {
32
+ if (typeof window === 'undefined') {
33
+ throw new Error('VideoSingleton requires browser environment');
34
+ }
35
+ if (!window.__videoElement) {
36
+ const video = document.createElement('video');
37
+ video.id = 'persistent-video';
38
+ video.playsInline = true;
39
+ video.muted = true;
40
+ video.loop = true;
41
+ video.preload = 'auto';
42
+ video.style.width = '100%';
43
+ video.style.height = '100%';
44
+ video.style.objectFit = 'contain';
45
+ video.style.backgroundColor = 'black';
46
+ // Set up event listeners
47
+ video.addEventListener('play', () => this.updateState({ isPlaying: true, isLoading: false }));
48
+ video.addEventListener('pause', () => this.updateState({ isPlaying: false }));
49
+ video.addEventListener('timeupdate', () => {
50
+ this.updateState({ currentTime: video.currentTime });
51
+ });
52
+ video.addEventListener('durationchange', () => {
53
+ this.updateState({ duration: video.duration });
54
+ });
55
+ video.addEventListener('loadeddata', () => {
56
+ this.updateState({ isLoading: false });
57
+ });
58
+ video.addEventListener('canplay', () => {
59
+ this.updateState({ isLoading: false });
60
+ });
61
+ video.addEventListener('waiting', () => {
62
+ this.updateState({ isLoading: true });
63
+ });
64
+ video.addEventListener('playing', () => {
65
+ this.updateState({ isLoading: false, isPlaying: true });
66
+ });
67
+ video.addEventListener('volumechange', () => {
68
+ this.updateState({ muted: video.muted, volume: video.volume });
69
+ });
70
+ video.addEventListener('progress', () => {
71
+ if (video.buffered.length > 0) {
72
+ this.updateState({ buffered: video.buffered.end(video.buffered.length - 1) });
73
+ }
74
+ });
75
+ video.addEventListener('error', () => {
76
+ this.updateState({ error: 'Video playback error', isLoading: false });
77
+ });
78
+ window.__videoElement = video;
79
+ }
80
+ return window.__videoElement;
81
+ }
82
+ /**
83
+ * Load a video source (supports HLS and regular video)
84
+ */
85
+ async loadSource(src, poster) {
86
+ // Skip if same source already loaded
87
+ if (this.currentSrc === src) {
88
+ return;
89
+ }
90
+ const video = this.getVideo();
91
+ this.currentSrc = src;
92
+ this.updateState({ isLoading: true, error: null });
93
+ if (poster) {
94
+ video.poster = poster;
95
+ }
96
+ // Clean up existing HLS instance
97
+ if (window.__videoHls) {
98
+ window.__videoHls.destroy();
99
+ window.__videoHls = undefined;
100
+ }
101
+ // Check if this is an HLS stream
102
+ if (src.includes('.m3u8')) {
103
+ await this.loadHls(src);
104
+ }
105
+ else {
106
+ // Regular video file
107
+ video.src = src;
108
+ }
109
+ }
110
+ /**
111
+ * Load HLS stream using hls.js
112
+ */
113
+ async loadHls(src) {
114
+ const video = this.getVideo();
115
+ // Load HLS.js from CDN if not already loaded
116
+ if (!this.hlsScriptLoaded && !window.Hls) {
117
+ await new Promise((resolve, reject) => {
118
+ const script = document.createElement('script');
119
+ script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
120
+ script.onload = () => {
121
+ this.hlsScriptLoaded = true;
122
+ resolve();
123
+ };
124
+ script.onerror = () => reject(new Error('Failed to load HLS.js'));
125
+ document.head.appendChild(script);
126
+ });
127
+ }
128
+ const Hls = window.Hls;
129
+ if (Hls.isSupported()) {
130
+ const hls = new Hls({
131
+ enableWorker: true,
132
+ lowLatencyMode: false,
133
+ });
134
+ hls.loadSource(src);
135
+ hls.attachMedia(video);
136
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
137
+ this.updateState({ isLoading: false });
138
+ });
139
+ hls.on(Hls.Events.ERROR, (_event, data) => {
140
+ const errorData = data;
141
+ if (errorData.fatal) {
142
+ switch (errorData.type) {
143
+ case Hls.ErrorTypes.NETWORK_ERROR:
144
+ hls.startLoad();
145
+ break;
146
+ case Hls.ErrorTypes.MEDIA_ERROR:
147
+ hls.recoverMediaError();
148
+ break;
149
+ default:
150
+ this.updateState({ error: 'HLS playback error', isLoading: false });
151
+ hls.destroy();
152
+ break;
153
+ }
154
+ }
155
+ });
156
+ window.__videoHls = hls;
157
+ }
158
+ else if (video.canPlayType('application/vnd.apple.mpegurl')) {
159
+ // Native HLS support (Safari)
160
+ video.src = src;
161
+ }
162
+ else {
163
+ this.updateState({ error: 'HLS not supported', isLoading: false });
164
+ }
165
+ }
166
+ /**
167
+ * Set up visibility handling via TileBridge
168
+ */
169
+ setupVisibilityHandling() {
170
+ if (typeof window === 'undefined' || this.visibilitySetup)
171
+ return;
172
+ this.visibilitySetup = true;
173
+ const video = this.getVideo();
174
+ // Ensure video starts paused and muted
175
+ video.muted = true;
176
+ video.pause();
177
+ try {
178
+ const bridge = getTileBridge();
179
+ bridge.onVisibilityChange((visibilityState) => {
180
+ console.log('[VideoPlayer] Visibility changed:', visibilityState.visible);
181
+ if (visibilityState.visible) {
182
+ // Tile is visible - play video, unmute audio
183
+ video.muted = false;
184
+ video.play().catch(() => {
185
+ // If unmuted play fails (browser policy), retry muted
186
+ video.muted = true;
187
+ video.play().catch(() => { });
188
+ });
189
+ }
190
+ else {
191
+ // Tile is hidden - pause video, mute audio
192
+ video.muted = true;
193
+ video.pause();
194
+ }
195
+ });
196
+ }
197
+ catch {
198
+ // Bridge not available (standalone tile, not in feed)
199
+ // For standalone tiles, play with audio if document is visible
200
+ if (!document.hidden) {
201
+ video.muted = false;
202
+ video.play().catch(() => {
203
+ video.muted = true;
204
+ video.play().catch(() => { });
205
+ });
206
+ }
207
+ }
208
+ }
209
+ /**
210
+ * Attach video element to a container (moves existing element)
211
+ */
212
+ attachTo(container) {
213
+ const video = this.getVideo();
214
+ if (!container.contains(video)) {
215
+ container.appendChild(video);
216
+ }
217
+ }
218
+ /**
219
+ * Get video element reference (for external use)
220
+ */
221
+ getVideoRef() {
222
+ return this.getVideo();
223
+ }
224
+ // Playback controls
225
+ play() {
226
+ this.getVideo().play().catch(() => { });
227
+ }
228
+ pause() {
229
+ this.getVideo().pause();
230
+ }
231
+ toggle() {
232
+ const video = this.getVideo();
233
+ if (video.paused) {
234
+ video.play().catch(() => { });
235
+ }
236
+ else {
237
+ video.pause();
238
+ }
239
+ }
240
+ seek(time) {
241
+ this.getVideo().currentTime = time;
242
+ }
243
+ setMuted(muted) {
244
+ this.getVideo().muted = muted;
245
+ }
246
+ setVolume(volume) {
247
+ this.getVideo().volume = Math.max(0, Math.min(1, volume));
248
+ }
249
+ setLoop(loop) {
250
+ this.getVideo().loop = loop;
251
+ }
252
+ // State management
253
+ getState() {
254
+ return { ...this.currentState };
255
+ }
256
+ onStateChange(callback) {
257
+ this.stateCallbacks.add(callback);
258
+ // Immediately call with current state
259
+ callback(this.currentState);
260
+ return () => this.stateCallbacks.delete(callback);
261
+ }
262
+ updateState(partial) {
263
+ this.currentState = { ...this.currentState, ...partial };
264
+ this.stateCallbacks.forEach(cb => cb(this.currentState));
265
+ }
266
+ }
267
+ /**
268
+ * Get the VideoSingleton instance (creates if needed)
269
+ */
270
+ function getVideoSingleton() {
271
+ if (typeof window === 'undefined') {
272
+ // SSR - return a dummy that will be replaced on client
273
+ return new VideoSingletonClass();
274
+ }
275
+ if (!window.__videoSingleton) {
276
+ window.__videoSingleton = new VideoSingletonClass();
277
+ }
278
+ return window.__videoSingleton;
279
+ }
20
280
  function getVideoMetadataFromEnv() {
21
- // Check if we're in a Next.js environment with process.env
22
281
  if (typeof process === 'undefined' || !process.env) {
23
282
  return null;
24
283
  }
@@ -28,11 +287,9 @@ function getVideoMetadataFromEnv() {
28
287
  }
29
288
  const hlsUrl = process.env.NEXT_PUBLIC_VIDEO_HLS_URL || null;
30
289
  const originalUrl = process.env.NEXT_PUBLIC_VIDEO_ORIGINAL_URL || null;
31
- // If neither URL is available, this isn't a video tile
32
290
  if (!hlsUrl && !originalUrl) {
33
291
  return null;
34
292
  }
35
- // Prefer HLS URL if available and transcoding is complete, otherwise use original
36
293
  const transcodingComplete = process.env.NEXT_PUBLIC_VIDEO_TRANSCODING_COMPLETE === 'true';
37
294
  const videoUrl = (hlsUrl && transcodingComplete) ? hlsUrl : (originalUrl || hlsUrl);
38
295
  return {
@@ -46,33 +303,17 @@ function getVideoMetadataFromEnv() {
46
303
  ? parseFloat(process.env.NEXT_PUBLIC_VIDEO_DURATION)
47
304
  : null,
48
305
  title: process.env.NEXT_PUBLIC_VIDEO_TITLE || null,
49
- // Default to false for autoplay - wait for visibility message from parent
50
- // This prevents offscreen tiles from playing in TikTok-style feeds
51
306
  autoplay: process.env.NEXT_PUBLIC_VIDEO_AUTOPLAY === 'true',
52
- // Default to true for loop (most video tiles loop)
53
307
  loop: process.env.NEXT_PUBLIC_VIDEO_LOOP !== 'false',
54
- // Default to true for muted - start muted, unmute on visibility
55
308
  muted: process.env.NEXT_PUBLIC_VIDEO_MUTED !== 'false',
56
309
  };
57
310
  }
58
311
  const VideoContext = createContext(null);
59
312
  /**
60
- * VideoPlayer component with HLS streaming support.
61
- * Provides video state and controls to child overlays via context.
62
- *
63
- * Features:
64
- * - Video URL is injected at build time via NEXT_PUBLIC env vars (no API call!)
65
- * - HLS streaming with automatic quality adaptation
66
- * - Falls back to original video URL if transcoding not complete
67
- * - Time-based cue points for triggering overlays
68
- * - Visibility-aware playback (plays when visible, pauses when hidden)
69
- * - Auto-loops by default (can be disabled via tile metadata)
313
+ * VideoPlayer component with HLS streaming support and route persistence.
70
314
  *
71
- * Environment Variables (set at build time by tile-deploy):
72
- * - NEXT_PUBLIC_VIDEO_HLS_URL - HLS streaming URL
73
- * - NEXT_PUBLIC_VIDEO_ORIGINAL_URL - Original video URL (fallback)
74
- * - NEXT_PUBLIC_VIDEO_THUMBNAIL - Poster image
75
- * - NEXT_PUBLIC_VIDEO_AUTOPLAY/LOOP/MUTED - Playback settings
315
+ * The video element persists across Next.js route changes (tile ↔ page).
316
+ * Video URL is read from NEXT_PUBLIC env vars (set at build time).
76
317
  *
77
318
  * Usage:
78
319
  * ```tsx
@@ -82,379 +323,112 @@ const VideoContext = createContext(null);
82
323
  * ```
83
324
  */
84
325
  export function VideoPlayer({ controls = false, children, className = '', videoClassName = '', cuePoints = [], onCuePoint, onTimeUpdate, }) {
85
- const videoRef = useRef(null);
86
- const hlsRef = useRef(null);
326
+ const containerRef = useRef(null);
327
+ const videoRefProxy = useRef(null);
87
328
  const triggeredCuePointsRef = useRef(new Set());
88
329
  const lastTimeRef = useRef(0);
89
- // State for video metadata (from env vars, set at build time)
90
- const [metadata, setMetadata] = useState(null);
330
+ const [state, setState] = useState(() => getVideoSingleton().getState());
91
331
  const [metadataError, setMetadataError] = useState(null);
92
- // Get video metadata from environment variables (set at build time by tile-deploy)
93
- // This is synchronous - no API call needed!
94
- useEffect(() => {
95
- const envMetadata = getVideoMetadataFromEnv();
96
- if (!envMetadata) {
97
- // Check if TILE_ID is set - if not, likely a dev environment issue
98
- if (typeof process !== 'undefined' && !process.env?.NEXT_PUBLIC_TILE_ID) {
99
- setMetadataError('NEXT_PUBLIC_TILE_ID not set');
100
- }
101
- else {
102
- setMetadataError('No video metadata found in environment variables');
103
- }
104
- return;
105
- }
106
- if (!envMetadata.videoUrl) {
107
- setMetadataError('No video URL found in environment variables');
108
- return;
109
- }
110
- setMetadata(envMetadata);
111
- }, []);
112
- // Get values from metadata
332
+ // Get video metadata from env vars
333
+ const metadata = getVideoMetadataFromEnv();
113
334
  const src = metadata?.videoUrl || null;
114
- // Default to false for autoplay - videos wait for visibility message from parent
115
- // This prevents offscreen tiles from playing in TikTok-style feeds
116
- const autoplay = metadata?.autoplay ?? false;
117
- // Default to true for muted - videos start muted until visibility says otherwise
118
- const muted = metadata?.muted ?? true;
119
335
  const poster = metadata?.thumbnail || undefined;
120
- // Default to loop enabled (videos almost always loop)
121
- const loop = metadata?.loop ?? true;
122
- const [state, setState] = useState({
123
- isPlaying: false,
124
- currentTime: 0,
125
- duration: 0,
126
- buffered: 0,
127
- volume: 1,
128
- muted: muted,
129
- isLoading: true,
130
- error: null,
131
- });
132
- // Control functions
133
- const play = useCallback(() => {
134
- videoRef.current?.play().catch(() => { });
135
- }, []);
136
- const pause = useCallback(() => {
137
- videoRef.current?.pause();
138
- }, []);
139
- const toggle = useCallback(() => {
140
- if (videoRef.current?.paused) {
141
- play();
142
- }
143
- else {
144
- pause();
145
- }
146
- }, [play, pause]);
147
- const seek = useCallback((time) => {
148
- if (videoRef.current) {
149
- videoRef.current.currentTime = time;
150
- }
151
- }, []);
152
- const setVolume = useCallback((volume) => {
153
- if (videoRef.current) {
154
- videoRef.current.volume = Math.max(0, Math.min(1, volume));
155
- }
336
+ // Subscribe to singleton state changes
337
+ useEffect(() => {
338
+ const singleton = getVideoSingleton();
339
+ const unsubscribe = singleton.onStateChange(setState);
340
+ return unsubscribe;
156
341
  }, []);
157
- const setMuted = useCallback((muted) => {
158
- if (videoRef.current) {
159
- videoRef.current.muted = muted;
342
+ // Attach video to container and load source
343
+ useEffect(() => {
344
+ if (!containerRef.current)
345
+ return;
346
+ const singleton = getVideoSingleton();
347
+ // Attach the persistent video element to our container
348
+ singleton.attachTo(containerRef.current);
349
+ // Update video ref proxy
350
+ videoRefProxy.current = singleton.getVideoRef();
351
+ // Set loop preference
352
+ if (metadata?.loop !== undefined) {
353
+ singleton.setLoop(metadata.loop);
160
354
  }
161
- }, []);
162
- // Helper to start playback with visibility-aware muting
163
- const startPlayback = useCallback((video) => {
164
- try {
165
- const bridge = getTileBridge();
166
- if (bridge.isVisible()) {
167
- // Visible tile - try unmuted first for mobile WebViews
168
- video.muted = false;
169
- video.play().catch(() => {
170
- // Unmuted failed (browser policy) - fall back to muted
171
- video.muted = true;
172
- video.play().catch(() => { });
173
- });
174
- }
175
- else {
176
- // Not visible - play muted
177
- video.muted = true;
178
- video.play().catch(() => { });
179
- }
355
+ // Load source if available
356
+ if (src) {
357
+ singleton.loadSource(src, poster);
180
358
  }
181
- catch {
182
- // Bridge not available - just play muted (safe default)
183
- video.play().catch(() => { });
359
+ else if (!metadata) {
360
+ setMetadataError('No video metadata found in environment variables');
184
361
  }
185
- }, []);
186
- // Load video source - handles both HLS streams and regular video files
362
+ // Set up visibility handling
363
+ singleton.setupVisibilityHandling();
364
+ // Note: We don't detach on unmount - that's the whole point of persistence!
365
+ }, [src, poster, metadata]);
366
+ // Handle cue points and time updates
187
367
  useEffect(() => {
188
- const video = videoRef.current;
189
- if (!video || !src)
190
- return;
191
- // Check if this is an HLS stream (.m3u8) or a regular video file
192
- const isHlsUrl = src.includes('.m3u8');
193
- if (!isHlsUrl) {
194
- // Regular video file (MP4, WebM, etc.) - just set src directly
195
- const handleLoadedMetadata = () => {
196
- setState(s => ({ ...s, isLoading: false }));
197
- if (autoplay) {
198
- startPlayback(video);
199
- }
200
- };
201
- const handleError = () => {
202
- setState(s => ({ ...s, error: 'Failed to load video', isLoading: false }));
203
- };
204
- video.src = src;
205
- video.addEventListener('loadedmetadata', handleLoadedMetadata);
206
- video.addEventListener('error', handleError);
207
- return () => {
208
- video.removeEventListener('loadedmetadata', handleLoadedMetadata);
209
- video.removeEventListener('error', handleError);
210
- };
368
+ const { currentTime, duration } = state;
369
+ // Call onTimeUpdate callback
370
+ if (onTimeUpdate && !isNaN(duration) && duration > 0) {
371
+ onTimeUpdate(currentTime, duration);
211
372
  }
212
- // HLS stream - load HLS.js
213
- const loadHls = async () => {
214
- if (!window.Hls) {
215
- await new Promise((resolve, reject) => {
216
- const script = document.createElement('script');
217
- script.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
218
- script.onload = () => resolve();
219
- script.onerror = () => reject(new Error('Failed to load HLS.js'));
220
- document.head.appendChild(script);
221
- });
222
- }
223
- const Hls = window.Hls;
224
- if (Hls.isSupported()) {
225
- const hls = new Hls({
226
- enableWorker: true,
227
- lowLatencyMode: false,
228
- });
229
- hlsRef.current = hls;
230
- hls.loadSource(src);
231
- hls.attachMedia(video);
232
- hls.on(Hls.Events.MANIFEST_PARSED, () => {
233
- setState(s => ({ ...s, isLoading: false }));
234
- if (autoplay) {
235
- startPlayback(video);
236
- }
237
- });
238
- hls.on(Hls.Events.ERROR, (_event, data) => {
239
- const errorData = data;
240
- if (errorData.fatal) {
241
- switch (errorData.type) {
242
- case Hls.ErrorTypes.NETWORK_ERROR:
243
- hls.startLoad();
244
- break;
245
- case Hls.ErrorTypes.MEDIA_ERROR:
246
- hls.recoverMediaError();
247
- break;
248
- default:
249
- setState(s => ({ ...s, error: 'Failed to load video', isLoading: false }));
250
- hls.destroy();
251
- break;
252
- }
253
- }
254
- });
255
- }
256
- else if (video.canPlayType('application/vnd.apple.mpegurl')) {
257
- // Native HLS support (Safari)
258
- video.src = src;
259
- video.addEventListener('loadedmetadata', () => {
260
- setState(s => ({ ...s, isLoading: false }));
261
- if (autoplay) {
262
- startPlayback(video);
263
- }
264
- });
265
- }
266
- else {
267
- setState(s => ({ ...s, error: 'HLS not supported', isLoading: false }));
268
- }
269
- };
270
- loadHls().catch(error => {
271
- setState(s => ({ ...s, error: error.message, isLoading: false }));
272
- });
273
- return () => {
274
- hlsRef.current?.destroy();
275
- hlsRef.current = null;
276
- };
277
- }, [src, autoplay, startPlayback]);
278
- // Video event listeners
279
- useEffect(() => {
280
- const video = videoRef.current;
281
- if (!video)
282
- return;
283
- const handlePlay = () => setState(s => ({ ...s, isPlaying: true, isLoading: false }));
284
- const handlePause = () => setState(s => ({ ...s, isPlaying: false }));
285
- // Fallback for loading state - hide spinner when video can play
286
- const handleCanPlay = () => setState(s => ({ ...s, isLoading: false }));
287
- const handleLoadedData = () => setState(s => ({ ...s, isLoading: false }));
288
- const handleTimeUpdate = () => {
289
- const currentTime = video.currentTime;
290
- const duration = video.duration;
291
- setState(s => ({ ...s, currentTime }));
292
- // Call onTimeUpdate callback
293
- if (onTimeUpdate && !isNaN(duration)) {
294
- onTimeUpdate(currentTime, duration);
295
- }
296
- // Check cue points - trigger if we crossed the cue time since last update
297
- if (onCuePoint && cuePoints.length > 0) {
298
- const lastTime = lastTimeRef.current;
299
- for (const cuePoint of cuePoints) {
300
- const cueKey = `${cuePoint.id}-${Math.floor(currentTime / (duration || 1))}`;
301
- // Trigger if we crossed the cue point time (handles both forward and loop)
302
- // For looping: reset triggered cues when video loops back
303
- if (currentTime < lastTime && lastTime > duration * 0.9) {
304
- // Video looped - clear triggered cues
305
- triggeredCuePointsRef.current.clear();
306
- }
307
- const alreadyTriggered = triggeredCuePointsRef.current.has(cueKey);
308
- const crossedCuePoint = lastTime < cuePoint.time && currentTime >= cuePoint.time;
309
- if (!alreadyTriggered && crossedCuePoint) {
310
- triggeredCuePointsRef.current.add(cueKey);
311
- onCuePoint(cuePoint);
312
- }
313
- }
314
- }
315
- lastTimeRef.current = currentTime;
316
- };
317
- const handleDurationChange = () => setState(s => ({ ...s, duration: video.duration }));
318
- const handleVolumeChange = () => setState(s => ({ ...s, volume: video.volume, muted: video.muted }));
319
- const handleProgress = () => {
320
- if (video.buffered.length > 0) {
321
- setState(s => ({
322
- ...s,
323
- buffered: video.buffered.end(video.buffered.length - 1),
324
- }));
373
+ // Check cue points
374
+ if (onCuePoint && cuePoints.length > 0) {
375
+ const lastTime = lastTimeRef.current;
376
+ // Detect video loop
377
+ if (currentTime < lastTime && lastTime > duration * 0.9) {
378
+ triggeredCuePointsRef.current.clear();
325
379
  }
326
- };
327
- const handleError = () => setState(s => ({ ...s, error: 'Video playback error', isLoading: false }));
328
- video.addEventListener('play', handlePlay);
329
- video.addEventListener('pause', handlePause);
330
- video.addEventListener('timeupdate', handleTimeUpdate);
331
- video.addEventListener('durationchange', handleDurationChange);
332
- video.addEventListener('volumechange', handleVolumeChange);
333
- video.addEventListener('progress', handleProgress);
334
- video.addEventListener('error', handleError);
335
- video.addEventListener('canplay', handleCanPlay);
336
- video.addEventListener('loadeddata', handleLoadedData);
337
- return () => {
338
- video.removeEventListener('play', handlePlay);
339
- video.removeEventListener('pause', handlePause);
340
- video.removeEventListener('timeupdate', handleTimeUpdate);
341
- video.removeEventListener('durationchange', handleDurationChange);
342
- video.removeEventListener('volumechange', handleVolumeChange);
343
- video.removeEventListener('progress', handleProgress);
344
- video.removeEventListener('error', handleError);
345
- video.removeEventListener('canplay', handleCanPlay);
346
- video.removeEventListener('loadeddata', handleLoadedData);
347
- };
348
- }, [onTimeUpdate, onCuePoint, cuePoints]);
349
- // Visibility handling - play/pause AND mute/unmute based on tile visibility.
350
- // Videos start paused and ONLY play when parent sends visible: true.
351
- // This enables TikTok-style feeds where only the active tile plays.
352
- // IMPORTANT: Do NOT check initial state - wait for explicit visibility message.
353
- useEffect(() => {
354
- const video = videoRef.current;
355
- if (!video)
356
- return;
357
- // Ensure video starts paused and muted
358
- video.muted = true;
359
- video.pause();
360
- try {
361
- const bridge = getTileBridge();
362
- // Handle visibility changes from parent (mobile app)
363
- // ONLY respond to explicit messages, not initial state
364
- const unsubscribe = bridge.onVisibilityChange((visibilityState) => {
365
- console.log('[VideoPlayer] Visibility changed:', visibilityState.visible);
366
- if (visibilityState.visible) {
367
- // Tile is visible - play video, unmute audio
368
- video.muted = false;
369
- video.play().catch(() => {
370
- // If unmuted play fails (browser policy), retry muted
371
- video.muted = true;
372
- video.play().catch(() => { });
373
- });
374
- }
375
- else {
376
- // Tile is hidden - pause video, mute audio
377
- video.muted = true;
378
- video.pause();
380
+ for (const cuePoint of cuePoints) {
381
+ const cueKey = `${cuePoint.id}-${Math.floor(currentTime / (duration || 1))}`;
382
+ const alreadyTriggered = triggeredCuePointsRef.current.has(cueKey);
383
+ const crossedCuePoint = lastTime < cuePoint.time && currentTime >= cuePoint.time;
384
+ if (!alreadyTriggered && crossedCuePoint) {
385
+ triggeredCuePointsRef.current.add(cueKey);
386
+ onCuePoint(cuePoint);
379
387
  }
380
- });
381
- return () => {
382
- unsubscribe();
383
- };
384
- }
385
- catch {
386
- // Bridge not available (standalone tile, not in feed)
387
- // For standalone tiles, play with audio if document is visible
388
- if (!document.hidden) {
389
- video.muted = false;
390
- video.play().catch(() => {
391
- video.muted = true;
392
- video.play().catch(() => { });
393
- });
394
388
  }
395
389
  }
396
- }, []);
390
+ lastTimeRef.current = currentTime;
391
+ }, [state.currentTime, state.duration, onTimeUpdate, onCuePoint, cuePoints]);
392
+ // Control functions
393
+ const singleton = getVideoSingleton();
394
+ const controlsObj = {
395
+ play: useCallback(() => singleton.play(), []),
396
+ pause: useCallback(() => singleton.pause(), []),
397
+ toggle: useCallback(() => singleton.toggle(), []),
398
+ seek: useCallback((time) => singleton.seek(time), []),
399
+ setVolume: useCallback((volume) => singleton.setVolume(volume), []),
400
+ setMuted: useCallback((muted) => singleton.setMuted(muted), []),
401
+ };
397
402
  const contextValue = {
398
403
  state,
399
- controls: { play, pause, toggle, seek, setVolume, setMuted },
400
- videoRef: videoRef,
404
+ controls: controlsObj,
405
+ videoRef: videoRefProxy,
401
406
  };
402
407
  return (React.createElement(VideoContext.Provider, { value: contextValue },
403
408
  React.createElement("div", { className: `relative w-full h-full bg-black ${className}` },
404
- React.createElement("video", { ref: videoRef, className: `w-full h-full object-contain ${videoClassName}`, poster: poster, loop: loop, muted: muted, autoPlay: autoplay, controls: controls, playsInline: true,
405
- // Hide iOS Safari's native poster play button
406
- style: {
407
- // @ts-expect-error - WebKit-specific property
408
- WebkitMediaControlsStartPlaybackButton: 'none',
409
- } }),
409
+ React.createElement("div", { ref: containerRef, className: `absolute inset-0 ${videoClassName}` }),
410
410
  React.createElement("style", null, `
411
- /* WebKit (Safari, iOS, Chrome) */
412
- video::-webkit-media-controls-start-playback-button {
411
+ #persistent-video::-webkit-media-controls-start-playback-button {
413
412
  display: none !important;
414
413
  -webkit-appearance: none;
415
414
  opacity: 0 !important;
416
- pointer-events: none !important;
417
- }
418
- video::-webkit-media-controls-panel {
419
- display: none !important;
420
- opacity: 0 !important;
421
- }
422
- video::-webkit-media-controls {
423
- display: none !important;
424
- opacity: 0 !important;
425
- }
426
- video::-webkit-media-controls-play-button {
427
- display: none !important;
428
- opacity: 0 !important;
429
- }
430
- video::-webkit-media-controls-overlay-play-button {
431
- display: none !important;
432
- opacity: 0 !important;
433
- }
434
- /* Firefox */
435
- video::-moz-media-controls {
436
- display: none !important;
437
- }
438
- /* Microsoft Edge / IE */
439
- video::-ms-media-controls {
440
- display: none !important;
441
- }
442
- /* General - remove default controls styling */
443
- video {
444
- -webkit-media-controls-start-playback-button: none;
445
- }
446
- video[poster] {
447
- object-fit: cover;
448
415
  }
416
+ #persistent-video::-webkit-media-controls-panel { display: none !important; }
417
+ #persistent-video::-webkit-media-controls { display: none !important; }
418
+ #persistent-video::-webkit-media-controls-play-button { display: none !important; }
419
+ #persistent-video::-webkit-media-controls-overlay-play-button { display: none !important; }
420
+ #persistent-video::-moz-media-controls { display: none !important; }
421
+ #persistent-video::-ms-media-controls { display: none !important; }
449
422
  `),
450
- (state.isLoading || (!src && !metadataError)) && (React.createElement("div", { className: "absolute inset-0 flex items-center justify-center" },
423
+ (state.isLoading || (!src && !metadataError)) && (React.createElement("div", { className: "absolute inset-0 flex items-center justify-center z-20" },
451
424
  React.createElement("div", { className: "w-10 h-10 border-3 border-white/30 border-t-white rounded-full animate-spin" }))),
452
- (state.error || metadataError) && (React.createElement("div", { className: "absolute inset-0 flex items-center justify-center text-white/80" },
425
+ (state.error || metadataError) && (React.createElement("div", { className: "absolute inset-0 flex items-center justify-center text-white/80 z-20" },
453
426
  React.createElement("p", null, state.error || metadataError))),
454
- children)));
427
+ React.createElement("div", { className: "absolute inset-0 z-10" }, children))));
455
428
  }
456
429
  /**
457
430
  * Hook to access video state and controls from within VideoPlayer children.
431
+ * Also aliased as useVideo for compatibility.
458
432
  */
459
433
  export function useVideoState() {
460
434
  const context = useContext(VideoContext);
@@ -463,21 +437,10 @@ export function useVideoState() {
463
437
  }
464
438
  return context;
465
439
  }
440
+ // Alias for compatibility with PersistentVideo's useVideo
441
+ export const useVideo = useVideoState;
466
442
  /**
467
443
  * Hook to trigger an action when a specific time is reached.
468
- * Returns true when the video has reached or passed the specified time.
469
- *
470
- * @param triggerTime - Time in seconds when to trigger
471
- * @param options - Configuration options
472
- * @returns boolean indicating if the trigger time has been reached
473
- *
474
- * @example
475
- * ```tsx
476
- * function PollOverlay() {
477
- * const showPoll = useCuePoint(5); // Show after 5 seconds
478
- * return showPoll ? <Poll /> : null;
479
- * }
480
- * ```
481
444
  */
482
445
  export function useCuePoint(triggerTime, options = {}) {
483
446
  const { resetOnLoop = true, mode = 'once' } = options;
@@ -486,13 +449,11 @@ export function useCuePoint(triggerTime, options = {}) {
486
449
  const lastTimeRef = useRef(0);
487
450
  useEffect(() => {
488
451
  const { currentTime, duration } = state;
489
- // Detect video loop (time jumped backwards significantly)
490
452
  if (currentTime < lastTimeRef.current && lastTimeRef.current > duration * 0.9) {
491
453
  if (resetOnLoop && mode === 'every-loop') {
492
454
  setTriggered(false);
493
455
  }
494
456
  }
495
- // Check if we crossed the trigger time
496
457
  if (!triggered && currentTime >= triggerTime) {
497
458
  setTriggered(true);
498
459
  }
@@ -502,31 +463,6 @@ export function useCuePoint(triggerTime, options = {}) {
502
463
  }
503
464
  /**
504
465
  * Hook to manage multiple cue points with callbacks.
505
- * More flexible than useCuePoint for complex time-based interactions.
506
- *
507
- * @param cuePoints - Array of cue points with times and callbacks
508
- * @param options - Configuration options
509
- *
510
- * @example
511
- * ```tsx
512
- * function InteractiveOverlay() {
513
- * const [showPoll, setShowPoll] = useState(false);
514
- * const [showCTA, setShowCTA] = useState(false);
515
- *
516
- * useCuePoints([
517
- * { time: 5, onTrigger: () => setShowPoll(true) },
518
- * { time: 10, onTrigger: () => setShowCTA(true) },
519
- * { time: 15, onTrigger: () => { setShowPoll(false); setShowCTA(false); } },
520
- * ]);
521
- *
522
- * return (
523
- * <>
524
- * {showPoll && <Poll />}
525
- * {showCTA && <CTAButton />}
526
- * </>
527
- * );
528
- * }
529
- * ```
530
466
  */
531
467
  export function useCuePoints(cuePoints, options = {}) {
532
468
  const { resetOnLoop = true } = options;
@@ -535,13 +471,11 @@ export function useCuePoints(cuePoints, options = {}) {
535
471
  const lastTimeRef = useRef(0);
536
472
  useEffect(() => {
537
473
  const { currentTime, duration } = state;
538
- // Detect video loop
539
474
  if (currentTime < lastTimeRef.current && lastTimeRef.current > duration * 0.9) {
540
475
  if (resetOnLoop) {
541
476
  triggeredRef.current.clear();
542
477
  }
543
478
  }
544
- // Check each cue point
545
479
  for (let i = 0; i < cuePoints.length; i++) {
546
480
  const cue = cuePoints[i];
547
481
  const cueId = cue.id ?? `cue-${i}`;
@@ -557,17 +491,6 @@ export function useCuePoints(cuePoints, options = {}) {
557
491
  }
558
492
  /**
559
493
  * Hook to get the current video progress as a percentage (0-100).
560
- * Useful for progress bars or time-based animations.
561
- *
562
- * @returns Progress percentage (0-100)
563
- *
564
- * @example
565
- * ```tsx
566
- * function ProgressBar() {
567
- * const progress = useVideoProgress();
568
- * return <div style={{ width: `${progress}%` }} className="h-1 bg-white" />;
569
- * }
570
- * ```
571
494
  */
572
495
  export function useVideoProgress() {
573
496
  const { state } = useVideoState();
@@ -1,4 +1,5 @@
1
- export { VideoPlayer, useVideoState, useCuePoint, useCuePoints, useVideoProgress, } from './VideoPlayer';
1
+ export { VideoPlayer, useVideoState, useVideo, // Alias for useVideoState (compatibility)
2
+ useCuePoint, useCuePoints, useVideoProgress, } from './VideoPlayer';
2
3
  export type { VideoState, VideoControls, VideoContextValue, VideoPlayerProps, CuePoint, } from './VideoPlayer';
3
4
  export { Slideshow, useSlideshowState, } from './Slideshow';
4
5
  export type { SlideImage, SlideshowState, SlideshowControls, SlideshowContextValue, SlideshowProps, } from './Slideshow';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/overlay/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,WAAW,EACX,aAAa,EACb,WAAW,EACX,YAAY,EACZ,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,UAAU,EACV,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAChB,QAAQ,GACT,MAAM,eAAe,CAAC;AAGvB,OAAO,EACL,SAAS,EACT,iBAAiB,GAClB,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,UAAU,EACV,cAAc,EACd,iBAAiB,EACjB,qBAAqB,EACrB,cAAc,GACf,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,WAAW,EACX,WAAW,EACX,eAAe,GAChB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,eAAe,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/overlay/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,WAAW,EACX,aAAa,EACb,QAAQ,EAAE,0CAA0C;AACpD,WAAW,EACX,YAAY,EACZ,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,UAAU,EACV,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAChB,QAAQ,GACT,MAAM,eAAe,CAAC;AAGvB,OAAO,EACL,SAAS,EACT,iBAAiB,GAClB,MAAM,aAAa,CAAC;AACrB,YAAY,EACV,UAAU,EACV,cAAc,EACd,iBAAiB,EACjB,qBAAqB,EACrB,cAAc,GACf,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,WAAW,EACX,WAAW,EACX,eAAe,GAChB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,eAAe,CAAC"}
@@ -1,5 +1,6 @@
1
- // Video player with HLS streaming support
2
- export { VideoPlayer, useVideoState, useCuePoint, useCuePoints, useVideoProgress, } from './VideoPlayer';
1
+ // Video player with HLS streaming support and route persistence
2
+ export { VideoPlayer, useVideoState, useVideo, // Alias for useVideoState (compatibility)
3
+ useCuePoint, useCuePoints, useVideoProgress, } from './VideoPlayer';
3
4
  // Image slideshow component
4
5
  export { Slideshow, useSlideshowState, } from './Slideshow';
5
6
  // Overlay positioning components
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thewhateverapp/tile-sdk",
3
- "version": "0.12.22",
3
+ "version": "0.13.0",
4
4
  "description": "SDK for building interactive tiles on The Whatever App platform",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",