lazy-player/lazy_player/__init__.py

511 lines
16 KiB
Python
Raw Normal View History

2025-03-08 19:39:55 +01:00
from __future__ import annotations
2025-03-08 19:55:49 +01:00
import os
2025-03-08 21:30:44 +01:00
import sys
2025-03-08 20:02:44 +01:00
from pathlib import Path
2025-03-09 09:57:24 +01:00
from typing import Any, cast
2025-03-08 19:39:55 +01:00
import gi
from .file_model import FileItem, FileListModel, FileType
2025-03-09 18:39:41 +01:00
from .thumbnailer import Thumbnailer
2025-03-09 17:07:52 +01:00
from .video_player import VideoPlayer
2025-03-08 23:34:19 +01:00
2025-03-08 20:53:47 +01:00
gi.require_version("Gdk", "4.0")
2025-03-08 19:39:55 +01:00
gi.require_version("Gtk", "4.0")
2025-03-08 20:53:47 +01:00
gi.require_version("Gst", "1.0")
2025-03-08 21:56:41 +01:00
gi.require_version("Pango", "1.0")
2025-03-09 19:27:05 +01:00
from gi.repository import Gdk, GLib, Gst, Gtk, Pango # NOQA: E402
2025-03-08 19:39:55 +01:00
class MainWindow(Gtk.ApplicationWindow):
2025-03-08 21:14:30 +01:00
stack: Gtk.Stack
list_view: Gtk.ListView
list_model: FileListModel
2025-03-09 09:37:03 +01:00
selection_model: Gtk.SingleSelection
2025-03-08 21:26:29 +01:00
video_widget: Gtk.Picture
2025-03-09 17:07:52 +01:00
tick_callback_id: int
2025-03-08 21:51:44 +01:00
overlay_label: Gtk.Label
overlay_hide_time: float
2025-03-09 09:37:03 +01:00
last_position_save: float
2025-03-08 20:42:04 +01:00
2025-03-09 18:39:41 +01:00
def __init__(self, *args: Any, thumbnailer: Thumbnailer, **kwargs: Any):
2025-03-08 19:39:55 +01:00
super().__init__(*args, **kwargs)
2025-03-09 18:39:41 +01:00
self.thumbnailer = thumbnailer
2025-03-08 19:39:55 +01:00
2025-03-09 10:58:01 +01:00
# Directory history stack
2025-03-09 11:49:42 +01:00
self.directory_history: list[Path] = []
2025-03-09 10:58:01 +01:00
2025-03-08 21:51:44 +01:00
# For overlay text timeout
self.overlay_hide_time = 0.0
2025-03-09 09:37:03 +01:00
self.last_position_save = 0.0
2025-03-08 21:51:44 +01:00
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)
2025-03-08 21:56:41 +01:00
self.overlay_label.set_wrap(True)
self.overlay_label.set_wrap_mode(Pango.WrapMode.WORD_CHAR)
2025-03-08 21:51:44 +01:00
2025-03-09 17:07:52 +01:00
self.tick_callback_id = self.add_tick_callback(self._on_tick, None)
2025-03-08 21:51:44 +01:00
2025-03-08 19:39:55 +01:00
# Make window fullscreen and borderless
self.set_decorated(False)
self.fullscreen()
2025-03-08 21:14:30 +01:00
# Setup key event controller
key_controller = Gtk.EventControllerKey()
key_controller.connect("key-pressed", self._on_key_pressed)
self.add_controller(key_controller)
2025-03-08 19:39:55 +01:00
# Main horizontal box to split the screen
main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
2025-03-08 21:14:30 +01:00
# Create stack to hold our widgets
self.stack = Gtk.Stack()
2025-03-08 21:26:29 +01:00
# 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)
2025-03-08 22:15:32 +01:00
2025-03-08 21:56:41 +01:00
# Create an overlay container
overlay = Gtk.Overlay()
overlay.set_child(self.video_widget)
overlay.add_overlay(self.overlay_label)
2025-03-08 22:15:32 +01:00
video_box.append(overlay)
2025-03-08 21:26:29 +01:00
2025-03-09 17:07:52 +01:00
# Setup video player
self.video_player = VideoPlayer(self.video_widget)
2025-03-08 21:14:30 +01:00
# Add both main menu and overlay to stack
self.stack.add_named(main_box, "menu")
self.stack.add_named(video_box, "player")
2025-03-08 21:14:30 +01:00
self.stack.set_visible_child_name("menu")
2025-03-08 20:33:46 +01:00
# 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)
2025-03-08 19:39:55 +01:00
left_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
2025-03-09 22:23:22 +01:00
left_box.set_valign(Gtk.Align.START)
2025-03-08 20:33:46 +01:00
left_box.set_halign(Gtk.Align.FILL)
2025-03-09 19:27:05 +01:00
2025-03-09 22:23:22 +01:00
# Create image widget for thumbnail
2025-03-09 19:27:05 +01:00
self.thumbnail_image = Gtk.Picture()
2025-03-09 22:23:22 +01:00
self.thumbnail_image.set_name("thumbnail-image")
2025-03-09 19:27:05 +01:00
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)
2025-03-08 20:33:46 +01:00
# Right two-thirds (2/3 width)
2025-03-08 20:18:43 +01:00
right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
right_box.set_hexpand(True)
2025-03-08 20:33:46 +01:00
# 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()
2025-03-08 21:14:30 +01:00
self.list_view = Gtk.ListView()
self.selection_model = Gtk.SingleSelection.new(self.list_model)
2025-03-09 09:37:03 +01:00
self.selection_model.connect("selection-changed", self._on_selection_changed)
self.list_view.set_model(self.selection_model)
2025-03-08 21:14:30 +01:00
self.list_view.set_vexpand(True)
2025-03-08 19:55:49 +01:00
2025-03-08 20:49:36 +01:00
def on_activate(widget: Gtk.ListView, index: int):
2025-03-09 09:37:03 +01:00
selected_item = self.selection_model.get_item(index)
2025-03-08 20:49:36 +01:00
if selected_item:
2025-03-08 23:34:19 +01:00
file_item = cast(FileItem, selected_item)
2025-03-08 21:20:29 +01:00
2025-03-08 23:34:19 +01:00
if file_item.file_type == FileType.DIRECTORY:
2025-03-09 11:49:42 +01:00
self._navigate_to(file_item.full_path)
2025-03-08 21:20:29 +01:00
return
2025-03-09 17:58:52 +01:00
position = file_item.saved_position
duration = file_item.saved_duration
2025-03-09 10:52:34 +01:00
if (position / duration) >= 0.99:
position = 0
2025-03-09 09:37:03 +01:00
2025-03-08 21:26:29 +01:00
# Start playing the video
2025-03-09 17:58:52 +01:00
self.video_player.play(
file_item.full_path,
position,
file_item.saved_subtitle_track,
)
self.last_position_save = self.now
2025-03-09 09:37:03 +01:00
self.stack.set_visible_child_name("player")
2025-03-09 09:37:03 +01:00
self.show_overlay_text(f"Playing: {file_item.name}")
2025-03-08 21:14:30 +01:00
self.list_view.connect("activate", on_activate)
2025-03-08 20:49:36 +01:00
2025-03-08 19:55:49 +01:00
# Factory for list items
factory = Gtk.SignalListItemFactory()
factory.connect("setup", self._setup_list_item)
factory.connect("bind", self._bind_list_item)
2025-03-08 21:14:30 +01:00
self.list_view.set_factory(factory)
2025-03-08 19:55:49 +01:00
# Populate the list store
2025-03-08 21:20:29 +01:00
self._populate_file_list()
2025-03-08 19:55:49 +01:00
# Add list view to a scrolled window
scrolled = Gtk.ScrolledWindow()
2025-03-08 21:14:30 +01:00
scrolled.set_child(self.list_view)
2025-03-08 20:18:43 +01:00
right_box.append(scrolled)
2025-03-08 19:39:55 +01:00
2025-03-08 21:14:30 +01:00
self.set_child(self.stack)
2025-03-08 19:39:55 +01:00
2025-03-09 17:07:52 +01:00
@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
2025-03-09 09:37:03 +01:00
@property
2025-03-09 09:57:24 +01:00
def selection(self) -> FileItem:
2025-03-09 09:37:03 +01:00
selected_item = self.selection_model.get_selected_item()
2025-03-09 09:57:24 +01:00
assert selected_item
return cast(FileItem, selected_item)
2025-03-09 09:37:03 +01:00
2025-03-08 20:42:04 +01:00
def _setup_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem):
2025-03-08 23:34:19 +01:00
# Create horizontal box to hold icon and label
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
box.set_spacing(8)
2025-03-09 10:18:12 +01:00
# Create icon image
icon = Gtk.Image()
2025-03-08 23:34:19 +01:00
icon.set_css_classes(["file-icon"])
box.append(icon)
# Create label
2025-03-08 19:55:49 +01:00
label = Gtk.Label()
label.set_halign(Gtk.Align.START)
2025-03-08 23:34:19 +01:00
box.append(label)
list_item.set_child(box)
2025-03-08 19:55:49 +01:00
2025-03-08 20:42:04 +01:00
def _bind_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem):
2025-03-08 23:34:19 +01:00
box = cast(Gtk.Box, list_item.get_child())
2025-03-09 10:18:12 +01:00
icon = cast(Gtk.Image, box.get_first_child())
2025-03-08 23:34:19 +01:00
label = cast(Gtk.Label, box.get_last_child())
item = cast(FileItem, list_item.get_item())
2025-03-09 10:52:34 +01:00
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"])
2025-03-09 10:18:12 +01:00
else:
2025-03-09 17:58:52 +01:00
position = item.saved_position
duration = item.saved_duration
2025-03-09 10:52:34 +01:00
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"])
2025-03-09 10:18:12 +01:00
2025-03-08 23:34:19 +01:00
label.set_text(item.name)
2025-03-09 17:58:52 +01:00
item.connect("notify::saved-position", update_icon)
2025-03-09 10:52:34 +01:00
update_icon()
2025-03-08 19:55:49 +01:00
2025-03-09 11:49:42 +01:00
def _navigate_to(self, path: Path):
self.directory_history.append(Path(os.getcwd()))
os.chdir(path)
self._populate_file_list()
def _navigate_back(self):
if not self.directory_history:
return
prev_dir = self.directory_history.pop()
current_dir = Path(os.getcwd())
os.chdir(prev_dir)
self._populate_file_list()
# Find and select the directory we came from
for i in range(self.list_model.get_n_items()):
item = self.list_model.get_item(i)
2025-03-09 11:49:42 +01:00
if not item:
continue
file_item = cast(FileItem, item)
2025-03-09 13:45:56 +01:00
if file_item.file_type != FileType.DIRECTORY:
continue
2025-03-09 11:49:42 +01:00
if current_dir == file_item.full_path:
self.list_view.scroll_to(
i, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None
)
break
return True
2025-03-08 20:42:04 +01:00
def _on_selection_changed(
self,
selection_model: Gtk.SingleSelection,
position: int,
n_items: int,
):
2025-03-09 22:23:22 +01:00
if selection_model.get_selected() != Gtk.INVALID_LIST_POSITION:
2025-03-09 19:27:05 +01:00
file_item = self.selection
# 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)
2025-03-08 20:42:04 +01:00
2025-03-08 21:36:27 +01:00
def _toggle_play_pause(self) -> None:
"""Toggle between play and pause states"""
2025-03-09 17:07:52 +01:00
self.video_player.toggle_play_pause()
2025-03-08 21:36:27 +01:00
def _on_player_key_pressed(
2025-03-08 21:14:30 +01:00
self,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
2025-03-08 21:36:27 +01:00
if keyval == Gdk.keyval_from_name("space"):
self._toggle_play_pause()
return True
elif keyval == Gdk.keyval_from_name("Escape"):
2025-03-09 09:37:03 +01:00
self._save_position()
2025-03-09 17:07:52 +01:00
self.video_player.stop()
2025-03-08 21:14:30 +01:00
self.stack.set_visible_child_name("menu")
self.list_view.grab_focus()
return True
2025-03-08 21:33:58 +01:00
elif keyval == Gdk.keyval_from_name("Left"):
2025-03-09 17:07:52 +01:00
self.video_player.seek_relative(-10)
2025-03-08 21:33:58 +01:00
return True
elif keyval == Gdk.keyval_from_name("Right"):
2025-03-09 17:07:52 +01:00
self.video_player.seek_relative(10)
2025-03-08 21:33:58 +01:00
return True
2025-03-09 17:07:52 +01:00
elif keyval == Gdk.keyval_from_name("Down"):
self.video_player.seek_relative(-60)
2025-03-08 21:33:58 +01:00
return True
2025-03-09 17:07:52 +01:00
elif keyval == Gdk.keyval_from_name("Up"):
self.video_player.seek_relative(60)
2025-03-08 21:33:58 +01:00
return True
2025-03-08 22:15:32 +01:00
2025-03-09 09:49:11 +01:00
elif keyval == Gdk.keyval_from_name("Home"):
2025-03-09 17:07:52 +01:00
self.video_player.seek_start()
2025-03-09 09:49:11 +01:00
return True
elif keyval == Gdk.keyval_from_name("End"):
2025-03-09 17:07:52 +01:00
self.video_player.seek_end()
2025-03-09 09:49:11 +01:00
return True
2025-03-08 22:10:40 +01:00
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")
else:
self.show_overlay_text("No subtitles available")
2025-03-08 22:10:40 +01:00
return True
2025-03-08 21:33:58 +01:00
2025-03-08 21:14:30 +01:00
return False
2025-03-09 10:08:26 +01:00
def _toggle_watched_status(self) -> None:
"""Toggle watched status for the selected file"""
2025-03-09 17:07:52 +01:00
2025-03-09 10:08:26 +01:00
file_item = self.selection
2025-03-09 17:07:52 +01:00
2025-03-09 10:08:26 +01:00
if file_item.file_type == FileType.DIRECTORY:
return
2025-03-09 17:58:52 +01:00
position = file_item.saved_position
duration = file_item.saved_duration
2025-03-09 10:08:26 +01:00
2025-03-09 10:52:34 +01:00
# If position exists and is >= 99% through, clear it
if position > 0 and (position / duration) >= 0.99:
2025-03-09 17:58:52 +01:00
file_item.saved_position = 0
2025-03-09 10:08:26 +01:00
else:
# Otherwise mark as complete
2025-03-09 17:58:52 +01:00
file_item.saved_position = duration
2025-03-09 10:08:26 +01:00
2025-03-09 10:18:12 +01:00
# Force the list to update the changed item
self.list_model.items_changed(self.selection_model.get_selected(), 1, 1)
2025-03-09 10:18:12 +01:00
2025-03-09 10:01:33 +01:00
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
2025-03-09 10:08:26 +01:00
elif keyval == Gdk.keyval_from_name("w"):
self._toggle_watched_status()
return True
2025-03-09 10:58:01 +01:00
elif keyval == Gdk.keyval_from_name("BackSpace"):
2025-03-09 11:49:42 +01:00
self._navigate_back()
2025-03-09 10:58:01 +01:00
return True
2025-03-09 10:01:33 +01:00
return False
2025-03-08 21:33:58 +01:00
def _on_key_pressed(
self,
controller: Gtk.EventControllerKey,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
2025-03-09 10:01:33 +01:00
# 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)
2025-03-09 10:01:33 +01:00
else:
return self._on_menu_key_pressed(keyval, keycode, state)
2025-03-08 21:33:58 +01:00
2025-03-09 09:37:03 +01:00
def _save_position(self) -> None:
"""Save current playback position as xattr"""
2025-03-09 17:07:52 +01:00
if not self.video_player.is_playing:
2025-03-08 22:10:40 +01:00
return
2025-03-09 17:07:52 +01:00
file_item = self.selection
2025-03-08 22:15:32 +01:00
2025-03-09 17:07:52 +01:00
if file_item.file_type == FileType.DIRECTORY:
2025-03-08 22:15:32 +01:00
return
2025-03-09 17:07:52 +01:00
position = self.video_player.get_position()
if position is not None:
2025-03-09 17:58:52 +01:00
file_item.saved_position = position
2025-03-09 17:07:52 +01:00
duration = self.video_player.get_duration()
if duration is not None:
2025-03-09 17:58:52 +01:00
file_item.saved_duration = duration
2025-03-08 22:10:40 +01:00
2025-03-08 21:51:44 +01:00
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
2025-03-09 17:07:52 +01:00
self.overlay_hide_time = self.now + timeout_seconds
2025-03-08 21:51:44 +01:00
2025-03-09 17:07:52 +01:00
def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool:
current_time = frame_clock.get_frame_time() / 1_000_000
if current_time >= self.overlay_hide_time:
self.overlay_label.set_visible(False)
2025-03-08 21:51:44 +01:00
2025-03-09 09:37:03 +01:00
# Save position every 60 seconds
2025-03-09 17:07:52 +01:00
frame_time = frame_clock.get_frame_time() / 1_000_000
2025-03-09 09:37:03 +01:00
if frame_time - self.last_position_save >= 60.0:
self._save_position()
self.last_position_save = frame_time
2025-03-09 19:27:05 +01:00
# Update thumbnail if available
file_item = self.selection
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)
2025-03-09 17:07:52 +01:00
return True
2025-03-08 21:20:29 +01:00
def _populate_file_list(self) -> None:
2025-03-08 23:34:19 +01:00
items: list[FileItem] = []
# Add parent directory
2025-03-09 11:49:42 +01:00
items.append(FileItem("..", FileType.DIRECTORY, Path("..").resolve()))
2025-03-08 19:55:49 +01:00
2025-03-09 12:02:00 +01:00
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"):
2025-03-09 18:39:41 +01:00
file_item = FileItem(path.name, FileType.VIDEO, path.resolve())
items.append(file_item)
2025-03-08 23:34:19 +01:00
# Sort directories first, then files, both alphabetically
items.sort(key=lambda x: (x.file_type != FileType.DIRECTORY, x.name.lower()))
2025-03-08 19:55:49 +01:00
self.list_model.set_items(items)
2025-03-08 19:55:49 +01:00
2025-03-08 19:39:55 +01:00
class App(Gtk.Application):
2025-03-09 21:21:20 +01:00
def __init__(self, thumbnailer: Thumbnailer):
2025-03-08 19:39:55 +01:00
super().__init__()
2025-03-09 21:21:20 +01:00
self.thumbnailer = thumbnailer
2025-03-09 18:39:41 +01:00
2025-03-08 20:02:44 +01:00
# Load CSS
css_provider = Gtk.CssProvider()
css_file = Path(__file__).parent / "style.css"
css_provider.load_from_path(str(css_file))
display = Gdk.Display.get_default()
if display is None:
raise RuntimeError("No display available")
Gtk.StyleContext.add_provider_for_display(
display,
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
2025-03-08 19:39:55 +01:00
def do_activate(self):
2025-03-09 18:39:41 +01:00
win = MainWindow(application=self, thumbnailer=self.thumbnailer)
2025-03-08 19:39:55 +01:00
win.present()
def main():
2025-03-08 21:30:44 +01:00
if len(sys.argv) >= 2:
os.chdir(sys.argv[1])
2025-03-09 21:21:20 +01:00
# Initialize GStreamer
Gst.init(None)
thumbnailer = Thumbnailer()
app = App(thumbnailer)
try:
thumbnailer.start()
app.run(None)
finally:
thumbnailer.stop()