|
|
<!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 (0–9) 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 0–9 (top row) → jump to 0–90%
|
|
|
if (!e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && /^[0-9]$/.test(e.key)) {
|
|
|
e.preventDefault();
|
|
|
jumpToPercent(Number(e.key) * 10);
|
|
|
return;
|
|
|
}
|
|
|
// Numpad 0–9 → 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> |