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:
parent
6d0b8936c5
commit
61ca20bf8b
10 changed files with 479 additions and 41 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Naechstes Element in Richtung finden (Nearest-Neighbor)
|
||||||
const current = active.getBoundingClientRect();
|
const currentRect = active.getBoundingClientRect();
|
||||||
const cx = current.left + current.width / 2;
|
const cx = currentRect.left + currentRect.width / 2;
|
||||||
const cy = current.top + current.height / 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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">📁</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(() => {});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">📁</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 }} · {% 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(() => {});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
· {{ ep.container|upper }}
|
||||||
{% if ep.video_codec %} · {{ ep.video_codec }}{% endif %}
|
{% if ep.video_codec %} · {{ ep.video_codec }}{% endif %}
|
||||||
|
{% if ep.file_size %} · {{ (ep.file_size / 1048576)|round|int }} MB{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue