565 lines
23 KiB
Plaintext
565 lines
23 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<title><%= currentEpisode.title %></title>
|
|
<link rel="stylesheet" href="/style.css">
|
|
</head>
|
|
<body>
|
|
<!-- Navbar -->
|
|
<nav class="navbar">
|
|
<div class="nav-left">
|
|
<button class="nav-btn" id="translateBtn" title="Toggle Translation">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
|
<path d="M12 20h9"/>
|
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
|
|
</svg>
|
|
</button>
|
|
<button class="nav-btn" id="goToBtn" title="Go to Current Line">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2.5">
|
|
<path d="M5 12h14"/>
|
|
<path d="M12 5l7 7-7 7"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="nav-center">
|
|
<button class="nav-btn" id="firstBtn" title="First Line">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
|
<path d="M11 18l-6-6 6-6"/>
|
|
<path d="M4 18V6"/>
|
|
</svg>
|
|
</button>
|
|
<button class="nav-btn" id="prevBtn" title="Previous Line">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
|
<path d="M15 18l-6-6 6-6"/>
|
|
</svg>
|
|
</button>
|
|
<span class="nav-title" id="timeDisplay">00:00</span>
|
|
<button class="nav-btn" id="nextBtn" title="Next Line">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
|
<path d="M9 18l6-6-6-6"/>
|
|
</svg>
|
|
</button>
|
|
<button class="nav-btn" id="lastBtn" title="Last Line">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
|
<path d="M13 18l6-6-6-6"/>
|
|
<path d="M20 18V6"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="nav-right">
|
|
<button class="nav-btn" id="playPauseBtn" title="Play/Pause">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" id="playIcon">
|
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
</svg>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" id="pauseIcon" style="display: none;">
|
|
<rect x="6" y="4" width="4" height="16"/>
|
|
<rect x="14" y="4" width="4" height="16"/>
|
|
</svg>
|
|
</button>
|
|
<button class="nav-btn" id="menuBtn" title="Episodes">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
|
<path d="M3 12h18"/>
|
|
<path d="M3 6h18"/>
|
|
<path d="M3 18h18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Main Content -->
|
|
<main class="main-content">
|
|
<div class="lines-container">
|
|
<% if (lines && lines.length > 0) { %>
|
|
<% lines.forEach((line, index) => { %>
|
|
<div class="line"
|
|
data-index="<%= index %>"
|
|
data-chinese="<%= escapeHtml(line.chinese) %>"
|
|
data-timestamp="<%= escapeHtml(line.timestamp) %>"
|
|
data-speaker="<%= escapeHtml(line.speaker) %>"
|
|
data-time-seconds="<%= line.timeSeconds %>">
|
|
<div class="blur-overlay"></div>
|
|
<div class="english" style="color: <%= colors[line.speaker] || '#777777' %>">
|
|
<%= line.english %>
|
|
</div>
|
|
</div>
|
|
<% }) %>
|
|
<% } else { %>
|
|
<div class="empty-state">
|
|
<p>No lines found in this episode.</p>
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Side Menu Overlay -->
|
|
<div class="side-menu-overlay" id="sideMenuOverlay"></div>
|
|
|
|
<!-- Side Menu -->
|
|
<aside class="side-menu" id="sideMenu">
|
|
<div class="side-menu-header">
|
|
<span class="side-menu-title">Episodes</span>
|
|
<button class="close-btn" id="closeMenuBtn">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke-width="2">
|
|
<path d="M18 6L6 18"/>
|
|
<path d="M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="episode-list">
|
|
<% episodes.forEach(ep => { %>
|
|
<a href="/episode/<%= ep.id %>"
|
|
class="episode-item <%= currentEpisode.id === ep.id ? 'active' : '' %>">
|
|
<span class="episode-id"><%= ep.title %></span>
|
|
</a>
|
|
<% }) %>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- JavaScript for interactions -->
|
|
<script>
|
|
(function() {
|
|
// DOM Elements
|
|
const translateBtn = document.getElementById('translateBtn');
|
|
const menuBtn = document.getElementById('menuBtn');
|
|
const closeMenuBtn = document.getElementById('closeMenuBtn');
|
|
const sideMenu = document.getElementById('sideMenu');
|
|
const sideMenuOverlay = document.getElementById('sideMenuOverlay');
|
|
const lines = document.querySelectorAll('.line');
|
|
const timeDisplay = document.getElementById('timeDisplay');
|
|
const playPauseBtn = document.getElementById('playPauseBtn');
|
|
const playIcon = document.getElementById('playIcon');
|
|
const pauseIcon = document.getElementById('pauseIcon');
|
|
const goToBtn = document.getElementById('goToBtn');
|
|
const prevBtn = document.getElementById('prevBtn');
|
|
const nextBtn = document.getElementById('nextBtn');
|
|
const firstBtn = document.getElementById('firstBtn');
|
|
const lastBtn = document.getElementById('lastBtn');
|
|
|
|
let selectedLine = null;
|
|
let expandedLine = null;
|
|
let isPlaying = false;
|
|
let currentTime = 0;
|
|
let playbackInterval = null;
|
|
let currentLineIndex = 0;
|
|
let lastTimestamp = Date.now();
|
|
|
|
// Parse time from seconds to mm:ss
|
|
function formatTime(seconds) {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// Get time in seconds from line element
|
|
function getLineTimeSeconds(line) {
|
|
return parseFloat(line.dataset.timeSeconds) || 0;
|
|
}
|
|
|
|
// Find current line index based on time
|
|
function findCurrentLineIndex(time) {
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const lineTime = getLineTimeSeconds(lines[i]);
|
|
if (lineTime <= time) {
|
|
return i;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// Update blur states based on current line
|
|
function updateBlurStates() {
|
|
// Cancel any active drag when updating blur states
|
|
activeDrag = null;
|
|
|
|
lines.forEach((line, index) => {
|
|
if (index < currentLineIndex) {
|
|
// Lines before current are clear
|
|
line.classList.remove('blurred');
|
|
line.classList.add('clear');
|
|
} else {
|
|
// Current line and after are blurred
|
|
line.classList.add('blurred');
|
|
line.classList.remove('clear');
|
|
// Reset blur overlay position when blur is reapplied
|
|
const blurOverlay = line.querySelector('.blur-overlay');
|
|
if (blurOverlay) {
|
|
blurOverlay.style.transform = '';
|
|
blurOverlay.style.opacity = '';
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update visual selection
|
|
function updateSelection(index) {
|
|
lines.forEach(l => l.classList.remove('selected'));
|
|
if (index >= 0 && index < lines.length) {
|
|
lines[index].classList.add('selected');
|
|
selectedLine = lines[index];
|
|
currentLineIndex = index;
|
|
}
|
|
}
|
|
|
|
// Scroll line into view
|
|
function scrollToLine(line) {
|
|
line.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
// Update time display
|
|
function updateTimeDisplay() {
|
|
timeDisplay.textContent = formatTime(currentTime);
|
|
}
|
|
|
|
// Pause playback
|
|
function pausePlayback() {
|
|
if (!isPlaying) return;
|
|
isPlaying = false;
|
|
clearInterval(playbackInterval);
|
|
playIcon.style.display = 'block';
|
|
pauseIcon.style.display = 'none';
|
|
}
|
|
|
|
// Resume playback
|
|
function resumePlayback() {
|
|
if (isPlaying) return;
|
|
isPlaying = true;
|
|
lastTimestamp = Date.now();
|
|
playIcon.style.display = 'none';
|
|
pauseIcon.style.display = 'block';
|
|
|
|
playbackInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
const delta = (now - lastTimestamp) / 1000;
|
|
lastTimestamp = now;
|
|
|
|
currentTime += delta;
|
|
updateTimeDisplay();
|
|
|
|
// Check if we need to advance to next line
|
|
const nextIndex = currentLineIndex + 1;
|
|
if (nextIndex < lines.length) {
|
|
const nextLineTime = getLineTimeSeconds(lines[nextIndex]);
|
|
if (currentTime >= nextLineTime) {
|
|
// Time reached next line - update without pausing
|
|
currentLineIndex = nextIndex;
|
|
updateSelection(currentLineIndex);
|
|
updateBlurStates();
|
|
scrollToLine(lines[currentLineIndex]);
|
|
}
|
|
} else {
|
|
// End of episode
|
|
pausePlayback();
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
// Toggle play/pause
|
|
function togglePlayPause() {
|
|
if (isPlaying) {
|
|
pausePlayback();
|
|
} else {
|
|
resumePlayback();
|
|
}
|
|
}
|
|
|
|
// Go to current line's timestamp
|
|
function goToCurrentLine() {
|
|
if (selectedLine) {
|
|
// Pause playback when going to a specific line
|
|
pausePlayback();
|
|
currentTime = getLineTimeSeconds(selectedLine);
|
|
updateTimeDisplay();
|
|
// Update blur states per design 2.5
|
|
const selectedIndex = parseInt(selectedLine.dataset.index);
|
|
currentLineIndex = selectedIndex;
|
|
updateBlurStates();
|
|
scrollToLine(selectedLine);
|
|
}
|
|
}
|
|
|
|
// Go to previous line
|
|
function goToPreviousLine() {
|
|
if (lines.length === 0) return;
|
|
pausePlayback();
|
|
const newIndex = Math.max(0, currentLineIndex - 1);
|
|
currentLineIndex = newIndex;
|
|
updateSelection(currentLineIndex);
|
|
updateBlurStates();
|
|
const targetLine = lines[currentLineIndex];
|
|
currentTime = getLineTimeSeconds(targetLine);
|
|
updateTimeDisplay();
|
|
scrollToLine(targetLine);
|
|
}
|
|
|
|
// Go to next line
|
|
function goToNextLine() {
|
|
if (lines.length === 0) return;
|
|
pausePlayback();
|
|
const newIndex = Math.min(lines.length - 1, currentLineIndex + 1);
|
|
currentLineIndex = newIndex;
|
|
updateSelection(currentLineIndex);
|
|
updateBlurStates();
|
|
const targetLine = lines[currentLineIndex];
|
|
currentTime = getLineTimeSeconds(targetLine);
|
|
updateTimeDisplay();
|
|
scrollToLine(targetLine);
|
|
}
|
|
|
|
// Go to first line
|
|
function goToFirstLine() {
|
|
if (lines.length === 0) return;
|
|
pausePlayback();
|
|
currentLineIndex = 0;
|
|
updateSelection(0);
|
|
updateBlurStates();
|
|
const targetLine = lines[0];
|
|
currentTime = getLineTimeSeconds(targetLine);
|
|
updateTimeDisplay();
|
|
scrollToLine(targetLine);
|
|
}
|
|
|
|
// Go to last line
|
|
function goToLastLine() {
|
|
if (lines.length === 0) return;
|
|
pausePlayback();
|
|
const lastIndex = lines.length - 1;
|
|
currentLineIndex = lastIndex;
|
|
updateSelection(lastIndex);
|
|
updateBlurStates();
|
|
const targetLine = lines[lastIndex];
|
|
currentTime = getLineTimeSeconds(targetLine);
|
|
updateTimeDisplay();
|
|
scrollToLine(targetLine);
|
|
}
|
|
|
|
// Interactive drag-to-reveal for blur overlay
|
|
const SWIPE_THRESHOLD = 160; // pixels to trigger reveal
|
|
let activeDrag = null; // Track which line is being dragged
|
|
|
|
// Initialize - all lines blurred by default
|
|
updateBlurStates();
|
|
|
|
lines.forEach((line, index) => {
|
|
let startX = 0;
|
|
let currentX = 0;
|
|
let blurOverlay = line.querySelector('.blur-overlay');
|
|
|
|
line.addEventListener('click', function(e) {
|
|
// Don't trigger selection if we just finished a drag
|
|
if (line._didDrag) {
|
|
line._didDrag = false;
|
|
return;
|
|
}
|
|
updateSelection(index);
|
|
});
|
|
|
|
// Touch events
|
|
line.addEventListener('touchstart', function(e) {
|
|
if (!line.classList.contains('blurred')) return;
|
|
activeDrag = line;
|
|
line._didDrag = false;
|
|
startX = e.touches[0].clientX;
|
|
currentX = startX;
|
|
line.style.transition = 'none';
|
|
blurOverlay.style.transition = 'none';
|
|
}, { passive: true });
|
|
|
|
line.addEventListener('touchmove', function(e) {
|
|
if (activeDrag !== line) return;
|
|
currentX = e.touches[0].clientX;
|
|
const deltaX = currentX - startX;
|
|
|
|
// Mark as drag if moved more than 5px
|
|
if (Math.abs(deltaX) > 5) {
|
|
line._didDrag = true;
|
|
}
|
|
|
|
// Move blur overlay with finger - no limit, follows finger exactly
|
|
blurOverlay.style.transform = `translateX(${deltaX}px)`;
|
|
}, { passive: true });
|
|
|
|
line.addEventListener('touchend', function(e) {
|
|
if (activeDrag !== line) return;
|
|
const deltaX = currentX - startX;
|
|
endDrag(line, blurOverlay, deltaX);
|
|
}, { passive: true });
|
|
|
|
line.addEventListener('touchcancel', function(e) {
|
|
if (activeDrag !== line) return;
|
|
const deltaX = currentX - startX;
|
|
endDrag(line, blurOverlay, deltaX);
|
|
}, { passive: true });
|
|
|
|
// Mouse events for desktop
|
|
line.addEventListener('mousedown', function(e) {
|
|
if (!line.classList.contains('blurred')) return;
|
|
activeDrag = line;
|
|
line._didDrag = false;
|
|
line._startX = e.clientX;
|
|
line._currentX = line._startX;
|
|
line.style.transition = 'none';
|
|
blurOverlay.style.transition = 'none';
|
|
e.preventDefault();
|
|
});
|
|
|
|
// Store line reference on element for global handlers
|
|
line._blurOverlay = blurOverlay;
|
|
line._startX = 0;
|
|
line._currentX = 0;
|
|
line._didDrag = false;
|
|
});
|
|
|
|
// Global mouse handlers (single instance)
|
|
document.addEventListener('mousemove', function(e) {
|
|
if (!activeDrag) return;
|
|
const line = activeDrag;
|
|
const blurOverlay = line._blurOverlay;
|
|
line._currentX = e.clientX;
|
|
const deltaX = line._currentX - line._startX;
|
|
|
|
// Mark as drag if moved more than 5px
|
|
if (Math.abs(deltaX) > 5) {
|
|
line._didDrag = true;
|
|
}
|
|
|
|
// Move blur overlay with mouse - no limit, follows cursor exactly
|
|
blurOverlay.style.transform = `translateX(${deltaX}px)`;
|
|
});
|
|
|
|
document.addEventListener('mouseup', function(e) {
|
|
if (!activeDrag) return;
|
|
const line = activeDrag;
|
|
const blurOverlay = line._blurOverlay;
|
|
const deltaX = line._currentX - line._startX;
|
|
endDrag(line, blurOverlay, deltaX);
|
|
});
|
|
|
|
function endDrag(line, blurOverlay, deltaX) {
|
|
activeDrag = null;
|
|
|
|
// Restore transitions
|
|
line.style.transition = '';
|
|
blurOverlay.style.transition = 'transform 0.3s ease';
|
|
|
|
if (Math.abs(deltaX) >= SWIPE_THRESHOLD) {
|
|
// Threshold reached - animate blur layer fully away then reveal
|
|
const direction = deltaX > 0 ? 1 : -1;
|
|
blurOverlay.style.transform = `translateX(${direction * window.innerWidth}px)`;
|
|
|
|
setTimeout(() => {
|
|
line.classList.remove('blurred');
|
|
line.classList.add('clear');
|
|
blurOverlay.style.transform = '';
|
|
}, 300);
|
|
} else {
|
|
// Not past threshold - snap back to initial state
|
|
blurOverlay.style.transform = 'translateX(0)';
|
|
// Reset transform after animation completes
|
|
setTimeout(() => {
|
|
if (line.classList.contains('blurred')) {
|
|
blurOverlay.style.transform = '';
|
|
}
|
|
}, 300);
|
|
}
|
|
}
|
|
|
|
// Toggle translation on selected line
|
|
translateBtn.addEventListener('click', function() {
|
|
if (!selectedLine) {
|
|
if (lines.length > 0) {
|
|
updateSelection(0);
|
|
}
|
|
}
|
|
|
|
if (selectedLine) {
|
|
const translationCard = selectedLine.querySelector('.translation-card');
|
|
|
|
if (translationCard) {
|
|
translationCard.remove();
|
|
expandedLine = null;
|
|
} else {
|
|
if (expandedLine) {
|
|
const prevCard = expandedLine.querySelector('.translation-card');
|
|
if (prevCard) prevCard.remove();
|
|
}
|
|
|
|
const chineseText = selectedLine.dataset.chinese;
|
|
const timestamp = selectedLine.dataset.timestamp;
|
|
const speaker = selectedLine.dataset.speaker;
|
|
if (chineseText) {
|
|
const card = document.createElement('div');
|
|
card.className = 'translation-card';
|
|
card.innerHTML = `
|
|
<div class="meta-info">
|
|
<span class="meta-timestamp">${escapeHtml(timestamp)}</span>
|
|
<span class="meta-speaker">${escapeHtml(speaker)}</span>
|
|
</div>
|
|
<div class="translation-label">中文</div>
|
|
<div class="chinese">${escapeHtml(chineseText)}</div>
|
|
`;
|
|
selectedLine.appendChild(card);
|
|
expandedLine = selectedLine;
|
|
|
|
setTimeout(() => {
|
|
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}, 100);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Play/Pause button
|
|
playPauseBtn.addEventListener('click', togglePlayPause);
|
|
|
|
// Go to button
|
|
goToBtn.addEventListener('click', goToCurrentLine);
|
|
|
|
// Navigation buttons
|
|
prevBtn.addEventListener('click', goToPreviousLine);
|
|
nextBtn.addEventListener('click', goToNextLine);
|
|
firstBtn.addEventListener('click', goToFirstLine);
|
|
lastBtn.addEventListener('click', goToLastLine);
|
|
|
|
// Open side menu
|
|
function openMenu() {
|
|
sideMenu.classList.add('active');
|
|
sideMenuOverlay.classList.add('active');
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
// Close side menu
|
|
function closeMenu() {
|
|
sideMenu.classList.remove('active');
|
|
sideMenuOverlay.classList.remove('active');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
menuBtn.addEventListener('click', openMenu);
|
|
closeMenuBtn.addEventListener('click', closeMenu);
|
|
sideMenuOverlay.addEventListener('click', closeMenu);
|
|
|
|
// Helper: Escape HTML to prevent XSS
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Close menu on escape key
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
closeMenu();
|
|
}
|
|
});
|
|
|
|
// Select first line by default
|
|
if (lines.length > 0) {
|
|
updateSelection(0);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|