From 3a92ba42ccce0e89e95d32b19d2b5f5093688f36 Mon Sep 17 00:00:00 2001 From: bossanova808 Date: Mon, 6 Oct 2025 21:47:37 +0000 Subject: [PATCH] [script.xbmc.unpausejumpback] 3.0.6 --- script.xbmc.unpausejumpback/addon.xml | 8 +- script.xbmc.unpausejumpback/changelog.txt | 3 + script.xbmc.unpausejumpback/default.py | 10 +- .../resources/lib/unpause_jumpback.py | 388 ++++++++++++------ 4 files changed, 280 insertions(+), 129 deletions(-) diff --git a/script.xbmc.unpausejumpback/addon.xml b/script.xbmc.unpausejumpback/addon.xml index 3b7e743009..3cd0907b8e 100644 --- a/script.xbmc.unpausejumpback/addon.xml +++ b/script.xbmc.unpausejumpback/addon.xml @@ -1,8 +1,8 @@ - + - + @@ -25,8 +25,8 @@ https://github.com/bossanova808/script.xbmc.unpausejumpback/ https://forum.kodi.tv/showthread.php?tid=355778 bossonav808@gmail.com - v3.0.5 -- fix for more possibles instances of "Kodi is not playing any media file" when skipping beyond end of file + v3.0.6 +- Minor updates for Piers (use new module Logger functions) icon.png diff --git a/script.xbmc.unpausejumpback/changelog.txt b/script.xbmc.unpausejumpback/changelog.txt index cc6ca97290..99e668c4d9 100644 --- a/script.xbmc.unpausejumpback/changelog.txt +++ b/script.xbmc.unpausejumpback/changelog.txt @@ -1,3 +1,6 @@ +v3.0.6 +- Minor updates for Piers (use new module Logger functions) + v3.0.5 - fix for more possibles instances of "Kodi is not playing any media file" when skipping beyond end of file diff --git a/script.xbmc.unpausejumpback/default.py b/script.xbmc.unpausejumpback/default.py index 40408b595e..6e29300213 100644 --- a/script.xbmc.unpausejumpback/default.py +++ b/script.xbmc.unpausejumpback/default.py @@ -1,6 +1,14 @@ +""" +Entry point for the Unpause Jumpback Kodi Add-on. + +This script serves as the main entry point when the add-on is executed by Kodi. +It wraps the main functionality with exception logging to ensure any errors +are properly captured and logged for debugging purposes. +""" + from bossanova808 import exception_logger from resources.lib import unpause_jumpback if __name__ == "__main__": with exception_logger.log_exception(): - unpause_jumpback.run() + unpause_jumpback.run() \ No newline at end of file diff --git a/script.xbmc.unpausejumpback/resources/lib/unpause_jumpback.py b/script.xbmc.unpausejumpback/resources/lib/unpause_jumpback.py index 267539c18d..50661d8dbf 100644 --- a/script.xbmc.unpausejumpback/resources/lib/unpause_jumpback.py +++ b/script.xbmc.unpausejumpback/resources/lib/unpause_jumpback.py @@ -1,53 +1,114 @@ +""" +Unpause Jumpback Add-on for Kodi + +This add-on provides functionality to automatically jump back a configurable amount +when resuming playback after a pause. + +The add-on supports multiple jumpback modes, with configurable timing: + +- Jump back on resume (default behavior) +- Jump back on pause (for low-power systems) +- Jump back on playback start from resume points +- Jump back after fast-forward/rewind operations + +""" + import xbmc import time +from typing import Optional from bossanova808.logger import Logger -from bossanova808.utilities import * -global player -global kodi_monitor +from bossanova808.utilities import get_setting, get_setting_as_bool + +player: Optional['MyPlayer'] = None +kodi_monitor: Optional['MyMonitor'] = None def run(): + """ + Main entry point for the add-on. + + Initializes the logger, creates player and monitor instances, and runs + the main monitoring loop. Handles cleanup on exit or abort signals. + The function will continue running until Kodi signals an abort request + or an exception occurs. + """ global player global kodi_monitor - footprints() + Logger.start() + try: + kodi_monitor = MyMonitor() + player = MyPlayer() + while not kodi_monitor.abortRequested(): + if kodi_monitor.waitForAbort(1): + break + except Exception as e: + Logger.error(f'Unhandled exception in run(): {e}') + raise + finally: + Logger.stop() + player = None + kodi_monitor = None + + +def _get_int_setting(key: str, default: int = 0) -> int: + try: + return int(float(get_setting(key))) + except (TypeError, ValueError): + Logger.debug(f"Invalid/missing setting '{key}', defaulting to {default}") + return default - # Set up our Kodi Monitor & Player... - kodi_monitor = MyMonitor() - player = MyPlayer() - # Run until abort requested - while not kodi_monitor.abortRequested(): - if kodi_monitor.waitForAbort(1): - # Abort was requested while waiting. We should exit - footprints(False) - break +class MyPlayer(xbmc.Player): + """ + Custom Kodi Player class that handles unpause jumpback functionality. + This class extends xbmc.Player to provide automatic jumpback functionality + when playback is resumed after being paused, fast-forwarded, or rewound. -class MyPlayer(xbmc.Player): + The class supports multiple jumpback modes: + - Jump back on resume: Seeks backward when playback resumes after pause + - Jump back on pause: Seeks backward during the pause (for low-power systems) + - Jump back on playback start: Seeks backward when starting from resume points + - Jump back after fast-forward/rewind at different speeds - def __init__(self, *args, **kwargs): - xbmc.Player.__init__(self) + Exclusion settings allow skipping jumpback for specific content types or paths. + """ + + def __init__(self): + """ + Initialize the MyPlayer instance. + + Sets up all configuration variables and loads settings from Kodi's + add-on configuration. + """ + super().__init__() Logger.debug('MyPlayer - init') - self.jump_back_on_resume = 0 - self.jump_back_on_playback_started = 0 + # Jumpback behavior settings + self.jump_back_on_resume = False + self.jump_back_on_playback_started = False self.paused_time = 0 self.jump_back_secs_after_pause = 0 + self.last_playback_speed = 0 + self.wait_for_jumpback = 0 + + # Fast-forward jumpback settings self.jump_back_secs_after_fwd_x2 = 0 self.jump_back_secs_after_fwd_x4 = 0 self.jump_back_secs_after_fwd_x8 = 0 self.jump_back_secs_after_fwd_x16 = 0 self.jump_back_secs_after_fwd_x32 = 0 + + # Rewind jumpback settings self.jump_back_secs_after_rwd_x2 = 0 self.jump_back_secs_after_rwd_x4 = 0 self.jump_back_secs_after_rwd_x8 = 0 self.jump_back_secs_after_rwd_x16 = 0 self.jump_back_secs_after_rwd_x32 = 0 - self.jump_back_secs_after_resume = 0 - self.last_playback_speed = 0 - self.wait_for_jumpback = 0 + + # Exclusion settings self.exclude_live_tv = True self.exclude_http = True self.excluded_path_1_enabled = None @@ -61,23 +122,28 @@ def __init__(self, *args, **kwargs): def load_settings(self): """ - Load the addon's settings - """ + Load and cache settings from Kodi's add-on settings. + Retrieves all jumpback configuration options, exclusion settings, + and timing parameters from the add-on settings and caches them + for performance during playback events. + + Logs the current jumpback mode after loading settings. + """ self.jump_back_on_resume = get_setting_as_bool('jumpbackonresume') self.jump_back_on_playback_started = get_setting_as_bool('jumpbackonplaybackstarted') - self.jump_back_secs_after_pause = int(float(get_setting("jumpbacksecs"))) - self.jump_back_secs_after_fwd_x2 = int(float(get_setting("jumpbacksecsfwdx2"))) - self.jump_back_secs_after_fwd_x4 = int(float(get_setting("jumpbacksecsfwdx4"))) - self.jump_back_secs_after_fwd_x8 = int(float(get_setting("jumpbacksecsfwdx8"))) - self.jump_back_secs_after_fwd_x16 = int(float(get_setting("jumpbacksecsfwdx16"))) - self.jump_back_secs_after_fwd_x32 = int(float(get_setting("jumpbacksecsfwdx32"))) - self.jump_back_secs_after_rwd_x2 = int(float(get_setting("jumpbacksecsrwdx2"))) - self.jump_back_secs_after_rwd_x4 = int(float(get_setting("jumpbacksecsrwdx4"))) - self.jump_back_secs_after_rwd_x8 = int(float(get_setting("jumpbacksecsrwdx8"))) - self.jump_back_secs_after_rwd_x16 = int(float(get_setting("jumpbacksecsrwdx16"))) - self.jump_back_secs_after_rwd_x32 = int(float(get_setting("jumpbacksecsrwdx32"))) - self.wait_for_jumpback = int(float(get_setting("waitforjumpback"))) + self.jump_back_secs_after_pause = _get_int_setting("jumpbacksecs", default=0) + self.jump_back_secs_after_fwd_x2 = _get_int_setting("jumpbacksecsfwdx2", default=0) + self.jump_back_secs_after_fwd_x4 = _get_int_setting("jumpbacksecsfwdx4", default=0) + self.jump_back_secs_after_fwd_x8 = _get_int_setting("jumpbacksecsfwdx8", default=0) + self.jump_back_secs_after_fwd_x16 = _get_int_setting("jumpbacksecsfwdx16", default=0) + self.jump_back_secs_after_fwd_x32 = _get_int_setting("jumpbacksecsfwdx32", default=0) + self.jump_back_secs_after_rwd_x2 = _get_int_setting("jumpbacksecsrwdx2", default=0) + self.jump_back_secs_after_rwd_x4 = _get_int_setting("jumpbacksecsrwdx4", default=0) + self.jump_back_secs_after_rwd_x8 = _get_int_setting("jumpbacksecsrwdx8", default=0) + self.jump_back_secs_after_rwd_x16 = _get_int_setting("jumpbacksecsrwdx16", default=0) + self.jump_back_secs_after_rwd_x32 = _get_int_setting("jumpbacksecsrwdx32", default=0) + self.wait_for_jumpback = _get_int_setting("waitforjumpback", default=0) self.exclude_live_tv = get_setting_as_bool('ExcludeLiveTV') self.exclude_http = get_setting_as_bool('ExcludeHTTP') self.excluded_path_1_enabled = get_setting_as_bool('ExcludePathOption') @@ -92,48 +158,63 @@ def load_settings(self): else: Logger.info(f'Settings loaded, jump back set to: On Pause with a jump back of {self.jump_back_secs_after_pause} seconds') - def is_excluded(self, full_path): + def is_excluded(self, full_path: str) -> bool: """ - Check exclusion settings for filename passed as argument + Check if the given file path should be excluded from jumpback functionality. + + Tests the provided path against configured exclusion rules including: + - Live TV streams (pvr:// protocol) + - HTTP/HTTPS streams + - Up to 3 custom excluded paths + + Args: + full_path (str): The full file path or URL to check for exclusion - @param full_path: path to check - @return True or False + Returns: + bool: True if the path should be excluded from jumpback, False otherwise """ if not full_path: return True Logger.info(f"Checking exclusion for: '{full_path}'.") - if (full_path.find("pvr://") > -1) and self.exclude_live_tv: + if full_path.startswith("pvr://") and self.exclude_live_tv: Logger.info("Video is playing via Live TV, which is set as an excluded location.") return True - if ((full_path.find("http://") > -1) or (full_path.find("https://") > -1)) and self.exclude_http: + if full_path.startswith(("http://", "https://")) and self.exclude_http: Logger.info("Video is playing via HTTP source, which is set as an excluded location.") return True - if self.excluded_path_1 and self.excluded_path_1_enabled: - if full_path.find(self.excluded_path_1) > -1: - Logger.info(f"Video is playing from '{self.excluded_path_1}', which is set as excluded path 1.") - return True - - if self.excluded_path_2 and self.excluded_path_2_enabled: - if full_path.find(self.excluded_path_2) > -1: - Logger.info(f"Video is playing from '{self.excluded_path_2}', which is set as excluded path 2.") - return True - - if self.excluded_path_3 and self.excluded_path_3_enabled: - if full_path.find(self.excluded_path_3) > -1: - Logger.info(f"Video is playing from '{self.excluded_path_3}', which is set as excluded path 3.") - return True + if self.excluded_path_1_enabled and self.excluded_path_1 and self.excluded_path_1 in full_path: + Logger.info(f"Video is playing from '{self.excluded_path_1}', which is set as excluded path 1.") + return True + if self.excluded_path_2_enabled and self.excluded_path_2 and self.excluded_path_2 in full_path: + Logger.info(f"Video is playing from '{self.excluded_path_2}', which is set as excluded path 2.") + return True + if self.excluded_path_3_enabled and self.excluded_path_3 and self.excluded_path_3 in full_path: + Logger.info(f"Video is playing from '{self.excluded_path_3}', which is set as excluded path 3.") + return True Logger.info(f"Not excluded: '{full_path}'") return False - # Default case, Jump Back on Resume - # This means the pause position is where the user actually paused...which is usually the desired behaviour def onPlayBackResumed(self): + """ + Handle playback resume events (default jumpback mode). + + Called when playback is resumed after being paused. This is the default + behavior where the pause position remains where the user actually paused, + which is usually the desired behavior. + + When jump_back_on_resume is enabled: + - Checks exclusion settings for the current file + - Performs jumpback if conditions are met (sufficient pause time, etc.) + - Resets the paused_time tracking variable + When jump_back_on_resume is disabled: + - Cancels any pending alarm-based jumpback operations + """ Logger.info(f'onPlayBackResumed with jump_back_on_resume: {self.jump_back_on_resume}') if self.jump_back_on_resume: @@ -141,7 +222,7 @@ def onPlayBackResumed(self): if self.paused_time > 0: Logger.info(f'Was paused for {int(time.time() - self.paused_time)} seconds.') - # check for exclusion + # Check for exclusion try: _filename = self.getPlayingFile() except RuntimeError: @@ -154,28 +235,38 @@ def onPlayBackResumed(self): return else: - # handle jump back after pause - if self.jump_back_secs_after_pause != 0 \ - and self.isPlayingVideo() \ - and self.getTime() > self.jump_back_secs_after_pause \ - and self.paused_time > 0 \ - and (time.time() - self.paused_time) > self.wait_for_jumpback: - resume_time = self.getTime() - self.jump_back_secs_after_pause + # Handle jump back after pause + current_time = self.getTime() + if (self.jump_back_secs_after_pause != 0 + and self.isPlayingVideo() + and current_time > self.jump_back_secs_after_pause + and self.paused_time > 0 + and (time.time() - self.paused_time) > self.wait_for_jumpback): + resume_time = current_time - self.jump_back_secs_after_pause self.seekTime(resume_time) Logger.info(f'Resumed, with {int(self.jump_back_secs_after_pause)}s jump back') self.paused_time = 0 - # If we're not jumping back on resume, then we should cancel the alarm set if they manually resume playback - # before it goes off + # If we're not jumping back on resume, cancel any alarm set for manual resume else: Logger.info('Cancelling alarm - playback either resumed or stopped by the user.') xbmc.executebuiltin('CancelAlarm(JumpbackPaused, true)') - # Alternatively, handle Jump Back on Pause - # (for low power systems, so it happens in the background during the pause - helps prevents janky-ness) def onPlayBackPaused(self): + """ + Handle playback pause events (alternative jumpback mode for low-power systems). + + Called when playback is paused. Records the pause time and optionally + sets up alarm-based jumpback during the pause period. + For low-power systems, the add-on can be configured to perform the jumpback + during the pause period, which prevents jankiness on resume but has the + disadvantage that the paused image also jumps back. + + Checks exclusion settings and sets up delayed jumpback via Kodi's AlarmClock + if jump_back_on_resume is disabled. + """ # Record when the pause was done self.paused_time = time.time() Logger.info(f'onPlayBackPaused. Time: {self.paused_time}') @@ -186,25 +277,36 @@ def onPlayBackPaused(self): Logger.info('No file is playing, could not getPlayingFile(), stopping UnpauseJumpBack') xbmc.executebuiltin('CancelAlarm(JumpbackPaused, true)') return - + if self.is_excluded(_filename): Logger.info(f'Playback paused - ignoring because [{_filename}] is in exclusion settings.') return - # For low power systems, the addon can be set to do the jump back _during_ the pause period - # Which prevents a janky experience on resume, but has the disadvantage that actual paused image - # jumps back as well. + # For low power systems, perform jumpback during pause period + # Prevents janky resume experience but paused image also jumps back if not self.jump_back_on_resume and self.isPlayingVideo() and 0 < self.jump_back_secs_after_pause < self.getTime(): jump_back_point = self.getTime() - self.jump_back_secs_after_pause Logger.info(f'Playback paused - jumping back {self.jump_back_secs_after_pause}s to: {int(jump_back_point)} seconds') xbmc.executebuiltin( - f'AlarmClock(JumpbackPaused, Seek(-{self.jump_back_secs_after_pause})), 0:{self.wait_for_jumpback}, silent)') + f'AlarmClock(JumpbackPaused, Seek(-{self.jump_back_secs_after_pause}), 00:00:{int(self.wait_for_jumpback):02d}, silent), silent)') def onAVStarted(self): + """ + Handle audio/video start events. - Logger.info(f'onAVStarted.') + Called when audio/video playback starts. If jump_back_on_playback_started + is enabled, this method will perform a jumpback when playback begins from + a resume point (not from the beginning). - # If the addon is set to do a jump back when playback is started from a resume point... + This is useful for situations where users want to jump back a bit when + resuming a previously watched video to catch up on context. + + Checks exclusion settings and only performs jumpback if the current + playback position is greater than zero (indicating a resume operation). + """ + Logger.info('onAVStarted.') + + # If configured to jump back when playback starts from a resume point if self.jump_back_on_playback_started: try: @@ -216,7 +318,7 @@ def onAVStarted(self): Logger.info(f'Current playback time is {current_time}') - # check for exclusion + # Check for exclusion try: _filename = self.getPlayingFile() except RuntimeError: @@ -236,62 +338,100 @@ def onAVStarted(self): self.seekTime(resume_time) def onPlayBackSpeedChanged(self, speed): + """ + Handle playback speed change events. - if speed == 1: # normal playback speed reached - direction = 1 - abs_last_speed = abs(self.last_playback_speed) - # default value, just in case - resume_time = 0 + Called when the playback speed changes (e.g., fast-forward, rewind, or + return to normal speed). When returning to normal speed (speed == 1) + from fast-forward or rewind, performs configurable jumpback operations. + + Supports different jumpback amounts for different fast-forward/rewind speeds: + - Fast-forward: Jump back after returning to normal speed + - Rewind: Jump forward after returning to normal speed + - Speeds supported: 2x, 4x, 8x, 16x, 32x + + Args: + speed (int): The new playback speed (1 = normal, >1 = fast-forward, + <0 = rewind, 0 = paused) + """ + prev_speed = self.last_playback_speed + self.last_playback_speed = speed + if speed == 1: # Normal playback speed reached + abs_last_speed = abs(prev_speed) + # Only act if we actually FF/RW'd with a supported speed + if abs_last_speed not in (2, 4, 8, 16, 32): + return try: - resume_time = self.getTime() + current_time = self.getTime() except RuntimeError: Logger.info('No file is playing, stopping UnpauseJumpBack') xbmc.executebuiltin('CancelAlarm(JumpbackPaused, true)') - pass - - Logger.log(f"onPlayBackSpeedChanged with speed {speed} and resume_time {resume_time}") - - if self.last_playback_speed < 0: - Logger.info('Resuming. Was rewound with speed X%d.' % (abs(self.last_playback_speed))) - if self.last_playback_speed > 1: - direction = -1 - Logger.info('Resuming. Was forwarded with speed X%d.' % (abs(self.last_playback_speed))) - # handle jump after fwd/rwd (jump back after fwd, jump forward after rwd) - if direction == -1: # fwd - if abs_last_speed == 2: - resume_time = self.getTime() + self.jump_back_secs_after_fwd_x2 * direction - elif abs_last_speed == 4: - resume_time = self.getTime() + self.jump_back_secs_after_fwd_x4 * direction - elif abs_last_speed == 8: - resume_time = self.getTime() + self.jump_back_secs_after_fwd_x8 * direction - elif abs_last_speed == 16: - resume_time = self.getTime() + self.jump_back_secs_after_fwd_x16 * direction - elif abs_last_speed == 32: - resume_time = self.getTime() + self.jump_back_secs_after_fwd_x32 * direction - else: # rwd - if abs_last_speed == 2: - resume_time = self.getTime() + self.jump_back_secs_after_rwd_x2 * direction - elif abs_last_speed == 4: - resume_time = self.getTime() + self.jump_back_secs_after_rwd_x4 * direction - elif abs_last_speed == 8: - resume_time = self.getTime() + self.jump_back_secs_after_rwd_x8 * direction - elif abs_last_speed == 16: - resume_time = self.getTime() + self.jump_back_secs_after_rwd_x16 * direction - elif abs_last_speed == 32: - resume_time = self.getTime() + self.jump_back_secs_after_rwd_x32 * direction - - if abs_last_speed != 1: # we really fwd'ed or rwd'ed - self.seekTime(resume_time) # do the jump + return - self.last_playback_speed = speed + direction = -1 if prev_speed > 1 else 1 # fwd => jump back; rwd => jump forward + if direction == -1: + delta_map = { + 2: self.jump_back_secs_after_fwd_x2, + 4: self.jump_back_secs_after_fwd_x4, + 8: self.jump_back_secs_after_fwd_x8, + 16: self.jump_back_secs_after_fwd_x16, + 32: self.jump_back_secs_after_fwd_x32, + } + else: + delta_map = { + 2: self.jump_back_secs_after_rwd_x2, + 4: self.jump_back_secs_after_rwd_x4, + 8: self.jump_back_secs_after_rwd_x8, + 16: self.jump_back_secs_after_rwd_x16, + 32: self.jump_back_secs_after_rwd_x32, + } + delta = delta_map.get(abs_last_speed, 0) + if not delta: + return + + resume_time = int(current_time + (delta * direction)) + # Clamp within stream bounds + try: + total = int(self.getTotalTime()) + if total > 0: + resume_time = max(0, min(resume_time, total - 1)) + else: + resume_time = max(0, resume_time) + except (RuntimeError, TypeError, ValueError): + resume_time = max(0, resume_time) + + Logger.info(f'onPlayBackSpeedChanged: last_speed={prev_speed}, jump {"back" if direction == -1 else "forward"} {delta}s to {int(resume_time)}') + self.seekTime(resume_time) class MyMonitor(xbmc.Monitor): + """ + Custom Kodi Monitor class for handling system events. + + This class extends xbmc.Monitor to handle settings changes and other + system events that affect the unpause jumpback functionality. - def __init__(self, *args, **kwargs): - xbmc.Monitor.__init__(self) - Logger.info('MyMonitor - init') + Primary responsibility is to reload player settings when the user + changes add-on configuration through Kodi's settings interface. + """ + + def __init__(self): + """Initialize the MyMonitor instance.""" + super().__init__() + Logger.debug('MyMonitor - init') def onSettingsChanged(self): - global player - player.load_settings() + """ + Handle add-on settings change events. + + Called when the user changes settings in the add-on configuration. + Reloads settings in the player if it's initialized, or defers loading + until the player is available. + + This ensures that configuration changes take effect immediately without + requiring a restart of the add-on. + """ + if player is not None: + player.load_settings() + else: + Logger.debug('Settings changed before player initialised; deferring.')