Miscellaneous code and documentation updates
This commit is contained in:
@@ -24,6 +24,8 @@ GOOGLE_API_KEY=
|
||||
|
||||
# Mistral Voxtral TTS — https://console.mistral.ai/api-keys
|
||||
# MISTRAL_API_KEY=
|
||||
# Mistral voice ID — maak een stem aan op https://console.mistral.ai en plak het ID hier
|
||||
# MISTRAL_VOICE_ID=
|
||||
|
||||
# OpenAI TTS-1 — https://platform.openai.com/api-keys
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
+7
-7
@@ -3,11 +3,11 @@ services:
|
||||
# image: git.hiddenden.cafe/hiddenden/cozy-den:latest
|
||||
build: .
|
||||
container_name: cozy-den
|
||||
# ports:
|
||||
# - "3000:3000"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy
|
||||
# networks:
|
||||
# - proxy
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- HOST=0.0.0.0
|
||||
@@ -22,6 +22,6 @@ services:
|
||||
volumes:
|
||||
guestbook_data:
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
# networks:
|
||||
# proxy:
|
||||
# external: true
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+187
-66
@@ -21,6 +21,23 @@ import re
|
||||
import sys
|
||||
|
||||
|
||||
def load_dotenv() -> None:
|
||||
"""Load .env from the project root into os.environ (stdlib only, never overwrites)."""
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
env_path = os.path.join(project_root, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
||||
"""Extract YAML frontmatter and return (metadata_dict, body)."""
|
||||
if not text.startswith("---"):
|
||||
@@ -69,99 +86,195 @@ def clean_markdown(text: str) -> str:
|
||||
return text.strip()
|
||||
|
||||
|
||||
def tts_google(text: str, slug: str, output_path: str) -> None:
|
||||
"""Generate audio with Google Cloud TTS (free tier: 1M chars/month)."""
|
||||
def split_into_chunks(text: str, max_chars: int = 4000) -> list[str]:
|
||||
"""Split text into chunks that fit within max_chars.
|
||||
|
||||
Splits on paragraph boundaries first; falls back to sentence boundaries
|
||||
for paragraphs that are still too long. No text is ever discarded.
|
||||
"""
|
||||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||
|
||||
# Flatten into atomic segments (paragraphs or individual sentences)
|
||||
segments: list[str] = []
|
||||
for para in paragraphs:
|
||||
if len(para) <= max_chars:
|
||||
segments.append(para)
|
||||
else:
|
||||
sentences = re.split(r"(?<=[.!?])\s+", para)
|
||||
segments.extend(s for s in sentences if s.strip())
|
||||
|
||||
# Merge segments greedily into chunks under max_chars
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for segment in segments:
|
||||
if not current:
|
||||
current = segment
|
||||
elif len(current) + 2 + len(segment) <= max_chars:
|
||||
current += "\n\n" + segment
|
||||
else:
|
||||
chunks.append(current)
|
||||
current = segment
|
||||
if current:
|
||||
chunks.append(current)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def merge_audio_chunks(chunks: list[bytes], output_path: str) -> None:
|
||||
"""Concatenate MP3 byte chunks with a ~400ms silent pause between each.
|
||||
|
||||
MP3 frames are self-contained, so byte concatenation produces a valid file.
|
||||
Silent frame: 128kbps/44100Hz frame header + null payload = 417 bytes.
|
||||
16 frames * ~26.1ms each ≈ 418ms of silence.
|
||||
"""
|
||||
silent_frame = b"\xff\xfb\x90\x00" + b"\x00" * 413 # 417 bytes
|
||||
silence = silent_frame * 16
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
for i, chunk in enumerate(chunks):
|
||||
f.write(chunk)
|
||||
if i < len(chunks) - 1:
|
||||
f.write(silence)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider helpers — return raw MP3 bytes for a single text chunk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _google_synthesize(text: str, api_key: str) -> bytes:
|
||||
import base64
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
api_key = os.environ.get("GOOGLE_API_KEY")
|
||||
if not api_key:
|
||||
raise EnvironmentError("GOOGLE_API_KEY is not set")
|
||||
|
||||
payload = json.dumps({
|
||||
payload = json.dumps(
|
||||
{
|
||||
"input": {"text": text},
|
||||
"voice": {
|
||||
"languageCode": "nl-NL",
|
||||
"name": "nl-NL-Wavenet-D",
|
||||
"ssmlGender": "FEMALE",
|
||||
"ssmlGender": "MALE",
|
||||
},
|
||||
"audioConfig": {"audioEncoding": "MP3"},
|
||||
}).encode()
|
||||
}
|
||||
).encode()
|
||||
|
||||
url = f"https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}"
|
||||
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
|
||||
|
||||
req = urllib.request.Request(
|
||||
url, data=payload, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
body = json.loads(resp.read())
|
||||
|
||||
return base64.b64decode(body["audioContent"])
|
||||
|
||||
|
||||
def _mistral_synthesize(text: str, api_key: str) -> bytes:
|
||||
import base64
|
||||
audio_bytes = base64.b64decode(body["audioContent"])
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(audio_bytes)
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
body: dict = {
|
||||
"model": "voxtral-mini-tts-2603",
|
||||
"input": text,
|
||||
"response_format": "mp3",
|
||||
}
|
||||
voice_id = os.environ.get("MISTRAL_VOICE_ID", "").strip()
|
||||
if not voice_id:
|
||||
raise EnvironmentError(
|
||||
"MISTRAL_VOICE_ID is not set. "
|
||||
"Create a voice at https://console.mistral.ai and add its ID to .env"
|
||||
)
|
||||
body["voice"] = voice_id
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.mistral.ai/v1/audio/speech",
|
||||
data=json.dumps(body).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
return base64.b64decode(data["audio_data"])
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"Mistral API {exc.code}: {detail}") from exc
|
||||
|
||||
|
||||
def _openai_synthesize(text: str, api_key: str) -> bytes:
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": "gpt-4o-mini-tts",
|
||||
"input": text,
|
||||
"voice": "ash",
|
||||
"instructions": "Read aloud in a warm and friendly tone.",
|
||||
}
|
||||
).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.openai.com/v1/audio/speech",
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return resp.read()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public provider functions — chunk, call API per chunk, merge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def tts_google(text: str, slug: str, output_path: str) -> None:
|
||||
"""Generate audio with Google Cloud TTS (free tier: 1M chars/month)."""
|
||||
api_key = os.environ.get("GOOGLE_API_KEY")
|
||||
if not api_key:
|
||||
raise EnvironmentError("GOOGLE_API_KEY is not set")
|
||||
|
||||
chunks = split_into_chunks(text)
|
||||
audio_chunks: list[bytes] = []
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...")
|
||||
audio_chunks.append(_google_synthesize(chunk, api_key))
|
||||
merge_audio_chunks(audio_chunks, output_path)
|
||||
|
||||
|
||||
def tts_mistral(text: str, slug: str, output_path: str) -> None:
|
||||
"""Generate audio with Mistral Voxtral TTS (~$16/M chars)."""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
api_key = os.environ.get("MISTRAL_API_KEY")
|
||||
if not api_key:
|
||||
raise EnvironmentError("MISTRAL_API_KEY is not set")
|
||||
|
||||
payload = json.dumps({
|
||||
"model": "voxtral-mini-tts-2507",
|
||||
"input": text,
|
||||
"voice": "river",
|
||||
}).encode()
|
||||
|
||||
url = "https://api.mistral.ai/v1/audio/speech"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
audio_bytes = resp.read()
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(audio_bytes)
|
||||
chunks = split_into_chunks(text)
|
||||
audio_chunks: list[bytes] = []
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...")
|
||||
audio_chunks.append(_mistral_synthesize(chunk, api_key))
|
||||
merge_audio_chunks(audio_chunks, output_path)
|
||||
|
||||
|
||||
def tts_openai(text: str, slug: str, output_path: str) -> None:
|
||||
"""Generate audio with OpenAI TTS-1 (~$15/M chars)."""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise EnvironmentError("OPENAI_API_KEY is not set")
|
||||
|
||||
payload = json.dumps({
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": "nova",
|
||||
}).encode()
|
||||
|
||||
url = "https://api.openai.com/v1/audio/speech"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
},
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
audio_bytes = resp.read()
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(audio_bytes)
|
||||
chunks = split_into_chunks(text)
|
||||
audio_chunks: list[bytes] = []
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...")
|
||||
audio_chunks.append(_openai_synthesize(chunk, api_key))
|
||||
merge_audio_chunks(audio_chunks, output_path)
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
@@ -172,9 +285,13 @@ PROVIDERS = {
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_dotenv()
|
||||
|
||||
parser = argparse.ArgumentParser(description="Generate TTS audio for a blog post")
|
||||
parser.add_argument("file", help="Path to the .md blog post")
|
||||
parser.add_argument("--output-dir", default=".", help="Directory to write the .mp3 (default: .)")
|
||||
parser.add_argument(
|
||||
"--output-dir", default=".", help="Directory to write the .mp3 (default: .)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
md_path = args.file
|
||||
@@ -190,17 +307,21 @@ def main() -> None:
|
||||
slug = meta.get("slug") or os.path.splitext(os.path.basename(md_path))[0]
|
||||
title = meta.get("title", "")
|
||||
|
||||
# Prepend title so TTS reads it aloud
|
||||
full_text = f"{title}.\n\n{clean_markdown(body)}" if title else clean_markdown(body)
|
||||
|
||||
provider_name = os.environ.get("TTS_PROVIDER", "google").lower()
|
||||
if provider_name not in PROVIDERS:
|
||||
print(f"ERROR: unknown TTS_PROVIDER '{provider_name}'. Choose from: {', '.join(PROVIDERS)}", file=sys.stderr)
|
||||
print(
|
||||
f"ERROR: unknown TTS_PROVIDER '{provider_name}'. Choose from: {', '.join(PROVIDERS)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
output_path = os.path.join(args.output_dir, f"{slug}.mp3")
|
||||
|
||||
print(f"Generating audio for '{slug}' using provider '{provider_name}' ({len(full_text)} chars)...")
|
||||
print(
|
||||
f"Generating audio for '{slug}' using provider '{provider_name}' ({len(full_text)} chars)..."
|
||||
)
|
||||
|
||||
try:
|
||||
PROVIDERS[provider_name](full_text, slug, output_path)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
---
|
||||
title: "After the Silence"
|
||||
description: "Reflections on what remains after a meaningful relationship ends, and how love can transform without disappearing."
|
||||
pubDate: 2026-03-04
|
||||
tags: ["love", "reflection", "healing", "relationships", "personal"]
|
||||
category: "reflection"
|
||||
featuredEssay: true
|
||||
audio: true
|
||||
---
|
||||
|
||||
When a relationship ends, the world does not become quiet immediately.
|
||||
|
||||
@@ -6,6 +6,7 @@ tags: ["reflection", "identity", "intimacy", "self-knowledge", "personal", "nuan
|
||||
category: "reflection"
|
||||
featuredEssay: false
|
||||
draft: false
|
||||
audio: true
|
||||
---
|
||||
|
||||
*A quiet reflection on BDSM, nuance, and why my answers are rarely simple*
|
||||
|
||||
@@ -6,6 +6,7 @@ tags: ["reflection", "personal", "internet", "building", "devlog"]
|
||||
category: "reflection"
|
||||
featuredEssay: false
|
||||
draft: false
|
||||
audio: true
|
||||
---
|
||||
|
||||
*by LATTE*
|
||||
|
||||
@@ -9,6 +9,7 @@ readingOrder: 2
|
||||
series:
|
||||
name: "Coffee & Code"
|
||||
part: 1
|
||||
audio: true
|
||||
---
|
||||
|
||||
The modern internet is loud in a way that can be hard to notice until you step away from it.
|
||||
|
||||
@@ -7,6 +7,7 @@ category: "building"
|
||||
featuredEssay: true
|
||||
readingOrder: 1
|
||||
draft: false
|
||||
audio: true
|
||||
---
|
||||
|
||||
So I finally got around to setting up a proper blog. Welcome.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
---
|
||||
title: "Love Without Access"
|
||||
description: "A reflection on a first love - what it meant, what it cost, and why distance was the most loving thing left."
|
||||
pubDate: 2026-03-01
|
||||
tags: ["love", "reflection", "healing", "relationships", "personal"]
|
||||
audio: true
|
||||
---
|
||||
|
||||
*by LATTE*
|
||||
|
||||
@@ -3,6 +3,7 @@ title: "Rebuilding Without Rushing"
|
||||
description: "On slowing down after loss, choosing clarity over urgency, and learning that rebuilding does not have to be loud to be real."
|
||||
pubDate: 2026-03-22
|
||||
tags: ["reflection", "healing", "personal", "rebuilding", "growth"]
|
||||
audio: true
|
||||
---
|
||||
|
||||
## The idea of rebuilding
|
||||
|
||||
@@ -6,6 +6,7 @@ tags: ["games", "reflection", "relationships", "internet", "personal"]
|
||||
category: "reflection"
|
||||
featuredEssay: false
|
||||
draft: false
|
||||
audio: true
|
||||
---
|
||||
|
||||
*by LATTE*
|
||||
|
||||
@@ -6,6 +6,7 @@ tags: ["reflection", "personal", "love", "healing"]
|
||||
category: "reflection"
|
||||
featuredEssay: false
|
||||
draft: false
|
||||
audio: true
|
||||
---
|
||||
|
||||
*by LATTE*
|
||||
|
||||
@@ -5,6 +5,7 @@ pubDate: 2026-03-19
|
||||
tags: ["love", "grief", "reflection", "personal", "relationships"]
|
||||
category: "personal"
|
||||
featuredEssay: false
|
||||
audio: true
|
||||
---
|
||||
|
||||
*by LATTE*
|
||||
|
||||
@@ -6,6 +6,7 @@ tags: ["self-hosting", "privacy", "digital minimalism", "cozy web", "personal in
|
||||
category: "reflection"
|
||||
featuredEssay: false
|
||||
draft: false
|
||||
audio: true
|
||||
---
|
||||
|
||||
There is a certain kind of quiet that has become rare on the internet.
|
||||
|
||||
@@ -6,6 +6,7 @@ tags: ["love", "grief", "reflection", "personal"]
|
||||
category: "personal"
|
||||
featuredEssay: false
|
||||
draft: false
|
||||
audio: true
|
||||
---
|
||||
|
||||
*by LATTE*
|
||||
|
||||
@@ -6,6 +6,7 @@ tags: ["love", "relationships", "reflection", "emotional growth", "intimacy"]
|
||||
category: "personal"
|
||||
featuredEssay: true
|
||||
readingOrder: 3
|
||||
audio: true
|
||||
---
|
||||
|
||||
Some relationships change you in quiet but permanent ways.
|
||||
|
||||
@@ -4,6 +4,7 @@ description: "On suddenly carrying too much, being seen by the wrong person at t
|
||||
pubDate: 2026-04-04
|
||||
tags: ["personal", "work", "reflection", "healing", "life"]
|
||||
category: "reflection"
|
||||
audio: true
|
||||
---
|
||||
|
||||
Some weeks arrive with more variables than expected.
|
||||
@@ -17,7 +18,7 @@ And then, quietly, the configuration changes.
|
||||
|
||||
## Suddenly, Everything Is Your Problem
|
||||
|
||||
I work in tech support. Helpdesk.
|
||||
I work in tech support - Helpdesk.
|
||||
|
||||
Hardware. Laptops. Phones. SIM cards. Loaner devices.
|
||||
Accounts across multiple platforms and software.
|
||||
|
||||
+121
-17
@@ -119,17 +119,22 @@ const readingTime = getReadingTime(post.body);
|
||||
|
||||
{
|
||||
post.data.audio && (
|
||||
<div class="audio-player">
|
||||
<audio
|
||||
controls
|
||||
preload="none"
|
||||
aria-label={`Luister naar: ${post.data.title}`}
|
||||
>
|
||||
<source
|
||||
src={`https://audio.hiddenden.cafe/${post.slug}.mp3`}
|
||||
type="audio/mpeg"
|
||||
/>
|
||||
</audio>
|
||||
<div class="ap">
|
||||
<audio id="ap-audio" preload="none" src={`/audio/${post.slug}.mp3`}></audio>
|
||||
<button class="ap-btn" id="ap-btn" aria-label="Afspelen">
|
||||
<svg class="ap-icon ap-play" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<svg class="ap-icon ap-pause" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6 19h4V5H6zm8-14v14h4V5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="ap-waveform" id="ap-waveform">
|
||||
{[38,55,72,88,65,92,78,58,82,96,68,52,86,100,74,60,90,80,63,84,70,94,58,80,74,90,63,84,70,54,80,63,90,74,58,84].map((h, i) => (
|
||||
<span class="ap-bar" style={`height:${h}%;animation-delay:${((i * 73) % 600)}ms`}></span>
|
||||
))}
|
||||
</div>
|
||||
<span class="ap-time" id="ap-time">0:00</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -247,6 +252,33 @@ const readingTime = getReadingTime(post.body);
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const ap = document.querySelector(".ap") as HTMLElement | null;
|
||||
const audio = document.getElementById("ap-audio") as HTMLAudioElement | null;
|
||||
const btn = document.getElementById("ap-btn");
|
||||
const timeEl = document.getElementById("ap-time");
|
||||
|
||||
function fmt(s: number) {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = String(Math.floor(s % 60)).padStart(2, "0");
|
||||
return `${m}:${sec}`;
|
||||
}
|
||||
|
||||
btn?.addEventListener("click", () => {
|
||||
if (!audio) return;
|
||||
audio.paused ? audio.play() : audio.pause();
|
||||
});
|
||||
|
||||
audio?.addEventListener("play", () => ap?.setAttribute("data-playing", ""));
|
||||
audio?.addEventListener("pause", () => ap?.removeAttribute("data-playing"));
|
||||
audio?.addEventListener("ended", () => {
|
||||
ap?.removeAttribute("data-playing");
|
||||
if (timeEl) timeEl.textContent = "0:00";
|
||||
});
|
||||
audio?.addEventListener("timeupdate", () => {
|
||||
if (timeEl && audio) timeEl.textContent = fmt(audio.currentTime);
|
||||
});
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
@@ -362,15 +394,87 @@ const readingTime = getReadingTime(post.body);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
.ap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-md);
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.audio-player audio {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
accent-color: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
.ap-btn {
|
||||
flex-shrink: 0;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-bg);
|
||||
transition: background 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.ap-btn:hover {
|
||||
background: var(--color-accent-bright);
|
||||
transform: scale(1.07);
|
||||
}
|
||||
|
||||
.ap-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.ap-pause {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ap[data-playing] .ap-play {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ap[data-playing] .ap-pause {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ap-waveform {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.ap-bar {
|
||||
flex: 1;
|
||||
background: var(--color-accent);
|
||||
border-radius: 999px;
|
||||
opacity: 0.35;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.ap[data-playing] .ap-bar {
|
||||
opacity: 0.85;
|
||||
animation: ap-wave 0.55s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes ap-wave {
|
||||
from { transform: scaleY(0.25); }
|
||||
to { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
.ap-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-dim);
|
||||
min-width: 2.2rem;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
Reference in New Issue
Block a user