vod frog, frog with the vods
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix oauth metdata to allow captions in prod...

+242 -17
+241 -16
src/lib/CaptionEditor.svelte
··· 3 3 Two modes: 4 4 - "edit": Edit the single caption currently showing on screen 5 5 - "full": Full caption list editor with timestamps, text, and speakers 6 + Speakers can be added by searching handles, then assigned via dropdown. 6 7 --> 7 8 <script lang="ts"> 8 9 import { getAuthState } from './auth.svelte'; 9 10 import { putCaptions, toCaptionEntries, type CaptionEntry } from './caption-records'; 11 + import { getProfile, type BskyProfile } from './api'; 10 12 import WavyBorder from './WavyBorder.svelte'; 11 13 import WavyButton from './WavyButton.svelte'; 12 14 import { playCroak } from './croak'; ··· 35 37 let saveResult = $state(''); 36 38 let initialized = false; 37 39 38 - // Initialize editable copy once on open, not on every captions change 40 + // --- Speakers --- 41 + interface Speaker { 42 + did: string; 43 + handle: string; 44 + displayName: string; 45 + avatar?: string; 46 + } 47 + 48 + let speakers = $state<Speaker[]>([]); 49 + let speakerSearch = $state(''); 50 + let speakerSearching = $state(false); 51 + let speakerError = $state(''); 52 + 53 + async function addSpeaker() { 54 + const handle = speakerSearch.trim().replace(/^@/, ''); 55 + if (!handle) return; 56 + if (speakers.some(s => s.handle === handle)) { 57 + speakerError = 'Already added'; 58 + setTimeout(() => speakerError = '', 2000); 59 + return; 60 + } 61 + 62 + speakerSearching = true; 63 + speakerError = ''; 64 + try { 65 + const profile = await getProfile(handle); 66 + speakers = [...speakers, { 67 + did: profile.did, 68 + handle: profile.handle, 69 + displayName: profile.displayName || profile.handle, 70 + avatar: profile.avatar 71 + }]; 72 + speakerSearch = ''; 73 + playCroak(); 74 + } catch { 75 + speakerError = 'Handle not found'; 76 + setTimeout(() => speakerError = '', 2000); 77 + } 78 + speakerSearching = false; 79 + } 80 + 81 + function removeSpeaker(did: string) { 82 + speakers = speakers.filter(s => s.did !== did); 83 + // Clear speaker from any captions using this DID 84 + editCaptions = editCaptions.map(c => c.speaker === did ? { ...c, speaker: '' } : c); 85 + } 86 + 87 + function setAllSpeaker(did: string) { 88 + editCaptions = editCaptions.map(c => ({ ...c, speaker: did })); 89 + playCroak(); 90 + saveResult = `Set all captions to ${speakers.find(s => s.did === did)?.handle || did}`; 91 + setTimeout(() => saveResult = '', 2000); 92 + } 93 + 94 + function speakerLabel(did: string): string { 95 + const s = speakers.find(s => s.did === did); 96 + return s ? s.displayName : did ? did.substring(0, 20) + '...' : ''; 97 + } 98 + 99 + // --- Captions --- 39 100 $effect(() => { 40 101 if (!initialized && captions.length > 0) { 41 102 editCaptions = captions.map(c => ({ ··· 48 109 } 49 110 }); 50 111 51 - // Find the caption currently showing 52 112 let currentCaptionIdx = $derived( 53 113 captions.findIndex(c => currentTime >= c.start && currentTime <= c.end) 54 114 ); ··· 132 192 reader.onload = () => { 133 193 try { 134 194 const data = JSON.parse(reader.result as string); 135 - // Support both { record: { captions } } and { captions } formats 136 195 const caps = data.record?.captions || data.captions; 137 196 if (!Array.isArray(caps)) { 138 197 saveResult = 'Invalid JSON format'; ··· 144 203 end: String(c.end ?? '0'), 145 204 speaker: c.speaker || '' 146 205 })); 147 - // Auto-compute end times from next caption's start 148 206 for (let i = 0; i < editCaptions.length - 1; i++) { 149 207 if (parseFloat(editCaptions[i].end) === 0) { 150 208 editCaptions[i].end = editCaptions[i + 1].start; 151 209 } 152 210 } 211 + // Auto-add speakers found in imported data 212 + for (const c of editCaptions) { 213 + if (c.speaker && !speakers.some(s => s.did === c.speaker)) { 214 + speakers = [...speakers, { did: c.speaker, handle: c.speaker, displayName: c.speaker }]; 215 + // Resolve in background 216 + getProfile(c.speaker).then(p => { 217 + speakers = speakers.map(s => s.did === c.speaker ? { ...s, handle: p.handle, displayName: p.displayName || p.handle, avatar: p.avatar } : s); 218 + }).catch(() => {}); 219 + } 220 + } 153 221 applyEdits(); 154 222 playCroak(); 155 223 saveResult = `Imported ${caps.length} captions`; ··· 159 227 } 160 228 }; 161 229 reader.readAsText(file); 162 - // Reset so same file can be re-imported 163 230 if (fileInput) fileInput.value = ''; 164 231 } 165 232 166 - function formatTime(s: string): string { 167 - const n = parseFloat(s); 168 - if (isNaN(n)) return s; 169 - const mins = Math.floor(n / 60); 170 - const secs = (n % 60).toFixed(1); 171 - return `${mins}:${secs.padStart(4, '0')}`; 172 - } 173 - 174 233 function onBackdropClick(e: MouseEvent) { 175 234 if (e.target === e.currentTarget) onClose(); 235 + } 236 + 237 + function onSpeakerKeydown(e: KeyboardEvent) { 238 + if (e.key === 'Enter') { e.preventDefault(); addSpeaker(); } 176 239 } 177 240 </script> 178 241 ··· 191 254 </div> 192 255 </div> 193 256 257 + <!-- Speakers panel --> 258 + <div class="speakers-panel"> 259 + <div class="speakers-header">speakers</div> 260 + <div class="speakers-search"> 261 + <input 262 + class="speaker-search-input" 263 + placeholder="@handle.bsky.social" 264 + bind:value={speakerSearch} 265 + onkeydown={onSpeakerKeydown} 266 + disabled={speakerSearching} 267 + /> 268 + <WavyButton seed="add-speaker" fill="#0A182B" textColor="#FFDEED" disabled={speakerSearching} onclick={addSpeaker}> 269 + {speakerSearching ? '...' : 'add'} 270 + </WavyButton> 271 + </div> 272 + {#if speakerError} 273 + <p class="speaker-error">{speakerError}</p> 274 + {/if} 275 + {#if speakers.length > 0} 276 + <div class="speakers-list"> 277 + {#each speakers as speaker} 278 + <div class="speaker-tag"> 279 + {#if speaker.avatar} 280 + <img src={speaker.avatar} alt="" class="speaker-avatar" /> 281 + {/if} 282 + <span class="speaker-name">{speaker.displayName}</span> 283 + <button class="speaker-setall" onclick={() => setAllSpeaker(speaker.did)} title="Set all captions to this speaker">set all</button> 284 + <button class="speaker-remove" onclick={() => removeSpeaker(speaker.did)} title="Remove speaker">x</button> 285 + </div> 286 + {/each} 287 + </div> 288 + {/if} 289 + </div> 290 + 194 291 {#if mode === 'edit' && currentCaptionIdx >= 0} 195 292 <div class="single-edit"> 196 293 <label class="field-label"> ··· 206 303 <textarea class="text-input" rows="3" value={editCaptions[currentCaptionIdx]?.text} oninput={(e) => updateCaption(currentCaptionIdx, 'text', e.currentTarget.value)}></textarea> 207 304 </label> 208 305 <label class="field-label"> 209 - <span>speaker (DID)</span> 210 - <input class="speaker-input" value={editCaptions[currentCaptionIdx]?.speaker} placeholder="optional" oninput={(e) => updateCaption(currentCaptionIdx, 'speaker', e.currentTarget.value)} /> 306 + <span>speaker</span> 307 + <select class="speaker-select" value={editCaptions[currentCaptionIdx]?.speaker} onchange={(e) => updateCaption(currentCaptionIdx, 'speaker', e.currentTarget.value)}> 308 + <option value="">— none —</option> 309 + {#each speakers as s} 310 + <option value={s.did}>{s.displayName} (@{s.handle})</option> 311 + {/each} 312 + </select> 211 313 </label> 212 314 </div> 213 315 {:else} ··· 219 321 <span class="time-sep-sm">-</span> 220 322 <input class="time-input-sm" value={cap.end} oninput={(e) => updateCaption(i, 'end', e.currentTarget.value)} /> 221 323 </div> 324 + <select class="speaker-select-sm" value={cap.speaker} onchange={(e) => updateCaption(i, 'speaker', e.currentTarget.value)}> 325 + <option value=""></option> 326 + {#each speakers as s} 327 + <option value={s.did}>{s.displayName}</option> 328 + {/each} 329 + </select> 222 330 <input class="text-input-sm" value={cap.text} oninput={(e) => updateCaption(i, 'text', e.currentTarget.value)} /> 223 331 <button class="delete-btn" onclick={() => deleteCaption(i)} title="Delete">x</button> 224 332 </div> ··· 291 399 gap: 6px; 292 400 } 293 401 402 + /* Speakers panel */ 403 + .speakers-panel { 404 + border-top: 1.5px solid rgba(10, 24, 43, 0.15); 405 + padding-top: 12px; 406 + } 407 + 408 + .speakers-header { 409 + font-family: 'PicNic', cursive, system-ui; 410 + font-size: 1rem; 411 + color: #0A182B; 412 + margin-bottom: 8px; 413 + } 414 + 415 + .speakers-search { 416 + display: flex; 417 + gap: 6px; 418 + align-items: center; 419 + } 420 + 421 + .speaker-search-input { 422 + font-family: 'Fang', system-ui, sans-serif; 423 + font-size: 0.85rem; 424 + padding: 6px 10px; 425 + border: 2px solid #0A182B; 426 + background: #FFDEED; 427 + color: #0A182B; 428 + flex: 1; 429 + outline: none; 430 + } 431 + 432 + .speaker-search-input::placeholder { 433 + color: #0A182B; 434 + opacity: 0.4; 435 + } 436 + 437 + .speaker-error { 438 + font-family: 'Fang', system-ui, sans-serif; 439 + font-size: 0.75rem; 440 + color: #FF3992; 441 + margin: 4px 0 0; 442 + } 443 + 444 + .speakers-list { 445 + display: flex; 446 + flex-wrap: wrap; 447 + gap: 6px; 448 + margin-top: 8px; 449 + } 450 + 451 + .speaker-tag { 452 + display: flex; 453 + align-items: center; 454 + gap: 6px; 455 + background: rgba(10, 24, 43, 0.08); 456 + padding: 4px 8px; 457 + border-radius: 16px; 458 + } 459 + 460 + .speaker-avatar { 461 + width: 20px; 462 + height: 20px; 463 + border-radius: 50%; 464 + object-fit: cover; 465 + } 466 + 467 + .speaker-name { 468 + font-family: 'Fang', system-ui, sans-serif; 469 + font-size: 0.8rem; 470 + color: #0A182B; 471 + } 472 + 473 + .speaker-setall { 474 + all: unset; 475 + font-family: 'Fang', system-ui, sans-serif; 476 + font-size: 0.65rem; 477 + color: #3992FF; 478 + cursor: pointer; 479 + text-decoration: underline; 480 + } 481 + 482 + .speaker-setall:hover { 483 + color: #0A182B; 484 + } 485 + 486 + .speaker-remove { 487 + all: unset; 488 + font-family: monospace; 489 + font-size: 0.75rem; 490 + color: #FF3992; 491 + cursor: pointer; 492 + opacity: 0.6; 493 + } 494 + 495 + .speaker-remove:hover { opacity: 1; } 496 + 497 + /* Speaker selects */ 498 + .speaker-select, .speaker-select-sm { 499 + font-family: 'Fang', system-ui, sans-serif; 500 + border: 2px solid #0A182B; 501 + background: #FFDEED; 502 + color: #0A182B; 503 + outline: none; 504 + } 505 + 506 + .speaker-select { 507 + font-size: 0.85rem; 508 + padding: 6px 8px; 509 + width: 100%; 510 + } 511 + 512 + .speaker-select-sm { 513 + font-size: 0.7rem; 514 + padding: 2px 4px; 515 + width: 80px; 516 + flex-shrink: 0; 517 + } 518 + 294 519 /* Single edit mode */ 295 520 .single-edit { 296 521 display: flex; ··· 326 551 327 552 .time-sep { color: #0A182B; opacity: 0.5; } 328 553 329 - .text-input, .speaker-input { 554 + .text-input { 330 555 font-family: 'Fang', system-ui, sans-serif; 331 556 font-size: 0.9rem; 332 557 padding: 8px 10px;
+1 -1
static/client-metadata.json
··· 4 4 "client_uri": "https://vods.sky.boo", 5 5 "logo_uri": "https://vods.sky.boo/frogicon.png", 6 6 "redirect_uris": ["https://vods.sky.boo/oauth/callback"], 7 - "scope": "atproto repo:sky.boo.vods.watchlist", 7 + "scope": "atproto repo:sky.boo.vods.watchlist repo:boo.sky.vods.captions", 8 8 "grant_types": ["authorization_code", "refresh_token"], 9 9 "response_types": ["code"], 10 10 "token_endpoint_auth_method": "none",