mirror of
https://github.com/YusufB5/ASCILINE.git
synced 2026-06-23 22:48:06 +02:00
feat: polished player UI for live mode (hover thumbnails, skip, responsive)
Builds on the existing live seek/play/volume. Adds a polished, responsive control bar with play/pause, +/-10s skip, a played-progress fill, and a YouTube-style hover thumbnail preview on the seek bar. Thumbnails come from a small lazy /scrub endpoint that builds an in-memory sprite once per video with a single ffmpeg pass (no disk cache); easy to point at the static compiler's sprite instead.
This commit is contained in:
parent
461e0bd939
commit
a253c17507
4 changed files with 360 additions and 69 deletions
155
app.js
155
app.js
|
|
@ -20,6 +20,16 @@ const seekBar = document.getElementById('seek-slider');
|
|||
const timeCurrent = document.getElementById('time-current');
|
||||
const timeTotal = document.getElementById('time-total');
|
||||
|
||||
// Added controls: skip buttons, played fill, and the hover scrub preview
|
||||
const btnBack = document.getElementById('btn-back');
|
||||
const btnFwd = document.getElementById('btn-fwd');
|
||||
const seekPlayed = document.getElementById('seek-played');
|
||||
const seekWrap = document.querySelector('.seek-wrap');
|
||||
const seekPreview = document.getElementById('seek-preview');
|
||||
const seekPreviewImg = document.getElementById('seek-preview-img');
|
||||
const seekPreviewTime = document.getElementById('seek-preview-time');
|
||||
let scrubMeta = null; // hover sprite layout from /scrub
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (isNaN(seconds) || seconds < 0) return "00:00";
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
|
|
@ -193,8 +203,10 @@ function connectWebSocket() {
|
|||
}
|
||||
if (timeTotal) timeTotal.textContent = formatTime(duration);
|
||||
if (timeCurrent) timeCurrent.textContent = "00:00";
|
||||
|
||||
if (seekPlayed) seekPlayed.style.width = '0%';
|
||||
|
||||
audioOffset = 0;
|
||||
setupScrub(currentQueueIdx); // load hover thumbnails for this video
|
||||
|
||||
buildCanvas(parseInt(p[3]), parseInt(p[4]));
|
||||
|
||||
|
|
@ -308,6 +320,7 @@ function renderFrame(now) {
|
|||
if (!isSeeking && seekBar) {
|
||||
if (now - lastUiUpdateTime >= 100) {
|
||||
seekBar.value = masterClock;
|
||||
if (seekPlayed && duration) seekPlayed.style.width = Math.min(100, (masterClock / duration) * 100) + '%';
|
||||
lastUiUpdateTime = now;
|
||||
}
|
||||
const formattedTime = formatTime(masterClock);
|
||||
|
|
@ -477,62 +490,110 @@ if (playPauseBtn) {
|
|||
});
|
||||
}
|
||||
|
||||
// Seek to an absolute time. Reuses the live seek (tell the server, then reload
|
||||
// the audio from that point). Shared by the slider and the skip buttons.
|
||||
function doSeek(targetSec) {
|
||||
if (duration) targetSec = Math.max(0, Math.min(targetSec, duration));
|
||||
if (seekBar) seekBar.value = targetSec;
|
||||
if (seekPlayed && duration) seekPlayed.style.width = Math.min(100, (targetSec / duration) * 100) + '%';
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'seek', time: targetSec }));
|
||||
}
|
||||
|
||||
// Drop stale frames, then restart audio from the seek point
|
||||
frameBuffer.length = 0;
|
||||
audioOffset = targetSec;
|
||||
|
||||
if (audioEl) {
|
||||
audioEl.pause();
|
||||
audioEl.src = `/audio?v=${currentQueueIdx}&start=${targetSec}&t=${Date.now()}`;
|
||||
audioEl.load();
|
||||
|
||||
if (state === 'PLAYING') {
|
||||
readyToRender = false;
|
||||
audioEl.play().catch(() => {});
|
||||
const onAudioStart = () => {
|
||||
if (!readyToRender) {
|
||||
readyToRender = true;
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
lastRenderTime = performance.now();
|
||||
lastFpsUpdate = performance.now();
|
||||
frameCount = 0;
|
||||
requestAnimationFrame(renderFrame);
|
||||
}
|
||||
};
|
||||
if (audioEl.readyState >= 3) onAudioStart();
|
||||
else {
|
||||
audioEl.addEventListener('playing', onAudioStart, { once: true });
|
||||
setTimeout(onAudioStart, 500);
|
||||
}
|
||||
} else {
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
}
|
||||
} else {
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
}
|
||||
}
|
||||
|
||||
function getMasterClock() {
|
||||
if (audioEl && audioEl.readyState >= 1 && !audioEl.paused) return audioEl.currentTime + audioOffset;
|
||||
return (performance.now() - streamStartTime) / 1000.0;
|
||||
}
|
||||
|
||||
function skip(delta) {
|
||||
if (state !== 'PLAYING' && state !== 'PAUSED') return;
|
||||
if (!duration) return;
|
||||
doSeek(getMasterClock() + delta);
|
||||
}
|
||||
|
||||
// Pull the hover thumbnail sprite for this video (built lazily by the server).
|
||||
function setupScrub(v) {
|
||||
scrubMeta = null;
|
||||
if (seekPreviewImg) seekPreviewImg.style.backgroundImage = '';
|
||||
fetch('/scrub?v=' + (v || 0)).then(r => r.json()).then(m => {
|
||||
if (!m || !m.available || !seekPreviewImg) return;
|
||||
scrubMeta = m;
|
||||
seekPreviewImg.style.width = m.cellW + 'px';
|
||||
seekPreviewImg.style.height = m.cellH + 'px';
|
||||
seekPreviewImg.style.backgroundImage = `url(${m.sprite})`;
|
||||
seekPreviewImg.style.backgroundSize = (m.gridCols * m.cellW) + 'px ' + (m.gridRows * m.cellH) + 'px';
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function onSeekHover(e) {
|
||||
if (!scrubMeta || !duration || !seekWrap) return;
|
||||
const rect = seekWrap.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const time = (x / rect.width) * duration;
|
||||
const idx = Math.max(0, Math.min(Math.floor(time / scrubMeta.interval), scrubMeta.count - 1));
|
||||
const col = idx % scrubMeta.gridCols, row = Math.floor(idx / scrubMeta.gridCols);
|
||||
seekPreviewImg.style.backgroundPosition = `-${col * scrubMeta.cellW}px -${row * scrubMeta.cellH}px`;
|
||||
seekPreviewTime.textContent = formatTime(time);
|
||||
const half = scrubMeta.cellW / 2;
|
||||
seekPreview.style.left = Math.max(half, Math.min(x, rect.width - half)) + 'px';
|
||||
seekPreview.classList.add('show');
|
||||
}
|
||||
|
||||
if (seekBar) {
|
||||
seekBar.addEventListener('input', () => {
|
||||
isSeeking = true;
|
||||
if (timeCurrent) timeCurrent.textContent = formatTime(seekBar.value);
|
||||
});
|
||||
|
||||
seekBar.addEventListener('change', () => {
|
||||
const targetSec = parseFloat(seekBar.value);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'seek', time: targetSec }));
|
||||
}
|
||||
|
||||
// Clear buffer so we don't render stale frames
|
||||
frameBuffer.length = 0;
|
||||
audioOffset = targetSec;
|
||||
|
||||
// Reload audio with correct start offset
|
||||
if (audioEl) {
|
||||
audioEl.pause();
|
||||
const qs = `?v=${currentQueueIdx}&start=${targetSec}&`;
|
||||
audioEl.src = `/audio${qs}t=${Date.now()}`;
|
||||
audioEl.load();
|
||||
|
||||
if (state === 'PLAYING') {
|
||||
readyToRender = false;
|
||||
audioEl.play().catch(() => {});
|
||||
|
||||
const onAudioStart = () => {
|
||||
if (!readyToRender) {
|
||||
readyToRender = true;
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
lastRenderTime = performance.now();
|
||||
lastFpsUpdate = performance.now();
|
||||
frameCount = 0;
|
||||
requestAnimationFrame(renderFrame);
|
||||
}
|
||||
};
|
||||
|
||||
if (audioEl.readyState >= 3) {
|
||||
onAudioStart();
|
||||
} else {
|
||||
audioEl.addEventListener('playing', onAudioStart, { once: true });
|
||||
// Fallback in case audio is muted or fails to play
|
||||
setTimeout(onAudioStart, 500);
|
||||
}
|
||||
} else {
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
}
|
||||
} else {
|
||||
streamStartTime = performance.now() - (targetSec * 1000.0);
|
||||
}
|
||||
|
||||
doSeek(parseFloat(seekBar.value));
|
||||
isSeeking = false;
|
||||
});
|
||||
}
|
||||
|
||||
if (btnBack) btnBack.addEventListener('click', (e) => { e.stopPropagation(); skip(-10); });
|
||||
if (btnFwd) btnFwd.addEventListener('click', (e) => { e.stopPropagation(); skip(10); });
|
||||
|
||||
if (seekWrap) {
|
||||
seekWrap.addEventListener('mousemove', onSeekHover);
|
||||
seekWrap.addEventListener('mouseleave', () => { if (seekPreview) seekPreview.classList.remove('show'); });
|
||||
}
|
||||
|
||||
// ── EVENT LISTENERS ──
|
||||
overlay.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
31
index.html
31
index.html
|
|
@ -53,21 +53,24 @@
|
|||
</div>
|
||||
|
||||
<!-- Player Controls Bar -->
|
||||
<div class="player-controls" style="display: flex; align-items: center; justify-content: center; gap: 20px; padding: 10px 0; border-top: 1px solid #333; margin-top: 10px; width: 100%;">
|
||||
<!-- Play/Pause -->
|
||||
<div class="ctrl-group" style="flex: 0 0 auto;">
|
||||
<button id="play-pause-btn" style="background: none; border: none; color: var(--accent-color, #00ff41); font-family: monospace; font-size: 16px; cursor: pointer;">❚❚</button>
|
||||
<div class="player-controls">
|
||||
<button id="play-pause-btn" class="ctrl-btn" title="Play / Pause (Space)">❚❚</button>
|
||||
<button id="btn-back" class="ctrl-btn" title="Back 10s">«10</button>
|
||||
<span id="time-current" class="ctrl-time">00:00</span>
|
||||
<div class="seek-wrap">
|
||||
<div class="seek-track"></div>
|
||||
<div class="seek-played" id="seek-played"></div>
|
||||
<input id="seek-slider" class="seek-slider" type="range" min="0" max="100" step="0.1" value="0" title="Seek">
|
||||
<div class="seek-preview" id="seek-preview">
|
||||
<div class="seek-preview-img" id="seek-preview-img"></div>
|
||||
<div class="seek-preview-time" id="seek-preview-time">0:00</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Seek Bar -->
|
||||
<div class="ctrl-group seek-group" style="flex: 1; max-width: 500px; display: flex; align-items: center;">
|
||||
<span id="time-current" style="margin-right: 10px; font-size: 12px; color: #888; flex-shrink: 0; width: 35px; text-align: right;">00:00</span>
|
||||
<input id="seek-slider" type="range" min="0" max="100" step="0.1" value="0" style="flex: 1; cursor: pointer; accent-color: var(--accent-color, #00ff41);">
|
||||
<span id="time-total" style="margin-left: 10px; font-size: 12px; color: #888; flex-shrink: 0; width: 35px;">00:00</span>
|
||||
</div>
|
||||
<!-- Volume Control -->
|
||||
<div class="ctrl-group" style="display: flex; align-items: center; flex: 0 0 auto;">
|
||||
<span class="ctrl-icon" style="margin-right: 5px; color: #888;">VOL_</span>
|
||||
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1" style="cursor: pointer; width: 60px; accent-color: var(--accent-color, #00ff41);">
|
||||
<span id="time-total" class="ctrl-time">00:00</span>
|
||||
<button id="btn-fwd" class="ctrl-btn" title="Forward 10s">10»</button>
|
||||
<div class="ctrl-group">
|
||||
<span class="ctrl-icon">VOL_</span>
|
||||
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -244,6 +244,95 @@ async def audio_stream(v: int | None = None, start: float = 0.0):
|
|||
)
|
||||
|
||||
|
||||
# ── Scrub-preview sprite (powers the hover thumbnails on the seek bar) ──
|
||||
# A grid of small frames sampled across the video, like a YouTube preview strip.
|
||||
# Built once per video on first request and kept in memory only (no disk cache).
|
||||
# If you'd rather serve a sprite from the static compiler, just point /scrub at it.
|
||||
_scrub_cache: dict = {} # video_path -> {"meta": {...}, "jpeg": bytes} or None
|
||||
|
||||
|
||||
def _build_scrub_sprite(video_path: str, max_count: int = 64, cell_w: int = 160):
|
||||
import math
|
||||
# Probe size + duration quickly (metadata only, no frame decoding).
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
return None
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
w0 = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
h0 = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
cap.release()
|
||||
duration = (total / fps) if fps else 0
|
||||
if duration <= 0 or w0 <= 0 or h0 <= 0:
|
||||
return None
|
||||
|
||||
cell_h = max(1, round(cell_w * h0 / w0))
|
||||
n = max(1, min(max_count, int(duration))) # roughly one frame per second
|
||||
cols = max(1, math.ceil(math.sqrt(n)))
|
||||
rows = max(1, math.ceil(n / cols))
|
||||
interval = duration / n
|
||||
|
||||
# One ffmpeg pass: sample frames, scale them, tile into a single grid image.
|
||||
# Sequential decode (no per-frame seeking), so this is fast even on long clips.
|
||||
vf = f"fps={n}/{duration:.3f},scale={cell_w}:{cell_h},tile={cols}x{rows}"
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ffmpeg", "-nostdin", "-i", video_path, "-vf", vf,
|
||||
"-frames:v", "1", "-q:v", "4", "-f", "image2", "-c:v", "mjpeg",
|
||||
"-loglevel", "error", "pipe:1"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL, timeout=120,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if proc.returncode != 0 or not proc.stdout:
|
||||
return None
|
||||
|
||||
return {
|
||||
"meta": {"available": True, "count": n, "gridCols": cols, "gridRows": rows,
|
||||
"cellW": cell_w, "cellH": cell_h, "interval": interval, "duration": duration},
|
||||
"jpeg": proc.stdout,
|
||||
}
|
||||
|
||||
|
||||
def _scrub_video_path(v: int | None) -> str:
|
||||
queue = getattr(app.state, "queue", [])
|
||||
idx = getattr(app.state, "current_index", 0)
|
||||
if v is not None and 0 <= v < len(queue):
|
||||
idx = v
|
||||
entry = queue[idx] if queue and 0 <= idx < len(queue) else {}
|
||||
return entry.get("video", "")
|
||||
|
||||
|
||||
@app.get("/scrub")
|
||||
async def scrub_meta(v: int | None = None):
|
||||
"""Layout for the hover thumbnails. Builds the sprite lazily (off the event
|
||||
loop) the first time it's asked for, then reuses it from memory."""
|
||||
from fastapi import Response
|
||||
import json as _json
|
||||
video_path = _scrub_video_path(v)
|
||||
if not video_path or not os.path.exists(video_path):
|
||||
return Response(content='{"available": false}', media_type="application/json")
|
||||
if video_path not in _scrub_cache:
|
||||
loop = asyncio.get_event_loop()
|
||||
_scrub_cache[video_path] = await loop.run_in_executor(None, _build_scrub_sprite, video_path)
|
||||
built = _scrub_cache.get(video_path)
|
||||
if not built:
|
||||
return Response(content='{"available": false}', media_type="application/json")
|
||||
meta = dict(built["meta"])
|
||||
meta["sprite"] = f"/scrub_sprite?v={v if v is not None else 0}"
|
||||
return Response(content=_json.dumps(meta), media_type="application/json")
|
||||
|
||||
|
||||
@app.get("/scrub_sprite")
|
||||
async def scrub_sprite(v: int | None = None):
|
||||
from fastapi import Response, HTTPException
|
||||
built = _scrub_cache.get(_scrub_video_path(v))
|
||||
if not built:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return Response(content=built["jpeg"], media_type="image/jpeg")
|
||||
|
||||
|
||||
def _origin_allowed(origin: str | None, host_header: str | None = None) -> bool:
|
||||
"""Reject cross-site WebSocket hijacking while allowing localhost and LAN same-origin."""
|
||||
if not origin:
|
||||
|
|
|
|||
154
style.css
154
style.css
|
|
@ -115,8 +115,9 @@ body {
|
|||
position: relative;
|
||||
background: var(--player-bg);
|
||||
border-radius: 4px;
|
||||
width: 860px;
|
||||
height: 560px;
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
aspect-ratio: 860 / 560; /* scales with width, keeps shape */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -207,7 +208,8 @@ body {
|
|||
.player-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #1a1a1a;
|
||||
|
|
@ -222,7 +224,7 @@ body {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
|
|
@ -231,18 +233,133 @@ body {
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Styled range slider */
|
||||
/* Buttons (play / pause / skip) */
|
||||
.ctrl-btn {
|
||||
background: #050505;
|
||||
color: var(--accent-color);
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
min-width: 34px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.ctrl-btn:hover { border-color: var(--accent-color); }
|
||||
.ctrl-btn:disabled { opacity: 0.3; cursor: not-allowed; border-color: #333; }
|
||||
|
||||
/* Time readouts */
|
||||
.ctrl-time {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Seek bar (track + played fill + transparent slider on top) */
|
||||
.seek-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.seek-track {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 4px;
|
||||
background: #444;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.seek-played {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
width: 0;
|
||||
background: var(--accent-color);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.seek-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
.seek-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.seek-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hover scrub-preview thumbnail */
|
||||
.seek-preview {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
transform: translateX(-50%);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 60;
|
||||
}
|
||||
.seek-preview.show { display: flex; }
|
||||
.seek-preview-img {
|
||||
background-repeat: no-repeat;
|
||||
border: 1px solid var(--accent-color);
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.seek-preview-time {
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-tech);
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Volume */
|
||||
#volume-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
height: 4px;
|
||||
background: #444;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#volume-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
|
@ -252,7 +369,6 @@ body {
|
|||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#volume-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
|
|
@ -282,4 +398,26 @@ body {
|
|||
|
||||
#player-container.paused #ascii-player {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── RESPONSIVE ─────────────────────────── */
|
||||
@media (max-width: 920px) {
|
||||
.blog-container { margin: 30px auto; }
|
||||
.blog-post { padding: 24px; }
|
||||
.blog-header { padding: 50px 16px; }
|
||||
.blog-header h1 { font-size: 32px; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.blog-post { padding: 16px; }
|
||||
.blog-header { padding: 36px 12px; }
|
||||
.blog-header h1 { font-size: 26px; letter-spacing: 2px; }
|
||||
.post-title { font-size: 19px; }
|
||||
.post-content { font-size: 15px; }
|
||||
|
||||
.player-controls { gap: 8px; padding: 8px; }
|
||||
.ctrl-btn { min-width: 30px; height: 26px; padding: 0 6px; font-size: 11px; }
|
||||
.ctrl-time { min-width: 0; font-size: 10px; }
|
||||
.seek-wrap { order: 5; flex-basis: 100%; } /* seek bar gets its own row */
|
||||
#volume-slider { width: 64px; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue