From 4efa1d11828848cd42e7b8c617ab30d4acc772b7 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Tue, 30 Jun 2026 06:43:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20convert=20to=20PWA=20=E2=80=94=20OPFS?= =?UTF-8?q?=20storage,=20service=20worker,=20Bun/Hono=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Dockerfile | 51 +++ docker-compose.yml | 25 ++ frontend/fingerprint.js | 81 ++++ frontend/icons/icon-192.png | Bin 0 -> 1669 bytes frontend/icons/icon-512.png | Bin 0 -> 4609 bytes frontend/manifest.webmanifest | 41 ++ frontend/opfs.js | 194 ++++++++++ frontend/sw.js | 136 +++++++ app.zon => legacy/app.zon | 0 .../releases}/YT Player_1.0.0_x64-setup.exe | Bin .../releases}/YT Player_1.0.0_x64_en-US.msi | Bin .../releases}/YTPlayer_1.0.0_x64-setup.exe | Bin .../releases}/YTPlayer_1.0.0_x64_en-US.msi | Bin {releases => legacy/releases}/ytplayer.exe | Bin {src-tauri => legacy/src-tauri}/Cargo.lock | 0 {src-tauri => legacy/src-tauri}/Cargo.toml | 0 {src-tauri => legacy/src-tauri}/build.rs | 0 .../src-tauri}/capabilities/default.json | 0 {src-tauri => legacy/src-tauri}/src/main.rs | 0 .../src-tauri}/tauri.conf.json | 0 {src => legacy/src}/bridge.zig | 0 {src => legacy/src}/main.zig | 0 scripts/make-pwa-icons.js | 113 ++++++ server/db.js | 141 +++++++ server/package.json | 15 + server/server.js | 352 ++++++++++++++++++ 26 files changed, 1149 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/fingerprint.js create mode 100644 frontend/icons/icon-192.png create mode 100644 frontend/icons/icon-512.png create mode 100644 frontend/manifest.webmanifest create mode 100644 frontend/opfs.js create mode 100644 frontend/sw.js rename app.zon => legacy/app.zon (100%) rename {releases => legacy/releases}/YT Player_1.0.0_x64-setup.exe (100%) rename {releases => legacy/releases}/YT Player_1.0.0_x64_en-US.msi (100%) rename {releases => legacy/releases}/YTPlayer_1.0.0_x64-setup.exe (100%) rename {releases => legacy/releases}/YTPlayer_1.0.0_x64_en-US.msi (100%) rename {releases => legacy/releases}/ytplayer.exe (100%) rename {src-tauri => legacy/src-tauri}/Cargo.lock (100%) rename {src-tauri => legacy/src-tauri}/Cargo.toml (100%) rename {src-tauri => legacy/src-tauri}/build.rs (100%) rename {src-tauri => legacy/src-tauri}/capabilities/default.json (100%) rename {src-tauri => legacy/src-tauri}/src/main.rs (100%) rename {src-tauri => legacy/src-tauri}/tauri.conf.json (100%) rename {src => legacy/src}/bridge.zig (100%) rename {src => legacy/src}/main.zig (100%) create mode 100644 scripts/make-pwa-icons.js create mode 100644 server/db.js create mode 100644 server/package.json create mode 100644 server/server.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f94423a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# ============================================================================ +# YT Player PWA — Docker image +# +# Base: oven/bun:1-debian (Bun runtime on Debian slim) +# yt-dlp: downloaded from GitHub releases at build time (stays current) +# libsql: embedded via @libsql/client (no separate DB container needed) +# +# Build: docker compose build +# Run: docker compose up +# ============================================================================ + +FROM oven/bun:1-debian + +# ---- System dependencies ---- +# python3 is required by yt-dlp for some extraction paths +# ca-certificates for HTTPS fetches from yt-dlp +RUN apt-get update -qq && \ + apt-get install -y --no-install-recommends \ + curl \ + python3 \ + ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# ---- Install yt-dlp ---- +RUN curl -fsSL \ + https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \ + -o /usr/local/bin/yt-dlp && \ + chmod +x /usr/local/bin/yt-dlp + +WORKDIR /app + +# ---- Install Node/Bun deps ---- +COPY server/package.json ./ +RUN bun install --production + +# ---- Copy server source ---- +COPY server/ ./ + +# ---- Copy built frontend (served as static files from ./public) ---- +COPY frontend/ ./public/ + +# ---- Persistent data directory (volume-mounted) ---- +RUN mkdir -p /app/data + +EXPOSE 3000 + +# Healthcheck — ping the version endpoint +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -sf http://localhost:3000/api/version || exit 1 + +CMD ["bun", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e00ee2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + ytplayer: + build: . + restart: unless-stopped + ports: + - "${PORT:-3000}:3000" + volumes: + # libsql DB file persists across container rebuilds + - ytplayer-data:/app/data + environment: + PORT: "3000" + DB_PATH: "/app/data/ytplayer.db" + APP_VERSION: "1.0.0" + # Optional: override yt-dlp binary path if you mount a custom one + # YTDLP_PATH: "/usr/local/bin/yt-dlp" + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:3000/api/version"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + +volumes: + ytplayer-data: + driver: local diff --git a/frontend/fingerprint.js b/frontend/fingerprint.js new file mode 100644 index 0000000..5a8ff6c --- /dev/null +++ b/frontend/fingerprint.js @@ -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; + } + }; +}()); diff --git a/frontend/icons/icon-192.png b/frontend/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..87286ae799008ce2307bda7fb5a9aae6202bd23b GIT binary patch literal 1669 zcmX|?e^gWV6~NykCS+7V@P&g|kUbUh0}gFn)M_YUBP8e`kP>)8rb!T4feM>v2BeZu z9F>3|h+1JDCMb%$0|ZnWeunVFrm!+p!Z1KU1GX{=3kygf$sV@dAK!cL`F!v9oO{3b ze($$1I*RS=$(u}f1OUSCBf>&sv;OwxsU$Hr9vYanvcyKO zn9wbua_Gm%T1C|#Yvz~lQwFDV4)7&DgsnvN$NSEttaPQ=wacCM>JDQ9j4!nP<__6o z;d{tW8{$-advUbriqmk)l&134%Yg{I7?IGQBD=N^Bu`D<7oTU`?rR@Po_qP%U*S`G z+jT?peVFySw|${Ve16;8q^ULg^6bV12an>SkVd9YbnZ9tZ6u;LdB}BUU4-X_ugy-o<2-pP903^jxN09U7C=iUdjSGz=>n*F zs5DjYh*i}-0c;2N-IK9fah}nt-I(VoLgqOpSb7APCEe7g95Xj1A@jA&!RJHZ8$To0 zOq;pdd3d5p7QYo}e21b|C>RPJz55Pnblba}vi4?_u2N@6I9Zx$KC8*$xTfl5@Fp!W zlYgQXq!dm+q|IQi7|f$qc{5j$>)0{?z=6f)oXgIHZJ*U6J@1W`jk7yQqFYFh>(#P& z{aKn?EVhPMl!9SYe{BYfLD*Jbj`ZvhnO<|Q!)PVa<9+nXb#^->8XL4&-+n~cMrlB# zj-U7kxk@PNmJ;)-u%MGMSx;=>qdh~1vD4QSw*4c9f!Qb)5-he}^3fD!9D+pVVsqO$ zH2@#$w^RiSfihHVovOd`>MLwZtFYN(HOs$SheX+hW^Y~}0M7+#S2=jx#fry>eh=QX zA?$zyMikwVWt!IfH}Yn<-xAL12VfFK+eUTI-apnzq$Qw6K4eP2AL$nN4ABP~4L`aJL%Z9oBt5h|w*c`bm1H+DdtZjR^hwHaORWtf6 zgB}J=SOYwK6g3Jmw)&R0A^MKv22B=*(oo~^G*eb5-)%lvyV^v`W=z)*ccjR&$1wj{dE|5|(C zk*{lkgr??|m_HB1(xVJo>asCZ^>cdIMc;8>I6Qxe;hD<2K*C}jqA5e3-6qmfQR6kq zpQZO=+~OV}`UbMWCai@~j-s1i_JNGUx@f*6=PWw!r>afwFX*5flRN))hd7jkW|TW% zmCsOgv#FlU+nxOum-@0C++ots#hcnR;}o{VXIL;5A(2~s-3Pc=Cg1395gU&F=giX0 z>2Gh^yyeXagBBel2`D2=D-BW7d^lkJg-*KND|Yd}bh7#*TCPhs{){JckG$GXIW_)w zVuPPHst2!WtOq?NDItS@_~^AsANk_OtTDF5NGUoEUt9roI-C>oQ&N9To!g z{}7({U=2Z)x=iP#YyM%?K}&K_MORLAMS@L+Q>$_K&hnj8iKL(C=tDk6!^b`Fonjy} zvQct6MnzLjE02Lqo`4`}6a6JUPDQ)%{lk(-r>uTp=J_zN@;OgcI{~J@d-=KgwivJ! zbFSLKTo1=HD!o+%JcH^yle&)Sa<20Eqfn50G8f&2d#3vmMB`Q4kA1CQX^dCpv*uEO zrIUpq`l%{E$VKL+%HKP5!i%|Z2+w=;Z=h~DZPS7R?8AVTdb#EvSP}lumbko%u58}S1(!?PuD3v@sszpH_%7! p43aj-d_=3@x%sNve__$;sIJs$O~vhBVBZT6!HNoN{H^fI{{cF17EJ&E literal 0 HcmV?d00001 diff --git a/frontend/icons/icon-512.png b/frontend/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..31316a8304a3db3abaad7bdb8581f8a9c9ac55ea GIT binary patch literal 4609 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 z zgmR1p_OaCC?kIx&7f8ud*ZAD1i4>!k#fU(6-Ok+*zz`C+Xs|ulKMJC%Ne9&+&b0>l zG_jBVD7k|r!xrlQ2(Tc0MVqmN zagZ8A9gja02IlDGSh2*M&DNSLf)%h5gAGCeD#2qOE$x%aKd-X zAeJy??4502Ot2ms7d+YH|Wgc0!=rgJs|S4l)0M>3{thOPB|l zm|i=8aRCM^^nRHbmkGTBUFMB)@*D_b3EySxeqzSeG*JA7@C^xa7O#h>6Qn~Wx{dW8Ja z(3NL&bayySHkm0B3Da%7F<8131ahOJ+oNB|Ahtq4n9dYcn%JA}uIRb;GwL_!Z^. + * + * Object URLs created by getFileUrl() are tracked so they can be revoked + * when no longer needed — call OPFS.revokeUrl(url) after the