You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

454 lines
15 KiB
HTML

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ video.username }} - Video</title>
<style>
body {
font-family: Arial, sans-serif;
background: #111;
color: #eee;
text-align: center;
margin: 0;
padding: 0;
}
video {
margin-top: 20px;
width: 80%;
max-width: 1000px;
border: 2px solid #444;
border-radius: 8px;
background: #000;
}
.meta {
margin: 20px auto;
text-align: left;
max-width: 800px;
background: #222;
padding: 15px;
border-radius: 6px;
}
.meta p {
margin: 8px 0;
}
a {
color: #0af;
text-decoration: none;
}
.fav-btn {
font-size: 1.1rem;
line-height: 1;
border: none;
background: transparent;
cursor: pointer;
padding: .2rem .35rem;
}
.fav-btn[aria-pressed="true"] {
color: gold;
}
.fav-btn:hover {
transform: scale(1.05);
}
</style>
<style>
.player {
margin: 20px auto;
width: 80%;
max-width: 1000px;
background: #000;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 0 20px rgba(0, 0, 0, .6);
}
.player video {
width: 100%;
display: block;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
background: #181818;
padding: 10px;
border-top: 1px solid #333;
}
.controls button {
background: none;
border: none;
color: #eee;
font-size: 1.2rem;
cursor: pointer;
padding: 6px;
transition: color .2s, transform .2s;
}
.controls button:hover {
color: #0af;
transform: scale(1.2);
}
.time {
font-variant-numeric: tabular-nums;
font-size: .9rem;
color: #aaa;
}
.range {
flex: 1;
appearance: none;
height: 4px;
background: #444;
border-radius: 2px;
cursor: pointer;
}
.range::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #0af;
border: none;
}
.moments {
margin: 20px auto;
text-align: left;
max-width: 800px;
background: #222;
padding: 12px;
border-radius: 6px;
}
.moments h3 {
margin: 0 0 8px 0;
}
.moment-pill {
display: inline-block;
margin: 6px 6px 0 0;
padding: 6px 10px;
background: #0a2740;
color: #cfeaff;
border: 1px solid #0d3b63;
border-radius: 999px;
cursor: pointer;
font-size: 0.9rem;
transition: background .2s;
}
.moment-pill:hover {
background: #103553;
}
</style>
<style>
.osd {
position: absolute;
left: 50%;
top: 45%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, .6);
color: #fff;
padding: 6px 10px;
border-radius: 8px;
font-weight: 600;
pointer-events: none;
opacity: 0;
transition: opacity .15s;
}
.player {
position: relative;
}
/* ensure OSD positions over the video */
</style>
<script>
function openFolder(filePath) {
fetch('/open-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath })
}).then(res => res.json()).then(data => {
if (data.success) {
console.log("Opened folder successfully");
} else {
alert("Failed to open folder");
}
});
}
</script>
<script>
function deleteFile(filePath, videoId) {
if (!confirm("Are you sure you want to permanently delete this file?")) return;
fetch('/delete-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath, video_id: videoId })
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert("File deleted successfully");
// redirect back to the user page
window.location.href = "/user/{{ video.username }}";
} else {
alert("Failed to delete: " + (data.error || 'Unknown error'));
}
})
.catch(err => alert("Error: " + err));
}
</script>
<script>
document.addEventListener('click', async (e) => {
const btn = e.target.closest('#favBtn');
if (!btn) return;
const vid = btn.dataset.videoId;
try {
const res = await fetch(`/api/fav/toggle/${encodeURIComponent(vid)}`, { method: 'POST' });
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || 'Failed');
const isFav = data.is_favorite;
btn.textContent = isFav ? '★' : '☆';
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
btn.title = isFav ? 'Unfavorite' : 'Favorite';
} catch (err) {
alert('Could not toggle favorite. ' + (err?.message || ''));
}
// If a number key (09) is pressed (without modifiers), jump to that %.
if (!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
// e.key is '0'..'9' for main row; it also works for numpad if you check code:
if (/^[0-9]$/.test(e.key)) {
e.preventDefault();
const digit = Number(e.key); // 0..9
const pct = digit * 10; // 0->0%, 1->10%, ..., 9->90%
jumpToPercent(pct);
return; // stop further handling
}
}
});
</script>
</head>
<body>
<h1>{{ video.username }} - {{ video.site }}</h1>
<div>
<a href="/user/{{ video.username }}">⬅ Back to {{ video.username }}'s Videos</a>
</div>
<div class="player">
<video id="player" poster="/{{ video.thumbnail }}" muted>
<source src="/video/stream/{{ video.video_id }}" type="video/mp4">
</video>
<div class="controls">
<button id="playPause">▶️</button>
<span class="time" id="currentTime">00:00</span>
<input id="seek" class="range" type="range" min="0" max="0" value="0" step="0.1">
<span class="time" id="duration">00:00</span>
<button id="muteBtn">🔊</button>
<button id="addMoment" title="Add moment"></button>
</div>
</div>
<div id="osd" class="osd"></div>
<div class="moments">
<h3>Moments</h3>
<div id="momentsList"></div>
</div>
<div class="moments">
<h3>Moments</h3>
<div id="momentsList"></div>
</div>
<div class="meta">
<button onclick="openFolder('{{ video.filepath }}')">Open Folder</button>
<button onclick="deleteFile('{{ video.filepath }}','{{ video.video_id }}')" style="margin-left:8px">Delete
File</button>
<!-- Favorite star -->
<button id="favBtn" class="fav-btn" data-video-id="{{ video.video_id }}"
aria-pressed="{{ 'true' if video.is_favorite else 'false' }}"
title="{{ 'Unfavorite' if video.is_favorite else 'Favorite' }}" style="margin-left:8px">
{{ '★' if video.is_favorite else '☆' }}
</button>
</div>
<div class="meta">
<a href="{{ url_for('web.user_page', username=video.username) }}" class="username-link">
{{ video.username }}
</a>
<p><strong>Size:</strong> {{ "%.2f"|format(video.size/1024) }} GB</p>
<p><strong>Duration:</strong> {{ video.duration if video.duration else "Unknown" }}</p>
<p><strong>Path:</strong> {{ video.filepath }}</p>
<p><strong>Date:</strong> {{ video.created_at }}</p>
</div>
<script>
(function () {
const video = document.getElementById('player');
const playPause = document.getElementById('playPause');
const seek = document.getElementById('seek');
const cur = document.getElementById('currentTime');
const dur = document.getElementById('duration');
const muteBtn = document.getElementById('muteBtn');
const addBtn = document.getElementById('addMoment');
const list = document.getElementById('momentsList');
const osd = document.getElementById('osd');
const videoId = "{{ video.video_id }}";
function fmt(t) {
t = Math.max(0, Math.floor(t || 0));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
return (h > 0 ? String(h).padStart(2, '0') + ':' : '') +
String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
}
function showOSD(text) {
if (!osd) return;
osd.textContent = text;
osd.style.opacity = '1';
clearTimeout(showOSD._t);
showOSD._t = setTimeout(() => { osd.style.opacity = '0'; }, 600);
}
function jumpToPercent(percent) {
if (!isFinite(video.duration) || video.duration <= 0) return;
const t = (percent / 100) * video.duration;
video.currentTime = Math.min(Math.max(t, 0), video.duration);
showOSD(`${percent}%`);
}
// --- playback controls ---
playPause.addEventListener('click', () => {
if (video.paused) { video.play(); } else { video.pause(); }
});
video.addEventListener('play', () => playPause.textContent = '⏸');
video.addEventListener('pause', () => playPause.textContent = '▶️');
video.addEventListener('loadedmetadata', () => {
seek.max = video.duration || 0;
dur.textContent = fmt(video.duration);
});
video.addEventListener('timeupdate', () => {
cur.textContent = fmt(video.currentTime);
seek.value = video.currentTime;
});
seek.addEventListener('input', () => { video.currentTime = Number(seek.value); });
muteBtn.addEventListener('click', () => {
video.muted = !video.muted;
muteBtn.textContent = video.muted ? '🔇' : '🔊';
});
// --- keyboard shortcuts ---
document.addEventListener('keydown', e => {
// don't hijack when typing in inputs/textareas
const tag = (e.target.tagName || '').toUpperCase();
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.isComposing) return;
// Number keys 09 (top row) → jump to 090%
if (!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && /^[0-9]$/.test(e.key)) {
e.preventDefault();
jumpToPercent(Number(e.key) * 10);
return;
}
// Numpad 09 → same behavior
if (/^Numpad[0-9]$/.test(e.code)) {
e.preventDefault();
jumpToPercent(Number(e.code.replace('Numpad', '')) * 10);
return;
}
switch (e.code) {
case "Space": e.preventDefault(); video.paused ? video.play() : video.pause(); break;
case "ArrowRight": video.currentTime = Math.min(video.duration || Infinity, video.currentTime + 5); break;
case "ArrowLeft": video.currentTime = Math.max(0, video.currentTime - 5); break;
case "ArrowUp": video.volume = Math.min(1, (video.volume || 0) + 0.1); showOSD(`${Math.round(video.volume * 100)}%`); break;
case "ArrowDown": video.volume = Math.max(0, (video.volume || 0) - 0.1); showOSD(`${Math.round(video.volume * 100)}%`); break;
case "KeyM": video.muted = !video.muted; muteBtn.textContent = video.muted ? '🔇' : '🔊'; showOSD(video.muted ? 'Muted' : 'Unmuted'); break;
}
});
// --- moments ---
async function loadMoments() {
list.innerHTML = '';
try {
const res = await fetch(`/api/moments/${encodeURIComponent(videoId)}`);
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'Failed');
if (data.moments.length === 0) { list.textContent = 'No moments yet.'; return; }
data.moments.forEach(m => {
const pill = document.createElement('button');
pill.className = 'moment-pill';
pill.textContent = fmt(m.timestamp);
pill.onclick = () => { video.currentTime = m.timestamp; video.play(); };
list.appendChild(pill);
});
} catch {
list.textContent = 'Could not load moments.';
}
}
async function addMoment() {
const ts = Math.floor(video.currentTime || 0);
try {
const res = await fetch(`/api/moments/${encodeURIComponent(videoId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timestamp: ts })
});
const data = await res.json();
if (!data.ok) throw new Error();
const pill = document.createElement('button');
pill.className = 'moment-pill';
pill.textContent = fmt(ts);
pill.onclick = () => { video.currentTime = ts; video.play(); };
if (list.textContent === 'No moments yet.') list.textContent = '';
list.appendChild(pill);
} catch {
alert('Could not add moment');
}
}
addBtn.addEventListener('click', addMoment);
loadMoments();
})();
</script>
</body>
</html>