Compare commits

..

3 commits

6 changed files with 575 additions and 565 deletions

View file

@ -2,540 +2,20 @@ from __future__ import annotations
import os import os
import sys import sys
from datetime import datetime
from pathlib import Path
from typing import Any, cast
import gi import gi
from .file_model import FileItem, FileListModel, FileType
from .thumbnailer import Thumbnailer
from .video_player import VideoPlayer
gi.require_version("Gdk", "4.0") gi.require_version("Gdk", "4.0")
gi.require_version("Gtk", "4.0") gi.require_version("GLib", "2.0")
gi.require_version("GObject", "2.0")
gi.require_version("Gst", "1.0") gi.require_version("Gst", "1.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Pango", "1.0") gi.require_version("Pango", "1.0")
from gi.repository import Gdk, GLib, Gst, Gtk, Pango # NOQA: E402
from gi.repository import Gst # NOQA: E402
class MainWindow(Gtk.ApplicationWindow): from .application import Application # NOQA: E402
stack: Gtk.Stack from .thumbnailer import Thumbnailer # NOQA: E402
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
directory_history: list[Path]
selection_history: dict[str, int]
def __init__(self, *args: Any, thumbnailer: Thumbnailer, **kwargs: Any):
super().__init__(*args, **kwargs)
self.thumbnailer = thumbnailer
# Directory history stack
self.directory_history = []
self.selection_history = {}
# 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)
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)
self.fullscreen()
# Setup key event controller
key_controller = Gtk.EventControllerKey()
key_controller.connect("key-pressed", self._on_key_pressed)
self.add_controller(key_controller)
# Disable and hide mouse cursor
self.set_can_target(False)
display = Gdk.Display.get_default()
if display:
cursor = Gdk.Cursor.new_from_name("none")
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()
# 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 an overlay container
overlay = Gtk.Overlay()
overlay.set_child(self.video_widget)
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)
# Create digital clock
self.clock_label = Gtk.Label()
self.clock_label.set_name("digital-clock")
self.clock_label.set_halign(Gtk.Align.CENTER)
self.clock_label.set_valign(Gtk.Align.CENTER)
self.clock_label.set_text(datetime.now().strftime("%H:%M"))
left_box.append(self.clock_label)
# 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)
# Create list model and view
self.list_model = FileListModel()
self.list_view = Gtk.ListView()
self.selection_model = Gtk.SingleSelection.new(self.list_model)
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
duration = file_item.saved_duration
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,
file_item.saved_audio_track,
)
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)
# Factory for list items
factory = Gtk.SignalListItemFactory()
factory.connect("setup", self._setup_list_item)
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)
right_box.append(scrolled)
self.set_child(self.stack)
@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
@property
def selection(self) -> FileItem | None:
selected_item = self.selection_model.get_selected_item()
return cast(FileItem, selected_item) if selected_item else None
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 image
icon = Gtk.Image()
icon.set_css_classes(["file-icon"])
box.append(icon)
# Create label
label = Gtk.Label()
label.set_halign(Gtk.Align.START)
box.append(label)
list_item.set_child(box)
def _bind_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem):
box = cast(Gtk.Box, list_item.get_child())
icon = cast(Gtk.Image, box.get_first_child())
label = cast(Gtk.Label, box.get_last_child())
item = cast(FileItem, list_item.get_item())
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"])
else:
position = item.saved_position
duration = item.saved_duration
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"])
label.set_text(item.name)
item.connect("notify::saved-position", update_icon)
item.connect("notify::saved-duration", update_icon)
update_icon()
def _refresh(self):
self._populate_file_list()
pos = self.selection_history.get(str(os.getcwd()), 0)
self.selection_model.set_selected(pos)
self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None)
def _navigate_to(self, path: Path):
self.directory_history.append(Path(os.getcwd()))
os.chdir(path)
self._populate_file_list()
pos = self.selection_history.get(str(path), 0)
self.selection_model.set_selected(pos)
self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None)
def _navigate_back(self):
if not self.directory_history:
return
prev_dir = self.directory_history.pop()
os.chdir(prev_dir)
self._populate_file_list()
pos = self.selection_history.get(str(prev_dir), 0)
self.selection_model.set_selected(pos)
self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None)
def _on_selection_changed(
self,
selection_model: Gtk.SingleSelection,
position: int,
n_items: int,
):
position = selection_model.get_selected()
if position == Gtk.INVALID_LIST_POSITION:
return
self.selection_history[os.getcwd()] = position
file_item = self.selection
if file_item is not None:
# 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)
def _toggle_play_pause(self) -> None:
"""Toggle between play and pause states"""
self.video_player.toggle_play_pause()
def _on_player_key_pressed(
self,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
if keyval == Gdk.keyval_from_name("space"):
self._toggle_play_pause()
return True
elif keyval == Gdk.keyval_from_name("Escape"):
self._save_position()
self.video_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)
return True
elif keyval == Gdk.keyval_from_name("Right"):
self.video_player.seek_relative(10)
return True
elif keyval == Gdk.keyval_from_name("Down"):
self.video_player.seek_relative(-60)
return True
elif keyval == Gdk.keyval_from_name("Up"):
self.video_player.seek_relative(60)
return True
elif keyval == Gdk.keyval_from_name("Home"):
self.video_player.seek_start()
return True
elif keyval == Gdk.keyval_from_name("End"):
self.video_player.seek_end()
return True
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")
file_item = self.selection
if file_item is not None:
file_item.saved_subtitle_track = index - 1
else:
self.show_overlay_text("No subtitles available")
return True
elif keyval == Gdk.keyval_from_name("a"):
has_audio, index, lang = self.video_player.cycle_audio()
if has_audio:
self.show_overlay_text(f"Audio #{index} ({lang})")
file_item = self.selection
if file_item is not None:
file_item.saved_audio_track = index - 1
else:
self.show_overlay_text("No audio tracks available")
return True
return False
def _toggle_watched_status(self) -> None:
"""Toggle watched status for the selected file"""
file_item = self.selection
if file_item is None or file_item.file_type == FileType.DIRECTORY:
return
position = file_item.saved_position
duration = file_item.saved_duration
# If position exists and is >= 99% through, clear it
if position > 0 and (position / duration) >= 0.99:
file_item.saved_position = 0
else:
# Otherwise mark as complete
file_item.saved_position = duration
# Force the list to update the changed item
self.list_model.items_changed(self.selection_model.get_selected(), 1, 1)
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
elif keyval == Gdk.keyval_from_name("r"):
self._refresh()
return True
elif keyval == Gdk.keyval_from_name("w"):
self._toggle_watched_status()
return True
elif keyval == Gdk.keyval_from_name("BackSpace"):
self._navigate_back()
return True
return False
def _on_key_pressed(
self,
controller: Gtk.EventControllerKey,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
# 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)
else:
return self._on_menu_key_pressed(keyval, keycode, state)
def _save_position(self) -> None:
"""Save current playback position as xattr"""
if not self.video_player.is_playing:
return
file_item = self.selection
if file_item is None or file_item.file_type == FileType.DIRECTORY:
return
position = self.video_player.get_position()
if position is not None:
file_item.saved_position = position
duration = self.video_player.get_duration()
if duration is not None:
file_item.saved_duration = 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 _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool:
current_time = frame_clock.get_frame_time() / 1_000_000
self.clock_label.set_text(datetime.now().strftime("%H:%M"))
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:
self._save_position()
self.last_position_save = frame_time
# Update thumbnail if available
file_item = self.selection
if file_item is not None:
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)
return True
def _populate_file_list(self) -> None:
items: list[FileItem] = []
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"):
file_item = FileItem(path.name, FileType.VIDEO, path.resolve())
items.append(file_item)
# Sort directories first, then files, both alphabetically
items.sort(key=lambda x: (x.file_type != FileType.DIRECTORY, x.name.lower()))
self.list_model.set_items(items)
class App(Gtk.Application):
def __init__(self, thumbnailer: Thumbnailer):
super().__init__()
self.thumbnailer = thumbnailer
# 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,
)
def do_activate(self):
win = MainWindow(application=self, thumbnailer=self.thumbnailer)
win.present()
def main(): def main():
@ -546,7 +26,7 @@ def main():
Gst.init(None) Gst.init(None)
thumbnailer = Thumbnailer() thumbnailer = Thumbnailer()
app = App(thumbnailer) app = Application(thumbnailer=thumbnailer)
try: try:
thumbnailer.start() thumbnailer.start()

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from pathlib import Path
from gi.repository import Gdk, Gtk
from .main_window import MainWindow
from .thumbnailer import Thumbnailer
__all__ = ["Application"]
class Application(Gtk.Application):
def __init__(self, thumbnailer: Thumbnailer):
super().__init__()
self.thumbnailer = thumbnailer
# 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,
)
def do_activate(self):
win = MainWindow(application=self, thumbnailer=self.thumbnailer)
win.present()

View file

@ -6,10 +6,9 @@ from enum import Enum, auto
from pathlib import Path from pathlib import Path
from typing import Optional, overload from typing import Optional, overload
import gi from gi.repository import Gio, GObject
gi.require_version("GObject", "2.0") from .thumbnailer import Thumbnailer
from gi.repository import Gio, GObject # NOQA: E402
class FileType(Enum): class FileType(Enum):
@ -21,6 +20,8 @@ class FileItem(GObject.Object):
file_type: FileType file_type: FileType
full_path: Path full_path: Path
thumbnail: bytes thumbnail: bytes
attempted_thumbnail: bool
_has_thumbnail: bool _has_thumbnail: bool
__gtype_name__ = "FileItem" __gtype_name__ = "FileItem"
@ -31,6 +32,7 @@ class FileItem(GObject.Object):
self.file_type = file_type self.file_type = file_type
self.full_path = full_path self.full_path = full_path
self.thumbnail = b"" self.thumbnail = b""
self.attempted_thumbnail = False
self._has_thumbnail = False self._has_thumbnail = False
@GObject.Property(type=GObject.TYPE_UINT64) @GObject.Property(type=GObject.TYPE_UINT64)
@ -78,6 +80,13 @@ class FileItem(GObject.Object):
self._has_thumbnail = value self._has_thumbnail = value
self.notify("has-thumbnail") self.notify("has-thumbnail")
def ensure_thumbnail(self, thumbnailer: Thumbnailer):
if self.thumbnail or self.attempted_thumbnail:
return
if not self.attempted_thumbnail:
thumbnailer.generate_thumbnail(self)
@overload @overload
def _load_attribute(self, name: str, dfl: str) -> str: ... def _load_attribute(self, name: str, dfl: str) -> str: ...

501
lazy_player/main_window.py Normal file
View file

@ -0,0 +1,501 @@
from __future__ import annotations
import os
from datetime import datetime
from pathlib import Path
from typing import Any, cast
from gi.repository import Gdk, GLib, Gtk, Pango
from .file_model import FileItem, FileListModel, FileType
from .thumbnailer import Thumbnailer
from .video_player import VideoPlayer
class MainWindow(Gtk.ApplicationWindow):
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
directory_history: list[Path]
selection_history: dict[str, int]
def __init__(self, *args: Any, thumbnailer: Thumbnailer, **kwargs: Any):
super().__init__(*args, **kwargs)
self.thumbnailer = thumbnailer
# Directory history stack
self.directory_history = []
self.selection_history = {}
# 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)
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)
self.fullscreen()
# Setup key event controller
key_controller = Gtk.EventControllerKey()
key_controller.connect("key-pressed", self._on_key_pressed)
self.add_controller(key_controller)
# Disable and hide mouse cursor
self.set_can_target(False)
display = Gdk.Display.get_default()
if display:
cursor = Gdk.Cursor.new_from_name("none")
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()
# 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 an overlay container
overlay = Gtk.Overlay()
overlay.set_child(self.video_widget)
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)
# Create digital clock
self.clock_label = Gtk.Label()
self.clock_label.set_name("digital-clock")
self.clock_label.set_halign(Gtk.Align.CENTER)
self.clock_label.set_valign(Gtk.Align.CENTER)
self.clock_label.set_text(datetime.now().strftime("%H:%M"))
left_box.append(self.clock_label)
# 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)
# Create list model and view
self.list_model = FileListModel()
self.list_view = Gtk.ListView()
self.selection_model = Gtk.SingleSelection.new(self.list_model)
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
duration = file_item.saved_duration
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,
file_item.saved_audio_track,
)
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)
# Factory for list items
factory = Gtk.SignalListItemFactory()
factory.connect("setup", self._setup_list_item)
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)
right_box.append(scrolled)
self.set_child(self.stack)
@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
@property
def selection(self) -> FileItem | None:
selected_item = self.selection_model.get_selected_item()
return cast(FileItem, selected_item) if selected_item else None
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 image
icon = Gtk.Image()
icon.set_css_classes(["file-icon"])
box.append(icon)
# Create label
label = Gtk.Label()
label.set_halign(Gtk.Align.START)
box.append(label)
list_item.set_child(box)
def _bind_list_item(self, factory: Gtk.SignalListItemFactory, list_item: Gtk.ListItem):
box = cast(Gtk.Box, list_item.get_child())
icon = cast(Gtk.Image, box.get_first_child())
label = cast(Gtk.Label, box.get_last_child())
item = cast(FileItem, list_item.get_item())
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"])
else:
position = item.saved_position
duration = item.saved_duration
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"])
label.set_text(item.name)
item.connect("notify::saved-position", update_icon)
item.connect("notify::saved-duration", update_icon)
update_icon()
def _restore_selection(self):
pos = self.selection_history.get(os.getcwd(), 0)
self.selection_model.set_selected(pos)
self.list_view.scroll_to(pos, Gtk.ListScrollFlags.SELECT | Gtk.ListScrollFlags.FOCUS, None)
def _refresh(self):
self._populate_file_list()
self._restore_selection()
def _navigate_to(self, path: Path):
self.directory_history.append(Path(os.getcwd()))
os.chdir(path)
self._populate_file_list()
self._restore_selection()
def _navigate_back(self):
if not self.directory_history:
return
prev_dir = self.directory_history.pop()
os.chdir(prev_dir)
self._populate_file_list()
self._restore_selection()
def _on_selection_changed(
self,
selection_model: Gtk.SingleSelection,
position: int,
n_items: int,
):
position = selection_model.get_selected()
if position == Gtk.INVALID_LIST_POSITION:
return
self.selection_history[os.getcwd()] = position
if 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)
def _toggle_play_pause(self) -> None:
"""Toggle between play and pause states"""
self.video_player.toggle_play_pause()
def _on_player_key_pressed(
self,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
if keyval == Gdk.keyval_from_name("space"):
self._toggle_play_pause()
return True
elif keyval == Gdk.keyval_from_name("Escape"):
self._save_position()
self.video_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)
return True
elif keyval == Gdk.keyval_from_name("Right"):
self.video_player.seek_relative(10)
return True
elif keyval == Gdk.keyval_from_name("Down"):
self.video_player.seek_relative(-60)
return True
elif keyval == Gdk.keyval_from_name("Up"):
self.video_player.seek_relative(60)
return True
elif keyval == Gdk.keyval_from_name("Home"):
self.video_player.seek_start()
return True
elif keyval == Gdk.keyval_from_name("End"):
self.video_player.seek_end()
return True
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")
file_item = self.selection
if file_item is not None:
file_item.saved_subtitle_track = index - 1
else:
self.show_overlay_text("No subtitles available")
return True
elif keyval == Gdk.keyval_from_name("a"):
has_audio, index, lang = self.video_player.cycle_audio()
if has_audio:
self.show_overlay_text(f"Audio #{index} ({lang})")
file_item = self.selection
if file_item is not None:
file_item.saved_audio_track = index - 1
else:
self.show_overlay_text("No audio tracks available")
return True
return False
def _toggle_watched_status(self) -> None:
"""Toggle watched status for the selected file"""
file_item = self.selection
if file_item is None or file_item.file_type == FileType.DIRECTORY:
return
position = file_item.saved_position
duration = file_item.saved_duration
# If position exists and is >= 99% through, clear it
if position > 0 and (position / duration) >= 0.99:
file_item.saved_position = 0
else:
# Otherwise mark as complete
file_item.saved_position = duration
# Force the list to update the changed item
self.list_model.items_changed(self.selection_model.get_selected(), 1, 1)
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
elif keyval == Gdk.keyval_from_name("r"):
self._refresh()
return True
elif keyval == Gdk.keyval_from_name("w"):
self._toggle_watched_status()
return True
elif keyval == Gdk.keyval_from_name("BackSpace"):
self._navigate_back()
return True
return False
def _on_key_pressed(
self,
controller: Gtk.EventControllerKey,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
# 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)
else:
return self._on_menu_key_pressed(keyval, keycode, state)
def _save_position(self) -> None:
"""Save current playback position as xattr"""
if not self.video_player.is_playing:
return
file_item = self.selection
if file_item is None or file_item.file_type == FileType.DIRECTORY:
return
position = self.video_player.get_position()
if position is not None:
file_item.saved_position = position
duration = self.video_player.get_duration()
if duration is not None:
file_item.saved_duration = 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 _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool:
current_time = frame_clock.get_frame_time() / 1_000_000
self.clock_label.set_text(datetime.now().strftime("%H:%M"))
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:
self._save_position()
self.last_position_save = frame_time
# Update thumbnail if available
if file_item := self.selection:
file_item.ensure_thumbnail(self.thumbnailer)
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)
return True
def _populate_file_list(self) -> None:
items: list[FileItem] = []
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"):
file_item = FileItem(path.name, FileType.VIDEO, path.resolve())
items.append(file_item)
# Sort directories first, then files, both alphabetically
items.sort(key=lambda x: (x.file_type != FileType.DIRECTORY, x.name.lower()))
self.list_model.set_items(items)

View file

@ -3,12 +3,11 @@ from __future__ import annotations
import threading import threading
from queue import Empty, Queue from queue import Empty, Queue
import gi from gi.repository import GLib, Gst
from .file_model import FileItem from .file_model import FileItem
gi.require_version("Gst", "1.0") DEFAULT_SEEK_FLAGS = Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT
from gi.repository import Gst # NOQA: E402
class Thumbnailer(threading.Thread): class Thumbnailer(threading.Thread):
@ -29,6 +28,7 @@ class Thumbnailer(threading.Thread):
self.queue.get_nowait() self.queue.get_nowait()
except Empty: except Empty:
pass pass
self.queue.put_nowait(file_item) self.queue.put_nowait(file_item)
def stop(self): def stop(self):
@ -52,6 +52,7 @@ class Thumbnailer(threading.Thread):
if file_item is None: if file_item is None:
break break
file_item.attempted_thumbnail = True
self._generate_thumbnail(file_item) self._generate_thumbnail(file_item)
def _generate_thumbnail(self, file_item: FileItem): def _generate_thumbnail(self, file_item: FileItem):
@ -87,11 +88,7 @@ class Thumbnailer(threading.Thread):
return return
seek_pos = duration // 3 seek_pos = duration // 3
pipeline.seek_simple( pipeline.seek_simple(Gst.Format.TIME, DEFAULT_SEEK_FLAGS, seek_pos)
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
seek_pos,
)
# Start playing to capture frame # Start playing to capture frame
pipeline.set_state(Gst.State.PLAYING) pipeline.set_state(Gst.State.PLAYING)
@ -110,11 +107,18 @@ class Thumbnailer(threading.Thread):
return return
try: try:
file_item.thumbnail = bytes(map_info.data) thumbnail = bytes(map_info.data)
file_item.has_thumbnail = True
finally: finally:
buffer.unmap(map_info) buffer.unmap(map_info)
def set_thumbnail():
file_item.thumbnail = thumbnail
file_item.has_thumbnail = True
GLib.idle_add(set_thumbnail)
except Exception as err: except Exception as err:
print("Failed:", file_item.full_path, err) print("Failed:", file_item.full_path, err)
finally: finally:
pipeline.set_state(Gst.State.NULL) pipeline.set_state(Gst.State.NULL)

View file

@ -2,12 +2,9 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import gi from gi.repository import GObject, Gst, Gtk
gi.require_version("Gtk", "4.0") DEFAULT_SEEK_FLAGS = Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT
gi.require_version("Gst", "1.0")
gi.require_version("GObject", "2.0")
from gi.repository import GObject, Gst, Gtk # NOQA: E402 # NOQA: E402
class VideoPlayer(GObject.Object): class VideoPlayer(GObject.Object):
@ -70,11 +67,7 @@ class VideoPlayer(GObject.Object):
if position: if position:
# Seek to saved position # Seek to saved position
self.pipeline.seek_simple( self.pipeline.seek_simple(Gst.Format.TIME, DEFAULT_SEEK_FLAGS, position)
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE,
position,
)
# Start playing # Start playing
self.pipeline.set_state(Gst.State.PLAYING) self.pipeline.set_state(Gst.State.PLAYING)
@ -107,29 +100,17 @@ class VideoPlayer(GObject.Object):
if new_pos < 0: if new_pos < 0:
new_pos = 0 new_pos = 0
self.pipeline.seek_simple( self.pipeline.seek_simple(Gst.Format.TIME, DEFAULT_SEEK_FLAGS, new_pos)
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
new_pos,
)
def seek_start(self): def seek_start(self):
"""Seek to the start of the video.""" """Seek to the start of the video."""
self.pipeline.seek_simple( self.pipeline.seek_simple(Gst.Format.TIME, DEFAULT_SEEK_FLAGS, 0)
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
0,
)
def seek_end(self): def seek_end(self):
"""Seek to the end of the video.""" """Seek to the end of the video."""
duration = self.get_duration() duration = self.get_duration()
if duration: if duration:
self.pipeline.seek_simple( self.pipeline.seek_simple(Gst.Format.TIME, DEFAULT_SEEK_FLAGS, duration)
Gst.Format.TIME,
Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT,
duration,
)
def get_position(self) -> int | None: def get_position(self) -> int | None:
"""Get current playback position in nanoseconds""" """Get current playback position in nanoseconds"""