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)