feat: add invisible selection layer, audio streaming, and updated manifesto for pure performance mode

This commit is contained in:
YusufB5 2026-05-05 13:51:27 +03:00
parent e7002173c2
commit 2043a7bb37
6 changed files with 396 additions and 244 deletions

View file

@ -8,7 +8,7 @@
<br>
<img src="https://github.com/user-attachments/assets/6bd7f5c0-81de-49fe-ba0d-9a8872ec8ae3" width="600" alt="Animation-after" />
<br>
<sub><i>* Showcases rendered using Mode 3 (32K Colors)</i></sub>
<sub><i>* Showcases rendered using Mode 3 (32K Colors) from a 30 FPS source video. The engine naturally synchronizes up to 60+ FPS depending on the source material.</i></sub>
</p>
## 🎯 Strategic Vision & Core Capabilities
@ -45,13 +45,19 @@ cd ASCILINE
pip install fastapi uvicorn opencv-python numpy websockets
```
### 3. Run the engine
### 3. Run the Web Server
Place a `video.mp4` in the root directory and start the server:
```bash
python stream_server.py
```
Open `http://localhost:8000` in your browser.
### 4. Run directly in Terminal (Standalone)
If you prefer to bypass the web interface, you can render the video directly inside an ANSI-supported terminal (zero-flicker, true color):
```bash
python ascii_video_player2.py video.mp4 --quality 0
```
## 🎨 Customization
You can easily customize the look and feel of the engine:

232
app.js
View file

@ -1,8 +1,9 @@
/**
* ASCILINE ENGINE - Core Logic
* ASCILINE ENGINE - Pure & Performant Logic
* =========================================
* Handles WebSocket communication, frame buffering,
* and dual-mode rendering (Canvas/DOM).
* No decorative animations. Pure WebSocket streaming
* and high-performance canvas rendering.
* Includes an "Invisible Selection Layer" for text selection.
*/
const player = document.getElementById('ascii-player');
@ -11,100 +12,122 @@ const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
const container = document.getElementById('player-container');
const overlay = document.getElementById('play-overlay');
const audioEl = document.getElementById('ascii-audio');
const volumeSlider = document.getElementById('volume-slider');
// ── STATE ──
let state = 'IDLE'; // IDLE | PLAYING | DISSOLVING
let state = 'IDLE'; // IDLE | PLAYING
let ws = null;
const frameBuffer = [];
const BUFFER_SIZE = 4;
let targetFps = 24;
let frameInterval = 1000 / targetFps;
let renderMode = 1;
let readyToRender = false;
// Grid & Dimensions
let gridCols = 0, gridRows = 0;
let charWidth = 0, charHeight = 0;
let xPos = null, yPos = null;
// Selection Layer optimization
const textDecoder = new TextDecoder();
let selectionBuffer = null;
// Timing & Metrics
let lastRenderTime = 0;
let frameCount = 0, currentFps = 0, lastFpsUpdate = 0;
let lastFrameView = null; // Stored for the ripple effect
// Character Lookup Table (optimization)
const CHAR_LUT = new Array(128);
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i);
/**
* Pre-calculates positions and scales canvas for high-performance rendering.
*/
// ═══════════════════════════════════════
// CANVAS SETUP
// ═══════════════════════════════════════
function buildCanvas(cols, rows) {
gridCols = cols;
gridRows = rows;
ctx.font = 'bold 8px Courier New';
charWidth = Math.ceil(ctx.measureText('M').width);
charHeight = 8;
canvas.width = cols * charWidth;
canvas.height = rows * charHeight;
canvas.style.display = 'block';
player.style.display = 'none';
container.style.minWidth = canvas.width + 'px';
container.style.minHeight = canvas.height + 'px';
// Selection Layer Buffer
selectionBuffer = new Uint8Array((cols + 1) * rows);
for (let r = 0; r < rows; r++) selectionBuffer[r * (cols + 1) + cols] = 10; // Newline (\n)
// Sizing and positioning for both layers
const syncSize = (el) => {
el.style.width = container.clientWidth + 'px';
el.style.height = container.clientHeight + 'px';
el.style.objectFit = 'contain';
el.style.position = 'absolute';
el.style.top = '0';
el.style.left = '0';
};
syncSize(canvas);
syncSize(player);
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
xPos = new Float32Array(cols);
yPos = new Float32Array(rows);
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
}
/**
* Initiates WebSocket connection and stream handling.
*/
// ═══════════════════════════════════════
// STREAM CONTROL
// ═══════════════════════════════════════
function startStream() {
if (state !== 'IDLE') return;
state = 'PLAYING';
overlay.classList.add('hidden');
statusEl.textContent = 'Connecting...';
statusEl.style.color = 'var(--accent-color)';
connectWebSocket();
}
function connectWebSocket() {
frameBuffer.length = 0;
lastFrameView = null;
frameCount = 0;
currentFps = 0;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
if (audioEl) {
audioEl.src = '/audio?' + Date.now();
audioEl.volume = volumeSlider ? volumeSlider.value : 0.8;
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}/ws`);
ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => {
if (state !== 'PLAYING') return;
if (typeof event.data === 'string') {
if (event.data.startsWith('Error:')) {
statusEl.textContent = event.data;
statusEl.style.color = '#ff0000';
state = 'IDLE';
if (ws) ws.close();
setTimeout(() => resetToIdle(), 3000);
setTimeout(() => finishStream(), 3000);
return;
}
if (event.data.startsWith('INIT:')) {
const p = event.data.split(':');
targetFps = parseFloat(p[1]);
frameInterval = 1000 / targetFps;
renderMode = parseInt(p[2]);
if (renderMode > 1) {
buildCanvas(parseInt(p[3]), parseInt(p[4]));
} else {
player.style.display = 'block';
canvas.style.display = 'none';
}
buildCanvas(parseInt(p[3]), parseInt(p[4]));
if (audioEl) audioEl.play().catch(() => {});
readyToRender = true;
state = 'PLAYING';
lastRenderTime = performance.now();
lastFpsUpdate = lastRenderTime;
requestAnimationFrame(renderFrame);
return;
}
frameBuffer.push(event.data);
@ -112,39 +135,31 @@ function startStream() {
frameBuffer.push(event.data);
}
// Buffer Overflow Protection
while (frameBuffer.length > BUFFER_SIZE * 3) frameBuffer.shift();
// Start render loop once buffered
if (frameBuffer.length >= BUFFER_SIZE && lastRenderTime === 0) {
lastRenderTime = performance.now();
lastFpsUpdate = lastRenderTime;
requestAnimationFrame(renderFrame);
}
};
ws.onopen = () => {
statusEl.textContent = 'Buffering...';
};
ws.onopen = () => { statusEl.textContent = 'Buffering...'; };
ws.onclose = () => {
if (state === 'PLAYING') {
statusEl.textContent = 'Stream Ended.';
statusEl.style.color = '#888';
setTimeout(() => resetToIdle(), 1500);
if (audioEl) audioEl.pause();
setTimeout(() => finishStream(), 800);
}
};
ws.onerror = () => {
statusEl.textContent = 'Connection Error!';
statusEl.style.color = '#ff0000';
setTimeout(() => resetToIdle(), 2000);
setTimeout(() => finishStream(), 2000);
};
}
/**
* Main render loop using requestAnimationFrame.
*/
// ═══════════════════════════════════════
// RENDER LOOP
// ═══════════════════════════════════════
function renderFrame(now) {
if (state !== 'PLAYING') return;
requestAnimationFrame(renderFrame);
@ -152,29 +167,27 @@ function renderFrame(now) {
const elapsed = now - lastRenderTime;
if (elapsed < frameInterval) return;
// FPS Counter
frameCount++;
if (now - lastFpsUpdate >= 1000) {
currentFps = frameCount;
frameCount = 0;
lastFpsUpdate = now;
let modeText = 'B&W';
const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' };
modeText = modes[renderMode] || 'B&W';
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modeText}`;
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modes[renderMode] || 'B&W'}`;
}
if (frameBuffer.length === 0) return;
lastRenderTime = now;
const frame = frameBuffer.shift();
if (renderMode === 1) {
player.style.display = 'block';
player.style.color = '#fff';
player.textContent = frame;
} else {
const view = new Uint8Array(frame);
lastFrameView = view;
// 1. Draw Canvas (Background)
ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 8px Courier New';
@ -188,94 +201,37 @@ function renderFrame(now) {
prevPacked = packed;
}
ctx.fillText(CHAR_LUT[view[idx]], xPos[col], yPos[row]);
col++;
if (col >= gridCols) { col = 0; row++; }
}
}
}
/**
* Visual Effect: Ripple Dissolve
* Triggered on click during playback.
*/
function triggerRipple(clickX, clickY) {
if (!lastFrameView || renderMode === 1) {
resetToIdle();
return;
}
state = 'DISSOLVING';
if (ws) { ws.onclose = null; ws.close(); ws = null; }
const fv = lastFrameView;
const rippleSpeed = 400;
const waveFront = 70;
const maxShake = 6;
const maxDist = Math.sqrt(canvas.width * canvas.width + canvas.height * canvas.height);
const startTime = performance.now();
statusEl.textContent = '';
function animateRipple(now) {
const elapsed = (now - startTime) / 1000;
const radius = elapsed * rippleSpeed;
if (radius > maxDist + waveFront + 20) {
resetToIdle();
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 8px Courier New';
ctx.textBaseline = 'top';
let col = 0, row = 0;
for (let idx = 0; idx < fv.length; idx += 4) {
const px = xPos[col];
const py = yPos[row];
const dx = px - clickX;
const dy = py - clickY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > radius) {
ctx.globalAlpha = 1;
ctx.fillStyle = `rgb(${fv[idx+1]},${fv[idx+2]},${fv[idx+3]})`;
ctx.fillText(CHAR_LUT[fv[idx]], px, py);
} else if (dist > radius - waveFront) {
const progress = (radius - dist) / waveFront;
const shake = Math.sin(progress * Math.PI) * maxShake;
const ox = (Math.random() - 0.5) * shake * 2;
const oy = (Math.random() - 0.5) * shake * 2;
ctx.globalAlpha = 1 - progress;
ctx.fillStyle = `rgb(${fv[idx+1]},${fv[idx+2]},${fv[idx+3]})`;
ctx.fillText(CHAR_LUT[fv[idx]], px + ox, py + oy);
}
// Fill Selection Buffer (char code is at view[idx])
selectionBuffer[row * (gridCols + 1) + col] = view[idx];
col++;
if (col >= gridCols) { col = 0; row++; }
}
ctx.globalAlpha = 1;
requestAnimationFrame(animateRipple);
// 2. Update Selection Layer (Foreground)
player.style.display = 'block';
player.style.color = 'transparent';
player.textContent = textDecoder.decode(selectionBuffer);
}
requestAnimationFrame(animateRipple);
}
function resetToIdle() {
// ═══════════════════════════════════════
// CLEANUP
// ═══════════════════════════════════════
function finishStream() {
state = 'IDLE';
if (ws) { ws.onclose = null; ws.close(); ws = null; }
frameBuffer.length = 0;
lastRenderTime = 0;
lastFrameView = null;
if (audioEl) { audioEl.pause(); audioEl.src = ''; }
ctx.clearRect(0, 0, canvas.width, canvas.height);
player.textContent = '';
player.style.display = 'none';
overlay.classList.remove('hidden');
statusEl.textContent = 'Ready';
statusEl.style.color = 'rgba(255,255,255,0.6)';
readyToRender = false;
frameBuffer.length = 0;
}
// ── EVENT LISTENERS ──
@ -284,14 +240,18 @@ overlay.addEventListener('click', (e) => {
startStream();
});
container.addEventListener('click', (e) => {
if (state !== 'PLAYING') return;
if (volumeSlider) {
volumeSlider.addEventListener('input', () => {
if (audioEl) audioEl.volume = volumeSlider.value;
});
}
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const clickX = (e.clientX - rect.left) * scaleX;
const clickY = (e.clientY - rect.top) * scaleY;
triggerRipple(clickX, clickY);
window.addEventListener('resize', () => {
const syncSize = (el) => {
if (!el) return;
el.style.width = container.clientWidth + 'px';
el.style.height = container.clientHeight + 'px';
};
syncSize(canvas);
syncSize(player);
});

BIN
bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

View file

@ -1,38 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASCILINE | Real-Time ASCII Engine</title>
<title>ASCILINE - Dynamic Typography Engine</title>
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
<!-- Core Styles -->
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- Live Status Indicator -->
<div id="status" class="status">Ready</div>
<!-- Header Section -->
<h1>ASCILINE</h1>
<p class="subtitle">real-time ascii streaming engine</p>
<header class="blog-header">
<h1>ASCILINE</h1>
<p>Turning the web into a living, breathing typographic canvas.</p>
</header>
<!-- Main Player Container -->
<div id="player-container">
<!-- Text-based rendering (B&W Mode) -->
<pre id="ascii-player"></pre>
<!-- Canvas-based rendering (Color Modes) -->
<canvas id="ascii-canvas"></canvas>
<!-- Main Content -->
<main class="blog-container">
<article class="blog-post">
<h2 class="post-title">The Web Was Never Meant to Be Static</h2>
<div class="post-meta">Manifesto // May 4, 2026 // ASCILINE Core Team</div>
<!-- Tap to Play Interaction Overlay -->
<div id="play-overlay">
<div class="play-btn"></div>
<span class="play-label">Tap to Play</span>
</div>
</div>
<p class="post-content">
Every character on this page is potential energy. The web was built on text — HTML,
the very skeleton of every site you visit, is nothing but structured characters. Yet
somewhere along the way, we forgot that text itself can be the medium, not just the
message. ASCILINE exists to remind us: every glyph is a pixel waiting to move.
</p>
<!-- Video Wrapper -->
<div class="video-wrapper">
<div id="status" class="status">System Ready</div>
<!-- Main Player Container -->
<div id="player-container">
<!-- Text-based rendering (B&W Mode) -->
<pre id="ascii-player"></pre>
<!-- Canvas-based rendering (Color Modes) -->
<canvas id="ascii-canvas"></canvas>
<!-- Interaction Overlay -->
<div id="play-overlay">
<div class="play-btn"></div>
<span class="play-label">Initialize Uplink</span>
</div>
</div>
<!-- Player Controls Bar -->
<div class="player-controls">
<!-- Volume Control -->
<div class="ctrl-group">
<span class="ctrl-icon">VOL_</span>
<input id="volume-slider" type="range" min="0" max="1" step="0.05" value="0.8">
</div>
</div>
<!-- Hidden Audio Element -->
<audio id="ascii-audio" preload="none"></audio>
</div>
<p class="post-content">
This is not a video player in the traditional sense. There is no &lt;video&gt; tag here,
no compressed binary stream decoded by your GPU. Instead, a WebSocket pushes raw
character data at 24+ frames per second, and your browser renders it as pure text —
the same primitive that built the entire internet. The web becomes dynamic not through
heavier payloads, but by rethinking what was already there.
</p>
</article>
</main>
<!-- Core Engine Logic -->
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -6,10 +6,11 @@ Dependencies: pip install fastapi uvicorn websockets
"""
import asyncio
import subprocess
import numpy as np
import cv2
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
import uvicorn
import os
@ -34,6 +35,51 @@ async def root():
"""Serves the Frontend (HTML/JS/CSS) file to the client."""
return HTMLResponse(get_html_content())
@app.get("/audio")
async def audio_stream():
"""
Extracts and streams audio from the video file using ffmpeg.
Returns an MP3 audio stream that the browser can play natively.
"""
video_path = getattr(app.state, "video_path", "video.mp4")
if not os.path.exists(video_path):
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Video file not found")
def audio_generator():
# Use ffmpeg to extract audio as MP3 stream
process = subprocess.Popen(
[
"ffmpeg",
"-i", video_path,
"-vn", # No video
"-acodec", "libmp3lame",
"-ab", "128k", # 128kbps bitrate
"-ar", "44100", # Sample rate
"-f", "mp3", # Output format
"-loglevel", "quiet",
"pipe:1" # Output to stdout
],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
try:
while True:
chunk = process.stdout.read(4096)
if not chunk:
break
yield chunk
finally:
process.stdout.close()
process.wait()
return StreamingResponse(
audio_generator(),
media_type="audio/mpeg",
headers={"Accept-Ranges": "bytes"}
)
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""

266
style.css
View file

@ -1,16 +1,18 @@
/*
ASCILINE ENGINE - Core Styles
ASCILINE ENGINE - Minimal Blog Theme
=========================================
Feel free to customize the look using the variables below.
Pure, performant, zero-decoration.
*/
:root {
--bg-color: #050505;
--accent-color: #00ff41;
--accent-glow: rgba(0, 255, 65, 0.3);
--text-muted: rgba(255, 255, 255, 0.4);
--player-bg: #030303;
--font-main: 'Courier New', monospace;
--bg-color: #0a0a0c;
--text-color: #e0e0e0;
--accent-color: #00f3ff;
--accent-secondary: #39ff14;
--post-bg: #121217;
--font-main: 'Outfit', sans-serif;
--font-tech: 'Courier New', monospace;
--player-bg: #050505;
}
* {
@ -21,120 +23,165 @@
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-main);
line-height: 1.6;
overflow-x: hidden;
}
/* ── HEADER ───────────────────────── */
.blog-header {
background-color: #000;
color: var(--accent-color);
padding: 80px 20px;
text-align: center;
border-bottom: 1px solid #333;
}
.blog-header h1 {
font-size: 42px;
font-weight: 600;
margin-bottom: 10px;
letter-spacing: 4px;
text-transform: uppercase;
font-family: var(--font-tech);
}
.blog-header p {
font-size: 14px;
font-weight: 300;
opacity: 0.7;
letter-spacing: 2px;
}
/* ── MAIN CONTENT ─────────────────── */
.blog-container {
max-width: 900px;
margin: 60px auto;
padding: 0 20px;
}
.blog-post {
background: var(--post-bg);
border-radius: 4px;
padding: 40px;
border: 1px solid #222;
}
.post-title {
font-size: 24px;
color: var(--accent-secondary);
margin-bottom: 5px;
font-weight: 600;
font-family: var(--font-tech);
border-left: 4px solid var(--accent-secondary);
padding-left: 15px;
}
.post-meta {
font-size: 11px;
color: #555;
margin-bottom: 30px;
text-transform: uppercase;
letter-spacing: 1px;
}
.post-content {
font-size: 16px;
color: #bbb;
margin-bottom: 30px;
font-weight: 300;
}
/* ── VIDEO WRAPPER ────────────────── */
.video-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
font-family: var(--font-main);
overflow: hidden;
margin: 50px 0;
}
/* ── UI ELEMENTS ─────────────────────────── */
.status {
position: fixed;
top: 12px;
left: 16px;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
font-family: monospace;
z-index: 100;
}
h1 {
color: var(--accent-color);
font-size: 18px;
letter-spacing: 6px;
text-transform: uppercase;
margin-bottom: 8px;
text-shadow: 0 0 10px var(--accent-glow);
}
.subtitle {
color: var(--text-muted);
font-size: 11px;
letter-spacing: 3px;
margin-bottom: 24px;
color: var(--accent-color);
margin-bottom: 12px;
font-family: var(--font-tech);
text-transform: uppercase;
letter-spacing: 2px;
}
/* ── PLAYER CONTAINER ─────────────────────────── */
/* ── PLAYER CONTAINER ──────────────── */
#player-container {
position: relative;
border: 1px solid rgba(0, 255, 65, 0.2);
box-shadow: 0 0 30px rgba(0, 255, 65, 0.06),
inset 0 0 30px rgba(0, 0, 0, 0.5);
background: var(--player-bg);
cursor: pointer;
min-width: 500px;
min-height: 280px;
border-radius: 4px;
width: 860px;
height: 560px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: border-color 0.4s ease, box-shadow 0.4s ease;
}
#player-container:hover {
border-color: rgba(0, 255, 65, 0.4);
box-shadow: 0 0 40px rgba(0, 255, 65, 0.1),
inset 0 0 30px rgba(0, 0, 0, 0.5);
border: 1px solid #333;
}
#ascii-player {
font-family: var(--font-main);
font-family: 'Courier New', monospace;
white-space: pre;
overflow: hidden;
font-size: 8px;
line-height: 8px;
letter-spacing: 0;
font-weight: bold;
contain: content;
color: transparent;
/* Selection text is transparent */
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5;
pointer-events: auto;
display: none;
user-select: text;
}
#ascii-canvas {
position: absolute;
top: 0;
left: 0;
z-index: 1;
display: none;
}
/* ── OVERLAYS ───────────────────────── */
#play-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(3, 3, 3, 0.97);
background: rgba(0, 0, 0, 0.7);
z-index: 10;
transition: opacity 0.5s ease;
user-select: none;
cursor: pointer;
}
#play-overlay.hidden {
opacity: 0;
pointer-events: none;
display: none;
}
.play-btn {
width: 72px;
height: 72px;
border: 2px solid rgba(0, 255, 65, 0.5);
width: 60px;
height: 60px;
border: 3px solid var(--accent-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
animation: pulse-glow 2.5s ease-in-out infinite;
transition: transform 0.2s ease, border-color 0.2s ease;
}
.play-btn:hover {
transform: scale(1.08);
border-color: var(--accent-color);
margin-bottom: 15px;
}
.play-btn::after {
@ -143,25 +190,74 @@ h1 {
width: 0;
height: 0;
border-style: solid;
border-width: 12px 0 12px 22px;
border-width: 10px 0 10px 18px;
border-color: transparent transparent transparent var(--accent-color);
margin-left: 4px;
margin-left: 5px;
}
.play-label {
color: rgba(0, 255, 65, 0.5);
font-size: 11px;
letter-spacing: 5px;
color: #fff;
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
font-weight: 600;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(0, 255, 65, 0.3),
0 0 15px rgba(0, 255, 65, 0.05);
}
50% {
box-shadow: 0 0 0 12px rgba(0, 255, 65, 0),
0 0 25px rgba(0, 255, 65, 0.12);
}
/* ── PLAYER CONTROLS BAR ───────────────── */
.player-controls {
display: flex;
align-items: center;
gap: 12px;
margin-top: 10px;
padding: 8px 12px;
background: #1a1a1a;
border-radius: 8px;
border: 1px solid #333;
width: 100%;
max-width: 320px;
box-sizing: border-box;
}
.ctrl-group {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.ctrl-icon {
font-size: 14px;
opacity: 0.7;
flex-shrink: 0;
}
/* Styled range slider */
#volume-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
background: #444;
border-radius: 2px;
outline: none;
cursor: pointer;
}
#volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: var(--accent-color);
border-radius: 50%;
cursor: pointer;
}
#volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--accent-color);
border-radius: 50%;
border: none;
cursor: pointer;
}