diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d9f21..322488f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Fixed +- **Media deck on iOS** — The deck and backdrop are rendered in a body-level portal (outside `.app-container` / scrollable `.sidebar`) so `position: fixed` overlays are not clipped by overflow on iPhone Safari. Touch/narrow layouts open the source **Media deck** button on `pointerdown` to avoid the first tap only materializing YouTube in-page. + +### Improved +- **Media deck vs mini player** — Opening the deck from a post no longer materializes YouTube until **Play** (facade stays static; avoids the “first tap wakes video, second opens deck” feel). Source actions add **Mini player** next to **Media deck**; the deck header adds **Mini player** to hand off from the expanded view. **Minimize** docks YouTube into the mini player even when the source is still on-screen (`forceDockMini`). Mini player stays visible for a docked facade until Play; deck/mini controls resolve the iframe when present. +- **YouTube handoff** — Before reparenting embeds (mini ↔ deck ↔ inline), the app snapshots time/play state, updates the embed URL (`start` / `autoplay`) when needed, re-bridges the IFrame API after a `src` change, and runs the existing dock-restore loop so playback can continue instead of restarting at 0. The same path is used when restoring docked YouTube to the post placeholder. **Mini ↔ deck** moves skip URL rewrites and player resets so the iframe is not torn down in-place (avoids a blank deck stage). +- **Media deck resilience** — `repairMediaCurrentReference` rebinds `state.current` from the post/message when the prior node is disconnected; `reconcileDeckStageMediaPlacement` moves video/YouTube back onto the deck stage if it was left elsewhere; failed repair while the deck is open force-closes the deck. Docked YouTube **ENDED** uses the iframe’s `__canopyMiniYTState` when `state.current.el` is the wrapper. +- **Deck controls on short screens** — Header actions wrap; **Collapse** and **Mini bar** are duplicated in the sticky bottom control row (with Prev/Play/Next) so you can reach mini-player handoff without scrolling to the top. + +## [0.4.113] - 2026-03-20 + +### Improved +- **Media deck mobile** — Fullscreen-style surface on narrow portrait and short landscape; modal body scroll lock (`canopy-media-deck-modal`); sticky header and bottom controls with safe-area; visible Minimize/Close labels on touch; mini-player hidden while the deck is open; landscape compaction query scoped so short phones in landscape keep fullscreen instead of the floating tablet layout. +- **Source launcher and return** — Stale `returnUrl` / `dockedSubtitle` cleared when opening from a post or message; Return closes the deck with force-close so media restores to the source without handing off to the mini-player; mini-player **Show source** vs deck **Return to source** copy for clearer semantics. +- **Playback hardening** — YouTube auto-dock suppressed while the deck is open; global YouTube facade handler skips `defaultPrevented` clicks; deck selection key resyncs after facade→iframe materialization; redundant `keepDeckVisible` paths removed from `updateMini` (open deck already handled earlier). + +### Tests +- Frontend regression coverage for mobile deck layout, source/return labels, first-click hardening, and related guards. + +## [0.4.112] - 2026-03-19 + +### Improved +- **Media deck second pass** — Playback UI refreshes now coalesce through a shared scheduler, deck queue rerenders are skipped when membership and active item are unchanged, and media deactivation/cleanup paths are more centralized. Source-level deck launchers remain available on playable posts and messages. + +## [0.4.111] - 2026-03-19 + +### Added +- **Expanded media deck and related queue** — The sidebar mini-player can now open into a larger floating media deck with a stage area, richer controls, seek support, PiP for supported video, and a related-media queue scoped to the same post or message. + +### Improved +- **Mini-player continuity** — Off-screen audio, direct video, and YouTube playback now share one media state across the compact dock and expanded deck, including source return, minimize/close handling, and placeholder-preserving docking for larger playback. +- **Frontend coverage for media deck** — Regression assertions now cover the expanded deck markup, expand control, queue wiring, docking helpers, and seek behavior. + +## [0.4.110] - 2026-03-19 + +### Hardened +- **Message replay prevention** — Inbound P2P messages older than 2 hours or timestamped more than 30 seconds in the future are rejected, preventing replay attacks after the seen-message cache evicts old IDs. Locally-created messages are exempt for store-and-forward compatibility. +- **Routing table size cap** — The routing table is capped at 500 entries. When full, the oldest entry is evicted to bound memory and limit the impact of stale or poisoned routes. +- **Relay offer validation** — Relay offers are only accepted from directly connected peers, preventing a relayed offer from creating a routing entry through an unreachable intermediary. +- **Generic error responses** — API, UI, and MCP error responses no longer include raw exception strings. Internal details (paths, SQL, stack frames) are replaced with safe generic messages while full context is preserved in server logs. + ## [0.4.109] - 2026-03-19 ### Hardened diff --git a/README.md b/README.md index 6c6540b..2269f23 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

- Version 0.4.109 + Version 0.4.113 Python 3.10+ Apache 2.0 License ChaCha20-Poly1305 @@ -81,6 +81,8 @@ Most chat products treat AI as bolt-on automation hanging off webhooks or extern Recent user-facing changes reflected in the app and docs: +- **Media deck mobile and return flow** in `0.4.113`, with a fullscreen-style deck on phones, modal scroll lock, sticky controls with safe-area, clearer Minimize/Close, mini-player hidden while the deck is open, and **Return to source** / **Show source** semantics so the deck restores the post without handing off to the mini-player. Includes interaction hardening (YouTube dock, facade clicks, selection keys) and expanded frontend regression coverage. +- **Expanded media deck** in `0.4.111`, turning the sidebar mini-player into a two-tier media surface. Off-screen playback can now open into a larger floating deck with a stage area, queue navigation, seek support, PiP for supported video, and related media drawn from the same post or message. - **Privacy-first trust baseline** in `0.4.106`, where unknown peers start at trust score 0 (pending review) instead of being implicitly trusted. Feed posts default to private. Visibility-scoped propagation ensures narrowing a post's visibility sends revocation signals to peers that should no longer see it. - **Proactive P2P hardening** in `0.4.107`-`0.4.109`, tightening trust boundaries, enforcing payload and identity validation on inbound P2P messages, strengthening delete-signal authorization, and improving encryption helper robustness. API authentication coverage extended across all status endpoints. - **Sidebar performance** in `0.4.108`, with DOM batching, render-key diffing to skip unnecessary redraws, relaxed polling intervals, and GPU compositing hints for smoother animations. diff --git a/canopy/__init__.py b/canopy/__init__.py index 1ad7a22..9f01c66 100644 --- a/canopy/__init__.py +++ b/canopy/__init__.py @@ -11,7 +11,7 @@ Development: AI-assisted implementation (Claude, Codex, GitHub Copilot, Cursor IDE, Ollama) """ -__version__ = "0.4.109" +__version__ = "0.4.113" __protocol_version__ = 1 __author__ = "Canopy Contributors" __license__ = "Apache-2.0" diff --git a/canopy/ui/static/js/canopy-main.js b/canopy/ui/static/js/canopy-main.js index d25836c..103723a 100644 --- a/canopy/ui/static/js/canopy-main.js +++ b/canopy/ui/static/js/canopy-main.js @@ -1814,14 +1814,19 @@ ); } - document.addEventListener('click', function(e) { - var facade = e.target.closest('.yt-facade'); - if (!facade) return; - var src = facade.getAttribute('data-iframe-src'); - if (!src) return; - var container = facade.parentElement; - var iframe = document.createElement('iframe'); - iframe.src = src; + function materializeYouTubeFacade(facade, options = {}) { + if (!facade) return null; + if (facade.tagName && facade.tagName.toLowerCase() === 'iframe') return facade; + const src = facade.getAttribute('data-iframe-src'); + if (!src) return null; + let iframeSrc = src; + try { + const url = new URL(src, window.location.origin); + url.searchParams.set('autoplay', options.autoplay === true ? '1' : '0'); + iframeSrc = url.toString(); + } catch (_) {} + const iframe = document.createElement('iframe'); + iframe.src = iframeSrc; iframe.title = 'YouTube video'; iframe.frameBorder = '0'; iframe.allowFullscreen = true; @@ -1833,6 +1838,15 @@ iframe.style.background = '#000'; iframe.style.display = 'block'; facade.replaceWith(iframe); + registerMediaNode(iframe); + return iframe; + } + + document.addEventListener('click', function(e) { + if (e.defaultPrevented) return; + var facade = e.target.closest('.yt-facade'); + if (!facade) return; + materializeYouTubeFacade(facade, { autoplay: true }); }); const RICH_EMBED_PROVIDERS = [ @@ -4871,12 +4885,45 @@ const jumpBtn = document.getElementById('sidebar-media-mini-jump'); const pipBtn = document.getElementById('sidebar-media-mini-pip'); const pinBtn = document.getElementById('sidebar-media-mini-pin'); + const expandBtn = document.getElementById('sidebar-media-mini-expand'); const closeBtn = document.getElementById('sidebar-media-mini-close'); const timeEl = document.getElementById('sidebar-media-mini-time'); const mainScroller = document.querySelector('.main-content'); const miniVideoHost = document.getElementById('sidebar-media-mini-video'); const topSlot = document.getElementById('sidebar-media-mini-slot-top'); const bottomSlot = document.getElementById('sidebar-media-mini-slot-bottom'); + const deck = document.getElementById('sidebar-media-deck'); + const deckShell = deck ? deck.querySelector('.sidebar-media-deck-shell') : null; + const deckBackdrop = document.getElementById('sidebar-media-deck-backdrop'); + const deckStage = document.getElementById('sidebar-media-deck-stage'); + const deckVisual = document.getElementById('sidebar-media-deck-visual'); + const deckVisualCover = document.getElementById('sidebar-media-deck-visual-cover'); + const deckVisualIcon = document.getElementById('sidebar-media-deck-visual-icon'); + const deckVisualTitle = document.getElementById('sidebar-media-deck-visual-title'); + const deckVisualSubtitle = document.getElementById('sidebar-media-deck-visual-subtitle'); + const deckChipLabel = document.getElementById('sidebar-media-deck-chip-label'); + const deckCountChip = document.getElementById('sidebar-media-deck-count-chip'); + const deckSource = document.getElementById('sidebar-media-deck-source'); + const deckTitle = document.getElementById('sidebar-media-deck-title'); + const deckSubtitle = document.getElementById('sidebar-media-deck-subtitle'); + const deckProvider = document.getElementById('sidebar-media-deck-provider'); + const deckProviderLabel = document.getElementById('sidebar-media-deck-provider-label'); + const deckCount = document.getElementById('sidebar-media-deck-count'); + const deckSeek = document.getElementById('sidebar-media-deck-seek'); + const deckCurrentTime = document.getElementById('sidebar-media-deck-current-time'); + const deckDuration = document.getElementById('sidebar-media-deck-duration'); + const deckPrevBtn = document.getElementById('sidebar-media-deck-prev'); + const deckPlayBtn = document.getElementById('sidebar-media-deck-play'); + const deckNextBtn = document.getElementById('sidebar-media-deck-next'); + const deckPipBtn = document.getElementById('sidebar-media-deck-pip'); + const deckReturnBtn = document.getElementById('sidebar-media-deck-return'); + const deckMinimizeBtn = document.getElementById('sidebar-media-deck-minimize'); + const deckMiniPlayerBtn = document.getElementById('sidebar-media-deck-mini-player'); + const deckMinimizeFooterBtn = document.getElementById('sidebar-media-deck-minimize-footer'); + const deckMiniFooterBtn = document.getElementById('sidebar-media-deck-mini-player-footer'); + const deckCloseBtn = document.getElementById('sidebar-media-deck-close'); + const deckQueue = document.getElementById('sidebar-media-deck-queue'); + const deckQueueCount = document.getElementById('sidebar-media-deck-queue-count'); const state = { current: null, @@ -4886,7 +4933,15 @@ tickHandle: null, ytApiPromise: null, returnUrl: null, - dockedSubtitle: null + dockedSubtitle: null, + deckOpen: false, + deckItems: [], + deckSelectedKey: '', + deckQueueSignature: '', + deckSeeking: false, + mediaCounter: 0, + miniUpdateFrame: 0, + miniUpdateTimer: null }; function updateMiniPlacementControl() { @@ -4912,15 +4967,58 @@ setCanopySidebarMiniPosition(canopySidebarRailState.miniPosition); + function scheduleMiniUpdate(delay = 0) { + const wait = Number(delay || 0); + if (wait > 0) { + if (state.miniUpdateTimer) { + clearTimeout(state.miniUpdateTimer); + } + state.miniUpdateTimer = setTimeout(() => { + state.miniUpdateTimer = null; + scheduleMiniUpdate(0); + }, wait); + return; + } + if (state.miniUpdateFrame) return; + state.miniUpdateFrame = window.requestAnimationFrame(() => { + state.miniUpdateFrame = 0; + updateMini(); + }); + } + function mediaTypeFor(el) { if (!el || !el.tagName) return ''; const tag = el.tagName.toLowerCase(); if (tag === 'audio') return 'audio'; if (tag === 'video') return 'video'; - if (tag === 'iframe' && el.closest('.youtube-embed')) return 'youtube'; + if ((tag === 'iframe' || tag === 'div') && (el.closest('.youtube-embed') || el.matches('.youtube-embed, .yt-facade'))) return 'youtube'; return ''; } + function resolveYouTubeMediaElement(el, options = {}) { + if (!el) return null; + if (el.tagName && el.tagName.toLowerCase() === 'iframe' && el.closest('.youtube-embed')) { + return el; + } + const wrapper = el.matches && el.matches('.youtube-embed') + ? el + : (el.closest ? el.closest('.youtube-embed') : null); + if (!wrapper) return null; + const existingIframe = wrapper.querySelector('iframe'); + if (existingIframe) return existingIframe; + if (options.activate !== true) return wrapper; + const facade = wrapper.querySelector('.yt-facade'); + return materializeYouTubeFacade(facade, { autoplay: options.autoplay === true }) || wrapper; + } + + /** True when this embed is still the static facade (no iframe) — avoids loading YouTube until Play. */ + function isYouTubeFacadeOnly(el) { + if (!el) return false; + const w = resolveYouTubeMediaElement(el, { activate: false }); + if (!w || typeof w.querySelector !== 'function') return false; + return !!(w.querySelector('.yt-facade')) && !w.querySelector('iframe'); + } + function mediaIcon(type) { if (type === 'audio') return 'bi-music-note-beamed'; if (type === 'video') return 'bi-camera-video'; @@ -4928,6 +5026,38 @@ return 'bi-play-circle'; } + function mediaProviderLabel(type) { + if (type === 'audio') return 'Audio'; + if (type === 'video') return 'Video'; + if (type === 'youtube') return 'YouTube'; + return 'Media'; + } + + function ensureMediaIdentity(el) { + if (!el) return ''; + if (!el.__canopyMiniMediaId) { + state.mediaCounter += 1; + el.__canopyMiniMediaId = `canopy-media-${state.mediaCounter}`; + } + return String(el.__canopyMiniMediaId || ''); + } + + function safeMediaThumbSrc(value) { + if (typeof _safeImageSrc === 'function') { + return _safeImageSrc(value || ''); + } + return value || ''; + } + + function clearDeckStageDockedNodes() { + if (!deckStage) return; + Array.from(deckStage.children).forEach((child) => { + if (child !== deckVisual) { + child.remove(); + } + }); + } + function isYouTubePlayingState(ytState) { if (!Number.isFinite(ytState)) return false; // PLAYING (1) and BUFFERING (3) should keep the mini player active. @@ -5016,7 +5146,7 @@ setCurrent(el, 'youtube'); return; } - setTimeout(updateMini, 50); + scheduleMiniUpdate(50); } } }); @@ -5040,7 +5170,9 @@ } function sourceContainer(el) { - return el.closest('.post-card[data-post-id], .message-item[data-message-id], .card'); + if (!el || !el.closest) return null; + const postOrMessage = el.closest('.post-card[data-post-id], .message-item[data-message-id]'); + return postOrMessage || el.closest('.card'); } function sourceSubtitle(el) { @@ -5098,6 +5230,948 @@ return 'Media'; } + function subtitleFromMedia(el, type) { + const base = sourceSubtitle(el); + if (type === 'audio') return `${base} • audio`; + if (type === 'video') return `${base} • video`; + if (type === 'youtube') return `${base} • youtube`; + return base; + } + + function getYouTubeVideoId(el) { + if (!el) return ''; + return String( + el.getAttribute('data-video-id') || + (el.closest('.youtube-embed') && el.closest('.youtube-embed').getAttribute('data-video-id')) || + '' + ).trim(); + } + + function resolveMediaThumbnail(el, type) { + if (!el) return ''; + if (type === 'youtube') { + const videoId = getYouTubeVideoId(el); + if (videoId) { + return `https://i.ytimg.com/vi/${encodeURIComponent(videoId)}/hqdefault.jpg`; + } + } + + if (type === 'video') { + const poster = safeMediaThumbSrc(el.getAttribute('poster') || ''); + if (poster) return poster; + } + + const attachment = el.closest('.attachment-item, .provider-card-embed, .embed-preview'); + if (attachment) { + const candidate = attachment.querySelector('img'); + if (candidate) { + const src = safeMediaThumbSrc(candidate.getAttribute('src') || candidate.src || ''); + if (src) return src; + } + } + + const container = sourceContainer(el); + if (container) { + const candidates = Array.from(container.querySelectorAll('img')).filter((img) => { + if (!(img instanceof HTMLImageElement)) return false; + const src = safeMediaThumbSrc(img.getAttribute('src') || img.src || ''); + if (!src) return false; + if (img.closest('.sidebar-dm-avatar, .sidebar-peer-avatar, .profile-avatar, .message-avatar, .comment-avatar')) return false; + const width = Number(img.getAttribute('width') || img.naturalWidth || img.width || 0); + const height = Number(img.getAttribute('height') || img.naturalHeight || img.height || 0); + return width >= 80 || height >= 80; + }); + if (candidates.length) { + const img = candidates[0]; + return safeMediaThumbSrc(img.getAttribute('src') || img.src || ''); + } + } + return ''; + } + + function getMediaDockWrapper(el, type) { + if (!el) return null; + if (type === 'youtube') return (el.matches && el.matches('.youtube-embed') ? el : el.closest('.youtube-embed')) || el; + if (type === 'video') return el; + return null; + } + + function capturePlaceholderSize(el, wrapper) { + const rect = wrapper && typeof wrapper.getBoundingClientRect === 'function' + ? wrapper.getBoundingClientRect() + : (el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect() : null); + return { + width: Math.max(160, Math.round((rect && rect.width) || (wrapper && wrapper.offsetWidth) || (el && el.offsetWidth) || 320)), + height: Math.max(90, Math.round((rect && rect.height) || (wrapper && wrapper.offsetHeight) || (el && el.offsetHeight) || 180)), + }; + } + + function ensureMediaPlaceholder(el, type) { + if (!el) return null; + if (type === 'youtube' && el.__canopyAutoDockPlaceholder) return el.__canopyAutoDockPlaceholder; + if (type === 'video' && el.__canopyMiniVideoPlaceholder) return el.__canopyMiniVideoPlaceholder; + const wrapper = getMediaDockWrapper(el, type); + if (!wrapper || !wrapper.parentNode) return null; + if (wrapper.parentNode === miniVideoHost || wrapper.parentNode === deckStage) return null; + const size = capturePlaceholderSize(el, wrapper); + const placeholder = document.createElement('div'); + placeholder.className = type === 'youtube' ? 'canopy-yt-mini-placeholder' : 'canopy-video-mini-placeholder'; + placeholder.style.cssText = `width:${size.width}px;height:${size.height}px;`; + wrapper.parentNode.insertBefore(placeholder, wrapper); + if (type === 'youtube') { + el.__canopyAutoDockPlaceholder = placeholder; + } else if (type === 'video') { + el.__canopyMiniVideoPlaceholder = placeholder; + } + if (state.observer) state.observer.observe(placeholder); + return placeholder; + } + + function restoreDockedMedia(el, options = {}) { + if (!el) return; + const type = mediaTypeFor(el); + const preferMini = options && options.preferMini === true; + if (type === 'youtube') { + const forceDockMini = options && options.forceDockMini === true; + if (preferMini && miniVideoHost && (forceDockMini || isOffscreen(el))) { + const wrapper = getMediaDockWrapper(el, type); + if (wrapper && wrapper.parentNode !== miniVideoHost) { + prepareYouTubeEmbedForHostMove(el, { + skipResumeUrlRewrite: isSidebarDeckOrMiniHost(wrapper.parentNode), + }); + miniVideoHost.innerHTML = ''; + miniVideoHost.appendChild(wrapper); + miniVideoHost.style.display = 'block'; + const ytIframe = resolveYouTubeMediaElement(el, { activate: false }); + if (ytIframe && ytIframe.tagName.toLowerCase() === 'iframe') { + maybeRestoreYouTubeDockState(ytIframe); + } + } + return; + } + const ph = el.__canopyAutoDockPlaceholder; + const wrapper = getMediaDockWrapper(el, type); + if (ph && ph.isConnected && ph.parentNode && wrapper) { + prepareYouTubeEmbedForHostMove(el); + ph.parentNode.insertBefore(wrapper, ph); + ph.remove(); + const ytIframe = resolveYouTubeMediaElement(el, { activate: false }); + if (ytIframe && ytIframe.tagName.toLowerCase() === 'iframe') { + maybeRestoreYouTubeDockState(ytIframe); + } + } + delete el.__canopyAutoDockPlaceholder; + } else if (type === 'video') { + const ph = el.__canopyMiniVideoPlaceholder; + if (ph && ph.isConnected && ph.parentNode) { + ph.parentNode.insertBefore(el, ph); + ph.remove(); + } + delete el.__canopyMiniVideoPlaceholder; + } + } + + function moveDockedMediaToHost(el, host) { + if (!el || !host) return false; + const type = mediaTypeFor(el); + if (type !== 'youtube' && type !== 'video') return false; + ensureMediaPlaceholder(el, type); + const wrapper = getMediaDockWrapper(el, type); + if (!wrapper) return false; + if (wrapper.parentNode === host) { + host.style.display = 'block'; + return true; + } + if (type === 'youtube') { + prepareYouTubeEmbedForHostMove(el, { + skipResumeUrlRewrite: isSidebarDeckOrMiniHost(wrapper.parentNode) && + isSidebarDeckOrMiniHost(host), + }); + } + if (host === deckStage) { + clearDeckStageDockedNodes(); + if (deckVisual && deckVisual.parentNode === deckStage) { + deckStage.insertBefore(wrapper, deckVisual); + } else { + host.appendChild(wrapper); + } + } else { + host.innerHTML = ''; + host.appendChild(wrapper); + } + host.style.display = 'block'; + if (type === 'youtube') { + const ytIframe = resolveYouTubeMediaElement(el, { activate: false }); + if (ytIframe && ytIframe.tagName.toLowerCase() === 'iframe') { + maybeRestoreYouTubeDockState(ytIframe); + } + } + return true; + } + + function clearOrphanedDockedMedia(el, type, sourceEl) { + if (!el) return; + const wrapper = getMediaDockWrapper(el, type); + if (!wrapper) return; + const parent = wrapper.parentNode; + if (parent !== miniVideoHost && parent !== deckStage) return; + if (sourceEl && sourceEl.isConnected) return; + wrapper.remove(); + if (parent === miniVideoHost && miniVideoHost) { + miniVideoHost.innerHTML = ''; + miniVideoHost.style.display = 'none'; + } + if (parent === deckStage && deckStage) { + clearDeckStageDockedNodes(); + deckStage.classList.add('is-empty'); + } + } + + function pauseMediaElement(el, type) { + if (!el) return; + try { + if (type === 'audio' || type === 'video') { + el.pause(); + } else if (type === 'youtube') { + const target = resolveYouTubeMediaElement(el, { activate: false }); + const player = target && target.__canopyMiniYTPlayer; + if (player && typeof player.pauseVideo === 'function') { + player.pauseVideo(); + target.__canopyMiniYTState = 2; + } + } + } catch (_) {} + } + + function deactivateMediaEntry(entry, options = {}) { + if (!entry || !entry.el) return; + const el = entry.el; + const type = entry.type || mediaTypeFor(el); + if (!type) return; + pauseMediaElement(el, type); + if (type === 'youtube') { + clearYouTubeDockResumeState(el); + } + restoreDockedMedia(el, { preferMini: false }); + // Re-assert pause after restoration so a switched-away item cannot + // keep playing from its original source behind the active deck item. + pauseMediaElement(el, type); + clearOrphanedDockedMedia(el, type, entry.sourceEl || sourceContainer(el)); + if (options.resetReturnState !== false) { + state.dockedSubtitle = null; + state.returnUrl = null; + } + } + + function playMediaElement(el, type) { + if (!el) return; + try { + if (type === 'audio' || type === 'video') { + const playResult = el.play(); + if (playResult && typeof playResult.catch === 'function') { + playResult.catch(() => {}); + } + } else if (type === 'youtube') { + const ytEl = resolveYouTubeMediaElement(el, { activate: true, autoplay: true }); + if (!ytEl || ytEl.tagName.toLowerCase() !== 'iframe') return; + initYouTubePlayer(ytEl); + const player = ytEl.__canopyMiniYTPlayer; + if (player && typeof player.playVideo === 'function') { + player.playVideo(); + ytEl.__canopyMiniYTState = 1; + } + } + } catch (_) {} + } + + function buildRelatedMediaList(sourceEl, activeEl) { + const items = []; + const seen = new Set(); + + function pushCandidate(node) { + if (!node || !(node instanceof Element)) return; + const type = mediaTypeFor(node); + if (!type) return; + const target = type === 'youtube' ? (resolveYouTubeMediaElement(node, { activate: false }) || node) : node; + const key = ensureMediaIdentity(target); + if (!key || seen.has(key)) return; + seen.add(key); + items.push({ + key, + el: target, + type, + title: titleFromMedia(target, type), + subtitle: subtitleFromMedia(target, type), + thumb: resolveMediaThumbnail(target, type), + }); + } + + if (activeEl) pushCandidate(activeEl); + if (sourceEl && sourceEl.querySelectorAll) { + sourceEl.querySelectorAll('audio, video, .youtube-embed').forEach(pushCandidate); + } + return items; + } + + function getSourceMediaDeckItems(sourceEl) { + if (!sourceEl || !sourceEl.isConnected) return []; + return buildRelatedMediaList(sourceEl, null); + } + + function getDeckSelectedItem() { + if (!Array.isArray(state.deckItems) || !state.deckItems.length) return null; + if (state.deckSelectedKey) { + const selected = state.deckItems.find((item) => item.key === state.deckSelectedKey); + if (selected) return selected; + } + if (state.current && state.current.el) { + const currentMatch = state.deckItems.find((item) => isSameDeckMediaItem( + state.current.el, + state.current.type, + item + )); + if (currentMatch) return currentMatch; + } + return state.deckItems[0] || null; + } + + function ensureMediaSourceLinked() { + if (!state.current || !state.current.el || !state.current.el.isConnected) return; + if (!state.current.sourceEl || !state.current.sourceEl.isConnected) { + const next = sourceContainer(state.current.el); + if (next && next.isConnected) { + state.current.sourceEl = next; + } + } + } + + /** + * If the current media node was removed (DOM churn) but the post/message is still in the document, + * rebind `state.current` to the best playable element in that source. + * @returns {boolean} true if `state.current` points at a connected node afterward + */ + function repairMediaCurrentReference() { + if (!state.current) return false; + const cur = state.current; + if (cur.el && cur.el.isConnected) { + ensureMediaSourceLinked(); + return true; + } + if (!cur.sourceEl || !cur.sourceEl.isConnected) { + return false; + } + const items = getSourceMediaDeckItems(cur.sourceEl); + if (!items.length) { + return false; + } + const pref = getPreferredDeckItemForSource(cur.sourceEl, items); + if (!pref) { + return false; + } + state.deckItems = items; + state.deckQueueSignature = ''; + state.current = { + el: pref.el, + type: pref.type, + sourceEl: cur.sourceEl, + activatedAt: Date.now(), + }; + state.deckSelectedKey = ensureMediaIdentity(pref.el); + state.dismissedEl = null; + return true; + } + + /** If the deck is open but the video/YT wrapper is not on the stage, move it back (fixes empty stage after races). */ + function reconcileDeckStageMediaPlacement() { + if (!state.deckOpen || !state.current || !deckStage) return; + const t = state.current.type; + if (t !== 'youtube' && t !== 'video') return; + const w = getMediaDockWrapper(state.current.el, t); + if (!w || !w.isConnected) return; + if (deckStage.contains(w)) return; + if (miniVideoHost && miniVideoHost.contains(w)) return; + moveDockedMediaToHost(state.current.el, deckStage); + } + + function scrollDeckSelectionIntoView() { + if (!deckQueue || !state.deckSelectedKey) return; + const selectorKey = window.CSS && typeof window.CSS.escape === 'function' + ? window.CSS.escape(state.deckSelectedKey) + : state.deckSelectedKey.replace(/["\\]/g, '\\$&'); + const activeBtn = deckQueue.querySelector(`.sidebar-media-deck-item[data-media-key="${selectorKey}"]`); + if (!activeBtn || typeof activeBtn.scrollIntoView !== 'function') return; + window.requestAnimationFrame(() => { + activeBtn.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' }); + }); + } + + function selectDeckItem(item, options = {}) { + if (!item) return; + const shouldPlay = options.play === true; + state.dismissedEl = null; + const deferYt = !shouldPlay && item.type === 'youtube'; + setCurrent(item.el, item.type, deferYt ? { deferYouTubeMaterialize: true } : undefined); + state.deckSelectedKey = (state.current && state.current.el) + ? ensureMediaIdentity(state.current.el) + : (item.key || ''); + if (state.current && state.current.el) { + if (shouldPlay) { + playMediaElement(state.current.el, state.current.type); + } else { + pauseMediaElement(state.current.el, state.current.type); + } + } + updateDeckPanel(); + updateSourceDeckLauncherActiveStates(); + scrollDeckSelectionIntoView(); + } + + function resolveSourceMediaDeckLauncherHost(sourceEl) { + if (!sourceEl || !sourceEl.isConnected) { + return { host: null, owned: false }; + } + const actionsHost = sourceEl.querySelector('[data-post-actions] .d-flex.gap-2.flex-wrap, .message-actions .d-flex.gap-2.flex-wrap'); + if (actionsHost) { + return { host: actionsHost, owned: false }; + } + let slot = sourceEl.__canopyMediaDeckSlot; + if (slot && slot.isConnected) { + return { host: slot, owned: true }; + } + slot = sourceEl.querySelector('.canopy-media-deck-source-slot[data-media-deck-slot="1"]'); + if (!slot) { + slot = document.createElement('div'); + slot.className = 'canopy-media-deck-source-slot'; + slot.setAttribute('data-media-deck-slot', '1'); + const dmFooter = sourceEl.querySelector('.dm-bubble-footer'); + const dmBubble = sourceEl.querySelector('.dm-bubble'); + if (dmFooter && typeof dmFooter.insertAdjacentElement === 'function') { + dmFooter.insertAdjacentElement('afterend', slot); + } else if (dmBubble) { + dmBubble.appendChild(slot); + } else { + sourceEl.appendChild(slot); + } + } + sourceEl.__canopyMediaDeckSlot = slot; + return { host: slot, owned: true }; + } + + /** True when deck item matches current media (YouTube may be iframe in state vs wrapper in item list). */ + function isSameDeckMediaItem(currentEl, currentType, item) { + if (!currentEl || !item) return false; + if (currentType !== item.type) return false; + if (item.type === 'youtube') { + const a = getMediaDockWrapper(currentEl, 'youtube'); + const b = getMediaDockWrapper(item.el, 'youtube'); + return !!(a && b && a === b); + } + return item.el === currentEl; + } + + function getPreferredDeckItemForSource(sourceEl, items) { + const playableItems = Array.isArray(items) ? items : getSourceMediaDeckItems(sourceEl); + if (!playableItems.length) return null; + if (state.current && state.current.el && state.current.sourceEl === sourceEl) { + const currentMatch = playableItems.find((item) => isSameDeckMediaItem( + state.current.el, + state.current.type, + item + )); + if (currentMatch) return currentMatch; + } + const playingMatch = playableItems.find((item) => isElementPlaying(item.el, item.type)); + if (playingMatch) return playingMatch; + return playableItems[0] || null; + } + + function updateSourceDeckLauncherActiveStates() { + const activeSource = (state.current && state.current.sourceEl && state.dismissedEl !== state.current.el) + ? ensureMediaIdentity(state.current.sourceEl) + : ''; + document.querySelectorAll('[data-open-media-deck]').forEach((btn) => { + const sourceId = String(btn.getAttribute('data-source-media-id') || ''); + const isActive = !!sourceId && !!activeSource && sourceId === activeSource; + btn.classList.toggle('is-active', isActive); + btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + btn.setAttribute('aria-expanded', isActive && state.deckOpen ? 'true' : 'false'); + }); + document.querySelectorAll('[data-open-mini-player]').forEach((btn) => { + const sourceId = String(btn.getAttribute('data-source-media-id') || ''); + const isActive = !!sourceId && !!activeSource && sourceId === activeSource; + btn.classList.toggle('is-active', isActive); + btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + } + + function openMediaDeckForSource(sourceEl) { + if (!sourceEl || !sourceEl.isConnected) return; + const items = getSourceMediaDeckItems(sourceEl); + const preferred = getPreferredDeckItemForSource(sourceEl, items); + if (!preferred) return; + state.deckItems = items; + state.deckSelectedKey = preferred.key; + state.dismissedEl = null; + state.returnUrl = null; + state.dockedSubtitle = null; + state.deckOpen = true; + selectDeckItem(preferred, { play: false }); + updateSourceDeckLauncherActiveStates(); + scheduleMiniUpdate(20); + } + + /** Open the sidebar mini player for this post/message (no deck); keeps YouTube as facade until Play. */ + function openMiniPlayerForSource(sourceEl) { + if (!sourceEl || !sourceEl.isConnected || !miniVideoHost) return; + const items = getSourceMediaDeckItems(sourceEl); + const preferred = getPreferredDeckItemForSource(sourceEl, items); + if (!preferred) return; + state.deckOpen = false; + if (expandBtn) { + expandBtn.innerHTML = ''; + expandBtn.title = 'Open media deck'; + } + state.deckItems = items; + state.deckSelectedKey = preferred.key; + state.dismissedEl = null; + state.returnUrl = null; + state.dockedSubtitle = null; + selectDeckItem(preferred, { play: false }); + if (state.current && (preferred.type === 'youtube' || preferred.type === 'video')) { + moveDockedMediaToHost(state.current.el, miniVideoHost); + } + if (deckStage && !deckStage.querySelector('.youtube-embed, video')) { + deckStage.classList.add('is-empty'); + if (deckVisual) deckVisual.hidden = false; + } + updateDeckVisibility(); + updateSourceDeckLauncherActiveStates(); + scheduleMiniUpdate(20); + } + + function switchDeckToMiniPlayer() { + if (!state.current || !state.current.el || !miniVideoHost) return; + state.deckOpen = false; + if (expandBtn) { + expandBtn.innerHTML = ''; + expandBtn.title = 'Open media deck'; + } + const { el, type } = state.current; + if (type === 'youtube' || type === 'video') { + moveDockedMediaToHost(el, miniVideoHost); + } + if (deckStage && !deckStage.querySelector('.youtube-embed, video')) { + deckStage.classList.add('is-empty'); + if (deckVisual) deckVisual.hidden = false; + } + updateDeckVisibility(); + updateSourceDeckLauncherActiveStates(); + scheduleMiniUpdate(30); + } + + function attachMediaLauncherButton(btn, mqCoarseOrNarrow, openFn) { + let lastOpenAt = 0; + const openFromLauncher = (event) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + openFn(); + lastOpenAt = Date.now(); + }; + btn.addEventListener('pointerdown', (event) => { + if (!mqCoarseOrNarrow.matches || event.pointerType === 'mouse' || event.button !== 0) return; + openFromLauncher(event); + }, true); + btn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (mqCoarseOrNarrow.matches && (Date.now() - lastOpenAt) < 650) return; + openFromLauncher(event); + }); + } + + function syncSourceMediaDeckLauncher(sourceEl) { + if (!sourceEl || !sourceEl.isConnected) return; + const items = getSourceMediaDeckItems(sourceEl); + let btnDeck = sourceEl.querySelector('[data-open-media-deck]'); + let btnMini = sourceEl.querySelector('[data-open-mini-player]'); + if (!items.length) { + if (btnDeck) btnDeck.remove(); + if (btnMini) btnMini.remove(); + if (sourceEl.__canopyMediaDeckSlot && sourceEl.__canopyMediaDeckSlot.isConnected && !sourceEl.__canopyMediaDeckSlot.childElementCount) { + sourceEl.__canopyMediaDeckSlot.remove(); + } + delete sourceEl.__canopyMediaDeckSlot; + return; + } + + const hostInfo = resolveSourceMediaDeckLauncherHost(sourceEl); + const host = hostInfo.host; + if (!host) return; + const currentSourceId = ensureMediaIdentity(sourceEl); + const mqCoarseOrNarrow = window.matchMedia('(max-width: 640px), (pointer: coarse)'); + if (!btnDeck) { + btnDeck = document.createElement('button'); + btnDeck.type = 'button'; + btnDeck.className = 'canopy-media-deck-launcher'; + btnDeck.setAttribute('data-open-media-deck', '1'); + attachMediaLauncherButton(btnDeck, mqCoarseOrNarrow, () => openMediaDeckForSource(sourceEl)); + } + if (!btnMini) { + btnMini = document.createElement('button'); + btnMini.type = 'button'; + btnMini.className = 'canopy-media-mini-launcher'; + btnMini.setAttribute('data-open-mini-player', '1'); + attachMediaLauncherButton(btnMini, mqCoarseOrNarrow, () => openMiniPlayerForSource(sourceEl)); + } + + if (btnDeck.parentNode !== host) { + host.appendChild(btnDeck); + } + if (btnMini.parentNode !== host) { + host.appendChild(btnMini); + } + + const countLabel = items.length === 1 ? '1' : String(items.length); + const launcherSignature = `${currentSourceId}|${countLabel}`; + btnDeck.setAttribute('data-source-media-id', currentSourceId); + btnDeck.setAttribute('data-launcher-signature', launcherSignature); + btnMini.setAttribute('data-source-media-id', currentSourceId); + btnMini.setAttribute('data-launcher-signature', launcherSignature); + btnDeck.classList.toggle('is-in-source-slot', !!hostInfo.owned); + btnMini.classList.toggle('is-in-source-slot', !!hostInfo.owned); + btnDeck.setAttribute('aria-label', items.length > 1 ? `Open media deck with ${items.length} items` : 'Open media deck'); + btnDeck.title = items.length > 1 ? `Open media deck (${items.length} items)` : 'Open media deck'; + btnMini.setAttribute('aria-label', items.length > 1 ? `Open mini player with ${items.length} items` : 'Open mini player'); + btnMini.title = items.length > 1 ? `Mini player (${items.length} items)` : 'Mini player'; + if (btnDeck.getAttribute('data-rendered-signature') !== launcherSignature) { + btnDeck.innerHTML = `Media deck${countLabel}`; + btnDeck.setAttribute('data-rendered-signature', launcherSignature); + } + if (btnMini.getAttribute('data-rendered-signature') !== launcherSignature) { + btnMini.innerHTML = `Mini player${countLabel}`; + btnMini.setAttribute('data-rendered-signature', launcherSignature); + } + const srcActive = !!(state.current && state.current.sourceEl === sourceEl); + btnDeck.setAttribute('aria-pressed', srcActive ? 'true' : 'false'); + btnDeck.setAttribute('aria-expanded', srcActive && state.deckOpen ? 'true' : 'false'); + btnMini.setAttribute('aria-pressed', srcActive ? 'true' : 'false'); + } + + function syncSourceMediaDeckLaunchersInScope(scope) { + const seen = new Set(); + const addSource = (node) => { + if (!(node instanceof Element)) return; + const source = node.matches && node.matches('.post-card[data-post-id], .message-item[data-message-id]') + ? node + : node.closest ? node.closest('.post-card[data-post-id], .message-item[data-message-id]') : null; + if (!source || seen.has(source)) return; + seen.add(source); + syncSourceMediaDeckLauncher(source); + }; + + addSource(scope instanceof Element ? scope : null); + if (scope && scope.querySelectorAll) { + scope.querySelectorAll('.post-card[data-post-id], .message-item[data-message-id]').forEach(addSource); + } else { + document.querySelectorAll('.post-card[data-post-id], .message-item[data-message-id]').forEach(addSource); + } + updateSourceDeckLauncherActiveStates(); + } + + function setDeckVisualState(item) { + if (!deckVisual || !deckVisualIcon || !deckVisualTitle || !deckVisualSubtitle || !deckVisualCover) return; + const type = item ? item.type : ''; + const cover = item ? item.thumb : ''; + const iconClass = mediaIcon(type); + deckVisual.hidden = false; + deckVisualIcon.innerHTML = ``; + deckVisualTitle.textContent = item ? item.title : 'Now Playing'; + deckVisualSubtitle.textContent = item ? item.subtitle : 'Expanded playback with related media from the same post or message.'; + deckVisualCover.style.backgroundImage = cover + ? `url("${String(cover).replace(/"/g, '%22')}")` + : 'none'; + } + + function renderDeckQueue() { + if (!deckQueue || !deckQueueCount || !deckCount || !deckCountChip || !deckSource) return; + const current = state.current; + const sourceEl = current && current.sourceEl ? current.sourceEl : null; + state.deckItems = buildRelatedMediaList(sourceEl, current ? current.el : null); + const items = state.deckItems; + const selectedItem = getDeckSelectedItem(); + const total = items.length; + const label = total === 1 ? '1 item' : `${total} items`; + const activeKey = selectedItem ? selectedItem.key : ''; + const nextSignature = `${activeKey}::${items.map((item) => `${item.key}:${item.type}`).join('|')}`; + deckQueueCount.textContent = label; + deckCount.textContent = total === 1 ? '1 playable item' : `${total} playable items`; + deckCountChip.textContent = label; + deckSource.textContent = selectedItem ? sourceSubtitle(selectedItem.el) : 'Now playing from Canopy'; + + if (state.deckQueueSignature === nextSignature && deckQueue.childElementCount) { + return; + } + state.deckQueueSignature = nextSignature; + + deckQueue.innerHTML = ''; + if (!items.length) { + const empty = document.createElement('div'); + empty.className = 'sidebar-media-deck-empty'; + empty.textContent = 'Multiple videos or clips in the same post/message will appear here.'; + deckQueue.appendChild(empty); + return; + } + + items.forEach((item, index) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'sidebar-media-deck-item' + (selectedItem && selectedItem.key === item.key ? ' is-active' : ''); + btn.dataset.mediaKey = item.key; + btn.dataset.mediaIndex = String(index); + + const thumb = document.createElement('div'); + thumb.className = 'sidebar-media-deck-item-thumb'; + if (item.thumb) { + const img = document.createElement('img'); + img.src = item.thumb; + img.alt = ''; + img.loading = 'lazy'; + thumb.appendChild(img); + } else { + thumb.innerHTML = ``; + } + + const labelWrap = document.createElement('div'); + labelWrap.className = 'sidebar-media-deck-item-label'; + + const title = document.createElement('div'); + title.className = 'sidebar-media-deck-item-title'; + title.textContent = item.title; + + const meta = document.createElement('div'); + meta.className = 'sidebar-media-deck-item-meta'; + meta.textContent = mediaProviderLabel(item.type); + + labelWrap.appendChild(title); + labelWrap.appendChild(meta); + btn.appendChild(thumb); + btn.appendChild(labelWrap); + deckQueue.appendChild(btn); + }); + scrollDeckSelectionIntoView(); + } + + function updateDeckVisibility() { + if (!deck || !deckBackdrop) return; + const visible = state.deckOpen && !!state.current; + const mobileDeckMode = window.matchMedia('(max-width: 640px), (max-height: 540px) and (orientation: landscape)').matches; + deck.hidden = !visible; + deck.setAttribute('aria-hidden', visible ? 'false' : 'true'); + deck.classList.toggle('is-visible', visible); + deckBackdrop.hidden = !visible; + deckBackdrop.classList.toggle('is-visible', visible); + document.body.classList.toggle('canopy-media-deck-open', visible); + document.body.classList.toggle('canopy-media-deck-modal', visible && mobileDeckMode); + if (deckShell) { + deckShell.setAttribute('aria-modal', visible && mobileDeckMode ? 'true' : 'false'); + } + } + + function syncDeckStage() { + if (!deckStage) return; + const selectedItem = getDeckSelectedItem(); + if (!selectedItem || !state.deckOpen) { + clearDeckStageDockedNodes(); + deckStage.classList.add('is-empty'); + setDeckVisualState(null); + return; + } + + const type = selectedItem.type; + const el = selectedItem.el; + if (type === 'youtube' || type === 'video') { + const moved = moveDockedMediaToHost(el, deckStage); + if (moved) { + deckStage.classList.remove('is-empty'); + if (deckVisual) deckVisual.hidden = true; + return; + } + } + + clearDeckStageDockedNodes(); + deckStage.classList.add('is-empty'); + setDeckVisualState(selectedItem || { + el, + type, + title: titleFromMedia(el, type), + subtitle: subtitleFromMedia(el, type), + thumb: resolveMediaThumbnail(el, type), + }); + } + + function updateDeckControls(el, type) { + if (!deckPlayBtn || !deckSeek || !deckCurrentTime || !deckDuration || !deckPipBtn) return; + + let currentTime = 0; + let duration = 0; + let paused = false; + + if (type === 'audio' || type === 'video') { + paused = !!el.paused; + currentTime = Number(el.currentTime || 0); + duration = Number(el.duration || 0); + } else if (type === 'youtube') { + const ytIframe = resolveYouTubeMediaElement(el, { activate: false }); + const ytState = Number(ytIframe && ytIframe.__canopyMiniYTState); + paused = !isYouTubePlayingState(ytState); + currentTime = getYouTubeCurrentTimeSafe(ytIframe || el); + try { + const player = ytIframe && ytIframe.__canopyMiniYTPlayer; + if (player && typeof player.getDuration === 'function') { + duration = Number(player.getDuration() || 0); + } + } catch (_) {} + } else { + paused = true; + } + + deckPlayBtn.innerHTML = `${paused ? 'Play' : 'Pause'}`; + deckCurrentTime.textContent = formatTime(currentTime); + deckDuration.textContent = duration > 0 ? formatTime(duration) : '--:--'; + + if (!state.deckSeeking) { + const seekValue = duration > 0 ? Math.round((currentTime / duration) * 1000) : 0; + deckSeek.value = String(Math.max(0, Math.min(1000, seekValue))); + } + deckSeek.disabled = !(duration > 0.1); + + if (deckPipBtn) { + if (type === 'video' && supportsPictureInPicture(el)) { + const inPiP = isPictureInPictureActiveFor(el); + deckPipBtn.style.display = ''; + deckPipBtn.innerHTML = `${inPiP ? 'Exit PiP' : 'PiP'}`; + } else { + deckPipBtn.style.display = 'none'; + } + } + } + + function updateDeckPanel() { + updateDeckVisibility(); + if (!state.deckOpen || !state.current) return; + if (state.current.el && !state.current.el.isConnected) { + repairMediaCurrentReference(); + } else { + ensureMediaSourceLinked(); + } + if (!state.current || !state.current.el || !state.current.el.isConnected) { + closeMediaDeck({ forceClose: true }); + scheduleMiniUpdate(30); + return; + } + reconcileDeckStageMediaPlacement(); + renderDeckQueue(); + + const selectedItem = getDeckSelectedItem(); + if (!selectedItem) { + syncDeckStage(); + return; + } + const type = selectedItem.type; + const mediaTitle = titleFromMedia(selectedItem.el, type); + const subtitle = sourceSubtitle(selectedItem.el); + + if (deckChipLabel) deckChipLabel.textContent = 'Media Deck'; + if (deckTitle) deckTitle.textContent = mediaTitle; + if (deckSubtitle) { + deckSubtitle.textContent = state.deckItems.length > 1 + ? `${subtitle} • ${state.deckItems.length} playable items in this source` + : subtitle; + } + if (deckProvider) { + const iconNode = deckProvider.querySelector('i'); + if (iconNode) { + iconNode.className = `bi ${mediaIcon(type)}`; + } + } + if (deckProviderLabel) { + deckProviderLabel.textContent = mediaProviderLabel(type); + } + + syncDeckStage(); + updateDeckControls(selectedItem.el, type); + } + + function openMediaDeck() { + state.deckOpen = true; + if (state.current) updateDeckPanel(); + else updateDeckVisibility(); + updateSourceDeckLauncherActiveStates(); + if (expandBtn) { + expandBtn.innerHTML = ''; + expandBtn.title = 'Collapse media deck'; + } + } + + function closeMediaDeck(options = {}) { + const preserveMini = !(options && options.forceClose === true); + state.deckOpen = false; + state.deckSelectedKey = ''; + if (expandBtn) { + expandBtn.innerHTML = ''; + expandBtn.title = 'Open media deck'; + } + if (state.current && state.current.el) { + restoreDockedMedia(state.current.el, { + preferMini: preserveMini, + forceDockMini: preserveMini, + }); + } + if (deckStage) { + clearDeckStageDockedNodes(); + deckStage.classList.add('is-empty'); + } + updateDeckVisibility(); + updateSourceDeckLauncherActiveStates(); + scheduleMiniUpdate(40); + } + + function playDeckRelative(delta) { + if (!state.deckItems.length || !state.current) return; + const selectedItem = getDeckSelectedItem(); + const currentIndex = Math.max(0, state.deckItems.findIndex((item) => selectedItem && item.key === selectedItem.key)); + const nextIndex = (currentIndex + delta + state.deckItems.length) % state.deckItems.length; + const nextItem = state.deckItems[nextIndex]; + if (!nextItem) return; + selectDeckItem(nextItem, { play: false }); + scheduleMiniUpdate(20); + } + + function seekCurrentMediaTo(ratio) { + if (!state.current || !state.current.el) return; + const el = state.current.el; + const type = state.current.type; + const clampedRatio = Math.max(0, Math.min(1, Number(ratio) || 0)); + if (type === 'audio' || type === 'video') { + const duration = Number(el.duration || 0); + if (duration > 0) { + el.currentTime = duration * clampedRatio; + } + } else if (type === 'youtube') { + try { + const ytIframe = resolveYouTubeMediaElement(el, { activate: false }); + const player = ytIframe && ytIframe.__canopyMiniYTPlayer; + const duration = Number(player && typeof player.getDuration === 'function' ? player.getDuration() : 0); + if (player && typeof player.seekTo === 'function' && duration > 0) { + player.seekTo(duration * clampedRatio, true); + } + } catch (_) {} + } + scheduleMiniUpdate(40); + } + function isElementPlaying(el, type) { if (!el) return false; if (type === 'audio' || type === 'video') { @@ -5108,7 +6182,8 @@ } } if (type === 'youtube') { - return isYouTubePlayingState(Number(el.__canopyMiniYTState)); + const ytIframe = resolveYouTubeMediaElement(el, { activate: false }); + return isYouTubePlayingState(Number(ytIframe && ytIframe.__canopyMiniYTState)); } return false; } @@ -5140,10 +6215,12 @@ mini.classList.remove('is-visible'); if (pipBtn) pipBtn.style.display = 'none'; if (miniVideoHost) miniVideoHost.style.display = 'none'; + if (expandBtn) expandBtn.disabled = true; } function showMini() { mini.classList.add('is-visible'); + if (expandBtn) expandBtn.disabled = false; } function supportsPictureInPicture(videoEl) { @@ -5198,11 +6275,12 @@ return isYouTubePlayingState(getYouTubePlayerStateSafe(el)); } + /** @returns {boolean} true if iframe src was updated (navigation; YT.Player must be re-created). */ function setYouTubeDockResumeParams(el, resumeAt, shouldResume) { - if (!el) return; + if (!el) return false; try { const src = el.getAttribute('src') || ''; - if (!src) return; + if (!src) return false; const url = new URL(src, window.location.origin); if (resumeAt > 1) { url.searchParams.set('start', String(Math.max(0, Math.floor(resumeAt)))); @@ -5217,8 +6295,73 @@ const next = url.toString(); if (next !== src) { el.src = next; + return true; + } + } catch (_) {} + return false; + } + + /** After iframe src change or full reload, tear down stale YT API bridge so initYouTubePlayer can run again. */ + function resetYouTubePlayerBridge(iframe) { + if (!iframe) return; + try { + const p = iframe.__canopyMiniYTPlayer; + if (p && typeof p.destroy === 'function') { + p.destroy(); + } + } catch (_) {} + if (iframe.__canopyMiniYTDockRestoreTimer) { + try { + clearInterval(iframe.__canopyMiniYTDockRestoreTimer); + } catch (_) {} + delete iframe.__canopyMiniYTDockRestoreTimer; + } + delete iframe.__canopyMiniYTPlayer; + delete iframe.__canopyMiniYTReady; + delete iframe.__canopyMiniYTReadyInit; + delete iframe.__canopyMiniYTFailed; + iframe.__canopyMiniYTState = -1; + } + + /** True when the embed wrapper currently lives in sidebar mini or deck stage (DOM stays in-page; avoid forced iframe reload). */ + function isSidebarDeckOrMiniHost(node) { + return !!(node && (node === deckStage || node === miniVideoHost)); + } + + /** + * Snapshot time/play state; optionally sync embed URL before reparenting. + * Mini ↔ deck: reparenting usually keeps the iframe document — rewriting src + resetYouTubePlayerBridge + * can blank the player. Post ↔ sidebar: URL + re-init still helps when the embed reloads. + */ + function prepareYouTubeEmbedForHostMove(el, opts) { + const options = opts && typeof opts === 'object' ? opts : {}; + const iframe = resolveYouTubeMediaElement(el, { activate: false }); + if (!iframe || iframe.tagName.toLowerCase() !== 'iframe') return; + const t = getYouTubeCurrentTimeSafe(iframe); + let playing = isYouTubePlayingState(Number(iframe.__canopyMiniYTState)); + try { + const p = iframe.__canopyMiniYTPlayer; + if (p && typeof p.getPlayerState === 'function') { + playing = isYouTubePlayingState(Number(p.getPlayerState())); } } catch (_) {} + iframe.__canopyMiniYTDockResumeAt = t; + iframe.__canopyMiniYTDockShouldResume = playing; + if (t > 0.5) { + iframe.__canopyMiniYTLastTime = t; + } + if (options.skipResumeUrlRewrite === true) { + return; + } + const srcChanged = setYouTubeDockResumeParams( + iframe, + Number(iframe.__canopyMiniYTDockResumeAt || 0), + iframe.__canopyMiniYTDockShouldResume === true + ); + if (srcChanged) { + resetYouTubePlayerBridge(iframe); + initYouTubePlayer(iframe); + } } function maybeRestoreYouTubeDockState(el) { @@ -5279,16 +6422,25 @@ pipBtn.title = inPiP ? 'Exit Picture-in-Picture' : 'Open Picture-in-Picture'; } - function setCurrent(el, forcedType) { + function setCurrent(el, forcedType, opts) { if (!el) return; + const options = opts && typeof opts === 'object' ? opts : {}; const type = forcedType || mediaTypeFor(el); if (!type) return; + if (type === 'youtube') { + const defer = options.deferYouTubeMaterialize === true; + el = resolveYouTubeMediaElement(el, { activate: !defer, autoplay: false }) || el; + } if (state.current && state.current.el === el && state.current.type === type) { - updateMini(); + scheduleMiniUpdate(); return; } + if (state.current && state.current.el && state.current.el !== el) { + deactivateMediaEntry(state.current); + } + state.current = { el: el, type: type, @@ -5296,10 +6448,11 @@ activatedAt: Date.now() }; state.dismissedEl = null; - if (type === 'youtube' && miniVideoHost && !isDockedInMiniHost(el) && isOffscreen(el)) { + if (type === 'youtube' && miniVideoHost && !state.deckOpen && !isDockedInMiniHost(el) && isOffscreen(el)) { autoDockYouTube(el); } - updateMini(); + updateSourceDeckLauncherActiveStates(); + scheduleMiniUpdate(); } function findPlayingElement() { @@ -5329,15 +6482,7 @@ function undockYouTube(el) { if (!el) return; - const ph = el.__canopyAutoDockPlaceholder; - if (ph && ph.isConnected && ph.parentNode) { - const wrapper = el.closest('.youtube-embed'); - if (wrapper) { - ph.parentNode.insertBefore(wrapper, ph); - ph.remove(); - } - } - delete el.__canopyAutoDockPlaceholder; + restoreDockedMedia(el, { preferMini: false }); if (!state.returnUrl) state.dockedSubtitle = null; if (miniVideoHost) { miniVideoHost.style.display = 'none'; @@ -5347,8 +6492,6 @@ function autoDockYouTube(el) { if (!miniVideoHost) return; - const wrapper = el.closest('.youtube-embed'); - if (!wrapper || !wrapper.parentNode) return; if (isDockedInMiniHost(el)) return; el.__canopyMiniYTDockResumeAt = getYouTubeCurrentTimeSafe(el); el.__canopyMiniYTDockShouldResume = isYouTubePlayingState(Number(el.__canopyMiniYTState)); @@ -5357,34 +6500,39 @@ Number(el.__canopyMiniYTDockResumeAt || 0), el.__canopyMiniYTDockShouldResume === true ); - - var placeholder = document.createElement('div'); - placeholder.className = 'canopy-yt-mini-placeholder'; - placeholder.style.cssText = 'width:' + wrapper.offsetWidth + 'px;height:' + wrapper.offsetHeight + 'px;'; - wrapper.parentNode.insertBefore(placeholder, wrapper); - el.__canopyAutoDockPlaceholder = placeholder; - if (!state.dockedSubtitle) state.dockedSubtitle = sourceSubtitle(el); - - miniVideoHost.innerHTML = ''; - miniVideoHost.appendChild(wrapper); - miniVideoHost.style.display = 'block'; - - if (state.observer) state.observer.observe(placeholder); - + moveDockedMediaToHost(el, miniVideoHost); maybeRestoreYouTubeDockState(el); } function updateMini() { - if (!state.current || !state.current.el || !state.current.el.isConnected) { + if (!state.current) { const fallback = findPlayingElement(); if (fallback) { setCurrent(fallback); return; } + if (state.deckOpen) closeMediaDeck({ forceClose: true }); hideMini(); return; } + if (!state.current.el || !state.current.el.isConnected) { + if (!repairMediaCurrentReference()) { + const staleCurrent = state.current; + state.current = null; + deactivateMediaEntry(staleCurrent); + const fallback = findPlayingElement(); + if (fallback) { + setCurrent(fallback); + return; + } + if (state.deckOpen) closeMediaDeck({ forceClose: true }); + hideMini(); + return; + } + } else { + ensureMediaSourceLinked(); + } const current = state.current; const type = current.type; @@ -5393,28 +6541,47 @@ const isResumablePause = (type === 'audio' || type === 'video') && !!el.paused && !el.ended; if (state.dismissedEl && state.dismissedEl === el) { + if (state.deckOpen) closeMediaDeck({ forceClose: true }); + hideMini(); + return; + } + + if (state.deckOpen) { + updateDeckPanel(); hideMini(); return; } if (isDocked && type === 'youtube') { - const ytState = Number(el.__canopyMiniYTState); - if (ytState === 0) { + const ytIframe = resolveYouTubeMediaElement(el, { activate: false }); + const ytState = Number(ytIframe && ytIframe.__canopyMiniYTState); + const ytWrap = getMediaDockWrapper(el, 'youtube'); + const hasIframe = !!(ytWrap && ytWrap.querySelector('iframe')); + if (hasIframe && ytState === 0) { + if (state.deckOpen) closeMediaDeck({ forceClose: true }); hideMini(); return; } } else if (!isElementPlaying(el, type) && !isResumablePause) { - const fallback = findPlayingElement(); - if (fallback && fallback !== el) { - setCurrent(fallback); + if (isDocked && type === 'youtube' && isYouTubeFacadeOnly(el)) { + // Static preview in mini — keep chrome visible until user taps Play. + } else { + const fallback = findPlayingElement(); + if (fallback && fallback !== el) { + setCurrent(fallback); + return; + } + if (state.deckOpen) closeMediaDeck({ forceClose: true }); + hideMini(); return; } - hideMini(); - return; } if (!isDocked && !isOffscreen(el)) { if (state.dismissedEl === el) state.dismissedEl = null; + if (state.deckOpen) { + updateDeckPanel(); + } hideMini(); return; } @@ -5449,30 +6616,38 @@ updatePiPButton(el, type); } else if (type === 'youtube') { playBtn.style.display = ''; - const ytPlayer = el.__canopyMiniYTPlayer; - const ytState = Number(el.__canopyMiniYTState); - const isPaused = ytState === 2; - playBtn.innerHTML = `${isPaused ? 'Play' : 'Pause'}`; - progressWrap.classList.remove('show'); - progressBar.style.width = '0%'; - if (ytPlayer && typeof ytPlayer.getCurrentTime === 'function') { - try { - const cur = ytPlayer.getCurrentTime(); - const dur = ytPlayer.getDuration(); - if (dur > 0) { - const pct = Math.max(0, Math.min(100, (cur / dur) * 100)); - progressWrap.classList.add('show'); - progressBar.style.width = `${pct}%`; - timeEl.textContent = `${formatTime(cur)} / ${formatTime(dur)}`; - el.__canopyMiniYTLastTime = cur; - } else { + if (isYouTubeFacadeOnly(el)) { + playBtn.innerHTML = 'Play'; + progressWrap.classList.remove('show'); + progressBar.style.width = '0%'; + timeEl.textContent = 'YouTube'; + } else { + const ytIframe = resolveYouTubeMediaElement(el, { activate: false }); + const ytPlayer = ytIframe && ytIframe.__canopyMiniYTPlayer; + const ytState = Number(ytIframe && ytIframe.__canopyMiniYTState); + const isPaused = ytState === 2; + playBtn.innerHTML = `${isPaused ? 'Play' : 'Pause'}`; + progressWrap.classList.remove('show'); + progressBar.style.width = '0%'; + if (ytPlayer && typeof ytPlayer.getCurrentTime === 'function') { + try { + const cur = ytPlayer.getCurrentTime(); + const dur = ytPlayer.getDuration(); + if (dur > 0) { + const pct = Math.max(0, Math.min(100, (cur / dur) * 100)); + progressWrap.classList.add('show'); + progressBar.style.width = `${pct}%`; + timeEl.textContent = `${formatTime(cur)} / ${formatTime(dur)}`; + if (ytIframe) ytIframe.__canopyMiniYTLastTime = cur; + } else { + timeEl.textContent = 'YouTube'; + } + } catch (_) { timeEl.textContent = 'YouTube'; } - } catch (_) { + } else { timeEl.textContent = 'YouTube'; } - } else { - timeEl.textContent = 'YouTube'; } updatePiPButton(null, ''); } else { @@ -5483,7 +6658,16 @@ updatePiPButton(null, ''); } + if (expandBtn) { + expandBtn.disabled = false; + expandBtn.innerHTML = state.deckOpen + ? '' + : ''; + expandBtn.title = state.deckOpen ? 'Collapse media deck' : 'Open media deck'; + } + showMini(); + updateDeckPanel(); } function registerMediaNode(el) { @@ -5504,13 +6688,13 @@ state.dismissedEl = null; setCurrent(el, type); }); - el.addEventListener('pause', () => setTimeout(updateMini, 60)); - el.addEventListener('ended', () => setTimeout(updateMini, 60)); - el.addEventListener('timeupdate', updateMini); - el.addEventListener('seeking', updateMini); + el.addEventListener('pause', () => scheduleMiniUpdate(60)); + el.addEventListener('ended', () => scheduleMiniUpdate(60)); + el.addEventListener('timeupdate', scheduleMiniUpdate); + el.addEventListener('seeking', scheduleMiniUpdate); if (type === 'video') { - el.addEventListener('enterpictureinpicture', () => setTimeout(updateMini, 20)); - el.addEventListener('leavepictureinpicture', () => setTimeout(updateMini, 20)); + el.addEventListener('enterpictureinpicture', () => scheduleMiniUpdate(20)); + el.addEventListener('leavepictureinpicture', () => scheduleMiniUpdate(20)); } } else if (type === 'youtube') { initYouTubePlayer(el); @@ -5527,14 +6711,19 @@ function scanForMedia(scope) { const root = scope || document; root.querySelectorAll('audio, video, .youtube-embed iframe').forEach(registerMediaNode); + syncSourceMediaDeckLaunchersInScope(root); } function jumpToCurrentSource() { if (!state.current || !state.current.el) return; const el = state.current.el; - if (el.__canopyAutoDockPlaceholder) { - undockYouTube(el); + if (state.deckOpen) { + closeMediaDeck({ forceClose: true }); + } + + if (el.__canopyAutoDockPlaceholder || el.__canopyMiniVideoPlaceholder) { + restoreDockedMedia(el, { preferMini: false }); state.dismissedEl = null; state.dockedSubtitle = null; const container = sourceContainer(el); @@ -5559,6 +6748,7 @@ target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); applyFocusFlash(target); } + hideMini(); } if (playBtn) { @@ -5572,20 +6762,21 @@ } catch (_) {} } else if (type === 'youtube') { try { - const player = el.__canopyMiniYTPlayer; - if (player) { + const ytEl = resolveYouTubeMediaElement(el, { activate: false }); + const player = ytEl && ytEl.__canopyMiniYTPlayer; + if (player && typeof player.getPlayerState === 'function') { const s = player.getPlayerState(); if (s === 1 || s === 3) { - player.pauseVideo(); - el.__canopyMiniYTState = 2; + pauseMediaElement(el, type); } else { - player.playVideo(); - el.__canopyMiniYTState = 1; + playMediaElement(el, type); } + } else { + playMediaElement(el, type); } } catch (_) {} } - setTimeout(updateMini, 50); + scheduleMiniUpdate(50); }); } @@ -5625,7 +6816,7 @@ showAlert('Picture-in-Picture could not be started.', 'info'); } } - setTimeout(updateMini, 50); + scheduleMiniUpdate(50); }); } @@ -5650,10 +6841,15 @@ wrapper.remove(); } } + pauseMediaElement(el, type); + if (type === 'video' || type === 'youtube') { + clearOrphanedDockedMedia(el, type, state.current.sourceEl || sourceContainer(el)); + } state.dismissedEl = el; } state.returnUrl = null; state.dockedSubtitle = null; + closeMediaDeck({ forceClose: true }); if (miniVideoHost) { miniVideoHost.style.display = 'none'; miniVideoHost.innerHTML = ''; @@ -5668,6 +6864,148 @@ }); } + if (expandBtn) { + expandBtn.addEventListener('click', () => { + if (!state.current) return; + if (state.deckOpen) closeMediaDeck(); + else openMediaDeck(); + }); + } + + if (deckMinimizeBtn) { + deckMinimizeBtn.addEventListener('click', () => closeMediaDeck()); + } + + if (deckMiniPlayerBtn) { + deckMiniPlayerBtn.addEventListener('click', () => switchDeckToMiniPlayer()); + } + + if (deckMinimizeFooterBtn) { + deckMinimizeFooterBtn.addEventListener('click', () => closeMediaDeck()); + } + + if (deckMiniFooterBtn) { + deckMiniFooterBtn.addEventListener('click', () => switchDeckToMiniPlayer()); + } + + if (deckCloseBtn) { + deckCloseBtn.addEventListener('click', () => closeMediaDeck({ forceClose: true })); + } + + if (deckBackdrop) { + deckBackdrop.addEventListener('click', () => closeMediaDeck()); + } + + if (deckPrevBtn) { + deckPrevBtn.addEventListener('click', () => playDeckRelative(-1)); + } + + if (deckNextBtn) { + deckNextBtn.addEventListener('click', () => playDeckRelative(1)); + } + + if (deckPlayBtn) { + deckPlayBtn.addEventListener('click', () => { + if (!state.current || !state.current.el) return; + const el = state.current.el; + const type = state.current.type; + if (type === 'audio' || type === 'video') { + try { + if (el.paused) playMediaElement(el, type); else pauseMediaElement(el, type); + } catch (_) {} + } else if (type === 'youtube') { + try { + const player = el.__canopyMiniYTPlayer; + if (player) { + const s = player.getPlayerState(); + if (s === 1 || s === 3) pauseMediaElement(el, type); + else playMediaElement(el, type); + } else { + playMediaElement(el, type); + } + } catch (_) {} + } + scheduleMiniUpdate(50); + }); + } + + if (deckReturnBtn) { + deckReturnBtn.addEventListener('click', () => jumpToCurrentSource()); + } + + if (deckPipBtn) { + deckPipBtn.addEventListener('click', async () => { + if (!state.current || !state.current.el || state.current.type !== 'video') { + jumpToCurrentSource(); + return; + } + const el = state.current.el; + if (!supportsPictureInPicture(el)) { + if (typeof showAlert === 'function') { + showAlert('Picture-in-Picture is not available for this video here.', 'info'); + } + return; + } + try { + if (isPictureInPictureActiveFor(el)) { + if (typeof document.exitPictureInPicture === 'function') { + await document.exitPictureInPicture(); + } + } else { + if (document.pictureInPictureElement && typeof document.exitPictureInPicture === 'function') { + try { await document.exitPictureInPicture(); } catch (_) {} + } + await el.requestPictureInPicture(); + } + } catch (_) { + if (typeof showAlert === 'function') { + showAlert('Picture-in-Picture could not be started.', 'info'); + } + } + scheduleMiniUpdate(50); + }); + } + + if (deckSeek) { + deckSeek.addEventListener('input', () => { + state.deckSeeking = true; + if (!state.current || !state.current.el) return; + const ratio = Math.max(0, Math.min(1, Number(deckSeek.value || 0) / 1000)); + let duration = 0; + if (state.current.type === 'audio' || state.current.type === 'video') { + duration = Number(state.current.el.duration || 0); + } else if (state.current.type === 'youtube') { + try { + const player = state.current.el.__canopyMiniYTPlayer; + duration = Number(player && typeof player.getDuration === 'function' ? player.getDuration() : 0); + } catch (_) {} + } + const previewTime = duration > 0 ? duration * ratio : 0; + if (deckCurrentTime) deckCurrentTime.textContent = formatTime(previewTime); + }); + deckSeek.addEventListener('change', () => { + const ratio = Math.max(0, Math.min(1, Number(deckSeek.value || 0) / 1000)); + seekCurrentMediaTo(ratio); + state.deckSeeking = false; + }); + deckSeek.addEventListener('pointerup', () => { + state.deckSeeking = false; + }); + } + + if (deckQueue) { + deckQueue.addEventListener('click', (event) => { + const btn = event.target && event.target.closest ? event.target.closest('.sidebar-media-deck-item[data-media-index]') : null; + if (!btn) return; + const index = Number(btn.getAttribute('data-media-index') || -1); + if (!Number.isFinite(index) || index < 0 || index >= state.deckItems.length) return; + const nextItem = state.deckItems[index]; + if (!nextItem) return; + selectDeckItem(nextItem, { play: false }); + scheduleMiniUpdate(20); + }); + } + const observerRoot = mainScroller || null; state.observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { @@ -5679,13 +7017,14 @@ state.current.el === entry.target && !visible && miniVideoHost && + !state.deckOpen && !isDockedInMiniHost(entry.target) && isYouTubePlayingState(Number(entry.target.__canopyMiniYTState)) ) { autoDockYouTube(entry.target); } }); - updateMini(); + scheduleMiniUpdate(); }, { root: observerRoot, threshold: [0, 0.2, 0.4, 0.7, 1] @@ -5695,9 +7034,19 @@ const mutationRoot = mainScroller || document.body; state.mutationObserver = new MutationObserver((mutations) => { + const dirtySources = new Set(); + function addDirtySource(node) { + if (!(node instanceof Element)) return; + const source = node.matches && node.matches('.post-card[data-post-id], .message-item[data-message-id]') + ? node + : node.closest ? node.closest('.post-card[data-post-id], .message-item[data-message-id]') : null; + if (source) dirtySources.add(source); + } mutations.forEach((mutation) => { + addDirtySource(mutation.target); mutation.addedNodes.forEach((node) => { if (!(node instanceof Element)) return; + addDirtySource(node); if (node.matches && (node.matches('audio') || node.matches('video') || node.matches('.youtube-embed iframe'))) { registerMediaNode(node); } @@ -5705,15 +7054,30 @@ scanForMedia(node); } }); + mutation.removedNodes.forEach((node) => { + if (!(node instanceof Element)) return; + addDirtySource(mutation.target); + }); }); - updateMini(); + dirtySources.forEach((source) => { + if (state.deckOpen && state.current && state.current.sourceEl === source) return; + syncSourceMediaDeckLauncher(source); + }); + updateSourceDeckLauncherActiveStates(); + scheduleMiniUpdate(); }); state.mutationObserver.observe(mutationRoot, { childList: true, subtree: true }); - window.addEventListener('resize', updateMini); - document.addEventListener('visibilitychange', updateMini); - state.tickHandle = setInterval(updateMini, 700); - updateMini(); + window.addEventListener('resize', scheduleMiniUpdate); + document.addEventListener('visibilitychange', scheduleMiniUpdate); + document.addEventListener('keydown', (event) => { + if (!state.deckOpen) return; + if (event.key === 'Escape') { + closeMediaDeck(); + } + }); + state.tickHandle = setInterval(scheduleMiniUpdate, 700); + scheduleMiniUpdate(); window.canopyPersistActiveMedia = function() { if (!state.current || !state.current.el) return; diff --git a/canopy/ui/templates/base.html b/canopy/ui/templates/base.html index 076766d..d631c83 100644 --- a/canopy/ui/templates/base.html +++ b/canopy/ui/templates/base.html @@ -402,7 +402,8 @@ } .sidebar-panel-icon-btn, - .sidebar-media-mini-pin { + .sidebar-media-mini-pin, + .sidebar-media-mini-expand { border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.06); color: var(--canopy-text-secondary); @@ -417,14 +418,16 @@ } .sidebar-panel-icon-btn:hover, - .sidebar-media-mini-pin:hover { + .sidebar-media-mini-pin:hover, + .sidebar-media-mini-expand:hover { background: rgba(34, 197, 94, 0.12); border-color: rgba(74, 222, 128, 0.35); color: #dcfce7; } .sidebar-panel-icon-btn:focus-visible, - .sidebar-media-mini-pin:focus-visible { + .sidebar-media-mini-pin:focus-visible, + .sidebar-media-mini-expand:focus-visible { outline: none; background: rgba(34, 197, 94, 0.12); border-color: rgba(74, 222, 128, 0.42); @@ -848,6 +851,877 @@ text-align: right; } + .sidebar-media-deck { + position: fixed; + right: 24px; + bottom: 22px; + width: min(456px, calc(100vw - 36px)); + max-height: min(82vh, 860px); + z-index: 1600; + display: none; + pointer-events: none; + } + + .sidebar-media-deck.is-visible { + display: block; + pointer-events: auto; + } + + .sidebar-media-deck-shell { + border-radius: 26px; + overflow: hidden; + background: + radial-gradient(circle at top left, rgba(34, 197, 94, 0.12), transparent 36%), + radial-gradient(circle at top right, rgba(59, 130, 246, 0.12), transparent 32%), + linear-gradient(180deg, rgba(12, 20, 38, 0.97), rgba(9, 15, 30, 0.96)); + border: 1px solid rgba(118, 189, 255, 0.18); + box-shadow: + 0 28px 64px rgba(2, 8, 23, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.07); + backdrop-filter: blur(18px) saturate(145%); + display: grid; + grid-template-rows: auto auto auto auto; + } + + .sidebar-media-deck-header { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; + padding: 16px 18px 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent); + } + + .sidebar-media-deck-context { + min-width: 0; + flex: 1; + display: grid; + gap: 5px; + } + + .sidebar-media-deck-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .sidebar-media-deck-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.2rem 0.65rem; + border-radius: 999px; + background: rgba(16, 185, 129, 0.12); + border: 1px solid rgba(74, 222, 128, 0.24); + color: #d1fae5; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .sidebar-media-deck-chip.muted { + background: rgba(148, 163, 184, 0.1); + border-color: rgba(148, 163, 184, 0.16); + color: var(--canopy-text-secondary); + } + + .sidebar-media-deck-source { + font-size: 0.76rem; + color: var(--canopy-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .sidebar-media-deck-actions { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + flex-shrink: 0; + justify-content: flex-end; + max-width: 100%; + } + + .sidebar-media-deck-action-btn { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: var(--canopy-text-secondary); + width: 34px; + height: 34px; + border-radius: 11px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + .sidebar-media-deck-action-label { + display: none; + line-height: 1; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + } + + .sidebar-media-deck-action-btn:hover { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(74, 222, 128, 0.35); + color: #dcfce7; + } + + .sidebar-media-deck-action-btn:focus-visible { + outline: none; + background: rgba(34, 197, 94, 0.12); + border-color: rgba(74, 222, 128, 0.42); + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.22); + color: #dcfce7; + } + + .sidebar-media-deck-stage-shell { + padding: 0 18px 14px; + } + + .sidebar-media-deck-stage { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 22px; + overflow: hidden; + background: linear-gradient(180deg, rgba(2, 6, 23, 0.95), rgba(15, 23, 42, 0.98)); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02); + } + + .sidebar-media-deck-stage.is-empty { + display: flex; + align-items: center; + justify-content: center; + } + + .sidebar-media-deck-stage iframe, + .sidebar-media-deck-stage video { + width: 100% !important; + height: 100% !important; + border: 0; + display: block; + object-fit: cover; + background: #000; + } + + .sidebar-media-deck-stage .youtube-embed, + .sidebar-media-deck-stage .embed-preview { + margin: 0; + width: 100%; + height: 100%; + } + + .sidebar-media-deck-visual { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 20px; + background: + linear-gradient(180deg, rgba(2, 6, 23, 0.08) 0%, rgba(2, 6, 23, 0.78) 100%), + radial-gradient(circle at top right, rgba(34, 197, 94, 0.24), transparent 32%); + } + + .sidebar-media-deck-visual[hidden] { + display: none !important; + } + + .sidebar-media-deck-visual-cover { + position: absolute; + inset: 0; + background-size: cover; + background-position: center; + opacity: 0.72; + filter: saturate(1.05) contrast(1.04); + } + + .sidebar-media-deck-visual-overlay { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(2, 6, 23, 0.18), rgba(2, 6, 23, 0.86)), + linear-gradient(120deg, rgba(34, 197, 94, 0.18), transparent 34%); + } + + .sidebar-media-deck-visual-body { + position: relative; + z-index: 1; + display: grid; + gap: 10px; + } + + .sidebar-media-deck-visual-icon { + width: 54px; + height: 54px; + border-radius: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.12); + color: #eff6ff; + font-size: 1.4rem; + box-shadow: 0 14px 28px rgba(2, 8, 23, 0.28); + } + + .sidebar-media-deck-visual-text { + font-size: 1.55rem; + line-height: 1; + font-weight: 800; + color: #f8fafc; + letter-spacing: 0.01em; + text-shadow: 0 10px 24px rgba(2, 8, 23, 0.45); + } + + .sidebar-media-deck-visual-subtext { + font-size: 0.86rem; + color: rgba(226, 232, 240, 0.85); + max-width: 82%; + } + + .sidebar-media-deck-body { + padding: 0 18px 18px; + display: grid; + gap: 12px; + } + + .sidebar-media-deck-meta { + display: grid; + gap: 6px; + } + + .sidebar-media-deck-meta-top { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + .sidebar-media-deck-provider { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.26rem 0.65rem; + border-radius: 999px; + background: rgba(59, 130, 246, 0.12); + border: 1px solid rgba(96, 165, 250, 0.22); + color: #dbeafe; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .sidebar-media-deck-count { + font-size: 0.72rem; + color: var(--canopy-text-muted); + } + + .sidebar-media-deck-title { + font-size: 1.08rem; + font-weight: 800; + line-height: 1.28; + color: var(--canopy-text-primary); + } + + .sidebar-media-deck-subtitle { + font-size: 0.82rem; + color: var(--canopy-text-secondary); + } + + .sidebar-media-deck-progress-row { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 10px; + align-items: center; + } + + .sidebar-media-deck-time { + min-width: 54px; + font-size: 0.75rem; + color: var(--canopy-text-secondary); + font-variant-numeric: tabular-nums; + } + + .sidebar-media-deck-time.end { + text-align: right; + } + + .sidebar-media-deck-seek { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + border-radius: 999px; + background: linear-gradient(90deg, rgba(34, 197, 94, 0.9), rgba(59, 130, 246, 0.9)); + outline: none; + } + + .sidebar-media-deck-seek::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: #f8fafc; + border: 2px solid rgba(34, 197, 94, 0.85); + box-shadow: 0 4px 12px rgba(2, 8, 23, 0.34); + cursor: pointer; + } + + .sidebar-media-deck-seek::-moz-range-thumb { + width: 16px; + height: 16px; + border: none; + border-radius: 50%; + background: #f8fafc; + box-shadow: 0 4px 12px rgba(2, 8, 23, 0.34); + cursor: pointer; + } + + .sidebar-media-deck-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + row-gap: 8px; + } + + .sidebar-media-deck-btn-aux { + border-color: rgba(148, 163, 184, 0.35); + color: var(--canopy-text-secondary); + font-size: 0.78rem; + } + + .sidebar-media-deck-btn-aux:hover { + border-color: rgba(129, 140, 248, 0.45); + color: #e0e7ff; + } + + .sidebar-media-deck-btn { + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--canopy-text-primary); + background: rgba(255, 255, 255, 0.06); + border-radius: 12px; + font-size: 0.76rem; + line-height: 1; + padding: 9px 12px; + display: inline-flex; + align-items: center; + gap: 7px; + transition: all 0.2s ease; + } + + .sidebar-media-deck-btn:hover { + border-color: rgba(74, 222, 128, 0.55); + background: rgba(34, 197, 94, 0.14); + color: #dcfce7; + } + + .sidebar-media-deck-btn:focus-visible { + outline: none; + border-color: rgba(74, 222, 128, 0.55); + background: rgba(34, 197, 94, 0.14); + color: #dcfce7; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.18); + } + + .sidebar-media-deck-btn.primary { + background: linear-gradient(135deg, rgba(34, 197, 94, 0.22), rgba(59, 130, 246, 0.2)); + border-color: rgba(74, 222, 128, 0.35); + } + + .sidebar-media-deck-spacer { + flex: 1; + } + + .sidebar-media-deck-queue-shell { + padding: 0 18px 14px; + display: grid; + gap: 10px; + min-width: 0; + } + + .sidebar-media-deck-queue-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .sidebar-media-deck-queue-title { + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--canopy-text-muted); + } + + .sidebar-media-deck-queue-count { + font-size: 0.73rem; + color: var(--canopy-text-secondary); + } + + .sidebar-media-deck-queue { + display: flex; + gap: 10px; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; + scrollbar-gutter: stable; + padding: 2px 2px 6px; + } + + .sidebar-media-deck-item { + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.05); + border-radius: 18px; + padding: 10px; + min-width: 138px; + width: 138px; + flex: 0 0 138px; + display: grid; + gap: 8px; + text-align: left; + scroll-snap-align: start; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease; + } + + .sidebar-media-deck-item:hover { + transform: translateY(-1px); + border-color: rgba(96, 165, 250, 0.3); + background: rgba(59, 130, 246, 0.08); + } + + .sidebar-media-deck-item:focus-visible { + outline: none; + border-color: rgba(74, 222, 128, 0.55); + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.16); + } + + .sidebar-media-deck-item.is-active { + border-color: rgba(74, 222, 128, 0.48); + background: rgba(34, 197, 94, 0.1); + box-shadow: inset 0 0 0 1px rgba(74, 222, 128, 0.18); + } + + .sidebar-media-deck-item-thumb { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 14px; + overflow: hidden; + background: + linear-gradient(145deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.94)), + radial-gradient(circle at top left, rgba(34, 197, 94, 0.18), transparent 40%); + display: flex; + align-items: center; + justify-content: center; + color: #e2e8f0; + font-size: 1.4rem; + } + + .sidebar-media-deck-item-thumb::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, transparent 40%, rgba(2, 6, 23, 0.34)); + pointer-events: none; + } + + .sidebar-media-deck-item-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .sidebar-media-deck-item-label { + min-width: 0; + display: grid; + gap: 4px; + } + + .sidebar-media-deck-item-title { + font-size: 0.76rem; + font-weight: 700; + color: var(--canopy-text-primary); + line-height: 1.25; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .sidebar-media-deck-item-meta { + font-size: 0.67rem; + color: var(--canopy-text-muted); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .sidebar-media-deck-empty { + padding: 14px 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px dashed rgba(255, 255, 255, 0.12); + color: var(--canopy-text-secondary); + font-size: 0.82rem; + } + + .sidebar-media-deck-backdrop { + position: fixed; + inset: 0; + background: linear-gradient(180deg, rgba(2, 6, 23, 0.18), rgba(2, 6, 23, 0.34)); + z-index: 1590; + display: none; + } + + .sidebar-media-deck-backdrop.is-visible { + display: block; + } + + .canopy-media-deck-source-slot { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 0.65rem; + position: relative; + z-index: 2; + } + + [data-post-actions], + .message-actions { + position: relative; + z-index: 2; + } + + .canopy-media-mini-launcher { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 11px; + border-radius: 10px; + touch-action: manipulation; + -webkit-tap-highlight-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(130, 170, 255, 0.35); + background: rgba(9, 17, 32, 0.58); + color: #e8eeff; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; + } + + .canopy-media-mini-launcher:hover { + transform: translateY(-1px); + background: rgba(13, 28, 48, 0.88); + border-color: rgba(160, 190, 255, 0.55); + box-shadow: 0 16px 30px rgba(0, 0, 0, 0.28); + } + + .canopy-media-mini-launcher:focus-visible { + outline: 2px solid rgba(160, 190, 255, 0.75); + outline-offset: 2px; + } + + .canopy-media-mini-launcher.is-active { + background: linear-gradient(135deg, rgba(88, 120, 220, 0.88), rgba(14, 32, 56, 0.94)); + border-color: rgba(190, 210, 255, 0.55); + color: #ffffff; + } + + .canopy-media-mini-launcher i { + font-size: 0.9rem; + } + + .canopy-media-mini-launcher.is-in-source-slot { + margin-left: 0.5rem; + } + + .canopy-media-deck-launcher { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 11px; + border-radius: 10px; + touch-action: manipulation; + -webkit-tap-highlight-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(89, 222, 137, 0.28); + background: rgba(9, 17, 32, 0.66); + color: #eefbf4; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.16); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; + } + + .canopy-media-deck-launcher:hover { + transform: translateY(-1px); + background: rgba(13, 28, 48, 0.92); + border-color: rgba(89, 222, 137, 0.48); + box-shadow: 0 16px 30px rgba(0, 0, 0, 0.28); + } + + .canopy-media-deck-launcher:focus-visible { + outline: 2px solid rgba(89, 222, 137, 0.7); + outline-offset: 2px; + } + + .canopy-media-deck-launcher.is-active { + background: linear-gradient(135deg, rgba(26, 184, 108, 0.92), rgba(14, 32, 56, 0.94)); + border-color: rgba(161, 247, 198, 0.58); + color: #ffffff; + } + + .canopy-media-deck-launcher i { + font-size: 0.9rem; + } + + .canopy-media-deck-launcher-label { + line-height: 1; + } + + .canopy-media-deck-launcher-count { + min-width: 1.35rem; + height: 1.35rem; + padding: 0 0.38rem; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.14); + color: inherit; + font-size: 0.68rem; + font-weight: 800; + } + + .canopy-media-deck-launcher.is-in-source-slot { + margin-left: auto; + } + + body.canopy-media-deck-open .sidebar-media-mini { + opacity: 0.36; + } + + body.canopy-media-deck-modal { + overflow: hidden; + overscroll-behavior: contain; + } + + @media (max-width: 900px) { + .sidebar-media-deck { + right: 16px; + left: 16px; + width: auto; + bottom: 14px; + max-height: min(86vh, 860px); + } + + .sidebar-media-deck-item { + min-width: 130px; + width: 130px; + flex-basis: 130px; + } + + .canopy-media-deck-launcher, + .canopy-media-mini-launcher { + padding: 7px 10px; + gap: 6px; + } + } + + @media (max-width: 640px), (max-height: 540px) and (orientation: landscape) { + body.canopy-media-deck-open .sidebar-media-mini { + display: none !important; + } + + .sidebar-media-deck { + left: 0; + right: 0; + top: 0; + bottom: 0; + width: 100%; + max-height: 100dvh; + } + + .sidebar-media-deck-shell { + position: relative; + border-radius: 0; + height: 100dvh; + max-height: 100dvh; + overflow-y: auto; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + } + + .sidebar-media-deck-shell::before { + content: ""; + position: absolute; + top: calc(env(safe-area-inset-top) + 8px); + left: 50%; + transform: translateX(-50%); + width: 42px; + height: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.2); + pointer-events: none; + z-index: 6; + } + + .sidebar-media-deck-stage-shell, + .sidebar-media-deck-body, + .sidebar-media-deck-header, + .sidebar-media-deck-queue-shell { + padding-left: 14px; + padding-right: 14px; + } + + .sidebar-media-deck-header { + position: sticky; + top: 0; + z-index: 5; + padding-top: calc(env(safe-area-inset-top) + 20px); + padding-bottom: 10px; + background: + linear-gradient(180deg, rgba(10, 16, 29, 0.98), rgba(10, 16, 29, 0.92)); + backdrop-filter: blur(20px) saturate(130%); + } + + .sidebar-media-deck-source { + white-space: normal; + overflow: visible; + text-overflow: unset; + } + + .sidebar-media-deck-action-btn { + width: auto; + min-width: 42px; + min-height: 40px; + padding: 0 12px; + border-radius: 12px; + gap: 6px; + } + + .sidebar-media-deck-action-label { + display: inline; + } + + .sidebar-media-deck-stage { + max-height: min(34dvh, 280px); + border-radius: 18px; + } + + .sidebar-media-deck-stage-shell { + padding-top: 10px; + padding-bottom: 8px; + } + + .sidebar-media-deck-visual { + padding: 16px; + } + + .sidebar-media-deck-visual-text { + font-size: 1.2rem; + } + + .sidebar-media-deck-visual-subtext { + max-width: 100%; + font-size: 0.8rem; + } + + .sidebar-media-deck-queue-shell { + padding-bottom: 10px; + } + + .sidebar-media-deck-body { + position: sticky; + bottom: 0; + z-index: 5; + padding: 12px 14px calc(env(safe-area-inset-bottom) + 14px); + gap: 10px; + background: + linear-gradient(180deg, rgba(9, 15, 30, 0.72), rgba(9, 15, 30, 0.98) 24%); + backdrop-filter: blur(18px) saturate(125%); + border-top: 1px solid rgba(255, 255, 255, 0.08); + } + + .sidebar-media-deck-controls { + gap: 8px; + } + + .sidebar-media-deck-btn { + min-height: 44px; + padding: 9px 12px; + font-size: 0.74rem; + border-radius: 12px; + justify-content: center; + flex: 1 1 calc(50% - 4px); + } + + .sidebar-media-deck-btn.primary { + order: -1; + flex-basis: 100%; + min-height: 48px; + } + + .sidebar-media-deck-spacer { + display: none; + } + + #sidebar-media-deck-return { + flex-basis: 100%; + } + + .sidebar-media-deck-item { + min-width: 124px; + width: 124px; + flex-basis: 124px; + } + } + + @media (min-height: 541px) and (max-height: 720px) and (orientation: landscape) { + .sidebar-media-deck { + width: min(640px, calc(100vw - 28px)); + bottom: 10px; + max-height: calc(100vh - 20px); + } + + .sidebar-media-deck-shell { + grid-template-rows: auto auto auto auto; + max-height: calc(100vh - 20px); + overflow-y: auto; + overscroll-behavior-y: contain; + } + + .sidebar-media-deck-stage { + min-height: 180px; + max-height: 42vh; + } + + .sidebar-media-deck-item { + min-width: 126px; + width: 126px; + flex-basis: 126px; + } + } + .canopy-mini-focus-flash { animation: canopyMiniFocusFlash 1.2s ease; } @@ -870,6 +1744,9 @@ .sidebar-media-mini-progress > span, .sidebar-media-mini-btn, .sidebar-media-mini-close, + .sidebar-media-deck-shell, + .sidebar-media-deck-item, + .sidebar-media-deck-btn, .canopy-mini-focus-flash { animation: none !important; transition: none !important; @@ -5795,6 +6672,9 @@

+ + {# Media deck lives outside .app-container so position:fixed is not clipped by overflow/stacking + inside .sidebar / .main-row (critical for iOS Safari). IDs unchanged for canopy-main.js. #} +
+ + +
diff --git a/docs/AGENT_ONBOARDING.md b/docs/AGENT_ONBOARDING.md index 711fc5e..8308c24 100644 --- a/docs/AGENT_ONBOARDING.md +++ b/docs/AGENT_ONBOARDING.md @@ -4,7 +4,7 @@ Get a new AI agent connected to the Canopy network in under 5 minutes. This guide also applies to OpenClaw-style agent deployments that want Canopy to provide the shared collaboration surface. -> Version scope: aligned to Canopy `0.4.99`. Canonical endpoints are prefixed with `http://localhost:7770/api/v1`. A backward-compatible `/api` alias exists for legacy agent clients, but new integrations should use `/api/v1`. +> Version scope: aligned to Canopy `0.4.111`. Canonical endpoints are prefixed with `http://localhost:7770/api/v1`. A backward-compatible `/api` alias exists for legacy agent clients, but new integrations should use `/api/v1`. --- diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 7980d2a..d786e64 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1,6 +1,6 @@ # Canopy API Reference -Version scope: this reference is aligned to the current Canopy `0.4.99` development surface. +Version scope: this reference is aligned to the current Canopy `0.4.111` development surface. Canonical endpoints are prefixed with `/api/v1`. Canopy also mounts a backward-compatible `/api` alias for legacy agents; new clients should use `/api/v1`. @@ -157,6 +157,7 @@ Rich media notes: - Uploaded images can now be referenced inline inside message or feed body content with Markdown image syntax using a Canopy file URI: `![caption](file:FILE_ID)`. - Image attachment metadata may include `layout_hint` with one of `grid`, `hero`, `strip`, or `stack`. Invalid values are stripped during normalization. - URLs from supported providers (YouTube, Vimeo, Loom, Spotify, SoundCloud, OpenStreetMap, TradingView, and direct audio/video links) are automatically rendered as rich embeds in the UI. Google Maps links render as inline map iframes when `CANOPY_GOOGLE_MAPS_EMBED_API_KEY` is configured; otherwise they fall back to safe preview cards. +- Off-screen audio, direct video, and YouTube playback can surface in the sidebar mini-player. In `0.4.111`, the mini-player can expand into a larger media deck with seek controls and a related-media queue scoped to the same post or message. --- diff --git a/docs/GITHUB_RELEASE_ANNOUNCEMENT_DRAFT.md b/docs/GITHUB_RELEASE_ANNOUNCEMENT_DRAFT.md index 7d24cea..1369da8 100644 --- a/docs/GITHUB_RELEASE_ANNOUNCEMENT_DRAFT.md +++ b/docs/GITHUB_RELEASE_ANNOUNCEMENT_DRAFT.md @@ -1,4 +1,4 @@ -# GitHub Release Announcement Draft (Canopy 0.4.109) +# GitHub Release Announcement Draft (Canopy 0.4.111) Use this as the base for the GitHub release page, repo announcement, and launch posts. @@ -8,9 +8,9 @@ Use this as the base for the GitHub release page, repo announcement, and launch ## Full announcement (GitHub release notes) -**Canopy 0.4.109 is out.** +**Canopy 0.4.111 is out.** -This release focuses on trust, privacy defaults, and making the mesh harder to abuse — while also speeding up the sidebar and keeping search rock-solid. +This release focuses on trust, privacy defaults, and making the workspace feel more continuous during media playback — while also keeping the mesh harder to abuse and search rock-solid. ### What is Canopy? @@ -23,6 +23,7 @@ Canopy is a local-first encrypted collaboration system for humans and AI agents: ### Highlights since 0.4.105 +- **Expanded media deck** (`0.4.111`): The sidebar mini-player now opens into a larger floating media deck with a stage area, queue navigation, seek support, PiP for supported video, and related media drawn from the same post or message. - **Privacy-first trust baseline** (`0.4.106`): Unknown peers now start at trust score 0 instead of being implicitly trusted. Feed posts default to private. When you narrow a post's visibility, peers that should no longer see it receive a revocation signal automatically. - **Proactive P2P hardening** (`0.4.107`-`0.4.109`): Trust boundaries enforce ownership verification on compliance and violation signals. Inbound messages are validated for payload size, identity, and visibility scope. Delete signal authorization covers all data types. Encryption helpers handle edge cases gracefully. API authentication extended across status endpoints. - **Sidebar performance** (`0.4.108`): DOM batching and render-key diffing skip unnecessary redraws. Polling intervals relaxed. GPU compositing hints added for smoother animations. @@ -52,9 +53,10 @@ Canopy remains early-stage software. Test trust and visibility behavior on your ## Short version (for repo Discussions / announcements) -Canopy 0.4.109 is live. +Canopy 0.4.111 is live. -This release flips Canopy's defaults to privacy-first: +This release extends Canopy's media UX while keeping privacy-first defaults: +- off-screen playback can expand into a larger media deck with queue navigation and seek support, - unknown peers start at trust 0 instead of being implicitly trusted, - feed posts default to private, - visibility narrowing sends automatic revocation signals, @@ -70,6 +72,6 @@ Start here: ## Social copy (very short) -Canopy 0.4.109 is out: local-first encrypted collaboration for humans and AI agents. +Canopy 0.4.111 is out: local-first encrypted collaboration for humans and AI agents. -Privacy-first by default — peers earn trust, posts stay private, visibility changes propagate revocation. Plus faster sidebar rendering and hardened P2P message handling. +Privacy-first by default — peers earn trust, posts stay private, visibility changes propagate revocation. Plus a larger media deck for off-screen playback and hardened P2P message handling. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 4448699..1d55e36 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -1,7 +1,7 @@ # Canopy Quick Start This guide is the primary technical first-run path for Canopy. It is intentionally opinionated: technical users get one default repo path, nontechnical Windows users get one packaged path when available, and agent operators get Canopy running first before agent-specific setup. -Version scope: this quick start is aligned to Canopy `0.4.99`. +Version scope: this quick start is aligned to Canopy `0.4.111`. If your goal is to host human users alongside OpenClaw-style agents, this guide gets the instance online first and then points you to the right agent integration docs. diff --git a/pyproject.toml b/pyproject.toml index 4359200..37ca1af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "canopy" -version = "0.4.109" +version = "0.4.113" description = "Local-first peer-to-peer collaboration for humans and AI agents." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_frontend_regressions.py b/tests/test_frontend_regressions.py index 7161cbd..20988d0 100644 --- a/tests/test_frontend_regressions.py +++ b/tests/test_frontend_regressions.py @@ -18,7 +18,7 @@ def test_miniplayer_no_longer_eagerly_docks_youtube_on_update(self) -> None: main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') self.assertNotIn("if (type === 'youtube' && !isDocked && miniVideoHost) {", main_js) self.assertIn("} else if (type === 'youtube') {", main_js) - self.assertIn("if (type === 'youtube' && miniVideoHost && !isDockedInMiniHost(el) && isOffscreen(el)) {", main_js) + self.assertIn("if (type === 'youtube' && miniVideoHost && !state.deckOpen && !isDockedInMiniHost(el) && isOffscreen(el)) {", main_js) self.assertIn("autoDockYouTube(entry.target);", main_js) self.assertIn("el.__canopyMiniYTDockResumeAt = getYouTubeCurrentTimeSafe(el);", main_js) self.assertIn("player.seekTo(resumeAt, true);", main_js) @@ -26,7 +26,7 @@ def test_miniplayer_no_longer_eagerly_docks_youtube_on_update(self) -> None: self.assertIn("Object.prototype.hasOwnProperty.call(el, '__canopyMiniYTDockResumeAt')", main_js) self.assertIn("const shouldResume = el.__canopyMiniYTDockShouldResume === true;", main_js) self.assertIn("url.searchParams.set('start', String(Math.max(0, Math.floor(resumeAt))));", main_js) - self.assertIn("el.__canopyMiniYTLastTime = cur;", main_js) + self.assertIn("ytIframe.__canopyMiniYTLastTime = cur;", main_js) self.assertIn("function shouldPersistActiveYouTube(el) {", main_js) self.assertIn("if (!shouldPersistActiveYouTube(el)) {", main_js) self.assertIn("clearYouTubeDockResumeState(el);", main_js) @@ -208,6 +208,164 @@ def test_sidebar_cards_support_three_states_and_mini_player_placement(self) -> N self.assertIn("function setCanopySidebarMiniPosition(nextPosition)", main_js) self.assertIn("setCanopySidebarMiniPosition(canopySidebarRailState.miniPosition);", main_js) + def test_sidebar_media_deck_expands_miniplayer_with_queue_and_stage(self) -> None: + base_template = (ROOT / 'canopy' / 'ui' / 'templates' / 'base.html').read_text(encoding='utf-8') + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + self.assertIn('id="sidebar-media-mini-expand"', base_template) + self.assertIn('id="sidebar-media-deck"', base_template) + self.assertIn('id="sidebar-media-deck-stage"', base_template) + self.assertIn('id="sidebar-media-deck-queue"', base_template) + self.assertIn('id="sidebar-media-deck-seek"', base_template) + self.assertIn('id="sidebar-media-deck-prev"', base_template) + self.assertIn('id="sidebar-media-deck-next"', base_template) + self.assertIn('id="sidebar-media-deck-mini-player"', base_template) + self.assertIn('id="sidebar-media-deck-minimize-footer"', base_template) + self.assertIn('id="sidebar-media-deck-mini-player-footer"', base_template) + self.assertIn(".sidebar-media-deck-shell", base_template) + self.assertIn(".sidebar-media-deck-queue {", base_template) + self.assertIn("overflow-x: auto;", base_template) + self.assertIn("scroll-snap-type: x proximity;", base_template) + self.assertIn("function buildRelatedMediaList(sourceEl, activeEl) {", main_js) + self.assertIn("function openMediaDeck() {", main_js) + self.assertIn("function closeMediaDeck(options = {}) {", main_js) + self.assertIn("function renderDeckQueue() {", main_js) + self.assertIn("function moveDockedMediaToHost(el, host) {", main_js) + self.assertIn("function seekCurrentMediaTo(ratio) {", main_js) + self.assertIn("deckQueue.addEventListener('click'", main_js) + self.assertIn("deckSeek.addEventListener('change'", main_js) + + def test_media_deck_switching_uses_central_deactivation_and_disconnect_cleanup(self) -> None: + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + self.assertIn("function clearOrphanedDockedMedia(el, type, sourceEl) {", main_js) + self.assertIn("function deactivateMediaEntry(entry, options = {}) {", main_js) + self.assertIn("pauseMediaElement(el, type);", main_js) + self.assertIn("// Re-assert pause after restoration so a switched-away item cannot", main_js) + self.assertIn("clearOrphanedDockedMedia(el, type, entry.sourceEl || sourceContainer(el));", main_js) + self.assertIn("deactivateMediaEntry(state.current);", main_js) + self.assertIn("const staleCurrent = state.current;", main_js) + self.assertIn("deactivateMediaEntry(staleCurrent);", main_js) + + def test_media_deck_adds_source_level_launcher_for_playable_posts_and_messages(self) -> None: + base_template = (ROOT / 'canopy' / 'ui' / 'templates' / 'base.html').read_text(encoding='utf-8') + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + self.assertIn(".canopy-media-deck-source-slot", base_template) + self.assertIn(".canopy-media-deck-launcher", base_template) + self.assertIn(".canopy-media-mini-launcher", base_template) + self.assertIn(".canopy-media-deck-launcher-count", base_template) + self.assertIn("touch-action: manipulation", base_template) + self.assertIn("const mqCoarseOrNarrow = window.matchMedia('(max-width: 640px), (pointer: coarse)');", main_js) + self.assertIn("function resolveSourceMediaDeckLauncherHost(sourceEl) {", main_js) + self.assertIn("function openMediaDeckForSource(sourceEl) {", main_js) + self.assertIn("function syncSourceMediaDeckLauncher(sourceEl) {", main_js) + self.assertIn("function syncSourceMediaDeckLaunchersInScope(scope) {", main_js) + self.assertIn("btnDeck.setAttribute('data-open-media-deck', '1');", main_js) + self.assertIn("const actionsHost = sourceEl.querySelector('[data-post-actions] .d-flex.gap-2.flex-wrap, .message-actions .d-flex.gap-2.flex-wrap');", main_js) + self.assertIn("slot.className = 'canopy-media-deck-source-slot';", main_js) + self.assertIn("btnDeck.innerHTML = `Media deck${countLabel}`;", main_js) + self.assertIn("state.deckSelectedKey = preferred.key;", main_js) + self.assertIn("state.deckOpen = true;", main_js) + self.assertIn("selectDeckItem(preferred, { play: false });", main_js) + self.assertIn("function materializeYouTubeFacade(facade, options = {}) {", main_js) + self.assertIn("url.searchParams.set('autoplay', options.autoplay === true ? '1' : '0');", main_js) + self.assertIn("materializeYouTubeFacade(facade, { autoplay: true });", main_js) + self.assertIn("sourceEl.querySelectorAll('audio, video, .youtube-embed').forEach(pushCandidate);", main_js) + self.assertIn("const ytEl = resolveYouTubeMediaElement(el, { activate: true, autoplay: true });", main_js) + self.assertIn("deferYouTubeMaterialize: true", main_js) + self.assertIn("activate: !defer, autoplay: false", main_js) + self.assertIn("function openMiniPlayerForSource(sourceEl) {", main_js) + self.assertIn("function switchDeckToMiniPlayer() {", main_js) + self.assertIn("btnMini.setAttribute('data-open-mini-player', '1');", main_js) + self.assertIn("forceDockMini:", main_js) + self.assertIn("function isYouTubeFacadeOnly(el) {", main_js) + self.assertIn("function repairMediaCurrentReference() {", main_js) + self.assertIn("function reconcileDeckStageMediaPlacement() {", main_js) + self.assertIn("function prepareYouTubeEmbedForHostMove(el, opts) {", main_js) + self.assertIn("function isSidebarDeckOrMiniHost(node) {", main_js) + self.assertIn("skipResumeUrlRewrite:", main_js) + self.assertIn("function resetYouTubePlayerBridge(iframe) {", main_js) + self.assertIn("el.src = next;\n return true;", main_js) + self.assertIn("syncSourceMediaDeckLaunchersInScope(root);", main_js) + + def test_media_deck_prefers_post_or_message_source_over_nested_card(self) -> None: + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + self.assertIn("const postOrMessage = el.closest('.post-card[data-post-id], .message-item[data-message-id]');", main_js) + self.assertIn("return postOrMessage || el.closest('.card');", main_js) + self.assertNotIn("return el.closest('.post-card[data-post-id], .message-item[data-message-id], .card');", main_js) + + def test_media_deck_optimizes_refresh_and_queue_rerenders(self) -> None: + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + self.assertIn("deckQueueSignature: ''", main_js) + self.assertIn("deckSelectedKey: ''", main_js) + self.assertIn("miniUpdateFrame: 0", main_js) + self.assertIn("miniUpdateTimer: null", main_js) + self.assertIn("function scheduleMiniUpdate(delay = 0) {", main_js) + self.assertIn("state.miniUpdateFrame = window.requestAnimationFrame(() => {", main_js) + self.assertIn("const nextSignature = `${activeKey}::${items.map((item) => `${item.key}:${item.type}`).join('|')}`;", main_js) + self.assertIn("if (state.deckQueueSignature === nextSignature && deckQueue.childElementCount) {", main_js) + self.assertIn("btn.setAttribute('aria-expanded', isActive && state.deckOpen ? 'true' : 'false');", main_js) + self.assertIn("state.tickHandle = setInterval(scheduleMiniUpdate, 700);", main_js) + self.assertNotIn("state.tickHandle = setInterval(updateMini, 700);", main_js) + + def test_media_deck_source_launch_and_return_use_source_first_behavior(self) -> None: + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + base_html = (ROOT / 'canopy' / 'ui' / 'templates' / 'base.html').read_text(encoding='utf-8') + self.assertIn("state.returnUrl = null;", main_js) + self.assertIn("state.dockedSubtitle = null;", main_js) + self.assertNotIn("const keepDeckVisible", main_js) + self.assertIn("closeMediaDeck({ forceClose: true });", main_js) + self.assertIn("Show source", base_html) + self.assertIn("Return to source", base_html) + + def test_media_deck_open_state_is_authoritative_over_miniplayer_auto_logic(self) -> None: + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + self.assertIn("if (state.deckOpen) {", main_js) + self.assertIn("updateDeckPanel();", main_js) + self.assertIn("hideMini();", main_js) + self.assertNotIn("const shouldKeepDeckSelection = !!(state.deckOpen && !state.dismissedEl && current.el);", main_js) + + def test_media_deck_selection_is_decoupled_from_immediate_playback(self) -> None: + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + self.assertIn("function getDeckSelectedItem() {", main_js) + self.assertIn("function selectDeckItem(item, options = {}) {", main_js) + self.assertIn("ensureMediaIdentity(state.current.el)", main_js) + self.assertIn("pauseMediaElement(state.current.el, state.current.type);", main_js) + self.assertIn("selectDeckItem(nextItem, { play: false });", main_js) + self.assertIn("scrollDeckSelectionIntoView();", main_js) + + def test_media_deck_first_click_hardening(self) -> None: + main_js = (ROOT / 'canopy' / 'ui' / 'static' / 'js' / 'canopy-main.js').read_text(encoding='utf-8') + base_html = (ROOT / 'canopy' / 'ui' / 'templates' / 'base.html').read_text(encoding='utf-8') + self.assertIn("if (e.defaultPrevented) return;", main_js) + self.assertIn("!state.deckOpen &&", main_js) + self.assertIn("if (state.deckOpen && state.current && state.current.sourceEl === source) return;", main_js) + self.assertIn("[data-post-actions]", base_html) + self.assertIn("z-index: 2;", base_html) + + def test_media_deck_mobile_viewport_fit(self) -> None: + base_html = (ROOT / 'canopy' / 'ui' / 'templates' / 'base.html').read_text(encoding='utf-8') + self.assertIn("body.canopy-media-deck-modal", base_html) + self.assertIn("top: 0;", base_html) + self.assertIn("height: 100dvh;", base_html) + self.assertIn("max-height: 100dvh;", base_html) + self.assertIn("overflow-y: auto;", base_html) + self.assertIn("overscroll-behavior-y: contain;", base_html) + self.assertIn(".sidebar-media-deck-action-label", base_html) + self.assertIn("Minimize", base_html) + self.assertIn("Mini player", base_html) + self.assertIn("Close", base_html) + self.assertIn("(min-height: 541px) and (max-height: 720px) and (orientation: landscape)", base_html) + self.assertNotIn("min-height: min(86vh, 860px);", base_html) + + def test_media_deck_portal_is_body_level_for_ios_fixed_positioning(self) -> None: + base_html = (ROOT / 'canopy' / 'ui' / 'templates' / 'base.html').read_text(encoding='utf-8') + self.assertIn('data-canopy-deck-portal="1"', base_html) + self.assertIn('canopy-media-deck-portal', base_html) + self.assertGreater( + base_html.find('data-canopy-deck-portal="1"'), + base_html.find(''), + 'Deck portal should render after main layout (outside scroll/overflow sidebar stack)', + ) + def test_dm_search_uses_explicit_search_state_to_suspend_live_refresh(self) -> None: messages_template = (ROOT / 'canopy' / 'ui' / 'templates' / 'messages.html').read_text(encoding='utf-8') self.assertIn("const DM_SEARCH_QUERY = ", messages_template)