diff --git a/frontend/app.js b/frontend/app.js index b9fd720..84039ec 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -484,6 +484,152 @@ function updateProfileStatus() { if (sbName) sbName.textContent = (linked && data.profile.name) || ''; } +// ---------- Shared playlist link (WEB mode) ---------- + +function showSharedPlaylistModal(name, url) { + const body = document.createElement('div'); + body.innerHTML = ` +
+ Anyone with this link can add a copy of “${escapeHtml(name)}” to their playlists. +
+ `; + + const copyAction = async () => { + try { + await navigator.clipboard.writeText(url); + toast('Playlist link copied ✓'); + } catch { + const inp = $('sharedPlaylistUrlInput'); + if (inp) { inp.focus(); inp.select(); } + } + }; + + const actions = [ + { label: 'Done', onClick: closeModal }, + { label: 'Copy link', primary: !navigator.share, onClick: copyAction }, + ]; + + if (navigator.share) { + actions.push({ + label: 'Share', + primary: true, + onClick: async () => { + try { + await navigator.share({ + title: `Playlist: ${name}`, + text: `Open playlist “${name}” in YT Player`, + url, + }); + } catch { + // User dismissed share sheet or target rejected + } + }, + }); + } + + showModal('🔗 Share playlist', body, actions); + setTimeout(() => { + const inp = $('sharedPlaylistUrlInput'); + if (inp) { inp.focus(); inp.select(); } + }, 50); +} + +async function sharePlaylist(pl) { + if (!pl || !Array.isArray(pl.videos) || !pl.videos.length) return; + try { + const res = await fetch('/api/playlist/share', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist: { name: pl.name, videos: pl.videos } }), + }); + const j = await res.json().catch(() => null); + if (!j || !j.ok) { + toast('⚠ ' + ((j && j.error) || 'Failed to share playlist')); + return; + } + const url = `${location.origin}/?list=${encodeURIComponent(j.code)}`; + showSharedPlaylistModal(pl.name, url); + } catch { + toast('⚠ Network error — could not share playlist'); + } +} + +// A shared playlist link (?list=code) arriving on this device. +// Adds the playlist to the device's playlists without touching existing data or profiles. +async function adoptSharedPlaylistFromUrl() { + if (!WEB) return; + let code = ''; + try { + code = (new URLSearchParams(location.search).get('list') || '').trim(); + } catch { return; } + if (!code) return; + + // Strip the param FIRST so a reload can't re-trigger the prompt. + try { + const params = new URLSearchParams(location.search); + params.delete('list'); + const qs = params.toString(); + history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash); + } catch {} + + let shared; + try { + const res = await fetch(`/api/playlist/shared?code=${encodeURIComponent(code)}`); + const j = await res.json().catch(() => null); + if (!j || !j.ok || !j.playlist) { + toast('⚠ ' + ((j && j.error) || 'Shared playlist not found')); + return; + } + shared = j.playlist; + } catch { + toast('⚠ Network error — could not load shared playlist'); + return; + } + + // Dedupe BEFORE the prompt so the count the user agrees to is the count they get. + const seenIds = new Set(); + const dedupedVideos = []; + for (const v of (Array.isArray(shared.videos) ? shared.videos : [])) { + if (!v || !v.id || seenIds.has(v.id)) continue; + seenIds.add(v.id); + dedupedVideos.push(slim(v)); + } + const count = dedupedVideos.length; + if (!count) { toast('⚠ That shared playlist has no playable videos'); return; } + + const ok = await new Promise((resolve) => { + const body = document.createElement('div'); + body.innerHTML = ` ++ Add “${escapeHtml(shared.name)}” (${count} video${count === 1 ? '' : 's'}) to your playlists? + This playlist is added alongside your existing playlists and nothing is replaced. +
`; + showModal('⬇ Add shared playlist', body, [ + { label: 'Cancel', onClick: () => { closeModal(); resolve(false); } }, + { label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } }, + ]); + }); + if (!ok) return; + + // Ensure unique name: if name already exists, suffix (2), (3)... + const baseName = (shared.name || 'Shared Playlist').trim() || 'Shared Playlist'; + let finalName = baseName; + let counter = 2; + const existingNames = new Set((data.playlists || []).map((p) => p.name)); + while (existingNames.has(finalName)) { + finalName = `${baseName} (${counter})`; + counter++; + } + + const pl = { id: uid(), name: finalName, videos: dedupedVideos }; + if (!Array.isArray(data.playlists)) data.playlists = []; + data.playlists.push(pl); + persist(); + renderSidebar(); + render(); + toast(`Added “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`); +} + // A shared link (?profile=name) arriving on this device. Adopting replaces the // synced slice of local state, so anything already on the device gets a // confirm first; a link for the profile already linked here is a no-op. @@ -1853,7 +1999,7 @@ function renderUpNext() { const item = document.createElement('div'); item.className = 'upnext-item'; item.innerHTML = ` -