diff --git a/lazy_player/main_window.py b/lazy_player/main_window.py index bb4337c..c4362af 100644 --- a/lazy_player/main_window.py +++ b/lazy_player/main_window.py @@ -5,32 +5,36 @@ from datetime import datetime from pathlib import Path from typing import Any, cast -from gi.repository import Gdk, GLib, Gtk, Pango +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_widget: Gtk.Picture - tick_callback_id: int - overlay_label: Gtk.Label - overlay_hide_time: float - last_position_save: float - grid_segments: list[list[Gtk.Box]] - grid_overlay: Gtk.Grid - grid_clock: Gtk.Label - main_clock: Gtk.Label + + 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 @@ -39,18 +43,8 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): self.directory_history = [] self.selection_history = {} - # For overlay text timeout - self.overlay_hide_time = 0.0 + # Last time we've saved playback position. 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) @@ -69,107 +63,29 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): 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() + # Process frame ticks. + self.add_tick_callback(self._on_tick, None) # 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 grid overlay with 3x3 boxes - self.grid_overlay = Gtk.Grid() - self.grid_overlay.set_hexpand(True) - self.grid_overlay.set_vexpand(True) - self.grid_overlay.set_name("grid-overlay") - self.grid_overlay.set_column_homogeneous(True) - self.grid_overlay.set_row_homogeneous(True) - - # Store grid segments in a 3x3 array - self.grid_segments = [[Gtk.Box() for _ in range(3)] for _ in range(3)] - - # Setup 3x3 grid of boxes - for row in range(3): - for col in range(3): - box = self.grid_segments[row][col] - box.set_name("grid-box") - box.set_hexpand(True) - box.set_vexpand(True) - self.grid_overlay.attach(box, col, row, 1, 1) - - # Add clock to top-left grid box - self.grid_clock = Gtk.Label() - self.grid_clock.set_name("grid-clock") - self.grid_clock.set_halign(Gtk.Align.CENTER) - self.grid_clock.set_valign(Gtk.Align.START) - self.grid_clock.set_hexpand(True) - self.grid_clock.set_vexpand(True) - self.grid_clock.set_text(datetime.now().strftime("%H:%M")) - - # Attach to top-left grid box - self.grid_segments[0][0].append(self.grid_clock) - - # Create an overlay container - overlay = Gtk.Overlay() - overlay.set_child(self.video_widget) - overlay.add_overlay(self.grid_overlay) - 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) + 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.main_clock = Gtk.Label() - self.main_clock.set_name("digital-clock") - self.main_clock.set_halign(Gtk.Align.CENTER) - self.main_clock.set_valign(Gtk.Align.CENTER) - left_box.append(self.main_clock) + self.clock = Gtk.Label() + self.clock.set_name("main-clock") + self.clock.set_halign(Gtk.Align.CENTER) + self.clock.set_valign(Gtk.Align.CENTER) # 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) + self.thumbnail_picture = Gtk.Picture() + self.thumbnail_picture.set_name("thumbnail-picture") + self.thumbnail_picture.set_size_request(384, 216) + self.thumbnail_picture.set_can_shrink(True) + self.thumbnail_picture.set_keep_aspect_ratio(True) + self.thumbnail_picture.set_resource(None) # Create list model and view self.list_model = FileListModel() @@ -178,35 +94,7 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): 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.value - duration = file_item.saved_duration.value - - 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.value, - file_item.saved_audio_track.value, - ) - 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) + self.list_view.connect("activate", self._on_activate) # Factory for list items factory = Gtk.SignalListItemFactory() @@ -214,25 +102,52 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): 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) + + # 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() - @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 + # Populate the list store. + self._populate_file_list() @property def selection(self) -> FileItem | None: @@ -285,6 +200,33 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): item.connect("notify::saved-duration", 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.full_path, + 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) @@ -327,14 +269,14 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): if file_item.thumbnail.value: gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail.value)) texture = Gdk.Texture.new_from_bytes(gbytes) - self.thumbnail_image.set_paintable(texture) + self.thumbnail_picture.set_paintable(texture) else: self.thumbnailer.generate_thumbnail(file_item) - self.thumbnail_image.set_paintable(None) + self.thumbnail_picture.set_paintable(None) def _toggle_play_pause(self) -> None: """Toggle between play and pause states""" - self.video_player.toggle_play_pause() + self.player.toggle_play_pause() def _on_player_key_pressed( self, @@ -348,62 +290,62 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): elif keyval == Gdk.keyval_from_name("Escape"): self._save_position() - self.video_player.stop() + 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.video_player.seek_relative(-10) + self.player.seek_relative(-10) return True elif keyval == Gdk.keyval_from_name("Right"): - self.video_player.seek_relative(10) + self.player.seek_relative(10) return True elif keyval == Gdk.keyval_from_name("Down"): - self.video_player.seek_relative(-60) + self.player.seek_relative(-60) return True elif keyval == Gdk.keyval_from_name("Up"): - self.video_player.seek_relative(60) + self.player.seek_relative(60) return True elif keyval == Gdk.keyval_from_name("Home"): - self.video_player.seek_start() + self.player.seek_start() return True elif keyval == Gdk.keyval_from_name("End"): - self.video_player.seek_end() + self.player.seek_end() return True elif keyval == Gdk.keyval_from_name("j"): - has_subs, index, lang = self.video_player.cycle_subtitles() + has_subs, index, lang = self.player.cycle_subtitles() if has_subs: if index: - self.show_overlay_text(f"Subtitles #{index} ({lang})") + self.overlay.show_message(f"Subtitles #{index} ({lang})") else: - self.show_overlay_text("Subtitles turned off") + 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.show_overlay_text("No subtitles available") + self.overlay.show_message("No subtitles available") return True elif keyval == Gdk.keyval_from_name("a"): - has_audio, index, lang = self.video_player.cycle_audio() + has_audio, index, lang = self.player.cycle_audio() if has_audio: - self.show_overlay_text(f"Audio #{index} ({lang})") + 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.show_overlay_text("No audio tracks available") + self.overlay.show_message("No audio tracks available") return True @@ -470,7 +412,7 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): def _save_position(self) -> None: """Save current playback position as xattr""" - if not self.video_player.is_playing: + if not self.player.is_playing: return file_item = self.selection @@ -478,55 +420,35 @@ class MainWindow(Gtk.ApplicationWindow, Watcher): if file_item is None or file_item.file_type == FileType.DIRECTORY: return - position = self.video_player.get_position() + position = self.player.get_position() if position is not None: file_item.saved_position.value = position - duration = self.video_player.get_duration() + duration = self.player.get_duration() if duration is not None: file_item.saved_duration.value = 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 _watch_player_state(self): - is_playing = self.video_player.is_playing.value - is_paused = self.video_player.is_paused.value - self.grid_overlay.set_visible(is_playing and is_paused) - def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool: - # Update all reactive values. + # Update all reactive values for whole application. update_all_computed() - current_time = frame_clock.get_frame_time() / 1_000_000 + self.clock.set_text(datetime.now().strftime("%H:%M")) - current_time_str = datetime.now().strftime("%H:%M") - self.main_clock.set_text(current_time_str) - self.grid_clock.set_text(current_time_str) + self.now = frame_clock.get_frame_time() / 1_000_000 - 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: + # Save playback position every 60 seconds. + if self.now - self.last_position_save >= 60.0: self._save_position() - self.last_position_save = frame_time + 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_image.get_paintable(): + 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_image.set_paintable(texture) + self.thumbnail_picture.set_paintable(texture) return True diff --git a/lazy_player/style.css b/lazy_player/style.css index 9d7b088..07a5fb6 100644 --- a/lazy_player/style.css +++ b/lazy_player/style.css @@ -20,11 +20,11 @@ listview > row { color: #0f0; } -#black-overlay { +#overlay { background-color: black; } -#overlay-text { +#overlay-message { color: white; font-size: 24px; font-family: monospace; @@ -34,19 +34,19 @@ listview > row { margin: 32px; } -#thumbnail-image { +#thumbnail-picture { margin: 8px; } -#digital-clock, -#grid-clock { +#main-clock, +#overlay-clock { color: white; font-size: 48px; font-family: monospace; padding: 12px; } -#grid-clock { +#overlay-clock { background-color: rgba(32, 32, 32, 0.5); border-radius: 8px; } diff --git a/lazy_player/video_overlay.py b/lazy_player/video_overlay.py new file mode 100644 index 0000000..54695df --- /dev/null +++ b/lazy_player/video_overlay.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from gi.repository import Gdk, Gtk, Pango + +from .reactive import Watcher +from .video_player import VideoPlayer + + +class VideoOverlay(Gtk.Overlay, Watcher): + player: VideoPlayer + + message: Gtk.Label + message_expiration: float + + grid: Gtk.Grid + grid_expiration: float + + clock_box: Gtk.Box + clock: Gtk.Label + + now: float + + def __init__(self, player: VideoPlayer): + super().__init__() + + self.now = 0.0 + self.player = player + + # Message is appears at the center of the screen, + # above everything else. Usually to indicate change + # of subtitle or audio track or something similar. + self.message = Gtk.Label() + self.message.set_name("overlay-message") + self.message.set_valign(Gtk.Align.CENTER) + self.message.set_halign(Gtk.Align.CENTER) + self.message.set_visible(False) + self.message.set_wrap(True) + self.message.set_wrap_mode(Pango.WrapMode.WORD_CHAR) + + # Once specific time passes, message disappears. + self.message_expiration = 0.0 + + # Grid overlay is between the video at the bottom and + # the message at the top. It is only shown when user + # interacts with the player. + self.grid = Gtk.Grid() + self.grid.set_hexpand(True) + self.grid.set_vexpand(True) + self.grid.set_column_homogeneous(True) + self.grid.set_row_homogeneous(True) + + # Grid visibility can also expire after a while. + self.grid_expiration = 0.0 + + # Create grid boxes. + self.clock_box = Gtk.Box(hexpand=True, vexpand=True) + self.grid.attach(self.clock_box, 0, 0, 1, 1) + + self.grid.attach(Gtk.Box(), 1, 0, 2, 1) + self.grid.attach(Gtk.Box(), 0, 1, 3, 1) + self.grid.attach(Gtk.Box(), 0, 2, 3, 1) + + # Add clock to the top-left grid box. + self.clock = Gtk.Label(hexpand=True, vexpand=True) + self.clock.set_name("overlay-clock") + self.clock.set_halign(Gtk.Align.CENTER) + self.clock.set_valign(Gtk.Align.START) + self.clock.set_text(datetime.now().strftime("%H:%M")) + self.clock_box.append(self.clock) + + # Add children. + self.set_child(self.player.picture) + self.add_overlay(self.grid) + self.add_overlay(self.message) + + # Consume ticks for the clock and overlay expiration. + self.add_tick_callback(self._on_tick, None) + + # Register all watches. + self.watch_all() + + def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool: + self.clock.set_text(datetime.now().strftime("%H:%M")) + + self.now = frame_clock.get_frame_time() / 1_000_000 + + if self.grid_expiration <= self.now: + self.grid.hide() + + if self.message_expiration <= self.now: + self.message.hide() + + return True + + def show_message(self, text: str, timeout: float = 1.0) -> None: + """Show text in a centered overlay that disappears after timeout.""" + + self.message.set_text(text) + self.message.show() + self.message_expiration = self.now + timeout + + def _watch_player_state(self): + is_playing = self.player.is_playing.value + is_paused = self.player.is_paused.value + + if is_playing and is_paused: + self.grid.show() + self.grid_expiration = 1e20 + else: + self.grid_expiration = 0 diff --git a/lazy_player/video_player.py b/lazy_player/video_player.py index 194ff1e..ca71639 100644 --- a/lazy_player/video_player.py +++ b/lazy_player/video_player.py @@ -10,6 +10,7 @@ DEFAULT_SEEK_FLAGS = Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT class VideoPlayer(GObject.Object): + picture: Gtk.Picture pipeline: Gst.Pipeline playbin: Gst.Element @@ -21,6 +22,8 @@ class VideoPlayer(GObject.Object): def __init__(self, picture: Gtk.Picture): super().__init__() + self.picture = picture + self.is_playing = Ref(False) self.is_paused = Ref(True) @@ -41,7 +44,7 @@ class VideoPlayer(GObject.Object): # Link picture to sink paintable = video_sink.get_property("paintable") - picture.set_paintable(paintable) + self.picture.set_paintable(paintable) def play( self,