Compare commits

...

2 commits

Author SHA1 Message Date
18c1e6c4a7 Store playback info 2025-03-09 09:43:14 +01:00
27e754b3f7 Add space in front of files 2025-03-08 23:34:19 +01:00
3 changed files with 221 additions and 40 deletions

View file

@ -3,33 +3,40 @@ 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
from .file_model import FileItem, FileType
gi.require_version("Gdk", "4.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Gst", "1.0")
gi.require_version("Pango", "1.0")
from gi.repository import Gdk, Gst, Gtk, Pango # NOQA: E402
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: Gtk.StringList
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)
@ -127,31 +134,62 @@ class MainWindow(Gtk.ApplicationWindow):
main_box.append(grid)
# Create list store and view
self.list_store = Gtk.StringList()
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:
string_obj = cast(Gtk.StringObject, selected_item)
string = string_obj.get_string()
file_item = cast(FileItem, selected_item)
if string.endswith("/"):
os.chdir(string)
if file_item.file_type == FileType.DIRECTORY:
os.chdir(file_item.full_path)
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(string)}")
self.pipeline.set_state(Gst.State.PLAYING)
self.stack.set_visible_child_name("overlay")
self.show_overlay_text(string)
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)
@ -171,15 +209,46 @@ 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)
box.set_spacing(8)
# Create icon placeholder
icon = Gtk.Box()
icon.set_css_classes(["file-icon"])
box.append(icon)
# Create label
label = Gtk.Label()
label.set_halign(Gtk.Align.START)
list_item.set_child(label)
box.append(label)
list_item.set_child(box)
def _bind_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem):
label = cast(Gtk.Label, list_item.get_child())
item = cast(Gtk.StringObject, list_item.get_item())
label.set_text(item.get_string())
box = cast(Gtk.Box, list_item.get_child())
icon = cast(Gtk.Box, box.get_first_child())
label = cast(Gtk.Label, box.get_last_child())
item = cast(FileItem, list_item.get_item())
# Make icon transparent for directories
icon.set_opacity(0.0 if item.file_type == FileType.DIRECTORY else 1.0)
label.set_text(item.name)
def _on_selection_changed(
self,
@ -192,8 +261,8 @@ class MainWindow(Gtk.ApplicationWindow):
else:
selected_item = selection_model.get_selected_item()
if selected_item:
string_obj = cast(Gtk.StringObject, selected_item)
self.file_info_label.set_text(string_obj.get_string())
file_item = cast(FileItem, selected_item)
self.file_info_label.set_text(file_item.full_path)
def _toggle_play_pause(self) -> None:
"""Toggle between play and pause states"""
@ -214,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()
@ -255,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
@ -278,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)
@ -290,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
@ -312,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
@ -319,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
@ -326,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"""
@ -340,34 +463,43 @@ 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)
directories: list[str] = ["../"]
files: list[str] = []
items: list[FileItem] = []
# Add parent directory
items.append(FileItem("..", FileType.DIRECTORY, "../"))
with os.scandir(".") as it:
for entry in it:
if entry.name == ".." or not entry.name.startswith("."):
parts = entry.name.split(".")
suffix = parts[-1] if len(parts) >= 2 else ""
if entry.name != ".." and not entry.name.startswith("."):
try:
if entry.is_dir():
items.append(FileItem(entry.name, FileType.DIRECTORY, entry.name + "/"))
else:
file_item = FileItem.from_path(entry.name)
items.append(file_item)
except ValueError:
# Skip unsupported file types
continue
if entry.is_dir():
directories.append(entry.name + "/")
elif suffix in ("mkv", "mp4", "avi"):
files.append(entry.name)
directories.sort(key=lambda x: x.lower())
files.sort(key=lambda x: x.lower())
# Sort directories first, then files, both alphabetically
items.sort(key=lambda x: (x.file_type != FileType.DIRECTORY, x.name.lower()))
while self.list_store.get_n_items():
self.list_store.remove(0)
for name in directories + files:
self.list_store.append(name)
for item in items:
self.list_store.append(item)
all = directories + files
self.file_info_label.set_text(all[0] if all else "")
if items:
self.file_info_label.set_text(items[0].full_path)
class App(Gtk.Application):

43
lazy_player/file_model.py Normal file
View file

@ -0,0 +1,43 @@
from __future__ import annotations
import os
from enum import Enum, auto
import gi
gi.require_version("GObject", "2.0")
from gi.repository import GObject # NOQA: E402
class FileType(Enum):
DIRECTORY = auto()
VIDEO = auto()
class FileItem(GObject.Object):
__gtype_name__ = "FileItem"
def __init__(
self,
name: str,
file_type: FileType,
full_path: str,
):
super().__init__()
self.name = name
self.file_type = file_type
self.full_path = full_path
@staticmethod
def from_path(path: str) -> FileItem:
name = os.path.basename(path)
if path.endswith("/"):
return FileItem(name, FileType.DIRECTORY, path)
parts = name.split(".")
suffix = parts[-1].lower() if len(parts) >= 2 else ""
if suffix in ("mkv", "mp4", "avi"):
return FileItem(name, FileType.VIDEO, path)
raise ValueError(f"Unsupported file type: {path}")

View file

@ -4,6 +4,13 @@ listview > row {
font-family: monospace;
}
.file-icon {
min-width: 32px;
min-height: 32px;
margin-right: 8px;
border: 1px solid #666;
}
#black-overlay {
background-color: black;
}
@ -16,5 +23,4 @@ listview > row {
padding: 24px;
border-radius: 8px;
margin: 32px;
width: 80%;
}