diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js
index e0f3d89..b09d3be 100644
--- a/frontend/js/favorites.js
+++ b/frontend/js/favorites.js
@@ -21,21 +21,24 @@ App.favorites = App.favorites || {};
App.favorites.getKey = function(video) {
if (!video) return null;
- return video.key || video.id || video.url || null;
+ const meta = video.meta || video;
+ return video.key || meta.key || video.id || meta.id || video.url || meta.url || null;
};
App.favorites.normalize = function(video) {
const key = App.favorites.getKey(video);
if (!key) return null;
+ const meta = video && video.meta ? video.meta : video;
return {
key,
id: video.id || null,
url: video.url || '',
title: video.title || '',
thumb: video.thumb || '',
- channel: video.channel || '',
- uploader: video.uploader || video.channel || '',
- duration: video.duration || 0
+ channel: video.channel || (meta && meta.channel) || '',
+ uploader: video.uploader || (meta && (meta.uploader || meta.channel)) || '',
+ duration: video.duration || (meta && meta.duration) || 0,
+ meta: meta
};
};
@@ -102,6 +105,11 @@ App.favorites = App.favorites || {};
const uploaderText = item.uploader || item.channel || '';
card.innerHTML = `
${item.title}
@@ -116,6 +124,30 @@ App.favorites = App.favorites || {};
App.favorites.toggle(item);
};
}
+ const menuBtn = card.querySelector('.video-menu-btn');
+ const menu = card.querySelector('.video-menu');
+ const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
+ const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
+ if (menuBtn && menu) {
+ menuBtn.onclick = (event) => {
+ event.stopPropagation();
+ App.videos.toggleMenu(menu, menuBtn);
+ };
+ }
+ if (showInfoBtn) {
+ showInfoBtn.onclick = (event) => {
+ event.stopPropagation();
+ App.ui.showInfo(item.meta || item);
+ App.videos.closeAllMenus();
+ };
+ }
+ if (downloadBtn) {
+ downloadBtn.onclick = (event) => {
+ event.stopPropagation();
+ App.videos.downloadVideo(item.meta || item);
+ App.videos.closeAllMenus();
+ };
+ }
const uploaderBtn = card.querySelector('.uploader-link');
if (uploaderBtn) {
uploaderBtn.onclick = (event) => {
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index ec22c6d..5a919a0 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -26,6 +26,65 @@ App.ui = App.ui || {};
}, 4000);
};
+ App.ui.showInfo = function(video) {
+ const modal = document.getElementById('info-modal');
+ if (!modal) return;
+ const title = document.getElementById('info-title');
+ const list = document.getElementById('info-list');
+ const empty = document.getElementById('info-empty');
+
+ const data = video && video.meta ? video.meta : video;
+ const titleText = data && data.title ? data.title : 'Video Info';
+ if (title) title.textContent = titleText;
+
+ if (list) {
+ list.innerHTML = "";
+ }
+
+ let hasRows = false;
+ if (data && typeof data === 'object') {
+ Object.entries(data).forEach(([key, value]) => {
+ if (!list) return;
+ const row = document.createElement('div');
+ row.className = 'info-row';
+
+ const label = document.createElement('span');
+ label.className = 'info-label';
+ label.textContent = key;
+
+ let valueNode;
+ if (value && typeof value === 'object') {
+ valueNode = document.createElement('pre');
+ valueNode.className = 'info-json';
+ valueNode.textContent = JSON.stringify(value, null, 2);
+ } else {
+ valueNode = document.createElement('span');
+ valueNode.className = 'info-value';
+ valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
+ }
+
+ row.appendChild(label);
+ row.appendChild(valueNode);
+ list.appendChild(row);
+ hasRows = true;
+ });
+ }
+
+ if (empty) {
+ empty.style.display = hasRows ? 'none' : 'block';
+ }
+
+ modal.classList.add('open');
+ modal.setAttribute('aria-hidden', 'false');
+ };
+
+ App.ui.closeInfo = function() {
+ const modal = document.getElementById('info-modal');
+ if (!modal) return;
+ modal.classList.remove('open');
+ modal.setAttribute('aria-hidden', 'true');
+ };
+
// Drawer controls shared by the inline HTML handlers.
App.ui.closeDrawers = function() {
const menuDrawer = document.getElementById('drawer-menu');
@@ -431,7 +490,31 @@ App.ui = App.ui || {};
window.handleSearch = App.videos.handleSearch;
document.addEventListener('keydown', (event) => {
- if (event.key === 'Escape') App.ui.closeDrawers();
+ if (event.key === 'Escape') {
+ App.ui.closeDrawers();
+ App.ui.closeInfo();
+ App.videos.closeAllMenus();
+ }
});
+
+ document.addEventListener('click', () => {
+ App.videos.closeAllMenus();
+ });
+
+ const infoModal = document.getElementById('info-modal');
+ if (infoModal) {
+ infoModal.addEventListener('click', (event) => {
+ if (event.target === infoModal) {
+ App.ui.closeInfo();
+ }
+ });
+ }
+
+ const infoClose = document.getElementById('info-close');
+ if (infoClose) {
+ infoClose.addEventListener('click', () => {
+ App.ui.closeInfo();
+ });
+ }
};
})();
diff --git a/frontend/js/videos.js b/frontend/js/videos.js
index 1205f8c..f833020 100644
--- a/frontend/js/videos.js
+++ b/frontend/js/videos.js
@@ -98,6 +98,11 @@ App.videos = App.videos || {};
const uploaderText = v.uploader || v.channel || '';
card.innerHTML = `
+
+
${v.title}
${uploaderText ? `
` : ''}
@@ -119,6 +124,30 @@ App.videos = App.videos || {};
App.videos.handleSearch(uploader);
};
}
+ const menuBtn = card.querySelector('.video-menu-btn');
+ const menu = card.querySelector('.video-menu');
+ const showInfoBtn = card.querySelector('.video-menu-item[data-action="info"]');
+ const downloadBtn = card.querySelector('.video-menu-item[data-action="download"]');
+ if (menuBtn && menu) {
+ menuBtn.onclick = (event) => {
+ event.stopPropagation();
+ App.videos.toggleMenu(menu, menuBtn);
+ };
+ }
+ if (showInfoBtn) {
+ showInfoBtn.onclick = (event) => {
+ event.stopPropagation();
+ App.ui.showInfo(v);
+ App.videos.closeAllMenus();
+ };
+ }
+ if (downloadBtn) {
+ downloadBtn.onclick = (event) => {
+ event.stopPropagation();
+ App.videos.downloadVideo(v);
+ App.videos.closeAllMenus();
+ };
+ }
card.onclick = () => App.player.open(v.url);
grid.appendChild(card);
state.renderedVideoIds.add(v.id);
@@ -174,4 +203,49 @@ App.videos = App.videos || {};
loadMoreBtn.disabled = state.isLoading || !state.hasNextPage;
loadMoreBtn.style.display = state.hasNextPage ? 'flex' : 'none';
};
+
+ // Context menu helpers for per-card actions.
+ App.videos.closeAllMenus = function() {
+ document.querySelectorAll('.video-menu.open').forEach((menu) => {
+ menu.classList.remove('open');
+ });
+ document.querySelectorAll('.video-menu-btn[aria-expanded="true"]').forEach((btn) => {
+ btn.setAttribute('aria-expanded', 'false');
+ });
+ };
+
+ App.videos.toggleMenu = function(menu, button) {
+ const isOpen = menu.classList.contains('open');
+ App.videos.closeAllMenus();
+ if (!isOpen) {
+ menu.classList.add('open');
+ if (button) {
+ button.setAttribute('aria-expanded', 'true');
+ }
+ }
+ };
+
+ // Builds a proxied stream URL with an optional referer parameter.
+ App.videos.buildStreamUrl = function(videoUrl) {
+ let refererParam = '';
+ try {
+ const origin = new URL(videoUrl).origin;
+ refererParam = `&referer=${encodeURIComponent(origin + '/')}`;
+ } catch (err) {
+ refererParam = '';
+ }
+ return `/api/stream?url=${encodeURIComponent(videoUrl)}${refererParam}`;
+ };
+
+ App.videos.downloadVideo = function(video) {
+ if (!video || !video.url) return;
+ const link = document.createElement('a');
+ link.href = App.videos.buildStreamUrl(video.url);
+ const rawName = (video.title || video.id || 'video').toString();
+ const safeName = rawName.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').slice(0, 80);
+ link.download = safeName ? `${safeName}.mp4` : 'video.mp4';
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ };
})();