music on atproto
plyr.fm
1<script lang="ts">
2 import { goto } from '$app/navigation';
3 import { browser } from '$app/environment';
4 import { search, type SearchResult } from '$lib/search.svelte';
5 import { onMount, onDestroy } from 'svelte';
6 import SensitiveImage from './SensitiveImage.svelte';
7
8 let inputRef: HTMLInputElement | null = $state(null);
9 let isMobile = $state(false);
10
11 // detect mobile on mount
12 $effect(() => {
13 if (browser) {
14 const checkMobile = () => window.matchMedia('(max-width: 768px)').matches;
15 isMobile = checkMobile();
16 const mediaQuery = window.matchMedia('(max-width: 768px)');
17 const handler = (e: MediaQueryListEvent) => (isMobile = e.matches);
18 mediaQuery.addEventListener('change', handler);
19 return () => mediaQuery.removeEventListener('change', handler);
20 }
21 });
22
23 // register input ref with search state for direct focus (mobile keyboard fix)
24 $effect(() => {
25 if (inputRef) {
26 search.setInputRef(inputRef);
27 }
28 });
29
30 function handleKeydown(event: KeyboardEvent) {
31 if (!search.isOpen) return;
32
33 switch (event.key) {
34 case 'Escape':
35 event.preventDefault();
36 search.close();
37 break;
38 case 'ArrowDown':
39 event.preventDefault();
40 search.selectNext();
41 break;
42 case 'ArrowUp':
43 event.preventDefault();
44 search.selectPrevious();
45 break;
46 case 'Enter': {
47 event.preventDefault();
48 const selected = search.getSelectedResult();
49 if (selected) {
50 navigateToResult(selected);
51 }
52 break;
53 }
54 }
55 }
56
57 function navigateToResult(result: SearchResult) {
58 const href = search.getResultHref(result);
59 search.close();
60 goto(href);
61 }
62
63 function handleBackdropClick(event: MouseEvent) {
64 if (event.target === event.currentTarget) {
65 search.close();
66 }
67 }
68
69 function getResultImage(result: SearchResult): string | null {
70 switch (result.type) {
71 case 'track':
72 return result.image_url;
73 case 'artist':
74 return result.avatar_url;
75 case 'album':
76 return result.image_url;
77 case 'tag':
78 return null;
79 case 'playlist':
80 return result.image_url;
81 }
82 }
83
84 function getResultTitle(result: SearchResult): string {
85 switch (result.type) {
86 case 'track':
87 return result.title;
88 case 'artist':
89 return result.display_name;
90 case 'album':
91 return result.title;
92 case 'tag':
93 return result.name;
94 case 'playlist':
95 return result.name;
96 }
97 }
98
99 function getResultSubtitle(result: SearchResult): string {
100 switch (result.type) {
101 case 'track':
102 return `by ${result.artist_display_name}`;
103 case 'artist':
104 return `@${result.handle}`;
105 case 'album':
106 return `by ${result.artist_display_name}`;
107 case 'tag':
108 return `${result.track_count} track${result.track_count === 1 ? '' : 's'}`;
109 case 'playlist':
110 return `by ${result.owner_display_name} · ${result.track_count} track${result.track_count === 1 ? '' : 's'}`;
111 }
112 }
113
114 function getShortcutHint(): string {
115 // detect platform - use text instead of symbols for clarity
116 if (browser && navigator.platform.toLowerCase().includes('mac')) {
117 return 'Cmd+K';
118 }
119 return 'Ctrl+K';
120 }
121
122 onMount(() => {
123 window.addEventListener('keydown', handleKeydown);
124 });
125
126 onDestroy(() => {
127 if (browser) {
128 window.removeEventListener('keydown', handleKeydown);
129 }
130 });
131</script>
132
133<!-- always render for mobile keyboard focus, use CSS to show/hide -->
134<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
135<div
136 class="search-backdrop"
137 class:open={search.isOpen}
138 role="presentation"
139 onclick={handleBackdropClick}
140>
141 <div class="search-modal" role="dialog" aria-modal="true" aria-label="search">
142 <div class="search-input-wrapper">
143 <svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
144 <circle cx="11" cy="11" r="8"></circle>
145 <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
146 </svg>
147 <input
148 bind:this={inputRef}
149 type="text"
150 class="search-input"
151 placeholder="search tracks, artists, albums, playlists..."
152 value={search.query}
153 oninput={(e) => search.setQuery(e.currentTarget.value)}
154 autocomplete="off"
155 autocorrect="off"
156 autocapitalize="off"
157 spellcheck="false"
158 />
159 {#if search.loading}
160 <div class="search-spinner"></div>
161 {:else if !isMobile}
162 <kbd class="search-shortcut">{getShortcutHint()}</kbd>
163 {/if}
164 </div>
165
166 {#if search.results.length > 0}
167 <div class="search-results">
168 {#each search.results as result, index (result.type + '-' + (result.type === 'track' ? result.id : result.type === 'artist' ? result.did : result.type === 'album' ? result.id : result.type === 'playlist' ? result.id : result.id))}
169 {@const imageUrl = getResultImage(result)}
170 <button
171 class="search-result"
172 class:selected={index === search.selectedIndex}
173 onclick={() => navigateToResult(result)}
174 onmouseenter={() => (search.selectedIndex = index)}
175 >
176 <span class="result-icon" data-type={result.type}>
177 {#if imageUrl}
178 <SensitiveImage src={imageUrl} compact>
179 <img
180 src={imageUrl}
181 alt=""
182 class="result-image"
183 loading="lazy"
184 />
185 </SensitiveImage>
186 {:else if result.type === 'track'}
187 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
188 <path d="M9 18V5l12-2v13"></path>
189 <circle cx="6" cy="18" r="3"></circle>
190 <circle cx="18" cy="16" r="3"></circle>
191 </svg>
192 {:else if result.type === 'artist'}
193 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
194 <circle cx="8" cy="5" r="3" fill="none" />
195 <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke-linecap="round" />
196 </svg>
197 {:else if result.type === 'album'}
198 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
199 <rect x="2" y="2" width="12" height="12" fill="none" />
200 <circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none" />
201 </svg>
202 {:else if result.type === 'tag'}
203 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
204 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
205 <line x1="7" y1="7" x2="7.01" y2="7"></line>
206 </svg>
207 {:else if result.type === 'playlist'}
208 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
209 <line x1="8" y1="6" x2="21" y2="6"></line>
210 <line x1="8" y1="12" x2="21" y2="12"></line>
211 <line x1="8" y1="18" x2="21" y2="18"></line>
212 <line x1="3" y1="6" x2="3.01" y2="6"></line>
213 <line x1="3" y1="12" x2="3.01" y2="12"></line>
214 <line x1="3" y1="18" x2="3.01" y2="18"></line>
215 </svg>
216 {/if}
217 </span>
218 <div class="result-content">
219 <span class="result-title">{getResultTitle(result)}</span>
220 <span class="result-subtitle">{getResultSubtitle(result)}</span>
221 </div>
222 <span class="result-type">{result.type}</span>
223 </button>
224 {/each}
225 </div>
226 {:else if search.query.length >= 2 && !search.loading}
227 <div class="search-empty">
228 no results for "{search.query}"
229 </div>
230 {:else if search.query.length === 0}
231 <div class="search-hints">
232 <p>start typing to search across all content</p>
233 {#if !isMobile}
234 <div class="hint-shortcuts">
235 <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
236 <span><kbd>↵</kbd> select</span>
237 <span><kbd>esc</kbd> close</span>
238 </div>
239 {/if}
240 </div>
241 {/if}
242
243 {#if search.error}
244 <div class="search-error">{search.error}</div>
245 {/if}
246 </div>
247 </div>
248
249<style>
250 .search-backdrop {
251 position: fixed;
252 inset: 0;
253 background: color-mix(in srgb, var(--bg-primary) 60%, transparent);
254 backdrop-filter: blur(4px);
255 -webkit-backdrop-filter: blur(4px);
256 z-index: 9999;
257 display: flex;
258 align-items: flex-start;
259 justify-content: center;
260 padding-top: 15vh;
261 /* hidden by default - use opacity only (not visibility) so input remains focusable for mobile keyboard */
262 opacity: 0;
263 pointer-events: none;
264 transition: opacity 0.15s;
265 }
266
267 .search-backdrop.open {
268 opacity: 1;
269 pointer-events: auto;
270 }
271
272 .search-modal {
273 width: 100%;
274 max-width: 560px;
275 background: color-mix(in srgb, var(--bg-secondary) 95%, transparent);
276 backdrop-filter: blur(20px) saturate(180%);
277 -webkit-backdrop-filter: blur(20px) saturate(180%);
278 border: 1px solid var(--border-subtle);
279 border-radius: var(--radius-xl);
280 box-shadow:
281 0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent),
282 0 0 1px var(--border-subtle) inset;
283 overflow: hidden;
284 margin: 0 1rem;
285 }
286
287 .search-input-wrapper {
288 display: flex;
289 align-items: center;
290 gap: 0.75rem;
291 padding: 1rem 1.25rem;
292 border-bottom: 1px solid var(--border-subtle);
293 background: color-mix(in srgb, var(--bg-tertiary) 50%, transparent);
294 }
295
296 .search-icon {
297 color: var(--text-tertiary);
298 flex-shrink: 0;
299 }
300
301 .search-input {
302 flex: 1;
303 background: transparent;
304 border: none;
305 outline: none;
306 font-size: var(--text-lg);
307 font-family: inherit;
308 color: var(--text-primary);
309 }
310
311 .search-input::placeholder {
312 color: var(--text-muted);
313 }
314
315 .search-shortcut {
316 font-size: var(--text-xs);
317 padding: 0.25rem 0.5rem;
318 background: var(--bg-tertiary);
319 border: 1px solid var(--border-default);
320 border-radius: var(--radius-sm);
321 color: var(--text-muted);
322 font-family: inherit;
323 }
324
325 .search-spinner {
326 width: 16px;
327 height: 16px;
328 border: 2px solid var(--border-default);
329 border-top-color: var(--accent);
330 border-radius: var(--radius-full);
331 animation: spin 0.6s linear infinite;
332 }
333
334 @keyframes spin {
335 to {
336 transform: rotate(360deg);
337 }
338 }
339
340 .search-results {
341 max-height: 400px;
342 overflow-y: auto;
343 padding: 0.5rem;
344 scrollbar-width: thin;
345 scrollbar-color: var(--border-default) transparent;
346 }
347
348 .search-results::-webkit-scrollbar {
349 width: 8px;
350 }
351
352 .search-results::-webkit-scrollbar-track {
353 background: transparent;
354 border-radius: var(--radius-sm);
355 }
356
357 .search-results::-webkit-scrollbar-thumb {
358 background: var(--border-default);
359 border-radius: var(--radius-sm);
360 }
361
362 .search-results::-webkit-scrollbar-thumb:hover {
363 background: var(--border-emphasis);
364 }
365
366 .search-result {
367 display: flex;
368 align-items: center;
369 gap: 0.75rem;
370 width: 100%;
371 padding: 0.75rem;
372 background: transparent;
373 border: none;
374 border-radius: var(--radius-md);
375 cursor: pointer;
376 text-align: left;
377 font-family: inherit;
378 color: var(--text-primary);
379 transition: background 0.1s;
380 }
381
382 .search-result:hover,
383 .search-result.selected {
384 background: var(--bg-hover);
385 }
386
387 .search-result.selected {
388 background: var(--bg-hover);
389 box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent) inset;
390 }
391
392 .result-icon {
393 width: 32px;
394 height: 32px;
395 display: flex;
396 align-items: center;
397 justify-content: center;
398 background: var(--bg-tertiary);
399 border-radius: var(--radius-md);
400 font-size: var(--text-base);
401 flex-shrink: 0;
402 position: relative;
403 overflow: hidden;
404 }
405
406 .result-image {
407 position: absolute;
408 inset: 0;
409 width: 100%;
410 height: 100%;
411 object-fit: cover;
412 border-radius: var(--radius-md);
413 }
414
415 .result-icon[data-type='track'] {
416 color: var(--accent);
417 }
418
419 .result-icon[data-type='artist'] {
420 color: #a78bfa;
421 }
422
423 .result-icon[data-type='album'] {
424 color: #34d399;
425 }
426
427 .result-icon[data-type='tag'] {
428 color: #fbbf24;
429 }
430
431 .result-icon[data-type='playlist'] {
432 color: #f472b6;
433 }
434
435 .result-content {
436 flex: 1;
437 min-width: 0;
438 display: flex;
439 flex-direction: column;
440 gap: 0.15rem;
441 }
442
443 .result-title {
444 font-size: var(--text-base);
445 font-weight: 500;
446 white-space: nowrap;
447 overflow: hidden;
448 text-overflow: ellipsis;
449 }
450
451 .result-subtitle {
452 font-size: var(--text-xs);
453 color: var(--text-secondary);
454 white-space: nowrap;
455 overflow: hidden;
456 text-overflow: ellipsis;
457 }
458
459 .result-type {
460 font-size: 0.6rem;
461 text-transform: uppercase;
462 letter-spacing: 0.03em;
463 color: var(--text-muted);
464 padding: 0.2rem 0.45rem;
465 background: var(--bg-tertiary);
466 border-radius: var(--radius-sm);
467 flex-shrink: 0;
468 }
469
470 .search-empty {
471 padding: 2rem;
472 text-align: center;
473 color: var(--text-secondary);
474 font-size: var(--text-base);
475 }
476
477 .search-hints {
478 padding: 1.5rem 2rem;
479 text-align: center;
480 }
481
482 .search-hints p {
483 margin: 0 0 1rem 0;
484 color: var(--text-secondary);
485 font-size: var(--text-sm);
486 }
487
488 .hint-shortcuts {
489 display: flex;
490 justify-content: center;
491 gap: 1.5rem;
492 color: var(--text-muted);
493 font-size: var(--text-xs);
494 }
495
496 .hint-shortcuts span {
497 display: flex;
498 align-items: center;
499 gap: 0.25rem;
500 }
501
502 .hint-shortcuts kbd {
503 font-size: 0.65rem;
504 padding: 0.15rem 0.35rem;
505 background: var(--bg-tertiary);
506 border: 1px solid var(--border-default);
507 border-radius: var(--radius-sm);
508 font-family: inherit;
509 }
510
511 .search-error {
512 padding: 1rem;
513 text-align: center;
514 color: var(--error);
515 font-size: var(--text-sm);
516 }
517
518 /* mobile optimizations */
519 @media (max-width: 768px) {
520 .search-backdrop {
521 padding-top: 10vh;
522 }
523
524 .search-modal {
525 margin: 0 0.75rem;
526 max-height: 80vh;
527 }
528
529 .search-input-wrapper {
530 padding: 0.875rem 1rem;
531 }
532
533 .search-input {
534 font-size: 16px; /* prevents iOS zoom */
535 }
536
537 .search-input::placeholder {
538 font-size: var(--text-sm);
539 }
540
541 .search-results {
542 max-height: 60vh;
543 }
544
545 .hint-shortcuts {
546 flex-wrap: wrap;
547 gap: 1rem;
548 }
549 }
550
551 /* respect reduced motion */
552 @media (prefers-reduced-motion: reduce) {
553 .search-spinner {
554 animation: none;
555 }
556 }
557</style>