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)