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 from .file_model import FileItem, FileListModel, FileType from .reactive import Watcher, update_all_computed from .thumbnailer import Thumbnailer from .video_overlay import VideoOverlay from .video_player import VideoPlayer class MainWindow(Gtk.ApplicationWindow, Watcher): player: VideoPlayer overlay: VideoOverlay stack: Gtk.Stack list_view: Gtk.ListView list_model: FileListModel selection_model: Gtk.SingleSelection video_picture: Gtk.Picture thumbnail_picture: Gtk.Picture clock: Gtk.Label directory_history: list[Path] selection_history: dict[str, int] last_position_save: float now: float 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 = {} # Last time we've saved playback position. self.last_position_save = 0.0 # 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) # Process frame ticks. self.add_tick_callback(self._on_tick, None) # Create video widget and overlay self.video_picture = Gtk.Picture() self.video_picture.set_can_shrink(True) self.video_picture.set_keep_aspect_ratio(True) self.video_picture.set_vexpand(True) self.video_picture.set_hexpand(True) # Create main menu clock self.clock = Gtk.Label( name="main-clock", halign=Gtk.Align.START, valign=Gtk.Align.START, ) # Create image widget for thumbnail self.thumbnail_picture = Gtk.Picture( name="thumbnail-picture", can_shrink=True, keep_aspect_ratio=True, ) self.thumbnail_picture.set_size_request(384, 216) self.thumbnail_picture.set_resource(None) # Create list & selection model for the file list view. self.list_model = FileListModel() self.selection_model = Gtk.SingleSelection(model=self.list_model) self.selection_model.connect("selection-changed", self._on_selection_changed) # Create file list view. self.list_view = Gtk.ListView( name="file-list", vexpand=True, model=self.selection_model, ) self.list_view.connect("activate", self._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) # Add list view to a scrolled window scrolled = Gtk.ScrolledWindow() scrolled.set_child(self.list_view) # Setup video player. self.player = VideoPlayer(self.video_picture) # Setup video overlay using that player. self.overlay = VideoOverlay(self.player) self.overlay.set_name("overlay") # 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) left_box.append(self.clock) left_box.append(self.thumbnail_picture) # Right two-thirds (2/3 width). right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) right_box.set_hexpand(True) right_box.append(scrolled) # Create a grid to handle the 1:2 ratio we want. grid = Gtk.Grid() grid.set_column_homogeneous(True) grid.set_hexpand(True) grid.attach(left_box, 0, 0, 1, 1) grid.attach(right_box, 1, 0, 2, 1) # Add both main menu and overlay to stack. self.stack = Gtk.Stack() self.stack.add_named(grid, "menu") self.stack.add_named(self.overlay, "player") # Start with main menu visible. self.stack.set_visible_child_name("menu") # Stack is our root. self.set_child(self.stack) # Enable all watch methods. self.watch_all() # Populate the list store. self._populate_file_list() @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(): if item.file_type == FileType.DIRECTORY: icon.set_from_icon_name("folder-symbolic") icon.set_css_classes(["file-icon"]) else: position = item.saved_position.value duration = item.saved_duration.value 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.watch(update_icon) update_icon() def _on_activate(self, 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.value duration = file_item.saved_duration.value if (position / duration) >= 0.99: position = 0 # Start playing the video self.player.play( file_item, position, file_item.saved_subtitle_track.value, file_item.saved_audio_track.value, ) self.last_position_save = self.now self.stack.set_visible_child_name("player") self.overlay.show_message(f"Playing: {file_item.name}") def _restore_selection(self): pos = self.selection_history.get(os.getcwd(), 0) self.selection_model.set_selected(pos) self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None) def _refresh(self): self._populate_file_list() self._restore_selection() def _navigate_to(self, path: Path): self.directory_history.append(Path(os.getcwd())) os.chdir(path) self._populate_file_list() self._restore_selection() def _navigate_back(self): if not self.directory_history: return prev_dir = self.directory_history.pop() os.chdir(prev_dir) self._populate_file_list() self._restore_selection() 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 if file_item := self.selection: # Update thumbnail if available if file_item.thumbnail.value: gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail.value)) texture = Gdk.Texture.new_from_bytes(gbytes) self.thumbnail_picture.set_paintable(texture) else: self.thumbnailer.generate_thumbnail(file_item) self.thumbnail_picture.set_paintable(None) def _toggle_play_pause(self) -> None: """Toggle between play and pause states""" self.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.player.stop() self.stack.set_visible_child_name("menu") self.list_view.grab_focus() return True elif keyval == Gdk.keyval_from_name("Left"): self.player.seek_relative(-10) return True elif keyval == Gdk.keyval_from_name("Right"): self.player.seek_relative(10) return True elif keyval == Gdk.keyval_from_name("Down"): self.player.seek_relative(-60) return True elif keyval == Gdk.keyval_from_name("Up"): self.player.seek_relative(60) return True elif keyval == Gdk.keyval_from_name("Home"): self.player.seek_start() return True elif keyval == Gdk.keyval_from_name("End"): self.player.seek_end() return True elif keyval == Gdk.keyval_from_name("o"): self.player.last_user_input.value = self.now return True elif keyval == Gdk.keyval_from_name("j"): has_subs, index, lang = self.player.cycle_subtitles() if has_subs: if index: self.overlay.show_message(f"Subtitles #{index} ({lang})") else: self.overlay.show_message("Subtitles turned off") file_item = self.selection if file_item is not None: file_item.saved_subtitle_track.value = index - 1 else: self.overlay.show_message("No subtitles available") return True elif keyval == Gdk.keyval_from_name("a"): has_audio, index, lang = self.player.cycle_audio() if has_audio: self.overlay.show_message(f"Audio #{index} ({lang})") file_item = self.selection if file_item is not None: file_item.saved_audio_track.value = index - 1 else: self.overlay.show_message("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.value duration = file_item.saved_duration.value # If position exists and is >= 99% through, clear it if position > 0 and (position / duration) >= 0.99: file_item.saved_position.value = 0 else: # Otherwise mark as complete file_item.saved_position.value = 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.player.is_playing: return file_item = self.player.file_item.value if file_item is None or file_item.file_type == FileType.DIRECTORY: return position = self.player.get_position() if position is not None: file_item.saved_position.value = position duration = self.player.get_duration() if duration is not None: file_item.saved_duration.value = duration def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool: # Update all reactive values for whole application. update_all_computed() self.clock.set_text(datetime.now().strftime("%H:%M")) self.now = frame_clock.get_frame_time() / 1_000_000 # Save playback position every 60 seconds. if self.now - self.last_position_save >= 60.0: self._save_position() self.last_position_save = self.now # Update thumbnail if available if file_item := self.selection: self.thumbnailer.generate_thumbnail(file_item) if file_item.thumbnail.value and not self.thumbnail_picture.get_paintable(): gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail.value)) texture = Gdk.Texture.new_from_bytes(gbytes) self.thumbnail_picture.set_paintable(texture) if self.thumbnailer._work_queue.empty(): for i in range(self.list_model.get_n_items()): if item := self.list_model.get_item(i): assert isinstance(item, FileItem) if item.file_type != FileType.DIRECTORY and not item.attempted_thumbnail.value: self.thumbnailer.generate_thumbnail(item) break 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)