Let a shared playlist be sent to another user by name

This commit is contained in:
Jonathan Sykes
2026-09-08 15:52:46 +08:00
parent 9366b1749c
commit 3e1b56610f
4 changed files with 310 additions and 10 deletions

View File

@@ -492,13 +492,22 @@ function updateProfileStatus() {
// ---------- Shared playlist link (WEB mode) ----------
function showSharedPlaylistModal(name, url) {
function showSharedPlaylistModal(name, url, code) {
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)}" />`;
<input id="sharedPlaylistUrlInput" type="text" readonly value="${escapeHtml(url)}" />
<div class="share-send">
<label class="share-send-label" for="shareSendName">Or send it straight to someone</label>
<div class="share-send-row">
<input id="shareSendName" type="text" autocomplete="off" autocapitalize="none"
spellcheck="false" placeholder="Their username" maxlength="40" />
<button id="shareSendBtn" class="btn" type="button">Send</button>
</div>
<div id="shareSendMsg" class="share-send-msg"></div>
</div>`;
const copyAction = async () => {
try {
@@ -510,6 +519,42 @@ function showSharedPlaylistModal(name, url) {
}
};
// Deliver to another profile by name. The recipient is prompted on their next
// launch (there is no push channel), so this queues rather than pushes.
const sendAction = async () => {
const input = $('shareSendName');
const btn = $('shareSendBtn');
const msg = $('shareSendMsg');
if (!input || !btn || !msg) return;
const to = input.value.trim();
if (!to) { msg.className = 'share-send-msg err'; msg.textContent = 'Enter a username first.'; input.focus(); return; }
btn.disabled = true;
msg.className = 'share-send-msg';
msg.textContent = 'Sending\u2026';
try {
const res = await fetch('/api/playlist/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to, from: (data.profile && data.profile.name) || '', code }),
});
const j = await res.json().catch(() => null);
if (!j || !j.ok) {
msg.className = 'share-send-msg err';
msg.textContent = '\u26a0 ' + ((j && j.error) || 'Could not send');
} else {
msg.className = 'share-send-msg ok';
msg.textContent = `Sent to \u201c${to}\u201d \u2014 they'll see it next time they open YT Player.`;
input.value = '';
}
} catch {
msg.className = 'share-send-msg err';
msg.textContent = '\u26a0 Network error \u2014 could not send';
} finally {
btn.disabled = false;
}
};
const actions = [
{ label: 'Done', onClick: closeModal },
{ label: 'Copy link', primary: !navigator.share, onClick: copyAction },
@@ -534,6 +579,16 @@ function showSharedPlaylistModal(name, url) {
}
showModal('🔗 Share playlist', body, actions);
// showModal moves the body into #modalBody, so the send controls only exist
// in the document from here on — wire them after it, not before.
const sendBtn = $('shareSendBtn');
if (sendBtn) sendBtn.onclick = sendAction;
const sendInput = $('shareSendName');
if (sendInput) {
sendInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); sendAction(); }
});
}
setTimeout(() => {
const inp = $('sharedPlaylistUrlInput');
if (inp) { inp.focus(); inp.select(); }
@@ -554,7 +609,7 @@ async function sharePlaylist(pl) {
return;
}
const url = `${location.origin}/?list=${encodeURIComponent(j.code)}`;
showSharedPlaylistModal(pl.name, url);
showSharedPlaylistModal(pl.name, url, j.code);
} catch {
toast('⚠ Network error — could not share playlist');
}
@@ -578,18 +633,26 @@ async function adoptSharedPlaylistFromUrl() {
history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
} catch {}
await adoptSharedPlaylistByCode(code);
}
// Fetch a shared playlist by code, confirm, and add it alongside the existing
// playlists. Shared by the ?list= link path and by inbox deliveries, so both
// routes get identical dedupe, naming and confirm behaviour.
// Returns true only when the playlist was actually added.
async function adoptSharedPlaylistByCode(code, { fromName = '' } = {}) {
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;
return false;
}
shared = j.playlist;
} catch {
toast('⚠ Network error — could not load shared playlist');
return;
return false;
}
// Dedupe BEFORE the prompt so the count the user agrees to is the count they get.
@@ -601,21 +664,27 @@ async function adoptSharedPlaylistFromUrl() {
dedupedVideos.push(slim(v));
}
const count = dedupedVideos.length;
if (!count) { toast('⚠ That shared playlist has no playable videos'); return; }
if (!count) { toast('⚠ That shared playlist has no playable videos'); return false; }
// fromName comes from another user, so it is escaped like any untrusted text.
const senderLine = fromName
? `<strong>${escapeHtml(fromName)}</strong> sent you this playlist.<br />`
: '';
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">
${senderLine}
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); } },
showModal(fromName ? '🎵 Playlist sent to you' : '⬇ Add shared playlist', body, [
{ label: fromName ? 'No thanks' : 'Cancel', onClick: () => { closeModal(); resolve(false); } },
{ label: 'Add playlist', primary: true, onClick: () => { closeModal(); resolve(true); } },
]);
});
if (!ok) return;
if (!ok) return false;
// Ensure unique name: if name already exists, suffix (2), (3)...
const baseName = (shared.name || 'Shared Playlist').trim() || 'Shared Playlist';
@@ -634,6 +703,38 @@ async function adoptSharedPlaylistFromUrl() {
renderSidebar();
render();
toast(`Added “${finalName}” (${dedupedVideos.length} video${dedupedVideos.length === 1 ? '' : 's'}) ✓`);
return true;
}
// Playlists other users sent to this device's linked profile. Offered one at a
// time on launch; each is cleared from the server inbox once answered, whether
// the user kept it or not, so it is never offered twice.
async function checkPlaylistInbox() {
if (!WEB) return;
const me = data.profile && data.profile.name;
if (!me) return; // deliveries are addressed to a profile, not a device
let items = [];
try {
const res = await fetch(`/api/playlist/inbox?name=${encodeURIComponent(me)}`);
const j = await res.json().catch(() => null);
if (!j || !j.ok || !Array.isArray(j.items)) return;
items = j.items;
} catch {
return; // offline — they stay queued for next launch
}
for (const item of items) {
if (!item || !item.code) continue;
await adoptSharedPlaylistByCode(item.code, { fromName: item.from || '' });
try {
await fetch('/api/playlist/inbox/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: me, id: item.id }),
});
} catch { /* leave it queued; a duplicate prompt beats losing the playlist */ }
}
}
// A shared link (?profile=name) arriving on this device. Adopting replaces the
@@ -4943,6 +5044,9 @@ async function boot() {
await adoptSharedPlaylistFromUrl();
await adoptProfileFromUrl();
await pullProfileIfNewer();
// After the profile is settled — a delivery is addressed to the profile, so
// this must run once we know which one (if any) this device is linked to.
await checkPlaylistInbox();
applyAppearance();
updateProfileStatus();
updateLoopRepeatButtons();