Skip to content

Instantly share code, notes, and snippets.

@mpentler
Last active July 21, 2025 21:16
Show Gist options
  • Select an option

  • Save mpentler/66dd46019ee59e1249f3b50af4da0c51 to your computer and use it in GitHub Desktop.

Select an option

Save mpentler/66dd46019ee59e1249f3b50af4da0c51 to your computer and use it in GitHub Desktop.
RPi CLI video player using MPV and GPIO for control (or ssh)
#!/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