mirror of
https://github.com/jellyfin/jellyfin-mpv-shim.git
synced 2024-11-23 14:09:57 +00:00
Add preview images on seek bar.
This commit is contained in:
parent
e541dcea2f
commit
c4f4210ec5
@ -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
|
||||
|
14
README.md
14
README.md
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
90
jellyfin_mpv_shim/bifdecode.py
Normal file
90
jellyfin_mpv_shim/bifdecode.py
Normal 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']}")
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
3005
jellyfin_mpv_shim/trickplay.lua
Normal file
3005
jellyfin_mpv_shim/trickplay.lua
Normal file
File diff suppressed because it is too large
Load Diff
119
jellyfin_mpv_shim/trickplay.py
Normal file
119
jellyfin_mpv_shim/trickplay.py
Normal 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)
|
7
setup.py
7
setup.py
@ -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=[
|
||||
|
Loading…
Reference in New Issue
Block a user