Let a shared playlist be sent to another user by name
This commit is contained in:
122
frontend/app.js
122
frontend/app.js
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user