@thewhateverapp/tile-sdk 0.16.4 → 0.16.8
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/bridge/TileBridge.d.ts.map +1 -1
- package/dist/bridge/TileBridge.js +22 -2
- package/dist/templates/slideshow/layout.tsx.template.d.ts +17 -7
- package/dist/templates/slideshow/layout.tsx.template.d.ts.map +1 -1
- package/dist/templates/slideshow/layout.tsx.template.js +403 -185
- package/dist/templates/video/layout.tsx.template.d.ts +17 -7
- package/dist/templates/video/layout.tsx.template.d.ts.map +1 -1
- package/dist/templates/video/layout.tsx.template.js +575 -122
- package/package.json +5 -1
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Video
|
|
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 (
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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={() =>
|
|
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-
|
|
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
|
-
</
|
|
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={
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
<
|
|
71
|
-
|
|
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
|
-
</
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
330
|
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-auto">
|
|
124
331
|
<button
|
|
125
332
|
onClick={controls.play}
|
|
126
|
-
className="
|
|
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-
|
|
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
|
-
</
|
|
340
|
+
</div>
|
|
133
341
|
)}
|
|
134
342
|
|
|
135
|
-
{/*
|
|
136
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
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-
|
|
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-
|
|
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={
|
|
159
|
-
|
|
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
|
-
{/*
|
|
393
|
+
{/* Volume */}
|
|
173
394
|
<button
|
|
174
|
-
onClick={() =>
|
|
175
|
-
|
|
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
|
|
189
|
-
<span className="text-sm
|
|
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
|
-
</
|
|
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,
|
|
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
|
-
|
|
226
|
-
|
|
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={() =>
|
|
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-
|
|
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
|
-
</
|
|
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={
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
<
|
|
247
|
-
|
|
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
|
-
</
|
|
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,
|
|
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
|
-
|
|
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
|
-
<
|
|
735
|
+
<div className="absolute inset-0 flex items-center justify-center pointer-events-auto">
|
|
308
736
|
<button
|
|
309
737
|
onClick={controls.play}
|
|
310
|
-
className="
|
|
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-
|
|
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
|
-
</
|
|
745
|
+
</div>
|
|
317
746
|
)}
|
|
318
747
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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-
|
|
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
|
-
|
|
338
|
-
|
|
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={
|
|
343
|
-
|
|
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={() =>
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
380
|
-
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
381
834
|
);
|
|
382
835
|
}
|
|
383
836
|
|