diff --git a/frontend/css/style.css b/frontend/css/style.css index 5cbbb7e..efe9b17 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1372,75 +1372,455 @@ body.theme-light .favorite-btn { padding-top: 6px; } -/* Modal */ -.modal { +/* --- Custom player: single fake-fullscreen state everywhere ------------ */ +.custom-player { display: none; position: fixed; inset: 0; background: #000; - z-index: 2000; + z-index: 3000; + touch-action: none; + transition: transform 0.25s ease, opacity 0.25s ease; } -body.theme-light .modal { - background: #0b0b0b; +.custom-player.open { + display: block; + animation: cp-open 0.22s ease; } -.modal.open { - display: flex; +@keyframes cp-open { + from { opacity: 0; } + to { opacity: 1; } } -.modal-content { +.cp-surface { + position: absolute; + inset: 0; +} + +/* Ambient backdrop: a blurred copy of the poster fills any letterbox bars + behind the contained video (set via the --poster custom property). */ +.cp-surface::before { + content: ''; + position: absolute; + inset: 0; + background-image: var(--poster); + background-size: cover; + background-position: center; + filter: blur(60px) saturate(1.3) brightness(0.5); + transform: scale(1.25); + opacity: 0.55; + z-index: 0; + pointer-events: none; +} + +.cp-video { + position: relative; + z-index: 1; width: 100%; height: 100%; + object-fit: contain; + background: #000; +} + +.cp-flash { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 8px 18px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 16px; + font-weight: 500; + letter-spacing: 0.02em; + pointer-events: none; + opacity: 0; + z-index: 5; +} + +.cp-flash.is-visible { + animation: cp-flash-fade 0.7s ease forwards; +} + +@keyframes cp-flash-fade { + 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.08); } +} + +.cp-spinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + z-index: 4; +} + +.cp-spinner.is-visible { + opacity: 1; +} + +.cp-spinner-ring { + width: 44px; + height: 44px; + border-radius: 50%; + border: 3px solid rgba(255, 255, 255, 0.25); + border-top-color: var(--accent); + animation: cp-spin 0.8s linear infinite; +} + +@keyframes cp-spin { + to { transform: rotate(360deg); } +} + +.cp-error { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; - justify-content: center; - padding: 20px; - box-sizing: border-box; - position: relative; + gap: 12px; + padding: 20px 24px; + background: rgba(0, 0, 0, 0.7); + border-radius: var(--radius-lg); + color: #fff; + text-align: center; + max-width: min(360px, 80vw); + z-index: 5; } -.close { - position: absolute; - top: 20px; - right: 20px; - color: #fff; - font-size: 32px; +.cp-retry-btn { + padding: 8px 20px; + border-radius: 999px; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); cursor: pointer; - background: rgba(0, 0, 0, 0.5); - border: none; + font: inherit; +} + +.cp-retry-btn:hover { + background: rgba(201, 165, 103, 0.15); +} + +.cp-replay-btn { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 64px; + height: 64px; border-radius: 50%; - width: 44px; - height: 44px; + border: none; + background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; - z-index: 2001; - transition: all 0.2s ease; + cursor: pointer; + z-index: 5; } -.close:hover { - background: rgba(255, 255, 255, 0.2); +.cp-replay-btn .icon-svg { + width: 30px; + height: 30px; + filter: invert(1); } -video { - width: 100%; - height: 100%; - max-width: 100%; - max-height: 100vh; - object-fit: contain; +.cp-hud { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + pointer-events: none; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent 18%, transparent 78%, rgba(0, 0, 0, 0.6)); + opacity: 1; + transition: opacity 0.3s ease; + z-index: 3; } -#mobile-video-host { - position: fixed; - left: -9999px; - top: 0; - width: 1px; - height: 1px; +.custom-player.cp-hud-idle .cp-hud { + opacity: 0; +} + +.cp-hud * { + pointer-events: auto; +} + +.cp-top-bar { + display: flex; + align-items: center; + gap: 12px; + padding: max(14px, env(safe-area-inset-top)) 16px 0; +} + +.cp-close-btn { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 18px; + cursor: pointer; +} + +.cp-title { + flex: 1; + min-width: 0; + margin: 0; + font-family: var(--font-display); + font-size: 16px; + color: #fff; + white-space: nowrap; overflow: hidden; } +.cp-title-text { + display: inline-block; + padding-right: 24px; + transform: translateX(0); +} + +.cp-title.has-marquee .cp-title-text { + animation: video-title-marquee var(--marquee-duration, 10s) linear infinite; +} + +.cp-fav-btn { + position: static; + flex-shrink: 0; +} + +.cp-bottom-bar { + padding: 0 16px max(14px, env(safe-area-inset-bottom)); +} + +.custom-player.is-live .cp-timeline, +.custom-player.is-live .cp-skip-back-btn, +.custom-player.is-live .cp-skip-fwd-btn { + display: none; +} + +.cp-timeline { + padding: 10px 0; + cursor: pointer; +} + +.cp-timeline-track { + position: relative; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.25); +} + +.cp-timeline-buffered { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 0%; + border-radius: 2px; + background: rgba(255, 255, 255, 0.35); +} + +.cp-timeline-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 0%; + border-radius: 2px; + background: var(--accent); +} + +.cp-timeline-handle { + position: absolute; + top: 50%; + left: 0%; + width: 13px; + height: 13px; + border-radius: 50%; + background: var(--accent); + transform: translate(-50%, -50%) scale(0.7); + transition: transform 0.15s ease; +} + +.cp-timeline:hover .cp-timeline-handle, +.cp-timeline.is-scrubbing .cp-timeline-handle { + transform: translate(-50%, -50%) scale(1); +} + +.cp-time-row { + display: flex; + align-items: center; + gap: 10px; +} + +.cp-time { + color: #fff; + font-size: 12px; + font-variant-numeric: tabular-nums; + flex-shrink: 0; + min-width: 34px; +} + +.cp-time-duration { + text-align: right; +} + +.cp-transport { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + justify-content: center; +} + +.cp-transport button { + position: relative; + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: transparent; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.cp-play-btn { + width: 48px !important; + height: 48px !important; + background: rgba(255, 255, 255, 0.12) !important; +} + +.cp-play-btn .icon-svg { + width: 22px; + height: 22px; +} + +.cp-skip-back-btn .icon-svg, +.cp-skip-fwd-btn .icon-svg { + width: 20px; + height: 20px; +} + +.cp-skip-amount { + position: absolute; + bottom: 2px; + font-size: 9px; + font-weight: 600; + background: rgba(0, 0, 0, 0.6); + border-radius: 6px; + padding: 0 3px; + pointer-events: none; +} + +.cp-secondary-controls { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.cp-mute-btn, +.cp-pip-btn { + width: 34px; + height: 34px; + border-radius: 50%; + border: none; + background: transparent; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.cp-mute-btn .icon-svg, +.cp-pip-btn .icon-svg { + width: 18px; + height: 18px; +} + +.cp-volume-range { + width: 64px; + accent-color: var(--accent); +} + +.cp-format-btn { + height: 26px; + padding: 0 8px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.35); + background: rgba(0, 0, 0, 0.4); + color: #fff; + font-size: 11px; + font-weight: 600; + cursor: pointer; +} + +.cp-format-menu { + position: absolute; + right: 16px; + bottom: 64px; + display: flex; + flex-direction: column; + background: rgba(20, 17, 13, 0.92); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + z-index: 6; + max-height: 40vh; + overflow-y: auto; +} + +.cp-format-option { + padding: 9px 18px; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + text-align: left; + cursor: pointer; + white-space: nowrap; +} + +.cp-format-option:hover { + background: var(--bg-tertiary); +} + +.cp-format-option.is-active { + color: var(--accent); +} + +/* `.cp-error`/`.cp-replay-btn`/`.cp-format-menu` above set `display` on the + bare class so JS can toggle visibility via the `hidden` attribute alone; + these higher-specificity overrides keep that attribute effective (an + unconditional `display` on the class would otherwise beat the UA + `[hidden] { display: none }` rule). */ +.cp-error[hidden], +.cp-replay-btn[hidden], +.cp-format-menu[hidden], +.cp-pip-btn[hidden], +.favorite-btn[hidden] { + display: none; +} + +@media (max-width: 480px) { + .cp-title { font-size: 14px; } + .cp-volume-range { display: none; } +} + .error-toast { position: fixed; right: 20px; @@ -1814,6 +2194,38 @@ body.feed-mode-open .mode-toggle-btn .icon-svg { background: rgba(255, 59, 48, 0.18); } +/* Reels HUD right rail additions: PiP + quality, stacked above favorite. */ +.feed-pip-btn { + position: absolute; + right: 24px; + bottom: 266px; + z-index: 6; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.18); + transition: background 0.2s ease, opacity 0.4s ease; +} + +.feed-pip-btn .icon-svg { + filter: invert(100%) saturate(0%); +} + +.feed-format-btn { + position: absolute; + right: 24px; + bottom: 322px; + z-index: 6; + transition: opacity 0.4s ease; +} + +.feed-format-menu { + right: 76px; + bottom: 322px; +} + +.feed-flash { + z-index: 5; +} + /* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay interactive (pointer-events untouched) so the buttons keep working while invisible; any pointer/scroll activity reveals them again (see App.feed). */ @@ -1821,6 +2233,8 @@ body.feed-hud-idle .feed-info, body.feed-hud-idle .feed-timeline, body.feed-hud-idle .feed-mute-btn, body.feed-hud-idle .feed-fav-btn, +body.feed-hud-idle .feed-pip-btn, +body.feed-hud-idle .feed-format-btn, body.feed-hud-idle .mode-toggle-btn { opacity: 0; } @@ -2055,26 +2469,6 @@ body.theme-light .video-card img:not(.is-loaded) { flex-shrink: 0; } -/* --- Player ambient backdrop: blurred poster behind the video --------- */ -.modal-content::before { - content: ''; - position: absolute; - inset: 0; - background-image: var(--poster); - background-size: cover; - background-position: center; - filter: blur(60px) saturate(1.3) brightness(0.5); - transform: scale(1.25); - opacity: 0.55; - z-index: 0; - pointer-events: none; -} - -.modal-content > * { - position: relative; - z-index: 1; -} - /* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */ .feed-title { font-family: var(--font-display); } @@ -2096,10 +2490,3 @@ body.theme-light .video-card img:not(.is-loaded) { pointer-events: none; } -/* --- View Transition timing (player open/close crossfade) -------------- */ -@media (prefers-reduced-motion: no-preference) { - ::view-transition-old(root), - ::view-transition-new(root) { - animation-duration: 0.32s; - } -} diff --git a/frontend/index.html b/frontend/index.html index 9707949..c6238fc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -142,12 +142,7 @@ Load More - + ` + ).join(''); + const cleanups = []; + menu.querySelectorAll('.cp-format-option').forEach((optBtn) => { + const onClick = (event) => { + event.stopPropagation(); + const idx = parseInt(optBtn.dataset.index, 10); + const opt = options[idx]; + menu.hidden = true; + menu.querySelectorAll('.cp-format-option').forEach((b) => b.classList.remove('is-active')); + optBtn.classList.add('is-active'); + if (opt) onSelect(opt.fmt); + }; + optBtn.addEventListener('click', onClick); + cleanups.push(() => optBtn.removeEventListener('click', onClick)); + }); + const onBtnClick = (event) => { + event.stopPropagation(); + menu.hidden = !menu.hidden; + }; + btn.addEventListener('click', onBtnClick); + cleanups.push(() => btn.removeEventListener('click', onBtnClick)); + return function destroy() { + cleanups.forEach((fn) => fn()); + }; + }; + + // ----------------------------------------------------------------- + // Picture-in-Picture: a manual toggle plus best-effort auto-PiP when the + // tab/app is backgrounded while a video is playing (Safari does this + // natively for inline video; Chrome/Android need an explicit call). + // ----------------------------------------------------------------- + App.customPlayer.supportsPiP = function() { + return !!(document.pictureInPictureEnabled); + }; + + App.customPlayer.togglePiP = async function(video) { + if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false; + try { + if (document.pictureInPictureElement === video) { + await document.exitPictureInPicture(); + } else { + await video.requestPictureInPicture(); + } + return true; + } catch (err) { + return false; + } + }; + + App.customPlayer.bindAutoPiP = function(video) { + if (!video) return function destroy() {}; + const trigger = () => { + if (document.visibilityState !== 'hidden') return; + if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return; + if (document.pictureInPictureElement) return; + if (video.paused || video.ended) return; + video.requestPictureInPicture().catch(() => {}); + }; + document.addEventListener('visibilitychange', trigger); + window.addEventListener('pagehide', trigger); + return function destroy() { + document.removeEventListener('visibilitychange', trigger); + window.removeEventListener('pagehide', trigger); + }; + }; + + // ----------------------------------------------------------------- + // Unified pointer-gesture recognizer for the video surface: a single + // pointer stream is classified into exactly one of tap / double-tap / + // volume-drag (right column) / dismiss-drag (top strip), so the gestures + // never fight each other over the same touch. + // ----------------------------------------------------------------- + App.customPlayer.attachGestures = function(surfaceEl, handlers) { + handlers = handlers || {}; + const TAP_MAX_MOVE = 10; + const DOUBLE_TAP_MS = 300; + const DISMISS_ZONE_FRACTION = 0.2; // top strip that owns swipe-to-dismiss + const VOLUME_ZONE_START = 0.66; // right column that owns volume swipe + const SKIP_ZONE_LEFT_END = 0.34; + const SKIP_ZONE_RIGHT_START = 0.66; + + let pointerId = null; + let startX = 0, startY = 0, lastY = 0; + let moved = false; + let mode = null; // 'dismiss-candidate' | 'dismiss' | 'volume-candidate' | 'volume' | 'ignore' + let volumeStartValue = 0; + let lastTapTime = 0; + let lastTapSide = null; + + const rectOf = () => surfaceEl.getBoundingClientRect(); + + const ignoreSelector = handlers.ignoreSelector || 'button, input, a, .cp-format-menu'; + + const onPointerDown = (e) => { + if (pointerId != null || e.button != null && e.button !== 0) return; + if (e.target && e.target.closest && e.target.closest(ignoreSelector)) return; + pointerId = e.pointerId; + startX = e.clientX; + startY = lastY = e.clientY; + moved = false; + mode = null; + const rect = rectOf(); + const relX = rect.width ? (e.clientX - rect.left) / rect.width : 0; + const relY = rect.height ? (e.clientY - rect.top) / rect.height : 0; + if (relY <= DISMISS_ZONE_FRACTION && handlers.onDismissDrag) { + mode = 'dismiss-candidate'; + } else if (relX >= VOLUME_ZONE_START && handlers.onVolumeDrag) { + mode = 'volume-candidate'; + volumeStartValue = handlers.onVolumeStart ? handlers.onVolumeStart() : 0; + } + try { surfaceEl.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ } + }; + + const onPointerMove = (e) => { + if (e.pointerId !== pointerId) return; + const dx = e.clientX - startX; + const dy = e.clientY - startY; + if (!moved && Math.hypot(dx, dy) > TAP_MAX_MOVE) moved = true; + if (moved) { + if (mode === 'dismiss-candidate') mode = 'dismiss'; + else if (mode === 'volume-candidate') mode = 'volume'; + else if (mode === null) mode = 'ignore'; + + if (mode === 'dismiss') { + handlers.onDismissDrag(dy, rectOf()); + } else if (mode === 'volume') { + const rect = rectOf(); + const deltaRatio = rect.height ? (startY - e.clientY) / rect.height : 0; + handlers.onVolumeDrag(Math.min(1, Math.max(0, volumeStartValue + deltaRatio))); + } + } + lastY = e.clientY; + }; + + const endGesture = (e) => { + if (e.pointerId !== pointerId) return; + try { surfaceEl.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ } + if (moved) { + if (mode === 'dismiss' && handlers.onDismissEnd) handlers.onDismissEnd(lastY - startY); + else if (mode === 'volume' && handlers.onVolumeEnd) handlers.onVolumeEnd(); + } else { + if (handlers.onSingleTap) handlers.onSingleTap(); + const rect = rectOf(); + const relX = rect.width ? (startX - rect.left) / rect.width : 0.5; + const side = relX <= SKIP_ZONE_LEFT_END ? 'left' : (relX >= SKIP_ZONE_RIGHT_START ? 'right' : 'center'); + const now = Date.now(); + if (side !== 'center' && lastTapSide === side && (now - lastTapTime) <= DOUBLE_TAP_MS) { + lastTapTime = 0; + lastTapSide = null; + if (side === 'left' && handlers.onDoubleTapLeft) handlers.onDoubleTapLeft(); + if (side === 'right' && handlers.onDoubleTapRight) handlers.onDoubleTapRight(); + } else { + lastTapTime = now; + lastTapSide = side; + } + } + pointerId = null; + mode = null; + }; + + surfaceEl.addEventListener('pointerdown', onPointerDown); + surfaceEl.addEventListener('pointermove', onPointerMove); + surfaceEl.addEventListener('pointerup', endGesture); + surfaceEl.addEventListener('pointercancel', endGesture); + + return function destroy() { + surfaceEl.removeEventListener('pointerdown', onPointerDown); + surfaceEl.removeEventListener('pointermove', onPointerMove); + surfaceEl.removeEventListener('pointerup', endGesture); + surfaceEl.removeEventListener('pointercancel', endGesture); + }; + }; +})(); diff --git a/frontend/js/feed.js b/frontend/js/feed.js index da2aeff..3aee673 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -233,6 +233,77 @@ App.feed = App.feed || {}; timeline.addEventListener('pointercancel', stopScrubbing); }; + const flashFeed = function(slide, text) { + const flashEl = slide.querySelector('.feed-flash'); + if (!flashEl) return; + flashEl.textContent = text; + flashEl.classList.remove('is-visible'); + void flashEl.offsetWidth; + flashEl.classList.add('is-visible'); + }; + + // Wires the controls shared with the standalone fullscreen player (skip + // escalation + double-tap zones, format switching, PiP) onto a reels + // slide, reusing the same App.customPlayer logic so both surfaces behave + // identically. Feed's own timeline/favorite/title and scroll-snap + // slide-to-slide navigation are untouched (see bindTimeline above and + // setActive/onScroll below). + const bindSharedControls = function(slide, video, videoData) { + const cleanups = []; + const escalator = App.customPlayer.createSkipEscalator(); + cleanups.push(() => escalator.destroy()); + + const doSkip = (direction) => { + const amount = App.customPlayer.skip(video, direction, escalator); + flashFeed(slide, `${direction === 'forward' ? '+' : '-'}${amount}s`); + wakeHud(); + }; + + const pipBtn = slide.querySelector('.feed-pip-btn'); + if (pipBtn) { + pipBtn.hidden = !App.customPlayer.supportsPiP(); + const onClick = async (event) => { + event.stopPropagation(); + await App.customPlayer.togglePiP(video); + }; + pipBtn.addEventListener('click', onClick); + cleanups.push(() => pipBtn.removeEventListener('click', onClick)); + } + cleanups.push(App.customPlayer.bindAutoPiP(video)); + + const formatBtn = slide.querySelector('.feed-format-btn'); + const formatMenu = slide.querySelector('.feed-format-menu'); + cleanups.push(App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, (fmt) => { + slide._formatOverride = fmt; + const t = video.currentTime; + if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t); + // Tear down the current source (mirrors destroySlidePlayback's + // hls/video reset) before reloading with the new format -- this + // is a live in-place reload, not a fresh never-loaded slide, so + // the old Hls.js instance must be destroyed or it keeps running + // (fetching segments, attached to the same