diff --git a/CHANGELOG.md b/CHANGELOG.md index 341f915..36c735d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ Alle relevanten Aenderungen am VideoKonverter-Projekt. +## [4.0.1] - 2026-03-01 + +### TV-App: UX-Verbesserungen & Bugfixes + +#### Neue Features +- **Alphabet-Seitenleiste**: Vertikale A-Z Sidebar auf Serien-/Filme-Seite zum Filtern nach Anfangsbuchstabe + - Buchstaben ohne Treffer automatisch abgedunkelt + - Wird in Ordner-Ansicht versteckt + - Responsive fuer Handy/Tablet +- **Genre-Select statt Chips**: Genre-Filter als Dropdown-Element (uebersichtlicher bei vielen Genres) +- **Player-Buttons**: Separate Symbole fuer Audio (Lautsprecher-SVG), Untertitel (CC-Badge), Qualitaet (HD-Badge) + - CC-Button leuchtet wenn Untertitel aktiv, Quality-Badge zeigt aktuellen Modus (4K/HD/SD/LD) + - Klick oeffnet Overlay direkt bei der entsprechenden Sektion +- **Gesehen-Markierung**: Buttons fuer "Episode gesehen" und "Staffel gesehen" in Serien-Detail +- **Batch-Thumbnails**: Neuer Button "Thumbnails" in der Bibliothek generiert alle fehlenden Episoden-Thumbnails im Hintergrund per ffmpeg +- **Redundanz-Markierung**: Duplikate in der Episoden-Tabelle werden jetzt orange markiert mit "REDUNDANT"-Badge + - Ranking: Neuerer Codec > kleinere Datei +- **Rating-Sortierung**: Serien/Filme nach Bewertung sortierbar + Min-Rating-Filter + +#### Bugfixes +- **tvdb_episode_cache**: Fehlende Spalten `overview` und `image_url` hinzugefuegt (Episoden-Beschreibungen funktionierten nicht) +- **Login-Form Flash**: Auto-Fill-Erkennung statt hartem Timeout (prueft 5x alle 200ms ob Browser Felder ausgefuellt hat) +- **Profil-Wechsel**: Zeigt jetzt alle User an (nicht nur die mit aktiver Session) +- **Debug-Prints entfernt**: Bereinigung aus server.py und tv_api.py +- **Route-Registrierung**: TV-API-Routen in `_setup_app()` verschoben (verhinderte 500-Fehler) + +#### Neue API-Endpunkte +- `POST /api/library/generate-thumbnails` - Batch-Thumbnail-Generierung starten +- `GET /api/library/thumbnail-status` - Thumbnail-Fortschritt abfragen + +#### Geaenderte Dateien (19 Dateien, +821/-122 Zeilen) +- `app/routes/library_api.py` - Batch-Thumbnails + aiomysql Import +- `app/routes/tv_api.py` - Gesehen-Status, Rating-Filter, Genre-Select +- `app/server.py` - Route-Registrierung Fix +- `app/services/auth.py` - Watch-Status DB-Methoden +- `app/services/library.py` - tvdb_episode_cache Spalten-Fix + Migration +- `app/static/css/style.css` - Redundanz-Zeilen-Style +- `app/static/js/library.js` - Redundanz-Erkennung, Batch-Thumbnails +- `app/static/tv/css/tv.css` - Player-Badges, Alphabet-Sidebar, Rating-Styles +- `app/static/tv/i18n/de.json` + `en.json` - Rating-Uebersetzungen +- `app/static/tv/js/player.js` - Overlay-Sections, Button-Updates +- `app/static/tv/js/tv.js` - Gesehen-Buttons, Alphabet-Filter +- `app/templates/library.html` - Thumbnails-Button +- `app/templates/tv/login.html` - Auto-Fill-Erkennung +- `app/templates/tv/movies.html` - Alphabet-Sidebar, data-letter +- `app/templates/tv/player.html` - Audio/CC/Quality-Buttons +- `app/templates/tv/profiles.html` - Alle User anzeigen +- `app/templates/tv/series.html` - Alphabet-Sidebar, data-letter +- `app/templates/tv/series_detail.html` - Gesehen-Buttons, Episoden-Beschreibungen + +--- + ## [4.0.0] - 2026-03-01 ### TV-App: Vollwertiger Streaming-Client diff --git a/docker-exports/videoconverter-4.0.1.tar b/docker-exports/videoconverter-4.0.1.tar new file mode 100644 index 0000000..9ce2121 Binary files /dev/null and b/docker-exports/videoconverter-4.0.1.tar differ diff --git a/video-konverter/app/routes/library_api.py b/video-konverter/app/routes/library_api.py index 0d48ad0..734c10c 100644 --- a/video-konverter/app/routes/library_api.py +++ b/video-konverter/app/routes/library_api.py @@ -1,6 +1,7 @@ """REST API Endpoints fuer die Video-Bibliothek""" import asyncio import logging +import aiomysql from aiohttp import web from app.config import Config from app.services.library import LibraryService @@ -1696,6 +1697,147 @@ def setup_library_routes(app: web.Application, config: Config, logging.error(f"Thumbnail-Fehler: {e}") return web.json_response({"error": str(e)}, status=500) + # === Batch-Thumbnail-Generierung === + + _thumbnail_task = None # Hintergrund-Task fuer Batch-Generierung + + async def post_generate_thumbnails(request: web.Request) -> web.Response: + """POST /api/library/generate-thumbnails + Generiert fehlende Thumbnails fuer alle Videos im Hintergrund. + Optional: ?series_id=123 fuer nur eine Serie.""" + import os + import asyncio as _asyncio + nonlocal _thumbnail_task + + # Laeuft bereits? + if _thumbnail_task and not _thumbnail_task.done(): + return web.json_response({ + "status": "running", + "message": "Thumbnail-Generierung laeuft bereits" + }) + + pool = await library_service._get_pool() + if not pool: + return web.json_response( + {"error": "Keine DB-Verbindung"}, status=500) + + series_id = request.query.get("series_id") + + async def _generate_batch(): + """Hintergrund-Task: Fehlende Thumbnails erzeugen.""" + generated = 0 + errors = 0 + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Videos ohne Thumbnail finden + sql = """ + SELECT v.id, v.file_path, v.duration_sec + FROM library_videos v + LEFT JOIN tv_episode_thumbnails t + ON v.id = t.video_id + WHERE t.video_id IS NULL + """ + params = [] + if series_id: + sql += " AND v.series_id = %s" + params.append(int(series_id)) + sql += " ORDER BY v.id" + await cur.execute(sql, params) + videos = await cur.fetchall() + + logging.info( + f"Thumbnail-Batch: {len(videos)} Videos ohne Thumbnail" + ) + + for video in videos: + vid = video["id"] + fp = video["file_path"] + dur = video.get("duration_sec") or 0 + + if not os.path.isfile(fp): + continue + + seek = dur * 0.25 if dur > 10 else 5 + vdir = os.path.dirname(fp) + tdir = os.path.join(vdir, ".metadata", "thumbnails") + os.makedirs(tdir, exist_ok=True) + tpath = os.path.join(tdir, f"{vid}.jpg") + + cmd = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", + "-ss", str(int(seek)), + "-i", fp, + "-vframes", "1", "-q:v", "5", + "-vf", "scale=480:-1", + "-y", tpath, + ] + try: + proc = await _asyncio.create_subprocess_exec( + *cmd, + stdout=_asyncio.subprocess.PIPE, + stderr=_asyncio.subprocess.PIPE, + ) + await proc.communicate() + + if proc.returncode == 0 and os.path.isfile(tpath): + async with pool.acquire() as conn2: + async with conn2.cursor() as cur2: + await cur2.execute(""" + INSERT INTO tv_episode_thumbnails + (video_id, thumbnail_path, source) + VALUES (%s, %s, 'ffmpeg') + ON DUPLICATE KEY UPDATE + thumbnail_path = VALUES(thumbnail_path) + """, (vid, tpath)) + generated += 1 + else: + errors += 1 + except Exception as e: + logging.warning(f"Thumbnail-Fehler Video {vid}: {e}") + errors += 1 + + logging.info( + f"Thumbnail-Batch fertig: {generated} erzeugt, " + f"{errors} Fehler" + ) + except Exception as e: + logging.error(f"Thumbnail-Batch Fehler: {e}") + + import asyncio + _thumbnail_task = asyncio.ensure_future(_generate_batch()) + + return web.json_response({ + "status": "started", + "message": "Thumbnail-Generierung gestartet" + }) + + async def get_thumbnail_status(request: web.Request) -> web.Response: + """GET /api/library/thumbnail-status + Zeigt Fortschritt der Thumbnail-Generierung.""" + pool = await library_service._get_pool() + if not pool: + return web.json_response( + {"error": "Keine DB-Verbindung"}, status=500) + + running = bool(_thumbnail_task and not _thumbnail_task.done()) + + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT COUNT(*) AS cnt FROM tv_episode_thumbnails") + done = (await cur.fetchone())["cnt"] + await cur.execute( + "SELECT COUNT(*) AS cnt FROM library_videos") + total = (await cur.fetchone())["cnt"] + + return web.json_response({ + "running": running, + "generated": done, + "total": total, + "missing": total - done, + }) + # === Import: Item zuordnen / ueberspringen === async def post_reassign_import_item( @@ -2202,6 +2344,13 @@ def setup_library_routes(app: web.Application, config: Config, app.router.add_get( "/api/library/videos/{video_id}/thumbnail", get_video_thumbnail ) + # Batch-Thumbnails + app.router.add_post( + "/api/library/generate-thumbnails", post_generate_thumbnails + ) + app.router.add_get( + "/api/library/thumbnail-status", get_thumbnail_status + ) # TVDB Auto-Match (Review-Modus) app.router.add_post( "/api/library/tvdb-auto-match", post_tvdb_auto_match diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py index e097fd3..7c9a022 100644 --- a/video-konverter/app/routes/tv_api.py +++ b/video-konverter/app/routes/tv_api.py @@ -316,7 +316,7 @@ def setup_tv_routes(app: web.Application, config: Config, ) src_name = src_map.get(source_filter, "") if items: - folder_data.append({"name": src_name, "items": items}) + folder_data.append({"name": src_name, "entries": items}) else: for src in sources: items = sorted( @@ -326,7 +326,7 @@ def setup_tv_routes(app: web.Application, config: Config, ) if items: folder_data.append({ - "name": src["name"], "items": items}) + "name": src["name"], "entries": items}) # Serien ohne Quelle (Fallback) src_ids = {src["id"] for src in sources} orphans = sorted( @@ -335,7 +335,7 @@ def setup_tv_routes(app: web.Application, config: Config, key=lambda x: (x.get("folder_name") or "").lower() ) if orphans: - folder_data.append({"name": "Sonstige", "items": orphans}) + folder_data.append({"name": "Sonstige", "entries": orphans}) return aiohttp_jinja2.render_template( "tv/series.html", request, { @@ -554,7 +554,7 @@ def setup_tv_routes(app: web.Application, config: Config, ) src_name = src_map.get(source_filter, "") if items: - folder_data.append({"name": src_name, "items": items}) + folder_data.append({"name": src_name, "entries": items}) else: for src in sources: items = sorted( @@ -564,7 +564,7 @@ def setup_tv_routes(app: web.Application, config: Config, ) if items: folder_data.append({ - "name": src["name"], "items": items}) + "name": src["name"], "entries": items}) # Filme ohne Quelle (Fallback) src_ids = {src["id"] for src in sources} orphans = sorted( @@ -573,7 +573,7 @@ def setup_tv_routes(app: web.Application, config: Config, key=lambda x: (x.get("folder_name") or "").lower() ) if orphans: - folder_data.append({"name": "Sonstige", "items": orphans}) + folder_data.append({"name": "Sonstige", "entries": orphans}) return aiohttp_jinja2.render_template( "tv/movies.html", request, { @@ -929,36 +929,56 @@ def setup_tv_routes(app: web.Application, config: Config, # --- Profilauswahl (Multi-User Quick-Switch) --- async def get_profiles(request: web.Request) -> web.Response: - """GET /tv/profiles - Profilauswahl (wer schaut?)""" + """GET /tv/profiles - Profilauswahl (wer schaut?) + Zeigt alle User an. Aktuelle Session wird hervorgehoben.""" client_id = request.cookies.get("vk_client_id") - profiles = [] - if client_id: - profiles = await auth_service.get_client_profiles(client_id) - # Aktuelle Session herausfinden current_session = request.cookies.get("vk_session") + current_user_id = None + if current_session: + user = await auth_service.validate_session(current_session) + if user: + current_user_id = user.get("id") + + # Alle User laden (nicht nur die mit Sessions auf diesem Client) + all_users = await auth_service.get_all_users() return aiohttp_jinja2.render_template( "tv/profiles.html", request, { - "profiles": profiles, - "current_session": current_session, + "profiles": all_users, + "current_user_id": current_user_id, } ) async def post_switch_profile(request: web.Request) -> web.Response: - """POST /tv/switch-profile - Profil wechseln (Session-ID)""" + """POST /tv/switch-profile - Auf anderen User wechseln. + Erstellt neue Session fuer den gewaehlten User.""" data = await request.post() - session_id = data.get("session_id", "") - if not session_id: + user_id = data.get("user_id", "") + if not user_id: raise web.HTTPFound("/tv/profiles") - # Session validieren - user = await auth_service.validate_session(session_id) - if not user: + + # Client-ID ermitteln/erstellen + client_id = request.cookies.get("vk_client_id") + client_id = await auth_service.get_or_create_client(client_id) + + # Neue Session fuer den User erstellen + ua = request.headers.get("User-Agent", "") + session_id = await auth_service.create_session( + int(user_id), ua, client_id=client_id, persistent=True + ) + if not session_id: raise web.HTTPFound("/tv/login") + resp = web.HTTPFound("/tv/") resp.set_cookie( "vk_session", session_id, max_age=10 * 365 * 24 * 3600, httponly=True, samesite="Lax", path="/", ) + resp.set_cookie( + "vk_client_id", client_id, + max_age=10 * 365 * 24 * 3600, + httponly=True, samesite="Lax", path="/", + ) return resp # --- User-Einstellungen --- diff --git a/video-konverter/app/server.py b/video-konverter/app/server.py index acfb5b9..95670fa 100644 --- a/video-konverter/app/server.py +++ b/video-konverter/app/server.py @@ -53,8 +53,18 @@ class VideoKonverterServer: @web.middleware async def _no_cache_middleware(self, request: web.Request, handler) -> web.Response: - """Verhindert Browser-Caching fuer API-Responses""" - response = await handler(request) + """Verhindert Browser-Caching fuer API-Responses + Error-Logging""" + try: + response = await handler(request) + except web.HTTPException as he: + if he.status >= 500: + logging.error(f"HTTP {he.status} bei {request.method} {request.path}: {he.reason}", + exc_info=True) + raise + except Exception as e: + logging.error(f"Unbehandelte Ausnahme bei {request.method} {request.path}: {e}", + exc_info=True) + raise if request.path.startswith("/api/"): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" response.headers["Pragma"] = "no-cache" @@ -96,8 +106,14 @@ class VideoKonverterServer: # Seiten Routes setup_page_routes(self.app, self.config, self.queue_service) - # TV-App Routes (Auth-Service wird spaeter mit DB-Pool initialisiert) - self.auth_service = None + # TV-App Routes (Auth-Service, DB-Pool wird in on_startup gesetzt) + async def _lazy_pool(): + return self.library_service._db_pool + self.auth_service = AuthService(_lazy_pool) + setup_tv_routes( + self.app, self.config, + self.auth_service, self.library_service, + ) # Statische Dateien static_dir = Path(__file__).parent / "static" @@ -151,16 +167,9 @@ class VideoKonverterServer: await self.tvdb_service.init_db() await self.importer_service.init_db() - # TV-App Auth-Service initialisieren (braucht DB-Pool) + # TV-App Auth-Service: DB-Tabellen initialisieren (Pool kommt ueber lazy getter) if self.library_service._db_pool: - async def _get_pool(): - return self.library_service._db_pool - self.auth_service = AuthService(_get_pool) await self.auth_service.init_db() - setup_tv_routes( - self.app, self.config, - self.auth_service, self.library_service, - ) host = self.config.server_config.get("host", "0.0.0.0") port = self.config.server_config.get("port", 8080) diff --git a/video-konverter/app/services/auth.py b/video-konverter/app/services/auth.py index c68b697..84e2a2c 100644 --- a/video-konverter/app/services/auth.py +++ b/video-konverter/app/services/auth.py @@ -596,6 +596,20 @@ class AuthService: """, (client_id,)) return await cur.fetchall() + async def get_all_users(self) -> list[dict]: + """Alle User laden (fuer Profilauswahl)""" + pool = await self._get_pool() + if not pool: + return [] + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT id, username, display_name, avatar_color + FROM tv_users + ORDER BY id + """) + return await cur.fetchall() + # --- User-Einstellungen --- async def update_user_settings(self, user_id: int, diff --git a/video-konverter/app/services/library.py b/video-konverter/app/services/library.py index 901d450..c43f1b2 100644 --- a/video-konverter/app/services/library.py +++ b/video-konverter/app/services/library.py @@ -220,6 +220,8 @@ class LibraryService: episode_name VARCHAR(512), aired DATE NULL, runtime INT NULL, + overview TEXT NULL, + image_url VARCHAR(1024) NULL, cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_series (series_tvdb_id), UNIQUE INDEX idx_episode ( @@ -227,6 +229,18 @@ class LibraryService: ) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) + # Spalten nachtraeglich hinzufuegen (bestehende DBs) + for col, coldef in [ + ("overview", "TEXT NULL"), + ("image_url", "VARCHAR(1024) NULL"), + ]: + try: + await cur.execute( + f"ALTER TABLE tvdb_episode_cache " + f"ADD COLUMN {col} {coldef}" + ) + except Exception: + pass # Spalte existiert bereits # movie_id Spalte in library_videos (falls noch nicht vorhanden) try: diff --git a/video-konverter/app/static/css/style.css b/video-konverter/app/static/css/style.css index ca1dde3..09fba60 100644 --- a/video-konverter/app/static/css/style.css +++ b/video-konverter/app/static/css/style.css @@ -1081,6 +1081,8 @@ legend { .row-missing { opacity: 0.6; } .row-missing td { color: #888; } +.row-redundant { background: rgba(255, 152, 0, 0.08); } +.row-redundant td { color: #b0a080; } .text-warn { color: #ffb74d; } .text-muted { color: #888; font-size: 0.8rem; } diff --git a/video-konverter/app/static/js/library.js b/video-konverter/app/static/js/library.js index 59f0404..3c4d67f 100644 --- a/video-konverter/app/static/js/library.js +++ b/video-konverter/app/static/js/library.js @@ -638,6 +638,33 @@ function renderEpisodesTab(series) { for (const ep of sData.missing) allEps.push({...ep, _type: "missing"}); allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0)); + // Redundante Dateien erkennen: gleiche Episode-Nummer mehrfach vorhanden + // Die "beste" Datei behalten (kleinere Datei bei gleichem Codec, neueres Format bevorzugt) + const epGroups = {}; + for (const ep of allEps) { + if (ep._type !== "local" || !ep.episode_number) continue; + const key = `${ep.season_number || 0}-${ep.episode_number}`; + if (!epGroups[key]) epGroups[key] = []; + epGroups[key].push(ep); + } + const redundantIds = new Set(); + const codecRank = {av1: 4, hevc: 3, h265: 3, h264: 2, x264: 2, mpeg4: 1, mpeg2video: 0}; + for (const key of Object.keys(epGroups)) { + const group = epGroups[key]; + if (group.length <= 1) continue; + // Sortiere: neuerer Codec besser, bei gleichem Codec kleinere Datei besser + group.sort((a, b) => { + const ra = codecRank[(a.video_codec || "").toLowerCase()] || 0; + const rb = codecRank[(b.video_codec || "").toLowerCase()] || 0; + if (ra !== rb) return rb - ra; + return (a.file_size || 0) - (b.file_size || 0); + }); + // Alle ausser dem ersten sind redundant + for (let i = 1; i < group.length; i++) { + redundantIds.add(group[i].id); + } + } + for (const ep of allEps) { if (ep._type === "missing") { html += ` @@ -647,6 +674,7 @@ function renderEpisodesTab(series) { FEHLT `; } else { + const isRedundant = redundantIds.has(ep.id); const audioInfo = (ep.audio_tracks || []).map(a => { const lang = (a.lang || "?").toUpperCase().substring(0, 3); return `${lang} ${channelLayout(a.channels)}`; @@ -654,9 +682,10 @@ function renderEpisodesTab(series) { const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-"; const epTitle = ep.episode_title || ep.file_name || "Episode"; const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-"; - html += ` + const redundantBadge = isRedundant ? ' REDUNDANT' : ''; + html += ` ${ep.episode_number || "-"} - ${escapeHtml(epTitle)} + ${escapeHtml(epTitle)}${redundantBadge} ${res} ${ep.video_codec || "-"} ${fileExt} @@ -3158,3 +3187,45 @@ async function deleteVideo(videoId, title, context) { }) .catch(e => showToast("Fehler: " + e, "error")); } + +// === Batch-Thumbnail-Generierung === + +async function generateThumbnails() { + // Status pruefen + const status = await fetch("/api/library/thumbnail-status").then(r => r.json()); + if (status.missing === 0) { + showToast("Alle " + status.total + " Videos haben bereits Thumbnails", "info"); + return; + } + + if (!await showConfirm( + status.missing + " von " + status.total + " Videos haben noch kein Thumbnail. Jetzt generieren?", + {title: "Thumbnails generieren", detail: "Die Generierung laeuft im Hintergrund per ffmpeg.", okText: "Starten", icon: "info"} + )) return; + + fetch("/api/library/generate-thumbnails", {method: "POST"}) + .then(r => r.json()) + .then(data => { + if (data.status === "running") { + showToast("Thumbnail-Generierung laeuft bereits", "info"); + } else { + showToast("Thumbnail-Generierung gestartet", "success"); + pollThumbnailStatus(); + } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +function pollThumbnailStatus() { + const interval = setInterval(() => { + fetch("/api/library/thumbnail-status") + .then(r => r.json()) + .then(data => { + if (!data.running) { + clearInterval(interval); + showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success"); + } + }) + .catch(() => clearInterval(interval)); + }, 3000); +} diff --git a/video-konverter/app/static/tv/css/tv.css b/video-konverter/app/static/tv/css/tv.css index 9eb371a..6898334 100644 --- a/video-konverter/app/static/tv/css/tv.css +++ b/video-konverter/app/static/tv/css/tv.css @@ -290,9 +290,71 @@ a { color: var(--accent); text-decoration: none; } transition: background 0.2s; color: var(--text); text-decoration: none; + align-items: center; + position: relative; +} +.tv-episode-card:hover { background: var(--bg-hover); } +.tv-ep-link { + display: flex; + gap: 1rem; + color: var(--text); + text-decoration: none; + flex: 1; + min-width: 0; +} +.tv-ep-link:focus { outline: var(--focus-ring); outline-offset: -2px; border-radius: var(--radius); } + +/* Gesehen-Button pro Episode */ +.tv-ep-mark-btn { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid var(--text-dim); + background: transparent; + color: var(--text-dim); + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + margin-right: 0.4rem; +} +.tv-ep-mark-btn:hover, .tv-ep-mark-btn:focus { + border-color: var(--accent); + color: var(--accent); + outline: none; +} +.tv-ep-mark-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.tv-ep-seen { opacity: 0.6; } +.tv-ep-seen:hover { opacity: 1; } + +/* Staffel-Aktionen */ +.tv-season-actions { + display: flex; + justify-content: flex-end; + padding: 0.3rem 0 0.6rem; +} +.tv-season-mark-btn { + background: transparent; + border: 1px solid var(--text-dim); + color: var(--text-dim); + padding: 0.3rem 0.8rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; +} +.tv-season-mark-btn:hover, .tv-season-mark-btn:focus { + border-color: var(--accent); + color: var(--accent); + outline: none; } -.tv-episode-card:hover, .tv-episode-card:focus { background: var(--bg-hover); } -.tv-episode-card:focus { outline: var(--focus-ring); outline-offset: -2px; } /* Thumbnail-Bereich */ .tv-ep-thumb { @@ -919,6 +981,22 @@ a { color: var(--accent); text-decoration: none; } border-radius: var(--radius); } .player-btn:focus { outline: var(--focus-ring); } +.player-btn svg { display: block; } +.player-btn-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + padding: 1px 4px; + border: 1px solid currentColor; + border-radius: 3px; + line-height: 1.2; +} +.player-btn.active { color: var(--accent); } +.player-btn.active .player-btn-badge { + border-color: var(--accent); + background: var(--accent); + color: #000; +} .player-time { color: var(--text-muted); font-size: 0.85rem; } .player-spacer { flex: 1; } @@ -1217,6 +1295,18 @@ a { color: var(--accent); text-decoration: none; } border-color: var(--accent); outline: none; } +/* SELECT im Editier-Modus: deutlich hervorgehoben */ +select.select-editing { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent); + background: var(--bg-hover); +} +/* Auch Sort-/Filter-Selects im Content-Bereich */ +.tv-sort-select.select-editing, +.tv-rating-filter.select-editing { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent); +} .color-picker-grid { display: flex; flex-wrap: wrap; @@ -1475,3 +1565,46 @@ a { color: var(--accent); text-decoration: none; } display: none; } } + +/* === Alphabet-Seitenleiste === */ +.tv-alpha-sidebar { + position: fixed; + right: 6px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + align-items: center; + z-index: 50; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 4px 2px; +} +.tv-alpha-letter { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 19px; + font-size: 0.65rem; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + transition: color 0.15s, background 0.15s; + font-weight: 600; + user-select: none; +} +.tv-alpha-letter:hover { color: var(--text); background: var(--bg-hover); } +.tv-alpha-letter:focus { outline: var(--focus-ring); outline-offset: -1px; } +.tv-alpha-letter.active { color: #000; background: var(--accent); } +.tv-alpha-letter.dimmed { color: var(--border); pointer-events: none; } + +@media (max-width: 768px) { + .tv-alpha-sidebar { right: 2px; padding: 3px 1px; } + .tv-alpha-letter { width: 20px; height: 17px; font-size: 0.58rem; } +} +@media (max-width: 480px) { + .tv-alpha-sidebar { right: 1px; padding: 2px 1px; } + .tv-alpha-letter { width: 16px; height: 14px; font-size: 0.5rem; } +} diff --git a/video-konverter/app/static/tv/i18n/de.json b/video-konverter/app/static/tv/i18n/de.json index 3c51e4a..a19bef4 100644 --- a/video-konverter/app/static/tv/i18n/de.json +++ b/video-konverter/app/static/tv/i18n/de.json @@ -169,6 +169,7 @@ "sort_episodes": "Episoden-Anzahl", "sort_last_watched": "Zuletzt angesehen", "sort_rating": "Bewertung", + "all_genres": "Alle Genres", "genres": "Genres", "min_rating": "Min. Sterne" }, diff --git a/video-konverter/app/static/tv/i18n/en.json b/video-konverter/app/static/tv/i18n/en.json index b649cbf..9ecc2af 100644 --- a/video-konverter/app/static/tv/i18n/en.json +++ b/video-konverter/app/static/tv/i18n/en.json @@ -169,6 +169,7 @@ "sort_episodes": "Episode Count", "sort_last_watched": "Last Watched", "sort_rating": "Rating", + "all_genres": "All Genres", "genres": "Genres", "min_rating": "Min. Stars" }, diff --git a/video-konverter/app/static/tv/js/player.js b/video-konverter/app/static/tv/js/player.js index feeabea..c824c48 100644 --- a/video-konverter/app/static/tv/js/player.js +++ b/video-konverter/app/static/tv/js/player.js @@ -42,6 +42,7 @@ function initPlayer(opts) { loadVideoInfo().then(() => { // Stream starten setStreamUrl(opts.startPos || 0); + updatePlayerButtons(); }); // Events @@ -63,7 +64,15 @@ function initPlayer(opts) { // Einstellungen-Button const btnSettings = document.getElementById("btn-settings"); - if (btnSettings) btnSettings.addEventListener("click", toggleOverlay); + if (btnSettings) btnSettings.addEventListener("click", () => openOverlaySection(null)); + + // Separate Buttons: Audio, Untertitel, Qualitaet + const btnAudio = document.getElementById("btn-audio"); + if (btnAudio) btnAudio.addEventListener("click", () => openOverlaySection("audio")); + const btnSubs = document.getElementById("btn-subs"); + if (btnSubs) btnSubs.addEventListener("click", () => openOverlaySection("subs")); + const btnQuality = document.getElementById("btn-quality"); + if (btnQuality) btnQuality.addEventListener("click", () => openOverlaySection("quality")); // Naechste-Episode-Button const btnNext = document.getElementById("btn-next"); @@ -279,6 +288,25 @@ function toggleOverlay() { } } +function openOverlaySection(section) { + const overlay = document.getElementById("player-overlay"); + if (!overlay) return; + if (overlayOpen) { + // Bereits offen -> schliessen + overlayOpen = false; + overlay.style.display = "none"; + return; + } + overlayOpen = true; + overlay.style.display = ""; + renderOverlay(); + showControls(); + if (section) { + var el = document.getElementById("overlay-" + section); + if (el) el.scrollIntoView({ behavior: "smooth" }); + } +} + function renderOverlay() { // Audio-Spuren const audioEl = document.getElementById("overlay-audio"); @@ -343,12 +371,14 @@ function switchAudio(idx) { const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0); setStreamUrl(currentTime); renderOverlay(); + updatePlayerButtons(); } function switchSub(idx) { currentSub = idx; updateSubtitleTrack(); renderOverlay(); + updatePlayerButtons(); } function updateSubtitleTrack() { @@ -364,6 +394,7 @@ function switchQuality(q) { const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0); setStreamUrl(currentTime); renderOverlay(); + updatePlayerButtons(); } function switchSpeed(s) { @@ -470,6 +501,26 @@ function saveProgress(completed) { window.addEventListener("beforeunload", () => saveProgress()); +// === Button-Status aktualisieren === + +function updatePlayerButtons() { + // CC-Button: aktiv wenn Untertitel an + var btnSubs = document.getElementById("btn-subs"); + if (btnSubs) btnSubs.classList.toggle("active", currentSub >= 0); + // Quality-Badge: aktuellen Modus anzeigen + var badge = document.getElementById("quality-badge"); + if (badge) { + var labels = { uhd: "4K", hd: "HD", sd: "SD", low: "LD" }; + badge.textContent = labels[currentQuality] || "HD"; + } + // Audio-Button: aktuelle Sprache anzeigen (Tooltip) + var btnAudio = document.getElementById("btn-audio"); + if (btnAudio && videoInfo && videoInfo.audio_tracks && videoInfo.audio_tracks[currentAudio]) { + var lang = videoInfo.audio_tracks[currentAudio].lang; + btnAudio.title = langName(lang) || "Audio"; + } +} + // === Hilfsfunktionen === const LANG_NAMES = { diff --git a/video-konverter/app/static/tv/js/tv.js b/video-konverter/app/static/tv/js/tv.js index ab6ea08..576b2b3 100644 --- a/video-konverter/app/static/tv/js/tv.js +++ b/video-konverter/app/static/tv/js/tv.js @@ -12,12 +12,20 @@ class FocusManager { this._currentFocus = null; // Merkt sich das letzte fokussierte Element im Content-Bereich this._lastContentFocus = null; + // SELECT-Editier-Modus: erst Enter druecken, dann Hoch/Runter aendert Werte + this._selectActive = false; // Tastatur-Events abfangen document.addEventListener("keydown", (e) => this._onKeyDown(e)); // Focus-Tracking: merken wo wir zuletzt waren document.addEventListener("focusin", (e) => { + // SELECT-Editier-Modus beenden wenn Focus sich aendert + if (this._selectActive && e.target && e.target.tagName !== "SELECT") { + this._selectActive = false; + document.querySelectorAll(".select-editing").forEach( + el => el.classList.remove("select-editing")); + } if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) { if (!e.target.closest("#tv-nav")) { this._lastContentFocus = e.target; @@ -84,9 +92,13 @@ class FocusManager { if (direction === "ArrowLeft" || direction === "ArrowRight") return; } - // Select-Elemente: Hoch/Runter dem Browser ueberlassen (Option wechseln) + // Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter durchlassen if (active && active.tagName === "SELECT") { - if (direction === "ArrowUp" || direction === "ArrowDown") return; + if (this._selectActive) { + // Editier-Modus: Hoch/Runter aendert den Wert + if (direction === "ArrowUp" || direction === "ArrowDown") return; + } + // Sonst: normal weiternavigieren (Select wird uebersprungen) } const focusables = this._getFocusableElements(); @@ -220,8 +232,20 @@ class FocusManager { return; } - // Select: Enter oeffnet/schliesst das Dropdown nativ + // Select: Enter aktiviert/deaktiviert den Editier-Modus if (active.tagName === "SELECT") { + if (this._selectActive) { + // Wert bestaetigen, Editier-Modus beenden + this._selectActive = false; + active.classList.remove("select-editing"); + // onchange ausloesen falls sich Wert geaendert hat + active.dispatchEvent(new Event("change", { bubbles: true })); + } else { + // Editier-Modus starten + this._selectActive = true; + active.classList.add("select-editing"); + } + e.preventDefault(); return; } @@ -252,9 +276,16 @@ class FocusManager { return; } - // In Select-Feldern: Escape = Blur (zurueck zur Navigation) + // In Select-Feldern: Escape = Editier-Modus beenden oder Blur if (active && active.tagName === "SELECT") { - active.blur(); + if (this._selectActive) { + // Editier-Modus beenden (Wert nicht uebernehmen) + this._selectActive = false; + active.classList.remove("select-editing"); + } else { + // Nicht im Editier-Modus -> Focus verlassen + active.blur(); + } e.preventDefault(); return; } diff --git a/video-konverter/app/templates/library.html b/video-konverter/app/templates/library.html index d550b80..92cd860 100644 --- a/video-konverter/app/templates/library.html +++ b/video-konverter/app/templates/library.html @@ -13,6 +13,7 @@ + diff --git a/video-konverter/app/templates/tv/login.html b/video-konverter/app/templates/tv/login.html index e8ded5d..a6ae591 100644 --- a/video-konverter/app/templates/tv/login.html +++ b/video-konverter/app/templates/tv/login.html @@ -49,15 +49,27 @@ diff --git a/video-konverter/app/templates/tv/movies.html b/video-konverter/app/templates/tv/movies.html index c0eb37b..03dd6b8 100644 --- a/video-konverter/app/templates/tv/movies.html +++ b/video-konverter/app/templates/tv/movies.html @@ -48,18 +48,12 @@
{% if genres %} -
- - {{ t('filter.all') }} - + {% endif %} -