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> <br>
<img src="https://github.com/user-attachments/assets/6bd7f5c0-81de-49fe-ba0d-9a8872ec8ae3" width="600" alt="Animation-after" /> <img src="https://github.com/user-attachments/assets/6bd7f5c0-81de-49fe-ba0d-9a8872ec8ae3" width="600" alt="Animation-after" />
<br> <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> </p>
## 🎯 Strategic Vision & Core Capabilities ## 🎯 Strategic Vision & Core Capabilities
@ -45,13 +45,19 @@ cd ASCILINE
pip install fastapi uvicorn opencv-python numpy websockets 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: Place a `video.mp4` in the root directory and start the server:
```bash ```bash
python stream_server.py python stream_server.py
``` ```
Open `http://localhost:8000` in your browser. 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 ## 🎨 Customization
You can easily customize the look and feel of the engine: You can easily customize the look and feel of the engine:

228
app.js
View file

@ -1,8 +1,9 @@
/** /**
* ASCILINE ENGINE - Core Logic * ASCILINE ENGINE - Pure & Performant Logic
* ========================================= * =========================================
* Handles WebSocket communication, frame buffering, * No decorative animations. Pure WebSocket streaming
* and dual-mode rendering (Canvas/DOM). * and high-performance canvas rendering.
* Includes an "Invisible Selection Layer" for text selection.
*/ */
const player = document.getElementById('ascii-player'); const player = document.getElementById('ascii-player');
@ -11,100 +12,122 @@ const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const container = document.getElementById('player-container'); const container = document.getElementById('player-container');
const overlay = document.getElementById('play-overlay'); const overlay = document.getElementById('play-overlay');
const audioEl = document.getElementById('ascii-audio');
const volumeSlider = document.getElementById('volume-slider');
// ── STATE ── // ── STATE ──
let state = 'IDLE'; // IDLE | PLAYING | DISSOLVING let state = 'IDLE'; // IDLE | PLAYING
let ws = null; let ws = null;
const frameBuffer = []; const frameBuffer = [];
const BUFFER_SIZE = 4; const BUFFER_SIZE = 4;
let targetFps = 24; let targetFps = 24;
let frameInterval = 1000 / targetFps; let frameInterval = 1000 / targetFps;
let renderMode = 1; let renderMode = 1;
let readyToRender = false;
// Grid & Dimensions // Grid & Dimensions
let gridCols = 0, gridRows = 0; let gridCols = 0, gridRows = 0;
let charWidth = 0, charHeight = 0; let charWidth = 0, charHeight = 0;
let xPos = null, yPos = null; let xPos = null, yPos = null;
// Selection Layer optimization
const textDecoder = new TextDecoder();
let selectionBuffer = null;
// Timing & Metrics // Timing & Metrics
let lastRenderTime = 0; let lastRenderTime = 0;
let frameCount = 0, currentFps = 0, lastFpsUpdate = 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); const CHAR_LUT = new Array(128);
for (let i = 0; i < 128; i++) CHAR_LUT[i] = String.fromCharCode(i); 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) { function buildCanvas(cols, rows) {
gridCols = cols; gridCols = cols;
gridRows = rows; gridRows = rows;
ctx.font = 'bold 8px Courier New'; ctx.font = 'bold 8px Courier New';
charWidth = Math.ceil(ctx.measureText('M').width); charWidth = Math.ceil(ctx.measureText('M').width);
charHeight = 8; charHeight = 8;
canvas.width = cols * charWidth; canvas.width = cols * charWidth;
canvas.height = rows * charHeight; canvas.height = rows * charHeight;
canvas.style.display = 'block'; canvas.style.display = 'block';
player.style.display = 'none';
container.style.minWidth = canvas.width + 'px'; // Selection Layer Buffer
container.style.minHeight = canvas.height + 'px'; 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.font = 'bold 8px Courier New';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
xPos = new Float32Array(cols); xPos = new Float32Array(cols);
yPos = new Float32Array(rows); yPos = new Float32Array(rows);
for (let c = 0; c < cols; c++) xPos[c] = c * charWidth; for (let c = 0; c < cols; c++) xPos[c] = c * charWidth;
for (let r = 0; r < rows; r++) yPos[r] = r * charHeight; for (let r = 0; r < rows; r++) yPos[r] = r * charHeight;
} }
/** // ═══════════════════════════════════════
* Initiates WebSocket connection and stream handling. // STREAM CONTROL
*/ // ═══════════════════════════════════════
function startStream() { function startStream() {
if (state !== 'IDLE') return; if (state !== 'IDLE') return;
state = 'PLAYING';
overlay.classList.add('hidden'); overlay.classList.add('hidden');
statusEl.textContent = 'Connecting...'; statusEl.textContent = 'Connecting...';
statusEl.style.color = 'var(--accent-color)'; statusEl.style.color = 'var(--accent-color)';
connectWebSocket();
}
function connectWebSocket() {
frameBuffer.length = 0; frameBuffer.length = 0;
lastFrameView = null;
frameCount = 0; frameCount = 0;
currentFps = 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 = new WebSocket(`${protocol}//${location.host}/ws`);
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (state !== 'PLAYING') return;
if (typeof event.data === 'string') { if (typeof event.data === 'string') {
if (event.data.startsWith('Error:')) { if (event.data.startsWith('Error:')) {
statusEl.textContent = event.data; statusEl.textContent = event.data;
statusEl.style.color = '#ff0000'; statusEl.style.color = '#ff0000';
state = 'IDLE';
if (ws) ws.close(); if (ws) ws.close();
setTimeout(() => resetToIdle(), 3000); setTimeout(() => finishStream(), 3000);
return; return;
} }
if (event.data.startsWith('INIT:')) { if (event.data.startsWith('INIT:')) {
const p = event.data.split(':'); const p = event.data.split(':');
targetFps = parseFloat(p[1]); targetFps = parseFloat(p[1]);
frameInterval = 1000 / targetFps; frameInterval = 1000 / targetFps;
renderMode = parseInt(p[2]); renderMode = parseInt(p[2]);
if (renderMode > 1) { buildCanvas(parseInt(p[3]), parseInt(p[4]));
buildCanvas(parseInt(p[3]), parseInt(p[4]));
} else { if (audioEl) audioEl.play().catch(() => {});
player.style.display = 'block'; readyToRender = true;
canvas.style.display = 'none'; state = 'PLAYING';
} lastRenderTime = performance.now();
lastFpsUpdate = lastRenderTime;
requestAnimationFrame(renderFrame);
return; return;
} }
frameBuffer.push(event.data); frameBuffer.push(event.data);
@ -112,39 +135,31 @@ function startStream() {
frameBuffer.push(event.data); frameBuffer.push(event.data);
} }
// Buffer Overflow Protection
while (frameBuffer.length > BUFFER_SIZE * 3) frameBuffer.shift(); 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 = () => { ws.onopen = () => { statusEl.textContent = 'Buffering...'; };
statusEl.textContent = 'Buffering...';
};
ws.onclose = () => { ws.onclose = () => {
if (state === 'PLAYING') { if (state === 'PLAYING') {
statusEl.textContent = 'Stream Ended.'; statusEl.textContent = 'Stream Ended.';
statusEl.style.color = '#888'; statusEl.style.color = '#888';
setTimeout(() => resetToIdle(), 1500); if (audioEl) audioEl.pause();
setTimeout(() => finishStream(), 800);
} }
}; };
ws.onerror = () => { ws.onerror = () => {
statusEl.textContent = 'Connection Error!'; statusEl.textContent = 'Connection Error!';
statusEl.style.color = '#ff0000'; statusEl.style.color = '#ff0000';
setTimeout(() => resetToIdle(), 2000); setTimeout(() => finishStream(), 2000);
}; };
} }
/** // ═══════════════════════════════════════
* Main render loop using requestAnimationFrame. // RENDER LOOP
*/ // ═══════════════════════════════════════
function renderFrame(now) { function renderFrame(now) {
if (state !== 'PLAYING') return; if (state !== 'PLAYING') return;
requestAnimationFrame(renderFrame); requestAnimationFrame(renderFrame);
@ -152,29 +167,27 @@ function renderFrame(now) {
const elapsed = now - lastRenderTime; const elapsed = now - lastRenderTime;
if (elapsed < frameInterval) return; if (elapsed < frameInterval) return;
// FPS Counter
frameCount++; frameCount++;
if (now - lastFpsUpdate >= 1000) { if (now - lastFpsUpdate >= 1000) {
currentFps = frameCount; currentFps = frameCount;
frameCount = 0; frameCount = 0;
lastFpsUpdate = now; lastFpsUpdate = now;
let modeText = 'B&W';
const modes = { 2: '512 Color', 3: '32K Color', 4: '262K Color', 5: '16M Ultra' }; 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} | ${modes[renderMode] || 'B&W'}`;
statusEl.textContent = `FPS: ${currentFps}/${Math.round(targetFps)} | Buf: ${frameBuffer.length} | ${modeText}`;
} }
if (frameBuffer.length === 0) return; if (frameBuffer.length === 0) return;
lastRenderTime = now; lastRenderTime = now;
const frame = frameBuffer.shift(); const frame = frameBuffer.shift();
if (renderMode === 1) { if (renderMode === 1) {
player.style.display = 'block';
player.style.color = '#fff';
player.textContent = frame; player.textContent = frame;
} else { } else {
const view = new Uint8Array(frame); const view = new Uint8Array(frame);
lastFrameView = view;
// 1. Draw Canvas (Background)
ctx.fillStyle = '#050505'; ctx.fillStyle = '#050505';
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 8px Courier New'; ctx.font = 'bold 8px Courier New';
@ -188,94 +201,37 @@ function renderFrame(now) {
prevPacked = packed; prevPacked = packed;
} }
ctx.fillText(CHAR_LUT[view[idx]], xPos[col], yPos[row]); ctx.fillText(CHAR_LUT[view[idx]], xPos[col], yPos[row]);
col++;
if (col >= gridCols) { col = 0; row++; }
}
}
}
/** // Fill Selection Buffer (char code is at view[idx])
* Visual Effect: Ripple Dissolve selectionBuffer[row * (gridCols + 1) + col] = view[idx];
* 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);
}
col++; col++;
if (col >= gridCols) { col = 0; row++; } if (col >= gridCols) { col = 0; row++; }
} }
ctx.globalAlpha = 1; // 2. Update Selection Layer (Foreground)
requestAnimationFrame(animateRipple); player.style.display = 'block';
player.style.color = 'transparent';
player.textContent = textDecoder.decode(selectionBuffer);
} }
requestAnimationFrame(animateRipple);
} }
function resetToIdle() { // ═══════════════════════════════════════
// CLEANUP
// ═══════════════════════════════════════
function finishStream() {
state = 'IDLE'; state = 'IDLE';
if (ws) { ws.onclose = null; ws.close(); ws = null; } if (ws) { ws.onclose = null; ws.close(); ws = null; }
frameBuffer.length = 0; if (audioEl) { audioEl.pause(); audioEl.src = ''; }
lastRenderTime = 0;
lastFrameView = null;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
player.textContent = ''; player.textContent = '';
player.style.display = 'none';
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
statusEl.textContent = 'Ready'; statusEl.textContent = 'Ready';
statusEl.style.color = 'rgba(255,255,255,0.6)'; statusEl.style.color = 'rgba(255,255,255,0.6)';
readyToRender = false;
frameBuffer.length = 0;
} }
// ── EVENT LISTENERS ── // ── EVENT LISTENERS ──
@ -284,14 +240,18 @@ overlay.addEventListener('click', (e) => {
startStream(); startStream();
}); });
container.addEventListener('click', (e) => { if (volumeSlider) {
if (state !== 'PLAYING') return; volumeSlider.addEventListener('input', () => {
if (audioEl) audioEl.volume = volumeSlider.value;
});
}
const rect = canvas.getBoundingClientRect(); window.addEventListener('resize', () => {
const scaleX = canvas.width / rect.width; const syncSize = (el) => {
const scaleY = canvas.height / rect.height; if (!el) return;
const clickX = (e.clientX - rect.left) * scaleX; el.style.width = container.clientWidth + 'px';
const clickY = (e.clientY - rect.top) * scaleY; el.style.height = container.clientHeight + 'px';
};
triggerRipple(clickX, clickY); 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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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 --> <!-- Core Styles -->
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<!-- Live Status Indicator -->
<div id="status" class="status">Ready</div>
<!-- Header Section --> <!-- Header Section -->
<h1>ASCILINE</h1> <header class="blog-header">
<p class="subtitle">real-time ascii streaming engine</p> <h1>ASCILINE</h1>
<p>Turning the web into a living, breathing typographic canvas.</p>
</header>
<!-- Main Player Container --> <!-- Main Content -->
<div id="player-container"> <main class="blog-container">
<!-- Text-based rendering (B&W Mode) --> <article class="blog-post">
<pre id="ascii-player"></pre> <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>
<!-- Canvas-based rendering (Color Modes) --> <p class="post-content">
<canvas id="ascii-canvas"></canvas> 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>
<!-- Tap to Play Interaction Overlay --> <!-- Video Wrapper -->
<div id="play-overlay"> <div class="video-wrapper">
<div class="play-btn"></div> <div id="status" class="status">System Ready</div>
<span class="play-label">Tap to Play</span>
</div> <!-- Main Player Container -->
</div> <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 --> <!-- Core Engine Logic -->
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>

View file

@ -6,10 +6,11 @@ Dependencies: pip install fastapi uvicorn websockets
""" """
import asyncio import asyncio
import subprocess
import numpy as np import numpy as np
import cv2 import cv2
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
import uvicorn import uvicorn
import os import os
@ -34,6 +35,51 @@ async def root():
"""Serves the Frontend (HTML/JS/CSS) file to the client.""" """Serves the Frontend (HTML/JS/CSS) file to the client."""
return HTMLResponse(get_html_content()) 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") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): 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 { :root {
--bg-color: #050505; --bg-color: #0a0a0c;
--accent-color: #00ff41; --text-color: #e0e0e0;
--accent-glow: rgba(0, 255, 65, 0.3); --accent-color: #00f3ff;
--text-muted: rgba(255, 255, 255, 0.4); --accent-secondary: #39ff14;
--player-bg: #030303; --post-bg: #121217;
--font-main: 'Courier New', monospace; --font-main: 'Outfit', sans-serif;
--font-tech: 'Courier New', monospace;
--player-bg: #050505;
} }
* { * {
@ -21,120 +23,165 @@
body { body {
background-color: var(--bg-color); 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); 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; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; margin: 50px 0;
min-height: 100vh;
font-family: var(--font-main);
overflow: hidden;
} }
/* ── UI ELEMENTS ─────────────────────────── */
.status { .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; font-size: 11px;
letter-spacing: 3px; color: var(--accent-color);
margin-bottom: 24px; margin-bottom: 12px;
font-family: var(--font-tech);
text-transform: uppercase;
letter-spacing: 2px;
} }
/* ── PLAYER CONTAINER ─────────────────────────── */ /* ── PLAYER CONTAINER ──────────────── */
#player-container { #player-container {
position: relative; 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); background: var(--player-bg);
cursor: pointer; border-radius: 4px;
min-width: 500px; width: 860px;
min-height: 280px; height: 560px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
transition: border-color 0.4s ease, box-shadow 0.4s ease; border: 1px solid #333;
}
#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);
} }
#ascii-player { #ascii-player {
font-family: var(--font-main); font-family: 'Courier New', monospace;
white-space: pre; white-space: pre;
overflow: hidden; overflow: hidden;
font-size: 8px; font-size: 8px;
line-height: 8px; line-height: 8px;
letter-spacing: 0;
font-weight: bold; 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; display: none;
user-select: text;
} }
#ascii-canvas { #ascii-canvas {
position: absolute;
top: 0;
left: 0;
z-index: 1;
display: none; display: none;
} }
/* ── OVERLAYS ───────────────────────── */ /* ── OVERLAYS ───────────────────────── */
#play-overlay { #play-overlay {
position: absolute; position: absolute;
top: 0; left: 0; right: 0; bottom: 0; top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(3, 3, 3, 0.97); background: rgba(0, 0, 0, 0.7);
z-index: 10; z-index: 10;
transition: opacity 0.5s ease;
user-select: none; user-select: none;
cursor: pointer;
} }
#play-overlay.hidden { #play-overlay.hidden {
opacity: 0; display: none;
pointer-events: none;
} }
.play-btn { .play-btn {
width: 72px; width: 60px;
height: 72px; height: 60px;
border: 2px solid rgba(0, 255, 65, 0.5); border: 3px solid var(--accent-color);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 20px; margin-bottom: 15px;
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);
} }
.play-btn::after { .play-btn::after {
@ -143,25 +190,74 @@ h1 {
width: 0; width: 0;
height: 0; height: 0;
border-style: solid; border-style: solid;
border-width: 12px 0 12px 22px; border-width: 10px 0 10px 18px;
border-color: transparent transparent transparent var(--accent-color); border-color: transparent transparent transparent var(--accent-color);
margin-left: 4px; margin-left: 5px;
} }
.play-label { .play-label {
color: rgba(0, 255, 65, 0.5); color: #fff;
font-size: 11px; font-size: 12px;
letter-spacing: 5px; letter-spacing: 2px;
text-transform: uppercase; text-transform: uppercase;
font-weight: 600;
} }
@keyframes pulse-glow { /* ── PLAYER CONTROLS BAR ───────────────── */
0%, 100% { .player-controls {
box-shadow: 0 0 0 0 rgba(0, 255, 65, 0.3), display: flex;
0 0 15px rgba(0, 255, 65, 0.05); align-items: center;
} gap: 12px;
50% { margin-top: 10px;
box-shadow: 0 0 0 12px rgba(0, 255, 65, 0), padding: 8px 12px;
0 0 25px rgba(0, 255, 65, 0.12); 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;
} }