Download functionality
This commit is contained in:
@@ -915,6 +915,72 @@ body.theme-light .setting-item select option {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-menu-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
transition: transform 0.2s ease, background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.theme-light .video-menu-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-menu-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 48px;
|
||||||
|
right: 10px;
|
||||||
|
min-width: 140px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow: 0 12px 24px var(--shadow);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-menu.open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-menu-item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.favorite-btn {
|
.favorite-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
@@ -950,6 +1016,108 @@ body.theme-light .favorite-btn {
|
|||||||
background: rgba(255, 59, 48, 0.12);
|
background: rgba(255, 59, 48, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Info Modal */
|
||||||
|
.info-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
z-index: 2500;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-modal.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
position: relative;
|
||||||
|
width: min(520px, 92vw);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px 22px;
|
||||||
|
box-shadow: 0 18px 36px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 16px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-rows,
|
||||||
|
.info-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-json {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal */
|
/* Modal */
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -113,6 +113,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="info-modal" class="info-modal" aria-hidden="true">
|
||||||
|
<div class="info-card" role="dialog" aria-modal="true" aria-labelledby="info-title">
|
||||||
|
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
|
||||||
|
<h3 id="info-title">Video Info</h3>
|
||||||
|
<div id="info-list" class="info-list"></div>
|
||||||
|
<div id="info-empty" class="info-empty">No additional info available.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="error-toast" class="error-toast" role="alert" aria-live="assertive">
|
<div id="error-toast" class="error-toast" role="alert" aria-live="assertive">
|
||||||
<span id="error-toast-text"></span>
|
<span id="error-toast-text"></span>
|
||||||
<button id="error-toast-close" type="button" aria-label="Close">✕</button>
|
<button id="error-toast-close" type="button" aria-label="Close">✕</button>
|
||||||
|
|||||||
@@ -21,21 +21,24 @@ App.favorites = App.favorites || {};
|
|||||||
|
|
||||||
App.favorites.getKey = function(video) {
|
App.favorites.getKey = function(video) {
|
||||||
if (!video) return null;
|
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) {
|
App.favorites.normalize = function(video) {
|
||||||
const key = App.favorites.getKey(video);
|
const key = App.favorites.getKey(video);
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
|
const meta = video && video.meta ? video.meta : video;
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
id: video.id || null,
|
id: video.id || null,
|
||||||
url: video.url || '',
|
url: video.url || '',
|
||||||
title: video.title || '',
|
title: video.title || '',
|
||||||
thumb: video.thumb || '',
|
thumb: video.thumb || '',
|
||||||
channel: video.channel || '',
|
channel: video.channel || (meta && meta.channel) || '',
|
||||||
uploader: video.uploader || video.channel || '',
|
uploader: video.uploader || (meta && (meta.uploader || meta.channel)) || '',
|
||||||
duration: video.duration || 0
|
duration: video.duration || (meta && meta.duration) || 0,
|
||||||
|
meta: meta
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,6 +105,11 @@ App.favorites = App.favorites || {};
|
|||||||
const uploaderText = item.uploader || item.channel || '';
|
const uploaderText = item.uploader || item.channel || '';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}">♥</button>
|
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}">♥</button>
|
||||||
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
||||||
|
<div class="video-menu" role="menu">
|
||||||
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
||||||
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
||||||
|
</div>
|
||||||
<img src="${item.thumb}" alt="${item.title}">
|
<img src="${item.thumb}" alt="${item.title}">
|
||||||
<div class="favorite-info">
|
<div class="favorite-info">
|
||||||
<h4>${item.title}</h4>
|
<h4>${item.title}</h4>
|
||||||
@@ -116,6 +124,30 @@ App.favorites = App.favorites || {};
|
|||||||
App.favorites.toggle(item);
|
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');
|
const uploaderBtn = card.querySelector('.uploader-link');
|
||||||
if (uploaderBtn) {
|
if (uploaderBtn) {
|
||||||
uploaderBtn.onclick = (event) => {
|
uploaderBtn.onclick = (event) => {
|
||||||
|
|||||||
@@ -26,6 +26,65 @@ App.ui = App.ui || {};
|
|||||||
}, 4000);
|
}, 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.
|
// Drawer controls shared by the inline HTML handlers.
|
||||||
App.ui.closeDrawers = function() {
|
App.ui.closeDrawers = function() {
|
||||||
const menuDrawer = document.getElementById('drawer-menu');
|
const menuDrawer = document.getElementById('drawer-menu');
|
||||||
@@ -431,7 +490,31 @@ App.ui = App.ui || {};
|
|||||||
window.handleSearch = App.videos.handleSearch;
|
window.handleSearch = App.videos.handleSearch;
|
||||||
|
|
||||||
document.addEventListener('keydown', (event) => {
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ App.videos = App.videos || {};
|
|||||||
const uploaderText = v.uploader || v.channel || '';
|
const uploaderText = v.uploader || v.channel || '';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}">♡</button>
|
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}">♡</button>
|
||||||
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
||||||
|
<div class="video-menu" role="menu">
|
||||||
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
||||||
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
||||||
|
</div>
|
||||||
<img src="${v.thumb}" alt="${v.title}">
|
<img src="${v.thumb}" alt="${v.title}">
|
||||||
<h4>${v.title}</h4>
|
<h4>${v.title}</h4>
|
||||||
${uploaderText ? `<p class="video-meta"><button class="uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button></p>` : ''}
|
${uploaderText ? `<p class="video-meta"><button class="uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button></p>` : ''}
|
||||||
@@ -119,6 +124,30 @@ App.videos = App.videos || {};
|
|||||||
App.videos.handleSearch(uploader);
|
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);
|
card.onclick = () => App.player.open(v.url);
|
||||||
grid.appendChild(card);
|
grid.appendChild(card);
|
||||||
state.renderedVideoIds.add(v.id);
|
state.renderedVideoIds.add(v.id);
|
||||||
@@ -174,4 +203,49 @@ App.videos = App.videos || {};
|
|||||||
loadMoreBtn.disabled = state.isLoading || !state.hasNextPage;
|
loadMoreBtn.disabled = state.isLoading || !state.hasNextPage;
|
||||||
loadMoreBtn.style.display = state.hasNextPage ? 'flex' : 'none';
|
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();
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user