Blurred layer

This commit is contained in:
2026-03-05 17:35:03 +08:00
parent 67ef8c3689
commit 323d14301d
3 changed files with 455 additions and 44 deletions

View File

@@ -5,14 +5,24 @@
box-sizing: border-box;
}
html, body {
/* Blur and opacity settings - tweak these values */
:root {
--blur-intensity: 4px; /* Blur amount (0px = no blur) */
--blur-overlay-opacity: 0.5; /* White overlay opacity (0 = transparent, 1 = solid white) */
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif;
font-size: 16px;
line-height: 1.5;
background: #fff;
color: #333;
overflow-x: hidden;
touch-action: pan-y;
}
/* Navbar */
@@ -27,10 +37,17 @@ html, body {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
padding: 0 12px;
z-index: 1000;
}
.nav-left,
.nav-right {
display: flex;
align-items: center;
gap: 4px;
}
.nav-btn {
width: 40px;
height: 40px;
@@ -42,6 +59,7 @@ html, body {
justify-content: center;
border-radius: 8px;
transition: background 0.2s;
flex-shrink: 0;
}
.nav-btn:hover {
@@ -53,15 +71,25 @@ html, body {
}
.nav-btn svg {
width: 24px;
height: 24px;
width: 22px;
height: 22px;
stroke: #555;
fill: none;
}
.nav-btn svg polygon {
fill: #555;
stroke: none;
}
.nav-title {
font-size: 18px;
font-size: 16px;
font-weight: 600;
color: #333;
font-family: monospace;
padding: 0 8px;
min-width: 60px;
text-align: center;
}
/* Main content area */
@@ -77,10 +105,14 @@ html, body {
/* Individual line */
.line {
position: relative;
padding: 10px 16px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid #f0f0f0;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
.line:hover {
@@ -91,10 +123,50 @@ html, body {
background: #e3f2fd;
}
/* Blur overlay */
.blur-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
backdrop-filter: blur(var(--blur-intensity));
-webkit-backdrop-filter: blur(var(--blur-intensity));
background: rgba(255, 255, 255, var(--blur-overlay-opacity));
opacity: 0;
visibility: hidden;
transition:
opacity 0.3s ease,
visibility 0.3s ease,
transform 0.3s ease;
z-index: 5;
pointer-events: none;
will-change: transform, opacity;
}
/* Blurred state */
.line.blurred .blur-overlay {
opacity: 1;
visibility: visible;
}
/* Clear state */
.line.clear .blur-overlay {
opacity: 0;
visibility: hidden;
}
.line.clear .english {
filter: none;
-webkit-filter: none;
}
.english {
position: relative;
font-size: 16px;
line-height: 1.5;
color: inherit;
z-index: 1;
}
/* Translation card (expanded state) */
@@ -105,6 +177,8 @@ html, body {
border-radius: 8px;
border-left: 3px solid #ffc107;
animation: slideDown 0.2s ease-out;
position: relative;
z-index: 10;
}
.meta-info {
@@ -163,7 +237,9 @@ html, body {
background: rgba(0, 0, 0, 0.5);
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
transition:
opacity 0.3s,
visibility 0.3s;
z-index: 2000;
}
@@ -226,6 +302,7 @@ html, body {
width: 24px;
height: 24px;
stroke: #555;
fill: none;
}
.episode-list {
@@ -268,14 +345,28 @@ html, body {
.line {
padding: 12px 16px;
}
.english {
font-size: 17px;
}
.chinese {
font-size: 18px;
}
.nav-btn {
width: 36px;
height: 36px;
}
.nav-btn svg {
width: 20px;
height: 20px;
}
.nav-title {
font-size: 15px;
}
}
/* Hide scrollbar but keep functionality */

View File

@@ -78,6 +78,20 @@ function loadEpisode(episodeId) {
return null;
}
/**
* Parse timestamp string [mm:ss] to seconds.
*/
function parseTimestamp(timestamp) {
if (!timestamp) return 0;
const match = timestamp.match(/\[(\d+):(\d+)\]/);
if (match) {
const minutes = parseInt(match[1], 10);
const seconds = parseInt(match[2], 10);
return minutes * 60 + seconds;
}
return 0;
}
/**
* Escape HTML special characters.
*/
@@ -103,9 +117,17 @@ app.get('/', (req, res) => {
// Get first episode data
const firstEpisode = episodes[0];
const lines = loadEpisode(firstEpisode.id);
let lines = loadEpisode(firstEpisode.id);
const colors = loadColors();
// Add timeSeconds to each line
if (lines && Array.isArray(lines)) {
lines = lines.map(line => ({
...line,
timeSeconds: parseTimestamp(line.timestamp)
}));
}
res.render('episode', {
episodes,
currentEpisode: firstEpisode,
@@ -116,7 +138,7 @@ app.get('/', (req, res) => {
app.get('/episode/:episodeId', (req, res) => {
const episodeId = req.params.episodeId;
const lines = loadEpisode(episodeId);
let lines = loadEpisode(episodeId);
if (!lines) {
return res.status(404).send('Episode not found');
@@ -130,6 +152,14 @@ app.get('/episode/:episodeId', (req, res) => {
};
const colors = loadColors();
// Add timeSeconds to each line
if (lines && Array.isArray(lines)) {
lines = lines.map(line => ({
...line,
timeSeconds: parseTimestamp(line.timestamp)
}));
}
res.render('episode', {
episodes,
currentEpisode,

View File

@@ -9,31 +9,53 @@
<body>
<!-- Navbar -->
<nav class="navbar">
<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>
<span class="nav-title"><%= currentEpisode.title %></span>
<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 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>
<span class="nav-title" id="timeDisplay">00:00</span>
<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 => { %>
<% 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-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>
@@ -71,7 +93,7 @@
</div>
</aside>
<!-- Minimal JavaScript for interactions -->
<!-- JavaScript for interactions -->
<script>
(function() {
// DOM Elements
@@ -81,29 +103,290 @@
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');
let selectedLine = null;
let expandedLine = null;
let isPlaying = false;
let currentTime = 0;
let playbackInterval = null;
let currentLineIndex = 0;
let lastTimestamp = Date.now();
// Line selection
lines.forEach(line => {
line.addEventListener('click', function(e) {
// Remove selected class from all lines
lines.forEach(l => l.classList.remove('selected'));
// Add selected class to clicked line
this.classList.add('selected');
selectedLine = this;
// 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);
}
}
// 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 no line selected, select the first one
if (lines.length > 0) {
lines[0].classList.add('selected');
selectedLine = lines[0];
updateSelection(0);
}
}
@@ -111,17 +394,14 @@
const translationCard = selectedLine.querySelector('.translation-card');
if (translationCard) {
// If already expanded, collapse it
translationCard.remove();
expandedLine = null;
} else {
// Collapse any previously expanded line
if (expandedLine) {
const prevCard = expandedLine.querySelector('.translation-card');
if (prevCard) prevCard.remove();
}
// Expand the selected line
const chineseText = selectedLine.dataset.chinese;
const timestamp = selectedLine.dataset.timestamp;
const speaker = selectedLine.dataset.speaker;
@@ -139,7 +419,6 @@
selectedLine.appendChild(card);
expandedLine = selectedLine;
// Scroll the expanded card into view smoothly
setTimeout(() => {
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
@@ -148,6 +427,12 @@
}
});
// Play/Pause button
playPauseBtn.addEventListener('click', togglePlayPause);
// Go to button
goToBtn.addEventListener('click', goToCurrentLine);
// Open side menu
function openMenu() {
sideMenu.classList.add('active');
@@ -179,6 +464,11 @@
closeMenu();
}
});
// Select first line by default
if (lines.length > 0) {
updateSelection(0);
}
})();
</script>
</body>