feat: Support previewing each inner video file for multi-file torrents with interactive playlist switcher
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user