Add preview images on seek bar.

This commit is contained in:
Ian Walton 2023-02-16 21:43:06 -05:00
parent e541dcea2f
commit c4f4210ec5
12 changed files with 3331 additions and 17 deletions

View File

@ -3,3 +3,4 @@ recursive-include jellyfin_mpv_shim/integration *
recursive-include jellyfin_mpv_shim/default_shader_pack *
recursive-include jellyfin_mpv_shim/messages *.mo
include jellyfin_mpv_shim/mouse.lua
include jellyfin_mpv_shim/trickplay.lua

View File

@ -307,6 +307,20 @@ use `shader_pack_custom`.
- If you use `shader_pack_remember`, this will be updated when you set a profile through the UI.
- `shader_pack_subtype` - The profile group to use. The default pack contains `lq` and `hq` groups. Use `hq` if you have a fancy graphics card.
### Trickplay Thumbnails
MPV will automatically display thumbnail previews. By default it uses the Jellyfin chapter images
but it can also use JellyScrub as the source. Please note that this feature will download and
uncompress all of the chapter images before it becomes available for a video. For a 4 hour movie this
causes disk usage of about 250 MB, but for the average TV episode it is around 40 MB. It also requires
overriding the default MPV OSC, which may conflict with some custom user script. The `trickplay.lua`
file contains the feature if you want to make a modified version.
- `thumbnail_enable` - Enable thumbnail feature. (Default: `true`)
- `thumbnail_jellyscrub` - Use JellyScrub as the thumbnail source instead of chapter images. (Default: `false`)
- `thumbnail_custom_script` - If enabled, it disables the default `trickplay.lua` so you can provide a modified version. (Default: `null`)
- `thumbnail_preferred_size` - The ideal size for thumbnails. (Default: `320`)
### SVP Integration
To enable SVP integration, set `svp_enable` to `true` and enable "External control via HTTP" within SVP

View File

@ -1,7 +1,7 @@
@echo off
rd /s /q __pycache__ dist build
set PATH=%PATH%;%CD%
pyinstaller -w --add-binary "mpv-2.dll;." --add-data "jellyfin_mpv_shim\mouse.lua;jellyfin_mpv_shim" --hidden-import pystray._win32 --add-data "jellyfin_mpv_shim\default_shader_pack;jellyfin_mpv_shim\default_shader_pack" --add-data "jellyfin_mpv_shim\messages;jellyfin_mpv_shim\messages" --add-data "jellyfin_mpv_shim\systray.png;jellyfin_mpv_shim" --add-data "jellyfin_mpv_shim\display_mirror\index.html;jellyfin_mpv_shim\display_mirror" --add-data "jellyfin_mpv_shim\display_mirror\jellyfin.css;jellyfin_mpv_shim\display_mirror" --add-binary "Microsoft.Toolkit.Forms.UI.Controls.WebView.dll;." --icon jellyfin.ico run.py
pyinstaller -w --add-binary "mpv-2.dll;." --add-data "jellyfin_mpv_shim\mouse.lua;jellyfin_mpv_shim" --add-data "jellyfin_mpv_shim\trickplay.lua;jellyfin_mpv_shim" --hidden-import pystray._win32 --add-data "jellyfin_mpv_shim\default_shader_pack;jellyfin_mpv_shim\default_shader_pack" --add-data "jellyfin_mpv_shim\messages;jellyfin_mpv_shim\messages" --add-data "jellyfin_mpv_shim\systray.png;jellyfin_mpv_shim" --add-data "jellyfin_mpv_shim\display_mirror\index.html;jellyfin_mpv_shim\display_mirror" --add-data "jellyfin_mpv_shim\display_mirror\jellyfin.css;jellyfin_mpv_shim\display_mirror" --add-binary "Microsoft.Toolkit.Forms.UI.Controls.WebView.dll;." --icon jellyfin.ico run.py
if %errorlevel% neq 0 exit /b %errorlevel%
del dist\run\run.exe.manifest
copy hidpi.manifest dist\run\run.exe.manifest

View File

@ -1,7 +1,7 @@
@echo off
rd /s /q __pycache__ dist build
set PATH=%PATH%;%CD%
pyinstaller -w --add-binary "mpv-2.dll;." --add-data "jellyfin_mpv_shim\systray.png;jellyfin_mpv_shim" --hidden-import pystray._win32 --add-data "jellyfin_mpv_shim\mouse.lua;jellyfin_mpv_shim" --add-data "jellyfin_mpv_shim\default_shader_pack;jellyfin_mpv_shim\default_shader_pack" --add-data "jellyfin_mpv_shim\messages;jellyfin_mpv_shim\messages" --add-data "jellyfin_mpv_shim\display_mirror\index.html;jellyfin_mpv_shim\display_mirror" --add-data "jellyfin_mpv_shim\display_mirror\jellyfin.css;jellyfin_mpv_shim\display_mirror" --add-binary "Microsoft.Toolkit.Forms.UI.Controls.WebView.dll;." --icon jellyfin.ico run.py
pyinstaller -w --add-binary "mpv-2.dll;." --add-data "jellyfin_mpv_shim\systray.png;jellyfin_mpv_shim" --hidden-import pystray._win32 --add-data "jellyfin_mpv_shim\mouse.lua;jellyfin_mpv_shim" --add-data "jellyfin_mpv_shim\trickplay.lua;jellyfin_mpv_shim" --add-data "jellyfin_mpv_shim\default_shader_pack;jellyfin_mpv_shim\default_shader_pack" --add-data "jellyfin_mpv_shim\messages;jellyfin_mpv_shim\messages" --add-data "jellyfin_mpv_shim\display_mirror\index.html;jellyfin_mpv_shim\display_mirror" --add-data "jellyfin_mpv_shim\display_mirror\jellyfin.css;jellyfin_mpv_shim\display_mirror" --add-binary "Microsoft.Toolkit.Forms.UI.Controls.WebView.dll;." --icon jellyfin.ico run.py
if %errorlevel% neq 0 exit /b %errorlevel%
del dist\run\run.exe.manifest
copy hidpi.manifest dist\run\run.exe.manifest

View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3
import struct
from io import BytesIO
from PIL import Image
BIF_MAGIC = b"\x89BIF\r\n\x1a\n"
BIF_SUPPORTED = 0
def decode_file(filename):
with open(filename, "rb") as fh:
return decode(fh)
def _read_i32(fh):
return struct.unpack("<I", fh.read(4))[0]
def decode(fh):
if fh.read(8) != BIF_MAGIC:
raise ValueError("Data provided is not a BIF file.")
bif_version = _read_i32(fh)
if bif_version != BIF_SUPPORTED:
raise ValueError(f"BIF version {bif_version} is not supported.")
image_count = _read_i32(fh)
multiplier = _read_i32(fh)
fh.read(44) # unused data
index = [] # timestamp, offset
for _ in range(image_count):
index.append((_read_i32(fh), _read_i32(fh)))
images = []
for i in range(len(index)):
timestamp, offset = index[i]
if i != timestamp:
raise ValueError("BIF file is not contiguous.")
fh.seek(offset)
if i + 1 == len(index):
images.append(fh.read())
else:
images.append(fh.read(index[i + 1][1] - offset))
return {"multiplier": multiplier, "images": images}
def decompress_bif(images, fh):
height = None
width = None
image_count = 0
for image in images:
image_count += 1
image = Image.open(BytesIO(image)).convert("RGBA")
if height is None:
height = image.height
width = image.width
else:
if height != image.height or width != image.width:
raise ValueError("BIF image sizes mismatch.")
r, g, b, a = image.split()
image = Image.merge("RGBA", (b, g, r, a))
fh.write(image.tobytes())
return {"count": image_count, "height": height, "width": width}
if __name__ == "__main__":
import sys
bif_data = decode_file(sys.argv[1])
print(f"Images: {len(bif_data['images'])}")
print(f"Multiplier: {bif_data['multiplier']}")
# for timestamp, image in enumerate(bif_data["images"]):
# with open(f"{timestamp}.jpg", "wb") as fh:
# fh.write(image)
with open("raw_images.bin", "wb") as fh:
bif_size_info = decompress_bif(bif_data["images"], fh)
print(f"Width: {bif_size_info['width']}")
print(f"Height: {bif_size_info['height']}")

View File

@ -126,6 +126,10 @@ class Settings(SettingsBase):
health_check_interval: Optional[int] = 300
skip_intro_always: bool = False
skip_intro_prompt: bool = False
thumbnail_enable: bool = True
thumbnail_jellyscrub: bool = False
thumbnail_custom_script: Optional[str] = None
thumbnail_preferred_size: int = 320
def __get_file(self, path: str, mode: str = "r", create: bool = True):
created = False

View File

@ -3,6 +3,7 @@ import urllib.parse
import os.path
import re
import pathlib
from io import BytesIO
from sys import platform
from .conf import settings
@ -252,6 +253,47 @@ class Video(object):
exc_info=1,
)
def get_chapters(self):
return [
{"start": item["StartPositionTicks"] / 10000000, "name": item["Name"]}
for item in self.item.get("Chapters", [])
]
def get_chapter_images(self, max_width=400, quality=90):
for i, item in enumerate(self.item.get("Chapters", [])):
data = BytesIO()
self.client.jellyfin._get_stream(
f"Items/{self.item_id}/Images/Chapter/{i}",
data,
{"tag": item["ImageTag"], "maxWidth": max_width, "quality": quality},
)
yield data.getvalue()
def get_bif(self, prefer_width=320):
# requires JellyScrub plugin
data = BytesIO()
manifest = self.client.jellyfin._get(
f"Trickplay/{self.media_source['Id']}/GetManifest"
)
if (
manifest is not None
and manifest.get("WidthResolutions") is not None
and len(manifest["WidthResolutions"]) > 0
):
if prefer_width is not None:
width = min(
manifest["WidthResolutions"], key=lambda x: abs(x - prefer_width)
)
else:
width = manifest["WidthResolutions"][-1]
self.client.jellyfin._get_stream(
f"Trickplay/{self.media_source['Id']}/{width}/GetBIF", data
)
else:
return None
data.seek(0)
return data
def get_playback_url(
self,
video_bitrate: Optional[int] = None,

View File

@ -555,6 +555,12 @@ class OSDMenu(object):
),
self.get_settings_toggle(_("Always Skip Intros"), "skip_intro_always"),
self.get_settings_toggle(_("Ask to Skip Intros"), "skip_intro_prompt"),
self.get_settings_toggle(
_("Enable thumbnail previews"), "thumbnail_enable"
),
self.get_settings_toggle(
_("Use JellyScrub thumbnails"), "thumbnail_jellyscrub"
),
],
)

View File

@ -154,6 +154,7 @@ class PlayerManager(object):
self.update_check = UpdateChecker(self)
self.is_in_intro = False
self.intro_has_triggered = False
self.trickplay = None
if is_using_ext_mpv:
mpv_options.update(
@ -164,11 +165,29 @@ class PlayerManager(object):
"player-operation-mode": "cplayer",
}
)
scripts = []
if settings.menu_mouse:
if is_using_ext_mpv:
mpv_options["script"] = get_resource("mouse.lua")
scripts.append(get_resource("mouse.lua"))
if settings.thumbnail_enable:
try:
from .trickplay import TrickPlay
self.trickplay = TrickPlay(self)
self.trickplay.start()
except Exception:
log.error("Could not enable trickplay.", exc_info=True)
if settings.thumbnail_custom_script:
scripts.append(settings.thumbnail_custom_script)
else:
mpv_options["scripts"] = get_resource("mouse.lua")
scripts.append(get_resource("trickplay.lua"))
if scripts:
mpv_options["scripts"] = (
";" if sys.platform.startswith("win32") else ":"
).join(scripts)
if not (settings.mpv_ext and settings.mpv_ext_no_ovr):
mpv_options["include"] = conffile.get(APP_NAME, "mpv.conf", True)
mpv_options["input_conf"] = conffile.get(APP_NAME, "input.conf", True)
@ -187,7 +206,7 @@ class PlayerManager(object):
register_join_event(self.syncplay.discord_join_group)
if hasattr(self._player, "osc"):
self._player.osc = settings.enable_osc
self.enable_osc(settings.enable_osc)
else:
log.warning("This mpv version doesn't support on-screen controller.")
@ -490,6 +509,9 @@ class PlayerManager(object):
self.url = url
self.menu.hide_menu()
if self.trickplay:
self.trickplay.clear()
if settings.log_decisions:
log.debug("Playing: {0}".format(url))
if self.get_webview() is not None and settings.display_mirroring:
@ -536,6 +558,9 @@ class PlayerManager(object):
else:
self.set_paused(False, False)
if self.trickplay:
self.trickplay.fetch_thumbnails()
self.should_send_timeline = True
self.do_not_handle_pause = False
if self._finished_lock.locked():
@ -584,6 +609,9 @@ class PlayerManager(object):
self.send_timeline_stopped(options=options, client=local_video.client)
self.exec_stop_cmd()
if self.trickplay:
self.trickplay.clear()
def get_volume(self, percent: bool = False):
if self._player:
if not percent:
@ -847,6 +875,10 @@ class PlayerManager(object):
self.pause_ignore = value
self._player.pause = value
@synchronous("_lock")
def script_message(self, command, *args):
self._player.command("script-message", command, *args)
def get_track_ids(self):
return self._video.aid, self._video.sid
@ -978,6 +1010,9 @@ class PlayerManager(object):
if is_using_ext_mpv:
self._player.terminate()
if self.trickplay:
self.trickplay.stop()
def get_seek_times(self):
if self._jf_settings is None:
self._jf_settings = self._video.client.jellyfin.get_user_settings()
@ -1015,13 +1050,16 @@ class PlayerManager(object):
self._player.osd_font_size = font_size
def enable_osc(self, enabled: bool):
if hasattr(self._player, "osc"):
self._player.osc = enabled
if settings.thumbnail_enable and self.trickplay:
self.script_message(
"osc-visibility", "always" if enabled else "never", False
)
else:
if hasattr(self._player, "osc"):
self._player.osc = enabled
def triggered_menu(self, enabled: bool):
self._player.command(
"script-message", "shim-menu-enable", "True" if enabled else "False"
)
self.script_message("shim-menu-enable", "True" if enabled else "False")
def playback_is_aborted(self):
return self._player.playback_abort

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,119 @@
import threading
import os
import logging
from .conf import settings
from . import bifdecode
from . import conffile
from .constants import APP_NAME
log = logging.getLogger("trickplay")
img_file = conffile.get(APP_NAME, "raw_images.bin")
class TrickPlay(threading.Thread):
def __init__(self, player):
self.trigger = threading.Event()
self.halt = False
self.player = player
threading.Thread.__init__(self)
def stop(self):
self.halt = True
if os.path.isfile(img_file):
os.remove(img_file)
self.join()
def fetch_thumbnails(self):
self.trigger.set()
def clear(self):
self.player.script_message("shim-trickplay-clear")
if os.path.isfile(img_file):
os.remove(img_file)
def run(self):
while not self.halt:
self.trigger.wait()
self.trigger.clear()
if self.halt:
break
try:
log.info("Collecting trickplay images...")
if not self.player.has_video():
continue
video = self.player.get_video()
if settings.thumbnail_jellyscrub:
try:
data = video.get_bif(settings.thumbnail_preferred_size)
if data:
bif = bifdecode.decode(data)
if (
not self.player.has_video()
or video != self.player.get_video()
):
# Video changed while we were getting the bif file
continue
with open(img_file, "wb") as fh:
bif_meta = bifdecode.decompress_bif(bif["images"], fh)
if (
not self.player.has_video()
or video != self.player.get_video()
):
# Video changed while we were decompressing the bif file
continue
self.player.script_message(
"shim-trickplay-bif",
bif_meta["count"],
bif["multiplier"],
bif_meta["width"],
bif_meta["height"],
img_file,
)
log.info(
f"Collected {len(bif['images'])} bif preview images"
)
continue
else:
log.warning("No bif file available")
except:
log.error(
"Could not get bif file. Do you have the plugin installed?",
exc_info=True,
)
chapter_data = video.get_chapters()
if chapter_data is None or len(chapter_data) == 0:
log.info("No chapters available")
continue
with open(img_file, "wb") as fh:
bif_meta = bifdecode.decompress_bif(
video.get_chapter_images(settings.thumbnail_preferred_size), fh
)
if not self.player.has_video() or video != self.player.get_video():
# Video changed while we were getting the thumbnails
break
self.player.script_message(
"shim-trickplay-chapters",
bif_meta["width"],
bif_meta["height"],
img_file,
",".join(str(x["start"]) for x in chapter_data),
)
log.info(f"Collected {len(chapter_data)} chapter preview images")
except:
log.error("Could not get trickplay images", exc_info=True)

View File

@ -30,12 +30,7 @@ setup(
"gui": ["pystray", "PIL"],
"mirror": ["Jinja2", "pywebview>=3.3.1"],
"discord": ["pypresence"],
"all": [
"Jinja2",
"pywebview>=3.3.1",
"pystray",
"pypresence",
],
"all": ["Jinja2", "pywebview>=3.3.1", "pystray", "pypresence", "PIL"],
},
python_requires=">=3.6",
install_requires=[