feat: progressive music streaming, library-first search, player fixes
Rebuild & Deploy / rebuild (push) Successful in 59s
Rebuild & Deploy / rebuild (push) Successful in 59s
- Stream downloads as they grow: raw best-audio (no mp3 transcode) served chunked from the stream endpoint mid-download, then with native Range once complete. Songs play within seconds of hitting Play. - Fix searched songs never starting: reconcileMedia now wires the stream URL when a track that was preparing gains one; audio_url is stable for the whole lifecycle so the player never reloads mid-playback. - Progress bars now parse yt-dlp stderr (stdout never carried [download]). - Prefer downloaded songs: /media/api/search merges ready library matches ahead of YouTube results; search_music/play_song tooling and the agent prompt tell Hope to always pick in_library=true. - Mute button turns solid red while the mic is muted.
This commit is contained in:
1 parent
d89918fd2a
commit
4f8eb00c9f
7 files changed
+440
-107
No files matched your search
+19
-9
@@ -1207,7 +1207,15 @@ function reconcileMedia(s) {
|
||||
const pendingJob = (s.pending || []).find(j => !track || j.track_id === track.id);
|
||||
updateMediaProgress(track, pendingJob);
|
||||
|
||||
if (track && track.id !== mediaCurrentTrackId) loadMediaTrack(track);
|
||||
if (track && track.id !== mediaCurrentTrackId) {
|
||||
loadMediaTrack(track);
|
||||
} else if (track && track.audio_url && mediaAudio.getAttribute("src") !== track.audio_url) {
|
||||
// The track was loaded before it had a stream (still downloading) and
|
||||
// now has one — wire it up so playback can start without a reload.
|
||||
mediaAudio.src = track.audio_url;
|
||||
mediaAudio.load();
|
||||
updateMediaNowCard(track);
|
||||
}
|
||||
if (!track && mediaCurrentTrackId && s.desired === "stopped") clearMediaAudio();
|
||||
|
||||
applyDesiredMediaState(s, track);
|
||||
@@ -1224,7 +1232,9 @@ function reconcileMedia(s) {
|
||||
|
||||
function applyDesiredMediaState(s, track) {
|
||||
if (s.desired === "playing") {
|
||||
if (track && track.status === "ready" && mediaAudio.src && mediaAudio.paused) {
|
||||
// audio_url presence means a playable stream exists — including a
|
||||
// still-growing download, which plays progressively.
|
||||
if (track && track.audio_url && mediaAudio.src && mediaAudio.paused) {
|
||||
mediaAudio.play().catch((e) => console.warn("playback blocked:", e));
|
||||
}
|
||||
} else if (!mediaAudio.paused) {
|
||||
@@ -1235,14 +1245,14 @@ function applyDesiredMediaState(s, track) {
|
||||
function loadMediaTrack(track) {
|
||||
mediaCurrentTrackId = track.id;
|
||||
updateMediaNowCard(track);
|
||||
if (track.audio_url && (track.kind === "stream" || track.status === "ready")) {
|
||||
if (track.audio_url) {
|
||||
if (mediaAudio.getAttribute("src") !== track.audio_url) {
|
||||
mediaAudio.src = track.audio_url;
|
||||
mediaAudio.load();
|
||||
}
|
||||
} else {
|
||||
// Downloading or failed: no stream yet. The poll flips this to the
|
||||
// real URL once status turns "ready".
|
||||
// Downloading or failed: no stream yet. reconcileMedia wires the
|
||||
// stream URL in via polling as soon as one is available.
|
||||
mediaAudio.removeAttribute("src");
|
||||
mediaAudio.load();
|
||||
updateMediaControls();
|
||||
@@ -1266,7 +1276,9 @@ function updateMediaNowCard(track) {
|
||||
if (track.status === "failed") {
|
||||
mediaChannelEl.textContent = "Download failed — pick another result or try again.";
|
||||
} else if (track.status === "downloading") {
|
||||
mediaChannelEl.textContent = "Preparing…";
|
||||
// Progressive streaming: the track plays while the download runs.
|
||||
mediaChannelEl.textContent = track.channel
|
||||
? `${track.channel} · downloading` : "Downloading…";
|
||||
} else {
|
||||
mediaChannelEl.textContent = track.channel || (track.kind === "stream" ? "Live stream" : "");
|
||||
}
|
||||
@@ -1288,9 +1300,7 @@ function updateMediaProgress(track, job) {
|
||||
mediaProgressWrap.hidden = false;
|
||||
const pct = Math.round(job.progress || 0);
|
||||
mediaProgressFill.style.width = `${pct}%`;
|
||||
const stage = job.stage === "transcoding"
|
||||
? "Re-encoding…"
|
||||
: job.stage === "queued" ? "Queued…" : "Downloading…";
|
||||
const stage = job.stage === "queued" ? "Queued…" : "Downloading…";
|
||||
mediaProgressLabel.textContent = `${stage} ${pct}%`;
|
||||
if (track) updateMediaNowCard(track);
|
||||
}
|
||||
|
||||
+235
-67
@@ -4,32 +4,36 @@
|
||||
A small FastAPI service that owns the permanent music library and player
|
||||
coordination:
|
||||
|
||||
- Library: downloaded YouTube Music tracks (mp3, transcoded via yt-dlp +
|
||||
ffmpeg) and user-added live audio stream URLs, persisted in library.json.
|
||||
- Library: downloaded YouTube Music tracks (raw best-audio files, saved by
|
||||
yt-dlp with no transcode) and user-added live audio stream URLs, persisted
|
||||
in library.json.
|
||||
- Search: yt-dlp ``ytsearch`` (YouTube Music pages share video ids), used by
|
||||
Hope's media MCP toolset and by the web UI search box.
|
||||
Hope's media MCP toolset and by the web UI search box. Ready library tracks
|
||||
that match the query are returned first so Hope prefers a downloaded copy.
|
||||
- Download jobs: one-at-a-time worker thread; progress is parsed from
|
||||
yt-dlp stdout and surfaced through GET /media/api/state.
|
||||
yt-dlp stderr and surfaced through GET /media/api/state. Playback starts
|
||||
while the file is still growing (progressive streaming), and the finished
|
||||
file stays in the library for instant replay.
|
||||
- Player state: per-room desired state (loaded track, playing/paused/stopped,
|
||||
seek hints). The browser is the actual player and reconciles against this;
|
||||
it reports real playback state back so Hope's voice tools can answer
|
||||
"what's playing".
|
||||
|
||||
Security posture matches the token server: bind 127.0.0.1 only, reachability
|
||||
exclusively through the nginx HTTPS proxy (/media/api/). File bytes for
|
||||
downloaded tracks are served as nginx static files (/media/files/, native
|
||||
HTTP Range support for seeking); this service only proxies external stream
|
||||
URLs (mixed-content: an http:// radio URL would be blocked by the browser,
|
||||
exclusively through the nginx HTTPS proxy (/media/api/). Growing downloads
|
||||
are streamed by this service (chunked, no Content-Length); finished files are
|
||||
served here too with native Range support for seeking. Live stream URLs are
|
||||
proxied (mixed-content: an http:// radio URL would be blocked by the browser,
|
||||
so it is fetched server-side and re-served over HTTPS).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -40,7 +44,7 @@ from typing import Any
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
|
||||
MEDIA_DIR = Path(os.environ.get("MEDIA_DIR", "/data/media"))
|
||||
FILES_DIR = MEDIA_DIR / "files"
|
||||
@@ -107,8 +111,11 @@ def _bump_version() -> int:
|
||||
def _track_dict(track: dict) -> dict:
|
||||
out = dict(track)
|
||||
if track["kind"] == "youtube":
|
||||
if track.get("status") == "ready":
|
||||
out["audio_url"] = f"/media/files/{track['id']}.mp3"
|
||||
# The stream endpoint serves the growing file during download and the
|
||||
# finished file afterwards; the URL is stable across the transition
|
||||
# so the browser never has to reload mid-playback.
|
||||
if track.get("status") in ("downloading", "ready"):
|
||||
out["audio_url"] = f"/media/api/stream/{track['id']}"
|
||||
out["art_url"] = (
|
||||
f"/media/thumbs/{track['id']}.jpg"
|
||||
if (THUMBS_DIR / f"{track['id']}.jpg").exists()
|
||||
@@ -179,6 +186,101 @@ def _ytdlp_base_args() -> list[str]:
|
||||
|
||||
_PROGRESS_RE = re.compile(r"\[download\]\s+([\d.]+)%")
|
||||
|
||||
# Audio containers yt-dlp can produce for a raw best-audio download.
|
||||
_AUDIO_EXTS = {
|
||||
".webm", ".opus", ".ogg", ".m4a", ".mp4", ".m4b", ".aac",
|
||||
".mp3", ".mka", ".flac", ".wav",
|
||||
}
|
||||
_AUDIO_MIME = {
|
||||
".webm": "audio/webm",
|
||||
".opus": "audio/opus",
|
||||
".ogg": "audio/ogg",
|
||||
".m4a": "audio/mp4",
|
||||
".mp4": "audio/mp4",
|
||||
".m4b": "audio/mp4",
|
||||
".aac": "audio/aac",
|
||||
".mp3": "audio/mpeg",
|
||||
".mka": "audio/x-matroska",
|
||||
".flac": "audio/flac",
|
||||
".wav": "audio/wav",
|
||||
}
|
||||
|
||||
|
||||
def _media_type_for(name: str) -> str:
|
||||
if name.endswith(".part"):
|
||||
name = name[:-5]
|
||||
return _AUDIO_MIME.get(Path(name).suffix.lower(), "audio/mpeg")
|
||||
|
||||
|
||||
def _youtube_media_file(track_id: str) -> Path | None:
|
||||
"""The on-disk audio file for a youtube track, if any.
|
||||
|
||||
During an active download yt-dlp writes ``{id}.{ext}.part`` and renames it
|
||||
to ``{id}.{ext}`` on completion — return whichever currently exists (the
|
||||
partial file wins, thumbnails and other sidecars are ignored).
|
||||
"""
|
||||
complete: Path | None = None
|
||||
for p in FILES_DIR.glob(f"{track_id}.*"):
|
||||
suffix = p.suffix.lower()
|
||||
if suffix == ".part":
|
||||
if p.stat().st_size > 0:
|
||||
return p
|
||||
elif suffix in _AUDIO_EXTS:
|
||||
if complete is None or p.name < complete.name:
|
||||
complete = p
|
||||
return complete
|
||||
|
||||
|
||||
async def _wait_for_media_file(track_id: str, timeout: float = 15.0) -> Path | None:
|
||||
"""Wait for yt-dlp to create the output file after a play request."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
path = _youtube_media_file(track_id)
|
||||
if path is not None:
|
||||
return path
|
||||
await asyncio.sleep(0.5)
|
||||
return None
|
||||
|
||||
|
||||
async def _growing_file(track_id: str, job: dict, chunk_size: int = 64 * 1024):
|
||||
"""Yield bytes from a file that yt-dlp is still writing.
|
||||
|
||||
The response stays open with no Content-Length until the download
|
||||
finishes, so the browser plays the track as it grows. The open descriptor
|
||||
keeps reading the old inode across yt-dlp's .part -> final rename, and the
|
||||
path is re-resolved if the file disappears between resolution and open.
|
||||
"""
|
||||
fd = -1
|
||||
pos = 0
|
||||
stall = 0
|
||||
try:
|
||||
while True:
|
||||
if fd == -1:
|
||||
path = _youtube_media_file(track_id)
|
||||
if path is not None:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY)
|
||||
except FileNotFoundError:
|
||||
fd = -1 # renamed between glob and open — retry
|
||||
if fd != -1:
|
||||
data = await asyncio.to_thread(os.pread, fd, chunk_size, pos)
|
||||
if data:
|
||||
pos += len(data)
|
||||
stall = 0
|
||||
yield data
|
||||
continue
|
||||
with _lock:
|
||||
active = job.get("stage") == "downloading"
|
||||
if not active:
|
||||
break
|
||||
stall += 1
|
||||
if stall > 60: # ~30s with no new bytes — stop waiting
|
||||
break
|
||||
await asyncio.sleep(0.5)
|
||||
finally:
|
||||
if fd != -1:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _run_search(query: str, limit: int) -> list[dict]:
|
||||
"""Flat ytsearch lookup — cheap, no download, works anonymously."""
|
||||
@@ -247,15 +349,18 @@ def _fail_job(job: dict, track: dict, message: str) -> None:
|
||||
|
||||
|
||||
def _download_worker(job: dict, track: dict, video_id: str) -> None:
|
||||
"""Runs in the single-worker executor: yt-dlp download + mp3 transcode."""
|
||||
target = FILES_DIR / f"{track['id']}.mp3"
|
||||
"""Runs in the single-worker executor: raw best-audio download.
|
||||
|
||||
No mp3 transcode — the audio container is streamed straight into the
|
||||
library so playback can begin while the file is still growing (the stream
|
||||
endpoint serves the partial file), and the finished file stays playable
|
||||
offline. Thumbnail handling is unchanged.
|
||||
"""
|
||||
cmd = _ytdlp_base_args() + [
|
||||
"-f", "bestaudio/best",
|
||||
"-x", "--audio-format", "mp3", "--audio-quality", "192K",
|
||||
"--embed-thumbnail", "--add-metadata",
|
||||
"--write-thumbnail", "--convert-thumbnails", "jpg",
|
||||
"--newline", "--progress",
|
||||
"--print-json",
|
||||
"--write-thumbnail", "--convert-thumbnails", "jpg",
|
||||
"--retries", "3", "--fragment-retries", "3",
|
||||
"-P", str(FILES_DIR),
|
||||
"-o", f"{track['id']}.%(ext)s",
|
||||
@@ -263,46 +368,52 @@ def _download_worker(job: dict, track: dict, video_id: str) -> None:
|
||||
]
|
||||
job["stage"] = "downloading"
|
||||
info: dict = {}
|
||||
# stderr goes to a temp file: reading it from a PIPE while the stdout
|
||||
# loop blocks could deadlock if yt-dlp ever fills the stderr buffer.
|
||||
with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as errfile:
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=errfile,
|
||||
text=True, errors="replace",
|
||||
)
|
||||
deadline = time.monotonic() + DOWNLOAD_TIMEOUT
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
if time.monotonic() > deadline:
|
||||
proc.kill()
|
||||
proc.wait(timeout=30)
|
||||
raise RuntimeError(f"Download exceeded {DOWNLOAD_TIMEOUT}s")
|
||||
line = line.strip()
|
||||
m = _PROGRESS_RE.search(line)
|
||||
if m:
|
||||
job["progress"] = min(85.0, float(m.group(1)) * 0.85)
|
||||
_persist_job(job)
|
||||
elif line.startswith(("[ExtractAudio]", "[Metadata]", "[EmbedThumbnail]")):
|
||||
job["stage"] = "transcoding"
|
||||
job["progress"] = max(job["progress"], 86.0)
|
||||
_persist_job(job)
|
||||
elif line.startswith("{"):
|
||||
try:
|
||||
info = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
proc.wait(timeout=30)
|
||||
if proc.returncode != 0:
|
||||
errfile.seek(0)
|
||||
stderr_tail = (errfile.read() or "").strip().splitlines()
|
||||
detail = stderr_tail[-1] if stderr_tail else f"exit code {proc.returncode}"
|
||||
raise RuntimeError(detail)
|
||||
if not target.exists():
|
||||
raise RuntimeError("yt-dlp finished but no mp3 was produced")
|
||||
except Exception as exc: # noqa: BLE001 — any failure marks the track failed
|
||||
_fail_job(job, track, str(exc))
|
||||
return
|
||||
# Progress lines go to stderr, the metadata JSON to stdout. Drain stderr
|
||||
# in a thread so the stdout read loop can never deadlock the pipe; the
|
||||
# tail is kept for the failure detail.
|
||||
stderr_tail: list[str] = []
|
||||
proc = None
|
||||
|
||||
def _drain_stderr() -> None:
|
||||
assert proc is not None and proc.stderr is not None
|
||||
for line in proc.stderr:
|
||||
stderr_tail.append(line)
|
||||
if len(stderr_tail) > 200:
|
||||
stderr_tail.pop(0)
|
||||
m = _PROGRESS_RE.search(line)
|
||||
if m:
|
||||
job["progress"] = min(99.0, float(m.group(1)))
|
||||
_persist_job(job)
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, errors="replace",
|
||||
)
|
||||
threading.Thread(target=_drain_stderr, daemon=True).start()
|
||||
deadline = time.monotonic() + DOWNLOAD_TIMEOUT
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
if time.monotonic() > deadline:
|
||||
proc.kill()
|
||||
proc.wait(timeout=30)
|
||||
raise RuntimeError(f"Download exceeded {DOWNLOAD_TIMEOUT}s")
|
||||
line = line.strip()
|
||||
if line.startswith("{"):
|
||||
try:
|
||||
info = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
proc.wait(timeout=30)
|
||||
if proc.returncode != 0:
|
||||
detail = stderr_tail[-1] if stderr_tail else f"exit code {proc.returncode}"
|
||||
raise RuntimeError(detail.strip())
|
||||
media = _youtube_media_file(track["id"])
|
||||
if media is None:
|
||||
raise RuntimeError("yt-dlp finished but no audio file was produced")
|
||||
except Exception as exc: # noqa: BLE001 — any failure marks the track failed
|
||||
_fail_job(job, track, str(exc))
|
||||
return
|
||||
|
||||
thumb_src = FILES_DIR / f"{track['id']}.jpg"
|
||||
try:
|
||||
@@ -317,6 +428,7 @@ def _download_worker(job: dict, track: dict, video_id: str) -> None:
|
||||
with _lock:
|
||||
track["status"] = "ready"
|
||||
track["error"] = None
|
||||
track["ext"] = media.suffix.lstrip(".").lower()
|
||||
if info:
|
||||
if info.get("title"):
|
||||
track["title"] = info["title"]
|
||||
@@ -364,11 +476,11 @@ def get_library() -> dict:
|
||||
def _delete_track_files(track: dict) -> None:
|
||||
if track["kind"] != "youtube":
|
||||
return
|
||||
for path in (
|
||||
FILES_DIR / f"{track['id']}.mp3",
|
||||
FILES_DIR / f"{track['id']}.jpg",
|
||||
THUMBS_DIR / f"{track['id']}.jpg",
|
||||
):
|
||||
paths = [
|
||||
FILES_DIR / f"{track['id']}.{ext}" for ext in
|
||||
(s.lstrip(".") for s in _AUDIO_EXTS)
|
||||
] + [FILES_DIR / f"{track['id']}.jpg", THUMBS_DIR / f"{track['id']}.jpg"]
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
@@ -394,6 +506,39 @@ def delete_track(track_id: str) -> dict:
|
||||
|
||||
# ── API: search + play + control ────────────────────────────────────────────
|
||||
|
||||
def _library_search(query: str, limit: int) -> list[dict]:
|
||||
"""Ready library tracks whose title matches the query.
|
||||
|
||||
These are merged in front of the YouTube results so Hope prefers an
|
||||
already-downloaded copy over fetching a new one.
|
||||
"""
|
||||
q = query.strip().lower()
|
||||
tokens = {t for t in re.split(r"\W+", q) if len(t) > 2}
|
||||
hits: list[dict] = []
|
||||
if not q:
|
||||
return hits
|
||||
for track in _library["tracks"].values():
|
||||
if track.get("kind") != "youtube" or track.get("status") != "ready":
|
||||
continue
|
||||
title = (track.get("title") or "").strip().lower()
|
||||
if not title:
|
||||
continue
|
||||
title_tokens = {t for t in re.split(r"\W+", title) if t}
|
||||
if not (q in title or title in q or (tokens and tokens <= title_tokens)):
|
||||
continue
|
||||
hits.append({
|
||||
"video_id": track["id"],
|
||||
"track_id": track["id"],
|
||||
"title": track.get("title") or track["id"],
|
||||
"channel": track.get("channel") or "",
|
||||
"duration": track.get("duration"),
|
||||
"view_count": None,
|
||||
"in_library": True,
|
||||
"library_status": "ready",
|
||||
})
|
||||
return hits[:limit]
|
||||
|
||||
|
||||
@app.post("/media/api/search")
|
||||
async def search(request: Request) -> dict:
|
||||
body = await request.json()
|
||||
@@ -404,7 +549,11 @@ async def search(request: Request) -> dict:
|
||||
limit = max(1, min(int(body.get("limit", 6)), 15))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "limit must be an integer")
|
||||
return {"query": query, "results": _run_search(query, limit)}
|
||||
local = _library_search(query, limit)
|
||||
remote = _run_search(query, limit)
|
||||
seen = {r["video_id"] for r in local}
|
||||
merged = local + [r for r in remote if r["video_id"] not in seen]
|
||||
return {"query": query, "results": merged[:limit]}
|
||||
|
||||
|
||||
def _resolve_play_target(body: dict) -> dict:
|
||||
@@ -588,13 +737,32 @@ _HOP_HEADERS = ("content-type", "content-range", "accept-ranges", "content-lengt
|
||||
async def stream_proxy(track_id: str, request: Request):
|
||||
with _lock:
|
||||
track = _get_track(track_id)
|
||||
job = next(
|
||||
(j for j in _jobs.values()
|
||||
if j["track_id"] == track_id and j["stage"] == "downloading"),
|
||||
None,
|
||||
)
|
||||
if not track:
|
||||
raise HTTPException(404, "unknown track")
|
||||
|
||||
if track["kind"] == "youtube":
|
||||
# Downloaded tracks are nginx static files (native Range support).
|
||||
if track.get("status") == "ready":
|
||||
return RedirectResponse(f"/media/files/{track_id}.mp3")
|
||||
raise HTTPException(409, f"track is {track.get('status')}, not ready")
|
||||
# Progressive download: an active download is streamed in real time
|
||||
# (chunked, no Content-Length) so playback starts while the file
|
||||
# grows; a finished file is served directly with native Range support.
|
||||
path = _youtube_media_file(track_id)
|
||||
if path is None and job is not None:
|
||||
path = await _wait_for_media_file(track_id)
|
||||
if path is None:
|
||||
raise HTTPException(
|
||||
404, f"audio file not available (track is {track.get('status')})"
|
||||
)
|
||||
if job is not None:
|
||||
return StreamingResponse(
|
||||
_growing_file(track_id, job),
|
||||
media_type=_media_type_for(path.name),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return FileResponse(path, media_type=_media_type_for(path.name))
|
||||
|
||||
range_header = request.headers.get("range")
|
||||
headers = {"Range": range_header} if range_header else {}
|
||||
|
||||
+9
-3
@@ -617,9 +617,15 @@ button {
|
||||
}
|
||||
|
||||
.btn-icon.muted {
|
||||
color: #ffd1ca;
|
||||
border-color: rgba(255, 128, 111, 0.5);
|
||||
background: rgba(143, 64, 58, 0.4);
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-icon.muted:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
|
||||
Reference in new issue
Block a user