fix: TV-App UX-Verbesserungen - Navigation, Ordner-Ansicht, Duplikate

- FocusManager: SELECT-Elemente, sequentielle Nav-Navigation, Zone-basiert
- Ordner-Ansicht (4. View) fuer Serien + Filme mit Quellen-Gruppierung
- Login-Flow: Lade-Spinner statt Form-Flash, Auto-Login bei 1 Profil
- Farbauswahl: Farbkreise statt input type=color (Samsung TV kompatibel)
- Duplikat-Episoden: Orange Markierung + Badge bei gleicher Episodennummer
- i18n: Neue Keys fuer Ordner-Ansicht und Duplikat-Markierung

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-01 07:57:57 +01:00
parent 6d0b8936c5
commit 61ca20bf8b
10 changed files with 479 additions and 41 deletions

View file

@ -44,11 +44,32 @@ def setup_tv_routes(app: web.Application, config: Config,
# --- Login / Logout --- # --- Login / Logout ---
async def get_login(request: web.Request) -> web.Response: async def get_login(request: web.Request) -> web.Response:
"""GET /tv/login - Login-Seite""" """GET /tv/login - Login-Seite.
# Bereits eingeloggt? -> Weiterleiten Wenn bereits eingeloggt -> weiter.
Wenn Profile auf dem Geraet -> Profilauswahl.
Wenn genau ein Profil -> direkt einloggen."""
user = await get_tv_user(request) user = await get_tv_user(request)
if user: if user:
raise web.HTTPFound("/tv/") 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( return aiohttp_jinja2.render_template(
"tv/login.html", request, {"error": None} "tv/login.html", request, {"error": None}
) )
@ -283,6 +304,39 @@ def setup_tv_routes(app: web.Application, config: Config,
if g: if g:
all_genres.add(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( return aiohttp_jinja2.render_template(
"tv/series.html", request, { "tv/series.html", request, {
"user": user, "user": user,
@ -290,6 +344,7 @@ def setup_tv_routes(app: web.Application, config: Config,
"series": series, "series": series,
"view": user.get("series_view") or "grid", "view": user.get("series_view") or "grid",
"sources": sources, "sources": sources,
"folder_data": folder_data,
"genres": sorted(all_genres), "genres": sorted(all_genres),
"current_source": source_filter, "current_source": source_filter,
"current_genre": genre_filter, "current_genre": genre_filter,
@ -360,6 +415,19 @@ def setup_tv_routes(app: web.Application, config: Config,
seasons[sn] = [] seasons[sn] = []
seasons[sn].append(ep) 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 # Watchlist-Status pruefen
in_watchlist = await auth_service.is_in_watchlist( in_watchlist = await auth_service.is_in_watchlist(
user["id"], series_id=series_id) user["id"], series_id=series_id)
@ -475,6 +543,38 @@ def setup_tv_routes(app: web.Application, config: Config,
if g: if g:
all_genres.add(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( return aiohttp_jinja2.render_template(
"tv/movies.html", request, { "tv/movies.html", request, {
"user": user, "user": user,
@ -482,6 +582,7 @@ def setup_tv_routes(app: web.Application, config: Config,
"movies": movies, "movies": movies,
"view": user.get("movies_view") or "grid", "view": user.get("movies_view") or "grid",
"sources": sources, "sources": sources,
"folder_data": folder_data,
"genres": sorted(all_genres), "genres": sorted(all_genres),
"current_source": source_filter, "current_source": source_filter,
"current_genre": genre_filter, "current_genre": genre_filter,

View file

@ -391,6 +391,20 @@ a { color: var(--accent); text-decoration: none; }
font-size: 0.75rem; font-size: 0.75rem;
margin-top: auto; 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 */ /* Serien-Detail Aktionen */
.tv-detail-actions { .tv-detail-actions {
@ -555,6 +569,62 @@ a { color: var(--accent); text-decoration: none; }
margin-top: auto; 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 === */ /* === Filter-Leiste === */
.tv-filter-bar { .tv-filter-bar {
display: flex; display: flex;
@ -699,6 +769,25 @@ a { color: var(--accent); text-decoration: none; }
min-height: 100vh; min-height: 100vh;
background: var(--bg); 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-container { width: 100%; max-width: 400px; padding: 1rem; }
.login-card { .login-card {
background: var(--bg-card); background: var(--bg-card);
@ -1128,14 +1217,32 @@ a { color: var(--accent); text-decoration: none; }
border-color: var(--accent); border-color: var(--accent);
outline: none; outline: none;
} }
.settings-color { .color-picker-grid {
width: 50px; display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 0.4rem;
}
.color-swatch {
width: 36px;
height: 36px; height: 36px;
border: 1px solid var(--border); border-radius: 50%;
border-radius: var(--radius); border: 3px solid transparent;
background: var(--bg-input);
cursor: pointer; 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-save { margin-top: 1rem; }
.settings-danger { .settings-danger {

View file

@ -26,7 +26,8 @@
"no_series": "Keine Serien vorhanden.", "no_series": "Keine Serien vorhanden.",
"episode_short": "E", "episode_short": "E",
"min": "Min", "min": "Min",
"watchlist": "Merkliste" "watchlist": "Merkliste",
"duplicate": "Duplikat"
}, },
"movies": { "movies": {
"title": "Filme", "title": "Filme",
@ -110,6 +111,7 @@
"view_grid": "Raster", "view_grid": "Raster",
"view_list": "Liste", "view_list": "Liste",
"view_detail": "Detail", "view_detail": "Detail",
"view_folder": "Ordner",
"autoplay": "Automatische Wiedergabe", "autoplay": "Automatische Wiedergabe",
"autoplay_enabled": "Nächste Episode automatisch abspielen", "autoplay_enabled": "Nächste Episode automatisch abspielen",
"autoplay_countdown": "Countdown-Dauer", "autoplay_countdown": "Countdown-Dauer",

View file

@ -26,7 +26,8 @@
"no_series": "No series available.", "no_series": "No series available.",
"episode_short": "E", "episode_short": "E",
"min": "min", "min": "min",
"watchlist": "Watchlist" "watchlist": "Watchlist",
"duplicate": "Duplicate"
}, },
"movies": { "movies": {
"title": "Movies", "title": "Movies",
@ -110,6 +111,7 @@
"view_grid": "Grid", "view_grid": "Grid",
"view_list": "List", "view_list": "List",
"view_detail": "Detail", "view_detail": "Detail",
"view_folder": "Folders",
"autoplay": "Autoplay", "autoplay": "Autoplay",
"autoplay_enabled": "Auto-play next episode", "autoplay_enabled": "Auto-play next episode",
"autoplay_countdown": "Countdown Duration", "autoplay_countdown": "Countdown Duration",

View file

@ -10,10 +10,21 @@ class FocusManager {
constructor() { constructor() {
this._enabled = true; this._enabled = true;
this._currentFocus = null; this._currentFocus = null;
// Merkt sich das letzte fokussierte Element im Content-Bereich
this._lastContentFocus = null;
// Tastatur-Events abfangen // Tastatur-Events abfangen
document.addEventListener("keydown", (e) => this._onKeyDown(e)); 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 // Initiales Focus-Element setzen
requestAnimationFrame(() => this._initFocus()); requestAnimationFrame(() => this._initFocus());
} }
@ -25,6 +36,12 @@ class FocusManager {
autofocusEl.focus(); autofocusEl.focus();
return; 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]"); const first = document.querySelector("[data-focusable]");
if (first) first.focus(); if (first) first.focus();
} }
@ -61,11 +78,17 @@ class FocusManager {
_navigate(direction, e) { _navigate(direction, e) {
const active = document.activeElement; const active = document.activeElement;
// Input-Felder: Links/Rechts nicht abfangen (Cursor-Navigation) // Input-Felder: Links/Rechts nicht abfangen (Cursor-Navigation)
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) { if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (direction === "ArrowLeft" || direction === "ArrowRight") return; 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(); const focusables = this._getFocusableElements();
if (!focusables.length) return; if (!focusables.length) return;
@ -78,24 +101,84 @@ class FocusManager {
return; return;
} }
// Naechstes Element in Richtung finden (Nearest-Neighbor) // 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(); const current = active.getBoundingClientRect();
const cx = current.left + current.width / 2; // Nur wenn Element nah am oberen Rand ist (< 200px vom Viewport-Top)
const cy = current.top + current.height / 2; 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 currentRect = active.getBoundingClientRect();
const cx = currentRect.left + currentRect.width / 2;
const cy = currentRect.top + currentRect.height / 2;
let bestEl = null; let bestEl = null;
let bestDist = Infinity; 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; if (el === active) continue;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
// Element muss sichtbar sein
if (rect.width === 0 || rect.height === 0) continue; if (rect.width === 0 || rect.height === 0) continue;
const ex = rect.left + rect.width / 2; const ex = rect.left + rect.width / 2;
const ey = rect.top + rect.height / 2; const ey = rect.top + rect.height / 2;
// Pruefen ob Element in der richtigen Richtung liegt
const dx = ex - cx; const dx = ex - cx;
const dy = ey - cy; const dy = ey - cy;
@ -108,7 +191,6 @@ class FocusManager {
} }
if (!valid) continue; if (!valid) continue;
// Distanz berechnen (gewichtet: Hauptrichtung weniger, Querrichtung mehr)
let dist; let dist;
if (direction === "ArrowUp" || direction === "ArrowDown") { if (direction === "ArrowUp" || direction === "ArrowDown") {
dist = Math.abs(dy) + Math.abs(dx) * 3; dist = Math.abs(dy) + Math.abs(dx) * 3;
@ -124,7 +206,6 @@ class FocusManager {
if (bestEl) { if (bestEl) {
bestEl.focus(); bestEl.focus();
// Ins Sichtfeld scrollen
bestEl.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); bestEl.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
e.preventDefault(); e.preventDefault();
} }
@ -134,9 +215,21 @@ class FocusManager {
const active = document.activeElement; const active = document.activeElement;
if (!active || active === document.body) return; if (!active || active === document.body) return;
// Links, Buttons -> Click ausfuehren // Links, Buttons -> Click ausfuehren (natuerliches Enter-Verhalten)
if (active.tagName === "A" || active.tagName === "BUTTON") { 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; return;
} }
@ -149,15 +242,41 @@ class FocusManager {
_goBack(e) { _goBack(e) {
const active = document.activeElement; const active = document.activeElement;
// In Input-Feldern: Escape = Blur, Backspace = natuerlich
if (active && active.tagName === "INPUT") { // In Input-Feldern: Escape = Blur
if (e.key === "Escape") { if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (e.key === "Escape" || e.keyCode === 10009) {
active.blur(); active.blur();
e.preventDefault(); e.preventDefault();
} }
return; 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 // Zurueck navigieren
if (window.history.length > 1) { if (window.history.length > 1) {
window.history.back(); window.history.back();
@ -166,7 +285,6 @@ class FocusManager {
} }
_getFocusableElements() { _getFocusableElements() {
// Alle sichtbaren fokussierbaren Elemente
const elements = document.querySelectorAll("[data-focusable]"); const elements = document.querySelectorAll("[data-focusable]");
return Array.from(elements).filter(el => { return Array.from(elements).filter(el => {
if (el.offsetParent === null && el.style.position !== "fixed") return false; if (el.offsetParent === null && el.style.position !== "fixed") return false;

View file

@ -8,7 +8,12 @@
<title>Login - VideoKonverter TV</title> <title>Login - VideoKonverter TV</title>
</head> </head>
<body class="login-body"> <body class="login-body">
<div class="login-container"> <!-- Lade-Spinner (verhindert Flash des Login-Formulars) -->
<div class="login-loader" id="login-loader">
<div class="login-spinner"></div>
</div>
<div class="login-container" id="login-container" style="display:none">
<div class="login-card"> <div class="login-card">
<h1 class="login-title">VideoKonverter</h1> <h1 class="login-title">VideoKonverter</h1>
<p class="login-subtitle">TV-Streaming</p> <p class="login-subtitle">TV-Streaming</p>
@ -21,7 +26,7 @@
<div class="login-field"> <div class="login-field">
<label for="username">Benutzername</label> <label for="username">Benutzername</label>
<input type="text" id="username" name="username" <input type="text" id="username" name="username"
autocomplete="username" autofocus autocomplete="username"
data-focusable required> data-focusable required>
</div> </div>
<div class="login-field"> <div class="login-field">
@ -42,5 +47,17 @@
</form> </form>
</div> </div>
</div> </div>
<script>
// Kurz warten, dann Formular einblenden (Server hat ggf. schon Redirect gemacht)
setTimeout(function() {
document.getElementById('login-loader').style.display = 'none';
var container = document.getElementById('login-container');
container.style.display = '';
container.style.animation = 'fadeIn 0.3s ease';
var usernameInput = document.getElementById('username');
if (usernameInput) usernameInput.focus();
}, 300);
</script>
</body> </body>
</html> </html>

View file

@ -21,10 +21,15 @@
title="{{ t('settings.view_detail') }}"> title="{{ t('settings.view_detail') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg> <svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg>
</button> </button>
<button class="tv-view-btn {% if view == 'folder' %}active{% endif %}"
data-focusable data-view="folder" onclick="switchView('folder')"
title="{{ t('settings.view_folder') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><path d="M2 4h5l2 2h7v8a1 1 0 01-1 1H2a1 1 0 01-1-1V5a1 1 0 011-1z" fill="currentColor" opacity="0.7"/></svg>
</button>
</div> </div>
</div> </div>
<!-- Quellen-Tabs --> <!-- Quellen-Tabs (immer sichtbar) -->
{% if sources|length > 1 %} {% if sources|length > 1 %}
<div class="tv-tabs tv-source-tabs"> <div class="tv-tabs tv-source-tabs">
<a href="/tv/movies?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}" <a href="/tv/movies?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
@ -40,8 +45,8 @@
</div> </div>
{% endif %} {% endif %}
<!-- Filter-Leiste --> <!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
<div class="tv-filter-bar"> <div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% if genres %} {% if genres %}
<div class="tv-genre-chips"> <div class="tv-genre-chips">
<a href="/tv/movies?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}" <a href="/tv/movies?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
@ -135,7 +140,30 @@
{% endfor %} {% endfor %}
</div> </div>
{% if not movies %} <!-- === Ordner-Ansicht === -->
<div class="tv-folder-view tv-view-folder" id="view-folder" {% if view != 'folder' %}style="display:none"{% endif %}>
{% for src in folder_data %}
<div class="tv-folder-source">
{% if folder_data|length > 1 %}
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
{% endif %}
<div class="tv-folder-list">
{% for m in src.items %}
<a href="/tv/movies/{{ m.id }}" class="tv-folder-item" data-focusable>
<span class="tv-folder-icon">&#128193;</span>
<span class="tv-folder-name">{{ m.folder_name }}</span>
<span class="tv-folder-meta">
{% if m.title and m.title != m.folder_name %}{{ m.title }}{% endif %}
{% if m.year %} ({{ m.year }}){% endif %}
</span>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% if not movies and view != 'folder' %}
<div class="tv-empty">{{ t('movies.no_movies') }}</div> <div class="tv-empty">{{ t('movies.no_movies') }}</div>
{% endif %} {% endif %}
</section> </section>
@ -152,9 +180,13 @@ function switchView(mode) {
document.querySelectorAll('.tv-view-btn').forEach(btn => { document.querySelectorAll('.tv-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode); 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', { fetch('/tv/settings', {
method: 'POST', 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, body: 'movies_view=' + mode,
}).catch(() => {}); }).catch(() => {});
} }

View file

@ -21,10 +21,15 @@
title="{{ t('settings.view_detail') }}"> title="{{ t('settings.view_detail') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg> <svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg>
</button> </button>
<button class="tv-view-btn {% if view == 'folder' %}active{% endif %}"
data-focusable data-view="folder" onclick="switchView('folder')"
title="{{ t('settings.view_folder') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><path d="M2 4h5l2 2h7v8a1 1 0 01-1 1H2a1 1 0 01-1-1V5a1 1 0 011-1z" fill="currentColor" opacity="0.7"/></svg>
</button>
</div> </div>
</div> </div>
<!-- Quellen-Tabs --> <!-- Quellen-Tabs (immer sichtbar) -->
{% if sources|length > 1 %} {% if sources|length > 1 %}
<div class="tv-tabs tv-source-tabs"> <div class="tv-tabs tv-source-tabs">
<a href="/tv/series?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}" <a href="/tv/series?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
@ -40,8 +45,8 @@
</div> </div>
{% endif %} {% endif %}
<!-- Filter-Leiste --> <!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
<div class="tv-filter-bar"> <div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% if genres %} {% if genres %}
<div class="tv-genre-chips"> <div class="tv-genre-chips">
<a href="/tv/series?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}" <a href="/tv/series?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
@ -136,7 +141,30 @@
{% endfor %} {% endfor %}
</div> </div>
{% if not series %} <!-- === Ordner-Ansicht === -->
<div class="tv-folder-view tv-view-folder" id="view-folder" {% if view != 'folder' %}style="display:none"{% endif %}>
{% for src in folder_data %}
<div class="tv-folder-source">
{% if folder_data|length > 1 %}
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
{% endif %}
<div class="tv-folder-list">
{% for s in src.items %}
<a href="/tv/series/{{ s.id }}" class="tv-folder-item" data-focusable>
<span class="tv-folder-icon">&#128193;</span>
<span class="tv-folder-name">{{ s.folder_name }}</span>
<span class="tv-folder-meta">
{% if s.title and s.title != s.folder_name %}{{ s.title }} &middot; {% endif %}
{{ s.episode_count or 0 }} {{ t('series.episodes') }}
</span>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% if not series and view != 'folder' %}
<div class="tv-empty">{{ t('series.no_series') }}</div> <div class="tv-empty">{{ t('series.no_series') }}</div>
{% endif %} {% endif %}
</section> </section>
@ -153,9 +181,13 @@ function switchView(mode) {
document.querySelectorAll('.tv-view-btn').forEach(btn => { document.querySelectorAll('.tv-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode); 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', { fetch('/tv/settings', {
method: 'POST', 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, body: 'series_view=' + mode,
}).catch(() => {}); }).catch(() => {});
} }

View file

@ -84,7 +84,7 @@
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}> <div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
<div class="tv-episode-list"> <div class="tv-episode-list">
{% for ep in episodes %} {% for ep in episodes %}
<a href="/tv/player?v={{ ep.id }}" class="tv-episode-card" data-focusable> <a href="/tv/player?v={{ ep.id }}" class="tv-episode-card {% if ep.is_duplicate %}tv-ep-duplicate{% endif %}" data-focusable>
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="tv-ep-thumb"> <div class="tv-ep-thumb">
{% if ep.ep_image_url %} {% if ep.ep_image_url %}
@ -118,8 +118,11 @@
<p class="tv-ep-desc">{{ ep.ep_overview }}</p> <p class="tv-ep-desc">{{ ep.ep_overview }}</p>
{% endif %} {% endif %}
<div class="tv-ep-meta"> <div class="tv-ep-meta">
{% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span> {% endif %}
{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %} {% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %}
&middot; {{ ep.container|upper }}
{% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %} {% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %}
{% if ep.file_size %} &middot; {{ (ep.file_size / 1048576)|round|int }} MB{% endif %}
</div> </div>
</div> </div>
</a> </a>

View file

@ -22,11 +22,21 @@
<input type="text" name="display_name" value="{{ user.display_name or '' }}" <input type="text" name="display_name" value="{{ user.display_name or '' }}"
class="settings-input" data-focusable> class="settings-input" data-focusable>
</label> </label>
<label class="settings-label"> <div class="settings-label">
{{ t('settings.avatar_color') }} {{ t('settings.avatar_color') }}
<input type="color" name="avatar_color" value="{{ user.avatar_color or '#64b5f6' }}" <input type="hidden" name="avatar_color" id="avatar-color-input"
class="settings-color" data-focusable> value="{{ user.avatar_color or '#64b5f6' }}">
</label> <div class="color-picker-grid">
{% set colors = ['#64b5f6', '#42a5f5', '#5c6bc0', '#7e57c2', '#ab47bc',
'#ec407a', '#ef5350', '#ff7043', '#ffa726', '#ffca28',
'#66bb6a', '#26a69a', '#26c6da', '#78909c', '#8d6e63'] %}
{% for c in colors %}
<button type="button" class="color-swatch {% if (user.avatar_color or '#64b5f6') == c %}active{% endif %}"
style="background:{{ c }}" data-color="{{ c }}" data-focusable
onclick="selectColor(this, '{{ c }}')"></button>
{% endfor %}
</div>
</div>
</fieldset> </fieldset>
<!-- Sprache --> <!-- Sprache -->
@ -83,6 +93,7 @@
<option value="grid" {% if user.series_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option> <option value="grid" {% if user.series_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option>
<option value="list" {% if user.series_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option> <option value="list" {% if user.series_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option>
<option value="detail" {% if user.series_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option> <option value="detail" {% if user.series_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option>
<option value="folder" {% if user.series_view == 'folder' %}selected{% endif %}>{{ t('settings.view_folder') }}</option>
</select> </select>
</label> </label>
<label class="settings-label"> <label class="settings-label">
@ -91,6 +102,7 @@
<option value="grid" {% if user.movies_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option> <option value="grid" {% if user.movies_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option>
<option value="list" {% if user.movies_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option> <option value="list" {% if user.movies_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option>
<option value="detail" {% if user.movies_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option> <option value="detail" {% if user.movies_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option>
<option value="folder" {% if user.movies_view == 'folder' %}selected{% endif %}>{{ t('settings.view_folder') }}</option>
</select> </select>
</label> </label>
</fieldset> </fieldset>
@ -174,3 +186,15 @@
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
function selectColor(btn, color) {
// Aktive Markierung wechseln
document.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
btn.classList.add('active');
// Hidden-Input aktualisieren
document.getElementById('avatar-color-input').value = color;
}
</script>
{% endblock %}