Update 2025-05-21_08:58:06
This commit is contained in:
@ -42,9 +42,9 @@ uvicorn main:app --reload
|
||||
- `static/` — static HTML/JS/CSS assets
|
||||
|
||||
### Notes
|
||||
- By default, audio uploads are stored in `/data` and streams in `/srv/streams` (change in code as needed).
|
||||
- By default, audio uploads are stored in `/data`.
|
||||
- Ollama music/singing detection requires a local Whisper API at `localhost:11434`.
|
||||
- Abuse logs are written to `log.txt`.
|
||||
- Abuse logs are written to `abuse.log`.
|
||||
|
||||
## License
|
||||
MIT
|
||||
|
0
__RELOAD__
Normal file
0
__RELOAD__
Normal file
37
concat_opus.py
Normal file
37
concat_opus.py
Normal file
@ -0,0 +1,37 @@
|
||||
# concat_opus.py — Concatenate all opus files in a user directory in random order into a single stream.opus
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def concat_opus_files(user_dir: Path, output_file: Path):
|
||||
"""
|
||||
Concatenate all .opus files in user_dir (except stream.opus) in random order into output_file.
|
||||
Overwrites output_file if exists. Creates it if missing.
|
||||
"""
|
||||
files = [f for f in user_dir.glob('*.opus') if f.name != 'stream.opus']
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No opus files to concatenate in {user_dir}")
|
||||
random.shuffle(files)
|
||||
|
||||
# Create a filelist for ffmpeg concat
|
||||
filelist_path = user_dir / 'filelist.txt'
|
||||
with open(filelist_path, 'w') as f:
|
||||
for opusfile in files:
|
||||
f.write(f"file '{opusfile.resolve()}'\n")
|
||||
|
||||
# ffmpeg concat demuxer (no re-encoding)
|
||||
cmd = [
|
||||
'ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', str(filelist_path),
|
||||
'-c', 'copy', str(output_file)
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"FFmpeg concat failed: {e}")
|
||||
finally:
|
||||
if filelist_path.exists():
|
||||
filelist_path.unlink()
|
||||
if not output_file.exists():
|
||||
raise RuntimeError("Concatenation did not produce output.")
|
||||
return output_file
|
10
gunicorn.conf.py
Normal file
10
gunicorn.conf.py
Normal file
@ -0,0 +1,10 @@
|
||||
bind = "0.0.0.0:8000"
|
||||
workers = 2 # Tune based on available CPU cores
|
||||
worker_class = "uvicorn.workers.UvicornWorker"
|
||||
timeout = 60
|
||||
keepalive = 30
|
||||
loglevel = "info"
|
||||
accesslog = "-"
|
||||
errorlog = "-"
|
||||
proxy_allow_ips = "*"
|
||||
|
@ -2,14 +2,63 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pathlib import Path
|
||||
from fastapi.responses import StreamingResponse
|
||||
import asyncio
|
||||
|
||||
router = APIRouter()
|
||||
DATA_ROOT = Path("./data")
|
||||
|
||||
@router.get("/streams")
|
||||
@router.get("/streams-sse")
|
||||
def streams_sse():
|
||||
return list_streams_sse()
|
||||
|
||||
import json
|
||||
|
||||
import datetime
|
||||
|
||||
def list_streams_sse():
|
||||
async def event_generator():
|
||||
txt_path = Path("./public_streams.txt")
|
||||
if not txt_path.exists():
|
||||
print(f"[{datetime.datetime.now()}] [SSE] No public_streams.txt found")
|
||||
yield f"data: {json.dumps({'end': True})}\n\n"
|
||||
return
|
||||
try:
|
||||
with txt_path.open("r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
stream = json.loads(line)
|
||||
print(f"[{datetime.datetime.now()}] [SSE] Yielding stream: {stream}")
|
||||
yield f"data: {json.dumps(stream)}\n\n"
|
||||
await asyncio.sleep(0) # Yield control to event loop
|
||||
except Exception as e:
|
||||
print(f"[{datetime.datetime.now()}] [SSE] JSON decode error: {e}")
|
||||
continue # skip malformed lines
|
||||
print(f"[{datetime.datetime.now()}] [SSE] Yielding end event")
|
||||
yield f"data: {json.dumps({'end': True})}\n\n"
|
||||
except Exception as e:
|
||||
print(f"[{datetime.datetime.now()}] [SSE] Exception: {e}")
|
||||
yield f"data: {json.dumps({'end': True, 'error': True})}\n\n"
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
def list_streams():
|
||||
streams = []
|
||||
for user_dir in DATA_ROOT.iterdir():
|
||||
if user_dir.is_dir() and (user_dir / "stream.opus").exists():
|
||||
streams.append(user_dir.name)
|
||||
return {"streams": streams}
|
||||
txt_path = Path("./public_streams.txt")
|
||||
if not txt_path.exists():
|
||||
return {"streams": []}
|
||||
try:
|
||||
streams = []
|
||||
with txt_path.open("r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
streams.append(json.loads(line))
|
||||
except Exception:
|
||||
continue # skip malformed lines
|
||||
return {"streams": streams}
|
||||
except Exception:
|
||||
return {"streams": []}
|
||||
|
2
log.py
2
log.py
@ -9,7 +9,7 @@ def log_violation(event: str, ip: str, uid: str, reason: str):
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
log_dir = os.path.join(os.path.dirname(__file__), "log")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, "log.txt")
|
||||
log_path = os.path.join(log_dir, "abuse.log")
|
||||
log_entry = f"[{timestamp}] {event} IP={ip} UID={uid} REASON={reason}\n"
|
||||
with open(log_path, "a") as f:
|
||||
f.write(log_entry)
|
||||
|
20
magic.py
20
magic.py
@ -11,20 +11,24 @@ router = APIRouter()
|
||||
|
||||
@router.post("/magic-login")
|
||||
def magic_login(request: Request, db: Session = Depends(get_db), token: str = Form(...)):
|
||||
print(f"[magic-login] Received token: {token}")
|
||||
user = db.exec(select(User).where(User.token == token)).first()
|
||||
print(f"[magic-login] User lookup: {'found' if user else 'not found'}")
|
||||
|
||||
if not user:
|
||||
print("[magic-login] Invalid or expired token")
|
||||
return RedirectResponse(url="/?error=Invalid%20or%20expired%20token", status_code=302)
|
||||
|
||||
if user.confirmed:
|
||||
return RedirectResponse(url="/?error=Token%20already%20used", status_code=302)
|
||||
|
||||
if datetime.utcnow() - user.token_created > timedelta(minutes=15):
|
||||
if datetime.utcnow() - user.token_created > timedelta(minutes=30):
|
||||
print(f"[magic-login] Token expired for user: {user.username}")
|
||||
return RedirectResponse(url="/?error=Token%20expired", status_code=302)
|
||||
|
||||
user.confirmed = True
|
||||
# record client IP on confirmation
|
||||
user.ip = request.client.host
|
||||
db.commit()
|
||||
if not user.confirmed:
|
||||
user.confirmed = True
|
||||
user.ip = request.client.host
|
||||
db.commit()
|
||||
print(f"[magic-login] User {user.username} confirmed. Redirecting to /?login=success&confirmed_uid={user.username}")
|
||||
else:
|
||||
print(f"[magic-login] Token already used for user: {user.username}, but allowing multi-use login.")
|
||||
|
||||
return RedirectResponse(url=f"/?login=success&confirmed_uid={user.username}", status_code=302)
|
||||
|
180
main.py
180
main.py
@ -1,18 +1,24 @@
|
||||
# main.py — FastAPI backend entrypoint for dicta2stream
|
||||
|
||||
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, HTMLResponse
|
||||
from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
import os
|
||||
import subprocess
|
||||
from log import log_violation
|
||||
from models import get_user_by_uid
|
||||
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
import io
|
||||
import traceback
|
||||
import shutil
|
||||
import mimetypes
|
||||
from typing import Optional
|
||||
from models import User, UploadLog
|
||||
from sqlmodel import Session, select, SQLModel
|
||||
from database import get_db, engine
|
||||
from models import User, UserQuota
|
||||
|
||||
from fastapi import Depends
|
||||
from log import log_violation
|
||||
import secrets
|
||||
import time
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
@ -31,16 +37,72 @@ from fastapi.exception_handlers import RequestValidationError
|
||||
from fastapi.exceptions import HTTPException as FastAPIHTTPException
|
||||
|
||||
app = FastAPI(debug=debug_mode)
|
||||
|
||||
# --- CORS Middleware for SSE and API access ---
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os
|
||||
if not os.path.exists("data"):
|
||||
os.makedirs("data")
|
||||
app.mount("/audio", StaticFiles(directory="data"), name="audio")
|
||||
# Secure audio file serving endpoint (replaces static mount)
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi import Security
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)):
|
||||
# Use your existing session/cookie/token mechanism here
|
||||
uid = request.headers.get("x-uid") or request.query_params.get("uid") or request.cookies.get("uid")
|
||||
if not uid:
|
||||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||||
user = get_user_by_uid(uid)
|
||||
if not user or not user.confirmed:
|
||||
raise HTTPException(status_code=403, detail="Invalid user")
|
||||
return user
|
||||
|
||||
from range_response import range_response
|
||||
|
||||
@app.get("/audio/{uid}/{filename}")
|
||||
def get_audio(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
|
||||
# Allow public access ONLY to stream.opus
|
||||
user_dir = os.path.join("data", uid)
|
||||
file_path = os.path.join(user_dir, filename)
|
||||
real_user_dir = os.path.realpath(user_dir)
|
||||
real_file_path = os.path.realpath(file_path)
|
||||
if not real_file_path.startswith(real_user_dir):
|
||||
raise HTTPException(status_code=403, detail="Path traversal detected")
|
||||
if not os.path.isfile(real_file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if filename == "stream.opus":
|
||||
# Use range_response for browser seeking support
|
||||
return range_response(request, real_file_path, content_type="audio/ogg")
|
||||
# Otherwise, require authentication and owner check
|
||||
try:
|
||||
from fastapi import Security
|
||||
current_user = get_current_user(request, db)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=403, detail="Not allowed")
|
||||
if uid != current_user.username:
|
||||
raise HTTPException(status_code=403, detail="Not allowed")
|
||||
return FileResponse(real_file_path, media_type="audio/ogg")
|
||||
|
||||
if debug_mode:
|
||||
print("[DEBUG] FastAPI running in debug mode.")
|
||||
|
||||
# Global error handler to always return JSON
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from models import get_user_by_uid, UserQuota
|
||||
|
||||
@app.exception_handler(RateLimitExceeded)
|
||||
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||
return JSONResponse(status_code=429, content={"detail": "Rate limit exceeded. Please try again later."})
|
||||
|
||||
@app.exception_handler(FastAPIHTTPException)
|
||||
async def http_exception_handler(request: FastAPIRequest, exc: FastAPIHTTPException):
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
@ -57,20 +119,64 @@ async def generic_exception_handler(request: FastAPIRequest, exc: Exception):
|
||||
from register import router as register_router
|
||||
from magic import router as magic_router
|
||||
from upload import router as upload_router
|
||||
from redirect import router as redirect_router
|
||||
from streams import router as streams_router
|
||||
from list_user_files import router as list_user_files_router
|
||||
|
||||
app.include_router(streams_router)
|
||||
|
||||
from list_streams import router as list_streams_router
|
||||
|
||||
app.include_router(register_router)
|
||||
app.include_router(magic_router)
|
||||
app.include_router(upload_router)
|
||||
app.include_router(redirect_router)
|
||||
app.include_router(list_user_files_router)
|
||||
app.include_router(list_streams_router)
|
||||
|
||||
# Serve static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@app.post("/log-client")
|
||||
async def log_client(request: Request):
|
||||
try:
|
||||
data = await request.json()
|
||||
msg = data.get("msg", "")
|
||||
ip = request.client.host
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
log_dir = os.path.join(os.path.dirname(__file__), "log")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, "debug.log")
|
||||
log_entry = f"[{timestamp}] IP={ip} MSG={msg}\n"
|
||||
with open(log_path, "a") as f:
|
||||
f.write(log_entry)
|
||||
if os.getenv("DEBUG", "0") in ("1", "true", "True"):
|
||||
print(f"[CLIENT-DEBUG] {log_entry.strip()}")
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
# Enhanced error logging
|
||||
import sys
|
||||
import traceback
|
||||
error_log_dir = os.path.join(os.path.dirname(__file__), "log")
|
||||
os.makedirs(error_log_dir, exist_ok=True)
|
||||
error_log_path = os.path.join(error_log_dir, "debug-errors.log")
|
||||
tb = traceback.format_exc()
|
||||
try:
|
||||
req_body = await request.body()
|
||||
except Exception:
|
||||
req_body = b"<failed to read body>"
|
||||
error_entry = (
|
||||
f"[{datetime.utcnow().isoformat()}] /log-client ERROR: {type(e).__name__}: {e}\n"
|
||||
f"Request IP: {getattr(request.client, 'host', None)}\n"
|
||||
f"Request body: {req_body}\n"
|
||||
f"Traceback:\n{tb}\n"
|
||||
)
|
||||
try:
|
||||
with open(error_log_path, "a") as ef:
|
||||
ef.write(error_entry)
|
||||
except Exception as ef_exc:
|
||||
print(f"[CLIENT-DEBUG-ERROR] Failed to write error log: {ef_exc}", file=sys.stderr)
|
||||
print(error_entry, file=sys.stderr)
|
||||
return {"status": "error", "detail": str(e)}
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def serve_index():
|
||||
with open("static/index.html") as f:
|
||||
@ -116,9 +222,6 @@ def debug(request: Request):
|
||||
"headers": dict(request.headers),
|
||||
}
|
||||
|
||||
STREAM_DIR = "/srv/streams"
|
||||
ICECAST_BASE_URL = "https://dicta2stream.net/stream/"
|
||||
ICECAST_MOUNT_PREFIX = "user-"
|
||||
MAX_QUOTA_BYTES = 100 * 1024 * 1024
|
||||
|
||||
@app.post("/delete-account")
|
||||
@ -142,47 +245,15 @@ async def delete_account(data: dict, request: Request, db: Session = Depends(get
|
||||
db.commit()
|
||||
|
||||
import shutil
|
||||
user_dir = os.path.join(STREAM_DIR, user.username)
|
||||
# Only allow deletion within STREAM_DIR
|
||||
user_dir = os.path.join('data', user.username)
|
||||
real_user_dir = os.path.realpath(user_dir)
|
||||
if not real_user_dir.startswith(os.path.realpath(STREAM_DIR)):
|
||||
if not real_user_dir.startswith(os.path.realpath('data')):
|
||||
raise HTTPException(status_code=400, detail="Invalid user directory")
|
||||
if os.path.exists(real_user_dir):
|
||||
shutil.rmtree(real_user_dir, ignore_errors=True)
|
||||
|
||||
return {"message": "User deleted"}
|
||||
|
||||
@app.post("/upload")
|
||||
async def upload_audio(
|
||||
request: Request,
|
||||
uid: str = Form(...),
|
||||
file: UploadFile = File(...)
|
||||
):
|
||||
ip = request.client.host
|
||||
user = get_user_by_uid(uid)
|
||||
if not user:
|
||||
log_violation("UPLOAD", ip, uid, "UID not found")
|
||||
raise HTTPException(status_code=403, detail="Invalid user ID")
|
||||
|
||||
if user.ip != ip:
|
||||
log_violation("UPLOAD", ip, uid, "UID/IP mismatch")
|
||||
raise HTTPException(status_code=403, detail="Device/IP mismatch")
|
||||
|
||||
user_dir = os.path.join(STREAM_DIR, user.username)
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
raw_path = os.path.join(user_dir, "upload.wav")
|
||||
final_path = os.path.join(user_dir, "stream.opus")
|
||||
|
||||
with open(raw_path, "wb") as out:
|
||||
content = await file.read()
|
||||
out.write(content)
|
||||
|
||||
usage = subprocess.check_output(["du", "-sb", user_dir]).split()[0]
|
||||
if int(usage) > MAX_QUOTA_BYTES:
|
||||
os.remove(raw_path)
|
||||
log_violation("UPLOAD", ip, uid, "Quota exceeded")
|
||||
raise HTTPException(status_code=403, detail="Quota exceeded")
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
# from detect_content_type_whisper_ollama import detect_content_type_whisper_ollama # Broken import: module not found
|
||||
content_type = None
|
||||
@ -214,8 +285,7 @@ async def upload_audio(
|
||||
except Exception as e:
|
||||
log_violation("QUOTA", ip, uid, f"Quota update failed: {e}")
|
||||
|
||||
stream_url = f"{ICECAST_BASE_URL}{ICECAST_MOUNT_PREFIX}{user.username}.opus"
|
||||
return {"stream_url": stream_url}
|
||||
return {}
|
||||
|
||||
@app.delete("/uploads/{uid}/{filename}")
|
||||
def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
|
||||
@ -227,7 +297,7 @@ def delete_file(uid: str, filename: str, request: Request, db: Session = Depends
|
||||
if user.ip != ip:
|
||||
raise HTTPException(status_code=403, detail="Device/IP mismatch")
|
||||
|
||||
user_dir = os.path.join(STREAM_DIR, user.username)
|
||||
user_dir = os.path.join('data', user.username)
|
||||
target_path = os.path.join(user_dir, filename)
|
||||
# Prevent path traversal attacks
|
||||
real_target_path = os.path.realpath(target_path)
|
||||
@ -268,7 +338,7 @@ def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
|
||||
if not user or user.ip != ip:
|
||||
raise HTTPException(status_code=403, detail="Unauthorized access")
|
||||
|
||||
user_dir = os.path.join(STREAM_DIR, user.username)
|
||||
user_dir = os.path.join('data', user.username)
|
||||
files = []
|
||||
if os.path.exists(user_dir):
|
||||
for f in os.listdir(user_dir):
|
||||
@ -280,7 +350,7 @@ def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
|
||||
quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0
|
||||
|
||||
return {
|
||||
"stream_url": f"{ICECAST_BASE_URL}{ICECAST_MOUNT_PREFIX}{user.username}.opus",
|
||||
|
||||
"files": files,
|
||||
"quota": quota_mb
|
||||
}
|
||||
|
1
public_streams.txt
Normal file
1
public_streams.txt
Normal file
@ -0,0 +1 @@
|
||||
{"uid":"devuser","size":22455090,"mtime":1747563720}
|
60
range_response.py
Normal file
60
range_response.py
Normal file
@ -0,0 +1,60 @@
|
||||
# range_response.py — Range request support for audio files in FastAPI
|
||||
import os
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Generator
|
||||
|
||||
def parse_range_header(range_header, file_size):
|
||||
if not range_header or not range_header.startswith('bytes='):
|
||||
return None
|
||||
ranges = range_header[6:].split(',')[0].strip().split('-')
|
||||
if len(ranges) != 2:
|
||||
return None
|
||||
try:
|
||||
start = int(ranges[0]) if ranges[0] else 0
|
||||
end = int(ranges[1]) if ranges[1] else file_size - 1
|
||||
if start > end or end >= file_size:
|
||||
return None
|
||||
return start, end
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def file_stream_generator(path, start, end, chunk_size=8192) -> Generator[bytes, None, None]:
|
||||
with open(path, 'rb') as f:
|
||||
f.seek(start)
|
||||
remaining = end - start + 1
|
||||
while remaining > 0:
|
||||
chunk = f.read(min(chunk_size, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
remaining -= len(chunk)
|
||||
|
||||
def range_response(request: Request, file_path: str, content_type: str = 'audio/ogg'):
|
||||
file_size = os.path.getsize(file_path)
|
||||
range_header = request.headers.get('range')
|
||||
range_tuple = parse_range_header(range_header, file_size)
|
||||
if range_tuple:
|
||||
start, end = range_tuple
|
||||
headers = {
|
||||
'Content-Range': f'bytes {start}-{end}/{file_size}',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': str(end - start + 1),
|
||||
}
|
||||
return StreamingResponse(
|
||||
file_stream_generator(file_path, start, end),
|
||||
status_code=206,
|
||||
media_type=content_type,
|
||||
headers=headers
|
||||
)
|
||||
else:
|
||||
headers = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': str(file_size),
|
||||
}
|
||||
return StreamingResponse(
|
||||
file_stream_generator(file_path, 0, file_size - 1),
|
||||
status_code=200,
|
||||
media_type=content_type,
|
||||
headers=headers
|
||||
)
|
14
redirect.py
14
redirect.py
@ -1,16 +1,2 @@
|
||||
# redirect.py — Short stream link: /stream/{uid} → /stream/{uid}/stream.opus
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
DATA_ROOT = Path("/data")
|
||||
|
||||
@router.get("/stream/{uid}")
|
||||
def redirect_to_stream(uid: str):
|
||||
stream_path = DATA_ROOT / uid / "stream.opus"
|
||||
if not stream_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Stream not found")
|
||||
|
||||
return RedirectResponse(f"/stream/{uid}/stream.opus")
|
||||
|
53
register.py
53
register.py
@ -15,14 +15,51 @@ MAGIC_DOMAIN = "https://dicta2stream.net"
|
||||
|
||||
@router.post("/register")
|
||||
def register(request: Request, email: str = Form(...), user: str = Form(...), db: Session = Depends(get_db)):
|
||||
if db.get(User, email):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
# Try to find user by email or username
|
||||
existing_user = db.get(User, email)
|
||||
if not existing_user:
|
||||
# Try by username (since username is not primary key, need to query)
|
||||
stmt = select(User).where(User.username == user)
|
||||
existing_user = db.exec(stmt).first()
|
||||
token = str(uuid.uuid4())
|
||||
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
|
||||
db.add(UserQuota(uid=user))
|
||||
db.commit()
|
||||
|
||||
if existing_user:
|
||||
# Update token, timestamp, and ip, set confirmed False
|
||||
from datetime import datetime
|
||||
existing_user.token = token
|
||||
existing_user.token_created = datetime.utcnow()
|
||||
existing_user.confirmed = False
|
||||
existing_user.ip = request.client.host
|
||||
db.add(existing_user)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
||||
else:
|
||||
# Register new user
|
||||
db.add(User(email=email, username=user, token=token, confirmed=False, ip=request.client.host))
|
||||
db.add(UserQuota(uid=user))
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
if isinstance(e, IntegrityError):
|
||||
# Race condition: user created after our check
|
||||
# Try again as login
|
||||
stmt = select(User).where((User.email == email) | (User.username == user))
|
||||
existing_user = db.exec(stmt).first()
|
||||
if existing_user:
|
||||
existing_user.token = token
|
||||
existing_user.confirmed = False
|
||||
existing_user.ip = request.client.host
|
||||
db.add(existing_user)
|
||||
db.commit()
|
||||
else:
|
||||
raise HTTPException(status_code=409, detail="Username or email already exists.")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {e}")
|
||||
# Send magic link
|
||||
msg = EmailMessage()
|
||||
msg["From"] = MAGIC_FROM
|
||||
msg["To"] = email
|
||||
@ -30,11 +67,9 @@ def register(request: Request, email: str = Form(...), user: str = Form(...), db
|
||||
msg.set_content(
|
||||
f"Hello {user},\n\nClick to confirm your account:\n{MAGIC_DOMAIN}/?token={token}\n\nThis link is valid for one-time login."
|
||||
)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP("localhost") as smtp:
|
||||
smtp.send_message(msg)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Email failed: {e}")
|
||||
|
||||
return { "message": "Confirmation sent" }
|
||||
|
14
requirements.in
Normal file
14
requirements.in
Normal file
@ -0,0 +1,14 @@
|
||||
fastapi==0.115.12
|
||||
httptools==0.6.4
|
||||
pip==25.0.1
|
||||
psycopg2-binary==2.9.10
|
||||
python-dotenv==1.1.0
|
||||
python-multipart==0.0.20
|
||||
PyYAML==6.0.2
|
||||
setuptools==66.1.1
|
||||
slowapi==0.1.9
|
||||
sqlmodel==0.0.24
|
||||
uvicorn==0.34.2
|
||||
uvloop==0.21.0
|
||||
watchfiles==1.0.5
|
||||
websockets==15.0.1
|
@ -1,9 +1,75 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlmodel
|
||||
python-dotenv
|
||||
slowapi
|
||||
requests
|
||||
smtplib
|
||||
email
|
||||
psycopg2-binary
|
||||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.11
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile requirements.in
|
||||
#
|
||||
annotated-types==0.6.0
|
||||
# via pydantic
|
||||
anyio==4.2.0
|
||||
# via
|
||||
# starlette
|
||||
# watchfiles
|
||||
click==8.1.3
|
||||
# via uvicorn
|
||||
deprecated==1.2.13
|
||||
# via limits
|
||||
fastapi==0.115.12
|
||||
# via -r requirements.in
|
||||
greenlet==3.2.1
|
||||
# via sqlalchemy
|
||||
h11==0.14.0
|
||||
# via uvicorn
|
||||
httptools==0.6.4
|
||||
# via -r requirements.in
|
||||
idna==3.4
|
||||
# via anyio
|
||||
limits==3.2.0
|
||||
# via slowapi
|
||||
packaging==23.0
|
||||
# via limits
|
||||
psycopg2-binary==2.9.10
|
||||
# via -r requirements.in
|
||||
pydantic==2.6.0
|
||||
# via
|
||||
# fastapi
|
||||
# sqlmodel
|
||||
pydantic-core==2.16.1
|
||||
# via pydantic
|
||||
python-dotenv==1.1.0
|
||||
# via -r requirements.in
|
||||
python-multipart==0.0.20
|
||||
# via -r requirements.in
|
||||
pyyaml==6.0.2
|
||||
# via -r requirements.in
|
||||
slowapi==0.1.9
|
||||
# via -r requirements.in
|
||||
sniffio==1.3.0
|
||||
# via anyio
|
||||
sqlalchemy==2.0.40
|
||||
# via sqlmodel
|
||||
sqlmodel==0.0.24
|
||||
# via -r requirements.in
|
||||
starlette==0.46.1
|
||||
# via fastapi
|
||||
typing-extensions==4.13.2
|
||||
# via
|
||||
# fastapi
|
||||
# limits
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# sqlalchemy
|
||||
uvicorn==0.34.2
|
||||
# via -r requirements.in
|
||||
uvloop==0.21.0
|
||||
# via -r requirements.in
|
||||
watchfiles==1.0.5
|
||||
# via -r requirements.in
|
||||
websockets==15.0.1
|
||||
# via -r requirements.in
|
||||
wrapt==1.15.0
|
||||
# via deprecated
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# pip
|
||||
# setuptools
|
||||
|
695
static/app.js
695
static/app.js
@ -1,27 +1,52 @@
|
||||
// app.js — Frontend upload + minimal native player logic with slide-in and pulse effect
|
||||
|
||||
import { playBeep } from "./sound.js";
|
||||
|
||||
// 🔔 Minimal toast helper so calls to showToast() don’t fail
|
||||
function showToast(msg) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
toast.textContent = msg;
|
||||
toast.style.position = "fixed";
|
||||
toast.style.bottom = "1.5rem";
|
||||
toast.style.left = "50%";
|
||||
toast.style.transform = "translateX(-50%)";
|
||||
toast.style.background = "#333";
|
||||
toast.style.color = "#fff";
|
||||
toast.style.padding = "0.6em 1.2em";
|
||||
toast.style.borderRadius = "6px";
|
||||
toast.style.boxShadow = "0 2px 6px rgba(0,0,0,.2)";
|
||||
toast.style.zIndex = 9999;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
import { playBeep } from "./sound.js";
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
|
||||
// Log debug messages to server
|
||||
export function logToServer(msg) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/log", true);
|
||||
xhr.setRequestHeader("Content-Type", "application/json");
|
||||
xhr.send(JSON.stringify({ msg }));
|
||||
}
|
||||
|
||||
// Expose for debugging
|
||||
window.logToServer = logToServer;
|
||||
|
||||
// Handle magic link login redirect
|
||||
(function handleMagicLoginRedirect() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('login') === 'success' && params.get('confirmed_uid')) {
|
||||
const username = params.get('confirmed_uid');
|
||||
localStorage.setItem('uid', username);
|
||||
logToServer(`[DEBUG] localStorage.setItem('uid', '${username}')`);
|
||||
localStorage.setItem('confirmed_uid', username);
|
||||
logToServer(`[DEBUG] localStorage.setItem('confirmed_uid', '${username}')`);
|
||||
const uidTime = Date.now().toString();
|
||||
localStorage.setItem('uid_time', uidTime);
|
||||
logToServer(`[DEBUG] localStorage.setItem('uid_time', '${uidTime}')`);
|
||||
// Set uid as cookie for backend authentication
|
||||
document.cookie = "uid=" + encodeURIComponent(username) + "; path=/";
|
||||
// Remove query params from URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
// Reload to show dashboard as logged in
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
})();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// (Removed duplicate logToServer definition)
|
||||
|
||||
// Guest vs. logged-in toggling is now handled by dashboard.js
|
||||
// --- Public profile view logic ---
|
||||
function showProfilePlayerFromUrl() {
|
||||
@ -43,373 +68,303 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`;
|
||||
const meDesc = document.querySelector("#me-page p");
|
||||
if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`;
|
||||
// Load playlist for the given profileUid
|
||||
loadProfilePlaylist(profileUid);
|
||||
// Show a Play Stream button for explicit user action
|
||||
const streamInfo = document.getElementById("stream-info");
|
||||
if (streamInfo) {
|
||||
streamInfo.innerHTML = "";
|
||||
const playBtn = document.createElement('button');
|
||||
playBtn.textContent = "▶ Play Stream";
|
||||
playBtn.onclick = () => {
|
||||
loadProfileStream(profileUid);
|
||||
playBtn.disabled = true;
|
||||
};
|
||||
streamInfo.appendChild(playBtn);
|
||||
streamInfo.hidden = false;
|
||||
}
|
||||
// Do NOT call loadProfileStream(profileUid) automatically!
|
||||
}
|
||||
}
|
||||
}
|
||||
// Run on popstate (SPA navigation and browser back/forward)
|
||||
window.addEventListener('popstate', showProfilePlayerFromUrl);
|
||||
|
||||
async function loadProfilePlaylist(uid) {
|
||||
const meAudio = document.getElementById("me-audio");
|
||||
if (!meAudio) return;
|
||||
const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`);
|
||||
const data = await resp.json();
|
||||
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
|
||||
meAudio.src = "";
|
||||
return;
|
||||
}
|
||||
// Shuffle playlist
|
||||
function shuffle(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
window.mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`));
|
||||
window.mePlaylistIdx = 0;
|
||||
const newSrc = window.mePlaylist[window.mePlaylistIdx];
|
||||
meAudio.src = newSrc;
|
||||
meAudio.load();
|
||||
meAudio.play().catch(() => {/* autoplay may be blocked, ignore */});
|
||||
// --- Only run showProfilePlayerFromUrl after session/profile checks are complete ---
|
||||
function runProfilePlayerIfSessionValid() {
|
||||
if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return;
|
||||
showProfilePlayerFromUrl();
|
||||
}
|
||||
window.loadProfilePlaylist = loadProfilePlaylist;
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setTimeout(runProfilePlayerIfSessionValid, 200);
|
||||
});
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(runProfilePlayerIfSessionValid, 200);
|
||||
});
|
||||
window.showProfilePlayerFromUrl = showProfilePlayerFromUrl;
|
||||
|
||||
// --- Playlist for #me-page ---
|
||||
const mePageLink = document.getElementById("show-me");
|
||||
const meAudio = document.getElementById("me-audio");
|
||||
const copyUrlBtn = document.getElementById("copy-url");
|
||||
if (copyUrlBtn) copyUrlBtn.onclick = () => {
|
||||
const uid = localStorage.getItem("uid");
|
||||
if (uid) {
|
||||
const streamUrl = `${window.location.origin}/stream/${encodeURIComponent(uid)}`;
|
||||
navigator.clipboard.writeText(streamUrl);
|
||||
showToast(`Copied your stream URL: ${streamUrl}`);
|
||||
} else {
|
||||
showToast("No user stream URL available");
|
||||
// Global audio state
|
||||
let globalAudio = null;
|
||||
let currentStreamUid = null;
|
||||
let audioPlaying = false;
|
||||
let lastPosition = 0;
|
||||
|
||||
// Expose main audio element for other scripts
|
||||
window.getMainAudio = () => globalAudio;
|
||||
window.stopMainAudio = () => {
|
||||
if (globalAudio) {
|
||||
globalAudio.pause();
|
||||
audioPlaying = false;
|
||||
updatePlayPauseButton();
|
||||
}
|
||||
};
|
||||
let mePlaylist = [];
|
||||
let mePlaylistIdx = 0;
|
||||
// Playlist UI is hidden, so do not render
|
||||
|
||||
function getOrCreateAudioElement() {
|
||||
if (!globalAudio) {
|
||||
globalAudio = document.getElementById('me-audio');
|
||||
if (!globalAudio) {
|
||||
console.error('Audio element not found');
|
||||
return null;
|
||||
}
|
||||
// Set up audio element properties
|
||||
globalAudio.preload = 'metadata'; // Preload metadata for better performance
|
||||
globalAudio.crossOrigin = 'use-credentials'; // Use credentials for authenticated requests
|
||||
globalAudio.setAttribute('crossorigin', 'use-credentials'); // Explicitly set the attribute
|
||||
|
||||
// Set up event listeners
|
||||
globalAudio.addEventListener('play', () => {
|
||||
audioPlaying = true;
|
||||
updatePlayPauseButton();
|
||||
});
|
||||
globalAudio.addEventListener('pause', () => {
|
||||
audioPlaying = false;
|
||||
updatePlayPauseButton();
|
||||
});
|
||||
globalAudio.addEventListener('timeupdate', () => lastPosition = globalAudio.currentTime);
|
||||
|
||||
// Add error handling
|
||||
globalAudio.addEventListener('error', (e) => {
|
||||
console.error('Audio error:', e);
|
||||
showToast('❌ Audio playback error');
|
||||
});
|
||||
}
|
||||
return globalAudio;
|
||||
}
|
||||
|
||||
// Function to update play/pause button state
|
||||
function updatePlayPauseButton() {
|
||||
const audio = getOrCreateAudioElement();
|
||||
if (playPauseButton && audio) {
|
||||
playPauseButton.textContent = audio.paused ? '▶' : '⏸️';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize play/pause button
|
||||
const playPauseButton = document.getElementById('play-pause');
|
||||
if (playPauseButton) {
|
||||
// Set initial state
|
||||
updatePlayPauseButton();
|
||||
|
||||
// Add click handler
|
||||
playPauseButton.addEventListener('click', () => {
|
||||
const audio = getOrCreateAudioElement();
|
||||
if (audio) {
|
||||
if (audio.paused) {
|
||||
// Stop any playing public streams first
|
||||
const publicPlayers = document.querySelectorAll('.stream-player audio');
|
||||
publicPlayers.forEach(player => {
|
||||
if (!player.paused) {
|
||||
player.pause();
|
||||
const button = player.closest('.stream-player').querySelector('.play-pause');
|
||||
if (button) {
|
||||
button.textContent = '▶';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
audio.play().catch(e => {
|
||||
console.error('Play failed:', e);
|
||||
audioPlaying = false;
|
||||
});
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
updatePlayPauseButton();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Preload audio without playing it
|
||||
function preloadAudio(src) {
|
||||
return new Promise((resolve) => {
|
||||
const audio = new Audio();
|
||||
audio.preload = 'auto';
|
||||
audio.crossOrigin = 'anonymous';
|
||||
audio.src = src;
|
||||
audio.load();
|
||||
audio.oncanplaythrough = () => resolve(audio);
|
||||
});
|
||||
}
|
||||
|
||||
// Load and play a stream
|
||||
async function loadProfileStream(uid) {
|
||||
const audio = getOrCreateAudioElement();
|
||||
if (!audio) return null;
|
||||
|
||||
// Always reset current stream and update audio source
|
||||
currentStreamUid = uid;
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
|
||||
// Wait a moment to ensure the previous source is cleared
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Set new source with cache-busting timestamp
|
||||
audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
|
||||
|
||||
// Try to play immediately
|
||||
try {
|
||||
await audio.play();
|
||||
audioPlaying = true;
|
||||
} catch (e) {
|
||||
console.error('Play failed:', e);
|
||||
audioPlaying = false;
|
||||
}
|
||||
|
||||
// Show stream info
|
||||
const streamInfo = document.getElementById("stream-info");
|
||||
if (streamInfo) streamInfo.hidden = false;
|
||||
|
||||
// Update button state
|
||||
updatePlayPauseButton();
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
// Load and play a stream
|
||||
async function loadProfileStream(uid) {
|
||||
const audio = getOrCreateAudioElement();
|
||||
if (!audio) return null;
|
||||
|
||||
// Hide playlist controls
|
||||
const mePrevBtn = document.getElementById("me-prev");
|
||||
if (mePrevBtn) mePrevBtn.style.display = "none";
|
||||
const meNextBtn = document.getElementById("me-next");
|
||||
if (meNextBtn) meNextBtn.style.display = "none";
|
||||
|
||||
async function loadUserPlaylist() {
|
||||
const uid = localStorage.getItem("uid");
|
||||
if (!uid) return;
|
||||
const resp = await fetch(`/user-files/${encodeURIComponent(uid)}`);
|
||||
const data = await resp.json();
|
||||
if (!data.files || !Array.isArray(data.files) || data.files.length === 0) {
|
||||
meAudio.src = "";
|
||||
return;
|
||||
}
|
||||
// Shuffle playlist
|
||||
function shuffle(array) {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
mePlaylist = shuffle(data.files.map(f => `/audio/${uid}/${f}`));
|
||||
mePlaylistIdx = 0;
|
||||
const newSrc = mePlaylist[mePlaylistIdx];
|
||||
const prevSrc = meAudio.src;
|
||||
const wasPlaying = !meAudio.paused && !meAudio.ended && meAudio.currentTime > 0;
|
||||
const fullNewSrc = window.location.origin + newSrc;
|
||||
if (prevSrc !== fullNewSrc) {
|
||||
meAudio.src = newSrc;
|
||||
meAudio.load();
|
||||
} // else: do nothing, already loaded
|
||||
// Don't call load() if already playing the correct file
|
||||
// Don't call load() redundantly
|
||||
// Don't set src redundantly
|
||||
// This prevents DOMException from fetch aborts
|
||||
}
|
||||
// Handle navigation to "Your Stream"
|
||||
const mePageLink = document.getElementById("show-me");
|
||||
if (mePageLink) {
|
||||
mePageLink.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const uid = localStorage.getItem("uid");
|
||||
if (!uid) return;
|
||||
|
||||
if (mePageLink && meAudio) {
|
||||
mePageLink.addEventListener("click", async () => {
|
||||
await loadUserPlaylist();
|
||||
// Start playback from current index
|
||||
if (mePlaylist.length > 0) {
|
||||
const newSrc = mePlaylist[mePlaylistIdx];
|
||||
const prevSrc = meAudio.src;
|
||||
const fullNewSrc = window.location.origin + newSrc;
|
||||
if (prevSrc !== fullNewSrc) {
|
||||
meAudio.src = newSrc;
|
||||
meAudio.load();
|
||||
// Show loading state
|
||||
const streamInfo = document.getElementById("stream-info");
|
||||
if (streamInfo) {
|
||||
streamInfo.hidden = false;
|
||||
streamInfo.innerHTML = '<p>Loading stream...</p>';
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the stream but don't autoplay
|
||||
await loadProfileStream(uid);
|
||||
|
||||
// Update URL without triggering a full page reload
|
||||
if (window.location.pathname !== '/') {
|
||||
window.history.pushState({}, '', '/');
|
||||
}
|
||||
meAudio.play();
|
||||
}
|
||||
});
|
||||
meAudio.addEventListener("ended", () => {
|
||||
if (mePlaylist.length > 1) {
|
||||
mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length;
|
||||
meAudio.src = mePlaylist[mePlaylistIdx];
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
} else if (mePlaylist.length === 1) {
|
||||
// Only one file: restart
|
||||
meAudio.currentTime = 0;
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
}
|
||||
});
|
||||
|
||||
// Detect player stop and random play a new track
|
||||
meAudio.addEventListener("pause", () => {
|
||||
// Only trigger if playback reached the end and playlist has more than 1 track
|
||||
if (meAudio.ended && mePlaylist.length > 1) {
|
||||
let nextIdx;
|
||||
do {
|
||||
nextIdx = Math.floor(Math.random() * mePlaylist.length);
|
||||
} while (nextIdx === mePlaylistIdx && mePlaylist.length > 1);
|
||||
mePlaylistIdx = nextIdx;
|
||||
meAudio.currentTime = 0;
|
||||
meAudio.src = mePlaylist[mePlaylistIdx];
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
|
||||
// Show the me-page section
|
||||
const mePage = document.getElementById('me-page');
|
||||
if (mePage) {
|
||||
document.querySelectorAll('main > section').forEach(s => s.hidden = s.id !== 'me-page');
|
||||
}
|
||||
|
||||
// Clear loading state
|
||||
const streamInfo = document.getElementById('stream-info');
|
||||
if (streamInfo) {
|
||||
streamInfo.innerHTML = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading stream:', error);
|
||||
const streamInfo = document.getElementById('stream-info');
|
||||
if (streamInfo) {
|
||||
streamInfo.innerHTML = '<p>Error loading stream. Please try again.</p>';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById("delete-account");
|
||||
if (deleteBtn) deleteBtn.onclick = async () => {
|
||||
if (!confirm("Are you sure you want to delete your account and all uploaded audio?")) return;
|
||||
const res = await fetch("/delete-account", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ uid })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast("✅ Account deleted");
|
||||
localStorage.removeItem("uid");
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} else {
|
||||
const msg = (await res.json()).detail || res.status;
|
||||
showToast("❌ Delete failed: " + msg);
|
||||
}
|
||||
};
|
||||
// Always reset current stream and update audio source
|
||||
currentStreamUid = uid;
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
|
||||
const fadeAllSections = () => {
|
||||
const uid = localStorage.getItem('uid');
|
||||
document.querySelectorAll("main > section").forEach(section => {
|
||||
// Always keep upload-area visible for logged-in users
|
||||
if (uid && section.id === 'upload-area') return;
|
||||
if (!section.hidden) {
|
||||
section.classList.add("fade-out");
|
||||
setTimeout(() => {
|
||||
section.classList.remove("fade-out");
|
||||
section.hidden = true;
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Wait a moment to ensure the previous source is cleared
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const dropzone = document.getElementById("upload-area");
|
||||
dropzone.setAttribute("aria-label", "Upload area. Click or drop an audio file to upload.");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const fileInfo = document.createElement("div");
|
||||
fileInfo.id = "file-info";
|
||||
fileInfo.style.textAlign = "center";
|
||||
fileInput.parentNode.insertBefore(fileInfo, fileInput.nextSibling);
|
||||
// Set new source with cache-busting timestamp
|
||||
audio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
|
||||
|
||||
// Try to play immediately
|
||||
try {
|
||||
await audio.play();
|
||||
audioPlaying = true;
|
||||
} catch (e) {
|
||||
console.error('Play failed:', e);
|
||||
audioPlaying = false;
|
||||
}
|
||||
|
||||
// Show stream info
|
||||
const streamInfo = document.getElementById("stream-info");
|
||||
const streamUrlEl = document.getElementById("streamUrl");
|
||||
if (streamInfo) streamInfo.hidden = false;
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const spinner = document.getElementById("spinner");
|
||||
// Update button state
|
||||
updatePlayPauseButton();
|
||||
|
||||
const uid = localStorage.getItem("uid");
|
||||
const uidTime = parseInt(localStorage.getItem("uid_time"), 10);
|
||||
const now = Date.now();
|
||||
// Hide register button if logged in
|
||||
const registerBtn = document.getElementById("show-register");
|
||||
if (uid && localStorage.getItem("confirmed_uid") === uid && uidTime && (now - uidTime) < 3600000) {
|
||||
if (registerBtn) registerBtn.style.display = "none";
|
||||
} else {
|
||||
if (registerBtn) registerBtn.style.display = "";
|
||||
}
|
||||
if (!uid || !uidTime || (now - uidTime) > 3600000) {
|
||||
localStorage.removeItem("uid");
|
||||
localStorage.removeItem("confirmed_uid");
|
||||
localStorage.removeItem("uid_time");
|
||||
status.className = "error-toast";
|
||||
status.innerText = "❌ Session expired. Please log in again.";
|
||||
// Add Login or Register button only for this error
|
||||
let loginBtn = document.createElement('button');
|
||||
loginBtn.textContent = 'Login or Register';
|
||||
loginBtn.className = 'login-register-btn';
|
||||
loginBtn.onclick = () => {
|
||||
document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page');
|
||||
};
|
||||
status.appendChild(document.createElement('br'));
|
||||
status.appendChild(loginBtn);
|
||||
// Remove the status div after a short delay so only toast remains
|
||||
setTimeout(() => {
|
||||
if (status.parentNode) status.parentNode.removeChild(status);
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
const confirmed = localStorage.getItem("confirmed_uid");
|
||||
if (!confirmed || uid !== confirmed) {
|
||||
status.className = "error-toast";
|
||||
status.innerText = "❌ Please confirm your account via email first.";
|
||||
showToast(status.innerText);
|
||||
return;
|
||||
}
|
||||
return audio;
|
||||
}
|
||||
|
||||
let abortController;
|
||||
// Export the function for use in other modules
|
||||
window.loadProfileStream = loadProfileStream;
|
||||
|
||||
const upload = async (file) => {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
fileInfo.innerText = `📁 ${file.name} • ${(file.size / 1024 / 1024).toFixed(2)} MB`;
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
status.className = "error-toast";
|
||||
status.innerText = "❌ Session expired. Please log in again.";
|
||||
// Add Login or Register button only for this error
|
||||
let loginBtn = document.createElement('button');
|
||||
loginBtn.textContent = 'Login or Register';
|
||||
loginBtn.className = 'login-register-btn';
|
||||
loginBtn.onclick = () => {
|
||||
document.querySelectorAll('main > section').forEach(sec => sec.hidden = sec.id !== 'register-page');
|
||||
};
|
||||
status.appendChild(document.createElement('br'));
|
||||
status.appendChild(loginBtn);
|
||||
showToast(status.innerText);
|
||||
return;
|
||||
}
|
||||
spinner.style.display = "block";
|
||||
status.innerHTML = '📡 Uploading…';
|
||||
status.className = "uploading-toast";
|
||||
fileInput.disabled = true;
|
||||
dropzone.classList.add("uploading");
|
||||
const formData = new FormData();
|
||||
formData.append("uid", uid);
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/upload", {
|
||||
signal: abortController.signal,
|
||||
method: "POST",
|
||||
body: formData,
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Initialize play/pause button
|
||||
const playPauseButton = document.getElementById('play-pause');
|
||||
if (playPauseButton) {
|
||||
// Set initial state
|
||||
audioPlaying = false;
|
||||
updatePlayPauseButton();
|
||||
|
||||
// Add event listener
|
||||
playPauseButton.addEventListener('click', () => {
|
||||
const audio = getMainAudio();
|
||||
if (audio) {
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
updatePlayPauseButton();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let data, parseError;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
parseError = e;
|
||||
}
|
||||
if (!data) {
|
||||
status.className = "error-toast";
|
||||
status.innerText = "❌ Upload failed: " + (parseError && parseError.message ? parseError.message : "Unknown error");
|
||||
showToast(status.innerText);
|
||||
spinner.style.display = "none";
|
||||
fileInput.disabled = false;
|
||||
dropzone.classList.remove("uploading");
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
status.className = "success-toast";
|
||||
streamInfo.hidden = false;
|
||||
streamInfo.innerHTML = `
|
||||
<p>Your stream is now live:</p>
|
||||
<audio controls id="me-audio" aria-label="Stream audio player. Your uploaded voice loop plays here.">
|
||||
<p style='font-size: 0.9em; text-align: center;'>🔁 This stream loops forever</p>
|
||||
<source src="${data.stream_url}" type="audio/ogg">
|
||||
</audio>
|
||||
<p><a href="${data.stream_url}" target="_blank" class="button" aria-label="Open your stream in a new tab">Open in external player</a></p>
|
||||
`;
|
||||
const meAudio = document.getElementById("me-audio");
|
||||
meAudio.addEventListener("ended", () => {
|
||||
if (mePlaylist.length > 1) {
|
||||
mePlaylistIdx = (mePlaylistIdx + 1) % mePlaylist.length;
|
||||
meAudio.src = mePlaylist[mePlaylistIdx];
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
meUrl.value = mePlaylist[mePlaylistIdx];
|
||||
} else if (mePlaylist.length === 1) {
|
||||
// Only one file: restart
|
||||
meAudio.currentTime = 0;
|
||||
meAudio.load();
|
||||
meAudio.play();
|
||||
}
|
||||
});
|
||||
if (data.quota && data.quota.used_mb !== undefined) {
|
||||
const bar = document.getElementById("quota-bar");
|
||||
const text = document.getElementById("quota-text");
|
||||
const quotaSec = document.getElementById("quota-meter");
|
||||
if (bar && text && quotaSec) {
|
||||
quotaSec.hidden = false;
|
||||
const used = parseFloat(data.quota.used_mb);
|
||||
bar.value = used;
|
||||
bar.max = 100;
|
||||
text.textContent = `${used.toFixed(1)} MB used`;
|
||||
}
|
||||
// Add bot protection for registration form
|
||||
const registerForm = document.getElementById('register-form');
|
||||
if (registerForm) {
|
||||
registerForm.addEventListener('submit', (e) => {
|
||||
const botTrap = e.target.elements.bot_trap;
|
||||
if (botTrap && botTrap.value) {
|
||||
e.preventDefault();
|
||||
showToast('❌ Bot detected! Please try again.');
|
||||
return false;
|
||||
}
|
||||
spinner.style.display = "none";
|
||||
fileInput.disabled = false;
|
||||
dropzone.classList.remove("uploading");
|
||||
showToast(status.innerText);
|
||||
status.innerText = "✅ Upload successful.";
|
||||
|
||||
playBeep(432, 0.25, "sine");
|
||||
|
||||
setTimeout(() => status.innerText = "", 5000);
|
||||
streamInfo.classList.add("visible", "slide-in");
|
||||
} else {
|
||||
streamInfo.hidden = true;
|
||||
status.className = "error-toast";
|
||||
spinner.style.display = "none";
|
||||
if ((data.detail || data.error || "").includes("music")) {
|
||||
status.innerText = "🎵 Upload rejected: singing or music detected.";
|
||||
} else {
|
||||
status.innerText = `❌ Upload failed: ${data.detail || data.error}`;
|
||||
}
|
||||
showToast(status.innerText);
|
||||
fileInput.value = null;
|
||||
dropzone.classList.remove("uploading");
|
||||
fileInput.disabled = false;
|
||||
streamInfo.classList.remove("visible", "slide-in");
|
||||
}
|
||||
};
|
||||
|
||||
dropzone.addEventListener("click", () => {
|
||||
console.log("[DEBUG] Dropzone clicked");
|
||||
fileInput.click();
|
||||
console.log("[DEBUG] fileInput.click() called");
|
||||
});
|
||||
dropzone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("dragover");
|
||||
dropzone.style.transition = "background-color 0.3s ease";
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => {
|
||||
dropzone.classList.remove("dragover");
|
||||
});
|
||||
dropzone.addEventListener("drop", (e) => {
|
||||
dropzone.classList.add("pulse");
|
||||
setTimeout(() => dropzone.classList.remove("pulse"), 400);
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("dragover");
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) upload(file);
|
||||
});
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
status.innerText = "";
|
||||
status.className = "";
|
||||
const file = e.target.files[0];
|
||||
if (file) upload(file);
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize navigation
|
||||
document.querySelectorAll('#links a[data-target]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
@ -426,4 +381,34 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (burger && burger.checked) burger.checked = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize profile player if valid session
|
||||
setTimeout(runProfilePlayerIfSessionValid, 200);
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(runProfilePlayerIfSessionValid, 200);
|
||||
});
|
||||
});
|
||||
// Initialize navigation
|
||||
document.querySelectorAll('#links a[data-target]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = link.getAttribute('data-target');
|
||||
// Only hide other sections when not opening #me-page
|
||||
if (target !== 'me-page') fadeAllSections();
|
||||
const section = document.getElementById(target);
|
||||
if (section) {
|
||||
section.hidden = false;
|
||||
section.classList.add("slide-in");
|
||||
section.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
const burger = document.getElementById('burger-toggle');
|
||||
if (burger && burger.checked) burger.checked = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize profile player if valid session
|
||||
setTimeout(runProfilePlayerIfSessionValid, 200);
|
||||
window.addEventListener('popstate', () => {
|
||||
setTimeout(runProfilePlayerIfSessionValid, 200);
|
||||
});
|
||||
});
|
||||
|
@ -1,28 +1,34 @@
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
// dashboard.js — toggle guest vs. user dashboard and reposition streams link
|
||||
|
||||
async function initDashboard() {
|
||||
const uploadArea = document.querySelector('#upload-area');
|
||||
const userDashboard = document.querySelector('#me-page');
|
||||
const meAudio = document.querySelector('#me-audio');
|
||||
const quotaBar = document.querySelector('#quota-bar');
|
||||
const quotaText = document.querySelector('#quota-text');
|
||||
const streamsLink = document.querySelector('#show-streams');
|
||||
const registerLink = document.querySelector('#show-register');
|
||||
// New dashboard toggling logic
|
||||
const guestDashboard = document.getElementById('guest-dashboard');
|
||||
const userDashboard = document.getElementById('user-dashboard');
|
||||
const userUpload = document.getElementById('user-upload-area');
|
||||
|
||||
// Default state: hide both
|
||||
uploadArea.hidden = true;
|
||||
userDashboard.hidden = true;
|
||||
// Hide all by default
|
||||
if (guestDashboard) guestDashboard.style.display = 'none';
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (userUpload) userUpload.style.display = 'none';
|
||||
|
||||
const uid = localStorage.getItem('uid');
|
||||
const uid = getCookie('uid');
|
||||
if (!uid) {
|
||||
// Guest: only upload area and move Streams next to Register
|
||||
uploadArea.hidden = false;
|
||||
userDashboard.hidden = true;
|
||||
if (registerLink && streamsLink) {
|
||||
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Guest view: only nav
|
||||
if (guestDashboard) guestDashboard.style.display = '';
|
||||
if (userDashboard) userDashboard.style.display = 'none';
|
||||
if (userUpload) userUpload.style.display = 'none';
|
||||
const mePage = document.getElementById('me-page');
|
||||
if (mePage) mePage.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/me/${uid}`);
|
||||
@ -30,22 +36,46 @@ async function initDashboard() {
|
||||
const data = await res.json();
|
||||
|
||||
// Logged-in view
|
||||
uploadArea.hidden = false;
|
||||
userDashboard.hidden = false;
|
||||
// Restore links section and show-me link
|
||||
const linksSection = document.getElementById('links');
|
||||
if (linksSection) linksSection.style.display = '';
|
||||
const showMeLink = document.getElementById('show-me');
|
||||
if (showMeLink && showMeLink.parentElement) showMeLink.parentElement.style.display = '';
|
||||
// Show me-page for logged-in users
|
||||
const mePage = document.getElementById('me-page');
|
||||
if (mePage) mePage.style.display = '';
|
||||
// Ensure upload area is visible if last_page was me-page
|
||||
const userUpload = document.getElementById('user-upload-area');
|
||||
if (userUpload && localStorage.getItem('last_page') === 'me-page') {
|
||||
// userUpload visibility is now only controlled by nav.js SPA logic
|
||||
}
|
||||
|
||||
// Remove guest warning if present
|
||||
const guestMsg = document.getElementById('guest-warning-msg');
|
||||
if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg);
|
||||
userDashboard.style.display = '';
|
||||
|
||||
// Set audio source
|
||||
meAudio.src = data.stream_url;
|
||||
const meAudio = document.getElementById('me-audio');
|
||||
if (meAudio && uid) {
|
||||
meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus`;
|
||||
}
|
||||
|
||||
// Update quota
|
||||
quotaBar.value = data.quota;
|
||||
quotaText.textContent = `${data.quota} MB used`;
|
||||
const quotaBar = document.getElementById('quota-bar');
|
||||
const quotaText = document.getElementById('quota-text');
|
||||
if (quotaBar) quotaBar.value = data.quota;
|
||||
if (quotaText) quotaText.textContent = `${data.quota} MB used`;
|
||||
|
||||
// Ensure Streams link remains in nav, not moved
|
||||
// (No action needed if static)
|
||||
} catch (e) {
|
||||
console.warn('Dashboard init error, treating as guest:', e);
|
||||
localStorage.removeItem('uid');
|
||||
uploadArea.hidden = false;
|
||||
userDashboard.hidden = true;
|
||||
|
||||
userUpload.style.display = '';
|
||||
userDashboard.style.display = 'none';
|
||||
const registerLink = document.getElementById('guest-login');
|
||||
const streamsLink = document.getElementById('guest-streams');
|
||||
if (registerLink && streamsLink) {
|
||||
registerLink.parentElement.insertAdjacentElement('afterend', streamsLink.parentElement);
|
||||
}
|
||||
@ -53,3 +83,111 @@ async function initDashboard() {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initDashboard);
|
||||
|
||||
// Registration form handler for guests
|
||||
// Handles the submit event on #register-form, sends data to /register, and alerts the user with the result
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const regForm = document.getElementById('register-form');
|
||||
if (regForm) {
|
||||
regForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(regForm);
|
||||
try {
|
||||
const res = await fetch('/register', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
let data;
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
data = await res.json();
|
||||
} else {
|
||||
data = { detail: await res.text() };
|
||||
}
|
||||
if (res.ok) {
|
||||
showToast('Confirmation sent! Check your email.');
|
||||
} else {
|
||||
showToast('Registration failed: ' + (data.detail || res.status));
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Network error: ' + err);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Connect Login or Register link to register form
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Login/Register (guest)
|
||||
const loginLink = document.getElementById('guest-login');
|
||||
if (loginLink) {
|
||||
loginLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== 'register-page';
|
||||
});
|
||||
const reg = document.getElementById('register-page');
|
||||
if (reg) reg.hidden = false;
|
||||
reg.scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
// Terms of Service (all dashboards)
|
||||
const termsLinks = [
|
||||
document.getElementById('guest-terms'),
|
||||
document.getElementById('user-terms')
|
||||
];
|
||||
termsLinks.forEach(link => {
|
||||
if (link) {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== 'terms-page';
|
||||
});
|
||||
const terms = document.getElementById('terms-page');
|
||||
if (terms) terms.hidden = false;
|
||||
terms.scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Imprint (all dashboards)
|
||||
const imprintLinks = [
|
||||
document.getElementById('guest-imprint'),
|
||||
document.getElementById('user-imprint')
|
||||
];
|
||||
imprintLinks.forEach(link => {
|
||||
if (link) {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== 'imprint-page';
|
||||
});
|
||||
const imprint = document.getElementById('imprint-page');
|
||||
if (imprint) imprint.hidden = false;
|
||||
imprint.scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Privacy Policy (all dashboards)
|
||||
const privacyLinks = [
|
||||
document.getElementById('guest-privacy'),
|
||||
document.getElementById('user-privacy')
|
||||
];
|
||||
privacyLinks.forEach(link => {
|
||||
if (link) {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('main > section').forEach(sec => {
|
||||
sec.hidden = sec.id !== 'privacy-page';
|
||||
});
|
||||
const privacy = document.getElementById('privacy-page');
|
||||
if (privacy) privacy.hidden = false;
|
||||
privacy.scrollIntoView({behavior:'smooth'});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -2,12 +2,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/static/style.css" media="all" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎙️</text></svg>">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="dicta2stream is a minimalist voice streaming platform for looping your spoken audio anonymously." />
|
||||
<title>dicta2stream</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<!-- Responsive burger menu display -->
|
||||
<style>
|
||||
#burger-label, #burger-toggle { display: none; }
|
||||
@ -30,67 +30,68 @@
|
||||
|
||||
<main>
|
||||
|
||||
<section id="upload-area" class="dropzone">
|
||||
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
|
||||
<input type="file" id="fileInput" accept="audio/*" hidden />
|
||||
<!-- Guest Dashboard -->
|
||||
<nav id="guest-dashboard" class="dashboard-nav">
|
||||
<a href="#" id="guest-welcome" data-target="welcome-page">Welcome</a> |
|
||||
<a href="#" id="guest-streams" data-target="stream-page">Streams</a> |
|
||||
<a href="#" id="guest-login" data-target="register-page">Login or Register</a>
|
||||
</nav>
|
||||
|
||||
<!-- User Dashboard -->
|
||||
<nav id="user-dashboard" class="dashboard-nav" style="display:none;">
|
||||
<a href="#" id="user-welcome" data-target="welcome-page">Welcome</a> |
|
||||
<a href="#" id="user-streams" data-target="stream-page">Streams</a> |
|
||||
<a href="#" id="show-me" data-target="me-page">Your Stream</a>
|
||||
</nav>
|
||||
<section id="me-page">
|
||||
<article>
|
||||
<h2>Your Stream 🎙️</h2>
|
||||
<p>This is your personal stream. Only you can upload to it.</p>
|
||||
<audio id="me-audio"></audio>
|
||||
<div class="audio-controls">
|
||||
<button id="play-pause" type="button">▶️</button>
|
||||
</div>
|
||||
</article>
|
||||
<section id="user-upload-area" class="dropzone">
|
||||
<p>🎙 Drag & drop your audio file here<br>or click to browse</p>
|
||||
<input type="file" id="fileInputUser" accept="audio/*" hidden />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<div id="spinner" class="spinner">
|
||||
<div id="spinner" class="spinner"></div>
|
||||
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
|
||||
<section id="stream-info" hidden>
|
||||
<p>Your loop stream:</p>
|
||||
<code id="streamUrl">...</code>
|
||||
<audio controls id="player" loop></audio>
|
||||
<p><button id="delete-account" class="delete-account">🗑️ Delete My Account</button></p>
|
||||
</section>
|
||||
|
||||
<input type="checkbox" id="burger-toggle" hidden>
|
||||
<label for="burger-toggle" id="burger-label" aria-label="Menu">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</label>
|
||||
<section id="links">
|
||||
<p><a href="#" id="show-me" data-target="me-page">Your Stream</a></p>
|
||||
<p><a href="#" id="show-register" data-target="register-page">Login or Register</a></p>
|
||||
<p>
|
||||
<a href="#" id="show-terms" data-target="terms-page">Terms of Service</a> |
|
||||
<a href="#" id="show-imprint" data-target="imprint-page">Imprint</a> |
|
||||
<a href="#" id="show-privacy" data-target="privacy-page">Privacy Policy</a> |
|
||||
<a href="#" id="show-streams" data-target="stream-page">Streams</a>
|
||||
</p>
|
||||
</section>
|
||||
<!-- Burger menu and legacy links section removed for clarity -->
|
||||
|
||||
<section id="terms-page" hidden>
|
||||
<article>
|
||||
<h2>Terms of Service</h2>
|
||||
<p><em>Last updated: April 18, 2025</em></p>
|
||||
<p>By accessing or using dicta2stream.net (the “Service”), you agree to be bound by these Terms of Service (“Terms”). If you do not agree, do not use the Service.</p>
|
||||
<ul>
|
||||
<li>You must be at least 18 years old to register.</li>
|
||||
<li>UID in localStorage must be uniquely yours.</li>
|
||||
<li>Each account must be unique and used by only one person.</li>
|
||||
<li>One account per device/IP per 24 hours.</li>
|
||||
<li>If hate speech, illegal, or harmful content is detected, the account and all associated data will be deleted.</li>
|
||||
<li>The associated email address will be banned from recreating an account.</li>
|
||||
<li>Uploads are limited to 100 MB and must be voice only.</li>
|
||||
<li>Music/singing will be rejected.</li>
|
||||
</ul>
|
||||
<p>Uploads are limited to <strong>100 MB</strong> and must be <strong>voice only</strong>. Music/singing will be rejected. Your stream will loop publicly and anonymously via Icecast.</p>
|
||||
<p>See full legal terms in the Git repository or request via support@dicta2stream.net.</p>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="privacy-page" hidden>
|
||||
<article>
|
||||
<h2>Privacy Policy</h2>
|
||||
<p><em>Last updated: April 18, 2025</em></p>
|
||||
<ul>
|
||||
<li>No cookies. UID is stored locally only.</li>
|
||||
<li><strong>Users</strong>: Session uses both cookies and localStorage to store UID and authentication state.</li>
|
||||
<li><strong>Guests</strong>: No cookies are set. No persistent identifiers are stored.</li>
|
||||
<li>We log IP + UID only for abuse protection and quota enforcement.</li>
|
||||
<li>Uploads are scanned via Whisper+Ollama but not stored as transcripts.</li>
|
||||
<li>Data is never sold. Contact us for account deletion.</li>
|
||||
</ul>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
|
||||
@ -99,90 +100,94 @@
|
||||
<h2>Imprint</h2>
|
||||
<p><strong>Andreas Michael Fleckl</strong></p>
|
||||
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
|
||||
<p><strong>Contact:</strong><br>
|
||||
<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="welcome-page">
|
||||
<article>
|
||||
<h2>Welcome</h2>
|
||||
<p>dicta2stream is a minimalist voice streaming platform for your spoken audio anonymously under a nickname in a loop. <br><br>
|
||||
<strong>What you can do here:</strong></p>
|
||||
<ul>
|
||||
<li>🎧 Listen to public voice streams from others, instantly</li>
|
||||
<li>🎙️ Upload your own audio and share your voice with the world</li>
|
||||
<li>🕵️ No sign-up required for listening</li>
|
||||
<li>🔒 Optional registration for uploading and managing your own stream</li>
|
||||
</ul>
|
||||
|
||||
</article>
|
||||
</section>
|
||||
<section id="stream-page" hidden>
|
||||
<article>
|
||||
<h2>🎧 Public Streams</h2>
|
||||
<!-- The list below is dynamically populated by streams-ui.js; shows 'Loading...' while fetching -->
|
||||
<ul id="stream-list"><li>Loading...</li></ul>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
<p style="margin-top:1.5em;font-size:0.98em;">
|
||||
<a href="#" id="show-terms" data-target="terms-page">Terms of Service</a> |
|
||||
<a href="#" id="show-imprint" data-target="imprint-page">Imprint</a> |
|
||||
<a href="#" id="show-privacy" data-target="privacy-page">Privacy Policy</a>
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="register-page" hidden>
|
||||
<article>
|
||||
<h2>Register</h2>
|
||||
<h2>Login or Register</h2>
|
||||
<form id="register-form">
|
||||
<p><label>Email<br><input type="email" name="email" required /></label></p>
|
||||
<p><label>Username<br><input type="text" name="user" required /></label></p>
|
||||
<p style="display: none;">
|
||||
<label>Leave this empty:<br>
|
||||
<input type="text" name="bot_trap" autocomplete="off" />
|
||||
</label>
|
||||
</p>
|
||||
<p><button type="submit">Create Account</button></p>
|
||||
</form>
|
||||
<p><small>You’ll receive a magic login link via email. No password required.</small></p>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
<p style="font-size: 0.85em; opacity: 0.65; margin-top: 1em;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="magic-login-page" hidden>
|
||||
<article>
|
||||
<h2>Magic Login</h2>
|
||||
<p>If you received a magic login link, you're almost in. Click below to confirm your account and activate streaming.</p>
|
||||
<form id="magic-login-form">
|
||||
<div id="magic-error" style="color: #b22222; font-size: 0.9em; display: none; margin-bottom: 1em;"></div>
|
||||
<input type="hidden" name="token" id="magic-token" />
|
||||
<button type="submit">Confirm & Activate</button>
|
||||
</form>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section id="quota-meter" hidden>
|
||||
<p class="quota-meter">Quota: <progress id="quota-bar" value="0" max="100"></progress> <span id="quota-text">0 MB used</span></p>
|
||||
</section>
|
||||
|
||||
<section id="me-page" hidden>
|
||||
<article>
|
||||
<h2>Your Stream 🎙️</h2>
|
||||
<p>This is your personal stream. Only you can upload to it.</p>
|
||||
<audio controls id="me-audio"></audio>
|
||||
<!-- Playlist and URL input hidden as per user request -->
|
||||
<div class="playlist-controls">
|
||||
<button id="me-prev" aria-label="Previous track">⏮️</button>
|
||||
<button id="me-next" aria-label="Next track">⏭️</button>
|
||||
</div>
|
||||
<!-- <ul id="me-playlist" class="playlist"></ul> -->
|
||||
<!-- <p><input id="me-url" readonly class="me-url" /></p> -->
|
||||
<p><button id="copy-url">📋 Copy URL to clipboard</button></p>
|
||||
<p><a href="#" data-back="upload-area">← Back</a></p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Built for public voice streaming • Opus | Mono | 48 kHz | 60 kbps</p>
|
||||
<p class="footer-hint">Need more space? Contact <a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
|
||||
<p style="font-size: 0.85em; opacity: 0.65;">Your session expires after 1 hour. Shareable links redirect to homepage.</p>
|
||||
<p class="footer-hint">Need more space? Contact<a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
|
||||
|
||||
<p class="footer-links">
|
||||
<a href="#" id="footer-terms" data-target="terms-page">Terms of Service</a> |
|
||||
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy Policy</a> |
|
||||
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/static/dashboard.js"></script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
<!-- Load public streams UI logic -->
|
||||
<script type="module" src="/static/streams-ui.js"></script>
|
||||
<!-- Load upload functionality -->
|
||||
<script type="module" src="/static/upload.js"></script>
|
||||
<script type="module">
|
||||
import "/static/nav.js";
|
||||
window.addEventListener("pageshow", () => {
|
||||
const dz = document.querySelector("#upload-area");
|
||||
dz.classList.remove("uploading");
|
||||
const dz = document.querySelector("#user-upload-area");
|
||||
if (dz) dz.classList.remove("uploading");
|
||||
const spinner = document.querySelector("#spinner");
|
||||
if (spinner) spinner.style.display = "none";
|
||||
});
|
||||
</script>
|
||||
<script type="module">
|
||||
import { initMagicLogin } from '/static/magic-login.js';
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has('token')) {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener('DOMContentLoaded', initMagicLogin);
|
||||
} else {
|
||||
initMagicLogin();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,18 +1,63 @@
|
||||
// static/magic-login.js — handles magic‑link token UI
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
export function initMagicLogin() {
|
||||
let magicLoginSubmitted = false;
|
||||
|
||||
export async function initMagicLogin() {
|
||||
console.debug('[magic-login] initMagicLogin called');
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get('token');
|
||||
if (token) {
|
||||
const tokenInput = document.getElementById('magic-token');
|
||||
if (tokenInput) tokenInput.value = token;
|
||||
showOnly('magic-login-page');
|
||||
const err = params.get('error');
|
||||
if (err) {
|
||||
const box = document.getElementById('magic-error');
|
||||
box.textContent = decodeURIComponent(err);
|
||||
box.style.display = 'block';
|
||||
if (!token) {
|
||||
console.debug('[magic-login] No token in URL');
|
||||
return;
|
||||
}
|
||||
// Remove token from URL immediately to prevent loops
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('token', token);
|
||||
const res = await fetch('/magic-login', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (res.redirected) {
|
||||
// If redirected, backend should set cookie; but set localStorage for SPA
|
||||
const url = new URL(res.url);
|
||||
const confirmedUid = url.searchParams.get('confirmed_uid');
|
||||
if (confirmedUid) {
|
||||
document.cookie = "uid=" + encodeURIComponent(confirmedUid) + "; path=/";
|
||||
// Set localStorage for SPA session logic instantly
|
||||
localStorage.setItem('uid', confirmedUid);
|
||||
localStorage.setItem('confirmed_uid', confirmedUid);
|
||||
localStorage.setItem('uid_time', Date.now().toString());
|
||||
}
|
||||
window.location.href = res.url;
|
||||
return;
|
||||
}
|
||||
// If not redirected, show error (shouldn't happen in normal flow)
|
||||
let data;
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
data = await res.json();
|
||||
if (data && data.confirmed_uid) {
|
||||
document.cookie = "uid=" + encodeURIComponent(data.confirmed_uid) + "; path=/";
|
||||
// Set localStorage for SPA session logic
|
||||
localStorage.setItem('uid', data.confirmed_uid);
|
||||
localStorage.setItem('confirmed_uid', data.confirmed_uid);
|
||||
localStorage.setItem('uid_time', Date.now().toString());
|
||||
import('./toast.js').then(({ showToast }) => showToast('✅ Login successful!'));
|
||||
// Optionally reload or navigate
|
||||
setTimeout(() => location.reload(), 700);
|
||||
return;
|
||||
}
|
||||
alert(data.detail || 'Login failed.');
|
||||
} else {
|
||||
const text = await res.text();
|
||||
alert(text || 'Login failed.');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error: ' + err);
|
||||
}
|
||||
}
|
||||
|
133
static/nav.js
133
static/nav.js
@ -1,8 +1,10 @@
|
||||
// nav.js — lightweight navigation & magic‑link handling
|
||||
import { showToast } from "./toast.js";
|
||||
|
||||
// fallback toast if app.js not yet loaded
|
||||
if (typeof window.showToast !== "function") {
|
||||
window.showToast = (msg) => alert(msg);
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
return null;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@ -13,6 +15,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
sec.hidden = sec.id !== id;
|
||||
sec.tabIndex = -1;
|
||||
});
|
||||
// Show user-upload-area only when me-page is shown and user is logged in
|
||||
const userUpload = document.getElementById("user-upload-area");
|
||||
if (userUpload) {
|
||||
const uid = getCookie("uid");
|
||||
userUpload.style.display = (id === "me-page" && uid) ? '' : 'none';
|
||||
}
|
||||
localStorage.setItem("last_page", id);
|
||||
const target = document.getElementById(id);
|
||||
if (target) target.focus();
|
||||
@ -20,7 +28,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
init() {
|
||||
initNavLinks();
|
||||
initBackButtons();
|
||||
initStreamsLoader();
|
||||
|
||||
initStreamLinks();
|
||||
}
|
||||
};
|
||||
@ -38,14 +46,30 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
link.classList.toggle('active', uidParam === profileUid);
|
||||
});
|
||||
}
|
||||
window.addEventListener('popstate', highlightActiveProfileLink);
|
||||
window.addEventListener('popstate', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const profileUid = params.get('profile');
|
||||
if (profileUid) {
|
||||
showOnly('me-page');
|
||||
if (typeof window.showProfilePlayerFromUrl === 'function') {
|
||||
window.showProfilePlayerFromUrl();
|
||||
}
|
||||
} else {
|
||||
highlightActiveProfileLink();
|
||||
}
|
||||
});
|
||||
|
||||
/* restore last page (unless magic‑link token present) */
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get("token");
|
||||
if (!token) {
|
||||
const last = localStorage.getItem("last_page");
|
||||
if (last && document.getElementById(last)) showOnly(last);
|
||||
if (last && document.getElementById(last)) {
|
||||
showOnly(last);
|
||||
} else if (document.getElementById("welcome-page")) {
|
||||
// Show Welcome page by default for all new/guest users
|
||||
showOnly("welcome-page");
|
||||
}
|
||||
// Highlight active link on initial load
|
||||
highlightActiveProfileLink();
|
||||
}
|
||||
@ -62,8 +86,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce loading and helper for streams list
|
||||
let loadingStreams = false;
|
||||
|
||||
function renderStreamList(streams) {
|
||||
const ul = document.getElementById("stream-list");
|
||||
if (!ul) return;
|
||||
@ -81,49 +104,79 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
// Initialize navigation listeners
|
||||
function initNavLinks() {
|
||||
const linksContainer = document.getElementById("links");
|
||||
if (!linksContainer) return;
|
||||
linksContainer.addEventListener("click", e => {
|
||||
const a = e.target.closest("a[data-target]");
|
||||
if (!a || !linksContainer.contains(a)) return;
|
||||
e.preventDefault();
|
||||
const target = a.dataset.target;
|
||||
if (target) showOnly(target);
|
||||
const burger = document.getElementById("burger-toggle");
|
||||
if (burger && burger.checked) burger.checked = false;
|
||||
const navIds = ["links", "user-dashboard", "guest-dashboard"];
|
||||
navIds.forEach(id => {
|
||||
const nav = document.getElementById(id);
|
||||
if (!nav) return;
|
||||
nav.addEventListener("click", e => {
|
||||
const a = e.target.closest("a[data-target]");
|
||||
if (!a || !nav.contains(a)) return;
|
||||
e.preventDefault();
|
||||
|
||||
// Save audio state before navigation
|
||||
const audio = document.getElementById('me-audio');
|
||||
const wasPlaying = audio && !audio.paused;
|
||||
const currentTime = audio ? audio.currentTime : 0;
|
||||
|
||||
const target = a.dataset.target;
|
||||
if (target) showOnly(target);
|
||||
|
||||
// Handle stream page specifically
|
||||
if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") {
|
||||
window.maybeLoadStreamsOnShow();
|
||||
}
|
||||
// Handle me-page specifically
|
||||
else if (target === "me-page" && audio) {
|
||||
// Restore audio state if it was playing
|
||||
if (wasPlaying) {
|
||||
audio.currentTime = currentTime;
|
||||
audio.play().catch(e => console.error('Play failed:', e));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add click handlers for footer links with audio state saving
|
||||
document.querySelectorAll(".footer-links a").forEach(link => {
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const target = link.dataset.target;
|
||||
if (!target) return;
|
||||
|
||||
// Save audio state before navigation
|
||||
const audio = document.getElementById('me-audio');
|
||||
const wasPlaying = audio && !audio.paused;
|
||||
const currentTime = audio ? audio.currentTime : 0;
|
||||
|
||||
showOnly(target);
|
||||
|
||||
// Handle me-page specifically
|
||||
if (target === "me-page" && audio) {
|
||||
// Restore audio state if it was playing
|
||||
if (wasPlaying) {
|
||||
audio.currentTime = currentTime;
|
||||
audio.play().catch(e => console.error('Play failed:', e));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function initBackButtons() {
|
||||
document.querySelectorAll('a[data-back]').forEach(btn => {
|
||||
btn.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
const target = btn.dataset.back;
|
||||
if (target) showOnly(target);
|
||||
// Ensure streams load instantly when stream-page is shown
|
||||
if (target === "stream-page" && typeof window.maybeLoadStreamsOnShow === "function") {
|
||||
window.maybeLoadStreamsOnShow();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initStreamsLoader() {
|
||||
const streamsLink = document.getElementById("show-streams");
|
||||
streamsLink?.addEventListener("click", async e => {
|
||||
e.preventDefault();
|
||||
if (loadingStreams) return;
|
||||
loadingStreams = true;
|
||||
showOnly("stream-page");
|
||||
try {
|
||||
const res = await fetch("/streams");
|
||||
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||
const data = await res.json();
|
||||
renderStreamList(data.streams || []);
|
||||
} catch {
|
||||
const ul = document.getElementById("stream-list");
|
||||
if (ul) ul.innerHTML = "<li>Error loading stream list</li>";
|
||||
} finally {
|
||||
loadingStreams = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function initStreamLinks() {
|
||||
const ul = document.getElementById("stream-list");
|
||||
|
0
static/reload.txt
Normal file
0
static/reload.txt
Normal file
@ -1,22 +1,198 @@
|
||||
// static/streams-ui.js — public streams loader and profile-link handling
|
||||
import { showOnly } from './router.js';
|
||||
|
||||
let loadingStreams = false;
|
||||
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
|
||||
|
||||
|
||||
export function initStreamsUI() {
|
||||
initStreamLinks();
|
||||
window.addEventListener('popstate', () => {
|
||||
highlightActiveProfileLink();
|
||||
maybeLoadStreamsOnShow();
|
||||
});
|
||||
document.addEventListener('visibilitychange', maybeLoadStreamsOnShow);
|
||||
maybeLoadStreamsOnShow();
|
||||
}
|
||||
|
||||
function maybeLoadStreamsOnShow() {
|
||||
// Expose globally for nav.js
|
||||
const streamPage = document.getElementById('stream-page');
|
||||
const isVisible = streamPage && !streamPage.hidden;
|
||||
if (isVisible) {
|
||||
loadAndRenderStreams();
|
||||
}
|
||||
}
|
||||
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
|
||||
|
||||
let currentlyPlayingAudio = null;
|
||||
let currentlyPlayingButton = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initStreamsUI);
|
||||
|
||||
function loadAndRenderStreams() {
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) {
|
||||
console.warn('[streams-ui] #stream-list not found in DOM');
|
||||
return;
|
||||
}
|
||||
console.debug('[streams-ui] loadAndRenderStreams (SSE mode) called');
|
||||
|
||||
ul.innerHTML = '<li>Loading...</li>';
|
||||
let gotAny = false;
|
||||
let streams = [];
|
||||
// Close previous EventSource if any
|
||||
if (window._streamsSSE) {
|
||||
window._streamsSSE.close();
|
||||
}
|
||||
const evtSource = new window.EventSource('/streams-sse');
|
||||
window._streamsSSE = evtSource;
|
||||
|
||||
evtSource.onmessage = function(event) {
|
||||
console.debug('[streams-ui] SSE event received:', event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.end) {
|
||||
if (!gotAny) {
|
||||
ul.innerHTML = '<li>No active streams.</li>';
|
||||
}
|
||||
evtSource.close();
|
||||
highlightActiveProfileLink();
|
||||
return;
|
||||
}
|
||||
// Remove Loading... on any valid event
|
||||
if (!gotAny) {
|
||||
ul.innerHTML = '';
|
||||
gotAny = true;
|
||||
}
|
||||
streams.push(data);
|
||||
const uid = data.uid || '';
|
||||
const sizeMb = data.size ? (data.size / (1024 * 1024)).toFixed(1) : '?';
|
||||
const mtime = data.mtime ? new Date(data.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `
|
||||
<article class="stream-player">
|
||||
<h3>${uid}</h3>
|
||||
<audio id="audio-${uid}" class="stream-audio" preload="auto" crossOrigin="anonymous" src="/audio/${encodeURIComponent(uid)}/stream.opus"></audio>
|
||||
<div class="audio-controls">
|
||||
<button id="play-pause-${uid}">▶</button>
|
||||
</div>
|
||||
<p class="stream-info" style='color:gray;font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
|
||||
</article>
|
||||
`;
|
||||
|
||||
// Add play/pause handler after appending to DOM
|
||||
ul.appendChild(li);
|
||||
|
||||
// Wait for DOM update
|
||||
requestAnimationFrame(() => {
|
||||
const playPauseButton = document.getElementById(`play-pause-${uid}`);
|
||||
const audio = document.getElementById(`audio-${uid}`);
|
||||
|
||||
if (playPauseButton && audio) {
|
||||
playPauseButton.addEventListener('click', () => {
|
||||
try {
|
||||
if (audio.paused) {
|
||||
// Stop any currently playing audio first
|
||||
if (currentlyPlayingAudio && currentlyPlayingAudio !== audio) {
|
||||
currentlyPlayingAudio.pause();
|
||||
if (currentlyPlayingButton) {
|
||||
currentlyPlayingButton.textContent = '▶';
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the main player if it's playing
|
||||
if (typeof window.stopMainAudio === 'function') {
|
||||
window.stopMainAudio();
|
||||
}
|
||||
|
||||
audio.play().then(() => {
|
||||
playPauseButton.textContent = '⏸️';
|
||||
currentlyPlayingAudio = audio;
|
||||
currentlyPlayingButton = playPauseButton;
|
||||
}).catch(e => {
|
||||
console.error('Play failed:', e);
|
||||
// Reset button if play fails
|
||||
playPauseButton.textContent = '▶';
|
||||
currentlyPlayingAudio = null;
|
||||
currentlyPlayingButton = null;
|
||||
});
|
||||
} else {
|
||||
audio.pause();
|
||||
playPauseButton.textContent = '▶';
|
||||
if (currentlyPlayingAudio === audio) {
|
||||
currentlyPlayingAudio = null;
|
||||
currentlyPlayingButton = null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Audio error:', e);
|
||||
playPauseButton.textContent = '▶';
|
||||
if (currentlyPlayingAudio === audio) {
|
||||
currentlyPlayingAudio = null;
|
||||
currentlyPlayingButton = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
highlightActiveProfileLink();
|
||||
ul.appendChild(li);
|
||||
highlightActiveProfileLink();
|
||||
} catch (e) {
|
||||
// Remove Loading... even if JSON parse fails, to avoid stuck UI
|
||||
if (!gotAny) {
|
||||
ul.innerHTML = '';
|
||||
gotAny = true;
|
||||
}
|
||||
console.error('[streams-ui] SSE parse error', e, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
evtSource.onerror = function(err) {
|
||||
console.error('[streams-ui] SSE error', err);
|
||||
ul.innerHTML = '<li>Error loading stream list</li>';
|
||||
if (typeof showToast === 'function') {
|
||||
showToast('❌ Error loading public streams.');
|
||||
}
|
||||
evtSource.close();
|
||||
// Add reload button if not present
|
||||
const reloadButton = document.getElementById('reload-streams');
|
||||
if (!reloadButton) {
|
||||
const reloadHtml = '<button id="reload-streams" onclick="loadAndRenderStreams()">Reload</button>';
|
||||
ul.insertAdjacentHTML('beforeend', reloadHtml);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function renderStreamList(streams) {
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (!ul) return;
|
||||
if (streams.length) {
|
||||
streams.sort();
|
||||
ul.innerHTML = streams
|
||||
.map(
|
||||
uid => `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a></li>`
|
||||
)
|
||||
.join('');
|
||||
if (!ul) {
|
||||
console.warn('[streams-ui] renderStreamList: #stream-list not found');
|
||||
return;
|
||||
}
|
||||
console.debug('[streams-ui] Rendering stream list:', streams);
|
||||
if (Array.isArray(streams)) {
|
||||
if (streams.length) {
|
||||
// Sort by mtime descending (most recent first)
|
||||
streams.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
|
||||
ul.innerHTML = streams
|
||||
.map(stream => {
|
||||
const uid = stream.uid || '';
|
||||
const sizeKb = stream.size ? (stream.size / 1024).toFixed(1) : '?';
|
||||
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toLocaleString() : '';
|
||||
return `<li><a href="/?profile=${encodeURIComponent(uid)}" class="profile-link">▶ ${uid}</a> <span style='color:gray;font-size:90%'>[${sizeKb} KB, ${mtime}]</span></li>`;
|
||||
})
|
||||
.join('');
|
||||
} else {
|
||||
ul.innerHTML = '<li>No active streams.</li>';
|
||||
}
|
||||
} else {
|
||||
ul.innerHTML = '<li>No active streams.</li>';
|
||||
ul.innerHTML = '<li>Error: Invalid stream data.</li>';
|
||||
console.error('[streams-ui] renderStreamList: streams is not an array', streams);
|
||||
}
|
||||
highlightActiveProfileLink();
|
||||
console.debug('[streams-ui] renderStreamList complete');
|
||||
}
|
||||
|
||||
export function highlightActiveProfileLink() {
|
||||
@ -31,26 +207,7 @@ export function highlightActiveProfileLink() {
|
||||
});
|
||||
}
|
||||
|
||||
export function initStreamsLoader() {
|
||||
const streamsLink = document.getElementById('show-streams');
|
||||
streamsLink?.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
if (loadingStreams) return;
|
||||
loadingStreams = true;
|
||||
showOnly('stream-page');
|
||||
try {
|
||||
const res = await fetch('/streams');
|
||||
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||
const data = await res.json();
|
||||
renderStreamList(data.streams || []);
|
||||
} catch {
|
||||
const ul = document.getElementById('stream-list');
|
||||
if (ul) ul.innerHTML = '<li>Error loading stream list</li>';
|
||||
} finally {
|
||||
loadingStreams = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function initStreamLinks() {
|
||||
const ul = document.getElementById('stream-list');
|
||||
@ -61,16 +218,17 @@ export function initStreamLinks() {
|
||||
e.preventDefault();
|
||||
const url = new URL(a.href, window.location.origin);
|
||||
const profileUid = url.searchParams.get('profile');
|
||||
if (profileUid && window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
|
||||
window.profileNavigationTriggered = true;
|
||||
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}`);
|
||||
window.dispatchEvent(new Event('popstate'));
|
||||
if (profileUid) {
|
||||
if (window.location.search !== `?profile=${encodeURIComponent(profileUid)}`) {
|
||||
window.profileNavigationTriggered = true;
|
||||
window.history.pushState({}, '', `/?profile=${encodeURIComponent(profileUid)}#`);
|
||||
window.dispatchEvent(new Event('popstate'));
|
||||
} else {
|
||||
// If already on this profile, still highlight
|
||||
if (typeof window.highlightActiveProfileLink === "function") {
|
||||
window.highlightActiveProfileLink();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initStreamsUI() {
|
||||
initStreamsLoader();
|
||||
initStreamLinks();
|
||||
window.addEventListener('popstate', highlightActiveProfileLink);
|
||||
}
|
||||
|
605
static/style.css
605
static/style.css
@ -1,4 +1,48 @@
|
||||
/* style.css — minimal UI styling for dicta2stream */
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center; /* centers children horizontally */
|
||||
}
|
||||
|
||||
nav#guest-dashboard.dashboard-nav {
|
||||
width: fit-content; /* optional: shrink to fit content */
|
||||
margin: 0 auto; /* fallback for block centering */
|
||||
}
|
||||
|
||||
/* Toast notification styles */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 40px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--crt-screen);
|
||||
color: var(--crt-text);
|
||||
padding: 1em 2em;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px var(--crt-shadow);
|
||||
margin-top: 0.5em;
|
||||
opacity: 0;
|
||||
animation: fadeInOut 3.5s both;
|
||||
font-size: 1.1em;
|
||||
pointer-events: auto;
|
||||
border: 1px solid var(--crt-border);
|
||||
text-shadow: 0 0 2px rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% { opacity: 0; transform: translateY(40px) scale(0.96); }
|
||||
10% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
90% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(40px) scale(0.96); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
@ -27,6 +71,8 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.cancel-upload {
|
||||
display: none;
|
||||
margin-top: 0.4em;
|
||||
@ -83,7 +129,7 @@ button.logout:hover {
|
||||
|
||||
audio {
|
||||
display: block;
|
||||
margin: 1em auto;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
outline: none;
|
||||
border-radius: 6px;
|
||||
@ -91,6 +137,366 @@ audio {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.audio-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 1.5em 0;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.audio-controls button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 1.2em;
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 96px;
|
||||
min-height: 96px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.audio-controls button:hover {
|
||||
background-color: #f0f0f0;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.audio-controls button:active {
|
||||
color: #000;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.audio-controls svg {
|
||||
fill: #333;
|
||||
transition: all 0.2s ease;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.audio-controls button:hover svg {
|
||||
fill: #000;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Hide the native controls */
|
||||
audio::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
audio::-webkit-media-controls-play-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
audio::-webkit-media-controls-volume-slider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
main > section {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
padding: 2em;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--audio-metal);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5em;
|
||||
color: var(--audio-text);
|
||||
text-shadow: 0 0 2px rgba(255, 102, 0, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
main > section article {
|
||||
background: #222;
|
||||
border: 1px solid #444;
|
||||
padding: 1.5em;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1em;
|
||||
position: relative;
|
||||
color: #d3d3d3; /* Light gray for better contrast */
|
||||
}
|
||||
|
||||
main > section article::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, rgba(255, 102, 0, 0.03), rgba(255, 102, 0, 0.01));
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
main > section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Audio controls styling */
|
||||
button.audio-control {
|
||||
background: #333;
|
||||
border: 2px solid #555;
|
||||
color: #fff;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#register-form {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#register-form p {
|
||||
margin-bottom: 1em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#register-form label {
|
||||
display: inline;
|
||||
margin-right: 10px;
|
||||
color: #d3d3d3;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
#register-form input[type="text"],
|
||||
#register-form input[type="email"],
|
||||
#register-form input[type="password"] {
|
||||
display: inline;
|
||||
background-color: rgb(26, 26, 26);
|
||||
border: 1px solid #444;
|
||||
color: #d3d3d3;
|
||||
padding: 0.8em;
|
||||
border-radius: 4px;
|
||||
width: 250px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#register-form input[type="text"]:focus,
|
||||
#register-form input[type="email"]:focus,
|
||||
#register-form input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #ff6600;
|
||||
box-shadow: 0 0 0 2px rgba(255, 102, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Submit button styling */
|
||||
#register-form button[type="submit"] {
|
||||
display: inline;
|
||||
width: calc(250px + 1.6em);
|
||||
padding: 0.8em;
|
||||
background: transparent;
|
||||
color: #d3d3d3;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-top: 1.25em;
|
||||
transition: color 0.3s;
|
||||
font-family: 'Courier New', monospace;
|
||||
text-align: center;
|
||||
border: 1px solid #444;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
#register-form button[type="submit"]:hover {
|
||||
color: #ff6600;
|
||||
}
|
||||
|
||||
#register-form button[type="submit"]:active {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button.audio-control:hover {
|
||||
background: #444;
|
||||
border-color: #666;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Audio meter styling */
|
||||
.audio-meter {
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.audio-meter-fill {
|
||||
background: linear-gradient(90deg, #ff6600, #ff8800);
|
||||
height: 100%;
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Audio list styling */
|
||||
.audio-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid var(--audio-metal);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#stream-list > li {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Stream player styling */
|
||||
.stream-player {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
padding: 1.5em;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1em;
|
||||
position: relative;
|
||||
color: #d3d3d3;
|
||||
}
|
||||
|
||||
.stream-player::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(45deg, rgba(255, 102, 0, 0.03), rgba(255, 102, 0, 0.01));
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.stream-player h3 {
|
||||
color: var(--audio-accent);
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 1.1em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stream-player audio {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 1em 0;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stream-player audio {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background: #1a1a1a;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
/* Custom play button */
|
||||
.stream-player audio::before {
|
||||
content: '▶️';
|
||||
position: absolute;
|
||||
left: 0.5em;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #2e8b57;
|
||||
font-size: 1.5em;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Custom progress bar */
|
||||
.stream-player audio::-webkit-progress-bar {
|
||||
background: #444;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.stream-player audio::-webkit-progress-value {
|
||||
background: linear-gradient(90deg, #ff6600, #ff8800);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Custom volume slider */
|
||||
.stream-player audio::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ff6600;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stream-player audio::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: #444;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Guest login heading styling */
|
||||
#guest-login h2 {
|
||||
color: var(--audio-accent);
|
||||
font-size: 1.1em;
|
||||
margin: 0.5em 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
background: #2e8b57;
|
||||
color: white;
|
||||
padding: 0.4em 1em;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
#guest-login h2:hover {
|
||||
background: #256b45;
|
||||
}
|
||||
|
||||
.audio-list li {
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
padding: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.audio-list li:hover {
|
||||
background: #444;
|
||||
border-color: #666;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#me-wrap {
|
||||
background: #fdfdfd;
|
||||
padding: 1.5em;
|
||||
@ -149,6 +555,13 @@ input[disabled], button[disabled] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#stream-info, #stream-info * {
|
||||
display: initial !important;
|
||||
visibility: visible !important;
|
||||
color: #222 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
background: #fafafa;
|
||||
@ -357,6 +770,68 @@ section article {
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
section article.stream-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stream-player {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
padding: 1.5em;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1em;
|
||||
position: relative;
|
||||
color: #d3d3d3;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stream-player h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #d3d3d3;
|
||||
}
|
||||
|
||||
.stream-audio {
|
||||
width: 100%;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.audio-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.audio-controls button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.audio-controls button:hover {
|
||||
background-color: #f0f0f0;
|
||||
color: #333;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.audio-controls button:active {
|
||||
color: #000;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.stream-info {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
ul#stream-list,
|
||||
ul#me-files {
|
||||
padding-left: 0;
|
||||
@ -413,10 +888,130 @@ section article a[href^="mailto"]:hover {
|
||||
}
|
||||
|
||||
code {
|
||||
background: #eee;
|
||||
background: #1a1a1a;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 4px rgba(0, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
:root {
|
||||
--audio-bg: #222;
|
||||
--audio-metal: #333;
|
||||
--audio-text: #fff;
|
||||
--audio-accent: #ff6600;
|
||||
--audio-shadow: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background-color: var(--audio-bg);
|
||||
color: var(--audio-text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at center, rgba(255, 102, 0, 0.05) 0%, rgba(255, 102, 0, 0) 20%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-width: 960px;
|
||||
width: 95%;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: var(--audio-bg);
|
||||
border: 2px solid var(--audio-metal);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px var(--audio-shadow);
|
||||
}
|
||||
|
||||
main::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
rgba(0, 255, 0, 0.05),
|
||||
rgba(0, 255, 0, 0.05) 2px,
|
||||
transparent 2px,
|
||||
transparent 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
nav#guest-dashboard.dashboard-nav,
|
||||
nav#user-dashboard.dashboard-nav {
|
||||
width: fit-content;
|
||||
margin: 20px auto;
|
||||
padding: 10px;
|
||||
background: var(--crt-screen);
|
||||
border: 1px solid var(--crt-border);
|
||||
border-radius: 5px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
nav.dashboard-nav a {
|
||||
color: #d3d3d3;
|
||||
text-decoration: none;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin: 0 0.5em;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
nav.dashboard-nav a:hover {
|
||||
color: #ff6600;
|
||||
}
|
||||
|
||||
/* Toast notification styles */
|
||||
|
||||
/* Footer links styling */
|
||||
.footer-links {
|
||||
margin-top: 1em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #d3d3d3;
|
||||
text-decoration: none;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin: 0 0.5em;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: #ff6600;
|
||||
}
|
||||
|
||||
|
||||
|
||||
footer p.footer-hint a {
|
||||
color: #d3d3d3;
|
||||
text-decoration: none;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin: 0 0.5em;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
footer p.footer-hint a:hover {
|
||||
color: #ff6600;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@ -424,7 +1019,7 @@ code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #e0f7ff;
|
||||
background: var(--crt-screen);
|
||||
padding: 1em;
|
||||
margin: 2em auto;
|
||||
border-radius: 6px;
|
||||
|
19
static/toast.js
Normal file
19
static/toast.js
Normal file
@ -0,0 +1,19 @@
|
||||
// toast.js — centralized toast notification logic for dicta2stream
|
||||
|
||||
export function showToast(message) {
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
// Do not remove the container; let it persist for stacking
|
||||
}, 3500);
|
||||
}
|
||||
|
128
static/upload.js
Normal file
128
static/upload.js
Normal file
@ -0,0 +1,128 @@
|
||||
// upload.js — Frontend file upload handler
|
||||
|
||||
import { showToast } from "./toast.js";
|
||||
import { playBeep } from "./sound.js";
|
||||
import { logToServer } from "./app.js";
|
||||
|
||||
// Initialize upload system when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dropzone = document.getElementById("user-upload-area");
|
||||
if (dropzone) {
|
||||
dropzone.setAttribute("aria-label", "Upload area. Click or drop an audio file to upload.");
|
||||
}
|
||||
const fileInput = document.getElementById("fileInputUser");
|
||||
const fileInfo = document.createElement("div");
|
||||
fileInfo.id = "file-info";
|
||||
fileInfo.style.textAlign = "center";
|
||||
if (fileInput) {
|
||||
fileInput.parentNode.insertBefore(fileInfo, fileInput.nextSibling);
|
||||
}
|
||||
const streamInfo = document.getElementById("stream-info");
|
||||
const streamUrlEl = document.getElementById("streamUrl");
|
||||
const spinner = document.getElementById("spinner");
|
||||
let abortController;
|
||||
|
||||
// Upload function
|
||||
const upload = async (file) => {
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
fileInfo.innerText = `📁 ${file.name} • ${(file.size / 1024 / 1024).toFixed(2)} MB`;
|
||||
if (file.size > 100 * 1024 * 1024) {
|
||||
showToast("❌ File too large. Please upload a file smaller than 100MB.");
|
||||
return;
|
||||
}
|
||||
spinner.style.display = "block";
|
||||
showToast('📡 Uploading…');
|
||||
|
||||
fileInput.disabled = true;
|
||||
dropzone.classList.add("uploading");
|
||||
const formData = new FormData();
|
||||
const sessionUid = localStorage.getItem("uid");
|
||||
formData.append("uid", sessionUid);
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await fetch("/upload", {
|
||||
signal: abortController.signal,
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
let data, parseError;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
parseError = e;
|
||||
}
|
||||
if (!data) {
|
||||
showToast("❌ Upload failed: " + (parseError && parseError.message ? parseError.message : "Unknown error"));
|
||||
spinner.style.display = "none";
|
||||
fileInput.disabled = false;
|
||||
dropzone.classList.remove("uploading");
|
||||
return;
|
||||
}
|
||||
if (res.ok) {
|
||||
if (data.quota && data.quota.used_mb !== undefined) {
|
||||
const bar = document.getElementById("quota-bar");
|
||||
const text = document.getElementById("quota-text");
|
||||
const quotaSec = document.getElementById("quota-meter");
|
||||
if (bar && text && quotaSec) {
|
||||
quotaSec.hidden = false;
|
||||
const used = parseFloat(data.quota.used_mb);
|
||||
bar.value = used;
|
||||
bar.max = 100;
|
||||
text.textContent = `${used.toFixed(1)} MB used`;
|
||||
}
|
||||
}
|
||||
spinner.style.display = "none";
|
||||
fileInput.disabled = false;
|
||||
dropzone.classList.remove("uploading");
|
||||
showToast("✅ Upload successful.");
|
||||
|
||||
playBeep(432, 0.25, "sine");
|
||||
} else {
|
||||
streamInfo.hidden = true;
|
||||
spinner.style.display = "none";
|
||||
if ((data.detail || data.error || "").includes("music")) {
|
||||
showToast("🎵 Upload rejected: singing or music detected.");
|
||||
} else {
|
||||
showToast(`❌ Upload failed: ${data.detail || data.error}`);
|
||||
}
|
||||
|
||||
if (fileInput) fileInput.value = null;
|
||||
if (dropzone) dropzone.classList.remove("uploading");
|
||||
if (fileInput) fileInput.disabled = false;
|
||||
if (streamInfo) streamInfo.classList.remove("visible", "slide-in");
|
||||
}
|
||||
};
|
||||
|
||||
// Export the upload function for use in other modules
|
||||
window.upload = upload;
|
||||
|
||||
if (dropzone && fileInput) {
|
||||
dropzone.addEventListener("click", () => {
|
||||
console.log("[DEBUG] Dropzone clicked");
|
||||
fileInput.click();
|
||||
console.log("[DEBUG] fileInput.click() called");
|
||||
});
|
||||
dropzone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("dragover");
|
||||
dropzone.style.transition = "background-color 0.3s ease";
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => {
|
||||
dropzone.classList.remove("dragover");
|
||||
});
|
||||
dropzone.addEventListener("drop", (e) => {
|
||||
dropzone.classList.add("pulse");
|
||||
setTimeout(() => dropzone.classList.remove("pulse"), 400);
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("dragover");
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) upload(file);
|
||||
});
|
||||
fileInput.addEventListener("change", (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) upload(file);
|
||||
});
|
||||
}
|
||||
});
|
27
streams.py
Normal file
27
streams.py
Normal file
@ -0,0 +1,27 @@
|
||||
# streams.py — Public streams endpoint for dicta2stream
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/streams")
|
||||
def list_streams():
|
||||
txt_path = Path("./public_streams.txt")
|
||||
if not txt_path.exists():
|
||||
return {"streams": []}
|
||||
try:
|
||||
streams = []
|
||||
with txt_path.open("r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
streams.append(json.loads(line))
|
||||
except Exception:
|
||||
continue # skip malformed lines
|
||||
return {"streams": streams}
|
||||
except Exception:
|
||||
return {"streams": []}
|
0
streams_cache.py
Normal file
0
streams_cache.py
Normal file
11
testmail.py
Normal file
11
testmail.py
Normal file
@ -0,0 +1,11 @@
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["From"] = "test@keisanki.net"
|
||||
msg["To"] = "oib@bubuit.net"
|
||||
msg["Subject"] = "Test"
|
||||
msg.set_content("Hello world")
|
||||
|
||||
with smtplib.SMTP("localhost") as smtp:
|
||||
smtp.send_message(msg)
|
20
upload.py
20
upload.py
@ -78,16 +78,15 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
|
||||
original_size = raw_path.stat().st_size
|
||||
raw_path.unlink(missing_ok=True) # cleanup
|
||||
|
||||
# Always copy latest upload as stream.opus for redirect compatibility
|
||||
import shutil
|
||||
stream_path = user_dir / "stream.opus"
|
||||
shutil.copy2(processed_path, stream_path)
|
||||
|
||||
# Also update ./data/{uid}/stream.opus for public stream listing
|
||||
streams_dir = Path("data") / uid
|
||||
streams_dir.mkdir(parents=True, exist_ok=True)
|
||||
streams_stream_path = streams_dir / "stream.opus"
|
||||
shutil.copy2(processed_path, streams_stream_path)
|
||||
# Concatenate all .opus files in random order to stream.opus for public playback
|
||||
from concat_opus import concat_opus_files
|
||||
try:
|
||||
concat_opus_files(user_dir, user_dir / "stream.opus")
|
||||
except Exception as e:
|
||||
# fallback: just use the latest processed file if concat fails
|
||||
import shutil
|
||||
stream_path = user_dir / "stream.opus"
|
||||
shutil.copy2(processed_path, stream_path)
|
||||
|
||||
db.add(UploadLog(
|
||||
uid=uid,
|
||||
@ -106,7 +105,6 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"stream_url": f"http://localhost:8000/streams/{uid}/stream.opus",
|
||||
"filename": file.filename,
|
||||
"original_size": round(original_size / 1024, 1),
|
||||
"quota": {
|
||||
|
Reference in New Issue
Block a user