music on atproto
plyr.fm
1<script lang="ts">
2 import { likeTrack, unlikeTrack } from '$lib/tracks.svelte';
3 import { toast } from '$lib/toast.svelte';
4 import { API_URL } from '$lib/config';
5 import type { Playlist } from '$lib/types';
6
7 interface Props {
8 trackId: number;
9 trackTitle: string;
10 trackUri?: string;
11 trackCid?: string;
12 fileId?: string;
13 gated?: boolean;
14 initialLiked: boolean;
15 shareUrl: string;
16 onQueue: () => void;
17 isAuthenticated: boolean;
18 likeDisabled?: boolean;
19 excludePlaylistId?: string;
20 }
21
22 let {
23 trackId,
24 trackTitle,
25 trackUri,
26 trackCid,
27 fileId,
28 gated,
29 initialLiked,
30 shareUrl,
31 onQueue,
32 isAuthenticated,
33 likeDisabled = false,
34 excludePlaylistId
35 }: Props = $props();
36
37 let showMenu = $state(false);
38 let showPlaylistPicker = $state(false);
39 let showCreateForm = $state(false);
40 let newPlaylistName = $state('');
41 let creatingPlaylist = $state(false);
42 let liked = $state(initialLiked);
43 let loading = $state(false);
44 let playlists = $state<Playlist[]>([]);
45 let loadingPlaylists = $state(false);
46 let addingToPlaylist = $state<string | null>(null);
47
48 // filter out the excluded playlist (must be after playlists state declaration)
49 let filteredPlaylists = $derived(
50 excludePlaylistId ? playlists.filter(p => p.id !== excludePlaylistId) : playlists
51 );
52
53 // update liked state when initialLiked changes
54 $effect(() => {
55 liked = initialLiked;
56 });
57
58 function toggleMenu(e: Event) {
59 e.stopPropagation();
60 showMenu = !showMenu;
61 if (!showMenu) {
62 showPlaylistPicker = false;
63 }
64 }
65
66 function closeMenu() {
67 showMenu = false;
68 showPlaylistPicker = false;
69 showCreateForm = false;
70 newPlaylistName = '';
71 }
72
73 function handleQueue(e: Event) {
74 e.stopPropagation();
75 onQueue();
76 closeMenu();
77 }
78
79 async function handleShare(e: Event) {
80 e.stopPropagation();
81 try {
82 await navigator.clipboard.writeText(shareUrl);
83 toast.success('link copied');
84 closeMenu();
85 } catch {
86 toast.error('failed to copy link');
87 }
88 }
89
90 async function handleLike(e: Event) {
91 e.stopPropagation();
92
93 if (loading || likeDisabled) {
94 if (likeDisabled) {
95 toast.error("track's record is unavailable");
96 }
97 return;
98 }
99
100 loading = true;
101 const previousState = liked;
102 liked = !liked;
103
104 try {
105 const success = liked
106 ? await likeTrack(trackId, fileId, gated)
107 : await unlikeTrack(trackId);
108
109 if (!success) {
110 liked = previousState;
111 toast.error('failed to update like');
112 } else {
113 if (liked) {
114 toast.success(`liked ${trackTitle}`);
115 } else {
116 toast.info(`unliked ${trackTitle}`);
117 }
118 }
119 closeMenu();
120 } catch {
121 liked = previousState;
122 toast.error('failed to update like');
123 } finally {
124 loading = false;
125 }
126 }
127
128 async function showPlaylists(e: Event) {
129 e.stopPropagation();
130 if (!trackUri || !trackCid) {
131 toast.error('track cannot be added to playlists');
132 return;
133 }
134
135 showPlaylistPicker = true;
136 if (playlists.length === 0) {
137 loadingPlaylists = true;
138 try {
139 const response = await fetch(`${API_URL}/lists/playlists`, {
140 credentials: 'include'
141 });
142 if (response.ok) {
143 playlists = await response.json();
144 }
145 } catch {
146 toast.error('failed to load playlists');
147 } finally {
148 loadingPlaylists = false;
149 }
150 }
151 }
152
153 async function addToPlaylist(playlist: Playlist, e: Event) {
154 e.stopPropagation();
155 if (!trackUri || !trackCid) return;
156
157 addingToPlaylist = playlist.id;
158 try {
159 const response = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, {
160 method: 'POST',
161 credentials: 'include',
162 headers: { 'Content-Type': 'application/json' },
163 body: JSON.stringify({
164 track_uri: trackUri,
165 track_cid: trackCid
166 })
167 });
168
169 if (response.ok) {
170 toast.success(`added to ${playlist.name}`);
171 closeMenu();
172 } else {
173 const data = await response.json().catch(() => ({}));
174 toast.error(data.detail || 'failed to add to playlist');
175 }
176 } catch {
177 toast.error('failed to add to playlist');
178 } finally {
179 addingToPlaylist = null;
180 }
181 }
182
183 function goBack(e: Event) {
184 e.stopPropagation();
185 if (showCreateForm) {
186 showCreateForm = false;
187 newPlaylistName = '';
188 } else {
189 showPlaylistPicker = false;
190 }
191 }
192
193 async function createPlaylist(e: Event) {
194 e.stopPropagation();
195 if (!newPlaylistName.trim() || !trackUri || !trackCid) return;
196
197 creatingPlaylist = true;
198 try {
199 // create the playlist
200 const createResponse = await fetch(`${API_URL}/lists/playlists`, {
201 method: 'POST',
202 credentials: 'include',
203 headers: { 'Content-Type': 'application/json' },
204 body: JSON.stringify({ name: newPlaylistName.trim() })
205 });
206
207 if (!createResponse.ok) {
208 const data = await createResponse.json().catch(() => ({}));
209 throw new Error(data.detail || 'failed to create playlist');
210 }
211
212 const playlist = await createResponse.json();
213
214 // add the track to the new playlist
215 const addResponse = await fetch(`${API_URL}/lists/playlists/${playlist.id}/tracks`, {
216 method: 'POST',
217 credentials: 'include',
218 headers: { 'Content-Type': 'application/json' },
219 body: JSON.stringify({
220 track_uri: trackUri,
221 track_cid: trackCid
222 })
223 });
224
225 if (addResponse.ok) {
226 toast.success(`created "${playlist.name}" and added track`);
227 } else {
228 toast.success(`created "${playlist.name}"`);
229 }
230
231 closeMenu();
232 } catch (err) {
233 toast.error(err instanceof Error ? err.message : 'failed to create playlist');
234 } finally {
235 creatingPlaylist = false;
236 }
237 }
238</script>
239
240<div class="actions-menu">
241 <button class="menu-button" onclick={toggleMenu} title="actions">
242 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
243 <circle cx="12" cy="5" r="1"></circle>
244 <circle cx="12" cy="12" r="1"></circle>
245 <circle cx="12" cy="19" r="1"></circle>
246 </svg>
247 </button>
248
249 {#if showMenu}
250 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
251 <div class="menu-backdrop" role="presentation" onclick={closeMenu}></div>
252 <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
253 <div class="menu-panel" role="menu" tabindex="-1" onclick={(e) => {
254 // don't stop propagation for links - let SvelteKit handle navigation
255 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) {
256 return;
257 }
258 e.stopPropagation();
259 }}>
260 {#if !showPlaylistPicker}
261 {#if isAuthenticated}
262 <button class="menu-item" onclick={handleLike} disabled={loading || likeDisabled} class:disabled={likeDisabled}>
263 <svg width="18" height="18" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
264 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
265 </svg>
266 <span>{liked ? 'remove from liked' : 'add to liked'}</span>
267 </button>
268 {#if trackUri && trackCid}
269 <button class="menu-item" onclick={showPlaylists}>
270 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
271 <line x1="8" y1="6" x2="21" y2="6"></line>
272 <line x1="8" y1="12" x2="21" y2="12"></line>
273 <line x1="8" y1="18" x2="21" y2="18"></line>
274 <line x1="3" y1="6" x2="3.01" y2="6"></line>
275 <line x1="3" y1="12" x2="3.01" y2="12"></line>
276 <line x1="3" y1="18" x2="3.01" y2="18"></line>
277 </svg>
278 <span>add to playlist</span>
279 <svg class="chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
280 <path d="M9 18l6-6-6-6"/>
281 </svg>
282 </button>
283 {/if}
284 {/if}
285 <button class="menu-item" onclick={handleQueue}>
286 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
287 <line x1="5" y1="15" x2="5" y2="21"></line>
288 <line x1="2" y1="18" x2="8" y2="18"></line>
289 <line x1="9" y1="6" x2="21" y2="6"></line>
290 <line x1="9" y1="12" x2="21" y2="12"></line>
291 <line x1="9" y1="18" x2="21" y2="18"></line>
292 </svg>
293 <span>add to queue</span>
294 </button>
295 <button class="menu-item" onclick={handleShare}>
296 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
297 <circle cx="18" cy="5" r="3"></circle>
298 <circle cx="6" cy="12" r="3"></circle>
299 <circle cx="18" cy="19" r="3"></circle>
300 <line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
301 <line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
302 </svg>
303 <span>share</span>
304 </button>
305 {:else}
306 <div class="playlist-picker">
307 <button class="back-button" onclick={goBack}>
308 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
309 <path d="M15 18l-6-6 6-6"/>
310 </svg>
311 <span>back</span>
312 </button>
313 {#if showCreateForm}
314 <div class="create-form">
315 <input
316 type="text"
317 bind:value={newPlaylistName}
318 placeholder="playlist name"
319 disabled={creatingPlaylist}
320 onkeydown={(e) => {
321 if (e.key === 'Enter' && newPlaylistName.trim()) {
322 createPlaylist(e);
323 }
324 }}
325 />
326 <button
327 class="create-btn"
328 onclick={createPlaylist}
329 disabled={creatingPlaylist || !newPlaylistName.trim()}
330 >
331 {#if creatingPlaylist}
332 <span class="spinner small"></span>
333 {:else}
334 create & add
335 {/if}
336 </button>
337 </div>
338 {:else}
339 <div class="playlist-list">
340 {#if loadingPlaylists}
341 <div class="loading-state">
342 <span class="spinner"></span>
343 <span>loading...</span>
344 </div>
345 {:else if filteredPlaylists.length === 0}
346 <div class="empty-state">
347 <span>no playlists</span>
348 </div>
349 {:else}
350 {#each filteredPlaylists as playlist}
351 <button
352 class="playlist-item"
353 onclick={(e) => addToPlaylist(playlist, e)}
354 disabled={addingToPlaylist === playlist.id}
355 >
356 {#if playlist.image_url}
357 <img src={playlist.image_url} alt="" class="playlist-thumb" />
358 {:else}
359 <div class="playlist-thumb-placeholder">
360 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
361 <line x1="8" y1="6" x2="21" y2="6"></line>
362 <line x1="8" y1="12" x2="21" y2="12"></line>
363 <line x1="8" y1="18" x2="21" y2="18"></line>
364 <line x1="3" y1="6" x2="3.01" y2="6"></line>
365 <line x1="3" y1="12" x2="3.01" y2="12"></line>
366 <line x1="3" y1="18" x2="3.01" y2="18"></line>
367 </svg>
368 </div>
369 {/if}
370 <span class="playlist-name">{playlist.name}</span>
371 {#if addingToPlaylist === playlist.id}
372 <span class="spinner small"></span>
373 {/if}
374 </button>
375 {/each}
376 {/if}
377 <button class="create-playlist-btn" onclick={(e) => { e.stopPropagation(); showCreateForm = true; }}>
378 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
379 <line x1="12" y1="5" x2="12" y2="19"></line>
380 <line x1="5" y1="12" x2="19" y2="12"></line>
381 </svg>
382 <span>create new playlist</span>
383 </button>
384 </div>
385 {/if}
386 </div>
387 {/if}
388 </div>
389 {/if}
390</div>
391
392<style>
393 .actions-menu {
394 position: relative;
395 }
396
397 .menu-button {
398 width: 32px;
399 height: 32px;
400 display: flex;
401 align-items: center;
402 justify-content: center;
403 background: transparent;
404 border: 1px solid var(--border-default);
405 border-radius: var(--radius-sm);
406 color: var(--text-tertiary);
407 cursor: pointer;
408 transition: all 0.2s;
409 }
410
411 .menu-button:hover {
412 background: var(--bg-tertiary);
413 border-color: var(--accent);
414 color: var(--accent);
415 }
416
417 .menu-backdrop {
418 position: fixed;
419 top: 0;
420 left: 0;
421 right: 0;
422 bottom: 0;
423 z-index: 100;
424 background: rgba(0, 0, 0, 0.4);
425 }
426
427 .menu-panel {
428 position: fixed;
429 top: 0;
430 left: 0;
431 right: 0;
432 background: var(--bg-secondary);
433 border-radius: 0 0 16px 16px;
434 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
435 z-index: 101;
436 animation: slideDown 0.2s ease-out;
437 padding-top: env(safe-area-inset-top, 0);
438 max-height: 70vh;
439 overflow-y: auto;
440 }
441
442 @keyframes slideDown {
443 from {
444 transform: translateY(-100%);
445 }
446 to {
447 transform: translateY(0);
448 }
449 }
450
451 .menu-item {
452 display: flex;
453 align-items: center;
454 gap: 0.75rem;
455 background: transparent;
456 border: none;
457 color: var(--text-primary);
458 cursor: pointer;
459 padding: 1rem 1.25rem;
460 transition: all 0.15s;
461 font-family: inherit;
462 width: 100%;
463 text-align: left;
464 border-bottom: 1px solid var(--border-subtle);
465 }
466
467 .menu-item:last-child {
468 border-bottom: none;
469 }
470
471 .menu-item:hover,
472 .menu-item:active {
473 background: var(--bg-tertiary);
474 }
475
476 .menu-item span {
477 font-size: var(--text-lg);
478 font-weight: 400;
479 flex: 1;
480 }
481
482 .menu-item svg {
483 width: 20px;
484 height: 20px;
485 flex-shrink: 0;
486 }
487
488 .menu-item .chevron {
489 color: var(--text-muted);
490 }
491
492 .menu-item:disabled {
493 opacity: 0.5;
494 cursor: not-allowed;
495 }
496
497 .playlist-picker {
498 display: flex;
499 flex-direction: column;
500 }
501
502 .back-button {
503 display: flex;
504 align-items: center;
505 gap: 0.5rem;
506 padding: 1rem 1.25rem;
507 background: transparent;
508 border: none;
509 border-bottom: 1px solid var(--border-default);
510 color: var(--text-secondary);
511 font-size: var(--text-base);
512 font-family: inherit;
513 cursor: pointer;
514 transition: background 0.15s;
515 }
516
517 .back-button:hover,
518 .back-button:active {
519 background: var(--bg-tertiary);
520 }
521
522 .playlist-list {
523 max-height: 50vh;
524 overflow-y: auto;
525 }
526
527 .playlist-item {
528 width: 100%;
529 display: flex;
530 align-items: center;
531 gap: 0.75rem;
532 padding: 0.875rem 1.25rem;
533 background: transparent;
534 border: none;
535 border-bottom: 1px solid var(--border-subtle);
536 color: var(--text-primary);
537 font-size: var(--text-lg);
538 font-family: inherit;
539 cursor: pointer;
540 transition: background 0.15s;
541 text-align: left;
542 }
543
544 .playlist-item:last-child {
545 border-bottom: none;
546 }
547
548 .playlist-item:hover,
549 .playlist-item:active {
550 background: var(--bg-tertiary);
551 }
552
553 .playlist-item:disabled {
554 opacity: 0.6;
555 }
556
557 .playlist-thumb,
558 .playlist-thumb-placeholder {
559 width: 36px;
560 height: 36px;
561 border-radius: var(--radius-sm);
562 flex-shrink: 0;
563 }
564
565 .playlist-thumb {
566 object-fit: cover;
567 }
568
569 .playlist-thumb-placeholder {
570 background: var(--bg-tertiary);
571 display: flex;
572 align-items: center;
573 justify-content: center;
574 color: var(--text-muted);
575 }
576
577 .playlist-name {
578 flex: 1;
579 min-width: 0;
580 overflow: hidden;
581 text-overflow: ellipsis;
582 white-space: nowrap;
583 }
584
585 .loading-state,
586 .empty-state {
587 display: flex;
588 align-items: center;
589 justify-content: center;
590 gap: 0.5rem;
591 padding: 2rem 1rem;
592 color: var(--text-tertiary);
593 font-size: var(--text-base);
594 }
595
596 .create-playlist-btn {
597 width: 100%;
598 display: flex;
599 align-items: center;
600 gap: 0.75rem;
601 padding: 0.875rem 1.25rem;
602 background: transparent;
603 border: none;
604 border-top: 1px solid var(--border-subtle);
605 color: var(--accent);
606 font-size: var(--text-lg);
607 font-family: inherit;
608 cursor: pointer;
609 transition: background 0.15s;
610 text-align: left;
611 }
612
613 .create-playlist-btn:hover,
614 .create-playlist-btn:active {
615 background: var(--bg-tertiary);
616 }
617
618 .create-form {
619 display: flex;
620 flex-direction: column;
621 gap: 0.75rem;
622 padding: 1rem 1.25rem;
623 }
624
625 .create-form input {
626 width: 100%;
627 padding: 0.75rem 1rem;
628 background: var(--bg-tertiary);
629 border: 1px solid var(--border-default);
630 border-radius: var(--radius-md);
631 color: var(--text-primary);
632 font-family: inherit;
633 font-size: var(--text-lg);
634 }
635
636 .create-form input:focus {
637 outline: none;
638 border-color: var(--accent);
639 }
640
641 .create-form input::placeholder {
642 color: var(--text-muted);
643 }
644
645 .create-form .create-btn {
646 display: flex;
647 align-items: center;
648 justify-content: center;
649 gap: 0.5rem;
650 padding: 0.75rem 1rem;
651 background: var(--accent);
652 border: none;
653 border-radius: var(--radius-md);
654 color: white;
655 font-family: inherit;
656 font-size: var(--text-lg);
657 font-weight: 500;
658 cursor: pointer;
659 transition: opacity 0.15s;
660 }
661
662 .create-form .create-btn:hover:not(:disabled) {
663 opacity: 0.9;
664 }
665
666 .create-form .create-btn:disabled {
667 opacity: 0.5;
668 cursor: not-allowed;
669 }
670
671 .spinner {
672 width: 18px;
673 height: 18px;
674 border: 2px solid var(--border-default);
675 border-top-color: var(--accent);
676 border-radius: var(--radius-full);
677 animation: spin 0.8s linear infinite;
678 }
679
680 .spinner.small {
681 width: 16px;
682 height: 16px;
683 }
684
685 @keyframes spin {
686 to { transform: rotate(360deg); }
687 }
688
689 /* desktop: show as dropdown instead of bottom sheet */
690 @media (min-width: 769px) {
691 .menu-backdrop {
692 background: transparent;
693 }
694
695 .menu-panel {
696 position: absolute;
697 bottom: auto;
698 left: auto;
699 right: 100%;
700 top: 50%;
701 transform: translateY(-50%);
702 margin-right: 0.5rem;
703 border-radius: var(--radius-md);
704 min-width: 180px;
705 max-height: none;
706 animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1);
707 padding-bottom: 0;
708 }
709
710 @keyframes slideIn {
711 from {
712 opacity: 0;
713 transform: translateY(-50%) scale(0.95);
714 }
715 to {
716 opacity: 1;
717 transform: translateY(-50%) scale(1);
718 }
719 }
720
721 .menu-item {
722 padding: 0.75rem 1rem;
723 }
724
725 .menu-item span {
726 font-size: var(--text-base);
727 }
728
729 .menu-item svg {
730 width: 18px;
731 height: 18px;
732 }
733
734 .back-button {
735 padding: 0.75rem 1rem;
736 }
737
738 .playlist-item {
739 padding: 0.625rem 1rem;
740 font-size: var(--text-base);
741 }
742
743 .playlist-thumb,
744 .playlist-thumb-placeholder {
745 width: 32px;
746 height: 32px;
747 }
748
749 .playlist-list {
750 max-height: 200px;
751 }
752
753 .loading-state,
754 .empty-state {
755 padding: 1.5rem 1rem;
756 }
757 }
758</style>