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:
93
DATABASE.md
Normal file
93
DATABASE.md
Normal 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
98
account_router.py
Normal 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
140
alembic.ini
Normal 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
1
alembic/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
61
alembic/env.py
Normal file
61
alembic/env.py
Normal 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
28
alembic/script.py.mako
Normal 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"}
|
86
alembic/versions/1ab2db0e4b5e_make_username_unique.py
Normal file
86
alembic/versions/1ab2db0e4b5e_make_username_unique.py
Normal 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 ###
|
@ -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
73
auth.py
Normal 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
106
auth_router.py
Normal 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
22
dicta2stream.service
Normal 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
35
gunicorn_config.py
Normal 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
36
init_db.py
Normal 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()
|
132
list_streams.py
132
list_streams.py
@ -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")
|
||||
|
53
magic.py
53
magic.py
@ -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
146
main.py
@ -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
73
middleware.py
Normal 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
|
67
migrations/0002_add_session_tables.py
Normal file
67
migrations/0002_add_session_tables.py
Normal 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')
|
24
migrations/add_processed_filename_to_uploadlog.py
Normal file
24
migrations/add_processed_filename_to_uploadlog.py
Normal 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')
|
16
models.py
16
models.py
@ -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)
|
||||
|
@ -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
29
run_migrations.py
Normal 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()
|
790
static/app.js
790
static/app.js
@ -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
208
static/css/base.css
Normal 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;
|
||||
}
|
0
static/css/components/buttons.css
Normal file
0
static/css/components/buttons.css
Normal file
0
static/css/components/forms.css
Normal file
0
static/css/components/forms.css
Normal file
80
static/css/layout/footer.css
Normal file
80
static/css/layout/footer.css
Normal 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;
|
||||
}
|
||||
}
|
149
static/css/layout/header.css
Normal file
149
static/css/layout/header.css
Normal 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);
|
||||
}
|
||||
}
|
0
static/css/pages/auth.css
Normal file
0
static/css/pages/auth.css
Normal file
0
static/css/pages/home.css
Normal file
0
static/css/pages/home.css
Normal file
0
static/css/pages/stream.css
Normal file
0
static/css/pages/stream.css
Normal file
0
static/css/utilities/spacing.css
Normal file
0
static/css/utilities/spacing.css
Normal file
0
static/css/utilities/typography.css
Normal file
0
static/css/utilities/typography.css
Normal 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
97
static/desktop.css
Normal 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
12
static/footer.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!-- Footer content -->
|
||||
<footer>
|
||||
<p>Built for public voice streaming • Opus | Mono | 48 kHz | 60 kbps</p>
|
||||
<p class="footer-hint">Need more space? Contact <a href="mailto:Andreas.Fleckl@dicta2stream.net">Andreas.Fleckl@dicta2stream.net</a></p>
|
||||
<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
13
static/generate-test-audio.sh
Executable 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
|
@ -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>You’ll 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
437
static/inject-nav.js
Normal 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;
|
@ -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
305
static/mobile.css
Normal 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;
|
||||
}
|
||||
}
|
@ -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");
|
||||
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
682
static/style.css
682
static/style.css
@ -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);
|
||||
}
|
||||
|
222
static/test-audio-player.html
Normal file
222
static/test-audio-player.html
Normal 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
192
static/test-audio.html
Normal 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
BIN
static/test-audio.opus
Normal file
Binary file not shown.
125
static/upload.js
125
static/upload.js
@ -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", () => {
|
||||
|
16
upload.py
16
upload.py
@ -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
|
||||
|
Reference in New Issue
Block a user