feat: convert to PWA — OPFS storage, service worker, Bun/Hono server
- Move native shells (Zig/src, Tauri/src-tauri, app.zon, releases) to legacy/
- Add Bun + Hono server with yt-dlp proxy endpoints (search, channel, streams,
download), libsql (concurrent SQLite fork) for fingerprint-keyed playlist/
history sync, and static file serving for the frontend
- Add Dockerfile + docker-compose.yml (single container, volume-mounted DB)
- Add frontend/sw.js: app-shell cache-first, /api/* network-only,
thumbnails stale-while-revalidate, SW_UPDATE_AVAILABLE broadcast,
SKIP_WAITING message handler for seamless auto-update
- Add frontend/manifest.webmanifest: standalone PWA, vermilion theme,
search/history shortcuts
- Add frontend/icons/icon-{192,512}.png: generated PWA icons
- Add frontend/fingerprint.js: canvas+UA djb2 fingerprint, localStorage-cached,
exposes window.getFingerprint() for server-side playlist keying
- Add frontend/opfs.js: full OPFS video store (writeFromResponse streams
directly without full-file buffering), exposes window.OPFS
- Add scripts/make-pwa-icons.js: regenerate icons without external deps
- Patch frontend/app.js: WEB mode detection, webFetch + opfs* bridge wrappers,
API object routes to WEB helpers when no native bridge present,
Player.loadVideo handles OPFS blob URLs + revokes them on next load,
SW registration + update banner in boot()
- Patch frontend/index.html: manifest link, theme-color, Apple PWA meta,
CSP blob:/worker-src, fingerprint.js + opfs.js script tags
- Patch frontend/styles.css: .toast-update + .toast-reload-btn for update banner
- Native Tauri/Zig builds unchanged — all new code is additive via WEB flag
This commit is contained in:
81
frontend/fingerprint.js
Normal file
81
frontend/fingerprint.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/* ============================================================================
|
||||
* fingerprint.js — stable browser identifier for server-side playlist sync
|
||||
*
|
||||
* Combines canvas rendering, UA, screen geometry, and hardware hints into a
|
||||
* short hex string. The result is stored in localStorage so the same ID
|
||||
* survives page reloads, and only regenerated when localStorage is cleared.
|
||||
*
|
||||
* Not for tracking — used exclusively so the server can associate playlist
|
||||
* and history rows with this browser without requiring a login.
|
||||
* ========================================================================== */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// djb2 hash over a string → 32-bit unsigned int
|
||||
function djb2(str) {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (((h << 5) + h) + str.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
// Render a small canvas to capture GPU/font rasterisation differences,
|
||||
// then hash the pixel data. Falls back to empty string if canvas is blocked.
|
||||
function canvasHash() {
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 200; c.height = 40;
|
||||
const ctx = c.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillStyle = '#f60';
|
||||
ctx.fillRect(125, 1, 62, 20);
|
||||
ctx.fillStyle = '#069';
|
||||
ctx.fillText('YTPlayer🎵', 2, 15);
|
||||
ctx.fillStyle = 'rgba(102,204,0,0.7)';
|
||||
ctx.fillText('YTPlayer🎵', 4, 17);
|
||||
return djb2(c.toDataURL()).toString(16);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function generateFingerprint() {
|
||||
const parts = [
|
||||
navigator.userAgent || '',
|
||||
String(screen.width) + 'x' + String(screen.height),
|
||||
String(screen.colorDepth),
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || '',
|
||||
navigator.language || '',
|
||||
String(navigator.hardwareConcurrency || 0),
|
||||
String(navigator.deviceMemory || 0),
|
||||
canvasHash(),
|
||||
];
|
||||
// Combine all component hashes into one 16-char hex fingerprint
|
||||
const combined = parts.reduce((acc, p) => acc + '|' + p, '');
|
||||
const h1 = djb2(combined);
|
||||
const h2 = djb2(combined.split('').reverse().join(''));
|
||||
return h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
window.getFingerprint = function getFingerprint() {
|
||||
try {
|
||||
let fp = localStorage.getItem('_ytpfp');
|
||||
if (!fp || fp.length < 8) {
|
||||
fp = generateFingerprint();
|
||||
localStorage.setItem('_ytpfp', fp);
|
||||
}
|
||||
return fp;
|
||||
} catch {
|
||||
// localStorage blocked (e.g. private mode on some browsers) — generate
|
||||
// ephemeral fingerprint that survives the page session via a closure.
|
||||
if (!window._ytpfpEphemeral) {
|
||||
window._ytpfpEphemeral = generateFingerprint();
|
||||
}
|
||||
return window._ytpfpEphemeral;
|
||||
}
|
||||
};
|
||||
}());
|
||||
BIN
frontend/icons/icon-192.png
Normal file
BIN
frontend/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/icons/icon-512.png
Normal file
BIN
frontend/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
41
frontend/manifest.webmanifest
Normal file
41
frontend/manifest.webmanifest
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "YT Player",
|
||||
"short_name": "YTPlayer",
|
||||
"description": "Ad-free YouTube player — search, play, and keep playlists offline. No login, no tracking.",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"theme_color": "#1a1311",
|
||||
"background_color": "#1a1311",
|
||||
"categories": ["music", "entertainment"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Search",
|
||||
"short_name": "Search",
|
||||
"url": "/?view=search",
|
||||
"description": "Search YouTube"
|
||||
},
|
||||
{
|
||||
"name": "History",
|
||||
"short_name": "History",
|
||||
"url": "/?view=history",
|
||||
"description": "Recently watched videos"
|
||||
}
|
||||
]
|
||||
}
|
||||
194
frontend/opfs.js
Normal file
194
frontend/opfs.js
Normal file
@@ -0,0 +1,194 @@
|
||||
/* ============================================================================
|
||||
* opfs.js — Origin Private File System video storage for PWA mode
|
||||
*
|
||||
* Exposes window.OPFS with methods that mirror the Tauri cache_* commands
|
||||
* so app.js can call them transparently in WEB mode.
|
||||
*
|
||||
* Videos are stored under the OPFS root at:
|
||||
* videos/<videoId>.<ext>
|
||||
*
|
||||
* Object URLs created by getFileUrl() are tracked so they can be revoked
|
||||
* when no longer needed — call OPFS.revokeUrl(url) after the <video> unloads.
|
||||
*
|
||||
* OPFS is available in all modern browsers (Chrome 86+, Firefox 111+,
|
||||
* Safari 15.2+). Calls degrade gracefully if the API is absent.
|
||||
* ========================================================================== */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Root directory handle, lazily initialised
|
||||
let _rootPromise = null;
|
||||
|
||||
async function getRoot() {
|
||||
if (!_rootPromise) {
|
||||
_rootPromise = (async () => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
return root.getDirectoryHandle('videos', { create: true });
|
||||
})();
|
||||
}
|
||||
return _rootPromise;
|
||||
}
|
||||
|
||||
// Iterate the videos/ directory and find a file whose stem matches videoId
|
||||
// (ignoring the extension). Returns [FileSystemFileHandle, filename] or null.
|
||||
async function findHandle(videoId) {
|
||||
const dir = await getRoot();
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
if (handle.kind !== 'file') continue;
|
||||
const dot = name.lastIndexOf('.');
|
||||
const stem = dot > -1 ? name.slice(0, dot) : name;
|
||||
if (stem === videoId) return [handle, name];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extension → MIME type for Content-Type headers on playback
|
||||
function extToMime(ext) {
|
||||
const map = { mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
||||
m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/ogg' };
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
const _blobUrls = new Set();
|
||||
|
||||
window.OPFS = {
|
||||
// Does a file exist for this videoId?
|
||||
async hasVideo(videoId) {
|
||||
try {
|
||||
return (await findHandle(videoId)) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Return a blob: URL usable as <video src>. Returns null if not cached.
|
||||
// The caller is responsible for calling OPFS.revokeUrl(url) when done.
|
||||
async getFileUrl(videoId) {
|
||||
try {
|
||||
const found = await findHandle(videoId);
|
||||
if (!found) return null;
|
||||
const [handle, name] = found;
|
||||
const file = await handle.getFile();
|
||||
const ext = name.slice(name.lastIndexOf('.') + 1);
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: extToMime(ext) });
|
||||
const url = URL.createObjectURL(blob);
|
||||
_blobUrls.add(url);
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Revoke an object URL previously returned by getFileUrl().
|
||||
revokeUrl(url) {
|
||||
if (url && _blobUrls.has(url)) {
|
||||
URL.revokeObjectURL(url);
|
||||
_blobUrls.delete(url);
|
||||
}
|
||||
},
|
||||
|
||||
// Stream a fetch Response body into OPFS. Uses a writable stream so only
|
||||
// a small chunk lives in memory at a time (no full-file buffering).
|
||||
// Falls back to ArrayBuffer if WritableStream is unavailable.
|
||||
async writeFromResponse(videoId, ext, response) {
|
||||
const dir = await getRoot();
|
||||
const filename = videoId + '.' + (ext || 'mp4');
|
||||
|
||||
// Write to a temporary file first so a partial download doesn't leave
|
||||
// a corrupt permanent entry.
|
||||
const tmpName = filename + '.part';
|
||||
const tmpHandle = await dir.getFileHandle(tmpName, { create: true });
|
||||
|
||||
try {
|
||||
if ('createWritable' in tmpHandle) {
|
||||
const writable = await tmpHandle.createWritable();
|
||||
try {
|
||||
if (response.body && typeof response.body.pipeTo === 'function') {
|
||||
await response.body.pipeTo(writable);
|
||||
} else {
|
||||
// Safari < 16.4 doesn't support pipeTo — buffer the whole response
|
||||
const buf = await response.arrayBuffer();
|
||||
await writable.write(buf);
|
||||
await writable.close();
|
||||
}
|
||||
} catch (err) {
|
||||
await writable.abort();
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
// Fallback: buffer entirely (older browsers)
|
||||
const buf = await response.arrayBuffer();
|
||||
const writable = await tmpHandle.createWritable();
|
||||
await writable.write(buf);
|
||||
await writable.close();
|
||||
}
|
||||
|
||||
// Rename tmp → final. OPFS doesn't have rename, so: read + write + delete.
|
||||
const finalHandle = await dir.getFileHandle(filename, { create: true });
|
||||
const finalWritable = await finalHandle.createWritable();
|
||||
const tmpFile = await tmpHandle.getFile();
|
||||
await finalWritable.write(await tmpFile.arrayBuffer());
|
||||
await finalWritable.close();
|
||||
} finally {
|
||||
// Remove .part file regardless
|
||||
try { await dir.removeEntry(tmpName); } catch { /* already gone */ }
|
||||
}
|
||||
},
|
||||
|
||||
// List all cached videos — returns [{ id, size, name }]
|
||||
async listVideos() {
|
||||
const dir = await getRoot();
|
||||
const items = [];
|
||||
try {
|
||||
for await (const [name, handle] of dir.entries()) {
|
||||
if (handle.kind !== 'file') continue;
|
||||
// Skip .part temporary files
|
||||
if (name.endsWith('.part')) continue;
|
||||
const dot = name.lastIndexOf('.');
|
||||
const id = dot > -1 ? name.slice(0, dot) : name;
|
||||
const file = await handle.getFile();
|
||||
items.push({ id, name, size: file.size });
|
||||
}
|
||||
} catch { /* OPFS not available */ }
|
||||
return items;
|
||||
},
|
||||
|
||||
// Total bytes stored
|
||||
async totalSize() {
|
||||
const items = await this.listVideos();
|
||||
return items.reduce((s, i) => s + i.size, 0);
|
||||
},
|
||||
|
||||
// Delete one video
|
||||
async deleteVideo(videoId) {
|
||||
try {
|
||||
const found = await findHandle(videoId);
|
||||
if (!found) return;
|
||||
const [, name] = found;
|
||||
const dir = await getRoot();
|
||||
await dir.removeEntry(name);
|
||||
} catch { /* already gone */ }
|
||||
},
|
||||
|
||||
// Delete all cached videos
|
||||
async clearAll() {
|
||||
try {
|
||||
const dir = await getRoot();
|
||||
const names = [];
|
||||
for await (const [name] of dir.entries()) names.push(name);
|
||||
await Promise.all(names.map((n) => dir.removeEntry(n).catch(() => {})));
|
||||
// Revoke any outstanding blob URLs
|
||||
for (const url of _blobUrls) URL.revokeObjectURL(url);
|
||||
_blobUrls.clear();
|
||||
} catch { /* OPFS not available */ }
|
||||
},
|
||||
|
||||
// Is the OPFS API supported in this browser?
|
||||
isSupported() {
|
||||
return typeof navigator !== 'undefined' &&
|
||||
typeof navigator.storage !== 'undefined' &&
|
||||
typeof navigator.storage.getDirectory === 'function';
|
||||
},
|
||||
};
|
||||
}());
|
||||
136
frontend/sw.js
Normal file
136
frontend/sw.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/* ============================================================================
|
||||
* sw.js — YT Player Service Worker
|
||||
*
|
||||
* Strategy:
|
||||
* App shell (HTML/CSS/JS) → cache-first, versioned cache
|
||||
* /api/* requests → network-only (never cache yt-dlp results)
|
||||
* /api/download/* → network-only (streamed binary, never cache)
|
||||
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
||||
* Everything else → network, fallback to cache
|
||||
*
|
||||
* Auto-update flow:
|
||||
* 1. New SW installs alongside the old one.
|
||||
* 2. activate: broadcast SW_UPDATE_AVAILABLE to all clients.
|
||||
* 3. Client shows "Update ready" banner.
|
||||
* 4. User clicks → client sends { type: 'SKIP_WAITING' }.
|
||||
* 5. SW calls skipWaiting() → takes over → client reloads.
|
||||
* ========================================================================== */
|
||||
|
||||
const VERSION = 'v1.0.0'; // ← bump this on every deploy to bust the cache
|
||||
const CACHE = 'ytplayer-' + VERSION;
|
||||
|
||||
// Files that form the installable app shell.
|
||||
const SHELL = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/styles.css',
|
||||
'/async-guard.js',
|
||||
'/fingerprint.js',
|
||||
'/opfs.js',
|
||||
'/app.js',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
];
|
||||
|
||||
// ---- Install: pre-cache the app shell ----
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then((cache) => cache.addAll(SHELL))
|
||||
// skipWaiting() is NOT called here — we wait for the client to confirm
|
||||
// before activating, so the update banner can appear first.
|
||||
);
|
||||
});
|
||||
|
||||
// ---- Activate: evict old caches, claim clients, notify about update ----
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil((async () => {
|
||||
// Delete every cache that isn't the current version
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))
|
||||
);
|
||||
// Claim all open clients immediately (new installs)
|
||||
await self.clients.claim();
|
||||
// Broadcast to every open window so the app can show an update banner
|
||||
const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
|
||||
all.forEach((c) => c.postMessage({ type: 'SW_UPDATE_AVAILABLE', version: VERSION }));
|
||||
})());
|
||||
});
|
||||
|
||||
// ---- Fetch: routing logic ----
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const { request } = e;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||
|
||||
// API calls and binary downloads → network only, no caching
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
e.respondWith(fetch(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// YouTube thumbnails → stale-while-revalidate (fast load, fresh in background)
|
||||
if (url.hostname === 'i.ytimg.com') {
|
||||
e.respondWith(staleWhileRevalidate(request, 'ytplayer-thumbs'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Google Fonts CSS — stale-while-revalidate so offline doesn't break type
|
||||
if (url.hostname === 'fonts.googleapis.com' || url.hostname === 'fonts.gstatic.com') {
|
||||
e.respondWith(staleWhileRevalidate(request, 'ytplayer-fonts'));
|
||||
return;
|
||||
}
|
||||
|
||||
// App shell → cache-first, then network, then generic offline fallback
|
||||
e.respondWith(cacheFirst(request));
|
||||
});
|
||||
|
||||
// ---- Message: handle SKIP_WAITING from the client ----
|
||||
self.addEventListener('message', (e) => {
|
||||
if (e.data && e.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Fetch helpers
|
||||
// ============================================================================
|
||||
|
||||
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
||||
async function cacheFirst(request) {
|
||||
const cache = await caches.open(CACHE);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
// Only cache successful, non-opaque responses
|
||||
if (response && response.status === 200 && response.type !== 'opaque') {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Network failed and nothing in cache — return a minimal offline page
|
||||
// for navigation requests; let sub-resources fail naturally.
|
||||
if (request.mode === 'navigate') {
|
||||
const nav = await cache.match('/index.html');
|
||||
if (nav) return nav;
|
||||
}
|
||||
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
// Stale-while-revalidate: return cached immediately, update in background.
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
// Start a background revalidation — don't await it before responding
|
||||
const networkFetch = fetch(request).then((r) => {
|
||||
if (r && r.status === 200) cache.put(request, r.clone());
|
||||
return r;
|
||||
}).catch(() => null);
|
||||
|
||||
return cached || networkFetch;
|
||||
}
|
||||
Reference in New Issue
Block a user