Add shareable single-playlist links with additive import

This commit is contained in:
Jonathan Sykes
2026-08-16 13:32:18 +00:00
parent 57413a07a1
commit 27b28f767b
3 changed files with 319 additions and 10 deletions

View File

@@ -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 = `
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
Anyone with this link can add a copy of “<strong>${escapeHtml(name)}</strong>” to their playlists.
</p>
<input id="sharedPlaylistUrlInput" type="text" readonly value="${escapeHtml(url)}" />`;
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 = `
<p style="margin:0;color:var(--text-2);font-size:13px;line-height:1.5">
Add “<strong>${escapeHtml(shared.name)}</strong>” (${count} video${count === 1 ? '' : 's'}) to your playlists?
This playlist is added alongside your existing playlists and nothing is replaced.
</p>`;
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 = `
<img src="${v.thumbnail || ''}" alt="" loading="lazy" />
<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" />
<div class="upnext-info">
<div class="upnext-title"></div>
<div class="upnext-channel"></div>
@@ -2179,7 +2325,16 @@ function renderList() {
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = () => deletePlaylist(pl);
els.listActions.append(playAll, playFull, queueAll, rename, del);
const buttons = [playAll, playFull, queueAll];
if (pl.videos && pl.videos.length >= 1) {
const share = document.createElement('button');
share.textContent = '🔗 Share';
share.onclick = () => sharePlaylist(pl);
buttons.push(share);
}
buttons.push(rename, del);
els.listActions.append(...buttons);
}
}
@@ -2350,7 +2505,7 @@ async function renderSaved() {
row.className = 'card saved-card' + (isCustom ? ' custom' : '');
row.dataset.id = it.id;
row.innerHTML = `
<div class="thumb"><img loading="lazy" src="${v.thumbnail || ''}" alt="" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}</div>
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />${isCustom ? '<span class="edit-badge" title="Edited copy">✂</span>' : ''}</div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
@@ -2414,7 +2569,7 @@ function renderDownloads() {
row.dataset.id = v.id;
row.innerHTML = `
<div class="thumb">
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
<div class="dl-progress"><div class="dl-bar"></div></div>
</div>
<div class="card-info">
@@ -2855,7 +3010,7 @@ function renderCard(v, index, list) {
card.innerHTML = `
<div class="batch-check"></div>
<div class="thumb">
<img loading="lazy" src="${v.thumbnail || ''}" alt="" />
<img loading="lazy" src="${escapeHtml(v.thumbnail || '')}" alt="" />
${v.duration ? `<span class="dur">${fmtTime(v.duration)}</span>` : ''}
<span class="saved-badge" title="${isDownloading ? 'Downloading…' : 'Saved offline'}">${isDownloading ? '' : '⬇'}</span>
${isDownloading ? '<div class="dl-progress"><div class="dl-bar"></div></div>' : ''}
@@ -3892,7 +4047,7 @@ function renderRelated() {
relatedVideos.forEach((v) => {
const item = document.createElement('div');
item.className = 'related-item';
item.innerHTML = `<img src="${v.thumbnail || ''}" alt="" loading="lazy" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
item.innerHTML = `<img src="${escapeHtml(v.thumbnail || '')}" alt="" loading="lazy" /><div class="ri-info"><div class="ri-title"></div><div class="ri-channel"></div></div>`;
item.querySelector('.ri-title').textContent = v.title;
const riChEl = item.querySelector('.ri-channel');
riChEl.textContent = v.channel || '';
@@ -4223,9 +4378,10 @@ async function boot() {
} catch {
// first run / bridge not ready — start with defaults
}
// A ?profile=… share link wins over whatever this device had linked; then
// adopt the server copy if another device pushed a newer one. Both run
// before any rendering so no re-render pass is needed.
// A ?list=… or ?profile=… share link; then adopt the server copy if another
// device pushed a newer one. Both run before any rendering so no re-render
// pass is needed.
await adoptSharedPlaylistFromUrl();
await adoptProfileFromUrl();
await pullProfileIfNewer();
applyAppearance();