Files
malabar/webnode/views/episode.ejs

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>