76 lines
3.3 KiB
JavaScript
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);
|