Share target: shared YouTube links play, shared audio/video uploads to the user's library; page shortcuts
- manifest share_target (POST multipart: title/text/url + audio/video files) and eight shortcuts (Search, Queue, Saved, Downloads, History, Library, Notes, Settings). /?view=<page> now actually opens that page — the old shortcuts pointed at it but nothing read it. - sw.js receives the share: files are parked in the ytp-share-inbox cache (not a versioned shell cache, so deploys never evict it) and the app is opened; links/text go to /?shared=. A server POST /share-target fallback covers the first visit before a worker is in control (links only). - The app plays a YouTube link found anywhere in the shared text (watch, youtu.be, shorts, live, embed, music; keeps t=), otherwise searches the text. /?shared=<link> also works from an iOS Shortcut (iOS has no share target). - Shared files upload with progress to PUT /api/uploads/shared and join a "Shared uploads" playlist; network failures stay in the inbox for the next open, refusals are dropped with the reason. - The public route is bounded: audio/video only (ffprobe-validated, error text without server paths), 500 MB per file, 2 GB and 20 a day per device, 50 GB for all shared uploads, one at a time per device (all env-tunable). Shared uploads are unlisted: reachable by id, never in anyone else's search. uploads gains owner + listed columns (idempotent ALTER).
This commit is contained in:
135
frontend/app.js
135
frontend/app.js
@@ -11287,6 +11287,139 @@ async function registerServiceWorker() {
|
||||
// /?q=<query> — a shareable search link (same family as Share's /?v=&t=).
|
||||
// Runs after wireUI() so the search form's submit handler exists; the param
|
||||
// is stripped immediately so a reload doesn't re-run the search.
|
||||
// ============================================================================
|
||||
// Share target — links and audio/video files shared to the installed app
|
||||
// (manifest share_target → sw.js receiveShare → here). Android / ChromeOS /
|
||||
// desktop Chrome; iOS has no share target, but an iOS Shortcut can open
|
||||
// /?shared=<link> and land in the same place.
|
||||
// ============================================================================
|
||||
// YouTube video id (+ start time) anywhere in a shared text.
|
||||
function youtubeFromText(text) {
|
||||
const t = String(text || '');
|
||||
const m = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^\s#]*&)?v=|shorts\/|live\/|embed\/|v\/))([\w-]{11})/i.exec(t);
|
||||
if (!m) return null;
|
||||
const tm = /[?&#](?:t|start)=(\d+)(?:s)?/.exec(t.slice(m.index));
|
||||
return { id: m[1], t: tm ? Number(tm[1]) : 0 };
|
||||
}
|
||||
|
||||
const SHARE_INBOX = 'ytp-share-inbox';
|
||||
const SHARED_PLAYLIST = 'Shared uploads';
|
||||
|
||||
function sharedUploadsPlaylist() {
|
||||
let pl = data.playlists.find((p) => p.sharedUploads);
|
||||
if (!pl) {
|
||||
pl = { id: uid(), name: SHARED_PLAYLIST, videos: [], sharedUploads: true };
|
||||
data.playlists.push(pl);
|
||||
}
|
||||
return pl;
|
||||
}
|
||||
|
||||
// PUT the file with upload progress (fetch has none) → { ok, upload | error }.
|
||||
function uploadSharedFile(blob, name, onProgress) {
|
||||
return new Promise((resolve) => {
|
||||
const fp = window.getFingerprint ? window.getFingerprint() : '';
|
||||
const qs = new URLSearchParams({ name, title: name.replace(/\.[^.]+$/, ''), fp });
|
||||
const x = new XMLHttpRequest();
|
||||
x.open('PUT', '/api/uploads/shared?' + qs.toString());
|
||||
x.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); };
|
||||
x.onload = () => {
|
||||
let j = {};
|
||||
try { j = JSON.parse(x.responseText); } catch { /* not JSON */ }
|
||||
resolve(x.status >= 200 && x.status < 300 && j.ok ? { ok: true, upload: j.upload } : { ok: false, status: x.status, error: j.error || 'upload failed (' + x.status + ')' });
|
||||
};
|
||||
x.onerror = () => resolve({ ok: false, status: 0, error: 'connection lost' });
|
||||
x.send(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// Upload every file the share target parked; each finished one joins the
|
||||
// "Shared uploads" playlist. Network failures stay in the inbox for next time.
|
||||
let _sharing = false;
|
||||
async function processSharedFiles() {
|
||||
if (_sharing || !('caches' in window)) return;
|
||||
_sharing = true;
|
||||
try {
|
||||
const inbox = await caches.open(SHARE_INBOX);
|
||||
const keys = await inbox.keys();
|
||||
let done = 0;
|
||||
for (const req of keys) {
|
||||
const res = await inbox.match(req);
|
||||
if (!res) continue;
|
||||
const name = decodeURIComponent(res.headers.get('X-Name') || 'shared');
|
||||
const blob = await res.blob();
|
||||
const t = toast(`Uploading “${name}”…`, { duration: 600000 });
|
||||
const r = await uploadSharedFile(blob, name, (got, total) => {
|
||||
if (t) t.textContent = `Uploading “${name}”… ${Math.round((got / total) * 100)}%`;
|
||||
});
|
||||
if (t) t.remove();
|
||||
if (r.ok && r.upload) {
|
||||
await inbox.delete(req);
|
||||
const u = r.upload;
|
||||
const v = { id: u.id, title: u.title || name, channel: u.artist || 'Shared upload', duration: u.duration || 0 };
|
||||
const pl = sharedUploadsPlaylist();
|
||||
if (!pl.videos.some((x) => x.id === v.id)) pl.videos.unshift(slim(v));
|
||||
persist();
|
||||
done++;
|
||||
toast(`Added “${v.title}” to ${SHARED_PLAYLIST} ✓`);
|
||||
} else if (r.status === 0) {
|
||||
toast('⚠ Upload paused — it will continue when you open the app online');
|
||||
break;
|
||||
} else {
|
||||
await inbox.delete(req); // refused (type, size, quota) — retrying will not help
|
||||
toast('⚠ ' + r.error, { duration: 6000 });
|
||||
}
|
||||
}
|
||||
if (done) {
|
||||
const pl = sharedUploadsPlaylist();
|
||||
view = { type: 'playlist', id: pl.id };
|
||||
render();
|
||||
}
|
||||
} catch { /* inbox unavailable */ } finally { _sharing = false; }
|
||||
}
|
||||
|
||||
function bootShareFromUrl() {
|
||||
let q;
|
||||
try { q = new URLSearchParams(location.search); } catch { return; }
|
||||
const shared = q.get('shared');
|
||||
const files = q.get('share') === 'files';
|
||||
if (shared === null && !files && !q.has('sharefail')) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
['shared', 'share', 'sharefail'].forEach((k) => u.searchParams.delete(k));
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch { /* keep */ }
|
||||
if (q.has('sharefail')) toast('⚠ Open the app once, then share the file again');
|
||||
if (shared) {
|
||||
const yt = youtubeFromText(shared);
|
||||
if (yt) {
|
||||
playVideoAt(data.history.find((x) => x.id === yt.id) || { id: yt.id, title: '' }, yt.t);
|
||||
} else {
|
||||
// Not a video link: search for whatever was shared.
|
||||
const text = shared.replace(/https?:\/\/\S+/g, '').trim() || shared.trim();
|
||||
els.searchInput.value = text.slice(0, 200);
|
||||
els.searchForm.requestSubmit();
|
||||
}
|
||||
}
|
||||
if (files) processSharedFiles();
|
||||
else setTimeout(processSharedFiles, 5000); // anything left from an earlier share
|
||||
}
|
||||
|
||||
// /?view=<page> — manifest shortcuts and links into a page of the app.
|
||||
const URL_VIEWS = new Set(['search', 'queue', 'history', 'saved', 'downloads', 'library', 'notes', 'settings', 'stats']);
|
||||
function bootViewFromUrl() {
|
||||
let v = '';
|
||||
try { v = new URLSearchParams(location.search).get('view') || ''; } catch { return; }
|
||||
if (!v) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('view');
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch { /* keep */ }
|
||||
if (!URL_VIEWS.has(v)) return;
|
||||
view = { type: v };
|
||||
render();
|
||||
}
|
||||
|
||||
function bootSearchFromUrl() {
|
||||
let query = '';
|
||||
try {
|
||||
@@ -11364,7 +11497,9 @@ async function boot() {
|
||||
Presenter.boot();
|
||||
SectionRail.boot();
|
||||
Share.bootFromUrl();
|
||||
bootViewFromUrl();
|
||||
bootSearchFromUrl();
|
||||
if (WEB) bootShareFromUrl();
|
||||
Party.boot();
|
||||
Remote.boot();
|
||||
Transition.renderButton();
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"scope": "/",
|
||||
"theme_color": "#1a1311",
|
||||
"background_color": "#1a1311",
|
||||
"categories": ["music", "entertainment"],
|
||||
"categories": [
|
||||
"music",
|
||||
"entertainment"
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
@@ -29,13 +32,135 @@
|
||||
"name": "Search",
|
||||
"short_name": "Search",
|
||||
"url": "/?view=search",
|
||||
"description": "Search YouTube"
|
||||
"description": "Search YouTube",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Queue",
|
||||
"short_name": "Queue",
|
||||
"url": "/?view=queue",
|
||||
"description": "What plays next",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Saved",
|
||||
"short_name": "Saved",
|
||||
"url": "/?view=saved",
|
||||
"description": "Videos saved for offline",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Downloads",
|
||||
"short_name": "Downloads",
|
||||
"url": "/?view=downloads",
|
||||
"description": "Saves in progress and paused",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "History",
|
||||
"short_name": "History",
|
||||
"url": "/?view=history",
|
||||
"description": "Recently watched videos"
|
||||
"description": "Recently watched videos",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Library",
|
||||
"short_name": "Library",
|
||||
"url": "/?view=library",
|
||||
"description": "Your playlists",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lyrics & notes",
|
||||
"short_name": "Notes",
|
||||
"url": "/?view=notes",
|
||||
"description": "Lyrics, chapters and notes",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Settings",
|
||||
"short_name": "Settings",
|
||||
"url": "/?view=settings",
|
||||
"description": "App settings",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "media",
|
||||
"accept": [
|
||||
"audio/*",
|
||||
"video/*",
|
||||
".mp3",
|
||||
".m4a",
|
||||
".aac",
|
||||
".wav",
|
||||
".flac",
|
||||
".ogg",
|
||||
".opus",
|
||||
".mp4",
|
||||
".mov",
|
||||
".webm",
|
||||
".mkv"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +160,42 @@ self.addEventListener('activate', (e) => {
|
||||
});
|
||||
|
||||
// ---- Fetch: routing logic ----
|
||||
// ---- Share target (manifest share_target) ----
|
||||
// The OS share sheet POSTs the shared link/text/files here. Files are parked in
|
||||
// a cache the app reads on open (name does not start with "ytplayer-", so no
|
||||
// deploy ever evicts it), then the page is opened to process them.
|
||||
const SHARE_INBOX = 'ytp-share-inbox';
|
||||
async function receiveShare(request) {
|
||||
let form;
|
||||
try { form = await request.formData(); } catch { return Response.redirect('/?shared=', 303); }
|
||||
const files = form.getAll('media').filter((f) => f && typeof f !== 'string' && f.size);
|
||||
if (files.length) {
|
||||
const inbox = await caches.open(SHARE_INBOX);
|
||||
let n = 0;
|
||||
for (const f of files) {
|
||||
const key = `/__share/${Date.now()}-${n++}`;
|
||||
await inbox.put(key, new Response(f, { headers: {
|
||||
'Content-Type': f.type || 'application/octet-stream',
|
||||
'X-Name': encodeURIComponent(f.name || 'shared'),
|
||||
'Content-Length': String(f.size),
|
||||
} }));
|
||||
}
|
||||
return Response.redirect('/?share=files', 303);
|
||||
}
|
||||
const pick = (k) => String(form.get(k) || '').trim();
|
||||
const text = [pick('url'), pick('text'), pick('title')].filter(Boolean).join(' ');
|
||||
return Response.redirect('/?shared=' + encodeURIComponent(text.slice(0, 2000)), 303);
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const { request } = e;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === 'POST' && url.pathname === '/share-target') {
|
||||
e.respondWith(receiveShare(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user