Compare commits
10 commits
981beffcce
...
eef1ec6585
Author | SHA1 | Date | |
---|---|---|---|
eef1ec6585 | |||
8944fdc291 | |||
81d9ddf606 | |||
8508b13382 | |||
561f68d221 | |||
bdc480ea1e | |||
4af3ee2164 | |||
12b6f33de3 | |||
d4601a77b9 | |||
789f86de25 |
2 changed files with 234 additions and 18 deletions
lazy_player
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
@ -9,17 +10,42 @@ import gi
|
|||
gi.require_version("Gdk", "4.0")
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gst", "1.0")
|
||||
from gi.repository import Gdk, Gst, Gtk # NOQA: E402
|
||||
gi.require_version("Pango", "1.0")
|
||||
from gi.repository import Gdk, Gst, Gtk, Pango # NOQA: E402
|
||||
|
||||
|
||||
class MainWindow(Gtk.ApplicationWindow):
|
||||
file_info_label: Gtk.Label
|
||||
stack: Gtk.Stack
|
||||
list_view: Gtk.ListView
|
||||
list_store: Gtk.StringList
|
||||
video_widget: Gtk.Picture
|
||||
pipeline: Gst.Pipeline
|
||||
overlay_tick_callback_id: int
|
||||
overlay_label: Gtk.Label
|
||||
overlay_hide_time: float
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# For overlay text timeout
|
||||
self.overlay_hide_time = 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)
|
||||
|
||||
def update_overlay(widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool:
|
||||
current_time = frame_clock.get_frame_time() / 1_000_000
|
||||
if current_time >= self.overlay_hide_time:
|
||||
self.overlay_label.set_visible(False)
|
||||
return True
|
||||
|
||||
self.overlay_tick_callback_id = self.add_tick_callback(update_overlay, None)
|
||||
|
||||
# Make window fullscreen and borderless
|
||||
self.set_decorated(False)
|
||||
self.fullscreen()
|
||||
|
@ -35,12 +61,43 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||
# Create stack to hold our widgets
|
||||
self.stack = Gtk.Stack()
|
||||
|
||||
# Create black overlay
|
||||
overlay_box = Gtk.Box()
|
||||
# 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)
|
||||
|
||||
overlay_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
overlay_box.set_name("black-overlay")
|
||||
overlay_box.set_vexpand(True)
|
||||
overlay_box.set_hexpand(True)
|
||||
|
||||
# Create an overlay container
|
||||
overlay = Gtk.Overlay()
|
||||
overlay.set_child(self.video_widget)
|
||||
overlay.add_overlay(self.overlay_label)
|
||||
|
||||
overlay_box.append(overlay)
|
||||
|
||||
# Setup GStreamer pipeline
|
||||
self.pipeline = Gst.Pipeline.new("video-player")
|
||||
|
||||
playbin = Gst.ElementFactory.make("playbin", "playbin")
|
||||
if not playbin:
|
||||
raise RuntimeError("Failed to create playbin element")
|
||||
|
||||
video_sink = Gst.ElementFactory.make("gtk4paintablesink", "gtk4paintablesink")
|
||||
if not video_sink:
|
||||
raise RuntimeError("Failed to create gtk4paintablesink element")
|
||||
|
||||
playbin.set_property("video-sink", video_sink)
|
||||
self.pipeline.add(playbin)
|
||||
|
||||
# Link video widget to sink
|
||||
paintable = video_sink.get_property("paintable")
|
||||
self.video_widget.set_paintable(paintable)
|
||||
|
||||
# Add both main menu and overlay to stack
|
||||
self.stack.add_named(main_box, "menu")
|
||||
self.stack.add_named(overlay_box, "overlay")
|
||||
|
@ -56,6 +113,7 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||
left_box.set_valign(Gtk.Align.CENTER)
|
||||
left_box.set_halign(Gtk.Align.FILL)
|
||||
self.file_info_label = Gtk.Label(label="")
|
||||
self.file_info_label.set_wrap(True)
|
||||
left_box.append(self.file_info_label)
|
||||
|
||||
# Right two-thirds (2/3 width)
|
||||
|
@ -69,9 +127,9 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||
main_box.append(grid)
|
||||
|
||||
# Create list store and view
|
||||
list_store = Gtk.StringList()
|
||||
self.list_store = Gtk.StringList()
|
||||
self.list_view = Gtk.ListView()
|
||||
selection_model = Gtk.SingleSelection.new(list_store)
|
||||
selection_model = Gtk.SingleSelection.new(self.list_store)
|
||||
selection_model.connect("selection-changed", self._on_selection_changed)
|
||||
self.list_view.set_model(selection_model)
|
||||
self.list_view.set_vexpand(True)
|
||||
|
@ -81,9 +139,19 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||
if selected_item:
|
||||
string_obj = cast(Gtk.StringObject, selected_item)
|
||||
string = string_obj.get_string()
|
||||
print("activated", string)
|
||||
|
||||
self.stack.set_visible_child_name("overlay")
|
||||
if string.endswith("/"):
|
||||
os.chdir(string)
|
||||
self._populate_file_list()
|
||||
return
|
||||
|
||||
# Start playing the video
|
||||
playbin = self.pipeline.get_by_name("playbin")
|
||||
if playbin:
|
||||
playbin.set_property("uri", f"file://{os.path.abspath(string)}")
|
||||
self.pipeline.set_state(Gst.State.PLAYING)
|
||||
self.stack.set_visible_child_name("overlay")
|
||||
self.show_overlay_text(string)
|
||||
|
||||
self.list_view.connect("activate", on_activate)
|
||||
|
||||
|
@ -94,7 +162,7 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||
self.list_view.set_factory(factory)
|
||||
|
||||
# Populate the list store
|
||||
self._populate_file_list(list_store)
|
||||
self._populate_file_list()
|
||||
|
||||
# Add list view to a scrolled window
|
||||
scrolled = Gtk.ScrolledWindow()
|
||||
|
@ -127,6 +195,52 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||
string_obj = cast(Gtk.StringObject, selected_item)
|
||||
self.file_info_label.set_text(string_obj.get_string())
|
||||
|
||||
def _toggle_play_pause(self) -> None:
|
||||
"""Toggle between play and pause states"""
|
||||
_, state, _ = self.pipeline.get_state(0)
|
||||
if state == Gst.State.PLAYING:
|
||||
self.pipeline.set_state(Gst.State.PAUSED)
|
||||
else:
|
||||
self.pipeline.set_state(Gst.State.PLAYING)
|
||||
|
||||
def _on_video_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.pipeline.set_state(Gst.State.NULL)
|
||||
self.stack.set_visible_child_name("menu")
|
||||
self.list_view.grab_focus()
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.keyval_from_name("Left"):
|
||||
self._seek_relative(-10)
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.keyval_from_name("Right"):
|
||||
self._seek_relative(10)
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.keyval_from_name("Up"):
|
||||
self._seek_relative(60)
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.keyval_from_name("Down"):
|
||||
self._seek_relative(-60)
|
||||
return True
|
||||
|
||||
elif keyval == Gdk.keyval_from_name("j"):
|
||||
self._cycle_subtitles()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _on_key_pressed(
|
||||
self,
|
||||
controller: Gtk.EventControllerKey,
|
||||
|
@ -134,14 +248,99 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||
keycode: int,
|
||||
state: Gdk.ModifierType,
|
||||
) -> bool:
|
||||
if keyval == Gdk.keyval_from_name("Escape"):
|
||||
self.stack.set_visible_child_name("menu")
|
||||
self.list_view.grab_focus()
|
||||
return True
|
||||
|
||||
# If we're showing video, handle keys differently
|
||||
if self.stack.get_visible_child_name() == "overlay":
|
||||
return self._on_video_key_pressed(keyval, keycode, state)
|
||||
return False
|
||||
|
||||
def _populate_file_list(self, list_store: Gtk.StringList) -> None:
|
||||
def _seek_relative(self, offset: int) -> None:
|
||||
"""Seek relative to current position by offset seconds"""
|
||||
playbin = self.pipeline.get_by_name("playbin")
|
||||
if not playbin:
|
||||
return
|
||||
|
||||
# Query current position
|
||||
success, current = self.pipeline.query_position(Gst.Format.TIME)
|
||||
if not success:
|
||||
return
|
||||
|
||||
# Convert offset to nanoseconds and add to current
|
||||
new_pos = current + (offset * Gst.SECOND)
|
||||
|
||||
# Ensure we don't seek before start
|
||||
if new_pos < 0:
|
||||
new_pos = 0
|
||||
|
||||
# Perform seek
|
||||
self.pipeline.seek_simple(
|
||||
Gst.Format.TIME, Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, new_pos
|
||||
)
|
||||
|
||||
def _get_subtitle_info(self, track_index: int) -> str:
|
||||
"""Get subtitle track info including language if available"""
|
||||
playbin = self.pipeline.get_by_name("playbin")
|
||||
if not playbin:
|
||||
return str(track_index)
|
||||
|
||||
# Query the subtitle track's tags
|
||||
caps: Gst.TagList | None = playbin.emit("get-text-tags", track_index)
|
||||
if not caps:
|
||||
return str(track_index)
|
||||
|
||||
found, lang = caps.get_string("language-code")
|
||||
return f"{track_index} ({lang})" if found else str(track_index)
|
||||
|
||||
def _cycle_subtitles(self) -> None:
|
||||
"""Cycle through available subtitle tracks, including off state"""
|
||||
playbin = self.pipeline.get_by_name("playbin")
|
||||
if not playbin:
|
||||
return
|
||||
|
||||
# Get current flags and subtitle track
|
||||
flags = playbin.get_property("flags")
|
||||
current = playbin.get_property("current-text")
|
||||
n_text = playbin.get_property("n-text")
|
||||
|
||||
if n_text == 0:
|
||||
self.show_overlay_text("No subtitles available")
|
||||
return
|
||||
|
||||
# If subtitles are disabled, enable them and set to first track
|
||||
if not (flags & 0x00000004): # TEXT flag
|
||||
flags |= 0x00000004
|
||||
playbin.set_property("flags", flags)
|
||||
playbin.set_property("current-text", 0)
|
||||
track_info = self._get_subtitle_info(0)
|
||||
self.show_overlay_text(f"Subtitle track: {track_info}")
|
||||
return
|
||||
|
||||
# If we're on the last track, disable subtitles
|
||||
if current >= n_text - 1:
|
||||
flags &= ~0x00000004 # TEXT flag
|
||||
playbin.set_property("flags", flags)
|
||||
self.show_overlay_text("Subtitles: Off")
|
||||
return
|
||||
|
||||
# Otherwise cycle to next track
|
||||
next_track = current + 1
|
||||
playbin.set_property("current-text", next_track)
|
||||
track_info = self._get_subtitle_info(next_track)
|
||||
self.show_overlay_text(f"Subtitle track: {track_info}")
|
||||
|
||||
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
|
||||
frame_clock = self.get_frame_clock()
|
||||
if frame_clock is None:
|
||||
return
|
||||
|
||||
frame_time = frame_clock.get_frame_time() / 1_000_000 # Convert to seconds
|
||||
self.overlay_hide_time = frame_time + timeout_seconds
|
||||
|
||||
def _populate_file_list(self) -> None:
|
||||
# TODO: Implement proper version sort (strverscmp equivalent)
|
||||
|
||||
directories: list[str] = ["../"]
|
||||
|
@ -150,19 +349,22 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||
with os.scandir(".") as it:
|
||||
for entry in it:
|
||||
if entry.name == ".." or not entry.name.startswith("."):
|
||||
parts = entry.name.split(".")
|
||||
suffix = parts[-1] if len(parts) >= 2 else ""
|
||||
|
||||
if entry.is_dir():
|
||||
directories.append(entry.name + "/")
|
||||
else:
|
||||
elif suffix in ("mkv", "mp4", "avi"):
|
||||
files.append(entry.name)
|
||||
|
||||
directories.sort(key=lambda x: x.lower())
|
||||
files.sort(key=lambda x: x.lower())
|
||||
|
||||
while list_store.get_n_items():
|
||||
list_store.remove(0)
|
||||
while self.list_store.get_n_items():
|
||||
self.list_store.remove(0)
|
||||
|
||||
for name in directories + files:
|
||||
list_store.append(name)
|
||||
self.list_store.append(name)
|
||||
|
||||
all = directories + files
|
||||
self.file_info_label.set_text(all[0] if all else "")
|
||||
|
@ -196,5 +398,8 @@ class App(Gtk.Application):
|
|||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) >= 2:
|
||||
os.chdir(sys.argv[1])
|
||||
|
||||
app = App()
|
||||
app.run(None)
|
||||
|
|
|
@ -7,3 +7,14 @@ listview > row {
|
|||
#black-overlay {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#overlay-text {
|
||||
color: white;
|
||||
font-size: 48px;
|
||||
font-family: monospace;
|
||||
background-color: rgba(64, 64, 64, 0.25);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin: 48px;
|
||||
width: 80%;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue