diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py index db0712f..e097fd3 100644 --- a/video-konverter/app/routes/tv_api.py +++ b/video-konverter/app/routes/tv_api.py @@ -44,11 +44,32 @@ def setup_tv_routes(app: web.Application, config: Config, # --- Login / Logout --- async def get_login(request: web.Request) -> web.Response: - """GET /tv/login - Login-Seite""" - # Bereits eingeloggt? -> Weiterleiten + """GET /tv/login - Login-Seite. + Wenn bereits eingeloggt -> weiter. + Wenn Profile auf dem Geraet -> Profilauswahl. + Wenn genau ein Profil -> direkt einloggen.""" user = await get_tv_user(request) if user: raise web.HTTPFound("/tv/") + # Pruefen ob Profile auf diesem Client existieren + client_id = request.cookies.get("vk_client_id") + if client_id: + profiles = await auth_service.get_client_profiles(client_id) + if len(profiles) == 1: + # Nur ein Profil -> direkt Session wechseln + session_id = profiles[0]["session_id"] + check_user = await auth_service.validate_session(session_id) + if check_user: + resp = web.HTTPFound("/tv/") + resp.set_cookie( + "vk_session", session_id, + max_age=10 * 365 * 24 * 3600, + httponly=True, samesite="Lax", path="/", + ) + return resp + elif len(profiles) > 1: + # Mehrere Profile -> Profilauswahl + raise web.HTTPFound("/tv/profiles") return aiohttp_jinja2.render_template( "tv/login.html", request, {"error": None} ) @@ -283,6 +304,39 @@ def setup_tv_routes(app: web.Application, config: Config, if g: all_genres.add(g) + # Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername) + folder_data = [] + src_map = {str(src["id"]): src["name"] for src in sources} + if source_filter: + # Nur gefilterte Quelle + items = sorted( + [s for s in series + if str(s.get("library_path_id")) == source_filter], + key=lambda x: (x.get("folder_name") or "").lower() + ) + src_name = src_map.get(source_filter, "") + if items: + folder_data.append({"name": src_name, "items": items}) + else: + for src in sources: + items = sorted( + [s for s in series + if s.get("library_path_id") == src["id"]], + key=lambda x: (x.get("folder_name") or "").lower() + ) + if items: + folder_data.append({ + "name": src["name"], "items": items}) + # Serien ohne Quelle (Fallback) + src_ids = {src["id"] for src in sources} + orphans = sorted( + [s for s in series + if s.get("library_path_id") not in src_ids], + key=lambda x: (x.get("folder_name") or "").lower() + ) + if orphans: + folder_data.append({"name": "Sonstige", "items": orphans}) + return aiohttp_jinja2.render_template( "tv/series.html", request, { "user": user, @@ -290,6 +344,7 @@ def setup_tv_routes(app: web.Application, config: Config, "series": series, "view": user.get("series_view") or "grid", "sources": sources, + "folder_data": folder_data, "genres": sorted(all_genres), "current_source": source_filter, "current_genre": genre_filter, @@ -360,6 +415,19 @@ def setup_tv_routes(app: web.Application, config: Config, seasons[sn] = [] seasons[sn].append(ep) + # Duplikat-Episoden markieren (gleiche Episodennummer) + for sn, eps in seasons.items(): + ep_count = {} + for ep in eps: + en = ep.get("episode_number") + if en is not None: + ep_count[en] = ep_count.get(en, 0) + 1 + for ep in eps: + en = ep.get("episode_number") + ep["is_duplicate"] = ( + en is not None and ep_count.get(en, 0) > 1 + ) + # Watchlist-Status pruefen in_watchlist = await auth_service.is_in_watchlist( user["id"], series_id=series_id) @@ -475,6 +543,38 @@ def setup_tv_routes(app: web.Application, config: Config, if g: all_genres.add(g) + # Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername) + folder_data = [] + src_map = {str(src["id"]): src["name"] for src in sources} + if source_filter: + items = sorted( + [m for m in movies + if str(m.get("library_path_id")) == source_filter], + key=lambda x: (x.get("folder_name") or "").lower() + ) + src_name = src_map.get(source_filter, "") + if items: + folder_data.append({"name": src_name, "items": items}) + else: + for src in sources: + items = sorted( + [m for m in movies + if m.get("library_path_id") == src["id"]], + key=lambda x: (x.get("folder_name") or "").lower() + ) + if items: + folder_data.append({ + "name": src["name"], "items": items}) + # Filme ohne Quelle (Fallback) + src_ids = {src["id"] for src in sources} + orphans = sorted( + [m for m in movies + if m.get("library_path_id") not in src_ids], + key=lambda x: (x.get("folder_name") or "").lower() + ) + if orphans: + folder_data.append({"name": "Sonstige", "items": orphans}) + return aiohttp_jinja2.render_template( "tv/movies.html", request, { "user": user, @@ -482,6 +582,7 @@ def setup_tv_routes(app: web.Application, config: Config, "movies": movies, "view": user.get("movies_view") or "grid", "sources": sources, + "folder_data": folder_data, "genres": sorted(all_genres), "current_source": source_filter, "current_genre": genre_filter, diff --git a/video-konverter/app/static/tv/css/tv.css b/video-konverter/app/static/tv/css/tv.css index a3bcc7c..9eb371a 100644 --- a/video-konverter/app/static/tv/css/tv.css +++ b/video-konverter/app/static/tv/css/tv.css @@ -391,6 +391,20 @@ a { color: var(--accent); text-decoration: none; } font-size: 0.75rem; margin-top: auto; } +.tv-ep-duplicate { + border-left: 3px solid var(--warning, #ffa726); +} +.tv-ep-dup-badge { + display: inline-block; + background: rgba(255, 167, 38, 0.2); + color: var(--warning, #ffa726); + font-size: 0.7rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.03em; +} /* Serien-Detail Aktionen */ .tv-detail-actions { @@ -555,6 +569,62 @@ a { color: var(--accent); text-decoration: none; } margin-top: auto; } +/* === Ordner-Ansicht === */ +.tv-folder-view { + display: flex; + flex-direction: column; + gap: 1.2rem; +} +.tv-folder-source-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-muted); + margin: 0 0 0.4rem 0; + padding-bottom: 0.3rem; + border-bottom: 1px solid var(--border); +} +.tv-folder-list { + display: flex; + flex-direction: column; + gap: 2px; +} +.tv-folder-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0.7rem; + background: var(--bg-card); + border-radius: calc(var(--radius) - 2px); + color: var(--text); + text-decoration: none; + transition: background 0.15s; +} +.tv-folder-item:hover, .tv-folder-item:focus { + background: var(--bg-hover); +} +.tv-folder-item:focus { outline: var(--focus-ring); outline-offset: -2px; } +.tv-folder-icon { + font-size: 1.3rem; + flex-shrink: 0; + width: 1.5rem; + text-align: center; +} +.tv-folder-name { + font-size: 0.92rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} +.tv-folder-meta { + color: var(--text-muted); + font-size: 0.78rem; + white-space: nowrap; + flex-shrink: 0; +} + /* === Filter-Leiste === */ .tv-filter-bar { display: flex; @@ -699,6 +769,25 @@ a { color: var(--accent); text-decoration: none; } min-height: 100vh; background: var(--bg); } +.login-loader { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + inset: 0; + background: var(--bg); + z-index: 100; +} +.login-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .login-container { width: 100%; max-width: 400px; padding: 1rem; } .login-card { background: var(--bg-card); @@ -1128,14 +1217,32 @@ a { color: var(--accent); text-decoration: none; } border-color: var(--accent); outline: none; } -.settings-color { - width: 50px; +.color-picker-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 0.4rem; +} +.color-swatch { + width: 36px; height: 36px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg-input); + border-radius: 50%; + border: 3px solid transparent; cursor: pointer; - padding: 2px; + transition: border-color 0.15s, transform 0.15s; + padding: 0; +} +.color-swatch:hover, .color-swatch:focus { + transform: scale(1.15); + outline: none; +} +.color-swatch:focus { + outline: var(--focus-ring); + outline-offset: 2px; +} +.color-swatch.active { + border-color: #fff; + box-shadow: 0 0 0 2px var(--accent); } .settings-save { margin-top: 1rem; } .settings-danger { diff --git a/video-konverter/app/static/tv/i18n/de.json b/video-konverter/app/static/tv/i18n/de.json index 1e774c4..3c51e4a 100644 --- a/video-konverter/app/static/tv/i18n/de.json +++ b/video-konverter/app/static/tv/i18n/de.json @@ -26,7 +26,8 @@ "no_series": "Keine Serien vorhanden.", "episode_short": "E", "min": "Min", - "watchlist": "Merkliste" + "watchlist": "Merkliste", + "duplicate": "Duplikat" }, "movies": { "title": "Filme", @@ -110,6 +111,7 @@ "view_grid": "Raster", "view_list": "Liste", "view_detail": "Detail", + "view_folder": "Ordner", "autoplay": "Automatische Wiedergabe", "autoplay_enabled": "Nächste Episode automatisch abspielen", "autoplay_countdown": "Countdown-Dauer", diff --git a/video-konverter/app/static/tv/i18n/en.json b/video-konverter/app/static/tv/i18n/en.json index 21e329f..b649cbf 100644 --- a/video-konverter/app/static/tv/i18n/en.json +++ b/video-konverter/app/static/tv/i18n/en.json @@ -26,7 +26,8 @@ "no_series": "No series available.", "episode_short": "E", "min": "min", - "watchlist": "Watchlist" + "watchlist": "Watchlist", + "duplicate": "Duplicate" }, "movies": { "title": "Movies", @@ -110,6 +111,7 @@ "view_grid": "Grid", "view_list": "List", "view_detail": "Detail", + "view_folder": "Folders", "autoplay": "Autoplay", "autoplay_enabled": "Auto-play next episode", "autoplay_countdown": "Countdown Duration", diff --git a/video-konverter/app/static/tv/js/tv.js b/video-konverter/app/static/tv/js/tv.js index 3e3ecd6..ab6ea08 100644 --- a/video-konverter/app/static/tv/js/tv.js +++ b/video-konverter/app/static/tv/js/tv.js @@ -10,10 +10,21 @@ class FocusManager { constructor() { this._enabled = true; this._currentFocus = null; + // Merkt sich das letzte fokussierte Element im Content-Bereich + this._lastContentFocus = null; // Tastatur-Events abfangen document.addEventListener("keydown", (e) => this._onKeyDown(e)); + // Focus-Tracking: merken wo wir zuletzt waren + document.addEventListener("focusin", (e) => { + if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) { + if (!e.target.closest("#tv-nav")) { + this._lastContentFocus = e.target; + } + } + }); + // Initiales Focus-Element setzen requestAnimationFrame(() => this._initFocus()); } @@ -25,6 +36,12 @@ class FocusManager { autofocusEl.focus(); return; } + // Erstes Element im Content bevorzugen (nicht Nav) + const contentFirst = document.querySelector(".tv-main [data-focusable]"); + if (contentFirst) { + contentFirst.focus(); + return; + } const first = document.querySelector("[data-focusable]"); if (first) first.focus(); } @@ -61,11 +78,17 @@ class FocusManager { _navigate(direction, e) { const active = document.activeElement; + // Input-Felder: Links/Rechts nicht abfangen (Cursor-Navigation) if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (direction === "ArrowLeft" || direction === "ArrowRight") return; } + // Select-Elemente: Hoch/Runter dem Browser ueberlassen (Option wechseln) + if (active && active.tagName === "SELECT") { + if (direction === "ArrowUp" || direction === "ArrowDown") return; + } + const focusables = this._getFocusableElements(); if (!focusables.length) return; @@ -78,24 +101,84 @@ class FocusManager { return; } + // Navigation innerhalb der Nav-Bar: Links/Rechts = sequentiell + const inNav = active.closest("#tv-nav"); + if (inNav && (direction === "ArrowLeft" || direction === "ArrowRight")) { + // Alle Nav-Elemente sequentiell (links + rechts zusammen) + const navEls = focusables.filter(el => el.closest("#tv-nav")); + const navIdx = navEls.indexOf(active); + if (navIdx !== -1) { + const nextIdx = direction === "ArrowRight" ? navIdx + 1 : navIdx - 1; + if (nextIdx >= 0 && nextIdx < navEls.length) { + navEls[nextIdx].focus(); + e.preventDefault(); + return; + } + } + } + + // Von Nav nach unten -> zum Content springen + if (inNav && direction === "ArrowDown") { + if (this._lastContentFocus && document.contains(this._lastContentFocus)) { + this._lastContentFocus.focus(); + this._lastContentFocus.scrollIntoView({ block: "nearest", behavior: "smooth" }); + e.preventDefault(); + return; + } + // Sonst: erstes Content-Element + const contentFirst = document.querySelector(".tv-main [data-focusable]"); + if (contentFirst) { + contentFirst.focus(); + contentFirst.scrollIntoView({ block: "nearest", behavior: "smooth" }); + e.preventDefault(); + return; + } + } + + // Vom Content nach oben zur Nav springen wenn am oberen Rand + if (!inNav && direction === "ArrowUp") { + const current = active.getBoundingClientRect(); + // Nur wenn Element nah am oberen Rand ist (< 200px vom Viewport-Top) + if (current.top < 200) { + // Pruefen ob es noch ein Element darueber im Content gibt + const contentEls = focusables.filter(el => !el.closest("#tv-nav")); + const above = contentEls.filter(el => { + const r = el.getBoundingClientRect(); + return r.top + r.height / 2 < current.top - 5; + }); + if (above.length === 0) { + // Kein Element darueber -> zur Nav springen + const activeNavItem = document.querySelector(".tv-nav-item.active"); + if (activeNavItem) { + activeNavItem.focus(); + e.preventDefault(); + return; + } + } + } + } + // Naechstes Element in Richtung finden (Nearest-Neighbor) - const current = active.getBoundingClientRect(); - const cx = current.left + current.width / 2; - const cy = current.top + current.height / 2; + const currentRect = active.getBoundingClientRect(); + const cx = currentRect.left + currentRect.width / 2; + const cy = currentRect.top + currentRect.height / 2; let bestEl = null; let bestDist = Infinity; - for (const el of focusables) { + // Nur Elemente im gleichen Bereich (Nav oder Content) bevorzugen + const searchEls = inNav + ? focusables.filter(el => el.closest("#tv-nav")) + : focusables.filter(el => !el.closest("#tv-nav")); + + for (const el of searchEls) { if (el === active) continue; const rect = el.getBoundingClientRect(); - // Element muss sichtbar sein if (rect.width === 0 || rect.height === 0) continue; const ex = rect.left + rect.width / 2; const ey = rect.top + rect.height / 2; - // Pruefen ob Element in der richtigen Richtung liegt const dx = ex - cx; const dy = ey - cy; @@ -108,7 +191,6 @@ class FocusManager { } if (!valid) continue; - // Distanz berechnen (gewichtet: Hauptrichtung weniger, Querrichtung mehr) let dist; if (direction === "ArrowUp" || direction === "ArrowDown") { dist = Math.abs(dy) + Math.abs(dx) * 3; @@ -124,7 +206,6 @@ class FocusManager { if (bestEl) { bestEl.focus(); - // Ins Sichtfeld scrollen bestEl.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); e.preventDefault(); } @@ -134,9 +215,21 @@ class FocusManager { const active = document.activeElement; if (!active || active === document.body) return; - // Links, Buttons -> Click ausfuehren + // Links, Buttons -> Click ausfuehren (natuerliches Enter-Verhalten) if (active.tagName === "A" || active.tagName === "BUTTON") { - // Natuerliches Enter-Verhalten beibehalten + return; + } + + // Select: Enter oeffnet/schliesst das Dropdown nativ + if (active.tagName === "SELECT") { + return; + } + + // Checkbox: Toggle + if (active.tagName === "INPUT" && active.type === "checkbox") { + active.checked = !active.checked; + active.dispatchEvent(new Event("change", { bubbles: true })); + e.preventDefault(); return; } @@ -149,15 +242,41 @@ class FocusManager { _goBack(e) { const active = document.activeElement; - // In Input-Feldern: Escape = Blur, Backspace = natuerlich - if (active && active.tagName === "INPUT") { - if (e.key === "Escape") { + + // In Input-Feldern: Escape = Blur + if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { + if (e.key === "Escape" || e.keyCode === 10009) { active.blur(); e.preventDefault(); } return; } + // In Select-Feldern: Escape = Blur (zurueck zur Navigation) + if (active && active.tagName === "SELECT") { + active.blur(); + e.preventDefault(); + return; + } + + // Wenn Focus in der Nav: nicht zurueck navigieren + if (active && active.closest && active.closest("#tv-nav")) { + // Focus zurueck zum Content verschieben + if (this._lastContentFocus && document.contains(this._lastContentFocus)) { + this._lastContentFocus.focus(); + } + e.preventDefault(); + return; + } + + // Wenn ein Player-Overlay offen ist, zuerst das schliessen + const overlay = document.querySelector(".player-overlay.visible, .player-next-overlay.visible"); + if (overlay) { + overlay.classList.remove("visible"); + e.preventDefault(); + return; + } + // Zurueck navigieren if (window.history.length > 1) { window.history.back(); @@ -166,7 +285,6 @@ class FocusManager { } _getFocusableElements() { - // Alle sichtbaren fokussierbaren Elemente const elements = document.querySelectorAll("[data-focusable]"); return Array.from(elements).filter(el => { if (el.offsetParent === null && el.style.position !== "fixed") return false; diff --git a/video-konverter/app/templates/tv/login.html b/video-konverter/app/templates/tv/login.html index e866844..e8ded5d 100644 --- a/video-konverter/app/templates/tv/login.html +++ b/video-konverter/app/templates/tv/login.html @@ -8,7 +8,12 @@ Login - VideoKonverter TV -
+ +
+ +
+ +
- + {% if sources|length > 1 %}
{% endif %} - -
+ +
{% if genres %}
- {% if not movies %} + + + + {% if not movies and view != 'folder' %}
{{ t('movies.no_movies') }}
{% endif %} @@ -152,9 +180,13 @@ function switchView(mode) { document.querySelectorAll('.tv-view-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === mode); }); + // Filter-Leiste in Ordner-Ansicht verstecken + const filterBar = document.getElementById('filter-bar'); + if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : ''; fetch('/tv/settings', { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest' }, body: 'movies_view=' + mode, }).catch(() => {}); } diff --git a/video-konverter/app/templates/tv/series.html b/video-konverter/app/templates/tv/series.html index 085c76b..6cb5296 100644 --- a/video-konverter/app/templates/tv/series.html +++ b/video-konverter/app/templates/tv/series.html @@ -21,10 +21,15 @@ title="{{ t('settings.view_detail') }}"> +
- + {% if sources|length > 1 %}
{% endif %} - -
+ +
{% if genres %}
- {% if not series %} + + + + {% if not series and view != 'folder' %}
{{ t('series.no_series') }}
{% endif %} @@ -153,9 +181,13 @@ function switchView(mode) { document.querySelectorAll('.tv-view-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === mode); }); + // Filter-Leiste in Ordner-Ansicht verstecken + const filterBar = document.getElementById('filter-bar'); + if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : ''; fetch('/tv/settings', { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest' }, body: 'series_view=' + mode, }).catch(() => {}); } diff --git a/video-konverter/app/templates/tv/series_detail.html b/video-konverter/app/templates/tv/series_detail.html index 5704739..e6ee404 100644 --- a/video-konverter/app/templates/tv/series_detail.html +++ b/video-konverter/app/templates/tv/series_detail.html @@ -84,7 +84,7 @@
{% for ep in episodes %} - +
{% if ep.ep_image_url %} @@ -118,8 +118,11 @@

{{ ep.ep_overview }}

{% endif %}
+ {% if ep.is_duplicate %}{{ t('series.duplicate') }} {% endif %} {% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %} + · {{ ep.container|upper }} {% if ep.video_codec %} · {{ ep.video_codec }}{% endif %} + {% if ep.file_size %} · {{ (ep.file_size / 1048576)|round|int }} MB{% endif %}
diff --git a/video-konverter/app/templates/tv/settings.html b/video-konverter/app/templates/tv/settings.html index 07350ba..75fd2cb 100644 --- a/video-konverter/app/templates/tv/settings.html +++ b/video-konverter/app/templates/tv/settings.html @@ -22,11 +22,21 @@ - @@ -174,3 +186,15 @@
{% endblock %} + +{% block scripts %} + +{% endblock %}