mirror of
https://github.com/jellyfin/jellyfin-mpv-shim.git
synced 2024-11-23 14:09:57 +00:00
1166 lines
38 KiB
Python
1166 lines
38 KiB
Python
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
import platform
|
|
|
|
from threading import RLock, Lock, Event
|
|
from queue import Queue
|
|
from collections import OrderedDict
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
from . import conffile
|
|
from .utils import synchronous, Timer, none_fallback, get_resource
|
|
from .conf import settings
|
|
from .menu import OSDMenu
|
|
from .constants import APP_NAME
|
|
from .syncplay import SyncPlayManager
|
|
from .update_check import UpdateChecker
|
|
from .i18n import _
|
|
|
|
if TYPE_CHECKING:
|
|
from .media import Video as Video_type
|
|
|
|
log = logging.getLogger("player")
|
|
mpv_log = logging.getLogger("mpv")
|
|
|
|
|
|
discord_presence = False
|
|
if settings.discord_presence:
|
|
try:
|
|
from .rich_presence import register_join_event, send_presence, clear_presence
|
|
|
|
discord_presence = True
|
|
except Exception:
|
|
log.error("Could not enable Discord Rich Presence.", exc_info=True)
|
|
|
|
python_mpv_available = True
|
|
is_using_ext_mpv = False
|
|
if not settings.mpv_ext:
|
|
try:
|
|
# noinspection PyPackageRequirements
|
|
import mpv
|
|
|
|
log.info("Using libmpv1 playback backend.")
|
|
except OSError:
|
|
log.warning("Could not find libmpv1.")
|
|
python_mpv_available = False
|
|
|
|
if settings.mpv_ext or not python_mpv_available:
|
|
import python_mpv_jsonipc as mpv
|
|
|
|
log.info("Using external mpv playback backend.")
|
|
is_using_ext_mpv = True
|
|
|
|
SUBTITLE_POS = {
|
|
"top": 0,
|
|
"bottom": 100,
|
|
"middle": 80,
|
|
}
|
|
|
|
mpv_log_levels = {
|
|
"fatal": mpv_log.error,
|
|
"error": mpv_log.error,
|
|
"warn": mpv_log.warning,
|
|
"info": mpv_log.info,
|
|
}
|
|
|
|
|
|
def mpv_log_handler(level: str, prefix: str, text: str):
|
|
if level in mpv_log_levels:
|
|
mpv_log_levels[level]("{0}: {1}".format(prefix, text))
|
|
else:
|
|
mpv_log.debug("{0}: {1}".format(prefix, text))
|
|
|
|
|
|
def wait_property(
|
|
instance, name: str, cond=lambda x: True, timeout: Optional[int] = None
|
|
):
|
|
success = True
|
|
event = Event()
|
|
|
|
def handler(_name, value):
|
|
if cond(value):
|
|
event.set()
|
|
|
|
if is_using_ext_mpv:
|
|
observer_id = instance.bind_property_observer(name, handler)
|
|
if timeout:
|
|
success = event.wait(timeout=timeout)
|
|
else:
|
|
event.wait()
|
|
instance.unbind_property_observer(observer_id)
|
|
else:
|
|
instance.observe_property(name, handler)
|
|
if timeout:
|
|
success = event.wait(timeout=timeout)
|
|
else:
|
|
event.wait()
|
|
instance.unobserve_property(name, handler)
|
|
return success
|
|
|
|
|
|
win_utils = None
|
|
if sys.platform.startswith("win32") or sys.platform.startswith("cygwin"):
|
|
try:
|
|
from . import win_utils
|
|
except ModuleNotFoundError:
|
|
log.warning("win_utils is not available.")
|
|
|
|
# Q: What is with the put_task call?
|
|
# A: Some calls to python-mpv require event processing.
|
|
# put_task is used to deal with the events originating from
|
|
# the event thread, which would cause deadlock if they run there.
|
|
|
|
|
|
# noinspection PyUnresolvedReferences
|
|
class PlayerManager(object):
|
|
"""
|
|
The underlying player is thread safe, however, locks are used in this
|
|
class to prevent concurrent control events hitting the player, which
|
|
violates assumptions.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._video = None
|
|
mpv_options = OrderedDict()
|
|
mpv_location = settings.mpv_ext_path
|
|
# Use bundled path for MPV if not specified by user, on Mac OS, and frozen
|
|
if (
|
|
mpv_location is None
|
|
and platform.system() == "Darwin"
|
|
and getattr(sys, "frozen", False)
|
|
):
|
|
mpv_location = get_resource("mpv")
|
|
self.timeline_trigger = None
|
|
self.action_trigger = None
|
|
self.external_subtitles = {}
|
|
self.external_subtitles_rev = {}
|
|
self.should_send_timeline = False
|
|
self.start_time = None
|
|
self.url = None
|
|
self.evt_queue = Queue()
|
|
self._lock = RLock()
|
|
self._tl_lock = RLock()
|
|
self._finished_lock = Lock()
|
|
self.last_update = Timer()
|
|
self._jf_settings = None
|
|
self.get_webview = lambda: None
|
|
self.pause_ignore = None # Used to ignore pause events that come from us.
|
|
self.do_not_handle_pause = False
|
|
self.last_seek = None
|
|
self.warned_about_transcode = False
|
|
self.fullscreen_disable = False
|
|
self.update_check = UpdateChecker(self)
|
|
self.is_in_intro = False
|
|
self.trickplay = None
|
|
|
|
if is_using_ext_mpv:
|
|
mpv_options.update(
|
|
{
|
|
"start_mpv": settings.mpv_ext_start,
|
|
"ipc_socket": settings.mpv_ext_ipc,
|
|
"mpv_location": mpv_location,
|
|
"player-operation-mode": "cplayer",
|
|
}
|
|
)
|
|
|
|
scripts = []
|
|
if settings.menu_mouse:
|
|
scripts.append(get_resource("mouse.lua"))
|
|
if settings.thumbnail_enable:
|
|
try:
|
|
from .trickplay import TrickPlay
|
|
|
|
self.trickplay = TrickPlay(self)
|
|
self.trickplay.start()
|
|
|
|
scripts.append(get_resource("thumbfast.lua"))
|
|
if settings.thumbnail_osc_builtin:
|
|
scripts.append(get_resource("trickplay-osc.lua"))
|
|
|
|
except Exception:
|
|
log.error("Could not enable trickplay.", exc_info=True)
|
|
|
|
mpv_options["osc"] = False
|
|
|
|
# ensure standard mpv configuration directories and files exist
|
|
conffile.get_dir(APP_NAME, "scripts")
|
|
conffile.get_dir(APP_NAME, "fonts")
|
|
conffile.get(APP_NAME, "input.conf", True)
|
|
conffile.get(APP_NAME, "mpv.conf", True)
|
|
|
|
if scripts:
|
|
if settings.mpv_ext:
|
|
mpv_options["script"] = scripts
|
|
else:
|
|
mpv_options["scripts"] = (
|
|
";" if sys.platform.startswith("win32") else ":"
|
|
).join(scripts)
|
|
|
|
if not (settings.mpv_ext and settings.mpv_ext_no_ovr):
|
|
mpv_options["config"] = True
|
|
mpv_options["config_dir"] = conffile.confdir(APP_NAME)
|
|
|
|
self._player = mpv.MPV(
|
|
input_default_bindings=True,
|
|
input_vo_keyboard=True,
|
|
input_media_keys=settings.media_keys,
|
|
log_handler=mpv_log_handler,
|
|
loglevel=settings.mpv_log_level,
|
|
**mpv_options,
|
|
)
|
|
self.menu = OSDMenu(self, self._player)
|
|
self.syncplay = SyncPlayManager(self)
|
|
|
|
if discord_presence:
|
|
register_join_event(self.syncplay.discord_join_group)
|
|
|
|
if hasattr(self._player, "osc"):
|
|
self.enable_osc(settings.enable_osc)
|
|
else:
|
|
log.warning("This mpv version doesn't support on-screen controller.")
|
|
|
|
if settings.screenshot_dir is not None:
|
|
if hasattr(self._player, "screenshot_directory"):
|
|
self._player.screenshot_directory = settings.screenshot_dir
|
|
else:
|
|
log.warning(
|
|
"This mpv version doesn't support setting the screenshot directory."
|
|
)
|
|
|
|
if hasattr(self._player, "resume_playback"):
|
|
# This can lead to unwanted skipping of videos
|
|
self._player.resume_playback = False
|
|
|
|
# Wrapper for on_key_press that ignores None.
|
|
def keypress(key):
|
|
def wrapper(func):
|
|
if key is not None:
|
|
self._player.on_key_press(key)(func)
|
|
return func
|
|
|
|
return wrapper
|
|
|
|
@self._player.on_key_press("CLOSE_WIN")
|
|
@self._player.on_key_press("STOP")
|
|
@keypress(settings.kb_stop)
|
|
def handle_stop():
|
|
self.stop()
|
|
|
|
@keypress(settings.kb_prev)
|
|
def handle_prev():
|
|
self.put_task(self.play_prev)
|
|
|
|
@keypress(settings.kb_next)
|
|
def handle_next():
|
|
self.put_task(self.play_next)
|
|
|
|
@self._player.on_key_press("PREV")
|
|
@self._player.on_key_press("XF86_PREV")
|
|
def handle_media_prev():
|
|
if settings.media_key_seek:
|
|
seektime, _x = self.get_seek_times()
|
|
self.seek(seektime)
|
|
else:
|
|
self.put_task(self.play_prev)
|
|
|
|
@self._player.on_key_press("NEXT")
|
|
@self._player.on_key_press("XF86_NEXT")
|
|
def handle_media_next():
|
|
if settings.media_key_seek:
|
|
if self.is_in_intro:
|
|
self.skip_intro()
|
|
else:
|
|
_x, seektime = self.get_seek_times()
|
|
self.seek(seektime)
|
|
else:
|
|
self.put_task(self.play_next)
|
|
|
|
@keypress(settings.kb_watched)
|
|
def handle_watched():
|
|
self.put_task(self.watched_skip)
|
|
|
|
@keypress(settings.kb_unwatched)
|
|
def handle_unwatched():
|
|
self.put_task(self.unwatched_quit)
|
|
|
|
@keypress(settings.kb_menu)
|
|
def menu_open():
|
|
if not self.menu.is_menu_shown:
|
|
self.menu.show_menu()
|
|
else:
|
|
self.menu.hide_menu()
|
|
|
|
@keypress(settings.kb_menu_esc)
|
|
def menu_back():
|
|
if self.menu.is_menu_shown:
|
|
self.menu.menu_action("back")
|
|
else:
|
|
self._player.command("set", "fullscreen", "no")
|
|
self.fullscreen_disable = True
|
|
|
|
@keypress(settings.kb_menu_ok)
|
|
def menu_ok():
|
|
self.menu.menu_action("ok")
|
|
|
|
@keypress(settings.kb_menu_left)
|
|
def menu_left():
|
|
if self.menu.is_menu_shown:
|
|
self.menu.menu_action("left")
|
|
else:
|
|
self.kb_seek("left")
|
|
|
|
@keypress(settings.kb_menu_right)
|
|
def menu_right():
|
|
if self.menu.is_menu_shown:
|
|
self.menu.menu_action("right")
|
|
else:
|
|
if self.is_in_intro:
|
|
self.skip_intro()
|
|
else:
|
|
self.kb_seek("right")
|
|
|
|
@keypress(settings.kb_menu_up)
|
|
def menu_up():
|
|
if self.menu.is_menu_shown:
|
|
self.menu.menu_action("up")
|
|
else:
|
|
if self.is_in_intro:
|
|
self.skip_intro()
|
|
else:
|
|
self.kb_seek("up")
|
|
|
|
@keypress(settings.kb_menu_down)
|
|
def menu_down():
|
|
if self.menu.is_menu_shown:
|
|
self.menu.menu_action("down")
|
|
else:
|
|
self.kb_seek("down")
|
|
|
|
@keypress(settings.kb_pause)
|
|
def handle_pause():
|
|
if self.menu.is_menu_shown:
|
|
self.menu.menu_action("ok")
|
|
else:
|
|
self.toggle_pause()
|
|
|
|
@keypress(settings.kb_fullscreen)
|
|
def handle_fullscreen():
|
|
self.toggle_fullscreen()
|
|
|
|
# This gives you an interactive python debugger prompt.
|
|
@keypress(settings.kb_debug)
|
|
def handle_debug():
|
|
import pdb
|
|
|
|
pdb.set_trace()
|
|
|
|
# Kill shader packs (useful for breakage)
|
|
@keypress(settings.kb_kill_shader)
|
|
def kill_shaders():
|
|
if settings.shader_pack_remember:
|
|
settings.shader_pack_profile = None
|
|
settings.save()
|
|
if self.menu.profile_manager is not None:
|
|
self.menu.profile_manager.unload_profile()
|
|
|
|
# Fires between episodes.
|
|
@self._player.property_observer("eof-reached")
|
|
def handle_end(_name, reached_end: bool):
|
|
self.pause_ignore = True
|
|
if self._video and reached_end:
|
|
has_lock = self._finished_lock.acquire(False)
|
|
self.put_task(self.finished_callback, has_lock)
|
|
|
|
# Fires at the end.
|
|
@self._player.property_observer("playback-abort")
|
|
def handle_end_idle(_name, value: bool):
|
|
self.pause_ignore = True
|
|
if self._video and value and not self._video.parent.has_next:
|
|
has_lock = self._finished_lock.acquire(False)
|
|
self.put_task(self.finished_callback, has_lock)
|
|
|
|
@self._player.property_observer("seeking")
|
|
def handle_seeking(_name, value: bool):
|
|
if self.do_not_handle_pause:
|
|
return
|
|
|
|
if self.syncplay.is_enabled():
|
|
play_time = self._player.playback_time
|
|
if (
|
|
play_time is not None
|
|
and self.last_seek is not None
|
|
and abs(self.last_seek - play_time) > 10
|
|
):
|
|
self.syncplay.seek_request(play_time)
|
|
else:
|
|
log.debug("SyncPlay Buffering: {0}".format(value))
|
|
if value:
|
|
self.syncplay.on_buffer()
|
|
else:
|
|
self.syncplay.on_buffer_done()
|
|
|
|
@self._player.property_observer("pause")
|
|
def pause_handler(_name, value: bool):
|
|
if self.do_not_handle_pause:
|
|
return
|
|
|
|
if not self._player.playback_abort:
|
|
self.timeline_handle()
|
|
|
|
if value != self.pause_ignore:
|
|
if self.syncplay.is_enabled():
|
|
if value:
|
|
self.syncplay.pause_request()
|
|
else:
|
|
# Don't allow unpausing locally through MPV.
|
|
self.syncplay.play_request()
|
|
self.set_paused(True, True)
|
|
|
|
@self._player.event_callback("client-message")
|
|
def handle_client_message(event):
|
|
try:
|
|
# Python-MPV 1.0 uses a class/struct combination now
|
|
if hasattr(event, "as_dict"):
|
|
event = event.as_dict()
|
|
if "event" in event:
|
|
event["event"] = event["event"].decode("utf-8")
|
|
if "args" in event:
|
|
event["args"] = [d.decode("utf-8") for d in event["args"]]
|
|
|
|
if "event_id" in event:
|
|
args = event["event"]["args"]
|
|
else:
|
|
args = event["args"]
|
|
if len(args) == 0:
|
|
return
|
|
if args[0] == "shim-menu-select":
|
|
# Apparently this can happen...
|
|
if args[1] == "inf":
|
|
return
|
|
self.menu.mouse_select(int(args[1]))
|
|
elif args[0] == "shim-menu-click":
|
|
self.menu.menu_action("ok")
|
|
except Exception:
|
|
log.warning("Error when processing client-message.", exc_info=True)
|
|
|
|
# Put a task to the event queue.
|
|
# This ensures the task executes outside
|
|
# of an event handler, which causes a crash.
|
|
def put_task(self, func, *args):
|
|
self.evt_queue.put([func, args])
|
|
if self.action_trigger:
|
|
self.action_trigger.set()
|
|
|
|
# Trigger the timeline to update all
|
|
# clients immediately.
|
|
def timeline_handle(self):
|
|
if self.timeline_trigger:
|
|
self.timeline_trigger.set()
|
|
|
|
def skip_intro(self):
|
|
_, intro = self._video.get_current_intro(self._player.playback_time)
|
|
|
|
self._player.playback_time = intro.end
|
|
intro.has_triggered = True
|
|
self.timeline_handle()
|
|
self.is_in_intro = False
|
|
|
|
@synchronous("_lock")
|
|
def update(self):
|
|
if (
|
|
(
|
|
settings.skip_intro_always
|
|
or settings.skip_intro_prompt
|
|
or settings.skip_credits_always
|
|
or settings.skip_credits_prompt
|
|
)
|
|
and not self.syncplay.is_enabled()
|
|
and self._video is not None
|
|
and self._player.playback_time is not None
|
|
):
|
|
ready_to_skip, intro = self._video.get_current_intro(
|
|
self._player.playback_time
|
|
)
|
|
|
|
if intro is not None:
|
|
should_prompt = (
|
|
intro.type != "Credits" and settings.skip_intro_prompt
|
|
) or (intro.type == "Credits" and settings.skip_credits_prompt)
|
|
should_skip = (not intro.has_triggered) and (
|
|
(intro.type != "Credits" and settings.skip_intro_always)
|
|
or (intro.type == "Credits" and settings.skip_credits_always)
|
|
)
|
|
|
|
if should_skip and ready_to_skip:
|
|
intro.has_triggered = True
|
|
self.skip_intro()
|
|
self._player.show_text(
|
|
_("Skipped Credits")
|
|
if intro.type == "Credits"
|
|
else _("Skipped Intro"),
|
|
3000,
|
|
1,
|
|
)
|
|
|
|
if not self.is_in_intro and should_prompt:
|
|
self._player.show_text(
|
|
_("Seek to Skip Credits")
|
|
if intro.type == "Credits"
|
|
else _("Seek to Skip Intro"),
|
|
3000,
|
|
1,
|
|
)
|
|
self.is_in_intro = True
|
|
else:
|
|
self.is_in_intro = False
|
|
|
|
while not self.evt_queue.empty():
|
|
func, args = self.evt_queue.get()
|
|
func(*args)
|
|
if self._video and not self._player.playback_abort:
|
|
if not self.is_paused():
|
|
self.last_update.restart()
|
|
|
|
def play(
|
|
self,
|
|
video: "Video_type",
|
|
offset: int = 0,
|
|
no_initial_timeline: bool = False,
|
|
is_initial_play: bool = False,
|
|
):
|
|
self.should_send_timeline = False
|
|
self.start_time = time.time()
|
|
url = video.get_playback_url()
|
|
if not url:
|
|
log.error("PlayerManager::play no URL found")
|
|
return
|
|
|
|
self._play_media(video, url, offset, no_initial_timeline, is_initial_play)
|
|
|
|
@synchronous("_lock")
|
|
def _play_media(
|
|
self,
|
|
video: "Video_type",
|
|
url: str,
|
|
offset: int = 0,
|
|
no_initial_timeline: bool = False,
|
|
is_initial_play: bool = False,
|
|
):
|
|
self.pause_ignore = True
|
|
self.do_not_handle_pause = True
|
|
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:
|
|
# noinspection PyUnresolvedReferences
|
|
self.get_webview().hide()
|
|
|
|
self._player.play(self.url)
|
|
if not wait_property(
|
|
self._player, "duration", lambda x: x is not None, settings.playback_timeout
|
|
):
|
|
# Timeout playback attempt after 10 seconds
|
|
log.error("Timeout when waiting for media duration. Stopping playback!")
|
|
self.stop()
|
|
return
|
|
log.debug("Finished waiting for media duration.")
|
|
if settings.fullscreen and not self.fullscreen_disable:
|
|
self._player.fs = True
|
|
self._player.force_media_title = video.get_proper_title()
|
|
self._video = video
|
|
self.is_in_intro = False
|
|
self.external_subtitles = {}
|
|
self.external_subtitles_rev = {}
|
|
|
|
self.upd_player_hide()
|
|
self.configure_streams()
|
|
self.update_subtitle_visuals()
|
|
|
|
if win_utils and settings.raise_mpv and is_initial_play:
|
|
win_utils.raise_mpv()
|
|
|
|
if offset is not None and offset > 0:
|
|
self.last_seek = offset
|
|
self._player.playback_time = offset
|
|
|
|
if not no_initial_timeline:
|
|
self.send_timeline_initial()
|
|
else:
|
|
self.send_timeline()
|
|
|
|
if self.syncplay.is_enabled():
|
|
self.set_speed(1)
|
|
self.syncplay.play_done()
|
|
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():
|
|
self._finished_lock.release()
|
|
|
|
self.update_check.check()
|
|
|
|
if (
|
|
not self._video.parent.is_local
|
|
and self._video.is_transcode
|
|
and not self.warned_about_transcode
|
|
and settings.transcode_warning
|
|
):
|
|
self.warned_about_transcode = True
|
|
self._player.show_text(
|
|
_(
|
|
"Your remote video is transcoding!\nPress c to adjust bandwidth settings if this is not needed."
|
|
),
|
|
5000,
|
|
1,
|
|
)
|
|
|
|
@staticmethod
|
|
def exec_stop_cmd():
|
|
if settings.stop_cmd:
|
|
os.system(settings.stop_cmd)
|
|
|
|
@synchronous("_lock")
|
|
def stop(self):
|
|
if self.syncplay.is_enabled():
|
|
self.syncplay.disable_sync_play(False)
|
|
|
|
if not self._video or self._player.playback_abort:
|
|
self.exec_stop_cmd()
|
|
return
|
|
|
|
log.debug("PlayerManager::stop stopping playback of %s" % self._video)
|
|
|
|
self.should_send_timeline = False
|
|
options = self.get_timeline_options()
|
|
self.set_paused(False)
|
|
local_video = self._video
|
|
self._video = None
|
|
self._player.command("stop")
|
|
local_video.terminate_transcode()
|
|
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:
|
|
return self._player.volume / 100
|
|
return self._player.volume
|
|
|
|
@synchronous("_lock")
|
|
def toggle_pause(self):
|
|
if not self._player.playback_abort:
|
|
self.set_paused(not self._player.pause)
|
|
|
|
@synchronous("_lock")
|
|
def pause_if_playing(self):
|
|
if not self._player.playback_abort:
|
|
if not self._player.pause:
|
|
self.set_paused(True)
|
|
self.timeline_handle()
|
|
|
|
@synchronous("_lock")
|
|
def play_if_paused(self):
|
|
if not self._player.playback_abort:
|
|
if self._player.pause:
|
|
self.set_paused(False)
|
|
self.timeline_handle()
|
|
|
|
@synchronous("_lock")
|
|
def seek(
|
|
self,
|
|
offset: float,
|
|
absolute: bool = False,
|
|
force: bool = False,
|
|
exact: Optional[bool] = None,
|
|
):
|
|
"""
|
|
Seek to ``offset`` seconds
|
|
"""
|
|
if exact is None:
|
|
exact = absolute
|
|
if self.syncplay.is_enabled() and not force:
|
|
if not absolute:
|
|
offset += self._player.playback_time
|
|
self.syncplay.seek_request(offset)
|
|
else:
|
|
if not self._player.playback_abort:
|
|
if absolute:
|
|
if self.syncplay.is_enabled():
|
|
self.last_seek = offset
|
|
if self.is_in_intro and offset > self._player.playback_time:
|
|
self.skip_intro()
|
|
p2 = "absolute"
|
|
if exact:
|
|
p2 += "+exact"
|
|
self._player.command("seek", offset, p2)
|
|
else:
|
|
if self.syncplay.is_enabled():
|
|
self.last_seek = self._player.playback_time + offset
|
|
if (
|
|
self.is_in_intro
|
|
and self._player.playback_time + offset
|
|
> self._player.playback_time
|
|
):
|
|
self.skip_intro()
|
|
if exact:
|
|
self._player.command("seek", offset, "exact")
|
|
else:
|
|
self._player.command("seek", offset)
|
|
self.timeline_handle()
|
|
|
|
@synchronous("_lock")
|
|
def set_volume(self, pct: float):
|
|
if not self._player.playback_abort:
|
|
self._player.volume = pct
|
|
self.timeline_handle()
|
|
|
|
@synchronous("_lock")
|
|
def get_state(self):
|
|
if self._player.playback_abort:
|
|
return "stopped"
|
|
|
|
if self._player.pause:
|
|
return "paused"
|
|
|
|
return "playing"
|
|
|
|
@synchronous("_lock")
|
|
def is_paused(self):
|
|
if not self._player.playback_abort:
|
|
return self._player.pause
|
|
return False
|
|
|
|
@synchronous("_lock")
|
|
def finished_callback(self, has_lock: bool):
|
|
if not self._video:
|
|
self.pause_ignore = False
|
|
return
|
|
|
|
if settings.force_set_played:
|
|
self._video.set_played()
|
|
if self._video.parent.has_next and settings.auto_play:
|
|
if has_lock:
|
|
log.debug("PlayerManager::finished_callback starting next episode")
|
|
new_video = self._video.parent.get_next().video
|
|
self.send_timeline_stopped(True)
|
|
if self.syncplay.is_enabled():
|
|
self.syncplay.request_next(self._video.get_playlist_id())
|
|
else:
|
|
self.play(new_video)
|
|
else:
|
|
log.debug("PlayerManager::finished_callback No lock, skipping...")
|
|
else:
|
|
if settings.media_ended_cmd:
|
|
os.system(settings.media_ended_cmd)
|
|
|
|
if self.syncplay.is_enabled():
|
|
self.syncplay.disable_sync_play(False)
|
|
|
|
log.debug("PlayerManager::finished_callback reached end")
|
|
self.send_timeline_stopped(True)
|
|
self.pause_ignore = False
|
|
|
|
@synchronous("_lock")
|
|
def watched_skip(self):
|
|
if not self._video:
|
|
return
|
|
|
|
self._video.set_played()
|
|
self.play_next()
|
|
|
|
@synchronous("_lock")
|
|
def unwatched_quit(self):
|
|
if not self._video:
|
|
return
|
|
|
|
video = self._video
|
|
self.stop()
|
|
video.set_played(False)
|
|
|
|
@synchronous("_lock")
|
|
def play_next(self):
|
|
if self._video.parent.has_next:
|
|
new_video = self._video.parent.get_next().video
|
|
self.send_timeline_stopped(True)
|
|
if self.syncplay.is_enabled():
|
|
self.syncplay.request_next(self._video.get_playlist_id())
|
|
else:
|
|
self.play(new_video)
|
|
return True
|
|
return False
|
|
|
|
@synchronous("_lock")
|
|
def skip_to(self, key: str):
|
|
media = self._video.parent.get_from_key(key)
|
|
if media:
|
|
self.send_timeline_stopped(True)
|
|
if self.syncplay.is_enabled():
|
|
self.syncplay.request_skip(media.video.get_playlist_id())
|
|
else:
|
|
self.play(media.get_video(0))
|
|
return True
|
|
return False
|
|
|
|
@synchronous("_lock")
|
|
def play_prev(self):
|
|
if self._video.parent.has_prev:
|
|
new_video = self._video.parent.get_prev().video
|
|
self.send_timeline_stopped(True)
|
|
if self.syncplay.is_enabled():
|
|
self.syncplay.request_prev(self._video.get_playlist_id())
|
|
else:
|
|
self.play(new_video)
|
|
return True
|
|
return False
|
|
|
|
@synchronous("_lock")
|
|
def restart_playback(self):
|
|
current_time = self._player.playback_time
|
|
self.play(self._video, current_time)
|
|
return True
|
|
|
|
@synchronous("_lock")
|
|
def get_video_attr(self, attr: str, default=None):
|
|
if self._video:
|
|
return self._video.get_video_attr(attr, default)
|
|
return default
|
|
|
|
@synchronous("_lock")
|
|
def configure_streams(self):
|
|
audio_uid = self._video.aid
|
|
sub_uid = self._video.sid
|
|
|
|
if audio_uid is not None and not self._video.is_transcode:
|
|
log.debug("PlayerManager::play selecting audio stream index=%s" % audio_uid)
|
|
self._player.audio = self._video.audio_seq[audio_uid]
|
|
|
|
if sub_uid is None or sub_uid == -1:
|
|
log.debug("PlayerManager::play selecting subtitle stream (none)")
|
|
self._player.sub = "no"
|
|
else:
|
|
log.debug(
|
|
"PlayerManager::play selecting subtitle stream index=%s" % sub_uid
|
|
)
|
|
if sub_uid in self._video.subtitle_seq:
|
|
self._player.sub = self._video.subtitle_seq[sub_uid]
|
|
elif sub_uid in self._video.subtitle_url:
|
|
log.debug(
|
|
"PlayerManager::play selecting external subtitle id=%s" % sub_uid
|
|
)
|
|
self.load_external_sub(sub_uid)
|
|
|
|
@synchronous("_lock")
|
|
def set_streams(self, audio_uid: int, sub_uid: int):
|
|
need_restart = self._video.set_streams(audio_uid, sub_uid)
|
|
|
|
if need_restart:
|
|
self.restart_playback()
|
|
else:
|
|
self.configure_streams()
|
|
self.timeline_handle()
|
|
|
|
@synchronous("_lock")
|
|
def load_external_sub(self, sub_id: int):
|
|
if sub_id in self.external_subtitles:
|
|
self._player.sub = self.external_subtitles[sub_id]
|
|
else:
|
|
try:
|
|
sub_url = self._video.subtitle_url[sub_id]
|
|
if settings.log_decisions:
|
|
log.debug("Load External Subtitle: {0}".format(sub_url))
|
|
self._player.sub_add(sub_url)
|
|
self.external_subtitles[sub_id] = self._player.sub
|
|
self.external_subtitles_rev[self._player.sub] = sub_id
|
|
except SystemError:
|
|
log.debug("PlayerManager::could not load external subtitle")
|
|
|
|
@synchronous("_lock")
|
|
def toggle_fullscreen(self):
|
|
self._player.fs = not self._player.fs
|
|
self.fullscreen_disable = not self._player.fs
|
|
|
|
@synchronous("_lock")
|
|
def set_fullscreen(self, enabled: bool):
|
|
self._player.fs = enabled
|
|
self.fullscreen_disable = not enabled
|
|
|
|
@synchronous("_lock")
|
|
def set_mute(self, mute):
|
|
self._player.mute = mute
|
|
|
|
@synchronous("_lock")
|
|
def screenshot(self):
|
|
self._player.screenshot()
|
|
|
|
@synchronous("_lock")
|
|
def set_paused(self, value: bool, force: bool = False):
|
|
if self.syncplay.is_enabled() and not force:
|
|
if value:
|
|
self.syncplay.pause_request()
|
|
else:
|
|
self.syncplay.play_request()
|
|
else:
|
|
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
|
|
|
|
def update_subtitle_visuals(self):
|
|
self._player.sub_pos = SUBTITLE_POS[settings.subtitle_position]
|
|
self._player.sub_scale = settings.subtitle_size / 100
|
|
self._player.sub_color = settings.subtitle_color
|
|
self.timeline_handle()
|
|
|
|
def get_timeline_options(self, finished=False):
|
|
# PlaylistItemId is dynamically generated. A more stable Id will be used
|
|
# if queue manipulation is added as a feature.
|
|
player = self._player
|
|
if finished:
|
|
safe_pos = self._video.get_duration() or 0
|
|
else:
|
|
safe_pos = player.playback_time or 0
|
|
self.last_seek = safe_pos
|
|
self.pause_ignore = player.pause
|
|
options = {
|
|
"VolumeLevel": int(player.volume or 100),
|
|
"IsMuted": player.mute,
|
|
"IsPaused": player.pause,
|
|
"RepeatMode": "RepeatNone",
|
|
# "MaxStreamingBitrate": 140000000,
|
|
"PositionTicks": int(safe_pos * 10000000),
|
|
"PlaybackStartTimeTicks": int(self.start_time * 10000000),
|
|
"SubtitleStreamIndex": none_fallback(self._video.sid, -1),
|
|
"AudioStreamIndex": none_fallback(self._video.aid, -1),
|
|
"BufferedRanges": [],
|
|
"PlayMethod": "Transcode" if self._video.is_transcode else "DirectPlay",
|
|
"PlaySessionId": self._video.playback_info["PlaySessionId"],
|
|
"PlaylistItemId": self._video.get_playlist_id(),
|
|
"MediaSourceId": self._video.media_source["Id"],
|
|
"CanSeek": True,
|
|
"ItemId": self._video.item_id,
|
|
"NowPlayingQueue": self._video.parent.queue,
|
|
}
|
|
if player.duration is not None:
|
|
options["BufferedRanges"] = [
|
|
{
|
|
"start": int(safe_pos * 10000000),
|
|
"end": int(
|
|
(
|
|
(
|
|
player.duration
|
|
- safe_pos
|
|
* none_fallback(player.cache_buffering_state, 0)
|
|
/ 100
|
|
)
|
|
+ safe_pos
|
|
)
|
|
* 10000000
|
|
),
|
|
}
|
|
]
|
|
if discord_presence:
|
|
try:
|
|
if (
|
|
self._video.is_tv
|
|
and self._video.item.get("IndexNumber") is not None
|
|
and self._video.item.get("ParentIndexNumber") is not None
|
|
):
|
|
title = self._video.item.get("SeriesName")
|
|
subtitle = _("Season {0} - Episode {1}").format(
|
|
self._video.item.get("ParentIndexNumber"),
|
|
self._video.item.get("IndexNumber"),
|
|
)
|
|
else:
|
|
title = self._video.item.get("Name")
|
|
subtitle = str(self._video.item.get("ProductionYear", ""))
|
|
send_presence(
|
|
title,
|
|
subtitle,
|
|
player.playback_time,
|
|
player.duration,
|
|
not player.pause,
|
|
self.syncplay.current_group,
|
|
)
|
|
except Exception:
|
|
log.error("Could not send Discord Rich Presence.", exc_info=True)
|
|
return options
|
|
|
|
@synchronous("_tl_lock")
|
|
def send_timeline(self):
|
|
if (
|
|
self.should_send_timeline
|
|
and self._video
|
|
and not self._player.playback_abort
|
|
):
|
|
self._video.client.jellyfin.session_progress(self.get_timeline_options())
|
|
try:
|
|
if self.syncplay.is_enabled():
|
|
self.syncplay.sync_playback_time()
|
|
except:
|
|
log.error("Error syncing playback time.", exc_info=True)
|
|
|
|
@synchronous("_tl_lock")
|
|
def send_timeline_initial(self):
|
|
self._video.client.jellyfin.session_playing(self.get_timeline_options())
|
|
|
|
@synchronous("_tl_lock")
|
|
def send_timeline_stopped(self, finished=False, options=None, client=None):
|
|
self.should_send_timeline = False
|
|
|
|
if options is None:
|
|
options = self.get_timeline_options(finished)
|
|
|
|
if client is None:
|
|
client = self._video.client
|
|
|
|
client.jellyfin.session_stop(options)
|
|
|
|
if self.get_webview() is not None and settings.display_mirroring:
|
|
self.get_webview().show()
|
|
|
|
if discord_presence:
|
|
try:
|
|
clear_presence()
|
|
except Exception:
|
|
log.error("Could not clear Discord Rich Presence.", exc_info=True)
|
|
|
|
def upd_player_hide(self):
|
|
if self._video:
|
|
self._player.keep_open = self._video.parent.has_next
|
|
|
|
def terminate(self):
|
|
self.stop()
|
|
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()
|
|
custom_prefs = self._jf_settings.get("CustomPrefs") or {}
|
|
seek_left = custom_prefs.get("skipBackLength") or 15000
|
|
seek_right = custom_prefs.get("skipForwardLength") or 30000
|
|
return -int(seek_left) / 1000, int(seek_right) / 1000
|
|
|
|
# Wrappers to avoid private access
|
|
def is_active(self):
|
|
return bool(self._player and self._video)
|
|
|
|
def is_playing(self):
|
|
return bool(self._video and not self._player.playback_abort)
|
|
|
|
def is_not_paused(self):
|
|
return bool(
|
|
self._video and not self._player.playback_abort and not self._player.pause
|
|
)
|
|
|
|
def has_video(self):
|
|
return self._video is not None
|
|
|
|
def get_video(self):
|
|
return self._video
|
|
|
|
def show_text(self, text: str, duration: int, level: int = 1):
|
|
self._player.show_text(text, duration, level)
|
|
|
|
def get_osd_settings(self):
|
|
return self._player.osd_back_color, self._player.osd_font_size
|
|
|
|
def set_osd_settings(self, back_color: str, font_size: int):
|
|
self._player.osd_back_color = back_color
|
|
self._player.osd_font_size = font_size
|
|
|
|
def enable_osc(self, enabled: bool):
|
|
if settings.thumbnail_enable and self.trickplay:
|
|
self.script_message(
|
|
"osc-visibility", "auto" if enabled else "never", "False"
|
|
)
|
|
else:
|
|
if hasattr(self._player, "osc"):
|
|
self._player.osc = enabled
|
|
|
|
def triggered_menu(self, enabled: bool):
|
|
self.script_message("shim-menu-enable", "True" if enabled else "False")
|
|
|
|
def playback_is_aborted(self):
|
|
return self._player.playback_abort
|
|
|
|
def force_window(self, enabled: bool):
|
|
if enabled:
|
|
self._player.force_window = True
|
|
self._player.keep_open = True
|
|
self._player.play("")
|
|
if settings.fullscreen:
|
|
self._player.fs = True
|
|
else:
|
|
self._player.keep_open = False
|
|
if self._player.playback_abort:
|
|
self._player.force_window = False
|
|
self._player.play("")
|
|
else:
|
|
self.upd_player_hide()
|
|
|
|
def add_ipc(self, ipc_name: str):
|
|
self._player.input_ipc_server = ipc_name
|
|
|
|
def get_current_client(self):
|
|
return self._video.client
|
|
|
|
def get_time(self):
|
|
return self._player.playback_time
|
|
|
|
def get_speed(self):
|
|
return self._player.speed
|
|
|
|
def set_speed(self, speed: float):
|
|
self._player.speed = speed
|
|
|
|
def kb_seek(self, action):
|
|
if action == "up":
|
|
self.seek(settings.seek_up, exact=settings.seek_v_exact)
|
|
elif action == "down":
|
|
self.seek(settings.seek_down, exact=settings.seek_v_exact)
|
|
elif action == "left":
|
|
seektime = settings.seek_left
|
|
if settings.use_web_seek:
|
|
seektime, _x = self.get_seek_times()
|
|
self.seek(seektime, exact=settings.seek_h_exact)
|
|
elif action == "right":
|
|
seektime = settings.seek_right
|
|
if settings.use_web_seek:
|
|
_x, seektime = self.get_seek_times()
|
|
self.seek(seektime, exact=settings.seek_h_exact)
|
|
else:
|
|
self.menu.menu_action(action)
|
|
|
|
def menu_action(self, action):
|
|
if self.menu.is_menu_shown:
|
|
self.menu.menu_action(action)
|
|
else:
|
|
self.kb_seek(action)
|
|
|
|
|
|
playerManager = PlayerManager()
|