|
|
|
@ -60,6 +60,87 @@
|
|
|
|
}
|
|
|
|
}
|
|
|
|
</style>
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
|
|
|
.player {
|
|
|
|
|
|
|
|
margin: 20px auto;
|
|
|
|
|
|
|
|
width: 80%;
|
|
|
|
|
|
|
|
max-width: 1000px;
|
|
|
|
|
|
|
|
background: #000;
|
|
|
|
|
|
|
|
border: 2px solid #444;
|
|
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.player video {
|
|
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.controls {
|
|
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
|
|
background: #1b1b1b;
|
|
|
|
|
|
|
|
padding: 10px;
|
|
|
|
|
|
|
|
border-top: 1px solid #333;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.controls button,
|
|
|
|
|
|
|
|
.controls select,
|
|
|
|
|
|
|
|
.controls input[type="range"] {
|
|
|
|
|
|
|
|
background: #222;
|
|
|
|
|
|
|
|
color: #eee;
|
|
|
|
|
|
|
|
border: 1px solid #444;
|
|
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
|
|
padding: 6px 8px;
|
|
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.controls button:hover {
|
|
|
|
|
|
|
|
background: #2a2a2a;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.time {
|
|
|
|
|
|
|
|
font-variant-numeric: tabular-nums;
|
|
|
|
|
|
|
|
min-width: 90px;
|
|
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.range {
|
|
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.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.95rem;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.moment-pill:hover {
|
|
|
|
|
|
|
|
background: #103553;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
<script>
|
|
|
|
function openFolder(filePath) {
|
|
|
|
function openFolder(filePath) {
|
|
|
|
fetch('/open-folder', {
|
|
|
|
fetch('/open-folder', {
|
|
|
|
@ -101,8 +182,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
<script>
|
|
|
|
// existing openFolder(...) here
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('click', async (e) => {
|
|
|
|
document.addEventListener('click', async (e) => {
|
|
|
|
const btn = e.target.closest('#favBtn');
|
|
|
|
const btn = e.target.closest('#favBtn');
|
|
|
|
if (!btn) return;
|
|
|
|
if (!btn) return;
|
|
|
|
@ -131,10 +210,37 @@
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<a href="/user/{{ video.username }}">⬅ Back to {{ video.username }}'s Videos</a>
|
|
|
|
<a href="/user/{{ video.username }}">⬅ Back to {{ video.username }}'s Videos</a>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<video controls muted>
|
|
|
|
|
|
|
|
<source src="/video/stream/{{ video.video_id }}" type="video/mp4" poster="{{ video.thumbnail }}">
|
|
|
|
<div class="player">
|
|
|
|
Your browser does not support the video tag.
|
|
|
|
<video id="player" muted poster="{{ video.thumbnail }}">
|
|
|
|
</video>
|
|
|
|
<source src="/video/stream/{{ video.video_id }}" type="video/mp4">
|
|
|
|
|
|
|
|
Your browser does not support the video tag.
|
|
|
|
|
|
|
|
</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="1">
|
|
|
|
|
|
|
|
<span class="time" id="duration">00:00</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<input id="volume" type="range" min="0" max="1" step="0.01" value="1" title="Volume">
|
|
|
|
|
|
|
|
<select id="speed" title="Speed">
|
|
|
|
|
|
|
|
<option value="0.5">0.5×</option>
|
|
|
|
|
|
|
|
<option value="0.75">0.75×</option>
|
|
|
|
|
|
|
|
<option value="1" selected>1×</option>
|
|
|
|
|
|
|
|
<option value="1.25">1.25×</option>
|
|
|
|
|
|
|
|
<option value="1.5">1.5×</option>
|
|
|
|
|
|
|
|
<option value="2">2×</option>
|
|
|
|
|
|
|
|
</select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<button id="addMoment" title="Add moment">➕ Add Moment</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="moments">
|
|
|
|
|
|
|
|
<h3>Moments</h3>
|
|
|
|
|
|
|
|
<div id="momentsList"></div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="meta">
|
|
|
|
<div class="meta">
|
|
|
|
<button onclick="openFolder('{{ video.filepath }}')">Open Folder</button>
|
|
|
|
<button onclick="openFolder('{{ video.filepath }}')">Open Folder</button>
|
|
|
|
@ -158,6 +264,122 @@
|
|
|
|
<p><strong>Path:</strong> {{ video.filepath }}</p>
|
|
|
|
<p><strong>Path:</strong> {{ video.filepath }}</p>
|
|
|
|
<p><strong>Date:</strong> {{ video.created_at }}</p>
|
|
|
|
<p><strong>Date:</strong> {{ video.created_at }}</p>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
|
|
const video = document.getElementById('player');
|
|
|
|
|
|
|
|
const playPause = document.getElementById('playPause');
|
|
|
|
|
|
|
|
const seek = document.getElementById('seek');
|
|
|
|
|
|
|
|
const vol = document.getElementById('volume');
|
|
|
|
|
|
|
|
const speed = document.getElementById('speed');
|
|
|
|
|
|
|
|
const cur = document.getElementById('currentTime');
|
|
|
|
|
|
|
|
const dur = document.getElementById('duration');
|
|
|
|
|
|
|
|
const addBtn = document.getElementById('addMoment');
|
|
|
|
|
|
|
|
const list = document.getElementById('momentsList');
|
|
|
|
|
|
|
|
const videoId = "{{ video.video_id }}";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// format seconds → hh:mm:ss
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// load existing 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');
|
|
|
|
|
|
|
|
for (const m of data.moments) {
|
|
|
|
|
|
|
|
const pill = document.createElement('button');
|
|
|
|
|
|
|
|
pill.className = 'moment-pill';
|
|
|
|
|
|
|
|
pill.textContent = fmt(m.timestamp);
|
|
|
|
|
|
|
|
pill.dataset.ts = m.timestamp;
|
|
|
|
|
|
|
|
pill.addEventListener('click', () => {
|
|
|
|
|
|
|
|
video.currentTime = Number(pill.dataset.ts);
|
|
|
|
|
|
|
|
video.play();
|
|
|
|
|
|
|
|
playPause.textContent = '⏸';
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
list.appendChild(pill);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (list.children.length === 0) {
|
|
|
|
|
|
|
|
list.textContent = 'No moments yet.';
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
list.textContent = 'Could not load moments.';
|
|
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// add new moment at current time
|
|
|
|
|
|
|
|
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(data.error || 'Failed');
|
|
|
|
|
|
|
|
// append immediately
|
|
|
|
|
|
|
|
const pill = document.createElement('button');
|
|
|
|
|
|
|
|
pill.className = 'moment-pill';
|
|
|
|
|
|
|
|
pill.textContent = fmt(ts);
|
|
|
|
|
|
|
|
pill.dataset.ts = ts;
|
|
|
|
|
|
|
|
pill.addEventListener('click', () => {
|
|
|
|
|
|
|
|
video.currentTime = ts;
|
|
|
|
|
|
|
|
video.play();
|
|
|
|
|
|
|
|
playPause.textContent = '⏸';
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
if (list.textContent === 'No moments yet.') list.textContent = '';
|
|
|
|
|
|
|
|
list.appendChild(pill);
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
|
|
alert('Could not add moment: ' + (e?.message || ''));
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// player controls
|
|
|
|
|
|
|
|
playPause.addEventListener('click', () => {
|
|
|
|
|
|
|
|
if (video.paused) { video.play(); playPause.textContent = '⏸'; }
|
|
|
|
|
|
|
|
else { video.pause(); playPause.textContent = '▶️'; }
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
video.addEventListener('play', () => playPause.textContent = '⏸');
|
|
|
|
|
|
|
|
video.addEventListener('pause', () => playPause.textContent = '▶️');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
video.addEventListener('loadedmetadata', () => {
|
|
|
|
|
|
|
|
seek.max = Math.floor(video.duration || 0);
|
|
|
|
|
|
|
|
dur.textContent = fmt(video.duration);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
video.addEventListener('timeupdate', () => {
|
|
|
|
|
|
|
|
cur.textContent = fmt(video.currentTime);
|
|
|
|
|
|
|
|
if (!seek.matches(':active')) {
|
|
|
|
|
|
|
|
seek.value = Math.floor(video.currentTime || 0);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
seek.addEventListener('input', () => {
|
|
|
|
|
|
|
|
video.currentTime = Number(seek.value || 0);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
vol.addEventListener('input', () => video.volume = Number(vol.value));
|
|
|
|
|
|
|
|
speed.addEventListener('change', () => video.playbackRate = Number(speed.value));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
addBtn.addEventListener('click', addMoment);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// kick off
|
|
|
|
|
|
|
|
loadMoments();
|
|
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
</body>
|
|
|
|
</body>
|
|
|
|
|
|
|
|
|
|
|
|
</html>
|
|
|
|
</html>
|