feat: VideoKonverter v5.1 - TV-App UX-Verbesserungen, PWA-Fix, Library-Features
- PWA Cookie-Fix: SameSite/Secure je nach Protokoll (HTTP=Lax, HTTPS=None+Secure) - Samsung Fernbedienung: Media-Key-Registrierung, Return/Back navigiert zurueck - Post-Play Navigation: Countdown auf naechster Episode nach Wiedergabe-Ende - Gelbe Staffel-Tabs: Gold-Farbe wenn alle Episoden gesehen - Episoden Card-Grid: Plex-Style Thumbnail-Grid mit Detail-Panel bei Focus - Weiche Uebergaenge: Fade-In/Out Animationen fuer Player und Seitenwechsel - Codec-Badge: AV1/HEVC Badge in Videobibliothek bei komplett konvertierten Serien - Separate Import-Fortschrittsbalken: Pro Import-Job eigener Balken - Android APK signiert (v2+v3 Scheme) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
93983cf6ee
commit
0d1619c6c9
24 changed files with 1041 additions and 137 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -38,5 +38,12 @@ Thumbs.db
|
|||
# Tizen Studio Workspace
|
||||
workspace/
|
||||
|
||||
# Android
|
||||
android-app/.gradle/
|
||||
android-app/build/
|
||||
android-app/app/build/
|
||||
android-app/local.properties
|
||||
android-app/*.apk.idsig
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
|
|
|
|||
BIN
android-app/VideoKonverter-v1.0.0.apk
Normal file
BIN
android-app/VideoKonverter-v1.0.0.apk
Normal file
Binary file not shown.
BIN
android-app/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android-app/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android-app/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android-app/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 899 B |
BIN
android-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android-app/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 816 B |
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
android-app/gradlew
vendored
Normal file
248
android-app/gradlew
vendored
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
android-app/gradlew.bat
vendored
Normal file
93
android-app/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
@ -5,7 +5,8 @@ pluginManagement {
|
|||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolution {
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
|
|
|||
BIN
android-app/videokonverter.jks
Normal file
BIN
android-app/videokonverter.jks
Normal file
Binary file not shown.
|
|
@ -102,6 +102,21 @@
|
|||
var SUPPORTED_VIDEO = ["h264", "hevc", "av1", "vp9"];
|
||||
var SUPPORTED_AUDIO = ["aac", "opus", "ac3", "eac3", "flac", "mp3", "vorbis", "pcm"];
|
||||
|
||||
// === Media-Keys registrieren (Tizen erfordert explizite Registrierung) ===
|
||||
try {
|
||||
var keysToRegister = [
|
||||
"MediaPlayPause", "MediaPlay", "MediaPause", "MediaStop",
|
||||
"MediaFastForward", "MediaRewind", "MediaTrackPrevious", "MediaTrackNext",
|
||||
"ColorF0Red", "ColorF1Green", "ColorF2Yellow", "ColorF3Blue"
|
||||
];
|
||||
keysToRegister.forEach(function(key) {
|
||||
try { tizen.tvinputdevice.registerKey(key); } catch (e) {}
|
||||
});
|
||||
console.info("[TizenApp] Media-Keys registriert: " + keysToRegister.join(", "));
|
||||
} catch (e) {
|
||||
console.warn("[TizenApp] tvinputdevice nicht verfuegbar:", e);
|
||||
}
|
||||
|
||||
// === Setup ===
|
||||
|
||||
var savedUrl = localStorage.getItem(STORAGE_KEY);
|
||||
|
|
@ -420,6 +435,14 @@
|
|||
|
||||
// === Tastatur-Handling ===
|
||||
|
||||
// Media-Key-Codes die bei AVPlay direkt behandelt oder an iframe weitergeleitet werden
|
||||
var MEDIA_KEYCODES = {
|
||||
415: "play", 19: "pause", 413: "stop",
|
||||
417: "fastforward", 412: "rewind",
|
||||
10252: "playpause", // MediaPlayPause
|
||||
403: "colorred", 404: "colorgreen", 405: "coloryellow", 406: "colorblue"
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", function(e) {
|
||||
// Samsung Remote: Return/Back = 10009
|
||||
if (e.keyCode === 10009) {
|
||||
|
|
@ -439,6 +462,46 @@
|
|||
|
||||
// iframe sichtbar -> History-Back im iframe
|
||||
// (wird vom iframe selbst gehandelt via keydown event)
|
||||
return;
|
||||
}
|
||||
|
||||
// Media-Keys behandeln
|
||||
if (e.keyCode in MEDIA_KEYCODES) {
|
||||
if (_avplayActive) {
|
||||
// AVPlay aktiv -> direkt steuern
|
||||
var action = MEDIA_KEYCODES[e.keyCode];
|
||||
if (action === "play" || action === "playpause") {
|
||||
if (!_playing) _avplay_resume(); else _avplay_pause();
|
||||
} else if (action === "pause") {
|
||||
_avplay_pause();
|
||||
} else if (action === "stop") {
|
||||
_avplay_stop();
|
||||
_sendEvent("stopped");
|
||||
} else if (action === "fastforward") {
|
||||
// +10 Sekunden
|
||||
try {
|
||||
var cur = webapis.avplay.getCurrentTime();
|
||||
_avplay_seek(cur + 10000);
|
||||
} catch (ex) {}
|
||||
} else if (action === "rewind") {
|
||||
// -10 Sekunden
|
||||
try {
|
||||
var cur2 = webapis.avplay.getCurrentTime();
|
||||
_avplay_seek(Math.max(0, cur2 - 10000));
|
||||
} catch (ex) {}
|
||||
}
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// AVPlay nicht aktiv -> Key-Event an iframe weiterleiten
|
||||
if (_iframe && _iframe.contentWindow) {
|
||||
_sendToIframe({
|
||||
type: "vknative_keyevent",
|
||||
keyCode: e.keyCode
|
||||
});
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -151,12 +151,53 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
|
||||
# === Serien ===
|
||||
|
||||
# Mapping: Preset video_codec -> ffprobe-Codec-Name in der DB
|
||||
_PRESET_TO_DB_CODEC = {
|
||||
"av1_vaapi": "av1", "libsvtav1": "av1",
|
||||
"hevc_vaapi": "hevc", "libx265": "hevc",
|
||||
"h264_vaapi": "h264", "libx264": "h264",
|
||||
}
|
||||
|
||||
async def get_series(request: web.Request) -> web.Response:
|
||||
"""GET /api/library/series"""
|
||||
"""GET /api/library/series - mit optionalem Codec-Badge"""
|
||||
path_id = request.query.get("path_id")
|
||||
if path_id:
|
||||
path_id = int(path_id)
|
||||
series = await library_service.get_series_list(path_id)
|
||||
|
||||
# Codec-Badge: Pruefen ob alle Videos einer Serie den Ziel-Codec haben
|
||||
preset = config.default_preset
|
||||
preset_codec = preset.get("video_codec", "")
|
||||
target_codec = _PRESET_TO_DB_CODEC.get(preset_codec, "")
|
||||
|
||||
if target_codec and series:
|
||||
pool = library_service._db_pool
|
||||
if pool:
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute("""
|
||||
SELECT series_id,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN LOWER(video_codec) = %s
|
||||
THEN 1 ELSE 0 END) AS matching
|
||||
FROM library_videos
|
||||
WHERE series_id IS NOT NULL
|
||||
GROUP BY series_id
|
||||
""", (target_codec,))
|
||||
codec_stats = {
|
||||
r["series_id"]: r
|
||||
for r in await cur.fetchall()
|
||||
}
|
||||
# Badge zuweisen wenn alle Videos passen
|
||||
for s in series:
|
||||
stats = codec_stats.get(s.get("id"))
|
||||
if (stats and stats["total"] > 0
|
||||
and stats["total"] == stats["matching"]):
|
||||
s["codec_badge"] = target_codec.upper()
|
||||
except Exception as e:
|
||||
logging.warning(f"Codec-Badge Query fehlgeschlagen: {e}")
|
||||
|
||||
return web.json_response({"series": series})
|
||||
|
||||
async def get_series_detail(request: web.Request) -> web.Response:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,15 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
|
||||
# --- Auth-Hilfsfunktionen ---
|
||||
|
||||
def _cookie_params(request: web.Request) -> dict:
|
||||
"""SameSite-Parameter je nach Protokoll: None+Secure bei HTTPS, Lax bei HTTP.
|
||||
Browser verwerfen SameSite=None ohne Secure-Flag stillschweigend."""
|
||||
is_https = (request.secure
|
||||
or request.headers.get("X-Forwarded-Proto") == "https")
|
||||
if is_https:
|
||||
return {"samesite": "None", "secure": True}
|
||||
return {"samesite": "Lax"}
|
||||
|
||||
async def get_tv_user(request: web.Request) -> dict | None:
|
||||
"""Prueft Session-Cookie, gibt User zurueck oder None"""
|
||||
session_id = request.cookies.get("vk_session")
|
||||
|
|
@ -120,7 +129,8 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
resp.set_cookie(
|
||||
"vk_session", session_id,
|
||||
max_age=10 * 365 * 24 * 3600,
|
||||
httponly=True, samesite="None", path="/",
|
||||
httponly=True, path="/",
|
||||
**_cookie_params(request),
|
||||
)
|
||||
return resp
|
||||
elif len(profiles) > 1:
|
||||
|
|
@ -168,17 +178,15 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
resp.set_cookie(
|
||||
"vk_session", session_id,
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
samesite="None",
|
||||
path="/",
|
||||
httponly=True, path="/",
|
||||
**_cookie_params(request),
|
||||
)
|
||||
# Client-ID Cookie (immer permanent)
|
||||
resp.set_cookie(
|
||||
"vk_client_id", client_id,
|
||||
max_age=10 * 365 * 24 * 3600, # 10 Jahre
|
||||
httponly=True,
|
||||
samesite="None",
|
||||
path="/",
|
||||
httponly=True, path="/",
|
||||
**_cookie_params(request),
|
||||
)
|
||||
return resp
|
||||
|
||||
|
|
@ -626,17 +634,39 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
# Watch-Threshold aus Config (Standard: 90%, wie Plex)
|
||||
watched_threshold = config.tv_config.get("watched_threshold_pct", 90)
|
||||
|
||||
# Pro Staffel: Anzahl gesamt und gesehen berechnen
|
||||
season_watched = {}
|
||||
for sn, eps in seasons.items():
|
||||
total = len(eps)
|
||||
seen = sum(1 for ep in eps
|
||||
if ep.get("progress_pct", 0) >= watched_threshold)
|
||||
season_watched[sn] = {
|
||||
"total": total, "seen": seen,
|
||||
"all_seen": total > 0 and seen == total,
|
||||
}
|
||||
|
||||
# Post-Play Parameter (vom Player nach Episoden-Ende)
|
||||
post_play = request.query.get("post_play") == "1"
|
||||
next_video_id = request.query.get("next_video", "")
|
||||
countdown = int(request.query.get("countdown", "10") or "10")
|
||||
last_watched_id = request.query.get("last_watched", "")
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/series_detail.html", request, {
|
||||
"user": user,
|
||||
"active": "series",
|
||||
"series": series,
|
||||
"seasons": dict(sorted(seasons.items())),
|
||||
"season_watched": season_watched,
|
||||
"in_watchlist": in_watchlist,
|
||||
"user_rating": user_rating,
|
||||
"avg_rating": avg_rating,
|
||||
"tvdb_score": series.get("tvdb_score"),
|
||||
"watched_threshold_pct": watched_threshold,
|
||||
"post_play": post_play,
|
||||
"next_video_id": next_video_id,
|
||||
"countdown": countdown,
|
||||
"last_watched_id": last_watched_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -912,6 +942,11 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
if client_id:
|
||||
client = await auth_service.get_client_settings(client_id)
|
||||
|
||||
# URL zur Seriendetail-Seite (fuer Post-Play Navigation)
|
||||
series_detail_url = ""
|
||||
if video.get("series_id"):
|
||||
series_detail_url = f"/tv/series/{video['series_id']}"
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/player.html", request, {
|
||||
"user": user,
|
||||
|
|
@ -920,6 +955,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
"start_pos": start_pos,
|
||||
"next_video": next_video,
|
||||
"next_title": next_title,
|
||||
"series_detail_url": series_detail_url,
|
||||
"client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo",
|
||||
"client_stream_quality": client.get("stream_quality", "hd") if client else "hd",
|
||||
}
|
||||
|
|
@ -1174,12 +1210,14 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
resp.set_cookie(
|
||||
"vk_session", session_id,
|
||||
max_age=10 * 365 * 24 * 3600,
|
||||
httponly=True, samesite="None", path="/",
|
||||
httponly=True, path="/",
|
||||
**_cookie_params(request),
|
||||
)
|
||||
resp.set_cookie(
|
||||
"vk_client_id", client_id,
|
||||
max_age=10 * 365 * 24 * 3600,
|
||||
httponly=True, samesite="None", path="/",
|
||||
httponly=True, path="/",
|
||||
**_cookie_params(request),
|
||||
)
|
||||
return resp
|
||||
|
||||
|
|
|
|||
|
|
@ -957,6 +957,10 @@ legend {
|
|||
}
|
||||
.series-card:hover { border-color: #444; }
|
||||
|
||||
.series-poster-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.series-poster {
|
||||
width: 60px;
|
||||
height: 90px;
|
||||
|
|
@ -964,6 +968,19 @@ legend {
|
|||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.series-codec-badge {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 2px;
|
||||
background: #2e7d32;
|
||||
color: #fff;
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.series-poster-placeholder {
|
||||
width: 60px;
|
||||
height: 90px;
|
||||
|
|
@ -1946,3 +1963,37 @@ legend {
|
|||
.artwork-gallery { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
|
||||
.lib-section-header { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
|
||||
/* === Import: Separate Fortschrittsbalken pro Job === */
|
||||
.import-job-progress {
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.import-job-progress:last-child { border-bottom: none; }
|
||||
.import-job-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.import-job-name {
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 70%;
|
||||
}
|
||||
.import-job-status {
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
.import-job-progress .progress-container {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.import-job-text {
|
||||
font-size: 0.78rem;
|
||||
display: block;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -465,9 +465,14 @@ function renderSeriesGrid(series) {
|
|||
const tvdbBtn = s.tvdb_id
|
||||
? `<span class="tag ok">TVDB</span>`
|
||||
: `<button class="btn-small btn-secondary" onclick="event.stopPropagation(); openTvdbModal(${s.id}, '${escapeAttr(s.folder_name)}')">TVDB zuordnen</button>`;
|
||||
const codecBadge = s.codec_badge
|
||||
? `<span class="series-codec-badge">${escapeHtml(s.codec_badge)}</span>` : "";
|
||||
|
||||
html += `<div class="series-card" onclick="openSeriesDetail(${s.id})">
|
||||
${poster}
|
||||
<div class="series-poster-wrap">
|
||||
${poster}
|
||||
${codecBadge}
|
||||
</div>
|
||||
<div class="series-info">
|
||||
<h4 title="${escapeHtml(s.folder_path || '')}">${escapeHtml(s.title || s.folder_name)}</h4>
|
||||
${genres}
|
||||
|
|
@ -2864,16 +2869,38 @@ async function executeImport() {
|
|||
// WebSocket-Handler fuer Import-Fortschritt
|
||||
function handleImportWS(data) {
|
||||
if (!data || !data.job_id) return;
|
||||
// Nur Updates fuer aktuellen Job
|
||||
if (data.job_id !== currentImportJobId) return;
|
||||
|
||||
_importWsActive = true;
|
||||
// Polling abschalten wenn WS liefert
|
||||
stopImportPolling();
|
||||
|
||||
// Import-Progress-Container sichtbar machen
|
||||
const progressEl = document.getElementById("import-progress");
|
||||
if (progressEl) progressEl.style.display = "";
|
||||
|
||||
// Pro Job ein eigenes Element erstellen/finden
|
||||
const container = document.getElementById("import-jobs-container");
|
||||
if (!container) return;
|
||||
let jobEl = document.getElementById("import-job-" + data.job_id);
|
||||
if (!jobEl) {
|
||||
jobEl = document.createElement("div");
|
||||
jobEl.id = "import-job-" + data.job_id;
|
||||
jobEl.className = "import-job-progress";
|
||||
const jobName = data.source_path || data.job_name || "Job #" + data.job_id;
|
||||
const shortName = jobName.split("/").pop() || jobName;
|
||||
jobEl.innerHTML =
|
||||
'<div class="import-job-header">' +
|
||||
'<span class="import-job-name" title="' + escapeHtml(jobName) + '">' + escapeHtml(shortName) + '</span>' +
|
||||
'<span class="import-job-status"></span>' +
|
||||
'</div>' +
|
||||
'<div class="progress-container"><div class="progress-bar import-job-bar"></div></div>' +
|
||||
'<span class="text-muted import-job-text">Starte...</span>';
|
||||
container.appendChild(jobEl);
|
||||
}
|
||||
|
||||
const bar = jobEl.querySelector(".import-job-bar");
|
||||
const statusText = jobEl.querySelector(".import-job-text");
|
||||
const statusBadge = jobEl.querySelector(".import-job-status");
|
||||
|
||||
const status = data.status || "";
|
||||
const total = data.total || 1;
|
||||
const processed = data.processed || 0;
|
||||
|
|
@ -2887,34 +2914,35 @@ function handleImportWS(data) {
|
|||
pct += (bytesDone / bytesTotal) * (100 / total);
|
||||
}
|
||||
pct = Math.min(Math.round(pct), 100);
|
||||
|
||||
const bar = document.getElementById("import-bar");
|
||||
const statusText = document.getElementById("import-status-text");
|
||||
if (bar) bar.style.width = pct + "%";
|
||||
|
||||
if (status === "analyzing") {
|
||||
if (statusText) statusText.textContent =
|
||||
`Analysiere: ${processed} / ${total} - ${curFile}`;
|
||||
if (statusBadge) statusBadge.textContent = "Analyse";
|
||||
} else if (status === "embedding") {
|
||||
if (statusText) statusText.textContent =
|
||||
`Metadaten schreiben: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`;
|
||||
`Metadaten: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`;
|
||||
if (statusBadge) statusBadge.textContent = "Metadaten";
|
||||
} else if (status === "importing") {
|
||||
let txt = `Importiere: ${processed} / ${total} Dateien`;
|
||||
let txt = `${processed} / ${total} Dateien`;
|
||||
if (curFile && bytesTotal > 0 && processed < total) {
|
||||
const curPct = Math.round((bytesDone / bytesTotal) * 100);
|
||||
txt += ` - ${curFile.substring(0, 40)}... (${formatSize(bytesDone)} / ${formatSize(bytesTotal)}, ${curPct}%)`;
|
||||
txt += ` - ${curFile.substring(0, 40)}... (${formatSize(bytesDone)}/${formatSize(bytesTotal)})`;
|
||||
} else {
|
||||
txt += ` (${pct}%)`;
|
||||
}
|
||||
if (statusText) statusText.textContent = txt;
|
||||
if (statusBadge) statusBadge.textContent = pct + "%";
|
||||
} else if (status === "done" || status === "error") {
|
||||
if (bar) bar.style.width = "100%";
|
||||
if (statusText) statusText.textContent =
|
||||
status === "done"
|
||||
? `Import abgeschlossen (${processed} Dateien)`
|
||||
: `Import mit Fehlern beendet`;
|
||||
if (status === "done") {
|
||||
if (statusBadge) { statusBadge.textContent = "Fertig"; statusBadge.style.color = "#4caf50"; }
|
||||
} else {
|
||||
if (statusBadge) { statusBadge.textContent = "Fehler"; statusBadge.style.color = "#f44336"; }
|
||||
}
|
||||
|
||||
// Ergebnis per REST holen fuer Details
|
||||
// Ergebnis per REST holen
|
||||
fetch(`/api/library/import/${data.job_id}`)
|
||||
.then(r => r.json())
|
||||
.then(result => {
|
||||
|
|
@ -2923,9 +2951,8 @@ function handleImportWS(data) {
|
|||
const errors = items.filter(i => i.status === "error").length;
|
||||
const skipped = items.filter(i => i.status === "skipped").length;
|
||||
if (statusText) statusText.textContent =
|
||||
`Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
|
||||
`${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
|
||||
|
||||
// Ziel-Pfad scannen
|
||||
const job = result.job;
|
||||
if (job && job.target_library_id && imported > 0) {
|
||||
fetch(`/api/library/scan/${job.target_library_id}`, {method: "POST"})
|
||||
|
|
@ -2963,72 +2990,27 @@ function startImportPolling() {
|
|||
|
||||
if (data.error) {
|
||||
stopImportPolling();
|
||||
document.getElementById("import-status-text").textContent = "Fehler: " + data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
const job = data.job;
|
||||
if (!job) return;
|
||||
|
||||
const total = job.total_files || 1;
|
||||
const done = job.processed_files || 0;
|
||||
// handleImportWS wiederverwenden (rendert in den neuen Container)
|
||||
handleImportWS({
|
||||
job_id: currentImportJobId,
|
||||
status: job.status,
|
||||
total: job.total_files || 1,
|
||||
processed: job.processed_files || 0,
|
||||
current_file: job.current_file_name || "",
|
||||
bytes_done: job.current_file_bytes || 0,
|
||||
bytes_total: job.current_file_total || 0,
|
||||
source_path: job.source_path || "",
|
||||
});
|
||||
|
||||
// Byte-Fortschritt der aktuellen Datei
|
||||
const curFile = job.current_file_name || "";
|
||||
const curBytes = job.current_file_bytes || 0;
|
||||
const curTotal = job.current_file_total || 0;
|
||||
|
||||
// Prozent: fertige Dateien + anteilig aktuelle Datei
|
||||
let pct = (done / total) * 100;
|
||||
if (curTotal > 0 && done < total) {
|
||||
pct += (curBytes / curTotal) * (100 / total);
|
||||
}
|
||||
pct = Math.min(Math.round(pct), 100);
|
||||
|
||||
document.getElementById("import-bar").style.width = pct + "%";
|
||||
|
||||
// Status-Text mit Byte-Fortschritt
|
||||
let statusText = `Importiere: ${done} / ${total} Dateien`;
|
||||
if (curFile && curTotal > 0 && done < total) {
|
||||
const curPct = Math.round((curBytes / curTotal) * 100);
|
||||
statusText += ` - ${curFile.substring(0, 40)}... (${formatSize(curBytes)} / ${formatSize(curTotal)}, ${curPct}%)`;
|
||||
} else {
|
||||
statusText += ` (${pct}%)`;
|
||||
}
|
||||
document.getElementById("import-status-text").textContent = statusText;
|
||||
|
||||
// Fertig?
|
||||
// Fertig? (handleImportWS uebernimmt Ergebnis-Laden und Scan)
|
||||
if (job.status === "done" || job.status === "error") {
|
||||
stopImportPolling();
|
||||
document.getElementById("import-bar").style.width = "100%";
|
||||
|
||||
// Zaehle Ergebnisse
|
||||
const items = data.items || [];
|
||||
const imported = items.filter(i => i.status === "done").length;
|
||||
const errors = items.filter(i => i.status === "error").length;
|
||||
const skipped = items.filter(i => i.status === "skipped").length;
|
||||
|
||||
document.getElementById("import-status-text").textContent =
|
||||
`Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
|
||||
|
||||
// Nur Ziel-Pfad scannen und neu laden (statt alles)
|
||||
const targetPathId = job.target_library_id;
|
||||
if (targetPathId && imported > 0) {
|
||||
fetch(`/api/library/scan/${targetPathId}`, {method: "POST"})
|
||||
.then(() => {
|
||||
setTimeout(() => {
|
||||
loadSectionData(targetPathId);
|
||||
loadStats();
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
reloadAllSections();
|
||||
loadStats();
|
||||
});
|
||||
} else {
|
||||
reloadAllSections();
|
||||
loadStats();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Import-Polling Fehler:", e);
|
||||
|
|
|
|||
|
|
@ -1736,3 +1736,243 @@ textarea.input-editing {
|
|||
.tv-alpha-sidebar { right: 1px; padding: 2px 1px; }
|
||||
.tv-alpha-letter { width: 16px; height: 14px; font-size: 0.5rem; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Phase 3-6: Post-Play, Gelbe Tabs, Episode Grid, Animationen
|
||||
============================================================ */
|
||||
|
||||
/* --- Gelbe Staffel-Tabs bei komplett gesehen (Phase 4) --- */
|
||||
.tv-tab-complete {
|
||||
background: #b8860b !important;
|
||||
color: #fff !important;
|
||||
border-color: #9a7209 !important;
|
||||
}
|
||||
.tv-tab-complete:hover, .tv-tab-complete:focus {
|
||||
background: #d4a017 !important;
|
||||
}
|
||||
.tv-tab-complete.active {
|
||||
background: #d4a017 !important;
|
||||
border-color: #d4a017 !important;
|
||||
}
|
||||
.tv-tab-check {
|
||||
margin-left: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- Episode Card-Grid (Phase 5, Plex-Style) --- */
|
||||
.tv-episode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.tv-episode-tile {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.tv-episode-tile:hover {
|
||||
transform: scale(1.03);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
.tv-ep-tile-link {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tv-ep-tile-link:focus {
|
||||
outline: none;
|
||||
}
|
||||
.tv-ep-tile-link:focus .tv-ep-thumb {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.tv-episode-tile:focus-within {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
.tv-ep-tile-label {
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tv-ep-tile-title {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.tv-ep-tile-mark {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.5);
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: rgba(255,255,255,0.6);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
z-index: 2;
|
||||
}
|
||||
.tv-episode-tile:hover .tv-ep-tile-mark,
|
||||
.tv-episode-tile:focus-within .tv-ep-tile-mark,
|
||||
.tv-ep-tile-mark:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
.tv-ep-tile-mark.active {
|
||||
opacity: 1;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.tv-ep-tile-mark:hover, .tv-ep-tile-mark:focus {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
outline: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Detail-Panel oben (zeigt Beschreibung der fokussierten Episode) */
|
||||
.tv-ep-detail-panel {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 1rem;
|
||||
min-height: 60px;
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.tv-ep-detail-panel.visible {
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
.tv-ep-detail-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.tv-ep-detail-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tv-ep-detail-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* --- Post-Play: Naechste Episode Countdown auf Karte (Phase 3) --- */
|
||||
.tv-ep-next-loading {
|
||||
border: 2px solid var(--accent);
|
||||
animation: pulse-border 1.5s ease-in-out infinite;
|
||||
transform: scale(1.05);
|
||||
z-index: 5;
|
||||
}
|
||||
.tv-ep-countdown-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.75);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 3;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
.tv-ep-countdown-num {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
.tv-ep-countdown-label {
|
||||
font-size: 0.8rem;
|
||||
color: #ccc;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
@keyframes pulse-border {
|
||||
0%, 100% { border-color: var(--accent); box-shadow: 0 0 8px rgba(229,160,13,0.3); }
|
||||
50% { border-color: rgba(229,160,13,0.3); box-shadow: none; }
|
||||
}
|
||||
|
||||
/* --- Weiche Uebergangseffekte (Phase 6) --- */
|
||||
|
||||
/* Seiten-Fade-In */
|
||||
.tv-section {
|
||||
animation: fadeSlideIn 0.3s ease;
|
||||
}
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Player Fade-In */
|
||||
.player-wrapper {
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
/* Serien/Film-Karten Hover-Scale */
|
||||
.tv-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.tv-card:hover, .tv-card:focus-within {
|
||||
transform: scale(1.04);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Staffel-Tab-Wechsel: Sections sanft einblenden */
|
||||
.tv-season {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
/* Episode-Tile Duplikat-Markierung */
|
||||
.tv-episode-tile.tv-ep-duplicate {
|
||||
border-left: 3px solid var(--warn, #ff9800);
|
||||
}
|
||||
.tv-episode-tile .tv-ep-dup-badge {
|
||||
font-size: 0.6rem;
|
||||
background: var(--warn, #ff9800);
|
||||
color: #000;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tv-episode-tile.tv-ep-seen { opacity: 0.6; }
|
||||
.tv-episode-tile.tv-ep-seen:hover,
|
||||
.tv-episode-tile.tv-ep-seen:focus-within { opacity: 1; }
|
||||
|
||||
/* Responsive: Episode-Grid */
|
||||
@media (min-width: 1200px) {
|
||||
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.5rem; }
|
||||
.tv-ep-tile-label { font-size: 0.75rem; padding: 0.3rem; }
|
||||
.tv-ep-detail-panel.visible { max-height: 150px; }
|
||||
.tv-ep-detail-desc { -webkit-line-clamp: 2; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 0.4rem; }
|
||||
.tv-ep-tile-label { font-size: 0.7rem; }
|
||||
.tv-ep-tile-mark { width: 24px; height: 24px; font-size: 0.7rem; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ function initPlayer(opts) {
|
|||
const btnStillNo = document.getElementById("btn-still-no");
|
||||
if (btnStillNo) btnStillNo.addEventListener("click", () => {
|
||||
saveProgress();
|
||||
window.history.back();
|
||||
goBack();
|
||||
});
|
||||
|
||||
// Tastatur-Steuerung
|
||||
|
|
@ -528,6 +528,12 @@ async function _tryNativeDirectPlay(startPosSec) {
|
|||
onPause();
|
||||
}
|
||||
};
|
||||
// Return/Back-Taste auf Fernbedienung -> zurueck ins Menue navigieren
|
||||
window._vkOnStopped = function() {
|
||||
saveProgress();
|
||||
_cleanupNativePlayer();
|
||||
goBack();
|
||||
};
|
||||
|
||||
// Direct-Play starten
|
||||
var directUrl = info.direct_play_url;
|
||||
|
|
@ -599,6 +605,7 @@ function _cleanupNativePlayer() {
|
|||
window._vkOnError = null;
|
||||
window._vkOnBuffering = null;
|
||||
window._vkOnPlayStateChanged = null;
|
||||
window._vkOnStopped = null;
|
||||
}
|
||||
|
||||
/** Fallback von VKNative auf HLS */
|
||||
|
|
@ -810,11 +817,26 @@ async function onEnded() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Naechste Episode
|
||||
if (cfg.nextVideoId && cfg.autoplay) {
|
||||
showNextEpisodeOverlay();
|
||||
// Player bereinigen
|
||||
if (useNativePlayer) _cleanupNativePlayer();
|
||||
if (useDirectPlay) _cleanupDirectPlay();
|
||||
cleanupHLS();
|
||||
|
||||
// Zurueck zur Seriendetail-Seite navigieren
|
||||
if (cfg.seriesDetailUrl) {
|
||||
if (cfg.nextVideoId && cfg.autoplay) {
|
||||
// Autoplay: Serie mit Countdown auf naechster Episode anzeigen
|
||||
window.location.href = cfg.seriesDetailUrl +
|
||||
"?post_play=1&next_video=" + cfg.nextVideoId +
|
||||
"&countdown=" + (cfg.autoplayCountdown || 10);
|
||||
} else {
|
||||
// Kein Autoplay: Serie zeigen, zur letzten Episode scrollen
|
||||
window.location.href = cfg.seriesDetailUrl +
|
||||
"?last_watched=" + cfg.videoId;
|
||||
}
|
||||
} else {
|
||||
setTimeout(() => window.history.back(), 2000);
|
||||
// Kein Serien-Kontext (Film etc.) -> einfach zurueck
|
||||
goBack();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1187,6 +1209,18 @@ function switchSpeed(s) {
|
|||
renderPopup(popupSection);
|
||||
}
|
||||
|
||||
// === Navigation ===
|
||||
|
||||
/** Zurueck mit Fade-Out-Animation */
|
||||
function goBack() {
|
||||
var wrapper = document.getElementById("player-wrapper");
|
||||
if (wrapper) {
|
||||
wrapper.style.transition = "opacity 0.3s ease";
|
||||
wrapper.style.opacity = "0";
|
||||
}
|
||||
setTimeout(function() { window.history.back(); }, 300);
|
||||
}
|
||||
|
||||
// === Naechste Episode ===
|
||||
|
||||
function showNextEpisodeOverlay() {
|
||||
|
|
@ -1219,7 +1253,7 @@ function cancelNext() {
|
|||
if (nextCountdown) clearInterval(nextCountdown);
|
||||
const overlay = document.getElementById("next-overlay");
|
||||
if (overlay) overlay.style.display = "none";
|
||||
setTimeout(() => window.history.back(), 500);
|
||||
goBack();
|
||||
}
|
||||
|
||||
// === D-Pad Navigation fuer Fernbedienung ===
|
||||
|
|
@ -1386,7 +1420,7 @@ function onKeyDown(e) {
|
|||
if (useNativePlayer) _cleanupNativePlayer();
|
||||
if (useDirectPlay) _cleanupDirectPlay();
|
||||
cleanupHLS();
|
||||
setTimeout(() => window.history.back(), 100);
|
||||
goBack();
|
||||
e.preventDefault(); break;
|
||||
case "f":
|
||||
toggleFullscreen(); e.preventDefault(); break;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,18 @@
|
|||
case "vknative_event":
|
||||
_handleParentEvent(data.event, data.detail || {});
|
||||
break;
|
||||
|
||||
case "vknative_keyevent":
|
||||
// Media-Key vom Parent weitergeleitet -> als KeyboardEvent dispatchen
|
||||
if (data.keyCode) {
|
||||
var keyEvt = new KeyboardEvent("keydown", {
|
||||
keyCode: data.keyCode,
|
||||
which: data.keyCode,
|
||||
bubbles: true,
|
||||
});
|
||||
document.dispatchEvent(keyEvt);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -120,6 +132,8 @@
|
|||
_playing = false;
|
||||
_currentTimeMs = 0;
|
||||
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false);
|
||||
// Zurueck-Navigation ausloesen (Return-Taste auf Fernbedienung)
|
||||
if (window._vkOnStopped) window._vkOnStopped();
|
||||
break;
|
||||
|
||||
case "duration":
|
||||
|
|
|
|||
|
|
@ -386,12 +386,9 @@
|
|||
</div>
|
||||
<div id="import-items-list" class="import-items-list"></div>
|
||||
</div>
|
||||
<!-- Schritt 3: Fortschritt -->
|
||||
<!-- Schritt 3: Fortschritt (mehrere Jobs gleichzeitig) -->
|
||||
<div id="import-progress" style="display:none; padding:1rem;">
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar" id="import-bar"></div>
|
||||
</div>
|
||||
<span class="text-muted" id="import-status-text">Importiere...</span>
|
||||
<div id="import-jobs-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@
|
|||
subtitlesEnabled: {{ 'true' if user.subtitles_enabled else 'false' }},
|
||||
soundMode: "{{ client_sound_mode or 'stereo' }}",
|
||||
streamQuality: "{{ client_stream_quality or 'hd' }}",
|
||||
seriesDetailUrl: "{{ series_detail_url or '' }}",
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -71,15 +71,25 @@
|
|||
{% if seasons %}
|
||||
<div class="tv-tabs" id="season-tabs">
|
||||
{% for sn in seasons.keys() %}
|
||||
<button class="tv-tab {% if loop.first %}active{% endif %}"
|
||||
data-focusable
|
||||
<button class="tv-tab {% if loop.first %}active{% endif %} {% if season_watched.get(sn, {}).get('all_seen') %}tv-tab-complete{% endif %}"
|
||||
data-focusable data-season="{{ sn }}"
|
||||
onclick="showSeason({{ sn }})">
|
||||
{% if sn == 0 %}{{ t('series.specials') }}{% else %}{{ t('series.season') }} {{ sn }}{% endif %}
|
||||
{% if season_watched.get(sn, {}).get('all_seen') %}<span class="tv-tab-check">✓</span>{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Episoden pro Staffel -->
|
||||
<!-- Episoden-Detail-Panel (wird per JS bei Focus befuellt) -->
|
||||
<div class="tv-ep-detail-panel" id="ep-detail-panel">
|
||||
<div class="tv-ep-detail-inner">
|
||||
<h3 class="tv-ep-detail-title" id="ep-detail-title"></h3>
|
||||
<p class="tv-ep-detail-desc" id="ep-detail-desc"></p>
|
||||
<p class="tv-ep-detail-meta" id="ep-detail-meta"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episoden pro Staffel (Card-Grid) -->
|
||||
{% for sn, episodes in seasons.items() %}
|
||||
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
|
||||
<div class="tv-season-actions">
|
||||
|
|
@ -88,12 +98,14 @@
|
|||
✓ {{ t('status.mark_season') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tv-episode-list">
|
||||
<div class="tv-episode-grid">
|
||||
{% for ep in episodes %}
|
||||
<div class="tv-episode-card {% if ep.is_duplicate %}tv-ep-duplicate{% endif %} {% if ep.progress_pct >= watched_threshold_pct|default(90) %}tv-ep-seen{% endif %}"
|
||||
data-video-id="{{ ep.id }}">
|
||||
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-link" data-focusable>
|
||||
<!-- Thumbnail -->
|
||||
<div class="tv-episode-tile {% if ep.is_duplicate %}tv-ep-duplicate{% endif %} {% if ep.progress_pct >= watched_threshold_pct|default(90) %}tv-ep-seen{% endif %}"
|
||||
data-video-id="{{ ep.id }}"
|
||||
data-ep-title="{{ ep.episode_title or ep.file_name }}"
|
||||
data-ep-desc="{{ ep.ep_overview|default('', true)|e }}"
|
||||
data-ep-meta="{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %} · {{ ep.container|upper|default('') }} {% if ep.video_codec %}· {{ ep.video_codec }}{% endif %} {% if ep.file_size %}· {{ (ep.file_size / 1048576)|round|int }} MB{% endif %} {% if ep.duration_sec %}· {{ (ep.duration_sec / 60)|round|int }} Min{% endif %}">
|
||||
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-tile-link" data-focusable>
|
||||
<div class="tv-ep-thumb">
|
||||
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
|
||||
{% if ep.progress_pct > 0 and ep.progress_pct < watched_threshold_pct|default(90) %}
|
||||
|
|
@ -108,34 +120,14 @@
|
|||
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="tv-ep-info">
|
||||
<div class="tv-ep-header">
|
||||
<span class="tv-ep-num">
|
||||
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}
|
||||
</span>
|
||||
<span class="tv-ep-title">
|
||||
{{ ep.episode_title or ep.file_name }}
|
||||
</span>
|
||||
</div>
|
||||
{% if ep.ep_overview %}
|
||||
<p class="tv-ep-desc">{{ ep.ep_overview }}</p>
|
||||
{% endif %}
|
||||
<div class="tv-ep-meta">
|
||||
{% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span> {% endif %}
|
||||
{% if user.show_tech_info %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tv-ep-tile-label">
|
||||
<span class="tv-ep-num">{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}</span>
|
||||
<span class="tv-ep-tile-title">{{ ep.episode_title or '' }}</span>
|
||||
{% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
<!-- Gesehen-Button -->
|
||||
<button class="tv-ep-mark-btn {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
|
||||
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
|
||||
data-focusable
|
||||
title="{% if ep.progress_pct >= watched_threshold_pct|default(90) %}{{ t('status.mark_unwatched') }}{% else %}{{ t('status.mark_watched') }}{% endif %}"
|
||||
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
|
||||
✓
|
||||
</button>
|
||||
|
|
@ -223,7 +215,7 @@ function setRating(value) {
|
|||
|
||||
function toggleWatched(videoId, btn) {
|
||||
// Aktuellen Status pruefen und togglen
|
||||
const card = btn.closest('.tv-episode-card');
|
||||
const card = btn.closest('.tv-episode-tile');
|
||||
const isSeen = card.classList.contains('tv-ep-seen');
|
||||
const newPct = isSeen ? 0 : 100;
|
||||
|
||||
|
|
@ -263,10 +255,9 @@ function toggleWatched(videoId, btn) {
|
|||
}
|
||||
|
||||
function markSeasonWatched(seriesId, seasonNum) {
|
||||
// Alle Episoden der Staffel als gesehen markieren
|
||||
const season = document.getElementById('season-' + seasonNum);
|
||||
if (!season) return;
|
||||
const cards = season.querySelectorAll('.tv-episode-card:not(.tv-ep-seen)');
|
||||
const cards = season.querySelectorAll('.tv-episode-tile:not(.tv-ep-seen)');
|
||||
const ids = [];
|
||||
cards.forEach(card => {
|
||||
const vid = card.dataset.videoId;
|
||||
|
|
@ -274,7 +265,6 @@ function markSeasonWatched(seriesId, seasonNum) {
|
|||
});
|
||||
if (ids.length === 0) return;
|
||||
|
||||
// Alle auf einmal senden
|
||||
Promise.all(ids.map(id =>
|
||||
fetch('/tv/api/watch-progress', {
|
||||
method: 'POST',
|
||||
|
|
@ -282,10 +272,9 @@ function markSeasonWatched(seriesId, seasonNum) {
|
|||
body: JSON.stringify({ video_id: id, position_sec: 100, duration_sec: 100 }),
|
||||
})
|
||||
)).then(() => {
|
||||
// UI aktualisieren
|
||||
season.querySelectorAll('.tv-episode-card').forEach(card => {
|
||||
season.querySelectorAll('.tv-episode-tile').forEach(card => {
|
||||
card.classList.add('tv-ep-seen');
|
||||
const btn = card.querySelector('.tv-ep-mark-btn');
|
||||
const btn = card.querySelector('.tv-ep-tile-mark');
|
||||
if (btn) btn.classList.add('active');
|
||||
const thumb = card.querySelector('.tv-ep-thumb');
|
||||
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
|
||||
|
|
@ -295,7 +284,105 @@ function markSeasonWatched(seriesId, seasonNum) {
|
|||
thumb.appendChild(check);
|
||||
}
|
||||
});
|
||||
// Staffel-Tab als komplett markieren
|
||||
const tab = document.querySelector('.tv-tab[data-season="' + seasonNum + '"]');
|
||||
if (tab && !tab.classList.contains('tv-tab-complete')) {
|
||||
tab.classList.add('tv-tab-complete');
|
||||
if (!tab.querySelector('.tv-tab-check')) {
|
||||
const check = document.createElement('span');
|
||||
check.className = 'tv-tab-check';
|
||||
check.innerHTML = ' ✓';
|
||||
tab.appendChild(check);
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// === Episode Detail-Panel bei Focus ===
|
||||
document.addEventListener('focusin', function(e) {
|
||||
const tile = e.target.closest('.tv-episode-tile');
|
||||
const panel = document.getElementById('ep-detail-panel');
|
||||
if (!panel) return;
|
||||
if (tile) {
|
||||
document.getElementById('ep-detail-title').textContent = tile.dataset.epTitle || '';
|
||||
document.getElementById('ep-detail-desc').textContent = tile.dataset.epDesc || '';
|
||||
document.getElementById('ep-detail-meta').innerHTML = tile.dataset.epMeta || '';
|
||||
panel.classList.add('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// === Post-Play Navigation (von Player nach Episoden-Ende) ===
|
||||
{% if post_play and next_video_id %}
|
||||
(function() {
|
||||
var nextCard = document.querySelector('[data-video-id="{{ next_video_id }}"]');
|
||||
if (!nextCard) return;
|
||||
|
||||
// Zur richtigen Staffel wechseln
|
||||
var season = nextCard.closest('.tv-season');
|
||||
if (season && season.style.display === 'none') {
|
||||
var sn = season.id.replace('season-', '');
|
||||
document.querySelectorAll('.tv-season').forEach(function(el) { el.style.display = 'none'; });
|
||||
document.querySelectorAll('.tv-tab').forEach(function(el) { el.classList.remove('active'); });
|
||||
season.style.display = '';
|
||||
var tab = document.querySelector('.tv-tab[data-season="' + sn + '"]');
|
||||
if (tab) tab.classList.add('active');
|
||||
}
|
||||
|
||||
// Karte hervorheben und hineinsccrollen
|
||||
nextCard.classList.add('tv-ep-next-loading');
|
||||
nextCard.scrollIntoView({block: 'center', behavior: 'smooth'});
|
||||
|
||||
// Countdown-Overlay auf der Karte
|
||||
var countdownEl = document.createElement('div');
|
||||
countdownEl.className = 'tv-ep-countdown-overlay';
|
||||
var remaining = {{ countdown }};
|
||||
countdownEl.innerHTML = '<span class="tv-ep-countdown-num">' + remaining + '</span><span class="tv-ep-countdown-label">{{ t("player.next_episode") }}</span>';
|
||||
nextCard.querySelector('.tv-ep-thumb').appendChild(countdownEl);
|
||||
|
||||
var timer = setInterval(function() {
|
||||
remaining--;
|
||||
var numEl = countdownEl.querySelector('.tv-ep-countdown-num');
|
||||
if (numEl) numEl.textContent = remaining;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer);
|
||||
window.location.href = '/tv/player?v={{ next_video_id }}';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Abbrechen per Escape/Return
|
||||
function cancelAutoplay(e) {
|
||||
if (e.keyCode === 10009 || e.keyCode === 27 || e.key === 'Escape') {
|
||||
clearInterval(timer);
|
||||
nextCard.classList.remove('tv-ep-next-loading');
|
||||
countdownEl.remove();
|
||||
document.removeEventListener('keydown', cancelAutoplay);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', cancelAutoplay);
|
||||
})();
|
||||
{% elif last_watched_id %}
|
||||
// Zur letzten geschauten Episode scrollen
|
||||
(function() {
|
||||
var lastCard = document.querySelector('[data-video-id="{{ last_watched_id }}"]');
|
||||
if (!lastCard) return;
|
||||
|
||||
// Zur richtigen Staffel wechseln
|
||||
var season = lastCard.closest('.tv-season');
|
||||
if (season && season.style.display === 'none') {
|
||||
var sn = season.id.replace('season-', '');
|
||||
document.querySelectorAll('.tv-season').forEach(function(el) { el.style.display = 'none'; });
|
||||
document.querySelectorAll('.tv-tab').forEach(function(el) { el.classList.remove('active'); });
|
||||
season.style.display = '';
|
||||
var tab = document.querySelector('.tv-tab[data-season="' + sn + '"]');
|
||||
if (tab) tab.classList.add('active');
|
||||
}
|
||||
|
||||
lastCard.scrollIntoView({block: 'center', behavior: 'smooth'});
|
||||
var focusEl = lastCard.querySelector('[data-focusable]');
|
||||
if (focusEl) setTimeout(function() { focusEl.focus(); }, 300);
|
||||
})();
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue