lazy-player/lazy_player/__init__.py

405 lines
14 KiB
Python

from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any, cast
import gi
gi.require_version("Gdk", "4.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Gst", "1.0")
gi.require_version("Pango", "1.0")
from gi.repository import Gdk, Gst, Gtk, Pango # NOQA: E402
class MainWindow(Gtk.ApplicationWindow):
file_info_label: Gtk.Label
stack: Gtk.Stack
list_view: Gtk.ListView
list_store: Gtk.StringList
video_widget: Gtk.Picture
pipeline: Gst.Pipeline
overlay_tick_callback_id: int
overlay_label: Gtk.Label
overlay_hide_time: float
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
# For overlay text timeout
self.overlay_hide_time = 0.0
self.overlay_label = Gtk.Label()
self.overlay_label.set_name("overlay-text")
self.overlay_label.set_valign(Gtk.Align.CENTER)
self.overlay_label.set_halign(Gtk.Align.CENTER)
self.overlay_label.set_visible(False)
self.overlay_label.set_wrap(True)
self.overlay_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR)
def update_overlay(widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool:
current_time = frame_clock.get_frame_time() / 1_000_000
if current_time >= self.overlay_hide_time:
self.overlay_label.set_visible(False)
return True
self.overlay_tick_callback_id = self.add_tick_callback(update_overlay, None)
# Make window fullscreen and borderless
self.set_decorated(False)
self.fullscreen()
# Setup key event controller
key_controller = Gtk.EventControllerKey()
key_controller.connect("key-pressed", self._on_key_pressed)
self.add_controller(key_controller)
# Main horizontal box to split the screen
main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
# Create stack to hold our widgets
self.stack = Gtk.Stack()
# Create video widget and overlay
self.video_widget = Gtk.Picture()
self.video_widget.set_can_shrink(True)
self.video_widget.set_keep_aspect_ratio(True)
self.video_widget.set_vexpand(True)
self.video_widget.set_hexpand(True)
overlay_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
overlay_box.set_name("black-overlay")
overlay_box.set_vexpand(True)
overlay_box.set_hexpand(True)
# Create an overlay container
overlay = Gtk.Overlay()
overlay.set_child(self.video_widget)
overlay.add_overlay(self.overlay_label)
overlay_box.append(overlay)
# Setup GStreamer pipeline
self.pipeline = Gst.Pipeline.new("video-player")
playbin = Gst.ElementFactory.make("playbin", "playbin")
if not playbin:
raise RuntimeError("Failed to create playbin element")
video_sink = Gst.ElementFactory.make("gtk4paintablesink", "gtk4paintablesink")
if not video_sink:
raise RuntimeError("Failed to create gtk4paintablesink element")
playbin.set_property("video-sink", video_sink)
self.pipeline.add(playbin)
# Link video widget to sink
paintable = video_sink.get_property("paintable")
self.video_widget.set_paintable(paintable)
# Add both main menu and overlay to stack
self.stack.add_named(main_box, "menu")
self.stack.add_named(overlay_box, "overlay")
self.stack.set_visible_child_name("menu")
# Create a grid to handle the 1:2 ratio
grid = Gtk.Grid()
grid.set_column_homogeneous(True)
grid.set_hexpand(True)
# Left third (1/3 width)
left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
left_box.set_valign(Gtk.Align.CENTER)
left_box.set_halign(Gtk.Align.FILL)
self.file_info_label = Gtk.Label(label="")
self.file_info_label.set_wrap(True)
left_box.append(self.file_info_label)
# Right two-thirds (2/3 width)
right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
right_box.set_hexpand(True)
# Attach boxes to grid with specific column spans
grid.attach(left_box, 0, 0, 1, 1)
grid.attach(right_box, 1, 0, 2, 1)
main_box.append(grid)
# Create list store and view
self.list_store = Gtk.StringList()
self.list_view = Gtk.ListView()
selection_model = Gtk.SingleSelection.new(self.list_store)
selection_model.connect("selection-changed", self._on_selection_changed)
self.list_view.set_model(selection_model)
self.list_view.set_vexpand(True)
def on_activate(widget: Gtk.ListView, index: int):
selected_item = selection_model.get_item(index)
if selected_item:
string_obj = cast(Gtk.StringObject, selected_item)
string = string_obj.get_string()
if string.endswith("/"):
os.chdir(string)
self._populate_file_list()
return
# Start playing the video
playbin = self.pipeline.get_by_name("playbin")
if playbin:
playbin.set_property("uri", f"file://{os.path.abspath(string)}")
self.pipeline.set_state(Gst.State.PLAYING)
self.stack.set_visible_child_name("overlay")
self.show_overlay_text(string)
self.list_view.connect("activate", on_activate)
# Factory for list items
factory = Gtk.SignalListItemFactory()
factory.connect("setup", self._setup_list_item)
factory.connect("bind", self._bind_list_item)
self.list_view.set_factory(factory)
# Populate the list store
self._populate_file_list()
# Add list view to a scrolled window
scrolled = Gtk.ScrolledWindow()
scrolled.set_child(self.list_view)
right_box.append(scrolled)
self.set_child(self.stack)
def _setup_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem):
label = Gtk.Label()
label.set_halign(Gtk.Align.START)
list_item.set_child(label)
def _bind_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem):
label = cast(Gtk.Label, list_item.get_child())
item = cast(Gtk.StringObject, list_item.get_item())
label.set_text(item.get_string())
def _on_selection_changed(
self,
selection_model: Gtk.SingleSelection,
position: int,
n_items: int,
):
if selection_model.get_selected() == Gtk.INVALID_LIST_POSITION:
self.file_info_label.set_text("")
else:
selected_item = selection_model.get_selected_item()
if selected_item:
string_obj = cast(Gtk.StringObject, selected_item)
self.file_info_label.set_text(string_obj.get_string())
def _toggle_play_pause(self) -> None:
"""Toggle between play and pause states"""
_, state, _ = self.pipeline.get_state(0)
if state == Gst.State.PLAYING:
self.pipeline.set_state(Gst.State.PAUSED)
else:
self.pipeline.set_state(Gst.State.PLAYING)
def _on_video_key_pressed(
self,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
if keyval == Gdk.keyval_from_name("space"):
self._toggle_play_pause()
return True
elif keyval == Gdk.keyval_from_name("Escape"):
self.pipeline.set_state(Gst.State.NULL)
self.stack.set_visible_child_name("menu")
self.list_view.grab_focus()
return True
elif keyval == Gdk.keyval_from_name("Left"):
self._seek_relative(-10)
return True
elif keyval == Gdk.keyval_from_name("Right"):
self._seek_relative(10)
return True
elif keyval == Gdk.keyval_from_name("Up"):
self._seek_relative(60)
return True
elif keyval == Gdk.keyval_from_name("Down"):
self._seek_relative(-60)
return True
elif keyval == Gdk.keyval_from_name("j"):
self._cycle_subtitles()
return True
return False
def _on_key_pressed(
self,
controller: Gtk.EventControllerKey,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
# If we're showing video, handle keys differently
if self.stack.get_visible_child_name() == "overlay":
return self._on_video_key_pressed(keyval, keycode, state)
return False
def _seek_relative(self, offset: int) -> None:
"""Seek relative to current position by offset seconds"""
playbin = self.pipeline.get_by_name("playbin")
if not playbin:
return
# Query current position
success, current = self.pipeline.query_position(Gst.Format.TIME)
if not success:
return
# Convert offset to nanoseconds and add to current
new_pos = current + (offset * Gst.SECOND)
# Ensure we don't seek before start
if new_pos < 0:
new_pos = 0
# Perform seek
self.pipeline.seek_simple(
Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, new_pos
)
def _get_subtitle_info(self, track_index: int) -> str:
"""Get subtitle track info including language if available"""
playbin = self.pipeline.get_by_name("playbin")
if not playbin:
return str(track_index)
# Query the subtitle track's tags
caps: Gst.TagList | None = playbin.emit("get-text-tags", track_index)
if not caps:
return str(track_index)
found, lang = caps.get_string("language-code")
return f"{track_index} ({lang})" if found else str(track_index)
def _cycle_subtitles(self) -> None:
"""Cycle through available subtitle tracks, including off state"""
playbin = self.pipeline.get_by_name("playbin")
if not playbin:
return
# Get current flags and subtitle track
flags = playbin.get_property("flags")
current = playbin.get_property("current-text")
n_text = playbin.get_property("n-text")
if n_text == 0:
self.show_overlay_text("No subtitles available")
return
# If subtitles are disabled, enable them and set to first track
if not (flags & 0x00000004): # TEXT flag
flags |= 0x00000004
playbin.set_property("flags", flags)
playbin.set_property("current-text", 0)
track_info = self._get_subtitle_info(0)
self.show_overlay_text(f"Subtitle track: {track_info}")
return
# If we're on the last track, disable subtitles
if current >= n_text - 1:
flags &= ~0x00000004 # TEXT flag
playbin.set_property("flags", flags)
self.show_overlay_text("Subtitles: Off")
return
# Otherwise cycle to next track
next_track = current + 1
playbin.set_property("current-text", next_track)
track_info = self._get_subtitle_info(next_track)
self.show_overlay_text(f"Subtitle track: {track_info}")
def show_overlay_text(self, text: str, timeout_seconds: float = 1.0) -> None:
"""Show text in a centered overlay that disappears after timeout"""
self.overlay_label.set_text(text)
self.overlay_label.set_visible(True)
# Set absolute time when overlay should hide
frame_clock = self.get_frame_clock()
if frame_clock is None:
return
frame_time = frame_clock.get_frame_time() / 1_000_000 # Convert to seconds
self.overlay_hide_time = frame_time + timeout_seconds
def _populate_file_list(self) -> None:
# TODO: Implement proper version sort (strverscmp equivalent)
directories: list[str] = ["../"]
files: list[str] = []
with os.scandir(".") as it:
for entry in it:
if entry.name == ".." or not entry.name.startswith("."):
parts = entry.name.split(".")
suffix = parts[-1] if len(parts) >= 2 else ""
if entry.is_dir():
directories.append(entry.name + "/")
elif suffix in ("mkv", "mp4", "avi"):
files.append(entry.name)
directories.sort(key=lambda x: x.lower())
files.sort(key=lambda x: x.lower())
while self.list_store.get_n_items():
self.list_store.remove(0)
for name in directories + files:
self.list_store.append(name)
all = directories + files
self.file_info_label.set_text(all[0] if all else "")
class App(Gtk.Application):
def __init__(self):
super().__init__()
# Initialize GStreamer
Gst.init(None)
# Load CSS
css_provider = Gtk.CssProvider()
css_file = Path(__file__).parent / "style.css"
css_provider.load_from_path(str(css_file))
display = Gdk.Display.get_default()
if display is None:
raise RuntimeError("No display available")
Gtk.StyleContext.add_provider_for_display(
display,
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
def do_activate(self):
win = MainWindow(application=self)
win.present()
def main():
if len(sys.argv) >= 2:
os.chdir(sys.argv[1])
app = App()
app.run(None)