From c62963091df477f86f0894963c69f654c0913e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Hamal=20Dvo=C5=99=C3=A1k?= <mordae@anilinux.org> Date: Tue, 11 Mar 2025 09:58:17 +0100 Subject: [PATCH] Reorganize project --- lazy_player/__init__.py | 534 +----------------------------------- lazy_player/application.py | 35 +++ lazy_player/file_model.py | 5 +- lazy_player/main_window.py | 506 ++++++++++++++++++++++++++++++++++ lazy_player/thumbnailer.py | 5 +- lazy_player/video_player.py | 7 +- 6 files changed, 551 insertions(+), 541 deletions(-) create mode 100644 lazy_player/application.py create mode 100644 lazy_player/main_window.py diff --git a/lazy_player/__init__.py b/lazy_player/__init__.py index 609685f..1dc9c99 100644 --- a/lazy_player/__init__.py +++ b/lazy_player/__init__.py @@ -2,540 +2,20 @@ from __future__ import annotations import os import sys -from datetime import datetime -from pathlib import Path -from typing import Any, cast import gi -from .file_model import FileItem, FileListModel, FileType -from .thumbnailer import Thumbnailer -from .video_player import VideoPlayer - gi.require_version("Gdk", "4.0") -gi.require_version("Gtk", "4.0") +gi.require_version("GLib", "2.0") +gi.require_version("GObject", "2.0") gi.require_version("Gst", "1.0") +gi.require_version("Gtk", "4.0") gi.require_version("Pango", "1.0") -from gi.repository import Gdk, GLib, Gst, Gtk, Pango # NOQA: E402 +from gi.repository import Gst # NOQA: E402 -class MainWindow(Gtk.ApplicationWindow): - stack: Gtk.Stack - list_view: Gtk.ListView - list_model: FileListModel - selection_model: Gtk.SingleSelection - video_widget: Gtk.Picture - tick_callback_id: int - overlay_label: Gtk.Label - overlay_hide_time: float - last_position_save: float - - directory_history: list[Path] - selection_history: dict[str, int] - - def __init__(self, *args: Any, thumbnailer: Thumbnailer, **kwargs: Any): - super().__init__(*args, **kwargs) - self.thumbnailer = thumbnailer - - # Directory history stack - self.directory_history = [] - self.selection_history = {} - - # For overlay text timeout - self.overlay_hide_time = 0.0 - self.last_position_save = 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) - - self.tick_callback_id = self.add_tick_callback(self._on_tick, 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) - - # Disable and hide mouse cursor - self.set_can_target(False) - display = Gdk.Display.get_default() - if display: - cursor = Gdk.Cursor.new_from_name("none") - if cursor: - self.set_cursor(cursor) - - # 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) - - video_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - video_box.set_name("black-overlay") - video_box.set_vexpand(True) - video_box.set_hexpand(True) - - # Create an overlay container - overlay = Gtk.Overlay() - overlay.set_child(self.video_widget) - overlay.add_overlay(self.overlay_label) - - video_box.append(overlay) - - # Setup video player - self.video_player = VideoPlayer(self.video_widget) - - # Add both main menu and overlay to stack - self.stack.add_named(main_box, "menu") - self.stack.add_named(video_box, "player") - 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.START) - left_box.set_halign(Gtk.Align.FILL) - - # Create digital clock - self.clock_label = Gtk.Label() - self.clock_label.set_name("digital-clock") - self.clock_label.set_halign(Gtk.Align.CENTER) - self.clock_label.set_valign(Gtk.Align.CENTER) - self.clock_label.set_text(datetime.now().strftime("%H:%M")) - left_box.append(self.clock_label) - - # Create image widget for thumbnail - self.thumbnail_image = Gtk.Picture() - self.thumbnail_image.set_name("thumbnail-image") - self.thumbnail_image.set_size_request(384, 216) # 16:9 aspect ratio - self.thumbnail_image.set_can_shrink(True) - self.thumbnail_image.set_keep_aspect_ratio(True) - self.thumbnail_image.set_resource(None) - left_box.append(self.thumbnail_image) - - # 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 model and view - self.list_model = FileListModel() - self.list_view = Gtk.ListView() - self.selection_model = Gtk.SingleSelection.new(self.list_model) - self.selection_model.connect("selection-changed", self._on_selection_changed) - self.list_view.set_model(self.selection_model) - self.list_view.set_vexpand(True) - - def on_activate(widget: Gtk.ListView, index: int): - selected_item = self.selection_model.get_item(index) - if selected_item: - file_item = cast(FileItem, selected_item) - - if file_item.file_type == FileType.DIRECTORY: - self._navigate_to(file_item.full_path) - return - - position = file_item.saved_position - duration = file_item.saved_duration - - if (position / duration) >= 0.99: - position = 0 - - # Start playing the video - self.video_player.play( - file_item.full_path, - position, - file_item.saved_subtitle_track, - file_item.saved_audio_track, - ) - self.last_position_save = self.now - - self.stack.set_visible_child_name("player") - self.show_overlay_text(f"Playing: {file_item.name}") - - 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) - - @property - def now(self) -> float: - frame_clock = self.get_frame_clock() - if frame_clock is None: - return 0 - - return frame_clock.get_frame_time() / 1_000_000 - - @property - def selection(self) -> FileItem | None: - selected_item = self.selection_model.get_selected_item() - return cast(FileItem, selected_item) if selected_item else None - - def _setup_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem): - # Create horizontal box to hold icon and label - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box.set_spacing(8) - - # Create icon image - icon = Gtk.Image() - icon.set_css_classes(["file-icon"]) - box.append(icon) - - # Create label - label = Gtk.Label() - label.set_halign(Gtk.Align.START) - box.append(label) - - list_item.set_child(box) - - def _bind_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem): - box = cast(Gtk.Box, list_item.get_child()) - icon = cast(Gtk.Image, box.get_first_child()) - label = cast(Gtk.Label, box.get_last_child()) - item = cast(FileItem, list_item.get_item()) - - def update_icon(*args: object) -> None: - if item.file_type == FileType.DIRECTORY: - icon.set_from_icon_name("folder-symbolic") - icon.set_css_classes(["file-icon"]) - else: - position = item.saved_position - duration = item.saved_duration - - if position == 0: - icon.set_from_icon_name("media-playback-start-symbolic") - icon.set_css_classes(["file-icon", "unwatched"]) - elif (position / duration) >= 0.99: - icon.set_from_icon_name("object-select-symbolic") - icon.set_css_classes(["file-icon", "completed"]) - else: - icon.set_from_icon_name("media-playback-pause-symbolic") - icon.set_css_classes(["file-icon", "in-progress"]) - - label.set_text(item.name) - item.connect("notify::saved-position", update_icon) - item.connect("notify::saved-duration", update_icon) - update_icon() - - def _refresh(self): - self._populate_file_list() - - pos = self.selection_history.get(str(os.getcwd()), 0) - self.selection_model.set_selected(pos) - self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None) - - def _navigate_to(self, path: Path): - self.directory_history.append(Path(os.getcwd())) - os.chdir(path) - self._populate_file_list() - - pos = self.selection_history.get(str(path), 0) - self.selection_model.set_selected(pos) - self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None) - - def _navigate_back(self): - if not self.directory_history: - return - - prev_dir = self.directory_history.pop() - os.chdir(prev_dir) - self._populate_file_list() - - pos = self.selection_history.get(str(prev_dir), 0) - self.selection_model.set_selected(pos) - self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None) - - def _on_selection_changed( - self, - selection_model: Gtk.SingleSelection, - position: int, - n_items: int, - ): - position = selection_model.get_selected() - - if position == Gtk.INVALID_LIST_POSITION: - return - - self.selection_history[os.getcwd()] = position - - file_item = self.selection - if file_item is not None: - # Update thumbnail if available - if file_item.thumbnail: - gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail)) - texture = Gdk.Texture.new_from_bytes(gbytes) - self.thumbnail_image.set_paintable(texture) - else: - self.thumbnailer.generate_thumbnail(file_item) - self.thumbnail_image.set_paintable(None) - - def _toggle_play_pause(self) -> None: - """Toggle between play and pause states""" - self.video_player.toggle_play_pause() - - def _on_player_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._save_position() - self.video_player.stop() - self.stack.set_visible_child_name("menu") - self.list_view.grab_focus() - return True - - elif keyval == Gdk.keyval_from_name("Left"): - self.video_player.seek_relative(-10) - return True - - elif keyval == Gdk.keyval_from_name("Right"): - self.video_player.seek_relative(10) - return True - - elif keyval == Gdk.keyval_from_name("Down"): - self.video_player.seek_relative(-60) - return True - - elif keyval == Gdk.keyval_from_name("Up"): - self.video_player.seek_relative(60) - return True - - elif keyval == Gdk.keyval_from_name("Home"): - self.video_player.seek_start() - return True - - elif keyval == Gdk.keyval_from_name("End"): - self.video_player.seek_end() - return True - - elif keyval == Gdk.keyval_from_name("j"): - has_subs, index, lang = self.video_player.cycle_subtitles() - - if has_subs: - if index: - self.show_overlay_text(f"Subtitles #{index} ({lang})") - else: - self.show_overlay_text("Subtitles turned off") - - file_item = self.selection - if file_item is not None: - file_item.saved_subtitle_track = index - 1 - else: - self.show_overlay_text("No subtitles available") - - return True - - elif keyval == Gdk.keyval_from_name("a"): - has_audio, index, lang = self.video_player.cycle_audio() - - if has_audio: - self.show_overlay_text(f"Audio #{index} ({lang})") - file_item = self.selection - if file_item is not None: - file_item.saved_audio_track = index - 1 - else: - self.show_overlay_text("No audio tracks available") - - return True - - return False - - def _toggle_watched_status(self) -> None: - """Toggle watched status for the selected file""" - - file_item = self.selection - - if file_item is None or file_item.file_type == FileType.DIRECTORY: - return - - position = file_item.saved_position - duration = file_item.saved_duration - - # If position exists and is >= 99% through, clear it - if position > 0 and (position / duration) >= 0.99: - file_item.saved_position = 0 - else: - # Otherwise mark as complete - file_item.saved_position = duration - - # Force the list to update the changed item - self.list_model.items_changed(self.selection_model.get_selected(), 1, 1) - - def _on_menu_key_pressed( - self, - keyval: int, - keycode: int, - state: Gdk.ModifierType, - ) -> bool: - if keyval == Gdk.keyval_from_name("q"): - self.close() - return True - - elif keyval == Gdk.keyval_from_name("r"): - self._refresh() - return True - - elif keyval == Gdk.keyval_from_name("w"): - self._toggle_watched_status() - return True - - elif keyval == Gdk.keyval_from_name("BackSpace"): - self._navigate_back() - return True - - return False - - def _on_key_pressed( - self, - controller: Gtk.EventControllerKey, - keyval: int, - keycode: int, - state: Gdk.ModifierType, - ) -> bool: - # Handle keys differently based on which view is active - if self.stack.get_visible_child_name() == "player": - return self._on_player_key_pressed(keyval, keycode, state) - else: - return self._on_menu_key_pressed(keyval, keycode, state) - - def _save_position(self) -> None: - """Save current playback position as xattr""" - - if not self.video_player.is_playing: - return - - file_item = self.selection - - if file_item is None or file_item.file_type == FileType.DIRECTORY: - return - - position = self.video_player.get_position() - if position is not None: - file_item.saved_position = position - - duration = self.video_player.get_duration() - if duration is not None: - file_item.saved_duration = duration - - 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 - self.overlay_hide_time = self.now + timeout_seconds - - def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool: - current_time = frame_clock.get_frame_time() / 1_000_000 - - self.clock_label.set_text(datetime.now().strftime("%H:%M")) - - if current_time >= self.overlay_hide_time: - self.overlay_label.set_visible(False) - - # Save position every 60 seconds - frame_time = frame_clock.get_frame_time() / 1_000_000 - if frame_time - self.last_position_save >= 60.0: - self._save_position() - self.last_position_save = frame_time - - # Update thumbnail if available - file_item = self.selection - - if file_item is not None: - if file_item.thumbnail and not self.thumbnail_image.get_paintable(): - gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail)) - texture = Gdk.Texture.new_from_bytes(gbytes) - self.thumbnail_image.set_paintable(texture) - - return True - - def _populate_file_list(self) -> None: - items: list[FileItem] = [] - - for entry in os.scandir(): - if entry.name.startswith("."): - continue - - path = Path(entry.name) - - if path.is_dir(): - items.append(FileItem(path.name, FileType.DIRECTORY, path.resolve())) - elif path.suffix in (".mkv", ".mp4", ".avi"): - file_item = FileItem(path.name, FileType.VIDEO, path.resolve()) - items.append(file_item) - - # Sort directories first, then files, both alphabetically - items.sort(key=lambda x: (x.file_type != FileType.DIRECTORY, x.name.lower())) - - self.list_model.set_items(items) - - -class App(Gtk.Application): - def __init__(self, thumbnailer: Thumbnailer): - super().__init__() - self.thumbnailer = thumbnailer - - # 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, thumbnailer=self.thumbnailer) - win.present() +from .application import Application # NOQA: E402 +from .thumbnailer import Thumbnailer # NOQA: E402 def main(): @@ -546,7 +26,7 @@ def main(): Gst.init(None) thumbnailer = Thumbnailer() - app = App(thumbnailer) + app = Application(thumbnailer=thumbnailer) try: thumbnailer.start() diff --git a/lazy_player/application.py b/lazy_player/application.py new file mode 100644 index 0000000..2cbaca7 --- /dev/null +++ b/lazy_player/application.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path + +from gi.repository import Gdk, Gtk + +from .main_window import MainWindow +from .thumbnailer import Thumbnailer + +__all__ = ["Application"] + + +class Application(Gtk.Application): + def __init__(self, thumbnailer: Thumbnailer): + super().__init__() + self.thumbnailer = thumbnailer + + # 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, thumbnailer=self.thumbnailer) + win.present() diff --git a/lazy_player/file_model.py b/lazy_player/file_model.py index ae49f2c..698ed9e 100644 --- a/lazy_player/file_model.py +++ b/lazy_player/file_model.py @@ -6,10 +6,7 @@ from enum import Enum, auto from pathlib import Path from typing import Optional, overload -import gi - -gi.require_version("GObject", "2.0") -from gi.repository import Gio, GObject # NOQA: E402 +from gi.repository import Gio, GObject class FileType(Enum): diff --git a/lazy_player/main_window.py b/lazy_player/main_window.py new file mode 100644 index 0000000..8ffbd57 --- /dev/null +++ b/lazy_player/main_window.py @@ -0,0 +1,506 @@ +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +from gi.repository import Gdk, GLib, Gtk, Pango + +from .file_model import FileItem, FileListModel, FileType +from .thumbnailer import Thumbnailer +from .video_player import VideoPlayer + + +class MainWindow(Gtk.ApplicationWindow): + stack: Gtk.Stack + list_view: Gtk.ListView + list_model: FileListModel + selection_model: Gtk.SingleSelection + video_widget: Gtk.Picture + tick_callback_id: int + overlay_label: Gtk.Label + overlay_hide_time: float + last_position_save: float + + directory_history: list[Path] + selection_history: dict[str, int] + + def __init__(self, *args: Any, thumbnailer: Thumbnailer, **kwargs: Any): + super().__init__(*args, **kwargs) + self.thumbnailer = thumbnailer + + # Directory history stack + self.directory_history = [] + self.selection_history = {} + + # For overlay text timeout + self.overlay_hide_time = 0.0 + self.last_position_save = 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) + + self.tick_callback_id = self.add_tick_callback(self._on_tick, 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) + + # Disable and hide mouse cursor + self.set_can_target(False) + display = Gdk.Display.get_default() + if display: + cursor = Gdk.Cursor.new_from_name("none") + if cursor: + self.set_cursor(cursor) + + # 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) + + video_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + video_box.set_name("black-overlay") + video_box.set_vexpand(True) + video_box.set_hexpand(True) + + # Create an overlay container + overlay = Gtk.Overlay() + overlay.set_child(self.video_widget) + overlay.add_overlay(self.overlay_label) + + video_box.append(overlay) + + # Setup video player + self.video_player = VideoPlayer(self.video_widget) + + # Add both main menu and overlay to stack + self.stack.add_named(main_box, "menu") + self.stack.add_named(video_box, "player") + 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.START) + left_box.set_halign(Gtk.Align.FILL) + + # Create digital clock + self.clock_label = Gtk.Label() + self.clock_label.set_name("digital-clock") + self.clock_label.set_halign(Gtk.Align.CENTER) + self.clock_label.set_valign(Gtk.Align.CENTER) + self.clock_label.set_text(datetime.now().strftime("%H:%M")) + left_box.append(self.clock_label) + + # Create image widget for thumbnail + self.thumbnail_image = Gtk.Picture() + self.thumbnail_image.set_name("thumbnail-image") + self.thumbnail_image.set_size_request(384, 216) # 16:9 aspect ratio + self.thumbnail_image.set_can_shrink(True) + self.thumbnail_image.set_keep_aspect_ratio(True) + self.thumbnail_image.set_resource(None) + left_box.append(self.thumbnail_image) + + # 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 model and view + self.list_model = FileListModel() + self.list_view = Gtk.ListView() + self.selection_model = Gtk.SingleSelection.new(self.list_model) + self.selection_model.connect("selection-changed", self._on_selection_changed) + self.list_view.set_model(self.selection_model) + self.list_view.set_vexpand(True) + + def on_activate(widget: Gtk.ListView, index: int): + selected_item = self.selection_model.get_item(index) + if selected_item: + file_item = cast(FileItem, selected_item) + + if file_item.file_type == FileType.DIRECTORY: + self._navigate_to(file_item.full_path) + return + + position = file_item.saved_position + duration = file_item.saved_duration + + if (position / duration) >= 0.99: + position = 0 + + # Start playing the video + self.video_player.play( + file_item.full_path, + position, + file_item.saved_subtitle_track, + file_item.saved_audio_track, + ) + self.last_position_save = self.now + + self.stack.set_visible_child_name("player") + self.show_overlay_text(f"Playing: {file_item.name}") + + 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) + + @property + def now(self) -> float: + frame_clock = self.get_frame_clock() + if frame_clock is None: + return 0 + + return frame_clock.get_frame_time() / 1_000_000 + + @property + def selection(self) -> FileItem | None: + selected_item = self.selection_model.get_selected_item() + return cast(FileItem, selected_item) if selected_item else None + + def _setup_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem): + # Create horizontal box to hold icon and label + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + box.set_spacing(8) + + # Create icon image + icon = Gtk.Image() + icon.set_css_classes(["file-icon"]) + box.append(icon) + + # Create label + label = Gtk.Label() + label.set_halign(Gtk.Align.START) + box.append(label) + + list_item.set_child(box) + + def _bind_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem): + box = cast(Gtk.Box, list_item.get_child()) + icon = cast(Gtk.Image, box.get_first_child()) + label = cast(Gtk.Label, box.get_last_child()) + item = cast(FileItem, list_item.get_item()) + + def update_icon(*args: object) -> None: + if item.file_type == FileType.DIRECTORY: + icon.set_from_icon_name("folder-symbolic") + icon.set_css_classes(["file-icon"]) + else: + position = item.saved_position + duration = item.saved_duration + + if position == 0: + icon.set_from_icon_name("media-playback-start-symbolic") + icon.set_css_classes(["file-icon", "unwatched"]) + elif (position / duration) >= 0.99: + icon.set_from_icon_name("object-select-symbolic") + icon.set_css_classes(["file-icon", "completed"]) + else: + icon.set_from_icon_name("media-playback-pause-symbolic") + icon.set_css_classes(["file-icon", "in-progress"]) + + label.set_text(item.name) + item.connect("notify::saved-position", update_icon) + item.connect("notify::saved-duration", update_icon) + update_icon() + + def _refresh(self): + self._populate_file_list() + + pos = self.selection_history.get(str(os.getcwd()), 0) + self.selection_model.set_selected(pos) + self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None) + + def _navigate_to(self, path: Path): + self.directory_history.append(Path(os.getcwd())) + os.chdir(path) + self._populate_file_list() + + pos = self.selection_history.get(str(path), 0) + self.selection_model.set_selected(pos) + self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None) + + def _navigate_back(self): + if not self.directory_history: + return + + prev_dir = self.directory_history.pop() + os.chdir(prev_dir) + self._populate_file_list() + + pos = self.selection_history.get(str(prev_dir), 0) + self.selection_model.set_selected(pos) + self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None) + + def _on_selection_changed( + self, + selection_model: Gtk.SingleSelection, + position: int, + n_items: int, + ): + position = selection_model.get_selected() + + if position == Gtk.INVALID_LIST_POSITION: + return + + self.selection_history[os.getcwd()] = position + + file_item = self.selection + if file_item is not None: + # Update thumbnail if available + if file_item.thumbnail: + gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail)) + texture = Gdk.Texture.new_from_bytes(gbytes) + self.thumbnail_image.set_paintable(texture) + else: + self.thumbnailer.generate_thumbnail(file_item) + self.thumbnail_image.set_paintable(None) + + def _toggle_play_pause(self) -> None: + """Toggle between play and pause states""" + self.video_player.toggle_play_pause() + + def _on_player_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._save_position() + self.video_player.stop() + self.stack.set_visible_child_name("menu") + self.list_view.grab_focus() + return True + + elif keyval == Gdk.keyval_from_name("Left"): + self.video_player.seek_relative(-10) + return True + + elif keyval == Gdk.keyval_from_name("Right"): + self.video_player.seek_relative(10) + return True + + elif keyval == Gdk.keyval_from_name("Down"): + self.video_player.seek_relative(-60) + return True + + elif keyval == Gdk.keyval_from_name("Up"): + self.video_player.seek_relative(60) + return True + + elif keyval == Gdk.keyval_from_name("Home"): + self.video_player.seek_start() + return True + + elif keyval == Gdk.keyval_from_name("End"): + self.video_player.seek_end() + return True + + elif keyval == Gdk.keyval_from_name("j"): + has_subs, index, lang = self.video_player.cycle_subtitles() + + if has_subs: + if index: + self.show_overlay_text(f"Subtitles #{index} ({lang})") + else: + self.show_overlay_text("Subtitles turned off") + + file_item = self.selection + if file_item is not None: + file_item.saved_subtitle_track = index - 1 + else: + self.show_overlay_text("No subtitles available") + + return True + + elif keyval == Gdk.keyval_from_name("a"): + has_audio, index, lang = self.video_player.cycle_audio() + + if has_audio: + self.show_overlay_text(f"Audio #{index} ({lang})") + file_item = self.selection + if file_item is not None: + file_item.saved_audio_track = index - 1 + else: + self.show_overlay_text("No audio tracks available") + + return True + + return False + + def _toggle_watched_status(self) -> None: + """Toggle watched status for the selected file""" + + file_item = self.selection + + if file_item is None or file_item.file_type == FileType.DIRECTORY: + return + + position = file_item.saved_position + duration = file_item.saved_duration + + # If position exists and is >= 99% through, clear it + if position > 0 and (position / duration) >= 0.99: + file_item.saved_position = 0 + else: + # Otherwise mark as complete + file_item.saved_position = duration + + # Force the list to update the changed item + self.list_model.items_changed(self.selection_model.get_selected(), 1, 1) + + def _on_menu_key_pressed( + self, + keyval: int, + keycode: int, + state: Gdk.ModifierType, + ) -> bool: + if keyval == Gdk.keyval_from_name("q"): + self.close() + return True + + elif keyval == Gdk.keyval_from_name("r"): + self._refresh() + return True + + elif keyval == Gdk.keyval_from_name("w"): + self._toggle_watched_status() + return True + + elif keyval == Gdk.keyval_from_name("BackSpace"): + self._navigate_back() + return True + + return False + + def _on_key_pressed( + self, + controller: Gtk.EventControllerKey, + keyval: int, + keycode: int, + state: Gdk.ModifierType, + ) -> bool: + # Handle keys differently based on which view is active + if self.stack.get_visible_child_name() == "player": + return self._on_player_key_pressed(keyval, keycode, state) + else: + return self._on_menu_key_pressed(keyval, keycode, state) + + def _save_position(self) -> None: + """Save current playback position as xattr""" + + if not self.video_player.is_playing: + return + + file_item = self.selection + + if file_item is None or file_item.file_type == FileType.DIRECTORY: + return + + position = self.video_player.get_position() + if position is not None: + file_item.saved_position = position + + duration = self.video_player.get_duration() + if duration is not None: + file_item.saved_duration = duration + + 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 + self.overlay_hide_time = self.now + timeout_seconds + + def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool: + current_time = frame_clock.get_frame_time() / 1_000_000 + + self.clock_label.set_text(datetime.now().strftime("%H:%M")) + + if current_time >= self.overlay_hide_time: + self.overlay_label.set_visible(False) + + # Save position every 60 seconds + frame_time = frame_clock.get_frame_time() / 1_000_000 + if frame_time - self.last_position_save >= 60.0: + self._save_position() + self.last_position_save = frame_time + + # Update thumbnail if available + file_item = self.selection + + if file_item is not None: + if file_item.thumbnail and not self.thumbnail_image.get_paintable(): + gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail)) + texture = Gdk.Texture.new_from_bytes(gbytes) + self.thumbnail_image.set_paintable(texture) + + return True + + def _populate_file_list(self) -> None: + items: list[FileItem] = [] + + for entry in os.scandir(): + if entry.name.startswith("."): + continue + + path = Path(entry.name) + + if path.is_dir(): + items.append(FileItem(path.name, FileType.DIRECTORY, path.resolve())) + elif path.suffix in (".mkv", ".mp4", ".avi"): + file_item = FileItem(path.name, FileType.VIDEO, path.resolve()) + items.append(file_item) + + # Sort directories first, then files, both alphabetically + items.sort(key=lambda x: (x.file_type != FileType.DIRECTORY, x.name.lower())) + + self.list_model.set_items(items) diff --git a/lazy_player/thumbnailer.py b/lazy_player/thumbnailer.py index 82179d1..2bf52e2 100644 --- a/lazy_player/thumbnailer.py +++ b/lazy_player/thumbnailer.py @@ -3,13 +3,10 @@ from __future__ import annotations import threading from queue import Empty, Queue -import gi +from gi.repository import Gst from .file_model import FileItem -gi.require_version("Gst", "1.0") -from gi.repository import Gst # NOQA: E402 - class Thumbnailer(threading.Thread): queue: Queue[FileItem | None] diff --git a/lazy_player/video_player.py b/lazy_player/video_player.py index 4789e71..50f5277 100644 --- a/lazy_player/video_player.py +++ b/lazy_player/video_player.py @@ -2,12 +2,7 @@ from __future__ import annotations from pathlib import Path -import gi - -gi.require_version("Gtk", "4.0") -gi.require_version("Gst", "1.0") -gi.require_version("GObject", "2.0") -from gi.repository import GObject, Gst, Gtk # NOQA: E402 # NOQA: E402 +from gi.repository import GObject, Gst, Gtk class VideoPlayer(GObject.Object):