diff --git a/lazy_player/__init__.py b/lazy_player/__init__.py index f089dbc..b2fbb53 100644 --- a/lazy_player/__init__.py +++ b/lazy_player/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import os import sys from pathlib import Path -from typing import Any, cast +from typing import Any, TypeVar, cast, overload import gi @@ -15,23 +15,28 @@ gi.require_version("Gst", "1.0") gi.require_version("Pango", "1.0") from gi.repository import Gdk, Gio, Gst, Gtk, Pango # NOQA: E402 +_T = TypeVar("_T") + class MainWindow(Gtk.ApplicationWindow): file_info_label: Gtk.Label stack: Gtk.Stack list_view: Gtk.ListView list_store: Gio.ListStore + selection_model: Gtk.SingleSelection video_widget: Gtk.Picture pipeline: Gst.Pipeline overlay_tick_callback_id: int overlay_label: Gtk.Label overlay_hide_time: float + last_position_save: float def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) # 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) @@ -131,13 +136,13 @@ class MainWindow(Gtk.ApplicationWindow): # Create list store and view self.list_store = Gio.ListStore(item_type=FileItem) 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.selection_model = Gtk.SingleSelection.new(self.list_store) + 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 = selection_model.get_item(index) + selected_item = self.selection_model.get_item(index) if selected_item: file_item = cast(FileItem, selected_item) @@ -146,13 +151,45 @@ class MainWindow(Gtk.ApplicationWindow): self._populate_file_list() return + position = self._load_attribute("position", 0) + # Start playing the video playbin = self.pipeline.get_by_name("playbin") - if playbin: - playbin.set_property("uri", f"file://{os.path.abspath(file_item.full_path)}") - self.pipeline.set_state(Gst.State.PLAYING) - self.stack.set_visible_child_name("overlay") - self.show_overlay_text(f"Playing: {file_item.name}") + if not playbin: + return + + full_path = os.path.abspath(file_item.full_path) + playbin.set_property("uri", f"file://{full_path}") + + track = self._load_attribute("subtitle_track", -2) + + if track >= 0: + flags = playbin.get_property("flags") + flags |= 0x00000004 # TEXT flag + playbin.set_property("flags", flags) + playbin.set_property("current-text", track) + elif track == -1: + flags = playbin.get_property("flags") + flags &= ~0x00000004 # TEXT flag + playbin.set_property("flags", flags) + + if position: + # Pause and wait for it to complete. + self.pipeline.set_state(Gst.State.PAUSED) + self.pipeline.get_state(Gst.CLOCK_TIME_NONE) + + # Seek to saved position. + self.pipeline.seek_simple( + Gst.Format.TIME, + Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, + position, + ) + + # Now start playing + self.pipeline.set_state(Gst.State.PLAYING) + + self.stack.set_visible_child_name("overlay") + self.show_overlay_text(f"Playing: {file_item.name}") self.list_view.connect("activate", on_activate) @@ -172,6 +209,20 @@ class MainWindow(Gtk.ApplicationWindow): self.set_child(self.stack) + @property + def currently_playing(self): + selected_item = self.selection_model.get_selected_item() + + if not selected_item: + return None + + file_item = cast(FileItem, selected_item) + + if file_item.file_type == FileType.DIRECTORY: + return None + + return file_item.full_path + 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) @@ -232,6 +283,7 @@ class MainWindow(Gtk.ApplicationWindow): return True elif keyval == Gdk.keyval_from_name("Escape"): + self._save_position() self.pipeline.set_state(Gst.State.NULL) self.stack.set_visible_child_name("menu") self.list_view.grab_focus() @@ -273,6 +325,7 @@ class MainWindow(Gtk.ApplicationWindow): 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 @@ -296,6 +349,7 @@ class MainWindow(Gtk.ApplicationWindow): 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) @@ -308,8 +362,56 @@ class MainWindow(Gtk.ApplicationWindow): found, lang = caps.get_string("language-code") return f"{track_index} ({lang})" if found else str(track_index) + def _save_position(self) -> None: + """Save current playback position as xattr""" + + playbin = self.pipeline.get_by_name("playbin") + if not playbin: + return + + success, position = self.pipeline.query_position(Gst.Format.TIME) + success2, duration = self.pipeline.query_duration(Gst.Format.TIME) + + if success and success2: + self._save_attribute("position", position) + self._save_attribute("duration", duration) + + def _save_attribute(self, name: str, value: str | float | int | None): + path = self.currently_playing + + if path is None: + return + + try: + if value is None: + os.removexattr(path, f"user.lazy_player.{name}") + else: + os.setxattr(path, f"user.lazy_player.{name}", str(value).encode("utf8")) + except OSError as err: + print(err, file=sys.stderr) + + @overload + def _load_attribute(self, name: str, dfl: str) -> str: ... + + @overload + def _load_attribute(self, name: str, dfl: int) -> int: ... + + def _load_attribute(self, name: str, dfl: str | int) -> str | int: + path = self.currently_playing + + if path is None: + return dfl + + try: + strval = os.getxattr(path, f"user.lazy_player.{name}") + return type(dfl)(strval) + except OSError as err: + print(err, file=sys.stderr) + return dfl + def _cycle_subtitles(self) -> None: """Cycle through available subtitle tracks, including off state""" + playbin = self.pipeline.get_by_name("playbin") if not playbin: return @@ -330,6 +432,7 @@ class MainWindow(Gtk.ApplicationWindow): playbin.set_property("current-text", 0) track_info = self._get_subtitle_info(0) self.show_overlay_text(f"Subtitle track: {track_info}") + self._save_attribute("subtitle_track", 0) return # If we're on the last track, disable subtitles @@ -337,6 +440,7 @@ class MainWindow(Gtk.ApplicationWindow): flags &= ~0x00000004 # TEXT flag playbin.set_property("flags", flags) self.show_overlay_text("Subtitles: Off") + self._save_attribute("subtitle_track", -1) return # Otherwise cycle to next track @@ -344,6 +448,7 @@ class MainWindow(Gtk.ApplicationWindow): playbin.set_property("current-text", next_track) track_info = self._get_subtitle_info(next_track) self.show_overlay_text(f"Subtitle track: {track_info}") + self._save_attribute("subtitle_track", next_track) def show_overlay_text(self, text: str, timeout_seconds: float = 1.0) -> None: """Show text in a centered overlay that disappears after timeout""" @@ -358,6 +463,11 @@ class MainWindow(Gtk.ApplicationWindow): frame_time = frame_clock.get_frame_time() / 1_000_000 # Convert to seconds self.overlay_hide_time = frame_time + timeout_seconds + # Save position every 60 seconds + if frame_time - self.last_position_save >= 60.0: + self._save_position() + self.last_position_save = frame_time + def _populate_file_list(self) -> None: # TODO: Implement proper version sort (strverscmp equivalent)