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 @@
-
+
@@ -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 @@