Reorganize project
This commit is contained in:
parent
c37b05a3a1
commit
c62963091d
6 changed files with 551 additions and 541 deletions
|
@ -2,540 +2,20 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
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("Gtk", "4.0")
|
||||
gi.require_version("GLib", "2.0")
|
||||
gi.require_version("GObject", "2.0")
|
||||
gi.require_version("Gst", "1.0")
|
||||
gi.require_version("Gtk", "4.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):
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
from .application import Application # NOQA: E402
|
||||
from .thumbnailer import Thumbnailer # NOQA: E402
|
||||
|
||||
|
||||
def main():
|
||||
|
@ -546,7 +26,7 @@ def main():
|
|||
Gst.init(None)
|
||||
|
||||
thumbnailer = Thumbnailer()
|
||||
app = App(thumbnailer)
|
||||
app = Application(thumbnailer=thumbnailer)
|
||||
|
||||
try:
|
||||
thumbnailer.start()
|
||||
|
|
35
lazy_player/application.py
Normal file
35
lazy_player/application.py
Normal 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()
|
|
@ -6,10 +6,7 @@ from enum import Enum, auto
|
|||
from pathlib import Path
|
||||
from typing import Optional, overload
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("GObject", "2.0")
|
||||
from gi.repository import Gio, GObject # NOQA: E402
|
||||
from gi.repository import Gio, GObject
|
||||
|
||||
|
||||
class FileType(Enum):
|
||||
|
|
506
lazy_player/main_window.py
Normal file
506
lazy_player/main_window.py
Normal 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)
|
|
@ -3,13 +3,10 @@ from __future__ import annotations
|
|||
import threading
|
||||
from queue import Empty, Queue
|
||||
|
||||
import gi
|
||||
from gi.repository import Gst
|
||||
|
||||
from .file_model import FileItem
|
||||
|
||||
gi.require_version("Gst", "1.0")
|
||||
from gi.repository import Gst # NOQA: E402
|
||||
|
||||
|
||||
class Thumbnailer(threading.Thread):
|
||||
queue: Queue[FileItem | None]
|
||||
|
|
|
@ -2,12 +2,7 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
|
||||
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
|
||||
from gi.repository import GObject, Gst, Gtk
|
||||
|
||||
|
||||
class VideoPlayer(GObject.Object):
|
||||
|
|
Loading…
Reference in a new issue