Last active
July 21, 2025 21:16
-
-
Save mpentler/66dd46019ee59e1249f3b50af4da0c51 to your computer and use it in GitHub Desktop.
RPi CLI video player using MPV and GPIO for control (or ssh)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/home/pi/horrorbox/bin/python | |
| # Horrorbox v1.4 | |
| from ui.debug_panel import DebugPanel | |
| from ui.file_list_panel import FileListPanel | |
| import urwid | |
| import os | |
| import subprocess | |
| import shutil | |
| import RPi.GPIO as GPIO | |
| import time | |
| import random | |
| import json | |
| # User preferences | |
| VERSION = 1.4 | |
| VIDEO_EXTENSIONS = ('.mp4', '.mkv', '.avi') | |
| ROOT_DIR = "/media/usb" | |
| LIBRARY_PATH = '/home/pi/horrorbox/library.json' | |
| APO_TIMER = 300 | |
| APO_FINAL_COUNT = 10 | |
| SPLASH_SCREEN = [""] | |
| SPLASH_SCREEN += [" ) ) ) ( ( ) ( ) ) "] | |
| SPLASH_SCREEN += [" * ) ( /( ( /( ( /( )\ ) )\ ) ( /( )\ ) ( ( /( ( /( "] | |
| SPLASH_SCREEN += ["` ) /( )\()) ( )\()) )\()) (()/((()/( )\()) (()/( ( )\ )\()) )\())"] | |
| SPLASH_SCREEN += [" ( )(_))((_)\ )\ ((_)\ ((_)\ /(_))/(_))((_)\ /(_)))((_)((_)\ ((_)\ "] | |
| SPLASH_SCREEN += ["(_(_()) _((_)((_) _((_) ((_) (_)) (_)) ((_) (_)) ((_)_ ((_)__((_)"] | |
| SPLASH_SCREEN += ["|_ _| | || || __| | || | / _ \ | _ \| _ \ / _ \ | _ \ | _ ) / _ \\ \/ /"] | |
| SPLASH_SCREEN += [" | | | __ || _| | __ || (_) || /| / | (_) || / | _ \| (_) |> < "] | |
| SPLASH_SCREEN += [" |_| |_||_||___| |_||_| \___/ |_|_\|_|_\ \___/ |_|_\ |___/ \___//_/\_\\"] | |
| # GPIO Pins | |
| GPIO_RANDOM = 24 | |
| GPIO_STOP = 22 | |
| GPIO_SHUTDOWN = 17 | |
| GPIO_UP = 27 | |
| GPIO_DOWN = 25 | |
| GPIO_ENTER = 23 | |
| # Main Horrorbox Class | |
| class Horrorbox: | |
| def __init__(self): | |
| # GPIO setup for buttons with polling | |
| GPIO.setmode(GPIO.BCM) | |
| self.button_pins = [GPIO_SHUTDOWN, GPIO_STOP, GPIO_UP, GPIO_ENTER, GPIO_RANDOM, GPIO_DOWN] | |
| for pin in self.button_pins: | |
| GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_UP) | |
| # Debounce tracking | |
| self.last_press_time = {pin: 0 for pin in self.button_pins} | |
| self.debounce_delay = 0.3 # seconds | |
| # Set up process tracker and some variables for auto power off | |
| self.mpv_process = None | |
| self.last_interaction_time = time.time() | |
| self.idle_timeout = APO_TIMER | |
| self.shutdown_countdown = APO_FINAL_COUNT | |
| self.countdown_overlay = None | |
| self.countdown_alarm = None | |
| # Set up file list and message panel | |
| self.file_panel = FileListPanel(ROOT_DIR, VIDEO_EXTENSIONS) | |
| self.list_box = self.file_panel.listbox | |
| self.divider = urwid.Divider('─') # Panel divider | |
| self.debug_panel = DebugPanel(max_lines=10) | |
| self.footer = self.debug_panel.widget | |
| # Set up title bar | |
| self.title = urwid.Text("", align='left') | |
| self.update_title() | |
| # Build up our UI from all the parts | |
| body_pile = urwid.Pile([ | |
| ('pack', urwid.AttrMap(self.title, 'titlebar')), | |
| ('weight', 1, self.list_box), | |
| ('pack', self.divider), | |
| ('pack', self.footer), | |
| ]) | |
| self.frame = urwid.Frame(body_pile) | |
| # Display the UI | |
| screen = urwid.raw_display.Screen() | |
| screen.set_terminal_properties(colors=256) | |
| screen.use_alternate_buffer = False | |
| # Start the main urwid loop with all settings and styles | |
| self.loop = urwid.MainLoop( | |
| self.frame, | |
| palette=[ | |
| ('reversed', 'standout', ''), | |
| ('footer', 'dark cyan', ''), | |
| ('titlebar', 'black,bold', 'white'), | |
| ('line', 'black', 'light gray'), | |
| ], | |
| unhandled_input=self.handle_input, | |
| handle_mouse=False, | |
| screen=screen # Use our configured screen | |
| ) | |
| self.file_panel.populate() # Show root folder in file list | |
| # Check for an existing video library file | |
| self.video_library = self.load_library() | |
| if not self.video_library: | |
| self.debug_panel.add_message("No cached library found. Scanning video collection...") | |
| self.video_library = self.scan_video_library() | |
| self.save_library(self.video_library) | |
| self.debug_panel.add_message(f"Library created with {len(self.video_library)} videos.") | |
| else: | |
| self.debug_panel.add_message(f"Loaded library with {len(self.video_library)} videos.") | |
| self.loop.set_alarm_in(5, self.check_idle_timeout) | |
| self.loop.set_alarm_in(0.1, self.poll_buttons) # Button loop | |
| # ================================Input handler for GPIO and keyboard | |
| def poll_buttons(self, loop=None, user_data=None): | |
| now = time.time() | |
| for pin in self.button_pins: | |
| if GPIO.input(pin) == GPIO.LOW: # Button pressed (active low) | |
| if now - self.last_press_time[pin] > self.debounce_delay: | |
| self.last_press_time[pin] = now | |
| self.handle_button_press(pin) | |
| # Reschedule polling | |
| if self.loop: | |
| self.loop.set_alarm_in(0.1, self.poll_buttons) | |
| def handle_button_press(self, pin): | |
| self.last_interaction_time = time.time() | |
| if self.countdown_overlay: | |
| self.cancel_shutdown_prompt() | |
| else: | |
| if pin == GPIO_SHUTDOWN: | |
| self.handle_input('q') | |
| elif pin == GPIO_UP: | |
| self.file_panel.move_focus_up() | |
| elif pin == GPIO_STOP: | |
| self.handle_input('s') | |
| elif pin == GPIO_ENTER: | |
| self.handle_input('enter') | |
| elif pin == GPIO_RANDOM: | |
| self.handle_input('r') | |
| elif pin == GPIO_DOWN: | |
| self.file_panel.move_focus_down() | |
| def handle_input(self, key): | |
| self.last_interaction_time = time.time() | |
| if self.countdown_overlay: | |
| self.cancel_shutdown_prompt() | |
| else: | |
| # The easy keys first | |
| if key in ('q', 'esc'): | |
| self.shutdown() | |
| return | |
| if key == 'r': | |
| self.play_random_video() | |
| return | |
| if key == 's': | |
| self.stop_mpv() | |
| return | |
| if key == 'u': | |
| self.debug_panel.add_message("Updating library (UI locked)...") | |
| self.lock_ui_for_update() | |
| self.loop.draw_screen() | |
| self.loop.set_alarm_in(0.1, self.do_library_update) | |
| return | |
| if key in ('enter', 'return', '\n', '\r'): | |
| if not self.file_panel.walker: | |
| self.debug_panel.add_message("File list empty, no action") | |
| return | |
| label = self.file_panel.get_focused_label() | |
| if label is None: | |
| self.debug_panel.add_message("No item focused") | |
| return | |
| if label == "(Empty folder)": | |
| # Do nothing | |
| return | |
| if label == "Previous Folder": | |
| if not self.file_panel.go_to_previous_folder(): | |
| self.debug_panel.add_message("Already at root folder") | |
| else: | |
| self.update_title() | |
| elif label.startswith("[") and label.endswith("/]"): | |
| folder_name = label[1:-2] | |
| if self.file_panel.enter_folder(folder_name): | |
| self.update_title() | |
| else: | |
| self.debug_panel.add_message("Folder does not exist") | |
| elif label.lower().endswith(VIDEO_EXTENSIONS): | |
| filepath = os.path.join(self.file_panel.get_current_path(), label) | |
| self.play_video(filepath) | |
| return | |
| else: | |
| self.debug_panel.add_message("No action matched for this item") | |
| return | |
| def check_idle_timeout(self, loop=None, user_data=None): | |
| now = time.time() | |
| if self.mpv_process and self.mpv_process.poll() is None: | |
| # Don't trigger shutdown while video is playing | |
| pass | |
| elif now - self.last_interaction_time > self.idle_timeout: | |
| self.start_shutdown_prompt() | |
| else: | |
| self.loop.set_alarm_in(5, self.check_idle_timeout) | |
| # ================================Video player functions | |
| def play_video(self, filepath): | |
| if os.path.isfile(filepath): | |
| relative_path = os.path.relpath(filepath, ROOT_DIR) | |
| self.debug_panel.add_message(f"Playing video file: {relative_path}") | |
| self.stop_mpv() | |
| try: | |
| self.mpv_process = subprocess.Popen( | |
| ['mpv', '--quiet', '--osd-playing-msg-duration=5000', '--osd-playing-msg=\'${filename}\'', '--osd-align-x=center', '--osd-bar-align-y=bottom', filepath], | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| except Exception as e: | |
| self.debug_panel.add_message(f"Failed to play video: {e}") | |
| else: | |
| self.debug_panel.add_message(f"File does not exist: {filepath}") | |
| def play_random_video(self): | |
| if not self.video_library: | |
| self.debug_panel.add_message("Video library is empty.") | |
| return | |
| choice = random.choice(self.video_library) | |
| self.debug_panel.add_message(f"Playing random video...") | |
| self.play_video(choice['path']) | |
| def stop_mpv(self): | |
| if self.mpv_process and self.mpv_process.poll() is None: | |
| try: | |
| self.mpv_process.terminate() | |
| self.mpv_process.wait(timeout=5) | |
| self.debug_panel.add_message("Stopped video") | |
| except Exception as e: | |
| self.debug_panel.add_message(f"Error stopping mpv: {e}") | |
| self.mpv_process = None | |
| # ================================Video library functions | |
| def scan_video_library(self): | |
| self.debug_panel.add_message("Scanning video library...") | |
| videos = [] | |
| for root, dirs, files in os.walk(ROOT_DIR): | |
| for file in files: | |
| if file.lower().endswith(VIDEO_EXTENSIONS): | |
| full_path = os.path.join(root, file) | |
| rel_path = os.path.relpath(full_path, ROOT_DIR) | |
| videos.append({"path": full_path, "relative": rel_path}) | |
| return videos | |
| def save_library(self, library): | |
| os.makedirs(os.path.dirname(LIBRARY_PATH), exist_ok=True) | |
| with open(LIBRARY_PATH, 'w') as f: | |
| json.dump(library, f) | |
| def load_library(self): | |
| try: | |
| with open(LIBRARY_PATH, 'r') as f: | |
| return json.load(f) | |
| except Exception as e: | |
| self.debug_panel.add_message(f"Failed to load library: {e}") | |
| return None | |
| def lock_ui_for_update(self): | |
| locked_msg = urwid.Text("Updating library. Please wait...", align='center') | |
| self.loop.widget = urwid.Overlay( | |
| urwid.Filler(locked_msg), | |
| self.frame, | |
| align='center', width=('relative', 80), | |
| valign='middle', height=5 | |
| ) | |
| def do_library_update(self, loop=None, user_data=None): | |
| new_library = self.scan_video_library() | |
| if new_library: | |
| self.video_library = new_library | |
| self.save_library(self.video_library) | |
| self.debug_panel.add_message("Library update complete.") | |
| else: | |
| self.debug_panel.add_message("Library update failed.") | |
| self.loop.widget = self.frame | |
| # ================================Title bar functions | |
| def update_title(self): | |
| max_width = shutil.get_terminal_size((80, 24)).columns | |
| left = f"THE HORRORBOX v{VERSION}" | |
| sep = " | " | |
| right_max = max_width - len(left) - len(sep) | |
| path_display = self.file_panel.get_current_path().split('/media/usb')[1] | |
| if len(path_display) > right_max: | |
| path_display = "..." + path_display[-(right_max - 3):] | |
| title_text = left + sep + path_display.rjust(right_max) | |
| self.title.set_text(title_text) | |
| # ================================App init/shutdown functions | |
| def start_shutdown_prompt(self): | |
| # Prevent duplicate prompts | |
| if self.countdown_overlay: | |
| return | |
| self.countdown_remaining = self.shutdown_countdown | |
| text = urwid.Text(f"Idle shutdown in {self.countdown_remaining} seconds", align='center') | |
| filler = urwid.Filler(text, valign='middle') | |
| overlay = urwid.Overlay( | |
| filler, | |
| self.frame, | |
| align='center', width=('relative', 80), | |
| valign='middle', height=5 | |
| ) | |
| self.countdown_overlay = overlay | |
| self.loop.widget = overlay | |
| self.countdown_text = text | |
| self.schedule_countdown_tick() | |
| def schedule_countdown_tick(self): | |
| if self.countdown_remaining <= 0: | |
| self.debug_panel.add_message("Auto-power timeout reached") | |
| self.shutdown() | |
| return | |
| self.countdown_text.set_text(f"Idle shutdown in {self.countdown_remaining} seconds") | |
| self.countdown_remaining -= 1 | |
| self.countdown_alarm = self.loop.set_alarm_in(1, lambda loop, data: self.schedule_countdown_tick()) | |
| def cancel_shutdown_prompt(self): | |
| self.countdown_overlay = None | |
| if self.countdown_alarm: | |
| self.loop.remove_alarm(self.countdown_alarm) | |
| self.countdown_alarm = None | |
| self.loop.widget = self.frame | |
| self.last_interaction_time = time.time() # Reset idle time | |
| self.loop.set_alarm_in(5, self.check_idle_timeout) # <-- Reschedule idle check | |
| def shutdown(self): | |
| self.debug_panel.add_message("Shutting down...") | |
| self.stop_mpv() | |
| GPIO.cleanup() | |
| time.sleep(5) | |
| self.loop.stop() | |
| subprocess.call(['sudo', 'shutdown', '-h', 'now']) | |
| def run(self): | |
| splash = urwid.Text('\n'.join(SPLASH_SCREEN), align='left') | |
| splash_fill = urwid.Filler(splash, 'top') | |
| self.loop.widget = splash_fill | |
| def switch_to_main(loop, user_data): | |
| self.loop.widget = self.frame | |
| self.loop.draw_screen() | |
| self.loop.set_alarm_in(5, switch_to_main) # Show splash screen for 5 seconds | |
| self.loop.run() | |
| if __name__ == '__main__': | |
| hb = Horrorbox() | |
| hb.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment