feat: timestamped comment sharing (#739)

* feat: timestamped comment sharing

add youtube-style ?t= param support for deep linking to specific timestamps:
- parse ?t=X on page load and auto-seek to X seconds
- add "link" button to all comments that copies timestamped URL
- existing OpenGraph meta tags already handle link previews

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use onMount for timestamp param, rename link to share

* fix: use reactive effect for timestamp seek, document pattern

* fix: ensure playback continues after seek

* fix: don't force autoplay on timestamp links

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub e027b56d 83ed2fd6

Changed files
+104 -4
docs
frontend
src
routes
track
+53
docs/frontend/state-management.md
··· 133 133 2. no console errors about "Cannot access X before initialization" 134 134 3. UI reflects current variable value 135 135 136 + ### waiting for async conditions with `$effect` 137 + 138 + when you need to perform an action after some async condition is met (like audio being ready), **don't rely on event listeners** - they may not attach in time if the target element doesn't exist yet or the event fires before your listener is registered. 139 + 140 + **instead, use a reactive `$effect` that watches for the conditions to be met:** 141 + 142 + ```typescript 143 + // ❌ WRONG - event listener may not attach in time 144 + onMount(() => { 145 + queue.playNow(track); // triggers async loading in Player component 146 + 147 + // player.audioElement might be undefined here! 148 + // even if it exists, loadedmetadata may fire before this runs 149 + player.audioElement?.addEventListener('loadedmetadata', () => { 150 + player.audioElement.currentTime = seekTime; 151 + }); 152 + }); 153 + ``` 154 + 155 + ```typescript 156 + // ✅ CORRECT - reactive effect waits for conditions 157 + let pendingSeekMs = $state<number | null>(null); 158 + 159 + onMount(() => { 160 + pendingSeekMs = 11000; // store the pending action 161 + queue.playNow(track); // trigger the async operation 162 + }); 163 + 164 + // effect runs whenever dependencies change, including when audio becomes ready 165 + $effect(() => { 166 + if ( 167 + pendingSeekMs !== null && 168 + player.currentTrack?.id === track.id && 169 + player.audioElement && 170 + player.audioElement.readyState >= 1 171 + ) { 172 + player.audioElement.currentTime = pendingSeekMs / 1000; 173 + pendingSeekMs = null; // clear after performing action 174 + } 175 + }); 176 + ``` 177 + 178 + **why this works:** 179 + - `$effect` re-runs whenever any of its dependencies change 180 + - when `player.audioElement` becomes available and ready, the effect fires 181 + - no race condition - the effect will catch the ready state even if it happened "in the past" 182 + - setting `pendingSeekMs = null` ensures the action only runs once 183 + 184 + **use this pattern when:** 185 + - waiting for DOM elements to exist 186 + - waiting for async operations to complete 187 + - coordinating between components that load independently 188 + 136 189 ## global state management 137 190 138 191 ### overview
+51 -4
frontend/src/routes/track/[id]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { fade } from 'svelte/transition'; 3 + import { onMount } from 'svelte'; 3 4 import { browser } from '$app/environment'; 5 + import { page } from '$app/stores'; 4 6 import type { PageData } from './$types'; 5 7 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 6 8 import { API_URL } from '$lib/config'; ··· 231 233 } 232 234 } 233 235 236 + async function copyCommentLink(timestampMs: number) { 237 + const seconds = Math.floor(timestampMs / 1000); 238 + const url = `${window.location.origin}/track/${track.id}?t=${seconds}`; 239 + await navigator.clipboard.writeText(url); 240 + toast.success('link copied'); 241 + } 242 + 234 243 function formatRelativeTime(isoString: string): string { 235 244 const date = new Date(isoString); 236 245 const now = new Date(); ··· 308 317 // track if we've loaded liked state for this track (separate from general load) 309 318 let likedStateLoadedForTrackId = $state<number | null>(null); 310 319 320 + // pending seek time from ?t= URL param (milliseconds) 321 + let pendingSeekMs = $state<number | null>(null); 322 + 311 323 // reload data when navigating between track pages 312 324 // watch data.track.id (from server) not track.id (local state) 313 325 $effect(() => { ··· 324 336 editingCommentId = null; 325 337 editingCommentText = ''; 326 338 likedStateLoadedForTrackId = null; // reset liked state tracking 339 + pendingSeekMs = null; // reset pending seek 327 340 328 341 // sync track from server data 329 342 track = data.track; ··· 355 368 shareUrl = `${window.location.origin}/track/${track.id}`; 356 369 } 357 370 }); 371 + 372 + // handle ?t= timestamp param for deep linking (youtube-style) 373 + onMount(() => { 374 + const t = $page.url.searchParams.get('t'); 375 + if (t) { 376 + const seconds = parseInt(t, 10); 377 + if (!isNaN(seconds) && seconds >= 0) { 378 + pendingSeekMs = seconds * 1000; 379 + // start playing the track 380 + if (track.gated) { 381 + void playTrack(track); 382 + } else { 383 + queue.playNow(track); 384 + } 385 + } 386 + } 387 + }); 388 + 389 + // perform pending seek once track is loaded and ready 390 + $effect(() => { 391 + if ( 392 + pendingSeekMs !== null && 393 + player.currentTrack?.id === track.id && 394 + player.audioElement && 395 + player.audioElement.readyState >= 1 396 + ) { 397 + const seekTo = pendingSeekMs / 1000; 398 + pendingSeekMs = null; 399 + player.audioElement.currentTime = seekTo; 400 + // don't auto-play - browser policy blocks it without user interaction 401 + // user will click play themselves 402 + } 403 + }); 358 404 </script> 359 405 360 406 <svelte:head> ··· 647 693 </div> 648 694 {:else} 649 695 <p class="comment-text">{#each parseTextWithLinks(comment.text) as segment}{#if segment.type === 'link'}<a href={segment.url} target="_blank" rel="noopener noreferrer" class="comment-link">{segment.url}</a>{:else}{segment.content}{/if}{/each}</p> 650 - {#if auth.user?.did === comment.user_did} 651 - <div class="comment-actions"> 696 + <div class="comment-actions"> 697 + <button class="comment-action-btn" onclick={() => copyCommentLink(comment.timestamp_ms)}>share</button> 698 + {#if auth.user?.did === comment.user_did} 652 699 <button class="comment-action-btn" onclick={() => startEditing(comment)}>edit</button> 653 700 <button class="comment-action-btn delete" onclick={() => deleteComment(comment.id)}>delete</button> 654 - </div> 655 - {/if} 701 + {/if} 702 + </div> 656 703 {/if} 657 704 </div> 658 705 </div>