feat: Add database migrations and auth system

- Add Alembic for database migrations
- Implement user authentication system
- Update frontend styles and components
- Add new test audio functionality
- Update stream management and UI
This commit is contained in:
oib
2025-07-02 09:37:03 +02:00
parent 39934115a1
commit 17616ac5b8
49 changed files with 5059 additions and 804 deletions

93
DATABASE.md Normal file
View File

@ -0,0 +1,93 @@
# Database Setup and Migrations
This document explains how to set up and manage the database for the dicta2stream application.
## Prerequisites
- PostgreSQL database server
- Python 3.8+
- Required Python packages (install with `pip install -r requirements.txt`)
## Initial Setup
1. Create a PostgreSQL database:
```bash
createdb dicta2stream
```
2. Set up the database URL in your environment:
```bash
echo "DATABASE_URL=postgresql://username:password@localhost/dicta2stream" > .env
```
Replace `username` and `password` with your PostgreSQL credentials.
3. Initialize the database:
```bash
python init_db.py
```
## Running Migrations
After making changes to the database models, you can create and apply migrations:
1. Install the required dependencies:
```bash
pip install -r requirements.txt
```
2. Run the migrations:
```bash
python run_migrations.py
```
## Database Models
The application uses the following database models:
### User
- Stores user account information
- Fields: username, email, hashed_password, is_active, created_at, updated_at
### Session
- Manages user sessions
- Fields: id, user_id, token, ip_address, user_agent, created_at, expires_at, last_used_at, is_active
### PublicStream
- Tracks publicly available audio streams
- Fields: uid, filename, size, mtime, created_at, updated_at
### UserQuota
- Tracks user storage quotas
- Fields: uid, storage_bytes, updated_at
### UploadLog
- Logs file uploads
- Fields: id, uid, filename, size, ip_address, user_agent, created_at
## Backing Up the Database
To create a backup of your database:
```bash
pg_dump -U username -d dicta2stream -f backup.sql
```
To restore from a backup:
```bash
psql -U username -d dicta2stream -f backup.sql
```
## Troubleshooting
- If you encounter connection issues, verify that:
- The PostgreSQL server is running
- The database URL in your .env file is correct
- The database user has the necessary permissions
- If you need to reset the database:
```bash
dropdb dicta2stream
createdb dicta2stream
python init_db.py
```

98
account_router.py Normal file
View File

@ -0,0 +1,98 @@
# account_router.py — Account management endpoints
from fastapi import APIRouter, Request, HTTPException, Depends
from fastapi.responses import JSONResponse
from sqlmodel import Session, select
from models import User, UserQuota, UploadLog, DBSession
from database import get_db
import os
from typing import Dict, Any
router = APIRouter(prefix="/api", tags=["account"])
@router.post("/delete-account")
async def delete_account(data: Dict[str, Any], request: Request, db: Session = Depends(get_db)):
try:
# Get UID from request data
uid = data.get("uid")
if not uid:
print(f"[DELETE_ACCOUNT] Error: Missing UID in request data")
raise HTTPException(status_code=400, detail="Missing UID")
ip = request.client.host
print(f"[DELETE_ACCOUNT] Processing delete request for UID: {uid} from IP: {ip}")
# Verify user exists and IP matches
user = db.exec(select(User).where(User.username == uid)).first()
if not user:
print(f"[DELETE_ACCOUNT] Error: User {uid} not found")
raise HTTPException(status_code=404, detail="User not found")
if user.ip != ip:
print(f"[DELETE_ACCOUNT] Error: IP mismatch. User IP: {user.ip}, Request IP: {ip}")
raise HTTPException(status_code=403, detail="Unauthorized: IP address does not match")
# Start transaction
try:
# Delete user's upload logs
uploads = db.exec(select(UploadLog).where(UploadLog.uid == uid)).all()
for upload in uploads:
db.delete(upload)
print(f"[DELETE_ACCOUNT] Deleted {len(uploads)} upload logs for user {uid}")
# Delete user's quota
quota = db.get(UserQuota, uid)
if quota:
db.delete(quota)
print(f"[DELETE_ACCOUNT] Deleted quota for user {uid}")
# Delete user's active sessions
sessions = db.exec(select(DBSession).where(DBSession.user_id == uid)).all()
for session in sessions:
db.delete(session)
print(f"[DELETE_ACCOUNT] Deleted {len(sessions)} active sessions for user {uid}")
# Delete user account
user_obj = db.get(User, user.email)
if user_obj:
db.delete(user_obj)
print(f"[DELETE_ACCOUNT] Deleted user account {uid} ({user.email})")
db.commit()
print(f"[DELETE_ACCOUNT] Database changes committed for user {uid}")
except Exception as e:
db.rollback()
print(f"[DELETE_ACCOUNT] Database error during account deletion: {str(e)}")
raise HTTPException(status_code=500, detail="Database error during account deletion")
# Delete user's files
try:
user_dir = os.path.join('data', user.username)
real_user_dir = os.path.realpath(user_dir)
# Security check to prevent directory traversal
if not real_user_dir.startswith(os.path.realpath('data')):
print(f"[DELETE_ACCOUNT] Security alert: Invalid user directory path: {user_dir}")
raise HTTPException(status_code=400, detail="Invalid user directory")
if os.path.exists(real_user_dir):
import shutil
shutil.rmtree(real_user_dir, ignore_errors=True)
print(f"[DELETE_ACCOUNT] Deleted user directory: {real_user_dir}")
else:
print(f"[DELETE_ACCOUNT] User directory not found: {real_user_dir}")
except Exception as e:
print(f"[DELETE_ACCOUNT] Error deleting user files: {str(e)}")
# Continue even if file deletion fails, as the account is already deleted from the DB
print(f"[DELETE_ACCOUNT] Successfully deleted account for user {uid}")
return {"status": "success", "message": "Account and all associated data have been deleted"}
except HTTPException as he:
print(f"[DELETE_ACCOUNT] HTTP Error {he.status_code}: {he.detail}")
raise
except Exception as e:
print(f"[DELETE_ACCOUNT] Unexpected error: {str(e)}")
raise HTTPException(status_code=500, detail="An unexpected error occurred")

140
alembic.ini Normal file
View File

@ -0,0 +1,140 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql://postgres:postgres@localhost/dicta2stream
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration.

61
alembic/env.py Normal file
View File

@ -0,0 +1,61 @@
from logging.config import fileConfig
import os
import sys
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Import your SQLAlchemy models and engine
from models import SQLModel
from database import engine
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Import all your SQLModel models here so that Alembic can detect them
from models import User, DBSession
# Set the target metadata to SQLModel.metadata
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,86 @@
"""make username unique
Revision ID: 1ab2db0e4b5e
Revises:
Create Date: 2025-06-27 13:04:10.085253
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '1ab2db0e4b5e'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 1. First, add the unique constraint to the username column
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_unique_constraint('uq_user_username', ['username'])
# 2. Now create the dbsession table with the foreign key
op.create_table('dbsession',
sa.Column('token', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('ip_address', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_agent', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('last_activity', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.username'], ),
sa.PrimaryKeyConstraint('token')
)
# 3. Drop old tables if they exist
if op.get_bind().engine.dialect.has_table(op.get_bind(), 'session'):
op.drop_index(op.f('ix_session_token'), table_name='session')
op.drop_index(op.f('ix_session_user_id'), table_name='session')
op.drop_table('session')
if op.get_bind().engine.dialect.has_table(op.get_bind(), 'publicstream'):
op.drop_table('publicstream')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# 1. First drop the dbsession table
op.drop_table('dbsession')
# 2. Recreate the old tables
op.create_table('publicstream',
sa.Column('uid', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('size', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('mtime', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('uid', name=op.f('publicstream_pkey'))
)
op.create_table('session',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('token', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('ip_address', sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column('user_agent', sa.VARCHAR(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('expires_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('last_used_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('session_pkey'))
)
op.create_index(op.f('ix_session_user_id'), 'session', ['user_id'], unique=False)
op.create_index(op.f('ix_session_token'), 'session', ['token'], unique=True)
# 3. Finally, remove the unique constraint from username
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_constraint('uq_user_username', type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""add_processed_filename_to_uploadlog
Revision ID: f86c93c7a872
Revises: 1ab2db0e4b5e
Create Date: 2025-06-28 15:56:29.169668
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f86c93c7a872'
down_revision: Union[str, Sequence[str], None] = '1ab2db0e4b5e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('uploadlog',
sa.Column('processed_filename', sa.String(), nullable=True),
schema=None)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('uploadlog', 'processed_filename', schema=None)

73
auth.py Normal file
View File

@ -0,0 +1,73 @@
"""Authentication middleware and utilities for dicta2stream"""
from fastapi import Request, HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlmodel import Session
from typing import Optional
from models import User, Session as DBSession, verify_session
from database import get_db
security = HTTPBearer()
def get_current_user(
request: Request,
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> User:
"""Dependency to get the current authenticated user"""
token = credentials.credentials
db_session = verify_session(db, token)
if not db_session:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired session",
headers={"WWW-Authenticate": "Bearer"},
)
# Get the user from the session
user = db.exec(
select(User).where(User.username == db_session.user_id)
).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
headers={"WWW-Authenticate": "Bearer"},
)
# Attach the session to the request state for later use
request.state.session = db_session
return user
def get_optional_user(
request: Request,
db: Session = Depends(get_db),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security, use_cache=False)
) -> Optional[User]:
"""Dependency that returns the current user if authenticated, None otherwise"""
if not credentials:
return None
try:
return get_current_user(request, db, credentials)
except HTTPException:
return None
def create_session(db: Session, user: User, request: Request) -> DBSession:
"""Create a new session for the user"""
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else "0.0.0.0"
session = DBSession.create_for_user(
user_id=user.username,
ip_address=ip_address,
user_agent=user_agent
)
db.add(session)
db.commit()
return session

106
auth_router.py Normal file
View File

@ -0,0 +1,106 @@
"""Authentication routes for dicta2stream"""
from fastapi import APIRouter, Depends, Request, Response, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlmodel import Session
from models import Session as DBSession, User
from database import get_db
from auth import get_current_user
router = APIRouter()
security = HTTPBearer()
@router.post("/logout")
async def logout(
request: Request,
response: Response,
db: Session = Depends(get_db),
credentials: HTTPAuthorizationCredentials = Depends(security)
):
"""Log out by invalidating the current session"""
token = credentials.credentials
# Find and invalidate the session
session = db.exec(
select(DBSession)
.where(DBSession.token == token)
.where(DBSession.is_active == True) # noqa: E712
).first()
if session:
session.is_active = False
db.add(session)
db.commit()
# Clear the session cookie
response.delete_cookie(
key="sessionid", # Must match the cookie name in main.py
httponly=True,
secure=True, # Must match the cookie settings from login
samesite="lax",
path="/"
)
return {"message": "Successfully logged out"}
@router.get("/me")
async def get_current_user_info(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user information"""
return {
"username": current_user.username,
"email": current_user.email,
"created_at": current_user.token_created.isoformat(),
"is_confirmed": current_user.confirmed
}
@router.get("/sessions")
async def list_sessions(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List all active sessions for the current user"""
sessions = DBSession.get_active_sessions(db, current_user.username)
return [
{
"id": s.id,
"ip_address": s.ip_address,
"user_agent": s.user_agent,
"created_at": s.created_at.isoformat(),
"last_used_at": s.last_used_at.isoformat(),
"expires_at": s.expires_at.isoformat()
}
for s in sessions
]
@router.post("/sessions/{session_id}/revoke")
async def revoke_session(
session_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Revoke a specific session"""
session = db.get(DBSession, session_id)
if not session or session.user_id != current_user.username:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Session not found"
)
if not session.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session is already inactive"
)
session.is_active = False
db.add(session)
db.commit()
return {"message": "Session revoked"}

22
dicta2stream.service Normal file
View File

@ -0,0 +1,22 @@
[Unit]
Description=Dicta2Stream FastAPI application (Gunicorn)
After=network.target
[Service]
User=oib
Group=www-data
WorkingDirectory=/home/oib/games/dicta2stream
Environment="PATH=/home/oib/games/dicta2stream/venv/bin"
Environment="PYTHONPATH=/home/oib/games/dicta2stream"
ExecStart=/home/oib/games/dicta2stream/venv/bin/gunicorn -c gunicorn_config.py main:app
Restart=always
RestartSec=5
# Security
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
[Install]
WantedBy=multi-user.target

35
gunicorn_config.py Normal file
View File

@ -0,0 +1,35 @@
# Gunicorn configuration file
import multiprocessing
import os
# Server socket
bind = "0.0.0.0:8000"
# Worker processes
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "uvicorn.workers.UvicornWorker"
worker_connections = 1000
max_requests = 1000
max_requests_jitter = 50
timeout = 120
keepalive = 5
# Security
limit_request_line = 4094
limit_request_fields = 50
limit_request_field_size = 8190
# Debugging
debug = os.getenv("DEBUG", "false").lower() == "true"
reload = debug
# Logging
loglevel = "debug" if debug else "info"
accesslog = "-" # Log to stdout
errorlog = "-" # Log to stderr
# Server mechanics
preload_app = True
# Process naming
proc_name = "dicta2stream"

36
init_db.py Normal file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Initialize the database with required tables"""
import os
import sys
from sqlmodel import SQLModel, create_engine
from dotenv import load_dotenv
# Add the parent directory to the path so we can import our models
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from models import User, UserQuota, UploadLog, PublicStream, Session
def init_db():
"""Initialize the database with required tables"""
# Load environment variables
load_dotenv()
# Get database URL from environment or use default
database_url = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost/dicta2stream"
)
print(f"Connecting to database: {database_url}")
# Create database engine
engine = create_engine(database_url)
# Create all tables
print("Creating database tables...")
SQLModel.metadata.create_all(engine)
print("Database initialized successfully!")
if __name__ == "__main__":
init_db()

View File

@ -1,48 +1,116 @@
# list_streams.py — FastAPI route to list all public streams (users with stream.opus)
from fastapi import APIRouter
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse, Response
from pathlib import Path
from fastapi.responses import StreamingResponse
import asyncio
router = APIRouter()
DATA_ROOT = Path("./data")
@router.get("/streams-sse")
def streams_sse():
return list_streams_sse()
async def streams_sse(request: Request):
print(f"[SSE] New connection from {request.client.host}")
print(f"[SSE] Request headers: {dict(request.headers)}")
# Add CORS headers for SSE
origin = request.headers.get('origin', '')
allowed_origins = ["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"]
# Use the request origin if it's in the allowed list, otherwise use the first allowed origin
cors_origin = origin if origin in allowed_origins else allowed_origins[0]
headers = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": cors_origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Expose-Headers": "Content-Type",
"X-Accel-Buffering": "no" # Disable buffering for nginx
}
# Handle preflight requests
if request.method == "OPTIONS":
print("[SSE] Handling OPTIONS preflight request")
headers.update({
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": request.headers.get("access-control-request-headers", "*"),
"Access-Control-Max-Age": "86400" # 24 hours
})
return Response(status_code=204, headers=headers)
print("[SSE] Starting SSE stream")
async def event_wrapper():
try:
async for event in list_streams_sse():
yield event
except Exception as e:
print(f"[SSE] Error in event generator: {str(e)}")
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'error': True, 'message': str(e)})}\n\n"
return StreamingResponse(
event_wrapper(),
media_type="text/event-stream",
headers=headers
)
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")
async def list_streams_sse():
print("[SSE] Starting stream generator")
txt_path = Path("./public_streams.txt")
if not txt_path.exists():
print(f"[SSE] No public_streams.txt found")
yield f"data: {json.dumps({'end': True})}\n\n"
return
try:
# Send initial ping
print("[SSE] Sending initial ping")
yield ":ping\n\n"
# Read and send the file contents
with txt_path.open("r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
# Parse the JSON to validate it
stream = json.loads(line)
print(f"[SSE] Sending stream data: {stream}")
# Send the data as an SSE event
event = f"data: {json.dumps(stream)}\n\n"
yield event
# Small delay to prevent overwhelming the client
await asyncio.sleep(0.1)
except json.JSONDecodeError as e:
print(f"[SSE] JSON decode error: {e} in line: {line}")
continue
except Exception as e:
print(f"[SSE] Error processing line: {e}")
continue
print("[SSE] Sending end event")
yield f"data: {json.dumps({'end': True})}\n\n"
except Exception as e:
print(f"[SSE] Error in stream generator: {str(e)}")
import traceback
traceback.print_exc()
yield f"data: {json.dumps({'error': True, 'message': str(e)})}\n\n"
finally:
print("[SSE] Stream generator finished")
def list_streams():
txt_path = Path("./public_streams.txt")

View File

@ -1,16 +1,18 @@
# magic.py — handle magic token login confirmation
from fastapi import APIRouter, Form, HTTPException, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi import APIRouter, Form, HTTPException, Depends, Request, Response
from fastapi.responses import RedirectResponse, JSONResponse
from sqlmodel import Session, select
from database import get_db
from models import User
from models import User, DBSession
from datetime import datetime, timedelta
import secrets
import json
router = APIRouter()
@router.post("/magic-login")
def magic_login(request: Request, db: Session = Depends(get_db), token: str = Form(...)):
async def magic_login(request: Request, response: Response, 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'}")
@ -23,12 +25,45 @@ def magic_login(request: Request, db: Session = Depends(get_db), token: str = Fo
print(f"[magic-login] Token expired for user: {user.username}")
return RedirectResponse(url="/?error=Token%20expired", status_code=302)
# Mark user as confirmed if not already
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.")
db.add(user)
print(f"[magic-login] User {user.username} confirmed.")
return RedirectResponse(url=f"/?login=success&confirmed_uid={user.username}", status_code=302)
# Create a new session for the user (valid for 1 hour)
session_token = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(hours=1)
# Create new session
session = DBSession(
token=session_token,
user_id=user.username,
ip_address=request.client.host or "",
user_agent=request.headers.get("user-agent", ""),
expires_at=expires_at,
is_active=True
)
db.add(session)
db.commit()
# Set cookie with the session token (valid for 1 hour)
response.set_cookie(
key="sessionid",
value=session_token,
httponly=True,
secure=not request.url.hostname == "localhost",
samesite="lax",
max_age=3600, # 1 hour
path="/"
)
print(f"[magic-login] Session created for user: {user.username}")
# Redirect to success page
return RedirectResponse(
url=f"/?login=success&confirmed_uid={user.username}",
status_code=302,
headers=dict(response.headers)
)

146
main.py
View File

@ -1,6 +1,6 @@
# main.py — FastAPI backend entrypoint for dicta2stream
from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends
from fastapi import FastAPI, Request, Response, status, Form, UploadFile, File, Depends, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
@ -40,12 +40,20 @@ app = FastAPI(debug=debug_mode)
# --- CORS Middleware for SSE and API access ---
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
# Add GZip middleware for compression
app.add_middleware(GZipMiddleware, minimum_size=1000)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["https://dicta2stream.net", "http://localhost:8000", "http://127.0.0.1:8000"],
allow_credentials=True,
allow_methods=["*"],
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
expose_headers=["Content-Type", "Content-Length", "Cache-Control", "ETag", "Last-Modified"],
max_age=3600, # 1 hour
)
from fastapi.staticfiles import StaticFiles
@ -125,7 +133,9 @@ 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
from account_router import router as account_router
app.include_router(account_router)
app.include_router(register_router)
app.include_router(magic_router)
app.include_router(upload_router)
@ -135,6 +145,10 @@ app.include_router(list_streams_router)
# Serve static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Serve audio files
os.makedirs("data", exist_ok=True) # Ensure the data directory exists
app.mount("/audio", StaticFiles(directory="data"), name="audio")
@app.post("/log-client")
async def log_client(request: Request):
try:
@ -224,68 +238,7 @@ def debug(request: Request):
MAX_QUOTA_BYTES = 100 * 1024 * 1024
@app.post("/delete-account")
async def delete_account(data: dict, request: Request, db: Session = Depends(get_db)):
uid = data.get("uid")
if not uid:
raise HTTPException(status_code=400, detail="Missing UID")
ip = request.client.host
user = get_user_by_uid(uid)
if not user or user.ip != ip:
raise HTTPException(status_code=403, detail="Unauthorized")
# Delete user quota and user using ORM
quota = db.get(UserQuota, uid)
if quota:
db.delete(quota)
user_obj = db.get(User, user.email)
if user_obj:
db.delete(user_obj)
db.commit()
import shutil
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('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"}
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
if content_type in ["music", "singing"]:
os.remove(raw_path)
log_violation("UPLOAD", ip, uid, f"Rejected content: {content_type}")
return JSONResponse(status_code=403, content={"error": f"{content_type.capitalize()} uploads are not allowed."})
try:
subprocess.run([
"ffmpeg", "-y", "-i", raw_path,
"-ac", "1", "-ar", "48000",
"-c:a", "libopus", "-b:a", "60k",
final_path
], check=True)
except subprocess.CalledProcessError as e:
os.remove(raw_path)
log_violation("FFMPEG", ip, uid, f"ffmpeg failed: {e}")
raise HTTPException(status_code=500, detail="Encoding failed")
os.remove(raw_path)
try:
actual_bytes = int(subprocess.check_output(["du", "-sb", user_dir]).split()[0])
q = db.get(UserQuota, uid)
if q:
q.storage_bytes = actual_bytes
db.add(q)
db.commit()
except Exception as e:
log_violation("QUOTA", ip, uid, f"Quota update failed: {e}")
return {}
# Delete account endpoint has been moved to account_router.py
@app.delete("/uploads/{uid}/{filename}")
def delete_file(uid: str, filename: str, request: Request, db: Session = Depends(get_db)):
@ -333,24 +286,51 @@ def confirm_user(uid: str, request: Request):
@app.get("/me/{uid}")
def get_me(uid: str, request: Request, db: Session = Depends(get_db)):
ip = request.client.host
user = get_user_by_uid(uid)
if not user or user.ip != ip:
raise HTTPException(status_code=403, detail="Unauthorized access")
print(f"[DEBUG] GET /me/{uid} - Client IP: {request.client.host}")
try:
# Get user info
user = get_user_by_uid(uid)
if not user:
print(f"[ERROR] User with UID {uid} not found")
raise HTTPException(status_code=403, detail="User not found")
if user.ip != request.client.host:
print(f"[ERROR] IP mismatch for UID {uid}: {request.client.host} != {user.ip}")
raise HTTPException(status_code=403, detail="IP address mismatch")
user_dir = os.path.join('data', user.username)
files = []
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
path = os.path.join(user_dir, f)
if os.path.isfile(path):
files.append({"name": f, "size": os.path.getsize(path)})
# Get all upload logs for this user
upload_logs = db.exec(
select(UploadLog)
.where(UploadLog.uid == uid)
.order_by(UploadLog.created_at.desc())
).all()
print(f"[DEBUG] Found {len(upload_logs)} upload logs for UID {uid}")
# Build file list from database records
files = []
for log in upload_logs:
if log.filename and log.processed_filename:
# The actual filename on disk might have the log ID prepended
stored_filename = f"{log.id}_{log.processed_filename}"
files.append({
"name": stored_filename,
"original_name": log.filename,
"size": log.size_bytes
})
print(f"[DEBUG] Added file from DB: {log.filename} (stored as {stored_filename}, {log.size_bytes} bytes)")
# Get quota info
q = db.get(UserQuota, uid)
quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0
print(f"[DEBUG] Quota for UID {uid}: {quota_mb} MB")
q = db.get(UserQuota, uid)
quota_mb = round(q.storage_bytes / (1024 * 1024), 2) if q else 0
return {
"files": files,
"quota": quota_mb
}
response_data = {
"files": files,
"quota": quota_mb
}
print(f"[DEBUG] Returning {len(files)} files and quota info")
return response_data
except Exception as e:
print(f"[ERROR] Error in /me/{uid} endpoint: {str(e)}", exc_info=True)
raise

73
middleware.py Normal file
View File

@ -0,0 +1,73 @@
"""Custom middleware for the dicta2stream application"""
import time
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import Response
from starlette.types import ASGIApp
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Middleware to implement rate limiting"""
def __init__(self, app: ASGIApp, limit: int = 100, window: int = 60):
super().__init__(app)
self.limit = limit
self.window = window
self.requests = {}
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# Get client IP
if "x-forwarded-for" in request.headers:
ip = request.headers["x-forwarded-for"].split(",")[0]
else:
ip = request.client.host or "unknown"
# Get current timestamp
current_time = int(time.time())
# Clean up old entries
self.requests = {
k: v
for k, v in self.requests.items()
if current_time - v["timestamp"] < self.window
}
# Check rate limit
if ip in self.requests:
self.requests[ip]["count"] += 1
if self.requests[ip]["count"] > self.limit:
raise HTTPException(
status_code=429,
detail="Too many requests. Please try again later."
)
else:
self.requests[ip] = {"count": 1, "timestamp": current_time}
# Process the request
response = await call_next(request)
return response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Middleware to add security headers to responses"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Add security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# Content Security Policy
csp_parts = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"media-src 'self' blob: data:",
"connect-src 'self' https: wss:",
"frame-ancestors 'none'"
]
response.headers["Content-Security-Policy"] = "; ".join(csp_parts)
return response

View File

@ -0,0 +1,67 @@
"""Add session and public_stream tables
Revision ID: 0002
Revises: 0001
Create Date: 2023-04-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0002'
down_revision = '0001'
branch_labels = None
depends_on = None
def upgrade():
# Create public_stream table
op.create_table(
'public_stream',
sa.Column('uid', sa.String(), nullable=False, comment='User ID of the stream owner'),
sa.Column('filename', sa.String(), nullable=False, comment='Name of the audio file'),
sa.Column('size', sa.BigInteger(), nullable=False, comment='File size in bytes'),
sa.Column('mtime', sa.Float(), nullable=False, comment='Last modified time as Unix timestamp'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False, onupdate=sa.text('now()')),
sa.PrimaryKeyConstraint('uid', 'filename')
)
# Create session table
op.create_table(
'session',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.String(), nullable=False, index=True, comment='Reference to user.username'),
sa.Column('token', sa.Text(), nullable=False, index=True, comment='Random session token'),
sa.Column('ip_address', sa.String(45), nullable=False, comment='IP address of the client'),
sa.Column('user_agent', sa.Text(), nullable=True, comment='User-Agent header from the client'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False, comment='When the session expires'),
sa.Column('last_used_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False, onupdate=sa.text('now()')),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true'), nullable=False, comment='Whether the session is active'),
sa.ForeignKeyConstraint(['user_id'], ['user.username'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# Create indexes
op.create_index('ix_session_user_id', 'session', ['user_id'], unique=False)
op.create_index('ix_session_token', 'session', ['token'], unique=True)
op.create_index('ix_session_expires_at', 'session', ['expires_at'], unique=False)
op.create_index('ix_session_last_used_at', 'session', ['last_used_at'], unique=False)
op.create_index('ix_public_stream_uid', 'public_stream', ['uid'], unique=False)
op.create_index('ix_public_stream_updated_at', 'public_stream', ['updated_at'], unique=False)
def downgrade():
# Drop indexes first
op.drop_index('ix_session_user_id', table_name='session')
op.drop_index('ix_session_token', table_name='session')
op.drop_index('ix_session_expires_at', table_name='session')
op.drop_index('ix_session_last_used_at', table_name='session')
op.drop_index('ix_public_stream_uid', table_name='public_stream')
op.drop_index('ix_public_stream_updated_at', table_name='public_stream')
# Drop tables
op.drop_table('session')
op.drop_table('public_stream')

View File

@ -0,0 +1,24 @@
"""Add processed_filename to UploadLog
Revision ID: add_processed_filename_to_uploadlog
Revises:
Create Date: 2025-06-28 13:20:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_processed_filename_to_uploadlog'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Add the processed_filename column to the uploadlog table
op.add_column('uploadlog',
sa.Column('processed_filename', sa.String(), nullable=True))
def downgrade():
# Remove the processed_filename column if rolling back
op.drop_column('uploadlog', 'processed_filename')

View File

@ -8,7 +8,7 @@ from database import engine
class User(SQLModel, table=True):
token_created: datetime = Field(default_factory=datetime.utcnow)
email: str = Field(primary_key=True)
username: str
username: str = Field(unique=True, index=True)
token: str
confirmed: bool = False
ip: str = Field(default="")
@ -23,11 +23,23 @@ class UploadLog(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
uid: str
ip: str
filename: Optional[str]
filename: Optional[str] # Original filename
processed_filename: Optional[str] # Processed filename (UUID.opus)
size_bytes: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class DBSession(SQLModel, table=True):
token: str = Field(primary_key=True)
user_id: str = Field(foreign_key="user.username")
ip_address: str
user_agent: str
created_at: datetime = Field(default_factory=datetime.utcnow)
expires_at: datetime
is_active: bool = True
last_activity: datetime = Field(default_factory=datetime.utcnow)
def get_user_by_uid(uid: str) -> Optional[User]:
with Session(engine) as session:
statement = select(User).where(User.username == uid)

View File

@ -1 +1,3 @@
{"uid":"devuser","size":22455090,"mtime":1747563720}
{"uid":"oib9","size":2019706,"mtime":1751124547}
{"uid":"orangeicebear","size":1734396,"mtime":1748767975}

29
run_migrations.py Normal file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Run database migrations"""
import os
import sys
from alembic.config import Config
from alembic import command
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
def run_migrations():
# Get database URL from environment or use default
database_url = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost/dicta2stream"
)
# Set up Alembic config
alembic_cfg = Config()
alembic_cfg.set_main_option("script_location", "migrations")
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
# Run migrations
command.upgrade(alembic_cfg, "head")
print("Database migrations completed successfully.")
if __name__ == "__main__":
run_migrations()

View File

@ -1,5 +1,15 @@
// app.js — Frontend upload + minimal native player logic with slide-in and pulse effect
import { playBeep } from "./sound.js";
import { showToast } from "./toast.js";
// Global audio state
let globalAudio = null;
let currentStreamUid = null;
let audioPlaying = false;
let lastPosition = 0;
// Utility functions
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
@ -7,10 +17,6 @@ function getCookie(name) {
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();
@ -19,396 +25,476 @@ export function logToServer(msg) {
xhr.send(JSON.stringify({ msg }));
}
// Expose for debugging
window.logToServer = logToServer;
// Handle magic link login redirect
(function handleMagicLoginRedirect() {
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
localStorage.setItem('uid_time', Date.now().toString());
document.cookie = `uid=${encodeURIComponent(username)}; path=/`;
// Update UI state immediately without reload
const guestDashboard = document.getElementById('guest-dashboard');
const userDashboard = document.getElementById('user-dashboard');
const registerPage = document.getElementById('register-page');
if (guestDashboard) guestDashboard.style.display = 'none';
if (userDashboard) userDashboard.style.display = 'block';
if (registerPage) registerPage.style.display = 'none';
// Update URL and history without reloading
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() {
const params = new URLSearchParams(window.location.search);
const profileUid = params.get("profile");
if (profileUid) {
const mePage = document.getElementById("me-page");
if (mePage) {
document.querySelectorAll("main > section").forEach(sec => sec.hidden = sec.id !== "me-page");
// Hide upload/delete/copy-url controls for guest view
const uploadArea = document.getElementById("upload-area");
if (uploadArea) uploadArea.hidden = true;
const copyUrlBtn = document.getElementById("copy-url");
if (copyUrlBtn) copyUrlBtn.style.display = "none";
const deleteBtn = document.getElementById("delete-account");
if (deleteBtn) deleteBtn.style.display = "none";
// Update heading and description for guest view
const meHeading = document.querySelector("#me-page h2");
if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`;
const meDesc = document.querySelector("#me-page p");
if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`;
// 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!
}
// Navigate to user's profile page
if (window.showOnly) {
window.showOnly('me-page');
} else if (window.location.hash !== '#me') {
window.location.hash = '#me';
}
}
}
// --- Only run showProfilePlayerFromUrl after session/profile checks are complete ---
function runProfilePlayerIfSessionValid() {
if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return;
showProfilePlayerFromUrl();
}
document.addEventListener("DOMContentLoaded", () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
window.addEventListener('popstate', () => {
setTimeout(runProfilePlayerIfSessionValid, 200);
});
window.showProfilePlayerFromUrl = showProfilePlayerFromUrl;
// 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();
// Audio player functions
function getOrCreateAudioElement() {
if (!globalAudio) {
globalAudio = document.getElementById('me-audio');
if (!globalAudio) {
console.error('Audio element not found');
return null;
}
globalAudio.preload = 'metadata';
globalAudio.crossOrigin = 'use-credentials';
globalAudio.setAttribute('crossorigin', 'use-credentials');
// Set up event listeners
globalAudio.addEventListener('play', () => {
audioPlaying = true;
updatePlayPauseButton();
});
globalAudio.addEventListener('pause', () => {
audioPlaying = false;
updatePlayPauseButton();
}
};
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();
globalAudio.addEventListener('timeupdate', () => {
lastPosition = globalAudio.currentTime;
});
globalAudio.addEventListener('error', handleAudioError);
}
return globalAudio;
}
function handleAudioError(e) {
const error = this.error;
let errorMessage = 'Audio playback error';
let shouldShowToast = true;
if (error) {
switch(error.code) {
case MediaError.MEDIA_ERR_ABORTED:
errorMessage = 'Audio playback was aborted';
shouldShowToast = false; // Don't show toast for aborted operations
break;
case MediaError.MEDIA_ERR_NETWORK:
errorMessage = 'Network error while loading audio';
break;
case MediaError.MEDIA_ERR_DECODE:
errorMessage = 'Error decoding audio. The file may be corrupted.';
break;
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
// Don't show error for missing audio files on new accounts
if (this.currentSrc && this.currentSrc.includes('stream.opus')) {
console.log('Audio format not supported or file not found:', this.currentSrc);
return;
}
updatePlayPauseButton();
errorMessage = 'Audio format not supported. Please upload a supported format (Opus/OGG).';
break;
}
console.error('Audio error:', errorMessage, error);
// Only show error toast if we have a valid error and it's not a missing file
if (shouldShowToast && !(error.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED && !this.src)) {
showToast(errorMessage, 'error');
}
}
console.error('Audio error:', {
error: error,
src: this.currentSrc,
networkState: this.networkState,
readyState: this.readyState
});
if (errorMessage !== 'Audio format not supported') {
showToast(`${errorMessage}`, 'error');
}
}
function updatePlayPauseButton(audio, button) {
if (button && audio) {
button.textContent = audio.paused ? '▶️' : '⏸️';
}
}
// Stream loading and playback
async function loadProfileStream(uid) {
const audio = getOrCreateAudioElement();
if (!audio) {
console.error('Failed to initialize audio element');
return null;
}
// Hide playlist controls
const mePrevBtn = document.getElementById("me-prev");
const meNextBtn = document.getElementById("me-next");
if (mePrevBtn) mePrevBtn.style.display = "none";
if (meNextBtn) meNextBtn.style.display = "none";
// Reset current stream and update audio source
currentStreamUid = uid;
audio.pause();
audio.removeAttribute('src');
audio.load();
// Wait a moment to ensure the previous source is cleared
await new Promise(resolve => setTimeout(resolve, 50));
const username = localStorage.getItem('username') || uid;
const audioUrl = `/audio/${encodeURIComponent(username)}/stream.opus?t=${Date.now()}`;
try {
console.log('Checking audio file at:', audioUrl);
// First check if the audio file exists and get its content type
const response = await fetch(audioUrl, {
method: 'HEAD',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
}
// 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;
if (!response.ok) {
console.log('No audio file found for user:', username);
updatePlayPauseButton(audio, document.querySelector('.play-pause-btn'));
return null;
}
const contentType = response.headers.get('content-type');
console.log('Audio content type:', contentType);
if (!contentType || !contentType.includes('audio/')) {
throw new Error(`Invalid content type: ${contentType || 'unknown'}`);
}
// Set the audio source with proper type hint
const source = document.createElement('source');
source.src = audioUrl;
source.type = 'audio/ogg; codecs=opus';
// Clear any existing sources
while (audio.firstChild) {
audio.removeChild(audio.firstChild);
}
audio.appendChild(source);
// Load the new source
await new Promise((resolve, reject) => {
audio.load();
audio.oncanplaythrough = () => resolve(audio);
audio.oncanplaythrough = resolve;
audio.onerror = () => {
reject(new Error('Failed to load audio source'));
};
// Set a timeout in case the audio never loads
setTimeout(() => reject(new Error('Audio load timeout')), 10000);
});
}
// 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()}`;
console.log('Audio loaded, attempting to play...');
// Try to play immediately
try {
await audio.play();
audioPlaying = true;
console.log('Audio playback started successfully');
} catch (e) {
console.error('Play failed:', e);
console.log('Auto-play failed, waiting for user interaction:', e);
audioPlaying = false;
// Don't show error for autoplay restrictions
if (!e.message.includes('play() failed because the user')) {
showToast('Click the play button to start playback', 'info');
}
}
// Show stream info
// Show stream info if available
const streamInfo = document.getElementById("stream-info");
if (streamInfo) streamInfo.hidden = false;
// Update button state
updatePlayPauseButton();
return audio;
} catch (error) {
console.error('Error checking/loading audio:', error);
// Don't show error toasts for missing audio files or aborted requests
if (error.name !== 'AbortError' &&
!error.message.includes('404') &&
!error.message.includes('Failed to load')) {
showToast('Error loading audio: ' + (error.message || 'Unknown error'), 'error');
}
return null;
}
// 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";
// 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;
// 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({}, '', '/');
}
// 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>';
}
}
});
}
// 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();
updatePlayPauseButton(audio, document.querySelector('.play-pause-btn'));
return audio;
}
// Export the function for use in other modules
window.loadProfileStream = loadProfileStream;
document.addEventListener("DOMContentLoaded", () => {
// Initialize play/pause button
const playPauseButton = document.getElementById('play-pause');
if (playPauseButton) {
// Set initial state
audioPlaying = false;
updatePlayPauseButton();
// Navigation and UI functions
function showProfilePlayerFromUrl() {
const params = new URLSearchParams(window.location.search);
const profileUid = params.get("profile");
if (profileUid) {
const mePage = document.getElementById("me-page");
if (!mePage) return;
// Add event listener
playPauseButton.addEventListener('click', () => {
const audio = getMainAudio();
if (audio) {
document.querySelectorAll("main > section").forEach(sec =>
sec.hidden = sec.id !== "me-page"
);
// Hide upload/delete/copy-url controls for guest view
const uploadArea = document.getElementById("upload-area");
if (uploadArea) uploadArea.hidden = true;
const copyUrlBtn = document.getElementById("copy-url");
if (copyUrlBtn) copyUrlBtn.style.display = "none";
const deleteBtn = document.getElementById("delete-account");
if (deleteBtn) deleteBtn.style.display = "none";
// Update UI for guest view
const meHeading = document.querySelector("#me-page h2");
if (meHeading) meHeading.textContent = `${profileUid}'s Stream 🎙️`;
const meDesc = document.querySelector("#me-page p");
if (meDesc) meDesc.textContent = `This is ${profileUid}'s public stream.`;
// 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;
}
}
}
function initNavigation() {
const navLinks = document.querySelectorAll('nav a');
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
const href = link.getAttribute('href');
// Skip if href is empty or doesn't start with '#'
if (!href || !href.startsWith('#')) {
return; // Let the browser handle the link normally
}
const sectionId = href.substring(1); // Remove the '#'
// Skip if sectionId is empty after removing '#'
if (!sectionId) {
console.warn('Empty section ID in navigation link:', link);
return;
}
const section = document.getElementById(sectionId);
if (section) {
e.preventDefault();
// Hide all sections first
document.querySelectorAll('main > section').forEach(sec => {
sec.hidden = sec.id !== sectionId;
});
// Special handling for me-page
if (sectionId === 'me-page') {
const registerPage = document.getElementById('register-page');
if (registerPage) registerPage.hidden = true;
// Show the upload box in me-page
const uploadBox = document.querySelector('#me-page #user-upload-area');
if (uploadBox) uploadBox.style.display = 'block';
} else if (sectionId === 'register-page') {
// Ensure me-page is hidden when register-page is shown
const mePage = document.getElementById('me-page');
if (mePage) mePage.hidden = true;
}
section.scrollIntoView({ behavior: "smooth" });
// Close mobile menu if open
const burger = document.getElementById('burger-toggle');
if (burger && burger.checked) burger.checked = false;
}
});
});
}
function initProfilePlayer() {
if (typeof checkSessionValidity === "function" && !checkSessionValidity()) return;
showProfilePlayerFromUrl();
}
// Initialize the application when DOM is loaded
document.addEventListener("DOMContentLoaded", () => {
// Handle magic link redirect if needed
handleMagicLoginRedirect();
// Initialize components
initNavigation();
// Initialize profile player after a short delay
setTimeout(() => {
initProfilePlayer();
// Set up play/pause button click handler
document.addEventListener('click', (e) => {
const playPauseBtn = e.target.closest('.play-pause-btn');
if (!playPauseBtn || playPauseBtn.id === 'logout-button') return;
const audio = getOrCreateAudioElement();
if (!audio) return;
try {
if (audio.paused) {
audio.play();
// Stop any currently playing audio first
if (window.currentlyPlayingAudio && window.currentlyPlayingAudio !== audio) {
window.currentlyPlayingAudio.pause();
if (window.currentlyPlayingButton) {
updatePlayPauseButton(window.currentlyPlayingAudio, window.currentlyPlayingButton);
}
}
// Stop any playing public streams
const publicPlayers = document.querySelectorAll('.stream-player audio');
publicPlayers.forEach(player => {
if (!player.paused) {
player.pause();
const btn = player.closest('.stream-player').querySelector('.play-pause-btn');
if (btn) updatePlayPauseButton(player, btn);
}
});
// Check if audio has a valid source before attempting to play
// Only show this message for the main player, not public streams
if (!audio.src && !playPauseBtn.closest('.stream-player')) {
console.log('No audio source available for main player');
showToast('No audio file available. Please upload an audio file first.', 'info');
audioPlaying = false;
updatePlayPauseButton(audio, playPauseBtn);
return;
}
// Store the current play promise to handle aborts
const playPromise = audio.play();
// Handle successful play
playPromise.then(() => {
// Only update state if this is still the current play action
if (audio === getMainAudio()) {
window.currentlyPlayingAudio = audio;
window.currentlyPlayingButton = playPauseBtn;
updatePlayPauseButton(audio, playPauseBtn);
}
}).catch(e => {
// Don't log aborted errors as they're normal during rapid play/pause
if (e.name !== 'AbortError') {
console.error('Play failed:', e);
} else {
console.log('Playback was aborted as expected');
return; // Skip UI updates for aborted play
}
// Only update state if this is still the current audio element
if (audio === getMainAudio()) {
audioPlaying = false;
updatePlayPauseButton(audio, playPauseBtn);
// Provide more specific error messages
if (e.name === 'NotSupportedError' || e.name === 'NotAllowedError') {
showToast('Could not play audio. The format may not be supported.', 'error');
} else if (e.name !== 'AbortError') { // Skip toast for aborted errors
showToast('Failed to play audio. Please try again.', 'error');
}
}
});
} else {
audio.pause();
if (window.currentlyPlayingAudio === audio) {
window.currentlyPlayingAudio = null;
window.currentlyPlayingButton = null;
}
updatePlayPauseButton(audio, playPauseBtn);
}
updatePlayPauseButton();
} catch (e) {
console.error('Audio error:', e);
updatePlayPauseButton(audio, playPauseBtn);
}
});
}
// 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) {
// Set up delete account button if it exists
const deleteAccountBtn = document.getElementById('delete-account');
if (deleteAccountBtn) {
deleteAccountBtn.addEventListener('click', async (e) => {
e.preventDefault();
showToast('❌ Bot detected! Please try again.');
return false;
}
return true;
});
if (!confirm('Are you sure you want to delete your account? This action cannot be undone.')) {
return;
}
try {
const response = await fetch('/api/delete-account', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
// Clear local storage and redirect to home page
localStorage.clear();
window.location.href = '/';
} else {
const error = await response.json();
throw new Error(error.detail || 'Failed to delete account');
}
} catch (error) {
console.error('Error deleting account:', error);
showToast(`${error.message || 'Failed to delete account'}`, 'error');
}
});
}
}, 200); // End of setTimeout
});
// Expose functions for global access
window.logToServer = logToServer;
window.getMainAudio = () => globalAudio;
window.stopMainAudio = () => {
if (globalAudio) {
globalAudio.pause();
audioPlaying = false;
updatePlayPauseButton();
}
// 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);
});
});
// 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);
});
});
};
window.loadProfileStream = loadProfileStream;

208
static/css/base.css Normal file
View File

@ -0,0 +1,208 @@
/* Base styles and resets */
:root {
/* Colors */
--color-primary: #4a90e2;
--color-primary-dark: #2a6fc9;
--color-text: #333;
--color-text-light: #666;
--color-bg: #f8f9fa;
--color-border: #e9ecef;
--color-white: #fff;
--color-black: #000;
/* Typography */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
--font-size-base: 1rem;
--line-height-base: 1.5;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* Border radius */
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
/* Transitions */
--transition-base: all 0.2s ease;
--transition-slow: all 0.3s ease;
}
/* Reset and base styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
height: 100%;
font-size: 16px;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
margin: 0;
min-height: 100%;
font-family: var(--font-family);
line-height: var(--line-height-base);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Main content */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 6rem 1.5rem 2rem; /* Add top padding to account for fixed header */
min-height: calc(100vh - 200px); /* Ensure footer stays at bottom */
}
/* Sections */
section {
margin: 2rem 0;
padding: 2rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
section h2 {
color: var(--color-primary);
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 2rem;
}
section p {
color: var(--color-text);
line-height: 1.6;
margin-bottom: 1.5rem;
}
.main-heading {
font-size: 2.5rem;
margin: 0 0 2rem 0;
color: var(--color-text);
font-weight: 700;
line-height: 1.2;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
text-align: center;
}
.main-heading .mic-icon {
display: inline-flex;
animation: pulse 2s infinite;
transform-origin: center;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: var(--spacing-md);
font-weight: 600;
line-height: 1.2;
}
p {
margin-top: 0;
margin-bottom: var(--spacing-md);
}
a {
color: var(--color-primary);
text-decoration: none;
transition: var(--transition-base);
}
a:hover {
color: var(--color-primary-dark);
text-decoration: underline;
}
/* Images */
img {
max-width: 100%;
height: auto;
vertical-align: middle;
border-style: none;
}
/* Lists */
ul, ol {
padding-left: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
/* Loading animation */
.app-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background: var(--color-white);
z-index: 9999;
transition: opacity var(--transition-slow);
text-align: center;
padding: 2rem;
color: var(--color-text);
}
.app-loading > div:first-child {
margin-bottom: 1rem;
font-size: 2rem;
}
.app-loading.hidden {
opacity: 0;
pointer-events: none;
}
.app-content {
opacity: 1;
transition: opacity var(--transition-slow);
}
/* This class can be used for initial fade-in if needed */
.app-content.initial-load {
opacity: 0;
}
.app-content.loaded {
opacity: 1;
}
/* Utility classes */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

View File

View File

@ -0,0 +1,80 @@
/* Footer styles */
footer {
background: #2c3e50;
color: #ecf0f1;
padding: 2rem 0;
margin-top: 3rem;
width: 100%;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.footer-links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.footer-links a {
color: #ecf0f1;
text-decoration: none;
transition: color 0.2s;
}
.footer-links a:hover,
.footer-links a:focus {
color: #3498db;
text-decoration: underline;
}
.separator {
color: #7f8c8d;
margin: 0 0.25rem;
}
.footer-hint {
margin-top: 1rem;
font-size: 0.9rem;
color: #bdc3c7;
}
.footer-hint a {
color: #3498db;
text-decoration: none;
}
.footer-hint a:hover,
.footer-hint a:focus {
text-decoration: underline;
}
/* Responsive adjustments */
@media (max-width: 767px) {
footer {
padding: 1.5rem 1rem;
}
.footer-links {
flex-direction: column;
gap: 0.5rem;
}
.separator {
display: none;
}
.footer-hint {
font-size: 0.85rem;
line-height: 1.5;
}
}

View File

@ -0,0 +1,149 @@
/* Header and navigation styles */
header {
width: 100%;
background: rgba(33, 37, 41, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: fixed;
top: 0;
left: 0;
z-index: 1000;
padding: 0.5rem 0;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
/* Logo */
.logo {
color: white;
font-size: 1.5rem;
font-weight: bold;
text-decoration: none;
padding: 0.5rem 0;
}
.logo:hover {
text-decoration: none;
opacity: 0.9;
}
/* Navigation */
.nav-wrapper {
display: flex;
align-items: center;
height: 100%;
}
/* Menu toggle button */
.menu-toggle {
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
display: none; /* Hidden by default, shown on mobile */
}
/* Navigation list */
.nav-list {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 1rem;
align-items: center;
}
.nav-item {
margin: 0;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: background-color 0.2s, color 0.2s;
display: block;
}
.nav-link:hover,
.nav-link:focus {
background: rgba(255, 255, 255, 0.1);
text-decoration: none;
color: #fff;
}
/* Active navigation item */
.nav-link.active {
background: rgba(255, 255, 255, 0.2);
font-weight: 500;
}
/* Mobile menu */
@media (max-width: 767px) {
.menu-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: transparent;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
z-index: 1001;
}
.nav-wrapper {
position: fixed;
top: 0;
right: -100%;
width: 80%;
max-width: 300px;
height: 100vh;
background: rgba(33, 37, 41, 0.98);
padding: 5rem 1.5rem 2rem;
transition: right 0.3s ease-in-out;
z-index: 1000;
overflow-y: auto;
display: block;
}
.nav-wrapper.active {
right: 0;
}
.nav-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0;
}
.nav-item {
width: 100%;
}
.nav-link {
display: block;
padding: 0.75rem 1rem;
border-radius: 4px;
}
.nav-link:hover,
.nav-link:focus {
background: rgba(255, 255, 255, 0.15);
}
}

View File

View File

View File

View File

View File

View File

@ -8,44 +8,250 @@ function getCookie(name) {
}
// dashboard.js — toggle guest vs. user dashboard and reposition streams link
// Logout function
let isLoggingOut = false;
async function handleLogout(event) {
// Prevent multiple simultaneous logout attempts
if (isLoggingOut) return;
isLoggingOut = true;
// Prevent default button behavior
if (event) {
event.preventDefault();
event.stopPropagation();
}
try {
console.log('[LOGOUT] Starting logout process');
// Clear user data from localStorage
localStorage.removeItem('uid');
localStorage.removeItem('uid_time');
localStorage.removeItem('confirmed_uid');
localStorage.removeItem('last_page');
// Clear cookie
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
// Update UI state immediately
const userDashboard = document.getElementById('user-dashboard');
const guestDashboard = document.getElementById('guest-dashboard');
const logoutButton = document.getElementById('logout-button');
const deleteAccountButton = document.getElementById('delete-account-button');
if (userDashboard) userDashboard.style.display = 'none';
if (guestDashboard) guestDashboard.style.display = 'block';
if (logoutButton) logoutButton.style.display = 'none';
if (deleteAccountButton) deleteAccountButton.style.display = 'none';
// Show success message (only once)
if (window.showToast) {
showToast('Logged out successfully');
} else {
console.log('Logged out successfully');
}
// Navigate to register page
if (window.showOnly) {
window.showOnly('register-page');
} else {
// Fallback to URL change if showOnly isn't available
window.location.href = '/#register-page';
}
console.log('[LOGOUT] Logout completed');
} catch (error) {
console.error('[LOGOUT] Logout failed:', error);
if (window.showToast) {
showToast('Logout failed. Please try again.');
}
} finally {
isLoggingOut = false;
}
}
// Delete account function
async function handleDeleteAccount() {
try {
const uid = localStorage.getItem('uid');
if (!uid) {
showToast('No user session found. Please log in again.');
return;
}
// Show confirmation dialog
const confirmed = confirm('⚠️ WARNING: This will permanently delete your account and all your data. This action cannot be undone.\n\nAre you sure you want to delete your account?');
if (!confirmed) {
return; // User cancelled the deletion
}
// Show loading state
const deleteButton = document.getElementById('delete-account-button');
const originalText = deleteButton.textContent;
deleteButton.disabled = true;
deleteButton.textContent = 'Deleting...';
// Call the delete account endpoint
const response = await fetch(`/api/delete-account`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ uid }),
});
const result = await response.json();
if (response.ok) {
showToast('Account deleted successfully');
// Clear user data
localStorage.removeItem('uid');
localStorage.removeItem('uid_time');
localStorage.removeItem('confirmed_uid');
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
// Redirect to home page
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
throw new Error(result.detail || 'Failed to delete account');
}
} catch (error) {
console.error('Delete account failed:', error);
showToast(`Failed to delete account: ${error.message}`);
// Reset button state
const deleteButton = document.getElementById('delete-account-button');
if (deleteButton) {
deleteButton.disabled = false;
deleteButton.textContent = '🗑️ Delete Account';
}
}
}
async function initDashboard() {
// New dashboard toggling logic
console.log('[DASHBOARD] Initializing dashboard...');
// Get all dashboard elements
const guestDashboard = document.getElementById('guest-dashboard');
const userDashboard = document.getElementById('user-dashboard');
const userUpload = document.getElementById('user-upload-area');
// Hide all by default
if (guestDashboard) guestDashboard.style.display = 'none';
if (userDashboard) userDashboard.style.display = 'none';
if (userUpload) userUpload.style.display = 'none';
const logoutButton = document.getElementById('logout-button');
const deleteAccountButton = document.getElementById('delete-account-button');
console.log('[DASHBOARD] Elements found:', {
guestDashboard: !!guestDashboard,
userDashboard: !!userDashboard,
userUpload: !!userUpload,
logoutButton: !!logoutButton,
deleteAccountButton: !!deleteAccountButton
});
// Add click event listeners for logout and delete account buttons
if (logoutButton) {
console.log('[DASHBOARD] Adding logout button handler');
logoutButton.addEventListener('click', handleLogout);
}
if (deleteAccountButton) {
console.log('[DASHBOARD] Adding delete account button handler');
deleteAccountButton.addEventListener('click', (e) => {
e.preventDefault();
handleDeleteAccount();
});
}
const uid = getCookie('uid');
console.log('[DASHBOARD] UID from cookie:', uid);
// Guest view
if (!uid) {
// Guest view: only nav
if (guestDashboard) guestDashboard.style.display = '';
if (userDashboard) userDashboard.style.display = 'none';
if (userUpload) userUpload.style.display = 'none';
console.log('[DASHBOARD] No UID found, showing guest dashboard');
if (guestDashboard) guestDashboard.style.display = 'block';
if (userDashboard) userDashboard.style.display = 'none';
if (userUpload) userUpload.style.display = 'none';
if (logoutButton) logoutButton.style.display = 'none';
if (deleteAccountButton) deleteAccountButton.style.display = 'none';
const mePage = document.getElementById('me-page');
if (mePage) mePage.style.display = 'none';
return;
}
// Logged-in view - show user dashboard by default
console.log('[DASHBOARD] User is logged in, showing user dashboard');
// Log current display states
console.log('[DASHBOARD] Current display states:', {
guestDashboard: guestDashboard ? window.getComputedStyle(guestDashboard).display : 'not found',
userDashboard: userDashboard ? window.getComputedStyle(userDashboard).display : 'not found',
userUpload: userUpload ? window.getComputedStyle(userUpload).display : 'not found',
logoutButton: logoutButton ? window.getComputedStyle(logoutButton).display : 'not found',
deleteAccountButton: deleteAccountButton ? window.getComputedStyle(deleteAccountButton).display : 'not found'
});
// Show delete account button for logged-in users
if (deleteAccountButton) {
deleteAccountButton.style.display = 'block';
console.log('[DASHBOARD] Showing delete account button');
}
// Hide guest dashboard
if (guestDashboard) {
console.log('[DASHBOARD] Hiding guest dashboard');
guestDashboard.style.display = 'none';
}
// Show user dashboard
if (userDashboard) {
console.log('[DASHBOARD] Showing user dashboard');
userDashboard.style.display = 'block';
userDashboard.style.visibility = 'visible';
userDashboard.hidden = false;
// Debug: Check if the element is actually in the DOM
console.log('[DASHBOARD] User dashboard parent:', userDashboard.parentElement);
console.log('[DASHBOARD] User dashboard computed display:', window.getComputedStyle(userDashboard).display);
} else {
console.error('[DASHBOARD] userDashboard element not found!');
}
// Show essential elements for logged-in users
const linksSection = document.getElementById('links');
if (linksSection) {
console.log('[DASHBOARD] Showing links section');
linksSection.style.display = 'block';
}
const showMeLink = document.getElementById('show-me');
if (showMeLink && showMeLink.parentElement) {
console.log('[DASHBOARD] Showing show-me link');
showMeLink.parentElement.style.display = 'block';
}
// Show me-page for logged-in users
const mePage = document.getElementById('me-page');
if (mePage) mePage.style.display = 'none';
return;
}
if (mePage) {
console.log('[DASHBOARD] Showing me-page');
mePage.style.display = 'block';
}
try {
console.log(`[DEBUG] Fetching user data for UID: ${uid}`);
const res = await fetch(`/me/${uid}`);
if (!res.ok) throw new Error('Not authorized');
if (!res.ok) {
const errorText = await res.text();
console.error(`[ERROR] Failed to fetch user data: ${res.status} ${res.statusText}`, errorText);
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
// Logged-in view
// 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 = '';
console.log('[DEBUG] User data loaded:', data);
// 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
}
@ -53,19 +259,40 @@ async function initDashboard() {
// Remove guest warning if present
const guestMsg = document.getElementById('guest-warning-msg');
if (guestMsg && guestMsg.parentNode) guestMsg.parentNode.removeChild(guestMsg);
userDashboard.style.display = '';
// Show user dashboard and logout button
if (userDashboard) userDashboard.style.display = '';
if (logoutButton) {
logoutButton.style.display = 'block';
logoutButton.onclick = handleLogout;
}
// Set audio source
const meAudio = document.getElementById('me-audio');
if (meAudio && uid) {
meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus`;
if (meAudio && data && data.username) {
// Use username instead of UID for the audio file path
meAudio.src = `/audio/${encodeURIComponent(data.username)}/stream.opus?t=${Date.now()}`;
console.log('Setting audio source to:', meAudio.src);
} else if (meAudio && uid) {
// Fallback to UID if username is not available
meAudio.src = `/audio/${encodeURIComponent(uid)}/stream.opus?t=${Date.now()}`;
console.warn('Using UID fallback for audio source:', meAudio.src);
}
// Update quota
// Update quota and ensure quota meter is visible
const quotaMeter = document.getElementById('quota-meter');
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`;
if (quotaMeter) {
quotaMeter.hidden = false;
quotaMeter.style.display = 'block'; // Ensure it's not hidden by display:none
}
// Fetch and display the list of uploaded files if the function is available
if (window.fetchAndDisplayFiles) {
window.fetchAndDisplayFiles(uid);
}
// Ensure Streams link remains in nav, not moved
// (No action needed if static)

97
static/desktop.css Normal file
View File

@ -0,0 +1,97 @@
/* Desktop-specific styles for screens 960px and wider */
@media (min-width: 960px) {
html {
background-color: #111 !important;
background-image:
repeating-linear-gradient(
45deg,
rgba(188, 183, 107, 0.1) 0, /* Olive color */
rgba(188, 183, 107, 0.1) 1px,
transparent 1px,
transparent 20px
),
repeating-linear-gradient(
-45deg,
rgba(188, 183, 107, 0.1) 0, /* Olive color */
rgba(188, 183, 107, 0.1) 1px,
transparent 1px,
transparent 20px
) !important;
background-size: 40px 40px !important;
background-repeat: repeat !important;
background-attachment: fixed !important;
min-height: 100% !important;
}
body {
background: transparent !important;
min-height: 100vh !important;
}
/* Section styles are now handled in style.css */
nav.dashboard-nav a {
padding: 5px;
margin: 0 0.5em;
border-radius: 3px;
}
/* Reset mobile-specific styles for desktop */
.dashboard-nav {
padding: 0.5em;
max-width: none;
justify-content: center;
}
.dashboard-nav a {
min-width: auto;
font-size: 1rem;
}
/* Global article styles */
main > section > article,
#stream-page > article {
max-width: 600px;
margin: 0 auto 2em auto;
padding: 2em;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 8px;
transition: all 0.2s ease;
}
/* Stream player styles */
#stream-page #stream-list > li {
list-style: none;
margin-bottom: 1.5em;
}
#stream-page #stream-list > li .stream-player {
padding: 1.5em;
background: #1e1e1e;
border: none;
border-radius: 8px;
transition: all 0.2s ease;
}
/* Hover states - only apply to direct article children of sections */
main > section > article:hover {
transform: translateY(-2px);
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
border: 1px solid #ff6600;
}
/* Stream list desktop styles */
#stream-list {
max-width: 600px;
margin: 0 auto;
padding: 0 1rem;
}
/* User upload area desktop styles */
#user-upload-area {
max-width: 600px !important;
width: 100% !important;
margin: 1.5rem auto !important;
box-sizing: border-box !important;
}
}

12
static/footer.html Normal file
View File

@ -0,0 +1,12 @@
<!-- Footer content -->
<footer>
<p>Built for public voice streaming • Opus | Mono | 48kHz | 60kbps</p>
<p class="footer-hint">Need more space? Contact <a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
<div class="footer-links">
<a href="#" data-target="terms-page">Terms</a>
<span class="separator"></span>
<a href="#" data-target="privacy-page">Privacy</a>
<span class="separator"></span>
<a href="#" data-target="imprint-page">Imprint</a>
</div>
</footer>

13
static/generate-test-audio.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
# Create a 1-second silent audio file in Opus format
ffmpeg -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a libopus -b:a 60k /home/oib/games/dicta2stream/static/test-audio.opus
# Verify the file was created
if [ -f "/home/oib/games/dicta2stream/static/test-audio.opus" ]; then
echo "Test audio file created successfully at /home/oib/games/dicta2stream/static/test-audio.opus"
echo "File size: $(du -h /home/oib/games/dicta2stream/static/test-audio.opus | cut -f1)"
else
echo "Failed to create test audio file"
exit 1
fi

View File

@ -3,6 +3,8 @@
<html lang="en">
<head>
<link rel="stylesheet" href="/static/style.css" media="all" />
<link rel="stylesheet" href="/static/desktop.css" media="(min-width: 960px)">
<link rel="stylesheet" href="/static/mobile.css" media="(max-width: 959px)">
<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" />
@ -32,24 +34,32 @@
<!-- 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>
<a href="#welcome-page" id="guest-welcome">Welcome</a>
<a href="#stream-page" id="guest-streams">Streams</a>
<a href="#register-page" id="guest-login">Account</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>
<a href="#welcome-page" id="user-welcome">Welcome</a>
<a href="#stream-page" id="user-streams">Streams</a>
<a href="#me-page" id="show-me">Your Stream</a>
</nav>
<section id="me-page">
<div style="position: relative; margin: 0 0 1.5rem 0; text-align: center;">
<h2 style="margin: 0; padding: 0; line-height: 1; display: inline-block; position: relative; text-align: center;">
Your Stream
</h2>
<div style="position: absolute; right: 0; top: 50%; transform: translateY(-50%); display: flex; gap: 0.5rem;">
<button id="delete-account-button" class="delete-account-btn" style="font-size: 1rem; padding: 0.4em 0.8em; white-space: nowrap; display: none; background-color: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer;">🗑️ Delete Account</button>
<button id="logout-button" class="logout-btn" style="font-size: 1rem; padding: 0.4em 0.8em; white-space: nowrap; display: none;">🚪 LogOut</button>
</div>
</div>
<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>
<button class="play-pause-btn" type="button" aria-label="Play">▶️</button>
</div>
</article>
<section id="user-upload-area" class="dropzone">
@ -65,9 +75,9 @@
<!-- Burger menu and legacy links section removed for clarity -->
<section id="terms-page" hidden>
<h2>Terms of Service</h2>
<article>
<h2>Terms of Service</h2>
<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>
<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>Each account must be unique and used by only one person.</li>
@ -77,13 +87,12 @@
<li>Uploads are limited to 100 MB and must be voice only.</li>
<li>Music/singing will be rejected.</li>
</ul>
</article>
</section>
<section id="privacy-page" hidden>
<h2>Privacy Policy</h2>
<article>
<h2>Privacy Policy</h2>
<ul>
<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>
@ -91,22 +100,20 @@
<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>
</article>
</section>
<section id="imprint-page" hidden>
<h2>Imprint</h2>
<article>
<h2>Imprint</h2>
<p><strong>Andreas Michael Fleckl</strong></p>
<p>Johnstrassse 7/6<br>1140 Vienna<br>Austria / Europe</p>
</article>
</section>
<section id="welcome-page">
<h2>Welcome</h2>
<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>
@ -119,16 +126,14 @@
</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>
</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>
</section>
<section id="register-page" hidden>
<h2>Account</h2>
<article>
<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>
@ -137,7 +142,7 @@
<input type="text" name="bot_trap" autocomplete="off" />
</label>
</p>
<p><button type="submit">Create Account</button></p>
<p><button type="submit">Login / Create Account</button></p>
</form>
<p><small>Youll receive a magic login link via email. No password required.</small></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>
@ -147,6 +152,12 @@
<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>
<div id="uploaded-files" style="margin-top: 10px; font-size: 0.9em;">
<div style="font-weight: bold; margin-bottom: 5px;">Uploaded Files:</div>
<div id="file-list" style="max-height: 200px; overflow-y: auto; border: 1px solid #333; padding: 5px; border-radius: 4px; background: #1a1a1a;">
<div style="padding: 5px 0; color: #888; font-style: italic;">Loading files...</div>
</div>
</div>
</section>
@ -157,8 +168,8 @@
<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-terms" data-target="terms-page">Terms</a> |
<a href="#" id="footer-privacy" data-target="privacy-page">Privacy</a> |
<a href="#" id="footer-imprint" data-target="imprint-page">Imprint</a>
</p>
</footer>

437
static/inject-nav.js Normal file
View File

@ -0,0 +1,437 @@
// inject-nav.js - Handles dynamic injection and management of navigation elements
import { showOnly } from './router.js';
// Menu state
let isMenuOpen = false;
// Export the injectNavigation function
export function injectNavigation(isAuthenticated = false) {
console.log('injectNavigation called with isAuthenticated:', isAuthenticated);
const navContainer = document.getElementById('main-navigation');
if (!navContainer) {
console.error('Navigation container not found. Looking for #main-navigation');
console.log('Available elements with id:', document.querySelectorAll('[id]'));
return;
}
// Clear existing content
navContainer.innerHTML = '';
console.log('Creating navigation...');
try {
// Create the navigation wrapper
const navWrapper = document.createElement('nav');
navWrapper.className = 'nav-wrapper';
// Create the navigation content
const nav = isAuthenticated ? createUserNav() : createGuestNav();
console.log('Navigation HTML created:', nav.outerHTML);
// Append navigation to wrapper
navWrapper.appendChild(nav);
// Append to container
navContainer.appendChild(navWrapper);
console.log('Navigation appended to container');
// Initialize menu toggle after navigation is injected
setupMenuToggle();
// Set up menu links
setupMenuLinks();
// Add click handler for the logo to navigate home
const logo = document.querySelector('.logo');
if (logo) {
logo.addEventListener('click', (e) => {
e.preventDefault();
showOnly('welcome');
closeMenu();
});
}
} catch (error) {
console.error('Error creating navigation:', error);
return;
}
// Set up menu toggle for mobile
setupMenuToggle();
// Set up menu links
setupMenuLinks();
// Close menu when clicking on a nav link on mobile
const navLinks = navContainer.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.addEventListener('click', () => {
if (window.innerWidth < 768) { // Mobile breakpoint
closeMenu();
}
});
});
// Add click handler for the logo to navigate home
const logo = document.querySelector('.logo');
if (logo) {
logo.addEventListener('click', (e) => {
e.preventDefault();
showOnly('welcome');
});
}
}
// Function to create the guest navigation
function createGuestNav() {
const nav = document.createElement('div');
nav.className = 'dashboard-nav';
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'Main navigation');
const navList = document.createElement('ul');
navList.className = 'nav-list';
const links = [
{ id: 'nav-login', target: 'login', text: 'Login / Register' },
{ id: 'nav-streams', target: 'streams', text: 'Streams' },
{ id: 'nav-welcome', target: 'welcome', text: 'Welcome' }
];
// Create and append links
links.forEach((link) => {
const li = document.createElement('li');
li.className = 'nav-item';
const a = document.createElement('a');
a.id = link.id;
a.href = `#${link.target}`;
a.className = 'nav-link';
a.setAttribute('data-target', link.target);
a.textContent = link.text;
// Add click handler for navigation
a.addEventListener('click', (e) => {
e.preventDefault();
const target = e.currentTarget.getAttribute('data-target');
if (target) {
window.location.hash = target;
if (window.router && typeof window.router.showOnly === 'function') {
window.router.showOnly(target);
}
// Close menu on mobile after clicking a link
if (window.innerWidth < 768) {
closeMenu();
}
}
});
li.appendChild(a);
navList.appendChild(li);
});
nav.appendChild(navList);
return nav;
}
function createUserNav() {
const nav = document.createElement('div');
nav.className = 'dashboard-nav';
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'User navigation');
const navList = document.createElement('ul');
navList.className = 'nav-list';
const links = [
{ id: 'user-stream', target: 'your-stream', text: 'Your Stream' },
{ id: 'nav-streams', target: 'streams', text: 'Streams' },
{ id: 'nav-welcome', target: 'welcome', text: 'Welcome' },
{ id: 'user-logout', target: 'logout', text: 'Logout' }
];
// Create and append links
links.forEach((link, index) => {
const li = document.createElement('li');
li.className = 'nav-item';
const a = document.createElement('a');
a.id = link.id;
a.href = '#';
a.className = 'nav-link';
// Special handling for logout
if (link.target === 'logout') {
a.href = '#';
a.addEventListener('click', async (e) => {
e.preventDefault();
closeMenu();
// Use the handleLogout function from dashboard.js if available
if (typeof handleLogout === 'function') {
await handleLogout();
} else {
// Fallback in case handleLogout is not available
localStorage.removeItem('user');
localStorage.removeItem('uid');
localStorage.removeItem('uid_time');
localStorage.removeItem('confirmed_uid');
document.cookie = 'uid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
window.location.href = '/';
}
window.location.href = '#';
// Force reload to reset the app state
window.location.reload();
});
} else {
a.setAttribute('data-target', link.target);
}
a.textContent = link.text;
li.appendChild(a);
navList.appendChild(li);
});
nav.appendChild(navList);
return nav;
}
// Set up menu toggle functionality
function setupMenuToggle() {
const menuToggle = document.querySelector('.menu-toggle');
const navWrapper = document.querySelector('.nav-wrapper');
if (!menuToggle || !navWrapper) return;
menuToggle.addEventListener('click', toggleMenu);
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (isMenuOpen && !navWrapper.contains(e.target) && !menuToggle.contains(e.target)) {
closeMenu();
}
});
// Close menu on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isMenuOpen) {
closeMenu();
}
});
// Close menu when resizing to desktop
let resizeTimer;
window.addEventListener('resize', () => {
if (window.innerWidth >= 768) {
closeMenu();
}
});
}
// Toggle mobile menu
function toggleMenu(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const navWrapper = document.querySelector('.nav-wrapper');
const menuToggle = document.querySelector('.menu-toggle');
if (!navWrapper || !menuToggle) return;
isMenuOpen = !isMenuOpen;
if (isMenuOpen) {
// Open menu
navWrapper.classList.add('active');
menuToggle.setAttribute('aria-expanded', 'true');
menuToggle.innerHTML = '✕';
document.body.style.overflow = 'hidden';
// Focus the first link in the menu for better keyboard navigation
const firstLink = navWrapper.querySelector('a');
if (firstLink) firstLink.focus();
// Add click outside handler
document._handleClickOutside = (e) => {
if (!navWrapper.contains(e.target) && e.target !== menuToggle) {
closeMenu();
}
};
document.addEventListener('click', document._handleClickOutside);
// Add escape key handler
document._handleEscape = (e) => {
if (e.key === 'Escape') {
closeMenu();
}
};
document.addEventListener('keydown', document._handleEscape);
} else {
closeMenu();
}
}
// Close menu function
function closeMenu() {
const navWrapper = document.querySelector('.nav-wrapper');
const menuToggle = document.querySelector('.menu-toggle');
if (!navWrapper || !menuToggle) return;
isMenuOpen = false;
navWrapper.classList.remove('active');
menuToggle.setAttribute('aria-expanded', 'false');
menuToggle.innerHTML = '☰';
document.body.style.overflow = '';
// Remove event listeners
if (document._handleClickOutside) {
document.removeEventListener('click', document._handleClickOutside);
delete document._handleClickOutside;
}
if (document._handleEscape) {
document.removeEventListener('keydown', document._handleEscape);
delete document._handleEscape;
}
}
// Initialize menu toggle on page load
function initializeMenuToggle() {
console.log('Initializing menu toggle...');
const menuToggle = document.getElementById('menu-toggle');
if (!menuToggle) {
console.error('Main menu toggle button not found!');
return;
}
console.log('Menu toggle button found:', menuToggle);
// Remove any existing click listeners
const newToggle = menuToggle.cloneNode(true);
if (menuToggle.parentNode) {
menuToggle.parentNode.replaceChild(newToggle, menuToggle);
console.log('Replaced menu toggle button');
} else {
console.error('Menu toggle has no parent node!');
return;
}
// Add click handler to the new toggle
newToggle.addEventListener('click', function(event) {
console.log('Menu toggle clicked!', event);
event.preventDefault();
event.stopPropagation();
toggleMenu(event);
return false;
});
// Also handle the header menu toggle if it exists
const headerMenuToggle = document.getElementById('header-menu-toggle');
if (headerMenuToggle) {
console.log('Header menu toggle found, syncing with main menu');
headerMenuToggle.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
newToggle.click(); // Trigger the main menu toggle
return false;
});
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM fully loaded and parsed');
// Initialize navigation based on authentication state
// This will be set by the main app after checking auth status
if (window.initializeNavigation) {
window.initializeNavigation();
}
// Initialize menu toggle
initializeMenuToggle();
// Also try to initialize after a short delay in case the DOM changes
setTimeout(initializeMenuToggle, 500);
});
// Navigation injection function
export function injectNavigation(isAuthenticated = false) {
console.log('Injecting navigation, isAuthenticated:', isAuthenticated);
const container = document.getElementById('main-navigation');
const navWrapper = document.querySelector('.nav-wrapper');
if (!container || !navWrapper) {
console.error('Navigation elements not found. Looking for #main-navigation and .nav-wrapper');
return null;
}
try {
// Store scroll position
const scrollPosition = window.scrollY;
// Clear existing navigation
container.innerHTML = '';
// Create the appropriate navigation based on authentication status
const nav = isAuthenticated ? createUserNav() : createGuestNav();
// Append the navigation to the container
container.appendChild(nav);
// Set up menu toggle functionality
setupMenuToggle();
// Set up navigation links
setupMenuLinks();
// Show the appropriate page based on URL
if (window.location.hash === '#streams' || window.location.pathname === '/streams') {
showOnly('stream-page');
if (typeof window.maybeLoadStreamsOnShow === 'function') {
window.maybeLoadStreamsOnShow();
}
} else if (!window.location.hash || window.location.hash === '#') {
// Show welcome page by default if no hash
showOnly('welcome-page');
}
// Restore scroll position
window.scrollTo(0, scrollPosition);
return nav;
} catch (error) {
console.error('Error injecting navigation:', error);
return null;
}
}
// Set up menu links with click handlers
function setupMenuLinks() {
// Handle navigation link clicks
document.addEventListener('click', function(e) {
// Check if click is on a nav link or its children
let link = e.target.closest('.nav-link');
if (!link) return;
const target = link.getAttribute('data-target');
if (target) {
e.preventDefault();
console.log('Navigation link clicked:', target);
showOnly(target);
closeMenu();
// Update active state
document.querySelectorAll('.nav-link').forEach(l => {
l.classList.remove('active');
});
link.classList.add('active');
}
});
}
// Make the function available globally for debugging
window.injectNavigation = injectNavigation;

View File

@ -47,9 +47,22 @@ export async function initMagicLogin() {
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);
import('./toast.js').then(({ showToast }) => {
showToast('✅ Login successful!');
// Update UI state after login
const guestDashboard = document.getElementById('guest-dashboard');
const userDashboard = document.getElementById('user-dashboard');
const registerPage = document.getElementById('register-page');
if (guestDashboard) guestDashboard.style.display = 'none';
if (userDashboard) userDashboard.style.display = 'block';
if (registerPage) registerPage.style.display = 'none';
// Show the user's stream page
if (window.showOnly) {
window.showOnly('me-page');
}
});
return;
}
alert(data.detail || 'Login failed.');

305
static/mobile.css Normal file
View File

@ -0,0 +1,305 @@
/* Mobile-specific styles for screens up to 959px */
@media (max-width: 959px) {
/* Base layout adjustments */
html {
height: 100%;
min-height: 100%;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
font-size: 16px;
overflow-x: hidden;
width: 100%;
max-width: 100%;
background: transparent !important;
}
main {
padding: 0.5rem 1rem;
margin: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
box-shadow: none;
border: none;
background: none;
}
* {
box-sizing: border-box;
}
header {
padding: 0.5rem 1rem;
}
header h1 {
font-size: 1.8rem;
margin: 0.5rem 0;
}
header p {
font-size: 1rem;
margin: 0.25rem 0 1rem;
}
.dashboard-nav {
width: 100%;
padding: 0.5rem;
box-sizing: border-box;
text-align: center;
font-size: 0.9rem;
}
.dashboard-nav a {
padding: 0.5rem;
margin: 0 0.25rem;
display: inline-block;
}
main > section {
width: 100%;
max-width: 100%;
padding: 1rem;
box-sizing: border-box;
}
.btn {
width: 100%;
height: 48px;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
font-size: 1rem;
box-sizing: border-box;
}
.audio-player {
width: 100%;
margin: 1rem 0;
}
.audio-controls {
flex-direction: column;
}
.audio-controls button {
margin: 0.25rem 0;
}
.dropzone {
padding: 2rem;
}
#quota-meter {
margin: 1rem 0;
}
.quota-meter {
height: 20px;
}
.stream-item {
padding: 0.75rem;
}
.modal-content {
width: 90%;
max-width: 90%;
}
footer {
padding: 1rem;
}
.footer-hint {
font-size: 0.9rem;
}
.desktop-only {
display: none !important;
}
#burger-label {
display: block;
}
section#links {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #1e1e1e;
z-index: 1000;
}
#burger-toggle:checked + #burger-label + section#links {
display: block;
}
/* Make sure all interactive elements are touch-friendly */
a, [role="button"], label, select, textarea {
min-height: 44px;
min-width: 44px;
}
.dropzone {
padding: 1.5rem;
margin: 1rem 0;
}
.dropzone p {
font-size: 1rem;
margin: 0.5rem 0;
}
/* Adjust header text for better mobile display */
header h1 {
font-size: 1.5rem;
}
header p {
font-size: 0.9rem;
}
.dashboard-nav {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
padding: 0.5rem 0;
}
.dashboard-nav::-webkit-scrollbar {
display: none;
}
nav.dashboard-nav a {
all: unset;
display: inline-block;
background-color: #1e1e1e;
color: #fff;
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
text-align: center;
min-width: 100px;
box-sizing: border-box;
transition: background-color 0.2s;
}
.dashboard-nav a:active {
background-color: #333;
}
/* Stream page specific styles */
#stream-page {
padding: 0.5rem;
}
#stream-page h2 {
font-size: 1.5rem;
}
#stream-page article {
padding: 1rem;
margin: 0.5rem 0;
}
#stream-list {
padding: 0.5rem;
}
#stream-list li {
margin-bottom: 1rem;
}
.stream-player {
padding: 0.75rem;
}
.stream-player h3 {
font-size: 1.1rem;
}
.stream-info {
font-size: 0.9rem;
}
#stream-list > li {
margin-bottom: 1.5rem;
}
/* User upload area */
#user-upload-area {
margin: 1rem 0;
padding: 1.5rem;
text-align: center;
cursor: pointer;
border: 2px dashed #666;
border-radius: 8px;
}
#user-upload-area p {
margin: 0.5rem 0;
}
/* Stream player adjustments */
.stream-player {
padding: 1rem;
margin: 0.5rem 0;
border: 1px solid #444;
border-radius: 8px;
background-color: #1e1e1e;
}
.stream-player h3 {
margin: 0 0 0.5rem 0;
font-size: 1.2rem;
}
.stream-info {
font-size: 0.9rem;
color: #aaa;
margin-bottom: 0.5rem;
}
.stream-audio {
width: 100%;
}
/* Form elements */
input[type="text"],
input[type="email"],
input[type="password"],
textarea {
width: 100%;
padding: 0.75rem;
margin: 0.5rem 0;
font-size: 1rem;
}
/* Adjust audio element for mobile */
audio {
width: 100% !important;
max-width: 100% !important;
}
/* Toast notifications */
.toast {
width: 90%;
max-width: 100%;
left: 5%;
right: 5%;
transform: none;
margin: 0 auto;
}
}

View File

@ -11,9 +11,40 @@ document.addEventListener("DOMContentLoaded", () => {
const Router = {
sections: Array.from(document.querySelectorAll("main > section")),
showOnly(id) {
// Define which sections are part of the 'Your Stream' section
const yourStreamSections = ['me-page', 'register-page', 'quota-meter'];
const isYourStreamSection = yourStreamSections.includes(id);
// Handle the quota meter visibility - only show with 'me-page'
const quotaMeter = document.getElementById('quota-meter');
if (quotaMeter) {
quotaMeter.hidden = id !== 'me-page';
quotaMeter.tabIndex = id === 'me-page' ? 0 : -1;
}
// Check if user is logged in
const isLoggedIn = !!getCookie('uid');
// Handle all sections
this.sections.forEach(sec => {
sec.hidden = sec.id !== id;
sec.tabIndex = -1;
// Skip quota meter as it's already handled
if (sec.id === 'quota-meter') return;
// Special handling for register page - only show to guests
if (sec.id === 'register-page') {
sec.hidden = isLoggedIn || id !== 'register-page';
sec.tabIndex = (!isLoggedIn && id === 'register-page') ? 0 : -1;
return;
}
// Show the section if it matches the target ID
// OR if it's a 'Your Stream' section and we're in a 'Your Stream' context
const isSectionInYourStream = yourStreamSections.includes(sec.id);
const shouldShow = (sec.id === id) ||
(isYourStreamSection && isSectionInYourStream);
sec.hidden = !shouldShow;
sec.tabIndex = shouldShow ? 0 : -1;
});
// Show user-upload-area only when me-page is shown and user is logged in
const userUpload = document.getElementById("user-upload-area");

View File

@ -1,10 +1,12 @@
// static/streams-ui.js — public streams loader and profile-link handling
import { showOnly } from './router.js';
console.log('[streams-ui] Module loaded');
// Removed loadingStreams and lastStreamsPageVisible guards for instant fetch
export function initStreamsUI() {
console.log('[streams-ui] Initializing streams UI');
initStreamLinks();
window.addEventListener('popstate', () => {
highlightActiveProfileLink();
@ -24,145 +26,314 @@ function maybeLoadStreamsOnShow() {
}
window.maybeLoadStreamsOnShow = maybeLoadStreamsOnShow;
// Global variables for audio control
let currentlyPlayingAudio = null;
let currentlyPlayingButton = null;
document.addEventListener('DOMContentLoaded', initStreamsUI);
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
console.log('[streams-ui] DOM content loaded, initializing streams UI');
initStreamsUI();
// Also try to load streams immediately in case the page is already loaded
setTimeout(() => {
console.log('[streams-ui] Attempting initial stream load');
loadAndRenderStreams();
}, 100);
});
function loadAndRenderStreams() {
console.log('[streams-ui] loadAndRenderStreams called');
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>';
// Clear any existing error messages or retry buttons
ul.innerHTML = '<li>Loading public streams...</li>';
// Add a timestamp to prevent caching issues
const timestamp = new Date().getTime();
// Use the same protocol as the current page to avoid mixed content issues
const baseUrl = window.location.origin;
const sseUrl = `${baseUrl}/streams-sse?t=${timestamp}`;
console.log(`[streams-ui] Connecting to ${sseUrl}`);
let gotAny = false;
let streams = [];
// Close previous EventSource if any
let connectionTimeout = null;
// Close previous connection and clear any pending timeouts
if (window._streamsSSE) {
window._streamsSSE.close();
console.log('[streams-ui] Aborting previous connection');
if (window._streamsSSE.abort) {
window._streamsSSE.abort();
}
window._streamsSSE = null;
}
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>';
if (connectionTimeout) {
clearTimeout(connectionTimeout);
connectionTimeout = null;
}
console.log(`[streams-ui] Creating fetch-based SSE connection to ${sseUrl}`);
// Use fetch with ReadableStream for better CORS handling
const controller = new AbortController();
const signal = controller.signal;
// Store the controller for cleanup
window._streamsSSE = controller;
// Set a connection timeout
connectionTimeout = setTimeout(() => {
if (!gotAny) {
console.log('[streams-ui] Connection timeout reached, forcing retry...');
controller.abort();
loadAndRenderStreams();
}
}, 10000); // 10 second timeout
console.log('[streams-ui] Making fetch request to:', sseUrl);
console.log('[streams-ui] Making fetch request to:', sseUrl);
console.log('[streams-ui] Creating fetch request with URL:', sseUrl);
// Make the fetch request
fetch(sseUrl, {
method: 'GET',
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
credentials: 'same-origin',
signal: signal,
// Add mode and redirect options for better error handling
mode: 'cors',
redirect: 'follow'
})
.then(response => {
console.log('[streams-ui] Fetch response received, status:', response.status, response.statusText);
console.log('[streams-ui] Response URL:', response.url);
console.log('[streams-ui] Response type:', response.type);
console.log('[streams-ui] Response redirected:', response.redirected);
console.log('[streams-ui] Response headers:');
response.headers.forEach((value, key) => {
console.log(` ${key}: ${value}`);
});
if (!response.ok) {
// Try to get the response text for error details
return response.text().then(text => {
console.error('[streams-ui] Error response body:', text);
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
error.response = { status: response.status, statusText: response.statusText, body: text };
throw error;
}).catch(textError => {
console.error('[streams-ui] Could not read error response body:', textError);
const error = new Error(`HTTP error! status: ${response.status}, statusText: ${response.statusText}`);
error.response = { status: response.status, statusText: response.statusText };
throw error;
});
}
if (!response.body) {
const error = new Error('Response body is null or undefined');
console.error('[streams-ui] No response body:', error);
throw error;
}
console.log('[streams-ui] Response body is available, content-type:', response.headers.get('content-type'));
// Get the readable stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// Process the stream
function processStream({ done, value }) {
if (done) {
console.log('[streams-ui] Stream completed');
// Process any remaining data in the buffer
if (buffer.trim()) {
try {
const data = JSON.parse(buffer);
processSSEEvent(data);
} catch (e) {
console.error('[streams-ui] Error parsing final data:', e);
}
}
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);
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Wait for DOM update
requestAnimationFrame(() => {
const playPauseButton = document.getElementById(`play-pause-${uid}`);
const audio = document.getElementById(`audio-${uid}`);
// Process complete events in the buffer
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep incomplete event in buffer
for (const event of events) {
if (!event.trim()) continue;
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;
}
}
});
// Extract data field from SSE format
const dataMatch = event.match(/^data: (\{.*\})$/m);
if (dataMatch && dataMatch[1]) {
try {
const data = JSON.parse(dataMatch[1]);
processSSEEvent(data);
} catch (e) {
console.error('[streams-ui] Error parsing event data:', e, 'Event:', event);
}
}
}
// Read the next chunk
return reader.read().then(processStream);
}
// Start reading the stream
return reader.read().then(processStream);
})
.catch(error => {
console.error('[streams-ui] Fetch request failed:', error);
// Log additional error details
if (error.name === 'TypeError') {
console.error('[streams-ui] This is likely a network error or CORS issue');
if (error.message.includes('fetch')) {
console.error('[streams-ui] The fetch request was blocked or failed to reach the server');
}
if (error.message.includes('CORS')) {
console.error('[streams-ui] CORS error detected. Check server CORS configuration');
}
}
if (error.name === 'AbortError') {
console.log('[streams-ui] Request was aborted');
} else {
console.error('[streams-ui] Error details:', {
name: error.name,
message: error.message,
stack: error.stack,
constructor: error.constructor.name,
errorCode: error.code,
errorNumber: error.errno,
response: error.response
});
// Show a user-friendly error message
const ul = document.getElementById('stream-list');
if (ul) {
ul.innerHTML = `
<li class="error">
<p>Error loading streams. Please try again later.</p>
<p><small>Technical details: ${error.name}: ${error.message}</small></p>
</li>
`;
}
handleSSEError(error);
}
});
// Function to process SSE events
function processSSEEvent(data) {
console.log('[streams-ui] Received SSE event:', data);
if (data.end) {
console.log('[streams-ui] Received end event, total streams:', streams.length);
if (streams.length === 0) {
console.log('[streams-ui] No streams found, showing empty state');
ul.innerHTML = '<li>No active streams.</li>';
return;
}
// Sort streams by mtime in descending order (newest first)
streams.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
console.log('[streams-ui] Sorted streams:', streams);
// Clear the list
ul.innerHTML = '';
// Render each stream in sorted order
streams.forEach((stream, index) => {
const uid = stream.uid || `stream-${index}`;
const sizeMb = stream.size ? (stream.size / (1024 * 1024)).toFixed(1) : '?';
const mtime = stream.mtime ? new Date(stream.mtime * 1000).toISOString().split('T')[0].replace(/-/g, '/') : '';
console.log(`[streams-ui] Rendering stream ${index + 1}/${streams.length}:`, { uid, sizeMb, mtime });
const li = document.createElement('li');
li.className = 'stream-item';
try {
li.innerHTML = `
<article class="stream-player" data-uid="${escapeHtml(uid)}">
<h3>${escapeHtml(uid)}</h3>
<div class="audio-controls">
<button class="play-pause-btn" data-uid="${escapeHtml(uid)}" aria-label="Play">▶️</button>
</div>
<p class="stream-info" style='color:gray;font-size:90%'>[${sizeMb} MB, ${mtime}]</p>
</article>
`;
ul.appendChild(li);
console.log(`[streams-ui] Successfully rendered stream: ${uid}`);
} catch (error) {
console.error(`[streams-ui] Error rendering stream ${uid}:`, error);
const errorLi = document.createElement('li');
errorLi.textContent = `Error loading stream: ${uid}`;
errorLi.style.color = 'red';
ul.appendChild(errorLi);
}
});
highlightActiveProfileLink();
ul.appendChild(li);
highlightActiveProfileLink();
} catch (e) {
// Remove Loading... even if JSON parse fails, to avoid stuck UI
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
return;
}
// Add stream to our collection
streams.push(data);
// If this is the first stream, clear the loading message
if (!gotAny) {
ul.innerHTML = '';
gotAny = true;
}
}
// Function to handle SSE errors
function handleSSEError(error) {
console.error('[streams-ui] SSE error:', error);
// Only show error if we haven't already loaded any streams
if (streams.length === 0) {
const errorMsg = 'Error connecting to stream server. Please try again.';
ul.innerHTML = `
<li>${errorMsg}</li>
<li><button id="reload-streams" onclick="loadAndRenderStreams()" class="retry-button">🔄 Retry</button></li>
`;
if (typeof showToast === 'function') {
showToast('❌ ' + errorMsg);
}
console.error('[streams-ui] SSE parse error', e, event.data);
// Auto-retry after 5 seconds
setTimeout(() => {
loadAndRenderStreams();
}, 5000);
}
};
}
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);
}
};
// Error and open handlers are now part of the fetch implementation
// Message handling is now part of the fetch implementation
// Error handling is now part of the fetch implementation
}
export function renderStreamList(streams) {
@ -208,7 +379,6 @@ export function highlightActiveProfileLink() {
}
export function initStreamLinks() {
const ul = document.getElementById('stream-list');
if (!ul) return;
@ -232,3 +402,387 @@ export function initStreamLinks() {
}
});
}
// Helper function to safely escape HTML
function escapeHtml(unsafe) {
if (typeof unsafe !== 'string') return '';
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Function to update play/pause button state
function updatePlayPauseButton(button, isPlaying) {
if (!button) return;
button.textContent = isPlaying ? '⏸️' : '▶️';
button.setAttribute('aria-label', isPlaying ? 'Pause' : 'Play');
}
// Audio context for Web Audio API
let audioContext = null;
let audioSource = null;
let audioBuffer = null;
let isPlaying = false;
let currentUid = null;
let currentlyPlayingButton = null; // Controls the currently active play/pause button
let startTime = 0;
let pauseTime = 0;
let audioStartTime = 0;
let audioElement = null; // HTML5 Audio element for Opus playback
// Initialize audio context
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
// Stop current playback completely
function stopPlayback() {
console.log('[streams-ui] Stopping playback');
// Stop Web Audio API if active
if (audioSource) {
try {
// Don't try to stop if already stopped
if (audioSource.context && audioSource.context.state !== 'closed') {
audioSource.stop();
audioSource.disconnect();
}
} catch (e) {
// Ignore errors when stopping already stopped sources
if (!e.message.includes('has already been stopped') &&
!e.message.includes('has already finished playing')) {
console.warn('Error stopping audio source:', e);
}
}
audioSource = null;
}
// Stop HTML5 Audio element if active
if (audioElement) {
try {
// Remove all event listeners first
if (audioElement._eventHandlers) {
const { onPlay, onPause, onEnded, onError } = audioElement._eventHandlers;
if (onPlay) audioElement.removeEventListener('play', onPlay);
if (onPause) audioElement.removeEventListener('pause', onPause);
if (onEnded) audioElement.removeEventListener('ended', onEnded);
if (onError) audioElement.removeEventListener('error', onError);
}
// Pause and reset the audio element
audioElement.pause();
audioElement.removeAttribute('src');
audioElement.load();
// Clear references
if (audioElement._eventHandlers) {
delete audioElement._eventHandlers;
}
// Nullify the element to allow garbage collection
audioElement = null;
} catch (e) {
console.warn('Error cleaning up audio element:', e);
}
}
// Reset state
audioBuffer = null;
isPlaying = false;
startTime = 0;
pauseTime = 0;
audioStartTime = 0;
// Update UI
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
currentlyPlayingButton = null;
}
// Clear current playing reference
currentlyPlayingAudio = null;
}
// Load and play audio using HTML5 Audio element for Opus
async function loadAndPlayAudio(uid, playPauseBtn) {
console.log(`[streams-ui] loadAndPlayAudio called for UID: ${uid}`);
// If trying to play the currently paused audio, just resume it
if (audioElement && currentUid === uid) {
console.log('[streams-ui] Resuming existing audio');
try {
await audioElement.play();
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
return;
} catch (error) {
console.error('Error resuming audio:', error);
// Fall through to reload if resume fails
}
}
// Stop any current playback
stopPlayback();
// Update UI
updatePlayPauseButton(playPauseBtn, true);
currentlyPlayingButton = playPauseBtn;
currentUid = uid;
try {
console.log(`[streams-ui] Creating new audio element for ${uid}`);
// Create a new audio element with the correct MIME type
const audioUrl = `/audio/${encodeURIComponent(uid)}/stream.opus`;
console.log(`[streams-ui] Loading audio from: ${audioUrl}`);
// Create a new audio element with a small delay to prevent race conditions
await new Promise(resolve => setTimeout(resolve, 50));
audioElement = new Audio(audioUrl);
audioElement.preload = 'auto';
audioElement.crossOrigin = 'anonymous'; // Important for CORS
// Set up event handlers with proper binding
const onPlay = () => {
console.log('[streams-ui] Audio play event');
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
};
const onPause = () => {
console.log('[streams-ui] Audio pause event');
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
};
const onEnded = () => {
console.log('[streams-ui] Audio ended event');
isPlaying = false;
cleanupAudio();
};
const onError = (e) => {
// Ignore errors from previous audio elements that were cleaned up
if (!audioElement || audioElement.readyState === 0) {
console.log('[streams-ui] Ignoring error from cleaned up audio element');
return;
}
console.error('[streams-ui] Audio error:', e);
console.error('Error details:', audioElement.error);
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
// Don't show error to user for aborted requests
if (audioElement.error && audioElement.error.code === MediaError.MEDIA_ERR_ABORTED) {
console.log('[streams-ui] Playback was aborted as expected');
return;
}
// Show error to user for other errors
if (typeof showToast === 'function') {
showToast('Error playing audio. The format may not be supported.', 'error');
}
};
// Add event listeners
audioElement.addEventListener('play', onPlay, { once: true });
audioElement.addEventListener('pause', onPause);
audioElement.addEventListener('ended', onEnded, { once: true });
audioElement.addEventListener('error', onError);
// Store references for cleanup
audioElement._eventHandlers = { onPlay, onPause, onEnded, onError };
// Start playback with error handling
console.log('[streams-ui] Starting audio playback');
try {
const playPromise = audioElement.play();
if (playPromise !== undefined) {
await playPromise.catch(error => {
// Ignore abort errors when switching between streams
if (error.name !== 'AbortError') {
console.error('[streams-ui] Play failed:', error);
throw error;
}
console.log('[streams-ui] Play was aborted as expected');
});
}
isPlaying = true;
} catch (error) {
// Only log unexpected errors
if (error.name !== 'AbortError') {
console.error('[streams-ui] Error during playback:', error);
throw error;
}
}
} catch (error) {
console.error('[streams-ui] Error loading/playing audio:', error);
if (playPauseBtn) {
updatePlayPauseButton(playPauseBtn, false);
}
// Only show error if it's not an abort error
if (error.name !== 'AbortError' && typeof showToast === 'function') {
showToast('Error playing audio. Please try again.', 'error');
}
}
}
// Handle audio ended event
function handleAudioEnded() {
isPlaying = false;
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
}
cleanupAudio();
}
// Clean up audio resources
function cleanupAudio() {
console.log('[streams-ui] Cleaning up audio resources');
// Clean up Web Audio API resources if they exist
if (audioSource) {
try {
if (isPlaying) {
audioSource.stop();
}
audioSource.disconnect();
} catch (e) {
console.warn('Error cleaning up audio source:', e);
}
audioSource = null;
}
// Clean up HTML5 Audio element if it exists
if (audioElement) {
try {
// Remove event listeners first
if (audioElement._eventHandlers) {
const { onPlay, onPause, onEnded, onError } = audioElement._eventHandlers;
if (onPlay) audioElement.removeEventListener('play', onPlay);
if (onPause) audioElement.removeEventListener('pause', onPause);
if (onEnded) audioElement.removeEventListener('ended', onEnded);
if (onError) audioElement.removeEventListener('error', onError);
}
// Pause and clean up the audio element
audioElement.pause();
audioElement.removeAttribute('src');
audioElement.load();
// Force garbage collection by removing references
if (audioElement._eventHandlers) {
delete audioElement._eventHandlers;
}
audioElement = null;
} catch (e) {
console.warn('Error cleaning up audio element:', e);
}
}
// Reset state
isPlaying = false;
currentUid = null;
// Update UI
if (currentlyPlayingButton) {
updatePlayPauseButton(currentlyPlayingButton, false);
currentlyPlayingButton = null;
}
}
// Event delegation for play/pause buttons
document.addEventListener('click', async (e) => {
const playPauseBtn = e.target.closest('.play-pause-btn');
if (!playPauseBtn) return;
// Prevent default to avoid any potential form submission or link following
e.preventDefault();
e.stopPropagation();
const uid = playPauseBtn.dataset.uid;
if (!uid) {
console.error('No UID found for play button');
return;
}
console.log(`[streams-ui] Play/pause clicked for UID: ${uid}, currentUid: ${currentUid}, isPlaying: ${isPlaying}`);
// If clicking the currently playing button, toggle pause/play
if (currentUid === uid) {
if (isPlaying) {
console.log('[streams-ui] Pausing current audio');
await audioElement.pause();
isPlaying = false;
updatePlayPauseButton(playPauseBtn, false);
} else {
console.log('[streams-ui] Resuming current audio');
try {
await audioElement.play();
isPlaying = true;
updatePlayPauseButton(playPauseBtn, true);
} catch (error) {
console.error('[streams-ui] Error resuming audio:', error);
// If resume fails, try reloading the audio
await loadAndPlayAudio(uid, playPauseBtn);
}
}
return;
}
// If a different stream is playing, stop it and start the new one
console.log(`[streams-ui] Switching to new audio stream: ${uid}`);
stopPlayback();
await loadAndPlayAudio(uid, playPauseBtn);
});
// Handle audio end event to update button state
document.addEventListener('play', (e) => {
if (e.target.tagName === 'AUDIO' && e.target !== currentlyPlayingAudio) {
if (currentlyPlayingAudio) {
currentlyPlayingAudio.pause();
}
currentlyPlayingAudio = e.target;
// Update the play/pause button state
const playerArticle = e.target.closest('.stream-player');
if (playerArticle) {
const playBtn = playerArticle.querySelector('.play-pause-btn');
if (playBtn) {
if (currentlyPlayingButton && currentlyPlayingButton !== playBtn) {
updatePlayPauseButton(currentlyPlayingButton, false);
}
updatePlayPauseButton(playBtn, true);
currentlyPlayingButton = playBtn;
}
}
}
}, true);
// Handle audio pause event
document.addEventListener('pause', (e) => {
if (e.target.tagName === 'AUDIO' && e.target === currentlyPlayingAudio) {
const playerArticle = e.target.closest('.stream-player');
if (playerArticle) {
const playBtn = playerArticle.querySelector('.play-pause-btn');
if (playBtn) {
updatePlayPauseButton(playBtn, false);
}
}
currentlyPlayingAudio = null;
currentlyPlayingButton = null;
}
}, true);

View File

@ -4,6 +4,93 @@ main {
align-items: center; /* centers children horizontally */
}
/* Global section styles */
main > section {
width: 100%;
max-width: 100%;
margin: 0 0 1.5rem 0;
padding: 0; /* Remove padding from section, will be handled by inner elements */
box-sizing: border-box;
background: var(--crt-screen, #1a1a1a);
border-radius: 8px;
overflow: hidden; /* Ensures border-radius clips child elements */
}
/* Ensure consistent background for all sections */
#welcome-page,
#register-page,
#stream-page,
#me-page,
#terms-page,
#privacy-page,
#imprint-page {
background: var(--crt-screen, #1a1a1a);
}
/* Style articles within sections */
section > article {
background: transparent;
padding: 0;
margin: 0 auto;
box-shadow: none;
color: #f0f0f0;
max-width: 400px;
width: 100%;
padding: 2rem 1rem;
box-sizing: border-box;
}
/* Center the register form */
#register-form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 300px;
margin: 0 auto;
}
#register-form p {
margin: 0.5rem 0;
width: 100%;
}
#register-form label {
display: block;
margin-bottom: 0.5rem;
}
#register-form input[type="email"],
#register-form input[type="text"] {
width: 100%;
padding: 0.5rem;
box-sizing: border-box;
}
#register-form button[type="submit"] {
margin-top: 1rem;
width: calc(250px + 1.6em);
padding: 0.75rem;
cursor: pointer;
max-width: 100%;
box-sizing: border-box;
}
/* Style text inputs in register form */
#register-page #register-form input[type="email"],
#register-page #register-form input[type="text"] {
width: calc(250px + 1.6em);
max-width: 100%;
box-sizing: border-box;
}
/* Add subtle dividers between sections */
section + section,
article + article {
border-top: 1px solid #444;
}
nav#guest-dashboard.dashboard-nav {
width: fit-content; /* optional: shrink to fit content */
margin: 0 auto; /* fallback for block centering */
@ -20,21 +107,29 @@ nav#guest-dashboard.dashboard-nav {
flex-direction: column;
align-items: center;
pointer-events: none;
min-width: 300px;
max-width: 90%;
}
.toast {
background: var(--crt-screen);
color: var(--crt-text);
padding: 1em 2em;
background: rgba(30, 30, 30, 0.95);
color: #fff;
padding: 1.2em 2em;
border-radius: 8px;
box-shadow: 0 4px 20px var(--crt-shadow);
margin-top: 0.5em;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1);
margin-top: 0.8em;
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);
border: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
line-height: 1.5;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
transform: translateZ(0);
will-change: transform, opacity;
}
@keyframes fadeInOut {
@ -137,53 +232,102 @@ audio {
background: #fff;
}
/* Audio controls base styles */
.audio-controls {
display: flex;
justify-content: center;
margin: 1.5em 0;
padding: 1em;
align-items: center;
gap: 1.5rem;
padding: 0;
width: 100%;
}
/* Specific styles for play/pause button in me-page */
#me-page .audio-controls button {
border: 2px solid #444; /* Default border color */
transition: all 0.2s ease;
}
#me-page .audio-controls button:hover {
background: #222;
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
border-color: #666;
}
.audio-controls button {
background: none;
border: none;
cursor: pointer;
padding: 1.2em;
background: rgba(26, 26, 26, 0.9);
border: 2px solid #444;
border-radius: 12px;
transition: all 0.2s ease;
color: #f0f0f0;
cursor: pointer;
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;
width: 64px;
height: 64px;
padding: 0;
margin: 0.5rem;
transition: all 0.2s ease;
font-size: 1.5rem;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
/* Play/Pause button specific styles */
.audio-controls button#play-pause,
.audio-controls button#play-pause-devuser {
width: 80px;
height: 80px;
font-size: 3rem;
background: rgba(34, 34, 34, 0.95);
}
/* Hover and active states */
.audio-controls button:hover {
background-color: #f0f0f0;
transform: scale(1.1);
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
background: #222;
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
border-color: #666;
}
.audio-controls button:active {
color: #000;
transform: scale(0.9);
transform: scale(0.98);
}
/* Ensure touch targets are large enough on mobile */
@media (max-width: 959px) {
.audio-controls {
gap: 1rem;
}
.audio-controls button {
min-width: 64px;
min-height: 64px;
}
.audio-controls button#play-pause,
.audio-controls button#play-pause-devuser {
min-width: 80px;
min-height: 80px;
}
}
.audio-controls button:active {
transform: scale(0.98);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
background: #1a1a1a;
}
.audio-controls svg {
fill: #333;
width: 40px;
height: 40px;
fill: currentColor;
transition: all 0.2s ease;
width: 56px;
height: 56px;
}
.audio-controls button:hover svg {
fill: #000;
transform: scale(1.1);
.audio-controls button#play-pause svg {
width: 56px;
height: 56px;
}
/* Hide the native controls */
@ -212,16 +356,6 @@ main > section {
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;
@ -262,7 +396,6 @@ button.audio-control {
#register-form {
max-width: 400px;
margin: 0 auto;
padding: 20px;
text-align: center;
}
@ -367,13 +500,10 @@ button.audio-control:hover {
/* Stream player styling */
.stream-player {
background: #1a1a1a;
border: 1px solid #444;
padding: 1.5em;
border-radius: 8px;
margin-bottom: 1em;
background: transparent;
border: none;
position: relative;
color: #d3d3d3;
overflow: hidden;
}
.stream-player::before {
@ -562,12 +692,87 @@ input[disabled], button[disabled] {
background: #fff !important;
}
body {
font-family: sans-serif;
background: #fafafa;
/* Base document styles */
html {
height: 100%;
min-height: 100%;
margin: 0;
padding: 1em;
color: #333;
padding: 0;
background-color: #111;
background-image:
repeating-linear-gradient(
45deg,
rgba(188, 183, 107, 0.1) 0, /* Olive color */
rgba(188, 183, 107, 0.1) 1px,
transparent 1px,
transparent 20px
),
repeating-linear-gradient(
-45deg,
rgba(188, 183, 107, 0.1) 0, /* Olive color */
rgba(188, 183, 107, 0.1) 1px,
transparent 1px,
transparent 20px
);
background-size: 40px 40px;
background-repeat: repeat;
background-attachment: fixed;
}
body {
min-height: 100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
color: #f0f0f0;
font-family: sans-serif;
line-height: 1.6;
background: transparent;
}
/* Ensure main content stretches to fill available space */
main {
flex: 1 0 auto;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
box-sizing: border-box;
}
main > section {
width: 100%;
max-width: inherit;
margin: 0 auto;
box-sizing: border-box;
background: var(--crt-screen, #1a1a1a);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
section > article {
background: transparent;
padding: 0;
margin: 0;
box-shadow: none;
}
/* Fallback for browsers that don't support flexbox */
@supports not (display: flex) {
html {
background: #111;
}
body {
min-height: 100%;
}
main {
min-height: 100vh;
}
}
header h1 {
@ -585,7 +790,6 @@ header p {
header, footer {
text-align: center;
margin-bottom: 1.5em;
}
footer p {
@ -761,15 +965,10 @@ a.button:hover {
background: #256b45;
}
section article {
max-width: 600px;
margin: 2em auto;
padding: 1.5em;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
/* Stream page specific styles */
/* Article styles moved to desktop.css */
/* Specific styles for stream player */
section article.stream-page {
max-width: 800px;
margin: 0 auto;
@ -779,9 +978,7 @@ section article.stream-page {
.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);
@ -797,34 +994,31 @@ section article.stream-page {
margin-bottom: 15px;
}
.audio-controls {
display: flex;
justify-content: center;
margin-bottom: 10px;
/* Stream audio controls (specific to stream items) */
.stream-audio .audio-controls {
margin: 0;
padding: 0;
gap: 1.5rem;
}
.audio-controls button {
.stream-audio .audio-controls button {
width: 64px;
height: 64px;
padding: 0;
margin: 0;
font-size: 3rem;
background: none;
border: none;
cursor: pointer;
padding: 12px;
border-radius: 8px;
transition: all 0.2s ease;
font-size: 24px;
font-weight: bold;
color: #666;
box-shadow: none;
color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
min-width: auto;
min-height: auto;
}
.audio-controls button:hover {
background-color: #f0f0f0;
color: #333;
transform: scale(1.05);
}
.audio-controls button:active {
color: #000;
transform: scale(0.95);
}
/* Play button styles are now consolidated above */
.stream-info {
margin: 0;
@ -832,40 +1026,215 @@ section article.stream-page {
font-size: 0.9em;
}
ul#stream-list,
ul#me-files {
padding-left: 0;
/* Stream list styles */
#stream-page article {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
}
/* User upload area styles */
#user-upload-area {
width: 100%;
margin: 1.5rem auto;
padding: 1.5rem;
background: var(--crt-screen);
border: 1px solid #444;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
}
#user-upload-area:hover {
transform: translateY(-2px);
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
border: 1px solid #ff6600;
}
#user-upload-area p {
margin: 0;
color: #ddd;
font-size: 1.1rem;
}
#stream-list {
list-style: none;
text-align: center;
margin-top: 1em;
padding: 0;
margin: 0 0 1.5rem;
display: grid;
gap: 1.5rem;
width: 100%;
}
ul#stream-list li a,
ul#me-files li {
display: inline-flex;
align-items: center;
#stream-list > li {
background: #222;
border: 1px solid #444;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s ease, border-color 0.2s ease;
margin: 0;
width: 100%;
box-sizing: border-box;
}
#stream-list > li:hover {
transform: translateY(-2px);
border-color: #ff6600;
}
.stream-player {
width: 100%;
box-sizing: border-box;
background: #1e1e1e;
border: 1px solid #2a2a2a;
border-radius: 10px;
transition: all 0.25s ease;
}
.stream-player:hover {
transform: translateY(-2px);
border-color: #ff6600;
background: linear-gradient(45deg, rgba(255, 102, 0, 0.05), rgba(255, 102, 0, 0.02));
}
.stream-player h3 {
margin: 0 0 1.25rem;
color: #fff;
font-size: 1.3rem;
font-weight: 500;
text-align: center;
word-break: break-word;
letter-spacing: 0.3px;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.stream-info {
margin: 1.25rem 0 0;
color: #a0a0a0 !important;
font-size: 0.9rem !important;
text-align: center;
line-height: 1.6;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Stream list styles */
#stream-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 1.25rem;
max-width: 800px;
margin: 0 auto;
}
#stream-list li {
background: transparent;
margin: 0 0 1rem 0;
padding: 0;
transition: transform 0.2s ease;
}
#stream-list li:hover {
transform: translateY(-2px);
}
#stream-list a {
color: #4dabf7;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
#stream-list a:hover {
color: #74c0fc;
text-decoration: underline;
}
#stream-list .stream-meta {
display: block;
color: #888;
font-size: 0.85rem;
margin-top: 0.5rem;
}
/* User upload area */
#user-upload-area {
max-width: 600px;
width: 100%;
margin: 0 auto 1.5rem;
padding: 1.5rem;
background: var(--crt-screen, #1a1a1a);
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Ensure consistent audio player container */
.stream-audio {
width: 100%;
margin: 1rem 0 0;
}
/* Style for the play button container */
.audio-controls-container {
display: flex;
justify-content: center;
margin: 0.3em auto;
padding: 0.4em 0.8em;
border-radius: 6px;
background: #f0f0f0;
font-size: 0.95em;
max-width: 90%;
gap: 1em;
color: #333;
width: 100%;
margin-top: 1rem;
}
ul#stream-list li a:hover,
ul#me-files li:hover {
background: #e5f5ec;
/* Responsive adjustments */
@media (max-width: 767px) {
#stream-page article {
padding: 1rem;
border: none;
background: transparent;
box-shadow: none;
}
#stream-list {
gap: 1rem;
}
.stream-player {
padding: 1rem;
}
}
section article h2 {
/* Section h2 headers */
main > section > h2 {
text-align: center;
margin-top: 0;
margin-bottom: 0.6em;
margin: 0;
padding: 1rem 1.5rem;
color: #f0f0f0;
font-size: 1.8rem;
font-weight: 500;
}
/* Article styles consolidated above */
/* Stream page specific styles */
#stream-page > article {
max-width: 1200px; /* Wider for the stream list */
padding: 2rem 1rem; /* Match padding of other sections */
margin: 0 auto; /* Ensure centering */
}
/* Full width for form elements */
#register-page article,
#me-page > article {
max-width: 100%;
}
/* Add padding to the bottom of sections that only contain an h2 */
main > section:has(> h2:only-child) {
padding-bottom: 1.5rem;
}
/* Space between h2 and logout button is now handled inline */
section article a[href^="mailto"]::before {
content: "✉️ ";
margin-right: 0.3em;
@ -942,18 +1311,8 @@ 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;
/* Removed olive gradient overlay */
display: none;
}
nav#guest-dashboard.dashboard-nav,
@ -967,14 +1326,12 @@ nav#user-dashboard.dashboard-nav {
font-family: 'Courier New', monospace;
}
/* Dashboard nav base styles (moved to desktop.css and mobile.css) */
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;
transition: color 0.2s ease;
}
nav.dashboard-nav a:hover {
@ -1015,6 +1372,22 @@ footer p.footer-hint a:hover {
}
@media (min-width: 960px) {
/* Set max-width for content sections */
#register-page > article,
#me-page > article,
#user-upload-area {
max-width: 600px;
padding: 2rem;
margin-left: auto;
margin-right: auto;
}
#stream-page > article {
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
section#links {
display: flex;
flex-direction: column;
@ -1068,28 +1441,6 @@ footer p.footer-hint a:hover {
transition: all 0.3s ease;
}
@media (max-width: 959px) {
#burger-label {
display: block;
}
section#links {
display: none;
background: #fff;
position: absolute;
top: 3.2em;
right: 1em;
border: 1px solid #ccc;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
padding: 1em;
z-index: 10;
}
#burger-toggle:checked + #burger-label + section#links {
display: block;
}
}
@keyframes slideFadeIn {
0% {
@ -1112,3 +1463,38 @@ footer p.footer-hint a:hover {
transform: translateY(-10px);
}
}
/* Logout button styles */
.logout-btn {
background: rgba(34, 34, 34, 0.95);
color: #f0f0f0;
border: 2px solid #444;
border-radius: 6px;
padding: 0.5em 1em;
cursor: pointer;
font-family: inherit;
font-size: 1.2rem;
transition: all 0.2s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5em;
text-transform: none;
letter-spacing: normal;
width: auto;
height: auto;
margin: 0;
}
.logout-btn:hover {
background: #222;
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
border-color: #666;
}
.logout-btn:active {
transform: scale(0.98);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

View File

@ -0,0 +1,222 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Player Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
.test-case {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
.success { color: green; }
.error { color: red; }
button {
padding: 8px 16px;
margin: 5px;
cursor: pointer;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
background: #f5f5f5;
}
.audio-container {
margin: 20px 0;
}
audio {
width: 100%;
margin: 10px 0;
}
</style>
</head>
<body>
<h1>Audio Player Test</h1>
<div class="test-case">
<h2>Test 1: Direct Audio Element</h2>
<div class="audio-container">
<audio id="direct-audio" controls>
<source src="/audio/devuser/stream.opus" type="audio/ogg; codecs=opus">
Your browser does not support the audio element.
</audio>
</div>
<div>
<button onclick="document.getElementById('direct-audio').play()">Play</button>
<button onclick="document.getElementById('direct-audio').pause()">Pause</button>
</div>
</div>
<div class="test-case">
<h2>Test 2: Dynamic Audio Element</h2>
<div id="dynamic-audio-container">
<button onclick="setupDynamicAudio()">Initialize Dynamic Audio</button>
</div>
</div>
<div class="test-case">
<h2>Test 3: Using loadProfileStream</h2>
<div id="load-profile-container">
<button onclick="testLoadProfileStream()">Test loadProfileStream</button>
<div id="test3-status">Not started</div>
<div class="audio-container">
<audio id="profile-audio" controls></audio>
</div>
</div>
</div>
<div class="test-case">
<h2>Browser Audio Support</h2>
<div id="codec-support">Testing codec support...</div>
</div>
<div class="test-case">
<h2>Console Log</h2>
<div id="log"></div>
<button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
</div>
<script>
// Logging function
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const entry = document.createElement('div');
entry.className = type;
entry.textContent = `[${new Date().toISOString()}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
console.log(`[${type.toUpperCase()}] ${message}`);
}
// Test 2: Dynamic Audio Element
function setupDynamicAudio() {
log('Setting up dynamic audio element...');
const container = document.getElementById('dynamic-audio-container');
container.innerHTML = '';
try {
const audio = document.createElement('audio');
audio.controls = true;
audio.preload = 'auto';
audio.crossOrigin = 'anonymous';
const source = document.createElement('source');
source.src = '/audio/devuser/stream.opus';
source.type = 'audio/ogg; codecs=opus';
audio.appendChild(source);
container.appendChild(audio);
container.appendChild(document.createElement('br'));
const playBtn = document.createElement('button');
playBtn.textContent = 'Play';
playBtn.onclick = () => {
audio.play().catch(e => log(`Play error: ${e}`, 'error'));
};
container.appendChild(playBtn);
const pauseBtn = document.createElement('button');
pauseBtn.textContent = 'Pause';
pauseBtn.onclick = () => audio.pause();
container.appendChild(pauseBtn);
log('Dynamic audio element created successfully');
} catch (e) {
log(`Error creating dynamic audio: ${e}`, 'error');
}
}
// Test 3: loadProfileStream
async function testLoadProfileStream() {
const status = document.getElementById('test3-status');
status.textContent = 'Loading...';
status.className = '';
try {
// Import the loadProfileStream function from app.js
const { loadProfileStream } = await import('./app.js');
if (typeof loadProfileStream !== 'function') {
throw new Error('loadProfileStream function not found');
}
// Call loadProfileStream with test user
const audio = await loadProfileStream('devuser');
if (audio) {
status.textContent = 'Audio loaded successfully!';
status.className = 'success';
log('Audio loaded successfully', 'success');
// Add the audio element to the page
const audioContainer = document.querySelector('#load-profile-container .audio-container');
audioContainer.innerHTML = '';
audio.controls = true;
audioContainer.appendChild(audio);
} else {
status.textContent = 'No audio available for test user';
status.className = '';
log('No audio available for test user', 'info');
}
} catch (e) {
status.textContent = `Error: ${e.message}`;
status.className = 'error';
log(`Error in loadProfileStream: ${e}`, 'error');
console.error(e);
}
}
// Check browser audio support
function checkAudioSupport() {
const supportDiv = document.getElementById('codec-support');
const audio = document.createElement('audio');
const codecs = {
'audio/ogg; codecs=opus': 'Opus (OGG)',
'audio/webm; codecs=opus': 'Opus (WebM)',
'audio/mp4; codecs=mp4a.40.2': 'AAC (MP4)',
'audio/mpeg': 'MP3'
};
let results = [];
for (const [type, name] of Object.entries(codecs)) {
const canPlay = audio.canPlayType(type);
results.push(`${name}: ${canPlay || 'Not supported'}`);
}
supportDiv.innerHTML = results.join('<br>');
}
// Initialize tests
document.addEventListener('DOMContentLoaded', () => {
log('Test page loaded');
checkAudioSupport();
// Log audio element events for debugging
const audioElements = document.getElementsByTagName('audio');
Array.from(audioElements).forEach((audio, index) => {
['play', 'pause', 'error', 'stalled', 'suspend', 'abort', 'emptied', 'ended'].forEach(event => {
audio.addEventListener(event, (e) => {
log(`Audio ${index + 1} ${event} event: ${e.type}`);
});
});
});
});
</script>
</body>
</html>

192
static/test-audio.html Normal file
View File

@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audio Player Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
.test-case {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
.success { color: green; }
.error { color: red; }
button {
padding: 8px 16px;
margin: 5px;
cursor: pointer;
}
#log {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
background: #f5f5f5;
}
</style>
</head>
<body>
<h1>Audio Player Test</h1>
<div class="test-case">
<h2>Test 1: Basic Audio Element</h2>
<audio id="test1" controls>
<source src="/static/test-audio.opus" type="audio/ogg; codecs=opus">
Your browser does not support the audio element.
</audio>
<div>
<button onclick="document.getElementById('test1').play()">Play</button>
<button onclick="document.getElementById('test1').pause()">Pause</button>
</div>
</div>
<div class="test-case">
<h2>Test 2: Dynamic Audio Element</h2>
<div id="test2-container">
<button onclick="setupTest2()">Initialize Audio</button>
</div>
</div>
<div class="test-case">
<h2>Test 3: Using loadProfileStream</h2>
<div id="test3-container">
<button onclick="testLoadProfileStream()">Test loadProfileStream</button>
<div id="test3-status">Not started</div>
</div>
</div>
<div class="test-case">
<h2>Browser Audio Support</h2>
<div id="codec-support">Testing codec support...</div>
</div>
<div class="test-case">
<h2>Console Log</h2>
<div id="log"></div>
<button onclick="document.getElementById('log').innerHTML = ''">Clear Log</button>
</div>
<script>
// Logging function
function log(message, type = 'info') {
const logDiv = document.getElementById('log');
const entry = document.createElement('div');
entry.className = type;
entry.textContent = `[${new Date().toISOString()}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
console.log(`[${type.toUpperCase()}] ${message}`);
}
// Test 2: Dynamic Audio Element
function setupTest2() {
log('Setting up dynamic audio element...');
const container = document.getElementById('test2-container');
container.innerHTML = '';
try {
const audio = document.createElement('audio');
audio.controls = true;
audio.preload = 'auto';
const source = document.createElement('source');
source.src = '/static/test-audio.opus';
source.type = 'audio/ogg; codecs=opus';
audio.appendChild(source);
container.appendChild(audio);
container.appendChild(document.createElement('br'));
const playBtn = document.createElement('button');
playBtn.textContent = 'Play';
playBtn.onclick = () => audio.play().catch(e => log(`Play error: ${e}`, 'error'));
container.appendChild(playBtn);
const pauseBtn = document.createElement('button');
pauseBtn.textContent = 'Pause';
pauseBtn.onclick = () => audio.pause();
container.appendChild(pauseBtn);
log('Dynamic audio element created successfully');
} catch (e) {
log(`Error creating dynamic audio: ${e}`, 'error');
}
}
// Test 3: loadProfileStream
async function testLoadProfileStream() {
const status = document.getElementById('test3-status');
status.textContent = 'Loading...';
status.className = '';
try {
// Create a test user ID
const testUid = 'test-user-' + Math.random().toString(36).substr(2, 8);
log(`Testing with user: ${testUid}`);
// Call loadProfileStream
const audio = await window.loadProfileStream(testUid);
if (audio) {
status.textContent = 'Audio loaded successfully!';
status.className = 'success';
log('Audio loaded successfully', 'success');
} else {
status.textContent = 'No audio available for test user';
status.className = '';
log('No audio available for test user', 'info');
}
} catch (e) {
status.textContent = `Error: ${e.message}`;
status.className = 'error';
log(`Error in loadProfileStream: ${e}`, 'error');
}
}
// Check browser audio support
function checkAudioSupport() {
const supportDiv = document.getElementById('codec-support');
const audio = document.createElement('audio');
const codecs = {
'audio/ogg; codecs=opus': 'Opus (OGG)',
'audio/webm; codecs=opus': 'Opus (WebM)',
'audio/mp4; codecs=mp4a.40.2': 'AAC (MP4)',
'audio/mpeg': 'MP3'
};
let results = [];
for (const [type, name] of Object.entries(codecs)) {
const canPlay = audio.canPlayType(type);
results.push(`${name}: ${canPlay || 'Not supported'}`);
}
supportDiv.innerHTML = results.join('<br>');
}
// Initialize tests
document.addEventListener('DOMContentLoaded', () => {
log('Test page loaded');
checkAudioSupport();
// Expose loadProfileStream for testing
if (!window.loadProfileStream) {
log('Warning: loadProfileStream not found in global scope', 'warning');
}
});
</script>
</body>
</html>

BIN
static/test-audio.opus Normal file

Binary file not shown.

View File

@ -78,6 +78,22 @@ document.addEventListener('DOMContentLoaded', () => {
dropzone.classList.remove("uploading");
showToast("✅ Upload successful.");
// Refresh the audio player and file list
const uid = localStorage.getItem("uid");
if (uid) {
try {
if (window.loadProfileStream) {
await window.loadProfileStream(uid);
}
// Refresh the file list
if (window.fetchAndDisplayFiles) {
await window.fetchAndDisplayFiles(uid);
}
} catch (e) {
console.error('Failed to refresh:', e);
}
}
playBeep(432, 0.25, "sine");
} else {
streamInfo.hidden = true;
@ -95,8 +111,115 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
// Export the upload function for use in other modules
// Function to fetch and display uploaded files
async function fetchAndDisplayFiles(uid) {
console.log('[DEBUG] fetchAndDisplayFiles called with uid:', uid);
const fileList = document.getElementById('file-list');
if (!fileList) {
const errorMsg = 'File list element not found in DOM';
console.error(errorMsg);
return showErrorInUI(errorMsg);
}
// Show loading state
fileList.innerHTML = '<div style="padding: 10px; color: #888; font-style: italic;">Loading files...</div>';
try {
console.log(`[DEBUG] Fetching files for user: ${uid}`);
const response = await fetch(`/me/${uid}`);
console.log('[DEBUG] Response status:', response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
const errorMsg = `Failed to fetch files: ${response.status} ${response.statusText} - ${errorText}`;
console.error(`[ERROR] ${errorMsg}`);
throw new Error(errorMsg);
}
const data = await response.json();
console.log('[DEBUG] Received files data:', data);
if (!data.files) {
throw new Error('Invalid response format: missing files array');
}
if (data.files.length > 0) {
// Sort files by name
const sortedFiles = [...data.files].sort((a, b) => a.name.localeCompare(b.name));
fileList.innerHTML = sortedFiles.map(file => {
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
const displayName = file.original_name || file.name;
const isRenamed = file.original_name && file.original_name !== file.name;
return `
<div style="display: flex; justify-content: space-between; padding: 3px 0; border-bottom: 1px solid #2a2a2a;">
<div style="flex: 1; min-width: 0;">
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${displayName}">
${displayName}
${isRenamed ? `<div style="font-size: 0.8em; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="Stored as: ${file.name}">${file.name}</div>` : ''}
</div>
</div>
<span style="color: #888; white-space: nowrap; margin-left: 10px;">${sizeMB} MB</span>
</div>
`;
}).join('');
} else {
fileList.innerHTML = '<div style="padding: 5px 0; color: #888; font-style: italic;">No files uploaded yet</div>';
}
// Update quota display if available
if (data.quota !== 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;
bar.value = data.quota;
bar.max = 100;
text.textContent = `${data.quota.toFixed(1)} MB used`;
}
}
} catch (error) {
const errorMessage = `Error loading file list: ${error.message || 'Unknown error'}`;
console.error('[ERROR]', errorMessage, error);
showErrorInUI(errorMessage, fileList);
}
// Helper function to show error messages in the UI
function showErrorInUI(message, targetElement = null) {
const errorHtml = `
<div style="
padding: 10px;
margin: 5px 0;
background: #2a0f0f;
border-left: 3px solid #f55;
color: #ff9999;
font-family: monospace;
font-size: 0.9em;
white-space: pre-wrap;
word-break: break-word;
">
<div style="font-weight: bold; color: #f55;">Error loading files</div>
<div style="margin-top: 5px;">${message}</div>
<div style="margin-top: 10px; font-size: 0.8em; color: #888;">
Check browser console for details
</div>
</div>
`;
if (targetElement) {
targetElement.innerHTML = errorHtml;
} else {
// If no target element, try to find it
const fileList = document.getElementById('file-list');
if (fileList) fileList.innerHTML = errorHtml;
}
}
}
// Export functions for use in other modules
window.upload = upload;
window.fetchAndDisplayFiles = fetchAndDisplayFiles;
if (dropzone && fileInput) {
dropzone.addEventListener("click", () => {

View File

@ -88,12 +88,22 @@ async def upload(request: Request, db = Depends(get_db), uid: str = Form(...), f
stream_path = user_dir / "stream.opus"
shutil.copy2(processed_path, stream_path)
db.add(UploadLog(
# Create a log entry with the original filename
log = UploadLog(
uid=uid,
ip=request.client.host,
filename=file.filename,
filename=file.filename, # Store original filename
processed_filename=unique_name, # Store the processed filename
size_bytes=original_size
))
)
db.add(log)
db.commit()
db.refresh(log)
# Rename the processed file to include the log ID for better tracking
processed_with_id = user_dir / f"{log.id}_{unique_name}"
processed_path.rename(processed_with_id)
processed_path = processed_with_id
# Store updated quota
size = processed_path.stat().st_size