588 lines
32 KiB
HTML
588 lines
32 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="description" content="StreamDock - Web-basierter Stream-Player und Recorder">
|
|
<meta name="theme-color" content="#1a1a2e">
|
|
|
|
<!-- PWA -->
|
|
<link rel="manifest" href="/manifest.json">
|
|
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
|
|
<link rel="apple-touch-icon" href="/static/img/icon-192.png">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
|
|
<title>StreamDock</title>
|
|
<link rel="stylesheet" href="/static/css/style.css">
|
|
</head>
|
|
<body x-data="app()" x-init="init()">
|
|
<!-- Navigation -->
|
|
<nav class="navbar" x-show="isAuthenticated">
|
|
<div class="navbar-brand">
|
|
<img src="/static/img/favicon.svg" alt="StreamDock" class="navbar-logo">
|
|
<span class="navbar-title">StreamDock</span>
|
|
</div>
|
|
|
|
<button class="navbar-toggle" @click="menuOpen = !menuOpen" aria-label="Menü">
|
|
<span></span><span></span><span></span>
|
|
</button>
|
|
|
|
<div class="navbar-menu" :class="{ 'is-active': menuOpen }">
|
|
<a href="#" @click.prevent="navigate('dashboard')" :class="{ active: currentView === 'dashboard' }">Dashboard</a>
|
|
<a href="#" @click.prevent="navigate('streams')" :class="{ active: currentView === 'streams' }">Streams</a>
|
|
<a href="#" @click.prevent="navigate('radio')" :class="{ active: currentView === 'radio' }">Radio-Browser</a>
|
|
<a href="#" @click.prevent="navigate('recordings')" :class="{ active: currentView === 'recordings' }">Aufnahmen</a>
|
|
<a href="#" @click.prevent="navigate('schedules')" :class="{ active: currentView === 'schedules' }">Zeitplan</a>
|
|
<a href="#" @click.prevent="navigate('library')" :class="{ active: currentView === 'library' }">Bibliothek</a>
|
|
<a href="#" @click.prevent="navigate('settings')" :class="{ active: currentView === 'settings' }">Einstellungen</a>
|
|
<template x-if="user && user.role === 'admin'">
|
|
<a href="#" @click.prevent="navigate('admin')" :class="{ active: currentView === 'admin' }">Admin</a>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="navbar-user">
|
|
<img :src="user?.avatar_path || '/static/img/default-avatar.svg'" alt="Avatar" class="avatar-small">
|
|
<span x-text="user?.username"></span>
|
|
<button @click="logout()" class="btn btn-sm btn-ghost">Logout</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Login -->
|
|
<div class="login-container" x-show="!isAuthenticated">
|
|
<div class="login-card">
|
|
<img src="/static/img/favicon.svg" alt="StreamDock" class="login-logo">
|
|
<h1>StreamDock</h1>
|
|
<form @submit.prevent="login()">
|
|
<div class="form-group">
|
|
<label for="username">Benutzername</label>
|
|
<input type="text" id="username" x-model="loginForm.username" required autocomplete="username">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="password">Passwort</label>
|
|
<input type="password" id="password" x-model="loginForm.password" required autocomplete="current-password">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary btn-block" :disabled="loginLoading">
|
|
<span x-show="!loginLoading">Anmelden</span>
|
|
<span x-show="loginLoading">Wird geladen...</span>
|
|
</button>
|
|
<p class="error-message" x-show="loginError" x-text="loginError"></p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hauptinhalt -->
|
|
<main class="main-content" x-show="isAuthenticated">
|
|
<!-- Dashboard -->
|
|
<div x-show="currentView === 'dashboard'" class="view">
|
|
<h2>Dashboard</h2>
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value" x-text="stats.streams">0</div>
|
|
<div class="stat-label">Streams</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" x-text="stats.recordings">0</div>
|
|
<div class="stat-label">Aufnahmen</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" x-text="stats.activeRecordings">0</div>
|
|
<div class="stat-label">Aktive Aufnahmen</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value" x-text="formatBytes(stats.storageUsed)">0 B</div>
|
|
<div class="stat-label">Speicher belegt</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Streams (Platzhalter für weitere Views) -->
|
|
<div x-show="currentView === 'streams'" class="view">
|
|
<div class="view-header">
|
|
<h2>Meine Streams</h2>
|
|
<button class="btn btn-primary" @click="showAddStream = true">+ Stream hinzufügen</button>
|
|
</div>
|
|
<div class="stream-list" x-show="streams.length > 0">
|
|
<template x-for="stream in streams" :key="stream.id">
|
|
<div class="stream-card">
|
|
<div class="stream-info">
|
|
<img :src="stream.logo_url || '/static/img/default-stream.svg'" class="stream-logo" alt="">
|
|
<div>
|
|
<h3 x-text="stream.name"></h3>
|
|
<span class="badge" x-text="stream.stream_type"></span>
|
|
<span class="badge badge-secondary" x-text="stream.genre" x-show="stream.genre"></span>
|
|
</div>
|
|
</div>
|
|
<div class="stream-actions">
|
|
<button class="btn btn-sm btn-primary" @click="playStream(stream)">▶ Abspielen</button>
|
|
<button class="btn btn-sm btn-warning" @click="recordStream(stream)">⏺ Aufnahme</button>
|
|
<button class="btn btn-sm btn-ghost" @click="editStream(stream)">✏️</button>
|
|
<button class="btn btn-sm btn-danger" @click="deleteStream(stream)">🗑</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="empty-state" x-show="streams.length === 0">
|
|
<p>Noch keine Streams hinzugefügt. Starte mit dem Radio-Browser oder füge manuell einen Stream hinzu.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stream-Modal -->
|
|
<div class="modal-overlay" x-show="showAddStream" @click.self="showAddStream = false; editingStream = null; streamForm = { name: '', url: '', stream_type: 'audio', genre: '', description: '', logo_url: '' }">
|
|
<div class="modal">
|
|
<h3 x-text="editingStream ? 'Stream bearbeiten' : 'Stream hinzufügen'"></h3>
|
|
<form @submit.prevent="editingStream ? updateStream() : addStream()">
|
|
<div class="form-group">
|
|
<label>Name</label>
|
|
<input type="text" x-model="streamForm.name" placeholder="Mein Stream" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>URL</label>
|
|
<input type="url" x-model="streamForm.url" placeholder="https://stream.example.com/live" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Typ</label>
|
|
<select x-model="streamForm.stream_type">
|
|
<option value="audio">Audio</option>
|
|
<option value="video">Video</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Genre</label>
|
|
<input type="text" x-model="streamForm.genre" placeholder="Rock, Pop, Jazz...">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Beschreibung</label>
|
|
<input type="text" x-model="streamForm.description" placeholder="Optionale Beschreibung">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Logo-URL</label>
|
|
<input type="url" x-model="streamForm.logo_url" placeholder="https://example.com/logo.png">
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-ghost" @click="showAddStream = false; editingStream = null; streamForm = { name: '', url: '', stream_type: 'audio', genre: '', description: '', logo_url: '' }">Abbrechen</button>
|
|
<button type="submit" class="btn btn-primary" x-text="editingStream ? 'Speichern' : 'Hinzufügen'"></button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Radio-Browser -->
|
|
<div x-show="currentView === 'radio'" class="view">
|
|
<h2>Radio-Browser</h2>
|
|
<div class="search-bar">
|
|
<input type="text" placeholder="Sender suchen..." x-model="radioSearch" @keyup.enter="searchRadio()">
|
|
<select x-model="radioCountry">
|
|
<option value="">Alle Länder</option>
|
|
<option value="Germany">Deutschland</option>
|
|
<option value="Austria">Österreich</option>
|
|
<option value="Switzerland">Schweiz</option>
|
|
<option value="United Kingdom">UK</option>
|
|
<option value="United States">USA</option>
|
|
</select>
|
|
<button class="btn btn-primary" @click="searchRadio()">Suchen</button>
|
|
</div>
|
|
<div class="stream-list">
|
|
<template x-for="station in radioStations" :key="station.stationuuid">
|
|
<div class="stream-card">
|
|
<div class="stream-info">
|
|
<img :src="station.favicon || '/static/img/default-stream.svg'" class="stream-logo" alt="" @error="$el.src='/static/img/default-stream.svg'">
|
|
<div>
|
|
<h3 x-text="station.name"></h3>
|
|
<small x-text="station.country + ' · ' + station.codec + ' · ' + station.bitrate + ' kbps'"></small>
|
|
</div>
|
|
</div>
|
|
<div class="stream-actions">
|
|
<button class="btn btn-sm btn-primary" @click="playRadioStation(station)">▶</button>
|
|
<button class="btn btn-sm btn-success" @click="addRadioStation(station)">+ Hinzufügen</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Aufnahmen -->
|
|
<div x-show="currentView === 'recordings'" class="view">
|
|
<h2>Aufnahmen</h2>
|
|
<div class="recording-list">
|
|
<template x-for="rec in recordings" :key="rec.id">
|
|
<div class="recording-card">
|
|
<div class="recording-info">
|
|
<h3 x-text="rec.stream_name"></h3>
|
|
<small x-text="formatDate(rec.started_at) + ' · ' + formatBytes(rec.file_size)"></small>
|
|
<span class="badge" :class="'badge-' + rec.status" x-text="rec.status"></span>
|
|
</div>
|
|
<div class="recording-actions">
|
|
<button class="btn btn-sm btn-primary" @click="playRecording(rec)" x-show="rec.status === 'completed'">▶</button>
|
|
<button class="btn btn-sm btn-ghost" @click="downloadRecording(rec)" x-show="rec.status === 'completed'">⬇</button>
|
|
<button class="btn btn-sm btn-ghost" @click="shareRecording(rec)" x-show="rec.status === 'completed'">🔗</button>
|
|
<button class="btn btn-sm btn-ghost" @click="sendToPlik(rec)" x-show="rec.status === 'completed'">📤 Plik</button>
|
|
<button class="btn btn-sm btn-danger" @click="stopRecording(rec)" x-show="rec.status === 'recording'">⏹ Stop</button>
|
|
<button class="btn btn-sm btn-danger" @click="deleteRecording(rec)">🗑</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zeitplan -->
|
|
<div x-show="currentView === 'schedules'" class="view">
|
|
<div class="view-header">
|
|
<h2>Zeitplan</h2>
|
|
<button class="btn btn-primary" @click="showAddSchedule = true">+ Zeitplan erstellen</button>
|
|
</div>
|
|
<div class="schedule-list" x-show="schedules.length > 0">
|
|
<template x-for="sched in schedules" :key="sched.id">
|
|
<div class="schedule-card">
|
|
<div class="schedule-info">
|
|
<h3 x-text="getStreamName(sched.stream_id)"></h3>
|
|
<div class="schedule-meta">
|
|
<span x-show="sched.is_recurring">🔄 Cron: <code x-text="sched.cron_expr"></code></span>
|
|
<span x-show="!sched.is_recurring">📅 <span x-text="formatDate(sched.start_time)"></span></span>
|
|
<span>⏱ <span x-text="Math.round(sched.duration / 60)"></span> Min.</span>
|
|
<span class="badge" :class="sched.is_active ? 'badge-success' : 'badge-secondary'" x-text="sched.is_active ? 'Aktiv' : 'Inaktiv'"></span>
|
|
</div>
|
|
</div>
|
|
<div class="schedule-actions">
|
|
<button class="btn btn-sm btn-ghost" @click="toggleSchedule(sched)">
|
|
<span x-text="sched.is_active ? '⏸ Pause' : '▶ Aktivieren'"></span>
|
|
</button>
|
|
<button class="btn btn-sm btn-danger" @click="deleteSchedule(sched)">🗑</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="empty-state" x-show="schedules.length === 0">
|
|
<p>Keine geplanten Aufnahmen. Erstelle einen Zeitplan, um automatisch aufzunehmen.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Zeitplan-Modal -->
|
|
<div class="modal-overlay" x-show="showAddSchedule" @click.self="showAddSchedule = false">
|
|
<div class="modal">
|
|
<h3>Neue geplante Aufnahme</h3>
|
|
<form @submit.prevent="createSchedule()">
|
|
<div class="form-group">
|
|
<label>Stream</label>
|
|
<select x-model="scheduleForm.stream_id" required>
|
|
<option value="">— Stream wählen —</option>
|
|
<template x-for="s in streams" :key="s.id">
|
|
<option :value="s.id" x-text="s.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Dauer (Minuten)</label>
|
|
<input type="number" x-model.number="scheduleForm.duration_min" min="1" max="480" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" x-model="scheduleForm.is_recurring"> Wiederkehrend (Cron)</label>
|
|
</div>
|
|
<div class="form-group" x-show="scheduleForm.is_recurring">
|
|
<label>Cron-Ausdruck (Sek Min Std Tag Mon Wtag)</label>
|
|
<input type="text" x-model="scheduleForm.cron_expr" placeholder="0 0 20 * * 1-5">
|
|
<small>Beispiel: <code>0 0 20 * * 1-5</code> = Mo-Fr um 20:00</small>
|
|
</div>
|
|
<div class="form-group" x-show="!scheduleForm.is_recurring">
|
|
<label>Startzeit</label>
|
|
<input type="datetime-local" x-model="scheduleForm.start_time">
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-ghost" @click="showAddSchedule = false">Abbrechen</button>
|
|
<button type="submit" class="btn btn-primary">Erstellen</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bibliothek -->
|
|
<div x-show="currentView === 'library'" class="view">
|
|
<h2>Bibliothek</h2>
|
|
<div class="search-bar">
|
|
<input type="text" placeholder="In Bibliothek suchen..." x-model="librarySearch" @keyup.enter="searchLibrary()">
|
|
<button class="btn btn-primary" @click="searchLibrary()">Suchen</button>
|
|
</div>
|
|
<div x-show="libraryResults">
|
|
<h3 x-show="libraryResults?.streams?.length > 0">Streams</h3>
|
|
<div class="stream-list">
|
|
<template x-for="s in (libraryResults?.streams || [])" :key="'ls-' + s.id">
|
|
<div class="stream-card compact">
|
|
<span x-text="s.name"></span>
|
|
<button class="btn btn-sm btn-primary" @click="playStream(s)">▶</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<h3 x-show="libraryResults?.recordings?.length > 0">Aufnahmen</h3>
|
|
<div class="recording-list">
|
|
<template x-for="r in (libraryResults?.recordings || [])" :key="'lr-' + r.id">
|
|
<div class="recording-card compact">
|
|
<span x-text="r.stream_name + ' · ' + formatDate(r.started_at)"></span>
|
|
<button class="btn btn-sm btn-primary" @click="playRecording(r)">▶</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Einstellungen -->
|
|
<div x-show="currentView === 'settings'" class="view">
|
|
<h2>Einstellungen</h2>
|
|
|
|
<!-- Profil -->
|
|
<div class="settings-section">
|
|
<h3>Profil</h3>
|
|
<form @submit.prevent="updateProfile()">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Benutzername</label>
|
|
<input type="text" x-model="profileForm.username">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>E-Mail</label>
|
|
<input type="email" x-model="profileForm.email">
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">Profil aktualisieren</button>
|
|
</form>
|
|
|
|
<form @submit.prevent="uploadAvatar()" enctype="multipart/form-data" class="mt-1">
|
|
<div class="form-group">
|
|
<label>Profilbild</label>
|
|
<input type="file" accept="image/*" @change="avatarFile = $event.target.files[0]">
|
|
</div>
|
|
<button type="submit" class="btn btn-sm btn-ghost" :disabled="!avatarFile">Bild hochladen</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Passwort -->
|
|
<div class="settings-section">
|
|
<h3>Passwort ändern</h3>
|
|
<form @submit.prevent="changePassword()">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Altes Passwort</label>
|
|
<input type="password" x-model="passwordForm.old_password" autocomplete="current-password">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Neues Passwort</label>
|
|
<input type="password" x-model="passwordForm.new_password" minlength="6" autocomplete="new-password">
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">Passwort ändern</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Last.fm -->
|
|
<div class="settings-section">
|
|
<h3>Last.fm Scrobbling</h3>
|
|
<div x-show="userSettings.lastfm_username">
|
|
<p>Verbunden als: <strong x-text="userSettings.lastfm_username"></strong></p>
|
|
<button class="btn btn-sm btn-danger" @click="disconnectLastFM()">Trennen</button>
|
|
</div>
|
|
<div x-show="!userSettings.lastfm_username">
|
|
<p>Nicht verbunden. Tracks werden nach 10 Sekunden Wiedergabe gescrobbelt.</p>
|
|
<button class="btn btn-primary" @click="connectLastFM()">Mit Last.fm verbinden</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plik -->
|
|
<div class="settings-section">
|
|
<h3>Plik Integration</h3>
|
|
<form @submit.prevent="saveSettings()">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Plik Server URL</label>
|
|
<input type="url" x-model="userSettings.plik_url" placeholder="https://plik.example.com">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Plik API Key</label>
|
|
<input type="password" x-model="plikApiKeyInput" placeholder="API-Key eingeben">
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Benachrichtigungen -->
|
|
<div class="settings-section">
|
|
<h3>Benachrichtigungen</h3>
|
|
<form @submit.prevent="saveSettings()">
|
|
<div class="form-group">
|
|
<label><input type="checkbox" x-model="userSettings.notify_on_login"> Bei Login benachrichtigen</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" x-model="userSettings.notify_on_rec_start"> Bei Aufnahme-Start</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" x-model="userSettings.notify_on_rec_end"> Bei Aufnahme-Ende</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" x-model="userSettings.notify_on_rec_error"> Bei Aufnahme-Fehler</label>
|
|
</div>
|
|
|
|
<h4>Kanäle</h4>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" x-model="userSettings.notify_email"> E-Mail</label>
|
|
<input type="email" x-model="userSettings.notify_email_addr" placeholder="empfaenger@example.com" x-show="userSettings.notify_email">
|
|
</div>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" x-model="userSettings.notify_webhook"> Webhook</label>
|
|
<input type="url" x-model="userSettings.notify_webhook_url" placeholder="https://example.com/webhook" x-show="userSettings.notify_webhook">
|
|
</div>
|
|
<div class="form-group">
|
|
<label><input type="checkbox" x-model="userSettings.notify_ntfy"> Ntfy</label>
|
|
<div x-show="userSettings.notify_ntfy" class="form-row">
|
|
<input type="text" x-model="userSettings.notify_ntfy_topic" placeholder="Topic">
|
|
<input type="url" x-model="userSettings.notify_ntfy_server" placeholder="Server (leer = ntfy.sh)">
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-primary">Benachrichtigungen speichern</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin -->
|
|
<div x-show="currentView === 'admin'" class="view">
|
|
<div class="view-header">
|
|
<h2>Administration</h2>
|
|
<button class="btn btn-primary" @click="showAddUser = true">+ Benutzer erstellen</button>
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Benutzername</th>
|
|
<th>E-Mail</th>
|
|
<th>Rolle</th>
|
|
<th>Speicher</th>
|
|
<th>Erstellt</th>
|
|
<th>Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="u in adminUsers" :key="u.id">
|
|
<tr>
|
|
<td x-text="u.id"></td>
|
|
<td x-text="u.username"></td>
|
|
<td x-text="u.email"></td>
|
|
<td><span class="badge" :class="u.role === 'admin' ? 'badge-warning' : ''" x-text="u.role"></span></td>
|
|
<td x-text="formatBytes(u.storage_used) + ' / ' + (u.storage_quota > 0 ? formatBytes(u.storage_quota) : '∞')"></td>
|
|
<td x-text="formatDate(u.created_at)"></td>
|
|
<td>
|
|
<button class="btn btn-sm btn-ghost" @click="editUser(u)">✏️</button>
|
|
<button class="btn btn-sm btn-danger" @click="deleteUser(u)" x-show="u.id !== user.id">🗑</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Benutzer erstellen Modal -->
|
|
<div class="modal-overlay" x-show="showAddUser" @click.self="showAddUser = false">
|
|
<div class="modal">
|
|
<h3 x-text="editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'"></h3>
|
|
<form @submit.prevent="editingUser ? updateUser() : createUser()">
|
|
<div class="form-group">
|
|
<label>Benutzername</label>
|
|
<input type="text" x-model="userForm.username" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>E-Mail</label>
|
|
<input type="email" x-model="userForm.email" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Passwort <span x-show="editingUser">(leer = unverändert)</span></label>
|
|
<input type="password" x-model="userForm.password" :required="!editingUser" autocomplete="new-password">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Rolle</label>
|
|
<select x-model="userForm.role">
|
|
<option value="user">User</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Speicher-Kontingent (MB, 0 = unbegrenzt)</label>
|
|
<input type="number" x-model.number="userForm.storage_quota_mb" min="0">
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="button" class="btn btn-ghost" @click="showAddUser = false; editingUser = null">Abbrechen</button>
|
|
<button type="submit" class="btn btn-primary" x-text="editingUser ? 'Speichern' : 'Erstellen'"></button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Player-Leiste (fixiert am unteren Rand) -->
|
|
<div class="player-bar" x-show="isAuthenticated && player.active" @wheel.prevent="handlePlayerWheel($event)">
|
|
<div class="player-info">
|
|
<img :src="player.logo || '/static/img/default-stream.svg'" class="player-logo" alt="">
|
|
<div class="player-text">
|
|
<div class="player-title" x-text="player.title || player.name"></div>
|
|
<div class="player-artist" x-text="player.artist || ''"></div>
|
|
<div class="player-no-metadata" x-show="player.playing && _streamHasMetadata === false" style="font-size: 0.7rem; color: #f59e0b; margin-top: 2px;">⚠ Kein Titelupdate verfügbar</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="player-controls">
|
|
<button class="btn btn-icon" @click="playerToggle()">
|
|
<span x-text="player.playing ? '⏸' : '▶'"></span>
|
|
</button>
|
|
<button class="btn btn-icon" @click="playerStop()">⏹</button>
|
|
<span class="listen-timer" x-text="player.listenTime" title="Hörzeit"></span>
|
|
<input type="range" min="0" max="100" x-model="player.volume" @input="setVolume()" class="volume-slider">
|
|
<span class="volume-label" x-text="player.volume + '%'"></span>
|
|
</div>
|
|
|
|
<div class="player-extras">
|
|
<button class="btn btn-sm btn-ghost" @click="toggleEqualizer()" :class="{ active: showEqualizer }">EQ</button>
|
|
<button class="btn btn-sm btn-ghost" @click="toggleVisualizer()" :class="{ active: showVisualizer }">VIS</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Equalizer Panel -->
|
|
<div class="equalizer-panel" x-show="showEqualizer" x-transition>
|
|
<h3>Equalizer</h3>
|
|
<div class="eq-sliders">
|
|
<template x-for="(band, index) in eqBands" :key="index">
|
|
<div class="eq-band">
|
|
<input type="range" min="-12" max="12" step="0.5" x-model="band.gain" @input="updateEQ(index)" class="eq-slider" orient="vertical">
|
|
<label x-text="band.label"></label>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<div class="eq-presets">
|
|
<button class="btn btn-sm" @click="eqPreset('flat')">Flat</button>
|
|
<button class="btn btn-sm" @click="eqPreset('bass')">Bass</button>
|
|
<button class="btn btn-sm" @click="eqPreset('rock')">Rock</button>
|
|
<button class="btn btn-sm" @click="eqPreset('vocal')">Vocal</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Audio Visualizer -->
|
|
<div class="visualizer-container" x-show="showVisualizer" x-transition>
|
|
<canvas id="visualizer" width="800" height="200"></canvas>
|
|
</div>
|
|
|
|
<!-- Audio/Video Element (versteckt) -->
|
|
<audio id="audioPlayer" crossorigin="anonymous"></audio>
|
|
<div class="video-overlay" x-show="player.type === 'video' && player.active">
|
|
<video id="videoPlayer" controls></video>
|
|
<button class="btn btn-icon video-close" @click="playerStop()">✕</button>
|
|
</div>
|
|
|
|
<!-- Alpine.js (CDN) -->
|
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
|
<!-- HLS.js für Video-Streams -->
|
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
|
<!-- App -->
|
|
<script src="/static/js/app.js"></script>
|
|
<script src="/static/js/player.js"></script>
|
|
</body>
|
|
</html>
|