Reorganize project

This commit is contained in:
Jan Hamal Dvořák 2025-03-11 09:58:17 +01:00
parent c37b05a3a1
commit c62963091d
6 changed files with 551 additions and 541 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,7 @@ 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 gi.repository import Gio, GObject # NOQA: E402
class FileType(Enum): class FileType(Enum):

506
lazy_player/main_window.py Normal file
View file

@ -0,0 +1,506 @@
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 _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)

View file

@ -3,13 +3,10 @@ from __future__ import annotations
import threading import threading
from queue import Empty, Queue from queue import Empty, Queue
import gi from gi.repository import Gst
from .file_model import FileItem from .file_model import FileItem
gi.require_version("Gst", "1.0")
from gi.repository import Gst # NOQA: E402
class Thumbnailer(threading.Thread): class Thumbnailer(threading.Thread):
queue: Queue[FileItem | None] queue: Queue[FileItem | None]

View file

@ -2,12 +2,7 @@ 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")
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):