@thewhateverapp/tile-sdk 0.16.3 → 0.16.7

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.
@@ -1,11 +1,21 @@
1
1
  /**
2
- * Video Template - Layout
2
+ * Video Templates - Auto-generated from base-video-worker
3
+ *
4
+ * DO NOT EDIT THIS FILE DIRECTLY!
5
+ * Edit the source files in platform/workers/base-video-worker/ and run:
6
+ * pnpm sync-templates
7
+ *
8
+ * Source files:
9
+ * - TileOverlay: workers/base-video-worker/src/app/tile/TileOverlay.tsx
10
+ * - PageOverlay: workers/base-video-worker/src/app/page/PageOverlay.tsx
11
+ */
12
+ /**
13
+ * Video Layout Template
3
14
  *
4
15
  * This layout wraps both /tile and /page routes using a route group.
5
16
  * The VideoPlayer is rendered ONCE here and persists across route navigation.
6
17
  *
7
18
  * File path: src/app/(video)/layout.tsx
8
- * Routes: src/app/(video)/tile/page.tsx, src/app/(video)/page/page.tsx
9
19
  */
10
20
  export const videoLayoutTemplate = `'use client';
11
21
 
@@ -23,15 +33,15 @@ export default function VideoLayout({ children }: { children: React.ReactNode })
23
33
  /**
24
34
  * Video Tile Overlay Template
25
35
  *
26
- * Overlay-only component for tile view (256×554px).
36
+ * Overlay-only component for tile view (256x554px).
27
37
  * VideoPlayer is already mounted in the parent layout.
28
38
  *
29
39
  * File path: src/app/(video)/tile/page.tsx
30
40
  */
31
41
  export const videoTileOverlayTemplate = `'use client';
32
42
 
33
- import { useVideoState, OverlaySlot, GradientOverlay } from '@thewhateverapp/tile-sdk';
34
- import { useTile } from '@thewhateverapp/tile-sdk';
43
+ import { useState, useEffect, useRef, useCallback } from 'react';
44
+ import { useVideoState, useTile } from '@thewhateverapp/tile-sdk';
35
45
 
36
46
  function formatTime(seconds: number): string {
37
47
  if (!seconds || !isFinite(seconds)) return '0:00';
@@ -40,56 +50,182 @@ function formatTime(seconds: number): string {
40
50
  return \`\${mins}:\${secs.toString().padStart(2, '0')}\`;
41
51
  }
42
52
 
43
- export default function TileOverlay() {
53
+ const CONTROLS_HIDE_DELAY = 3000;
54
+
55
+ export function TileOverlay() {
44
56
  const tile = useTile();
45
57
  const { state, controls } = useVideoState();
58
+
59
+ const [hasInteracted, setHasInteracted] = useState(false);
60
+ const [controlsVisible, setControlsVisible] = useState(false);
61
+ const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
62
+
63
+ const showControls = useCallback(() => {
64
+ setHasInteracted(true);
65
+ setControlsVisible(true);
66
+
67
+ if (hideTimeoutRef.current) {
68
+ clearTimeout(hideTimeoutRef.current);
69
+ }
70
+
71
+ if (state.isPlaying) {
72
+ hideTimeoutRef.current = setTimeout(() => {
73
+ setControlsVisible(false);
74
+ }, CONTROLS_HIDE_DELAY);
75
+ }
76
+ }, [state.isPlaying]);
77
+
78
+ useEffect(() => {
79
+ if (!state.isPlaying && hasInteracted) {
80
+ setControlsVisible(true);
81
+ if (hideTimeoutRef.current) {
82
+ clearTimeout(hideTimeoutRef.current);
83
+ }
84
+ }
85
+ }, [state.isPlaying, hasInteracted]);
86
+
87
+ useEffect(() => {
88
+ return () => {
89
+ if (hideTimeoutRef.current) {
90
+ clearTimeout(hideTimeoutRef.current);
91
+ }
92
+ };
93
+ }, []);
94
+
95
+ const handleInteraction = useCallback(() => {
96
+ if (!hasInteracted) {
97
+ showControls();
98
+ controls.toggle();
99
+ } else if (controlsVisible) {
100
+ controls.toggle();
101
+ showControls();
102
+ } else {
103
+ showControls();
104
+ }
105
+ }, [hasInteracted, controlsVisible, showControls, controls]);
106
+
107
+ const showCenterPlayButton = hasInteracted && !state.isPlaying && !state.isLoading && controlsVisible;
108
+ const showBottomControls = hasInteracted && controlsVisible;
46
109
 
47
110
  return (
48
111
  <>
49
- {/* Expand button - opens full page view */}
50
- <OverlaySlot position="top-right">
112
+ <div
113
+ className="absolute inset-0 z-5 pointer-events-auto cursor-pointer"
114
+ onClick={handleInteraction}
115
+ onMouseMove={() => hasInteracted && showControls()}
116
+ />
117
+
118
+ <div className="absolute top-3 right-3 z-20 pointer-events-auto">
51
119
  <button
52
- onClick={() => tile.navigateToPage()}
120
+ onClick={(e) => {
121
+ e.stopPropagation();
122
+ tile.navigateToPage();
123
+ }}
53
124
  className="bg-black/40 backdrop-blur-sm p-2 rounded-full text-white hover:bg-black/60 transition-colors"
54
125
  aria-label="Expand to full view"
55
126
  >
56
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
127
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57
128
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
58
129
  </svg>
59
130
  </button>
60
- </OverlaySlot>
131
+ </div>
132
+
133
+ <div
134
+ className={\`absolute inset-0 flex items-center justify-center z-10 pointer-events-none transition-opacity duration-300 \${
135
+ showCenterPlayButton ? 'opacity-100' : 'opacity-0'
136
+ }\`}
137
+ >
138
+ <button
139
+ onClick={(e) => {
140
+ e.stopPropagation();
141
+ controls.play();
142
+ showControls();
143
+ }}
144
+ className="bg-black/50 backdrop-blur-sm p-4 rounded-full text-white hover:bg-black/70 transition-colors pointer-events-auto"
145
+ aria-label="Play video"
146
+ style={{ pointerEvents: showCenterPlayButton ? 'auto' : 'none' }}
147
+ >
148
+ <svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
149
+ <path d="M8 5v14l11-7z" />
150
+ </svg>
151
+ </button>
152
+ </div>
153
+
154
+ <div
155
+ className={\`absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-black/80 to-transparent pointer-events-none transition-opacity duration-300 \${
156
+ showBottomControls ? 'opacity-100' : 'opacity-0'
157
+ }\`}
158
+ />
159
+
160
+ <div
161
+ className={\`absolute bottom-3 left-3 right-3 z-20 transition-opacity duration-300 \${
162
+ showBottomControls ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
163
+ }\`}
164
+ >
165
+ <div
166
+ className="h-1 bg-white/30 rounded-full overflow-hidden mb-2 cursor-pointer"
167
+ onClick={(e) => {
168
+ e.stopPropagation();
169
+ const rect = e.currentTarget.getBoundingClientRect();
170
+ const percent = (e.clientX - rect.left) / rect.width;
171
+ controls.seek(percent * state.duration);
172
+ showControls();
173
+ }}
174
+ >
175
+ <div
176
+ className="h-full bg-white rounded-full transition-all duration-100"
177
+ style={{ width: \`\${(state.currentTime / state.duration) * 100 || 0}%\` }}
178
+ />
179
+ </div>
180
+
181
+ <div className="flex items-center justify-between text-white text-xs">
182
+ <div className="flex items-center gap-2">
183
+ <button
184
+ onClick={(e) => {
185
+ e.stopPropagation();
186
+ controls.toggle();
187
+ showControls();
188
+ }}
189
+ className="p-1 hover:bg-white/20 rounded transition-colors"
190
+ aria-label={state.isPlaying ? 'Pause' : 'Play'}
191
+ >
192
+ {state.isPlaying ? (
193
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
194
+ <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
195
+ </svg>
196
+ ) : (
197
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
198
+ <path d="M8 5v14l11-7z" />
199
+ </svg>
200
+ )}
201
+ </button>
202
+ <span className="font-mono">
203
+ {formatTime(state.currentTime)} / {formatTime(state.duration)}
204
+ </span>
205
+ </div>
61
206
 
62
- {/* Play/Pause button in center when paused */}
63
- {!state.isPlaying && !state.isLoading && (
64
- <OverlaySlot position="center">
65
207
  <button
66
- onClick={controls.play}
67
- className="w-16 h-16 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
208
+ onClick={(e) => {
209
+ e.stopPropagation();
210
+ controls.setMuted(!state.muted);
211
+ showControls();
212
+ }}
213
+ className="p-1 hover:bg-white/20 rounded transition-colors"
214
+ aria-label={state.muted ? 'Unmute' : 'Mute'}
68
215
  >
69
- <svg className="w-8 h-8 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
70
- <path d="M8 5v14l11-7z" />
71
- </svg>
216
+ {state.muted ? (
217
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
218
+ <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" />
219
+ </svg>
220
+ ) : (
221
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
222
+ <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" />
223
+ </svg>
224
+ )}
72
225
  </button>
73
- </OverlaySlot>
74
- )}
75
-
76
- {/* Bottom gradient with progress */}
77
- <GradientOverlay position="bottom" className="p-4">
78
- <div className="space-y-2">
79
- {/* Progress bar */}
80
- <div className="h-1 bg-white/30 rounded-full overflow-hidden">
81
- <div
82
- className="h-full bg-white rounded-full transition-all duration-200"
83
- style={{ width: \`\${(state.currentTime / state.duration) * 100 || 0}%\` }}
84
- />
85
- </div>
86
- {/* Time display */}
87
- <div className="flex justify-between text-xs text-white/80">
88
- <span>{formatTime(state.currentTime)}</span>
89
- <span>{formatTime(state.duration)}</span>
90
- </div>
91
226
  </div>
92
- </GradientOverlay>
227
+ </div>
228
+
93
229
  </>
94
230
  );
95
231
  }
@@ -104,7 +240,8 @@ export default function TileOverlay() {
104
240
  */
105
241
  export const videoPageOverlayTemplate = `'use client';
106
242
 
107
- import { useVideoState, OverlaySlot, GradientOverlay } from '@thewhateverapp/tile-sdk';
243
+ import { useState, useEffect, useCallback } from 'react';
244
+ import { useVideoState, useTile } from '@thewhateverapp/tile-sdk';
108
245
 
109
246
  function formatTime(seconds: number): string {
110
247
  if (!seconds || !isFinite(seconds)) return '0:00';
@@ -113,50 +250,134 @@ function formatTime(seconds: number): string {
113
250
  return \`\${mins}:\${secs.toString().padStart(2, '0')}\`;
114
251
  }
115
252
 
116
- export default function PageOverlay() {
253
+ export function PageOverlay() {
254
+ const tile = useTile();
117
255
  const { state, controls } = useVideoState();
256
+ const [showControls, setShowControls] = useState(true);
257
+ const [hideTimeout, setHideTimeout] = useState<NodeJS.Timeout | null>(null);
258
+
259
+ // Auto-hide controls after 3 seconds of inactivity when playing
260
+ const resetHideTimer = useCallback(() => {
261
+ if (hideTimeout) {
262
+ clearTimeout(hideTimeout);
263
+ }
264
+ setShowControls(true);
265
+
266
+ if (state.isPlaying) {
267
+ const timeout = setTimeout(() => {
268
+ setShowControls(false);
269
+ }, 3000);
270
+ setHideTimeout(timeout);
271
+ }
272
+ }, [state.isPlaying, hideTimeout]);
273
+
274
+ // Show controls when video is paused
275
+ useEffect(() => {
276
+ if (!state.isPlaying) {
277
+ setShowControls(true);
278
+ if (hideTimeout) {
279
+ clearTimeout(hideTimeout);
280
+ setHideTimeout(null);
281
+ }
282
+ } else {
283
+ resetHideTimer();
284
+ }
285
+ }, [state.isPlaying]);
286
+
287
+ // Cleanup timeout on unmount
288
+ useEffect(() => {
289
+ return () => {
290
+ if (hideTimeout) {
291
+ clearTimeout(hideTimeout);
292
+ }
293
+ };
294
+ }, [hideTimeout]);
295
+
296
+ // Toggle controls on tap/click
297
+ const handleToggleControls = useCallback((e: React.MouseEvent | React.TouchEvent) => {
298
+ // Don't toggle if clicking on a button or control
299
+ const target = e.target as HTMLElement;
300
+ if (target.closest('button, .controls-area')) {
301
+ return;
302
+ }
303
+
304
+ // Prevent default to avoid double-firing on touch devices
305
+ e.preventDefault();
306
+
307
+ if (showControls) {
308
+ setShowControls(false);
309
+ } else {
310
+ resetHideTimer();
311
+ }
312
+ }, [showControls, resetHideTimer]);
313
+
314
+ // Handle mouse movement to show controls
315
+ const handleMouseMove = useCallback(() => {
316
+ if (state.isPlaying) {
317
+ resetHideTimer();
318
+ }
319
+ }, [state.isPlaying, resetHideTimer]);
118
320
 
119
321
  return (
120
- <>
121
- {/* Play/Pause button in center when paused */}
322
+ <div
323
+ className="absolute inset-0 z-20 pointer-events-auto"
324
+ onClick={handleToggleControls}
325
+ onTouchEnd={handleToggleControls}
326
+ onMouseMove={handleMouseMove}
327
+ >
328
+ {/* Center play button when paused */}
122
329
  {!state.isPlaying && !state.isLoading && (
123
- <OverlaySlot position="center">
330
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-auto">
124
331
  <button
125
332
  onClick={controls.play}
126
- className="w-20 h-20 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
333
+ className="bg-black/50 backdrop-blur-sm p-6 rounded-full text-white hover:bg-black/70 transition-colors"
334
+ aria-label="Play video"
127
335
  >
128
- <svg className="w-10 h-10 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
336
+ <svg className="w-16 h-16" fill="currentColor" viewBox="0 0 24 24">
129
337
  <path d="M8 5v14l11-7z" />
130
338
  </svg>
131
339
  </button>
132
- </OverlaySlot>
340
+ </div>
133
341
  )}
134
342
 
135
- {/* Bottom gradient with controls */}
136
- <GradientOverlay position="bottom" className="p-6">
137
- <div className="space-y-3">
138
- {/* Progress bar */}
343
+ {/* Controls - toggleable */}
344
+ <div
345
+ className={\`controls-area absolute bottom-0 left-0 right-0 transition-opacity duration-300 \${
346
+ showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
347
+ }\`}
348
+ >
349
+ {/* Gradient background */}
350
+ <div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
351
+
352
+ <div className="absolute bottom-4 left-4 right-4 z-20 pointer-events-auto">
353
+ {/* Progress bar / Timeline */}
139
354
  <div
140
- className="h-1.5 bg-white/30 rounded-full overflow-hidden cursor-pointer"
355
+ className="h-1.5 bg-white/30 rounded-full overflow-hidden mb-3 cursor-pointer group hover:h-2 transition-all"
141
356
  onClick={(e) => {
357
+ e.stopPropagation();
142
358
  const rect = e.currentTarget.getBoundingClientRect();
143
359
  const percent = (e.clientX - rect.left) / rect.width;
144
360
  controls.seek(percent * state.duration);
361
+ resetHideTimer();
145
362
  }}
146
363
  >
147
364
  <div
148
- className="h-full bg-white rounded-full transition-all duration-200"
365
+ className="h-full bg-white rounded-full transition-all duration-100 group-hover:bg-blue-400"
149
366
  style={{ width: \`\${(state.currentTime / state.duration) * 100 || 0}%\` }}
150
367
  />
151
368
  </div>
152
369
 
153
370
  {/* Controls row */}
154
- <div className="flex items-center justify-between">
155
- <div className="flex items-center gap-4">
371
+ <div className="flex items-center justify-between text-white">
372
+ <div className="flex items-center gap-3">
156
373
  {/* Play/Pause */}
157
374
  <button
158
- onClick={controls.toggle}
159
- className="text-white hover:text-white/80 transition-colors"
375
+ onClick={(e) => {
376
+ e.stopPropagation();
377
+ controls.toggle();
378
+ }}
379
+ className="p-2 hover:bg-white/20 rounded-full transition-colors"
380
+ aria-label={state.isPlaying ? 'Pause' : 'Play'}
160
381
  >
161
382
  {state.isPlaying ? (
162
383
  <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
@@ -169,10 +390,15 @@ export default function PageOverlay() {
169
390
  )}
170
391
  </button>
171
392
 
172
- {/* Mute toggle */}
393
+ {/* Volume */}
173
394
  <button
174
- onClick={() => controls.setMuted(!state.muted)}
175
- className="text-white hover:text-white/80 transition-colors"
395
+ onClick={(e) => {
396
+ e.stopPropagation();
397
+ controls.setMuted(!state.muted);
398
+ resetHideTimer();
399
+ }}
400
+ className="p-2 hover:bg-white/20 rounded-full transition-colors"
401
+ aria-label={state.muted ? 'Unmute' : 'Mute'}
176
402
  >
177
403
  {state.muted ? (
178
404
  <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
@@ -185,15 +411,29 @@ export default function PageOverlay() {
185
411
  )}
186
412
  </button>
187
413
 
188
- {/* Time display */}
189
- <span className="text-sm text-white/80">
414
+ {/* Time */}
415
+ <span className="text-sm font-mono">
190
416
  {formatTime(state.currentTime)} / {formatTime(state.duration)}
191
417
  </span>
192
418
  </div>
419
+
420
+ {/* Right side - minimize button */}
421
+ <button
422
+ onClick={(e) => {
423
+ e.stopPropagation();
424
+ tile.navigateToTile();
425
+ }}
426
+ className="p-2 hover:bg-white/20 rounded-full transition-colors"
427
+ aria-label="Minimize to tile"
428
+ >
429
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
430
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
431
+ </svg>
432
+ </button>
193
433
  </div>
194
434
  </div>
195
- </GradientOverlay>
196
- </>
435
+ </div>
436
+ </div>
197
437
  );
198
438
  }
199
439
  `;
@@ -207,7 +447,8 @@ export default function PageOverlay() {
207
447
  */
208
448
  export const videoPreviewTileTemplate = `'use client';
209
449
 
210
- import { VideoPlayer, useVideoState, OverlaySlot, GradientOverlay, useTile } from '@thewhateverapp/tile-sdk';
450
+ import { VideoPlayer, useVideoState, useTile } from '@thewhateverapp/tile-sdk';
451
+ import { useState, useEffect, useRef, useCallback } from 'react';
211
452
 
212
453
  function formatTime(seconds: number): string {
213
454
  if (!seconds || !isFinite(seconds)) return '0:00';
@@ -216,56 +457,181 @@ function formatTime(seconds: number): string {
216
457
  return \`\${mins}:\${secs.toString().padStart(2, '0')}\`;
217
458
  }
218
459
 
460
+ const CONTROLS_HIDE_DELAY = 3000;
461
+
219
462
  function TileOverlay() {
220
463
  const tile = useTile();
221
464
  const { state, controls } = useVideoState();
222
465
 
466
+ const [hasInteracted, setHasInteracted] = useState(false);
467
+ const [controlsVisible, setControlsVisible] = useState(false);
468
+ const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
469
+
470
+ const showControls = useCallback(() => {
471
+ setHasInteracted(true);
472
+ setControlsVisible(true);
473
+
474
+ if (hideTimeoutRef.current) {
475
+ clearTimeout(hideTimeoutRef.current);
476
+ }
477
+
478
+ if (state.isPlaying) {
479
+ hideTimeoutRef.current = setTimeout(() => {
480
+ setControlsVisible(false);
481
+ }, CONTROLS_HIDE_DELAY);
482
+ }
483
+ }, [state.isPlaying]);
484
+
485
+ useEffect(() => {
486
+ if (!state.isPlaying && hasInteracted) {
487
+ setControlsVisible(true);
488
+ if (hideTimeoutRef.current) {
489
+ clearTimeout(hideTimeoutRef.current);
490
+ }
491
+ }
492
+ }, [state.isPlaying, hasInteracted]);
493
+
494
+ useEffect(() => {
495
+ return () => {
496
+ if (hideTimeoutRef.current) {
497
+ clearTimeout(hideTimeoutRef.current);
498
+ }
499
+ };
500
+ }, []);
501
+
502
+ const handleInteraction = useCallback(() => {
503
+ if (!hasInteracted) {
504
+ showControls();
505
+ controls.toggle();
506
+ } else if (controlsVisible) {
507
+ controls.toggle();
508
+ showControls();
509
+ } else {
510
+ showControls();
511
+ }
512
+ }, [hasInteracted, controlsVisible, showControls, controls]);
513
+
514
+ const showCenterPlayButton = hasInteracted && !state.isPlaying && !state.isLoading && controlsVisible;
515
+ const showBottomControls = hasInteracted && controlsVisible;
516
+
223
517
  return (
224
518
  <>
225
- {/* Expand button - opens full page view */}
226
- <OverlaySlot position="top-right">
519
+ <div
520
+ className="absolute inset-0 z-5 pointer-events-auto cursor-pointer"
521
+ onClick={handleInteraction}
522
+ onMouseMove={() => hasInteracted && showControls()}
523
+ />
524
+
525
+ <div className="absolute top-3 right-3 z-20 pointer-events-auto">
227
526
  <button
228
- onClick={() => tile.navigateToPage()}
527
+ onClick={(e) => {
528
+ e.stopPropagation();
529
+ tile.navigateToPage();
530
+ }}
229
531
  className="bg-black/40 backdrop-blur-sm p-2 rounded-full text-white hover:bg-black/60 transition-colors"
230
532
  aria-label="Expand to full view"
231
533
  >
232
- <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
534
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
233
535
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
234
536
  </svg>
235
537
  </button>
236
- </OverlaySlot>
538
+ </div>
539
+
540
+ <div
541
+ className={\`absolute inset-0 flex items-center justify-center z-10 pointer-events-none transition-opacity duration-300 \${
542
+ showCenterPlayButton ? 'opacity-100' : 'opacity-0'
543
+ }\`}
544
+ >
545
+ <button
546
+ onClick={(e) => {
547
+ e.stopPropagation();
548
+ controls.play();
549
+ showControls();
550
+ }}
551
+ className="bg-black/50 backdrop-blur-sm p-4 rounded-full text-white hover:bg-black/70 transition-colors pointer-events-auto"
552
+ aria-label="Play video"
553
+ style={{ pointerEvents: showCenterPlayButton ? 'auto' : 'none' }}
554
+ >
555
+ <svg className="w-12 h-12" fill="currentColor" viewBox="0 0 24 24">
556
+ <path d="M8 5v14l11-7z" />
557
+ </svg>
558
+ </button>
559
+ </div>
560
+
561
+ <div
562
+ className={\`absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-black/80 to-transparent pointer-events-none transition-opacity duration-300 \${
563
+ showBottomControls ? 'opacity-100' : 'opacity-0'
564
+ }\`}
565
+ />
566
+
567
+ <div
568
+ className={\`absolute bottom-3 left-3 right-3 z-20 transition-opacity duration-300 \${
569
+ showBottomControls ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
570
+ }\`}
571
+ >
572
+ <div
573
+ className="h-1 bg-white/30 rounded-full overflow-hidden mb-2 cursor-pointer"
574
+ onClick={(e) => {
575
+ e.stopPropagation();
576
+ const rect = e.currentTarget.getBoundingClientRect();
577
+ const percent = (e.clientX - rect.left) / rect.width;
578
+ controls.seek(percent * state.duration);
579
+ showControls();
580
+ }}
581
+ >
582
+ <div
583
+ className="h-full bg-white rounded-full transition-all duration-100"
584
+ style={{ width: \`\${(state.currentTime / state.duration) * 100 || 0}%\` }}
585
+ />
586
+ </div>
587
+
588
+ <div className="flex items-center justify-between text-white text-xs">
589
+ <div className="flex items-center gap-2">
590
+ <button
591
+ onClick={(e) => {
592
+ e.stopPropagation();
593
+ controls.toggle();
594
+ showControls();
595
+ }}
596
+ className="p-1 hover:bg-white/20 rounded transition-colors"
597
+ aria-label={state.isPlaying ? 'Pause' : 'Play'}
598
+ >
599
+ {state.isPlaying ? (
600
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
601
+ <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
602
+ </svg>
603
+ ) : (
604
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
605
+ <path d="M8 5v14l11-7z" />
606
+ </svg>
607
+ )}
608
+ </button>
609
+ <span className="font-mono">
610
+ {formatTime(state.currentTime)} / {formatTime(state.duration)}
611
+ </span>
612
+ </div>
237
613
 
238
- {/* Play/Pause button in center when paused */}
239
- {!state.isPlaying && !state.isLoading && (
240
- <OverlaySlot position="center">
241
614
  <button
242
- onClick={controls.play}
243
- className="w-16 h-16 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
615
+ onClick={(e) => {
616
+ e.stopPropagation();
617
+ controls.setMuted(!state.muted);
618
+ showControls();
619
+ }}
620
+ className="p-1 hover:bg-white/20 rounded transition-colors"
621
+ aria-label={state.muted ? 'Unmute' : 'Mute'}
244
622
  >
245
- <svg className="w-8 h-8 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
246
- <path d="M8 5v14l11-7z" />
247
- </svg>
623
+ {state.muted ? (
624
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
625
+ <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" />
626
+ </svg>
627
+ ) : (
628
+ <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
629
+ <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" />
630
+ </svg>
631
+ )}
248
632
  </button>
249
- </OverlaySlot>
250
- )}
251
-
252
- {/* Bottom gradient with progress */}
253
- <GradientOverlay position="bottom" className="p-4">
254
- <div className="space-y-2">
255
- {/* Progress bar */}
256
- <div className="h-1 bg-white/30 rounded-full overflow-hidden">
257
- <div
258
- className="h-full bg-white rounded-full transition-all duration-200"
259
- style={{ width: \`\${(state.currentTime / state.duration) * 100 || 0}%\` }}
260
- />
261
- </div>
262
- {/* Time display */}
263
- <div className="flex justify-between text-xs text-white/80">
264
- <span>{formatTime(state.currentTime)}</span>
265
- <span>{formatTime(state.duration)}</span>
266
- </div>
267
633
  </div>
268
- </GradientOverlay>
634
+ </div>
269
635
  </>
270
636
  );
271
637
  }
@@ -288,7 +654,8 @@ export default function PreviewTile() {
288
654
  */
289
655
  export const videoPreviewPageTemplate = `'use client';
290
656
 
291
- import { VideoPlayer, useVideoState, OverlaySlot, GradientOverlay } from '@thewhateverapp/tile-sdk';
657
+ import { VideoPlayer, useVideoState, useTile } from '@thewhateverapp/tile-sdk';
658
+ import { useState, useEffect, useCallback } from 'react';
292
659
 
293
660
  function formatTime(seconds: number): string {
294
661
  if (!seconds || !isFinite(seconds)) return '0:00';
@@ -298,49 +665,119 @@ function formatTime(seconds: number): string {
298
665
  }
299
666
 
300
667
  function PageOverlay() {
668
+ const tile = useTile();
301
669
  const { state, controls } = useVideoState();
670
+ const [showControls, setShowControls] = useState(true);
671
+ const [hideTimeout, setHideTimeout] = useState<NodeJS.Timeout | null>(null);
672
+
673
+ const resetHideTimer = useCallback(() => {
674
+ if (hideTimeout) {
675
+ clearTimeout(hideTimeout);
676
+ }
677
+ setShowControls(true);
678
+
679
+ if (state.isPlaying) {
680
+ const timeout = setTimeout(() => {
681
+ setShowControls(false);
682
+ }, 3000);
683
+ setHideTimeout(timeout);
684
+ }
685
+ }, [state.isPlaying, hideTimeout]);
686
+
687
+ useEffect(() => {
688
+ if (!state.isPlaying) {
689
+ setShowControls(true);
690
+ if (hideTimeout) {
691
+ clearTimeout(hideTimeout);
692
+ setHideTimeout(null);
693
+ }
694
+ } else {
695
+ resetHideTimer();
696
+ }
697
+ }, [state.isPlaying]);
698
+
699
+ useEffect(() => {
700
+ return () => {
701
+ if (hideTimeout) {
702
+ clearTimeout(hideTimeout);
703
+ }
704
+ };
705
+ }, [hideTimeout]);
706
+
707
+ const handleToggleControls = useCallback((e: React.MouseEvent | React.TouchEvent) => {
708
+ const target = e.target as HTMLElement;
709
+ if (target.closest('button, .controls-area')) {
710
+ return;
711
+ }
712
+ e.preventDefault();
713
+
714
+ if (showControls) {
715
+ setShowControls(false);
716
+ } else {
717
+ resetHideTimer();
718
+ }
719
+ }, [showControls, resetHideTimer]);
720
+
721
+ const handleMouseMove = useCallback(() => {
722
+ if (state.isPlaying) {
723
+ resetHideTimer();
724
+ }
725
+ }, [state.isPlaying, resetHideTimer]);
302
726
 
303
727
  return (
304
- <>
305
- {/* Play/Pause button in center when paused */}
728
+ <div
729
+ className="absolute inset-0 z-20 pointer-events-auto"
730
+ onClick={handleToggleControls}
731
+ onTouchEnd={handleToggleControls}
732
+ onMouseMove={handleMouseMove}
733
+ >
306
734
  {!state.isPlaying && !state.isLoading && (
307
- <OverlaySlot position="center">
735
+ <div className="absolute inset-0 flex items-center justify-center pointer-events-auto">
308
736
  <button
309
737
  onClick={controls.play}
310
- className="w-20 h-20 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-colors"
738
+ className="bg-black/50 backdrop-blur-sm p-6 rounded-full text-white hover:bg-black/70 transition-colors"
739
+ aria-label="Play video"
311
740
  >
312
- <svg className="w-10 h-10 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
741
+ <svg className="w-16 h-16" fill="currentColor" viewBox="0 0 24 24">
313
742
  <path d="M8 5v14l11-7z" />
314
743
  </svg>
315
744
  </button>
316
- </OverlaySlot>
745
+ </div>
317
746
  )}
318
747
 
319
- {/* Bottom gradient with controls */}
320
- <GradientOverlay position="bottom" className="p-6">
321
- <div className="space-y-3">
322
- {/* Progress bar */}
748
+ <div
749
+ className={\`controls-area absolute bottom-0 left-0 right-0 transition-opacity duration-300 \${
750
+ showControls ? 'opacity-100' : 'opacity-0 pointer-events-none'
751
+ }\`}
752
+ >
753
+ <div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-black/80 to-transparent pointer-events-none" />
754
+
755
+ <div className="absolute bottom-4 left-4 right-4 z-20 pointer-events-auto">
323
756
  <div
324
- className="h-1.5 bg-white/30 rounded-full overflow-hidden cursor-pointer"
757
+ className="h-1.5 bg-white/30 rounded-full overflow-hidden mb-3 cursor-pointer group hover:h-2 transition-all"
325
758
  onClick={(e) => {
759
+ e.stopPropagation();
326
760
  const rect = e.currentTarget.getBoundingClientRect();
327
761
  const percent = (e.clientX - rect.left) / rect.width;
328
762
  controls.seek(percent * state.duration);
763
+ resetHideTimer();
329
764
  }}
330
765
  >
331
766
  <div
332
- className="h-full bg-white rounded-full transition-all duration-200"
767
+ className="h-full bg-white rounded-full transition-all duration-100 group-hover:bg-blue-400"
333
768
  style={{ width: \`\${(state.currentTime / state.duration) * 100 || 0}%\` }}
334
769
  />
335
770
  </div>
336
771
 
337
- {/* Controls row */}
338
- <div className="flex items-center justify-between">
339
- <div className="flex items-center gap-4">
340
- {/* Play/Pause */}
772
+ <div className="flex items-center justify-between text-white">
773
+ <div className="flex items-center gap-3">
341
774
  <button
342
- onClick={controls.toggle}
343
- className="text-white hover:text-white/80 transition-colors"
775
+ onClick={(e) => {
776
+ e.stopPropagation();
777
+ controls.toggle();
778
+ }}
779
+ className="p-2 hover:bg-white/20 rounded-full transition-colors"
780
+ aria-label={state.isPlaying ? 'Pause' : 'Play'}
344
781
  >
345
782
  {state.isPlaying ? (
346
783
  <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
@@ -353,10 +790,14 @@ function PageOverlay() {
353
790
  )}
354
791
  </button>
355
792
 
356
- {/* Mute toggle */}
357
793
  <button
358
- onClick={() => controls.setMuted(!state.muted)}
359
- className="text-white hover:text-white/80 transition-colors"
794
+ onClick={(e) => {
795
+ e.stopPropagation();
796
+ controls.setMuted(!state.muted);
797
+ resetHideTimer();
798
+ }}
799
+ className="p-2 hover:bg-white/20 rounded-full transition-colors"
800
+ aria-label={state.muted ? 'Unmute' : 'Mute'}
360
801
  >
361
802
  {state.muted ? (
362
803
  <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
@@ -369,15 +810,27 @@ function PageOverlay() {
369
810
  )}
370
811
  </button>
371
812
 
372
- {/* Time display */}
373
- <span className="text-sm text-white/80">
813
+ <span className="text-sm font-mono">
374
814
  {formatTime(state.currentTime)} / {formatTime(state.duration)}
375
815
  </span>
376
816
  </div>
817
+
818
+ <button
819
+ onClick={(e) => {
820
+ e.stopPropagation();
821
+ tile.navigateToTile();
822
+ }}
823
+ className="p-2 hover:bg-white/20 rounded-full transition-colors"
824
+ aria-label="Minimize to tile"
825
+ >
826
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
827
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
828
+ </svg>
829
+ </button>
377
830
  </div>
378
831
  </div>
379
- </GradientOverlay>
380
- </>
832
+ </div>
833
+ </div>
381
834
  );
382
835
  }
383
836