lazy-player/lazy_player/main_window.py

490 lines
16 KiB
Python

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
from .file_model import FileItem, FileListModel, FileType
from .reactive import Watcher, update_all_computed
from .thumbnailer import Thumbnailer
from .video_overlay import VideoOverlay
from .video_player import VideoPlayer
class MainWindow(Gtk.ApplicationWindow, Watcher):
player: VideoPlayer
overlay: VideoOverlay
stack: Gtk.Stack
list_view: Gtk.ListView
list_model: FileListModel
selection_model: Gtk.SingleSelection
video_picture: Gtk.Picture
thumbnail_picture: Gtk.Picture
clock: Gtk.Label
directory_history: list[Path]
selection_history: dict[str, int]
last_position_save: float
now: float
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 = {}
# Last time we've saved playback position.
self.last_position_save = 0.0
# 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)
# Process frame ticks.
self.add_tick_callback(self._on_tick, None)
# Create video widget and overlay
self.video_picture = Gtk.Picture()
self.video_picture.set_can_shrink(True)
self.video_picture.set_keep_aspect_ratio(True)
self.video_picture.set_vexpand(True)
self.video_picture.set_hexpand(True)
# Create main menu clock
self.clock = Gtk.Label(
name="main-clock",
halign=Gtk.Align.START,
valign=Gtk.Align.START,
)
# Create image widget for thumbnail
self.thumbnail_picture = Gtk.Picture(
name="thumbnail-picture",
can_shrink=True,
keep_aspect_ratio=True,
)
self.thumbnail_picture.set_size_request(384, 216)
self.thumbnail_picture.set_resource(None)
# Create list & selection model for the file list view.
self.list_model = FileListModel()
self.selection_model = Gtk.SingleSelection(model=self.list_model)
self.selection_model.connect("selection-changed", self._on_selection_changed)
# Create file list view.
self.list_view = Gtk.ListView(
name="file-list",
vexpand=True,
model=self.selection_model,
)
self.list_view.connect("activate", self._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)
# Add list view to a scrolled window
scrolled = Gtk.ScrolledWindow()
scrolled.set_child(self.list_view)
# Setup video player.
self.player = VideoPlayer(self.video_picture)
# Setup video overlay using that player.
self.overlay = VideoOverlay(self.player)
self.overlay.set_name("overlay")
# 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)
left_box.append(self.clock)
left_box.append(self.thumbnail_picture)
# Right two-thirds (2/3 width).
right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
right_box.set_hexpand(True)
right_box.append(scrolled)
# Create a grid to handle the 1:2 ratio we want.
grid = Gtk.Grid()
grid.set_column_homogeneous(True)
grid.set_hexpand(True)
grid.attach(left_box, 0, 0, 1, 1)
grid.attach(right_box, 1, 0, 2, 1)
# Add both main menu and overlay to stack.
self.stack = Gtk.Stack()
self.stack.add_named(grid, "menu")
self.stack.add_named(self.overlay, "player")
# Start with main menu visible.
self.stack.set_visible_child_name("menu")
# Stack is our root.
self.set_child(self.stack)
# Enable all watch methods.
self.watch_all()
# Populate the list store.
self._populate_file_list()
@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():
if item.file_type == FileType.DIRECTORY:
icon.set_from_icon_name("folder-symbolic")
icon.set_css_classes(["file-icon"])
else:
position = item.saved_position.value
duration = item.saved_duration.value
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.watch(update_icon)
update_icon()
def _on_activate(self, 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.value
duration = file_item.saved_duration.value
if (position / duration) >= 0.99:
position = 0
# Start playing the video
self.player.play(
file_item,
position,
file_item.saved_subtitle_track.value,
file_item.saved_audio_track.value,
)
self.last_position_save = self.now
self.stack.set_visible_child_name("player")
self.overlay.show_message(f"Playing: {file_item.name}")
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.value:
gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail.value))
texture = Gdk.Texture.new_from_bytes(gbytes)
self.thumbnail_picture.set_paintable(texture)
else:
self.thumbnailer.generate_thumbnail(file_item)
self.thumbnail_picture.set_paintable(None)
def _toggle_play_pause(self) -> None:
"""Toggle between play and pause states"""
self.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.player.stop()
self.stack.set_visible_child_name("menu")
self.list_view.grab_focus()
return True
elif keyval == Gdk.keyval_from_name("Left"):
self.player.seek_relative(-10)
return True
elif keyval == Gdk.keyval_from_name("Right"):
self.player.seek_relative(10)
return True
elif keyval == Gdk.keyval_from_name("Down"):
self.player.seek_relative(-60)
return True
elif keyval == Gdk.keyval_from_name("Up"):
self.player.seek_relative(60)
return True
elif keyval == Gdk.keyval_from_name("Home"):
self.player.seek_start()
return True
elif keyval == Gdk.keyval_from_name("End"):
self.player.seek_end()
return True
elif keyval == Gdk.keyval_from_name("o"):
self.player.last_user_input.value = self.now
return True
elif keyval == Gdk.keyval_from_name("j"):
has_subs, index, lang = self.player.cycle_subtitles()
if has_subs:
if index:
self.overlay.show_message(f"Subtitles #{index} ({lang})")
else:
self.overlay.show_message("Subtitles turned off")
file_item = self.selection
if file_item is not None:
file_item.saved_subtitle_track.value = index - 1
else:
self.overlay.show_message("No subtitles available")
return True
elif keyval == Gdk.keyval_from_name("a"):
has_audio, index, lang = self.player.cycle_audio()
if has_audio:
self.overlay.show_message(f"Audio #{index} ({lang})")
file_item = self.selection
if file_item is not None:
file_item.saved_audio_track.value = index - 1
else:
self.overlay.show_message("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.value
duration = file_item.saved_duration.value
# If position exists and is >= 99% through, clear it
if position > 0 and (position / duration) >= 0.99:
file_item.saved_position.value = 0
else:
# Otherwise mark as complete
file_item.saved_position.value = 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.player.is_playing:
return
file_item = self.player.file_item.value
if file_item is None or file_item.file_type == FileType.DIRECTORY:
return
position = self.player.get_position()
if position is not None:
file_item.saved_position.value = position
duration = self.player.get_duration()
if duration is not None:
file_item.saved_duration.value = duration
def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool:
# Update all reactive values for whole application.
update_all_computed()
self.clock.set_text(datetime.now().strftime("%H:%M"))
self.now = frame_clock.get_frame_time() / 1_000_000
# Save playback position every 60 seconds.
if self.now - self.last_position_save >= 60.0:
self._save_position()
self.last_position_save = self.now
# Update thumbnail if available
if file_item := self.selection:
self.thumbnailer.generate_thumbnail(file_item)
if file_item.thumbnail.value and not self.thumbnail_picture.get_paintable():
gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail.value))
texture = Gdk.Texture.new_from_bytes(gbytes)
self.thumbnail_picture.set_paintable(texture)
if self.thumbnailer._work_queue.empty():
for i in range(self.list_model.get_n_items()):
if item := self.list_model.get_item(i):
assert isinstance(item, FileItem)
if item.file_type != FileType.DIRECTORY and not item.attempted_thumbnail.value:
self.thumbnailer.generate_thumbnail(item)
break
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)