Files
ytplayer/frontend/export.js

76 lines
3.3 KiB
JavaScript

/* Put a video file on the device itself (Photos / Files / Downloads).
* Pure helpers + the three ways a browser can do it; the app decides which
* source (saved copy, server copy, other devices) the bytes come from.
* DeviceExport.fileName(title, ext) → safe "Title.ext"
* DeviceExport.canShareFile(file) → Web Share with files works here
* DeviceExport.canPickLocation() → "Save as…" dialog exists (desktop Chromium)
* DeviceExport.share(file, name, title) → share sheet ("Save Video" → Photos / Files)
* DeviceExport.pick(file, name) → stream into a user-chosen file
* DeviceExport.download(urlOrFile, name) → plain browser download
*/
(function (root) {
'use strict';
const BAD = /[\u0000-\u001f\\/:*?"<>|]+/g;
function fileName(title, ext, fallback) {
const base = String(title || '').replace(BAD, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)
|| String(fallback || 'video');
const e = String(ext || 'mp4').replace(/^\./, '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'mp4';
return `${base}.${e}`;
}
const mimeFor = (name) => (/\.(m4a|mp3|aac|opus|ogg|flac|wav)$/i.test(name) ? 'audio/mp4' : /\.webm$/i.test(name) ? 'video/webm' : 'video/mp4');
const typed = (file, name) => (file.type ? file : new File([file], name, { type: mimeFor(name) }));
function canShareFile(file) {
try {
if (!navigator.canShare || !navigator.share || !file) return false;
return navigator.canShare({ files: [typed(file, file.name || 'video.mp4')] });
} catch { return false; }
}
const canPickLocation = () => typeof root.showSaveFilePicker === 'function' && !/Android|iPhone|iPad/i.test(navigator.userAgent || '');
// Resolves 'shared' | 'cancelled'; rejects on a real failure.
async function share(file, name, title) {
// new File() over an OPFS file does not copy it — it stays disk-backed.
const f = new File([file], name, { type: file.type || mimeFor(name) });
try {
await navigator.share({ files: [f], title: title || name });
return 'shared';
} catch (e) {
if (e && e.name === 'AbortError') return 'cancelled';
throw e;
}
}
async function pick(file, name) {
let handle;
try {
handle = await root.showSaveFilePicker({ suggestedName: name, types: [{ description: 'Video', accept: { [mimeFor(name)]: ['.' + name.split('.').pop()] } }] });
} catch (e) {
if (e && e.name === 'AbortError') return 'cancelled';
throw e;
}
const w = await handle.createWritable();
await file.stream().pipeTo(w); // chunked: a multi-GB file is never held in memory
return 'saved';
}
// A File/Blob → object URL; a string → used as the href directly (server copy).
function download(src, name) {
const isStr = typeof src === 'string';
const href = isStr ? src : URL.createObjectURL(src);
const a = document.createElement('a');
a.href = href;
a.download = name;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
if (!isStr) setTimeout(() => URL.revokeObjectURL(href), 10 * 60 * 1000);
return 'started';
}
root.DeviceExport = { fileName, canShareFile, canPickLocation, share, pick, download };
if (typeof module !== 'undefined' && module.exports) module.exports = root.DeviceExport;
})(typeof window !== 'undefined' ? window : globalThis);