Store playback info

This commit is contained in:
Jan Hamal Dvořák 2025-03-09 09:37:03 +01:00
parent 27e754b3f7
commit 18c1e6c4a7

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, TypeVar, cast, overload
import gi import gi
@ -15,23 +15,28 @@ gi.require_version("Gst", "1.0")
gi.require_version("Pango", "1.0") gi.require_version("Pango", "1.0")
from gi.repository import Gdk, Gio, Gst, Gtk, Pango # NOQA: E402 from gi.repository import Gdk, Gio, Gst, Gtk, Pango # NOQA: E402
_T = TypeVar("_T")
class MainWindow(Gtk.ApplicationWindow): class MainWindow(Gtk.ApplicationWindow):
file_info_label: Gtk.Label file_info_label: Gtk.Label
stack: Gtk.Stack stack: Gtk.Stack
list_view: Gtk.ListView list_view: Gtk.ListView
list_store: Gio.ListStore list_store: Gio.ListStore
selection_model: Gtk.SingleSelection
video_widget: Gtk.Picture video_widget: Gtk.Picture
pipeline: Gst.Pipeline pipeline: Gst.Pipeline
overlay_tick_callback_id: int overlay_tick_callback_id: int
overlay_label: Gtk.Label overlay_label: Gtk.Label
overlay_hide_time: float overlay_hide_time: float
last_position_save: float
def __init__(self, *args: Any, **kwargs: Any): def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# For overlay text timeout # For overlay text timeout
self.overlay_hide_time = 0.0 self.overlay_hide_time = 0.0
self.last_position_save = 0.0
self.overlay_label = Gtk.Label() self.overlay_label = Gtk.Label()
self.overlay_label.set_name("overlay-text") self.overlay_label.set_name("overlay-text")
self.overlay_label.set_valign(Gtk.Align.CENTER) self.overlay_label.set_valign(Gtk.Align.CENTER)
@ -131,13 +136,13 @@ class MainWindow(Gtk.ApplicationWindow):
# Create list store and view # Create list store and view
self.list_store = Gio.ListStore(item_type=FileItem) self.list_store = Gio.ListStore(item_type=FileItem)
self.list_view = Gtk.ListView() self.list_view = Gtk.ListView()
selection_model = Gtk.SingleSelection.new(self.list_store) self.selection_model = Gtk.SingleSelection.new(self.list_store)
selection_model.connect("selection-changed", self._on_selection_changed) self.selection_model.connect("selection-changed", self._on_selection_changed)
self.list_view.set_model(selection_model) self.list_view.set_model(self.selection_model)
self.list_view.set_vexpand(True) self.list_view.set_vexpand(True)
def on_activate(widget: Gtk.ListView, index: int): 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: if selected_item:
file_item = cast(FileItem, selected_item) file_item = cast(FileItem, selected_item)
@ -146,13 +151,45 @@ class MainWindow(Gtk.ApplicationWindow):
self._populate_file_list() self._populate_file_list()
return return
position = self._load_attribute("position", 0)
# Start playing the video # Start playing the video
playbin = self.pipeline.get_by_name("playbin") playbin = self.pipeline.get_by_name("playbin")
if playbin: if not playbin:
playbin.set_property("uri", f"file://{os.path.abspath(file_item.full_path)}") return
self.pipeline.set_state(Gst.State.PLAYING)
self.stack.set_visible_child_name("overlay") full_path = os.path.abspath(file_item.full_path)
self.show_overlay_text(f"Playing: {file_item.name}") 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) self.list_view.connect("activate", on_activate)
@ -172,6 +209,20 @@ class MainWindow(Gtk.ApplicationWindow):
self.set_child(self.stack) 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): def _setup_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem):
# Create horizontal box to hold icon and label # Create horizontal box to hold icon and label
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
@ -232,6 +283,7 @@ class MainWindow(Gtk.ApplicationWindow):
return True return True
elif keyval == Gdk.keyval_from_name("Escape"): elif keyval == Gdk.keyval_from_name("Escape"):
self._save_position()
self.pipeline.set_state(Gst.State.NULL) self.pipeline.set_state(Gst.State.NULL)
self.stack.set_visible_child_name("menu") self.stack.set_visible_child_name("menu")
self.list_view.grab_focus() self.list_view.grab_focus()
@ -273,6 +325,7 @@ class MainWindow(Gtk.ApplicationWindow):
def _seek_relative(self, offset: int) -> None: def _seek_relative(self, offset: int) -> None:
"""Seek relative to current position by offset seconds""" """Seek relative to current position by offset seconds"""
playbin = self.pipeline.get_by_name("playbin") playbin = self.pipeline.get_by_name("playbin")
if not playbin: if not playbin:
return return
@ -296,6 +349,7 @@ class MainWindow(Gtk.ApplicationWindow):
def _get_subtitle_info(self, track_index: int) -> str: def _get_subtitle_info(self, track_index: int) -> str:
"""Get subtitle track info including language if available""" """Get subtitle track info including language if available"""
playbin = self.pipeline.get_by_name("playbin") playbin = self.pipeline.get_by_name("playbin")
if not playbin: if not playbin:
return str(track_index) return str(track_index)
@ -308,8 +362,56 @@ class MainWindow(Gtk.ApplicationWindow):
found, lang = caps.get_string("language-code") found, lang = caps.get_string("language-code")
return f"{track_index} ({lang})" if found else str(track_index) 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: def _cycle_subtitles(self) -> None:
"""Cycle through available subtitle tracks, including off state""" """Cycle through available subtitle tracks, including off state"""
playbin = self.pipeline.get_by_name("playbin") playbin = self.pipeline.get_by_name("playbin")
if not playbin: if not playbin:
return return
@ -330,6 +432,7 @@ class MainWindow(Gtk.ApplicationWindow):
playbin.set_property("current-text", 0) playbin.set_property("current-text", 0)
track_info = self._get_subtitle_info(0) track_info = self._get_subtitle_info(0)
self.show_overlay_text(f"Subtitle track: {track_info}") self.show_overlay_text(f"Subtitle track: {track_info}")
self._save_attribute("subtitle_track", 0)
return return
# If we're on the last track, disable subtitles # If we're on the last track, disable subtitles
@ -337,6 +440,7 @@ class MainWindow(Gtk.ApplicationWindow):
flags &= ~0x00000004 # TEXT flag flags &= ~0x00000004 # TEXT flag
playbin.set_property("flags", flags) playbin.set_property("flags", flags)
self.show_overlay_text("Subtitles: Off") self.show_overlay_text("Subtitles: Off")
self._save_attribute("subtitle_track", -1)
return return
# Otherwise cycle to next track # Otherwise cycle to next track
@ -344,6 +448,7 @@ class MainWindow(Gtk.ApplicationWindow):
playbin.set_property("current-text", next_track) playbin.set_property("current-text", next_track)
track_info = self._get_subtitle_info(next_track) track_info = self._get_subtitle_info(next_track)
self.show_overlay_text(f"Subtitle track: {track_info}") 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: def show_overlay_text(self, text: str, timeout_seconds: float = 1.0) -> None:
"""Show text in a centered overlay that disappears after timeout""" """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 frame_time = frame_clock.get_frame_time() / 1_000_000 # Convert to seconds
self.overlay_hide_time = frame_time + timeout_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: def _populate_file_list(self) -> None:
# TODO: Implement proper version sort (strverscmp equivalent) # TODO: Implement proper version sort (strverscmp equivalent)