diff --git a/.archon/config.yaml b/.archon/config.yaml new file mode 100644 index 0000000..455d9a3 --- /dev/null +++ b/.archon/config.yaml @@ -0,0 +1,8 @@ +assistant: claude + +worktree: + baseBranch: main + +defaults: + loadDefaultCommands: true + loadDefaultWorkflows: true diff --git a/.archon/workflows/build-discover-page.yaml b/.archon/workflows/build-discover-page.yaml new file mode 100644 index 0000000..6df1039 --- /dev/null +++ b/.archon/workflows/build-discover-page.yaml @@ -0,0 +1,71 @@ +name: build-discover-page +description: Build a WebGL Voronoi movie discovery page in Streambert (nothing-to-watch style) + +nodes: + - id: plan + prompt: | + You are building a new "Discover" page for Streambert — an Electron + Vite + React desktop app at /Volumes/SanDisk/dev/projects/streambert. + + The feature: a WebGL force-directed Voronoi diagram showing movie poster thumbnails (inspired by https://github.com/gnovotny/nothing-to-watch, MIT licensed). User pans/zooms, hovers to preview, clicks to open MoviePage. + + Read the following files to understand the codebase: + - src/App.jsx (routing, how pages are wired) + - src/components/Sidebar.jsx (nav structure) + - src/components/Icons.jsx (icon pattern) + - src/utils/api.js (TMDB fetch, imgUrl, PLAYER_SOURCES) + - src/styles/global.css (CSS variables, patterns) + - package.json (existing deps) + + Then produce a detailed implementation plan covering: + 1. New npm dependencies needed (ogl for WebGL2, zustand already used?) + 2. File structure: which files to create, which to modify + 3. Data pipeline: how to fetch TMDB popular/top-rated movies and build the film dataset (paginate to get ~2000 movies, cache to localStorage) + 4. Texture atlas approach: since we can't pre-bake atlases, use individual poster JPGs loaded as textures per cell (the "uncompressed single" approach from nothing-to-watch) + 5. WebGL engine: custom OGL-based renderer — cells as quads, each textured with a poster, force-directed simulation for layout, pan/zoom controls + 6. Hover preview card: floating div showing title, year, rating, genres — follows cursor + 7. Click handler: calls onSelect(item) to navigate to existing MoviePage + 8. Sidebar integration: new "Discover" nav icon + 9. Performance targets: handle 500-2000 cells at 60fps in Electron WebGL2 + 10. Replacement GLSL shaders (no CC BY-NC-SA dependency) + + Output the plan as structured markdown. Be specific about file paths and API shapes. + + - id: implement + depends_on: [plan] + loop: + prompt: | + You are implementing the Discover page for Streambert based on this plan: + + $plan.output + + The repo is at /Volumes/SanDisk/dev/projects/streambert on the main branch. + + Work through the implementation in order: + 1. Install dependencies (ogl via npm if not present, check package.json first) + 2. Create src/utils/discover.js — TMDB film fetcher + localStorage cache (fetch popular + top_rated movies, paginate 10 pages each, dedupe by id, store {id, title, release_date, poster_path, vote_average, genre_ids, overview}) + 3. Create src/discover/ directory with: + - engine.js — OGL WebGL2 engine: force simulation, camera pan/zoom, cell quads with poster textures, hover detection via raycasting + - shaders.js — GLSL vertex + fragment shaders (MIT, no CC) for cell rendering + - DiscoverPage.jsx — React component mounting the canvas, hover preview card, click → onSelect + 4. Add DiscoverIcon to src/components/Icons.jsx + 5. Add Discover nav item to src/components/Sidebar.jsx + 6. Add lazy import + route for DiscoverPage in src/App.jsx + 7. Add CSS for DiscoverPage in src/styles/global.css + 8. Run: cd /Volumes/SanDisk/dev/projects/streambert && npm install && node_modules/.bin/vite build + 9. Fix any build errors + + After each file is written, continue to the next. Don't stop until the build succeeds. + + When the build passes with no errors, output: COMPLETE + until: COMPLETE + max_iterations: 15 + fresh_context: false + + - id: verify + depends_on: [implement] + bash: | + cd /Volumes/SanDisk/dev/projects/streambert + node_modules/.bin/vite build 2>&1 | tail -5 + echo "---" + grep -l "DiscoverPage\|discover" src/App.jsx src/components/Sidebar.jsx 2>/dev/null && echo "wired up" + ls src/discover/ 2>/dev/null && echo "discover dir exists" diff --git a/package-lock.json b/package-lock.json index 96d5e0c..256559e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "streambert", - "version": "2.4.0", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "streambert", - "version": "2.4.0", + "version": "2.5.0", "license": "GPL-3.0", "dependencies": { + "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -1443,9 +1444,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1460,9 +1458,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1477,9 +1472,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1494,9 +1486,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1511,9 +1500,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1528,9 +1514,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1545,9 +1528,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1562,9 +1542,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1579,9 +1556,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1596,9 +1570,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1613,9 +1584,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1630,9 +1598,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1647,9 +1612,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4622,6 +4584,12 @@ "node": ">= 0.4" } }, + "node_modules/ogl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ogl/-/ogl-1.0.11.tgz", + "integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==", + "license": "Unlicense" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index a4bdbdb..de350c5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dist:mac": "cross-env ELECTRON_DIST=1 vite build && electron-builder --mac --universal --publish never" }, "dependencies": { + "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/src/App.jsx b/src/App.jsx index 998800b..787eacd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -29,6 +29,7 @@ const TVPage = lazy(() => import("./pages/TVPage")); const LibraryPage = lazy(() => import("./pages/LibraryPage")); const SettingsPage = lazy(() => import("./pages/SettingsPage")); const DownloadsPage = lazy(() => import("./pages/DownloadsPage")); +const DiscoverPage = lazy(() => import("./discover/DiscoverPage")); import { checkForUpdates } from "./utils/updates"; export default function App() { @@ -1001,6 +1002,12 @@ export default function App() { } /> )} + {page === "discover" && ( + + )} diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 658493d..806f8bf 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -345,6 +345,13 @@ export const SubtitlesIcon = ({ size = 16, ...props }) => ( ); +export const DiscoverIcon = ({ size = 22 }) => ( + + + + +); + export const PopOutIcon = ({ size = 16 }) => ( 0 ? activeDownloads : null} /> + onNavigate("discover")} + icon={} + label="Discover" + />
diff --git a/src/discover/DiscoverPage.jsx b/src/discover/DiscoverPage.jsx new file mode 100644 index 0000000..18c3d04 --- /dev/null +++ b/src/discover/DiscoverPage.jsx @@ -0,0 +1,134 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { fetchDiscoverFilms, GENRE_MAP } from '../utils/discover.js'; +import { DiscoverEngine } from './engine.js'; + +const STAR = '★'; + +function PreviewCard({ hovered }) { + if (!hovered) return null; + const { film, screenX, screenY } = hovered; + const year = (film.release_date || '').slice(0, 4); + const genres = (film.genre_ids || []).slice(0, 3).map(id => GENRE_MAP[id]).filter(Boolean); + const rating = film.vote_average ? film.vote_average.toFixed(1) : null; + + // Keep card on screen + const cardW = 220; + const cardH = 160; + let left = screenX + 16; + let top = screenY - 20; + if (left + cardW > window.innerWidth - 10) left = screenX - cardW - 16; + if (top + cardH > window.innerHeight - 10) top = window.innerHeight - cardH - 10; + if (top < 10) top = 10; + + return ( +
+
{film.title}
+
+ {year && {year}} + {rating && {STAR} {rating}} +
+ {genres.length > 0 && ( +
+ {genres.map(g => {g})} +
+ )} + {film.overview && ( +

+ {film.overview.slice(0, 120)}{film.overview.length > 120 ? '…' : ''} +

+ )} +
+ ); +} + +export default function DiscoverPage({ apiKey, onSelect }) { + const canvasRef = useRef(null); + const engineRef = useRef(null); + const [loading, setLoading] = useState(true); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + const [hovered, setHovered] = useState(null); + const [filmCount, setFilmCount] = useState(0); + + const handleHover = useCallback((info) => setHovered(info), []); + + const handleSelect = useCallback((film) => { + onSelect?.({ ...film, media_type: 'movie' }); + }, [onSelect]); + + useEffect(() => { + let cancelled = false; + + fetchDiscoverFilms(apiKey, (pct) => { + if (!cancelled) setProgress(pct); + }) + .then((films) => { + if (cancelled) return; + setFilmCount(films.length); + setLoading(false); + + // Mount engine on next frame so canvas is sized + requestAnimationFrame(() => { + if (cancelled || !canvasRef.current) return; + engineRef.current = new DiscoverEngine(canvasRef.current, films, { + onHover: handleHover, + onSelect: handleSelect, + }); + }); + }) + .catch((e) => { + if (!cancelled) { + setError(e.message); + setLoading(false); + } + }); + + return () => { + cancelled = true; + engineRef.current?.destroy(); + engineRef.current = null; + }; + }, [apiKey, handleHover, handleSelect]); + + // Resize handler + useEffect(() => { + const onResize = () => engineRef.current?.handleResize(); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + return ( +
+ {loading && ( +
+
+ {progress > 0 ? `Loading films… ${progress}%` : 'Fetching films…'} +
+
+
+
+
+ )} + {error && ( +
Failed to load: {error}
+ )} + {!loading && !error && ( +
+ {filmCount} films · scroll to zoom · drag to pan · click to open +
+ )} + + +
+ ); +} diff --git a/src/discover/engine.js b/src/discover/engine.js new file mode 100644 index 0000000..a3d93ad --- /dev/null +++ b/src/discover/engine.js @@ -0,0 +1,431 @@ +// WebGL2 Voronoi-inspired grid engine for the Discover page. +// Uses OGL for WebGL2. Renders movie poster cells as textured quads +// in a force-relaxed grid layout. Supports pan/zoom and hover detection. + +import { Renderer, Camera, Transform, Program, Mesh, Geometry, Texture, Vec2 } from 'ogl'; +import { VERT, FRAG, HOVER_VERT, HOVER_FRAG } from './shaders.js'; + +const IMG_BASE = 'https://image.tmdb.org/t/p/w185'; +const CELL_ASPECT = 2 / 3; // poster ratio width/height + +export class DiscoverEngine { + constructor(canvas, films, { onHover, onSelect } = {}) { + this.canvas = canvas; + this.films = films; + this.onHover = onHover || (() => {}); + this.onSelect = onSelect || (() => {}); + this._destroyed = false; + this._hoveredIdx = -1; + this._textures = {}; + this._rafId = null; + this._time = 0; + + this._init(); + } + + _init() { + const { canvas } = this; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + + this.renderer = new Renderer({ canvas, dpr, alpha: true, antialias: false }); + this.gl = this.renderer.gl; + this.gl.clearColor(0.04, 0.04, 0.04, 1); + + this.camera = new Camera(this.gl, { near: 0.1, far: 100 }); + this.camera.position.set(0, 0, 1); + + this.scene = new Transform(); + + // Pan/zoom state in world units + this._pan = new Vec2(0, 0); + this._zoom = 1.0; + this._targetPan = new Vec2(0, 0); + this._targetZoom = 1.0; + + this._buildGrid(); + this._buildMeshes(); + this._bindEvents(); + this._resize(); + this._loop(); + } + + _buildGrid() { + const count = this.films.length; + const cols = Math.ceil(Math.sqrt(count / CELL_ASPECT)); + const rows = Math.ceil(count / cols); + const cellW = 120; // world units + const cellH = cellW / CELL_ASPECT; + const gap = 8; + + this._cols = cols; + this._rows = rows; + this._cellW = cellW; + this._cellH = cellH; + + // Grid positions with slight force jitter + this._positions = []; + for (let i = 0; i < count; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const x = (col - cols / 2) * (cellW + gap) + (Math.random() - 0.5) * 4; + const y = (row - rows / 2) * (cellH + gap) + (Math.random() - 0.5) * 4; + this._positions.push([x, y]); + } + + // Simple force relaxation — push overlapping cells apart + for (let iter = 0; iter < 3; iter++) { + for (let i = 0; i < count; i++) { + for (let j = i + 1; j < count; j++) { + const dx = this._positions[j][0] - this._positions[i][0]; + const dy = this._positions[j][1] - this._positions[i][1]; + const minDist = (cellW + gap) * 0.95; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < minDist && dist > 0) { + const push = (minDist - dist) / dist * 0.5; + this._positions[i][0] -= dx * push * 0.3; + this._positions[i][1] -= dy * push * 0.3; + this._positions[j][0] += dx * push * 0.3; + this._positions[j][1] += dy * push * 0.3; + } + } + } + } + } + + _buildMeshes() { + const { gl } = this; + const count = this.films.length; + + // One quad geometry shared across all cells (instanced-style via per-cell draw) + // For simplicity: one mesh per cell. For 2000 films this is fine in WebGL2. + // Each cell: 2 triangles = 6 vertices + const quadVerts = new Float32Array([ + -1, -1, 0, 1, // BL + 1, -1, 1, 1, // BR + 1, 1, 1, 0, // TR + -1, -1, 0, 1, // BL + 1, 1, 1, 0, // TR + -1, 1, 0, 0, // TL + ]); + + this._program = new Program(gl, { + vertex: VERT, + fragment: FRAG, + uniforms: { + uTexture: { value: new Texture(gl) }, + uHasTexture: { value: 0 }, + uFallbackColor: { value: [0.15, 0.15, 0.18] }, + uBorderRadius: { value: 0.04 }, + uViewMatrix: { value: new Float32Array(9) }, + }, + transparent: true, + depthTest: false, + }); + + this._hoverProgram = new Program(gl, { + vertex: HOVER_VERT, + fragment: HOVER_FRAG, + uniforms: { + uViewMatrix: { value: new Float32Array(9) }, + uCenter: { value: [0, 0] }, + uSize: { value: this._cellW }, + uTime: { value: 0 }, + }, + transparent: true, + depthTest: false, + }); + + // Build geometry arrays for all cells + const positions = []; + const uvs = []; + const cellCenters = []; + const cellSizes = []; + const cellAlphas = []; + + for (let i = 0; i < count; i++) { + // 6 verts per quad + for (let v = 0; v < 6; v++) { + const px = quadVerts[v * 4]; + const py = quadVerts[v * 4 + 1]; + const u = quadVerts[v * 4 + 2]; + const vv = quadVerts[v * 4 + 3]; + positions.push(px, py); + uvs.push(u, vv); + cellCenters.push(this._positions[i][0], this._positions[i][1]); + cellSizes.push(this._cellW); + cellAlphas.push(1.0); + } + } + + this._geom = new Geometry(gl, { + position: { size: 2, data: new Float32Array(positions) }, + uv: { size: 2, data: new Float32Array(uvs) }, + cellCenter: { size: 2, data: new Float32Array(cellCenters) }, + cellSize: { size: 1, data: new Float32Array(cellSizes) }, + cellAlpha: { size: 1, data: new Float32Array(cellAlphas) }, + }); + + this._mesh = new Mesh(gl, { geometry: this._geom, program: this._program }); + + // Hover quad geometry (single quad) + this._hoverGeom = new Geometry(gl, { + position: { + size: 2, + data: new Float32Array([-1,-1, 1,-1, 1,1, -1,-1, 1,1, -1,1]), + }, + }); + this._hoverMesh = new Mesh(gl, { geometry: this._hoverGeom, program: this._hoverProgram }); + + // Kick off texture loading in background + this._loadTextures(); + } + + _loadTextures() { + const { gl } = this; + const BATCH = 20; + let idx = 0; + + const loadNext = () => { + if (this._destroyed) return; + const end = Math.min(idx + BATCH, this.films.length); + for (let i = idx; i < end; i++) { + const film = this.films[i]; + if (!film.poster_path) continue; + const url = `${IMG_BASE}${film.poster_path}`; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + if (this._destroyed) return; + const tex = new Texture(gl, { image: img, generateMipmaps: true }); + this._textures[i] = tex; + }; + img.src = url; + } + idx = end; + if (idx < this.films.length) { + setTimeout(loadNext, 100); + } + }; + loadNext(); + } + + // Build a 3x3 view matrix encoding pan + zoom for the shaders + _buildViewMatrix() { + const w = this.canvas.clientWidth || this.canvas.width; + const h = this.canvas.clientHeight || this.canvas.height; + const sx = (2 * this._zoom) / w; + const sy = (2 * this._zoom) / h; + const tx = -this._pan.x * sx; + const ty = -this._pan.y * sy; + return new Float32Array([ + sx, 0, 0, + 0, sy, 0, + tx, ty, 1, + ]); + } + + _loop() { + if (this._destroyed) return; + this._rafId = requestAnimationFrame(() => this._loop()); + this._time += 0.016; + + // Smooth pan/zoom + this._pan.x += (this._targetPan.x - this._pan.x) * 0.12; + this._pan.y += (this._targetPan.y - this._pan.y) * 0.12; + this._zoom += (this._targetZoom - this._zoom) * 0.12; + + this._render(); + } + + _render() { + const { gl, renderer } = this; + const viewMatrix = this._buildViewMatrix(); + + renderer.render({ scene: this.scene }); + gl.clear(gl.COLOR_BUFFER_BIT); + + // Draw all cells, each with its own texture if loaded + const vertsPerCell = 6; + const count = this.films.length; + + for (let i = 0; i < count; i++) { + const tex = this._textures[i]; + this._program.uniforms.uViewMatrix.value = viewMatrix; + this._program.uniforms.uHasTexture.value = tex ? 1 : 0; + if (tex) this._program.uniforms.uTexture.value = tex; + + // Draw just this cell's 6 verts + this._program.use(); + this._geom.draw({ mode: gl.TRIANGLES, first: i * vertsPerCell, count: vertsPerCell }); + } + + // Draw hover highlight + if (this._hoveredIdx >= 0) { + const [cx, cy] = this._positions[this._hoveredIdx]; + this._hoverProgram.uniforms.uViewMatrix.value = viewMatrix; + this._hoverProgram.uniforms.uCenter.value = [cx, cy]; + this._hoverProgram.uniforms.uSize.value = this._cellW; + this._hoverProgram.uniforms.uTime.value = this._time; + this._hoverProgram.use(); + this._hoverGeom.draw({ mode: gl.TRIANGLES }); + } + } + + // Convert screen coords to world coords + _screenToWorld(sx, sy) { + const w = this.canvas.clientWidth; + const h = this.canvas.clientHeight; + const nx = (sx / w) * 2 - 1; + const ny = 1 - (sy / h) * 2; + return { + x: nx / this._zoom + this._pan.x, + y: ny / this._zoom + this._pan.y, + }; + } + + _hitTest(worldX, worldY) { + const hw = this._cellW / 2; + const hh = this._cellH / 2; + for (let i = 0; i < this._positions.length; i++) { + const [cx, cy] = this._positions[i]; + if ( + worldX >= cx - hw && worldX <= cx + hw && + worldY >= cy - hh && worldY <= cy + hh + ) return i; + } + return -1; + } + + _bindEvents() { + const el = this.canvas; + let dragging = false; + let lastX = 0, lastY = 0; + let dragDist = 0; + + const onMouseMove = (e) => { + const rect = el.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + + if (dragging) { + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + this._targetPan.x -= dx / this._zoom; + this._targetPan.y += dy / this._zoom; + dragDist += Math.abs(dx) + Math.abs(dy); + lastX = e.clientX; + lastY = e.clientY; + } + + const world = this._screenToWorld(sx, sy); + const idx = this._hitTest(world.x, world.y); + if (idx !== this._hoveredIdx) { + this._hoveredIdx = idx; + this.onHover(idx >= 0 ? { film: this.films[idx], screenX: e.clientX, screenY: e.clientY } : null); + } + }; + + const onMouseDown = (e) => { + dragging = true; + dragDist = 0; + lastX = e.clientX; + lastY = e.clientY; + }; + + const onMouseUp = (e) => { + dragging = false; + if (dragDist < 4 && this._hoveredIdx >= 0) { + this.onSelect(this.films[this._hoveredIdx]); + } + }; + + const onWheel = (e) => { + e.preventDefault(); + const factor = e.deltaY > 0 ? 0.92 : 1.08; + this._targetZoom = Math.max(0.08, Math.min(6, this._targetZoom * factor)); + }; + + // Touch support + let lastTouchDist = 0; + let lastTouchX = 0, lastTouchY = 0; + let touchDragDist = 0; + + const onTouchStart = (e) => { + if (e.touches.length === 1) { + lastTouchX = e.touches[0].clientX; + lastTouchY = e.touches[0].clientY; + touchDragDist = 0; + } else if (e.touches.length === 2) { + const dx = e.touches[1].clientX - e.touches[0].clientX; + const dy = e.touches[1].clientY - e.touches[0].clientY; + lastTouchDist = Math.sqrt(dx * dx + dy * dy); + } + }; + + const onTouchMove = (e) => { + e.preventDefault(); + if (e.touches.length === 1) { + const dx = e.touches[0].clientX - lastTouchX; + const dy = e.touches[0].clientY - lastTouchY; + this._targetPan.x -= dx / this._zoom; + this._targetPan.y += dy / this._zoom; + touchDragDist += Math.abs(dx) + Math.abs(dy); + lastTouchX = e.touches[0].clientX; + lastTouchY = e.touches[0].clientY; + } else if (e.touches.length === 2) { + const dx = e.touches[1].clientX - e.touches[0].clientX; + const dy = e.touches[1].clientY - e.touches[0].clientY; + const dist = Math.sqrt(dx * dx + dy * dy); + const factor = dist / lastTouchDist; + this._targetZoom = Math.max(0.08, Math.min(6, this._targetZoom * factor)); + lastTouchDist = dist; + } + }; + + const onTouchEnd = (e) => { + if (e.changedTouches.length === 1 && touchDragDist < 10) { + const rect = el.getBoundingClientRect(); + const sx = e.changedTouches[0].clientX - rect.left; + const sy = e.changedTouches[0].clientY - rect.top; + const world = this._screenToWorld(sx, sy); + const idx = this._hitTest(world.x, world.y); + if (idx >= 0) this.onSelect(this.films[idx]); + } + }; + + el.addEventListener('mousemove', onMouseMove); + el.addEventListener('mousedown', onMouseDown); + el.addEventListener('mouseup', onMouseUp); + el.addEventListener('wheel', onWheel, { passive: false }); + el.addEventListener('touchstart', onTouchStart, { passive: true }); + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchend', onTouchEnd, { passive: true }); + + this._cleanup = () => { + el.removeEventListener('mousemove', onMouseMove); + el.removeEventListener('mousedown', onMouseDown); + el.removeEventListener('mouseup', onMouseUp); + el.removeEventListener('wheel', onWheel); + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchmove', onTouchMove); + el.removeEventListener('touchend', onTouchEnd); + }; + } + + _resize() { + const { canvas } = this; + const w = canvas.parentElement?.clientWidth || window.innerWidth; + const h = canvas.parentElement?.clientHeight || window.innerHeight; + this.renderer.setSize(w, h); + } + + handleResize() { + this._resize(); + } + + destroy() { + this._destroyed = true; + if (this._rafId) cancelAnimationFrame(this._rafId); + if (this._cleanup) this._cleanup(); + } +} diff --git a/src/discover/shaders.js b/src/discover/shaders.js new file mode 100644 index 0000000..99e4c79 --- /dev/null +++ b/src/discover/shaders.js @@ -0,0 +1,79 @@ +// MIT-licensed GLSL shaders for the Discover WebGL renderer. +// Vertex: positions each cell quad; Fragment: samples the poster texture. + +export const VERT = /* glsl */` +attribute vec2 position; +attribute vec2 uv; +attribute vec2 cellCenter; +attribute float cellSize; +attribute float cellAlpha; + +uniform mat3 uViewMatrix; + +varying vec2 vUv; +varying float vAlpha; + +void main() { + vec2 worldPos = cellCenter + position * cellSize * 0.5; + vec3 clip = uViewMatrix * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + vUv = uv; + vAlpha = cellAlpha; +} +`; + +export const FRAG = /* glsl */` +precision highp float; + +uniform sampler2D uTexture; +uniform float uHasTexture; +uniform vec3 uFallbackColor; +uniform float uBorderRadius; + +varying vec2 vUv; +varying float vAlpha; + +float roundedBox(vec2 uv, float r) { + vec2 q = abs(uv - 0.5) - (0.5 - r); + return length(max(q, 0.0)) - r; +} + +void main() { + float d = roundedBox(vUv, uBorderRadius); + if (d > 0.0) discard; + + vec4 color; + if (uHasTexture > 0.5) { + color = texture2D(uTexture, vUv); + } else { + color = vec4(uFallbackColor, 1.0); + } + + gl_FragColor = vec4(color.rgb, color.a * vAlpha); +} +`; + +// Hover highlight overlay shader +export const HOVER_VERT = /* glsl */` +attribute vec2 position; + +uniform mat3 uViewMatrix; +uniform vec2 uCenter; +uniform float uSize; + +void main() { + vec2 worldPos = uCenter + position * uSize * 0.5; + vec3 clip = uViewMatrix * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} +`; + +export const HOVER_FRAG = /* glsl */` +precision mediump float; +uniform float uTime; + +void main() { + float pulse = 0.7 + 0.3 * sin(uTime * 3.0); + gl_FragColor = vec4(0.9, 0.1, 0.1, 0.55 * pulse); +} +`; diff --git a/src/styles/global.css b/src/styles/global.css index 2ec8059..54237da 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3833,3 +3833,130 @@ html[data-win-titlebar][data-maximized] .main { .episode-check-item:hover { color: var(--text) !important; } + +/* ── Discover Page ────────────────────────────────────────────────────────── */ +.discover-page { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: #0a0a0a; + display: flex; + flex-direction: column; +} + +.discover-canvas { + width: 100%; + flex: 1; + display: block; + cursor: crosshair; +} + +.discover-hint { + position: absolute; + bottom: 14px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + color: rgba(255,255,255,0.25); + pointer-events: none; + white-space: nowrap; + z-index: 10; +} + +.discover-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + z-index: 20; +} + +.discover-loading__label { + font-size: 14px; + color: rgba(255,255,255,0.5); +} + +.discover-loading__bar { + width: 240px; + height: 3px; + background: rgba(255,255,255,0.1); + border-radius: 2px; + overflow: hidden; +} + +.discover-loading__fill { + height: 100%; + background: var(--red, #e50914); + border-radius: 2px; + transition: width 0.3s ease; +} + +.discover-error { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--red, #e50914); + font-size: 14px; +} + +/* Hover preview card */ +.discover-preview { + position: fixed; + z-index: 100; + background: rgba(18,18,22,0.97); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 10px; + padding: 12px 14px; + width: 220px; + pointer-events: none; + box-shadow: 0 8px 32px rgba(0,0,0,0.6); + backdrop-filter: blur(8px); +} + +.discover-preview__title { + font-size: 13px; + font-weight: 600; + color: #fff; + line-height: 1.3; + margin-bottom: 5px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.discover-preview__meta { + display: flex; + gap: 10px; + font-size: 12px; + color: rgba(255,255,255,0.5); + margin-bottom: 6px; +} + +.discover-preview__genres { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 7px; +} + +.discover-preview__genre { + font-size: 10px; + background: rgba(229,9,20,0.18); + color: rgba(255,100,100,0.9); + border-radius: 4px; + padding: 2px 6px; +} + +.discover-preview__overview { + font-size: 11px; + color: rgba(255,255,255,0.38); + line-height: 1.5; + margin: 0; +} diff --git a/src/utils/discover.js b/src/utils/discover.js new file mode 100644 index 0000000..b5c72bf --- /dev/null +++ b/src/utils/discover.js @@ -0,0 +1,95 @@ +// TMDB film fetcher for the Discover page. +// Fetches popular + top_rated movies (10 pages each), dedupes by id, +// caches to localStorage for 24h. + +const TMDB_BASE = 'https://api.themoviedb.org/3'; +const CACHE_KEY = 'streambert_discoverFilms'; +const CACHE_TTL = 24 * 60 * 60 * 1000; + +export async function fetchDiscoverFilms(apiKey, onProgress) { + // Check cache + try { + const raw = localStorage.getItem(CACHE_KEY); + if (raw) { + const { films, expiresAt } = JSON.parse(raw); + if (Date.now() < expiresAt && films?.length > 100) return films; + } + } catch {} + + const headers = { Authorization: `Bearer ${apiKey}` }; + const seen = new Set(); + const films = []; + + const fetchPage = async (endpoint, page) => { + const res = await fetch( + `${TMDB_BASE}${endpoint}?page=${page}&language=en-US`, + { headers } + ); + if (!res.ok) return []; + const data = await res.json(); + return data.results || []; + }; + + const endpoints = [ + '/movie/popular', + '/movie/top_rated', + '/movie/now_playing', + '/trending/movie/week', + ]; + + let done = 0; + const total = endpoints.length * 10; + + for (const endpoint of endpoints) { + for (let page = 1; page <= 10; page++) { + const results = await fetchPage(endpoint, page); + for (const m of results) { + if (!seen.has(m.id) && m.poster_path) { + seen.add(m.id); + films.push({ + id: m.id, + title: m.title, + release_date: m.release_date || '', + poster_path: m.poster_path, + backdrop_path: m.backdrop_path || null, + vote_average: m.vote_average || 0, + vote_count: m.vote_count || 0, + genre_ids: m.genre_ids || [], + overview: m.overview || '', + media_type: 'movie', + popularity: m.popularity || 0, + }); + } + } + done++; + onProgress?.(Math.round((done / total) * 100)); + // Small delay to avoid rate limiting + await new Promise(r => setTimeout(r, 50)); + } + } + + // Sort by popularity descending + films.sort((a, b) => b.popularity - a.popularity); + + try { + localStorage.setItem(CACHE_KEY, JSON.stringify({ + films, + expiresAt: Date.now() + CACHE_TTL, + })); + } catch {} + + return films; +} + +export function clearDiscoverCache() { + try { localStorage.removeItem(CACHE_KEY); } catch {} +} + +// TMDB genre id → name map +export const GENRE_MAP = { + 28: 'Action', 12: 'Adventure', 16: 'Animation', 35: 'Comedy', + 80: 'Crime', 99: 'Documentary', 18: 'Drama', 10751: 'Family', + 14: 'Fantasy', 36: 'History', 27: 'Horror', 10402: 'Music', + 9648: 'Mystery', 10749: 'Romance', 878: 'Sci-Fi', 10770: 'TV Movie', + 53: 'Thriller', 10752: 'War', 37: 'Western', +};