vod frog, frog with the vods
1<!--
2 FrogHeader: Site header with the "vod frog" logo and frog mascot.
3 Includes a lilypad menu (settings + credits) that is:
4 - Always visible fixed bottom-left on desktop
5 - Toggled by tapping the frog on mobile, overlaid on the page
6-->
7<script lang="ts">
8 import LoginButton from './LoginButton.svelte';
9 import WavyButton from './WavyButton.svelte';
10 import WavyBorder from './WavyBorder.svelte';
11 import { playCroak } from './croak';
12 import { getSettings, setSoundEnabled, setPacifistMode } from './settings.svelte';
13 import { getModelStatus, getModelProgress, getModelError, loadModel } from './captions.svelte';
14
15 let { onHomeClick }: { onHomeClick?: () => void } = $props();
16 const settings = getSettings();
17 let menuOpen = $state(false);
18 let showCreditsModal = $state(false);
19 let showSettingsModal = $state(false);
20
21 function handleClick(e: MouseEvent) {
22 if (onHomeClick) {
23 e.preventDefault();
24 onHomeClick();
25 }
26 }
27
28 function onFrogClick() {
29 playCroak();
30 if (window.innerWidth <= 600) {
31 menuOpen = !menuOpen;
32 }
33 }
34
35 function onFrogTouch(e: TouchEvent) {
36 e.preventDefault();
37 onFrogClick();
38 }
39
40 function openCredits() {
41 playCroak();
42 showCreditsModal = true;
43 showSettingsModal = false;
44 menuOpen = false;
45 }
46
47 function closeCredits() {
48 showCreditsModal = false;
49 }
50
51 function openSettings() {
52 playCroak();
53 showSettingsModal = true;
54 showCreditsModal = false;
55 menuOpen = false;
56 }
57
58 function closeSettings() {
59 showSettingsModal = false;
60 }
61
62 function onBackdropClick(e: MouseEvent) {
63 if (e.target === e.currentTarget) {
64 closeCredits();
65 closeSettings();
66 }
67 }
68
69 function onKeyDown(e: KeyboardEvent) {
70 if (e.key === 'Escape') {
71 closeCredits();
72 closeSettings();
73 }
74 }
75</script>
76
77<svelte:window onkeydown={onKeyDown} />
78
79<header class="frog-header">
80 <div class="login-area">
81 <LoginButton />
82 </div>
83
84 <div class="title-area">
85 <a href="/" class="logo-link" onclick={handleClick}><h1 class="logo-text">vod frog</h1></a>
86 <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
87 <div class="frog-area">
88 <img
89 src="/froggie.png"
90 alt="A cute frog"
91 class="header-frog"
92 class:menu-open={menuOpen}
93 onclick={onFrogClick}
94 ontouchend={onFrogTouch}
95 />
96 <!-- Mobile: menu drops down directly under the frog -->
97 <div class="lily-menu mobile-lily" class:open={menuOpen}>
98 <button class="lily-item" onclick={openSettings}>
99 <img src="/lilymenu.svg" alt="" class="lily-img" />
100 <span class="lily-label">settings</span>
101 </button>
102 <button class="lily-item" onclick={openCredits}>
103 <img src="/lilymenu.svg" alt="" class="lily-img" />
104 <span class="lily-label">credits</span>
105 </button>
106 </div>
107 </div>
108 </div>
109
110 <div class="subtitle-lines">
111 <p class="subtitle">an exploration by</p>
112 <p class="subtitle"><a href="https://witchsky.app/profile/goose.art" target="_blank" class="subtitle-link">@goose.art</a> using <a href="https://witchsky.app/profile/stream.place" target="_blank" class="subtitle-link">@stream.place</a></p>
113 </div>
114</header>
115
116<!-- Desktop: lilypad menu fixed bottom-left -->
117<div class="lily-menu desktop-lily">
118 <button class="lily-item" onclick={openSettings}>
119 <img src="/lilymenu.svg" alt="" class="lily-img" />
120 <span class="lily-label">settings</span>
121 </button>
122 <button class="lily-item" onclick={openCredits}>
123 <img src="/lilymenu.svg" alt="" class="lily-img" />
124 <span class="lily-label">credits</span>
125 </button>
126</div>
127
128{#if showCreditsModal}
129 <!-- svelte-ignore a11y_no_static_element_interactions -->
130 <div class="modal-backdrop" onclick={onBackdropClick}>
131 <div class="modal-content">
132 <WavyBorder seed="credits-modal" fill="#39FF44" strokeColor="#0A182B" strokeWidth={2.5} padding={48}>
133 <div class="modal-body">
134 <h2 class="modal-title">credits</h2>
135 <div class="credit-item">
136 <p class="credit-label">Frog Croaking</p>
137 <p class="credit-author">by DrinkingWindGames</p>
138 <a href="https://freesound.org/s/848549/" target="_blank" class="credit-link">
139 freesound.org/s/848549/
140 </a>
141 <p class="credit-license">License: Attribution 4.0</p>
142 </div>
143 <WavyButton seed="close-credits" fill="#0A182B" textColor="#FFDEED" onclick={closeCredits}>close</WavyButton>
144 </div>
145 </WavyBorder>
146 </div>
147 </div>
148{/if}
149
150{#if showSettingsModal}
151 <!-- svelte-ignore a11y_no_static_element_interactions -->
152 <div class="modal-backdrop" onclick={onBackdropClick}>
153 <div class="modal-content">
154 <WavyBorder seed="settings-modal" fill="#39FF44" strokeColor="#0A182B" strokeWidth={2.5} padding={48}>
155 <div class="modal-body">
156 <h2 class="modal-title">settings</h2>
157
158 <div class="setting-row">
159 <span class="setting-label">sound</span>
160 <WavyButton
161 seed="toggle-sound"
162 fill={settings.soundEnabled ? '#39FF44' : '#0A182B'}
163 textColor={settings.soundEnabled ? '#0A182B' : '#FFDEED'}
164 onclick={() => setSoundEnabled(!settings.soundEnabled)}
165 >{settings.soundEnabled ? 'on' : 'off'}</WavyButton>
166 </div>
167
168 <div class="setting-row">
169 <span class="setting-label">pacifist mode</span>
170 <WavyButton
171 seed="toggle-pacifist"
172 fill={settings.pacifistMode ? '#39FF44' : '#0A182B'}
173 textColor={settings.pacifistMode ? '#0A182B' : '#FFDEED'}
174 onclick={() => setPacifistMode(!settings.pacifistMode)}
175 >{settings.pacifistMode ? 'on' : 'off'}</WavyButton>
176 </div>
177
178 <p class="setting-hint">pacifist mode disables the flies</p>
179
180 <hr class="setting-divider" />
181
182 <h3 class="setting-section-title">closed captions</h3>
183
184 {#if getModelStatus() === 'ready'}
185 <div class="model-status ready">
186 <span class="model-dot"></span>
187 model loaded
188 </div>
189 {:else if getModelStatus() === 'loading'}
190 <div class="model-loading">
191 <p class="model-loading-text">downloading model... {Math.round(getModelProgress())}%</p>
192 <div class="progress-track">
193 <div class="progress-fill" style="width: {getModelProgress()}%;"></div>
194 </div>
195 </div>
196 {:else if getModelStatus() === 'error'}
197 <p class="model-error">{getModelError()}</p>
198 <WavyButton seed="retry-model" fill="#FF3992" textColor="#FFDEED" onclick={loadModel}>retry</WavyButton>
199 {:else}
200 <WavyButton seed="download-model" fill="#0A182B" textColor="#FFDEED" onclick={loadModel}>download</WavyButton>
201 {/if}
202
203 <p class="setting-hint">
204 downloads whisper-tiny (~40mb) to run speech-to-text locally in your browser.
205 captions are generated on-device — no audio is sent anywhere.
206 results may be inaccurate, especially with background noise.
207 </p>
208
209 <WavyButton seed="close-settings" fill="#0A182B" textColor="#FFDEED" onclick={closeSettings}>close</WavyButton>
210 </div>
211 </WavyBorder>
212 </div>
213 </div>
214{/if}
215
216<style>
217 .frog-header {
218 padding: 30px 20px 50px;
219 position: relative;
220 }
221
222 .login-area {
223 position: absolute;
224 top: 30px;
225 right: 20px;
226 z-index: 10;
227 }
228
229 .title-area {
230 position: relative;
231 display: inline-block;
232 }
233
234 .logo-link {
235 text-decoration: none;
236 color: inherit;
237 }
238
239 .logo-text {
240 font-family: 'PicNic', cursive, system-ui;
241 font-size: clamp(3rem, 8vw, 5.5rem);
242 color: #0A182B;
243 margin: 0;
244 line-height: 0.9;
245 font-weight: 400;
246 letter-spacing: -1px;
247 position: relative;
248 z-index: 1;
249 }
250
251 .frog-area {
252 position: absolute;
253 right: -140px;
254 top: -50px;
255 z-index: 12;
256 }
257
258 .header-frog {
259 width: clamp(150px, 18vw, 280px);
260 height: auto;
261 transform: rotate(-10deg);
262 transform-origin: center center;
263 filter: drop-shadow(2px 4px 6px rgba(10, 24, 43, 0.3));
264 transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
265 }
266
267
268
269 /* ---- Lilypad menu ---- */
270 .lily-menu {
271 display: flex;
272 flex-direction: column;
273 align-items: flex-start;
274 gap: 8px;
275 }
276
277 .desktop-lily {
278 position: fixed;
279 bottom: 16px;
280 left: 16px;
281 z-index: 100;
282 }
283
284 .mobile-lily {
285 display: none;
286 }
287
288 .lily-item {
289 all: unset;
290 position: relative;
291 display: flex;
292 align-items: center;
293 justify-content: center;
294 cursor: pointer;
295 transition: transform 0.2s ease;
296 }
297
298 .lily-item:hover {
299 transform: scale(1.08) rotate(-3deg);
300 }
301
302 /* Offset alternating pads for a natural stagger */
303 .lily-item:nth-child(even) {
304 margin-left: 24px;
305 }
306
307 .lily-img {
308 width: clamp(70px, 10vw, 100px);
309 height: auto;
310 filter: drop-shadow(1px 2px 3px rgba(10, 24, 43, 0.25));
311 }
312
313 .lily-label {
314 position: absolute;
315 font-family: 'PicNic', cursive, system-ui;
316 font-size: clamp(0.7rem, 1.2vw, 0.9rem);
317 color: #0A182B;
318 pointer-events: none;
319 }
320
321 /* Credits modal */
322 .modal-backdrop {
323 position: fixed;
324 inset: 0;
325 background: rgba(10, 24, 43, 0.6);
326 z-index: 1000;
327 display: flex;
328 align-items: center;
329 justify-content: center;
330 padding: 20px;
331 }
332
333 .modal-content {
334 width: min(500px, 90vw);
335 }
336
337 .modal-body {
338 display: flex;
339 flex-direction: column;
340 align-items: center;
341 text-align: center;
342 gap: 12px;
343 }
344
345 .modal-title {
346 font-family: 'PicNic', cursive, system-ui;
347 font-size: clamp(1.6rem, 3.5vw, 2.2rem);
348 color: #0A182B;
349 margin: 0;
350 }
351
352 /* Settings */
353 .setting-row {
354 display: flex;
355 align-items: center;
356 justify-content: space-between;
357 width: min(280px, 70vw);
358 gap: 16px;
359 }
360
361 .setting-label {
362 font-family: 'PicNic', cursive, system-ui;
363 font-size: 1.1rem;
364 color: #0A182B;
365 }
366
367
368 .setting-hint {
369 font-family: 'Fang', system-ui, sans-serif;
370 font-size: 0.75rem;
371 color: #0A182B;
372 opacity: 0.5;
373 margin: 0;
374 font-style: italic;
375 max-width: 300px;
376 line-height: 1.5;
377 }
378
379 .setting-divider {
380 border: none;
381 border-top: 1.5px solid rgba(10, 24, 43, 0.15);
382 width: min(280px, 70vw);
383 margin: 8px 0;
384 }
385
386 .setting-section-title {
387 font-family: 'PicNic', cursive, system-ui;
388 font-size: 1.2rem;
389 color: #0A182B;
390 margin: 0;
391 }
392
393 .model-status {
394 font-family: 'Fang', system-ui, sans-serif;
395 font-size: 0.9rem;
396 color: #0A182B;
397 display: flex;
398 align-items: center;
399 gap: 8px;
400 }
401
402 .model-dot {
403 width: 10px;
404 height: 10px;
405 border-radius: 50%;
406 background: #39FF44;
407 box-shadow: 0 0 6px #39FF44;
408 }
409
410 .model-loading {
411 display: flex;
412 flex-direction: column;
413 align-items: center;
414 gap: 6px;
415 width: min(280px, 70vw);
416 }
417
418 .model-loading-text {
419 font-family: 'Fang', system-ui, sans-serif;
420 font-size: 0.85rem;
421 color: #0A182B;
422 margin: 0;
423 }
424
425 .progress-track {
426 width: 100%;
427 height: 8px;
428 background: rgba(10, 24, 43, 0.15);
429 border-radius: 4px;
430 overflow: hidden;
431 }
432
433 .progress-fill {
434 height: 100%;
435 background: #3992FF;
436 border-radius: 4px;
437 transition: width 0.3s ease;
438 }
439
440 .model-error {
441 font-family: 'Fang', system-ui, sans-serif;
442 font-size: 0.8rem;
443 color: #FF3992;
444 margin: 0;
445 }
446
447 .credit-item {
448 display: flex;
449 flex-direction: column;
450 gap: 4px;
451 }
452
453 .credit-label {
454 font-family: 'PicNic', cursive, system-ui;
455 font-size: 1.1rem;
456 color: #0A182B;
457 margin: 0;
458 }
459
460 .credit-author {
461 font-family: 'Fang', system-ui, sans-serif;
462 font-size: 0.95rem;
463 color: #0A182B;
464 margin: 0;
465 opacity: 0.8;
466 }
467
468 .credit-link {
469 font-family: 'Fang', system-ui, sans-serif;
470 font-size: 0.9rem;
471 color: #0A182B;
472 text-decoration: underline;
473 text-decoration-color: #FF3992;
474 transition: color 0.15s;
475 }
476
477 .credit-link:hover {
478 color: #FF3992;
479 }
480
481 .credit-license {
482 font-family: 'Fang', system-ui, sans-serif;
483 font-size: 0.8rem;
484 color: #0A182B;
485 opacity: 0.65;
486 margin: 0;
487 font-style: italic;
488 }
489
490
491
492 .subtitle-lines {
493 margin-top: 6px;
494 position: relative;
495 z-index: 0;
496 }
497
498 .subtitle-link {
499 color: #0A182B;
500 text-decoration: underline;
501 text-decoration-color: #FF3992;
502 transition: color 0.15s;
503 }
504
505 .subtitle-link:hover {
506 color: #FF3992;
507 }
508
509 .subtitle {
510 font-family: 'PicNic', cursive, system-ui;
511 font-size: clamp(0.85rem, 2vw, 1.15rem);
512 color: #0A182B;
513 margin: 0;
514 line-height: 1.4;
515 opacity: 0.85;
516 }
517
518 /* ---- Mobile ---- */
519 @media (max-width: 600px) {
520 .frog-header {
521 padding: 20px 16px 8px;
522 }
523
524 .frog-area {
525 right: -75px;
526 top: -35px;
527 }
528
529 .header-frog {
530 width: 150px !important;
531 }
532
533 .header-frog.menu-open {
534 transform: rotate(-80deg) scale(1.05);
535 }
536
537 .desktop-lily {
538 display: none;
539 }
540
541 /* On mobile, menu drops from frog, overlaid */
542 .mobile-lily {
543 display: flex;
544 position: absolute;
545 top: 100%;
546 left: 0;
547 z-index: 100;
548 pointer-events: none;
549 opacity: 0;
550 transition: opacity 0.3s ease;
551 }
552
553 .mobile-lily.open {
554 pointer-events: auto;
555 opacity: 1;
556 }
557
558 .lily-item {
559 opacity: 0;
560 transform: translateY(-20px) rotate(-6deg);
561 transition: opacity 0.3s ease, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
562 }
563
564 .lily-menu.open .lily-item {
565 opacity: 1;
566 transform: translateY(0) rotate(0deg);
567 }
568
569 .lily-menu.open .lily-item:nth-child(1) { transition-delay: 0.05s; }
570 .lily-menu.open .lily-item:nth-child(2) { transition-delay: 0.15s; }
571 .lily-menu.open .lily-item:nth-child(3) { transition-delay: 0.25s; }
572
573 .lily-menu:not(.open) .lily-item:nth-child(1) { transition-delay: 0.1s; }
574 .lily-menu:not(.open) .lily-item:nth-child(2) { transition-delay: 0.05s; }
575 .lily-menu:not(.open) .lily-item:nth-child(3) { transition-delay: 0s; }
576
577 .lily-img {
578 width: 90px;
579 }
580
581 .lily-label {
582 font-size: 0.8rem;
583 }
584 }
585</style>