feat: Support previewing each inner video file for multi-file torrents with interactive playlist switcher

This commit is contained in:
david
2026-08-17 10:39:19 -04:00
parent 95369f7d53
commit f553c4d02e
+139 -15
View File
@@ -740,6 +740,34 @@
border-color: var(--accent-blue); border-color: var(--accent-blue);
color: #fff; color: #fff;
} }
.playlist-pill {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
color: var(--text-main);
padding: 0.35rem 0.65rem;
font-size: 0.72rem;
font-family: var(--font-mono);
cursor: pointer;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transition: all 0.15s ease;
user-select: none;
}
.playlist-pill:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
background: rgba(6, 182, 212, 0.08);
}
.playlist-pill.active {
background: rgba(6, 182, 212, 0.22);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
font-weight: 700;
box-shadow: 0 0 10px rgba(6, 182, 212, 0.35);
}
.video-details-panel { .video-details-panel {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
background: var(--bg-card); background: var(--bg-card);
@@ -1391,6 +1419,17 @@
</div> </div>
</div> </div>
<!-- Multi-File Torrent Video Switcher Bar -->
<div id="video-playlist-bar" style="display: none; padding: 0.6rem 1.25rem; background: rgba(15, 23, 42, 0.95); border-bottom: 1px solid var(--border-color); flex-direction: column; gap: 0.4rem;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<span style="font-size: 0.75rem; font-weight: 700; color: var(--accent-cyan); display: flex; align-items: center; gap: 0.35rem;">
🎬 <span>Torrent Video Files</span> (<span id="video-playlist-count">0</span>)
</span>
<span style="font-size: 0.7rem; color: var(--text-muted);">Click any file to preview:</span>
</div>
<div id="video-playlist-pills" style="display: flex; gap: 0.45rem; overflow-x: auto; padding-bottom: 0.25rem;"></div>
</div>
<div class="video-details-panel"> <div class="video-details-panel">
<div id="video-preview-performer-box"></div> <div id="video-preview-performer-box"></div>
</div> </div>
@@ -3389,13 +3428,38 @@ Return ONLY valid JSON matching:
t.innerFiles = await fetchTorrentFiles(hash); t.innerFiles = await fetchTorrentFiles(hash);
} }
const videoExts = /\.(mp4|mkv|avi|mov|webm|m4v|ts)$/i;
if (t.innerFiles && t.innerFiles.length > 0) { if (t.innerFiles && t.innerFiles.length > 0) {
list.innerHTML = t.innerFiles.map(f => ` list.innerHTML = t.innerFiles.map((f, idx) => {
<li> const isVideo = videoExts.test(f.name);
<span>${escapeHtml(f.name)}</span> return `
<span style="color: var(--text-muted); font-size: 0.7rem;">${formatBytes(f.size)}</span> <li style="display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; padding: 0.45rem 0.75rem;">
</li> <div style="display: flex; align-items: center; gap: 0.5rem; overflow: hidden; flex: 1;">
`).join(''); <span style="font-size: 0.95rem;">${isVideo ? '🎬' : '📄'}</span>
<span style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 0.78rem;" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</span>
</div>
<div style="display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0;">
<span style="color: var(--text-muted); font-size: 0.72rem; font-family: var(--font-mono);">${formatBytes(f.size)}</span>
${isVideo ? `
<button type="button" class="btn btn-secondary btn-sm btn-modal-preview-inner-file" data-hash="${t.hash}" data-file="${escapeHtml(f.name)}" style="color: var(--accent-cyan); border-color: rgba(6, 182, 212, 0.4); background: rgba(6, 182, 212, 0.08); padding: 0.2rem 0.55rem; font-size: 0.72rem; display: inline-flex; align-items: center; gap: 0.25rem;">
▶ Preview File
</button>
` : ''}
</div>
</li>
`;
}).join('');
list.querySelectorAll('.btn-modal-preview-inner-file').forEach(pBtn => {
pBtn.addEventListener('click', (e) => {
e.preventDefault();
const targetHash = pBtn.getAttribute('data-hash');
const targetFile = pBtn.getAttribute('data-file');
document.getElementById('file-modal').classList.remove('open');
openVideoPreviewForTorrent(targetHash, targetFile);
});
});
} else { } else {
list.innerHTML = `<li>No inner files returned or single-file torrent.</li>`; list.innerHTML = `<li>No inner files returned or single-file torrent.</li>`;
} }
@@ -4269,7 +4333,7 @@ Return ONLY a valid JSON object with an array named "results":
renderCurrentVideoPreview(); renderCurrentVideoPreview();
} }
async function openVideoPreviewForTorrent(hash) { async function openVideoPreviewForTorrent(hash, targetInnerFile = null) {
const query = searchInput.value.trim().toLowerCase(); const query = searchInput.value.trim().toLowerCase();
const filtered = qbState.torrents.filter(t => { const filtered = qbState.torrents.filter(t => {
if (!query) return true; if (!query) return true;
@@ -4287,6 +4351,7 @@ Return ONLY a valid JSON object with an array named "results":
previewState.activeType = 'qb'; previewState.activeType = 'qb';
previewState.currentList = filtered; previewState.currentList = filtered;
previewState.currentIndex = idx; previewState.currentIndex = idx;
previewState.selectedInnerFile = targetInnerFile || null;
renderCurrentVideoPreview(); renderCurrentVideoPreview();
} }
@@ -4301,21 +4366,78 @@ Return ONLY a valid JSON object with an array named "results":
let streamPath = ''; let streamPath = '';
let titleName = item.name; let titleName = item.name;
const videoExts = /\.(mp4|mkv|avi|mov|webm|m4v|ts)$/i;
const playlistBar = document.getElementById('video-playlist-bar');
const playlistPills = document.getElementById('video-playlist-pills');
const playlistCount = document.getElementById('video-playlist-count');
if (previewState.activeType === 'smb') { if (previewState.activeType === 'smb') {
streamPath = item.path; streamPath = item.path;
if (playlistBar) playlistBar.style.display = 'none';
} else { } else {
// qb torrent // qb torrent
if (item.innerFiles && item.innerFiles.length > 0) { const videoFiles = (item.innerFiles || []).filter(f => videoExts.test(f.name));
const videoFile = item.innerFiles.find(f => /\.(mp4|mkv|avi|mov|webm|m4v|ts)$/i.test(f.name));
if (videoFile) { if (videoFiles.length > 1) {
const base = (item.save_path || '/downloads').replace(/\/+$/, ''); if (playlistBar) {
streamPath = `${base}/${videoFile.name}`; playlistBar.style.display = 'flex';
if (playlistCount) playlistCount.textContent = videoFiles.length;
} }
}
if (!streamPath) { // Determine selected inner file
let activeFile = null;
if (previewState.selectedInnerFile) {
activeFile = videoFiles.find(f => f.name === previewState.selectedInnerFile || f.name.endsWith(previewState.selectedInnerFile));
}
if (!activeFile) {
// Default to largest or first video file
let largest = videoFiles[0];
for (const vf of videoFiles) {
if ((vf.size || 0) > (largest.size || 0)) largest = vf;
}
activeFile = largest;
previewState.selectedInnerFile = activeFile.name;
}
const base = (item.save_path || '/downloads').replace(/\/+$/, ''); const base = (item.save_path || '/downloads').replace(/\/+$/, '');
streamPath = `${base}/${item.name}`; streamPath = `${base}/${activeFile.name}`;
const shortFileName = activeFile.name.split('/').pop();
titleName = `${item.name} ➔ ${shortFileName}`;
if (playlistPills) {
playlistPills.innerHTML = videoFiles.map((vf, vIdx) => {
const isActive = vf.name === activeFile.name;
const shortName = vf.name.split('/').pop();
return `
<button type="button" class="playlist-pill ${isActive ? 'active' : ''}" data-file="${escapeHtml(vf.name)}" title="${escapeHtml(vf.name)} (${formatBytes(vf.size)})">
<span>${isActive ? '▶' : '🎬'}</span>
<span>${escapeHtml(shortName)}</span>
<span style="opacity: 0.7; font-size: 0.68rem; font-family: var(--font-mono);">(${formatBytes(vf.size)})</span>
</button>
`;
}).join('');
playlistPills.querySelectorAll('.playlist-pill').forEach(pill => {
pill.addEventListener('click', (e) => {
e.preventDefault();
const chosenFile = pill.getAttribute('data-file');
previewState.selectedInnerFile = chosenFile;
renderCurrentVideoPreview();
});
});
}
} else {
if (playlistBar) playlistBar.style.display = 'none';
if (videoFiles.length === 1) {
const base = (item.save_path || '/downloads').replace(/\/+$/, '');
streamPath = `${base}/${videoFiles[0].name}`;
const shortFileName = videoFiles[0].name.split('/').pop();
titleName = `${item.name} ➔ ${shortFileName}`;
} else {
const base = (item.save_path || '/downloads').replace(/\/+$/, '');
streamPath = `${base}/${item.name}`;
}
} }
} }
@@ -4534,6 +4656,7 @@ Return ONLY a valid JSON object with an array named "results":
btnPreviewPrev.addEventListener('click', () => { btnPreviewPrev.addEventListener('click', () => {
if (previewState.currentIndex > 0) { if (previewState.currentIndex > 0) {
previewState.currentIndex--; previewState.currentIndex--;
previewState.selectedInnerFile = null;
renderCurrentVideoPreview(); renderCurrentVideoPreview();
} }
}); });
@@ -4541,6 +4664,7 @@ Return ONLY a valid JSON object with an array named "results":
btnPreviewNext.addEventListener('click', () => { btnPreviewNext.addEventListener('click', () => {
if (previewState.currentIndex < previewState.currentList.length - 1) { if (previewState.currentIndex < previewState.currentList.length - 1) {
previewState.currentIndex++; previewState.currentIndex++;
previewState.selectedInnerFile = null;
renderCurrentVideoPreview(); renderCurrentVideoPreview();
} }
}); });