Split-off video overlay

This commit is contained in:
Jan Hamal Dvořák 2025-03-11 19:02:15 +01:00
parent ae1e361cc9
commit 3c7953d815
4 changed files with 248 additions and 210 deletions

View file

@ -5,32 +5,36 @@ from datetime import datetime
from pathlib import Path
from typing import Any, cast
from gi.repository import Gdk, GLib, Gtk, Pango
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_widget: Gtk.Picture
tick_callback_id: int
overlay_label: Gtk.Label
overlay_hide_time: float
last_position_save: float
grid_segments: list[list[Gtk.Box]]
grid_overlay: Gtk.Grid
grid_clock: Gtk.Label
main_clock: Gtk.Label
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
@ -39,18 +43,8 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
self.directory_history = []
self.selection_history = {}
# For overlay text timeout
self.overlay_hide_time = 0.0
# Last time we've saved playback position.
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)
@ -69,107 +63,29 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
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()
# Process frame ticks.
self.add_tick_callback(self._on_tick, None)
# 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 grid overlay with 3x3 boxes
self.grid_overlay = Gtk.Grid()
self.grid_overlay.set_hexpand(True)
self.grid_overlay.set_vexpand(True)
self.grid_overlay.set_name("grid-overlay")
self.grid_overlay.set_column_homogeneous(True)
self.grid_overlay.set_row_homogeneous(True)
# Store grid segments in a 3x3 array
self.grid_segments = [[Gtk.Box() for _ in range(3)] for _ in range(3)]
# Setup 3x3 grid of boxes
for row in range(3):
for col in range(3):
box = self.grid_segments[row][col]
box.set_name("grid-box")
box.set_hexpand(True)
box.set_vexpand(True)
self.grid_overlay.attach(box, col, row, 1, 1)
# Add clock to top-left grid box
self.grid_clock = Gtk.Label()
self.grid_clock.set_name("grid-clock")
self.grid_clock.set_halign(Gtk.Align.CENTER)
self.grid_clock.set_valign(Gtk.Align.START)
self.grid_clock.set_hexpand(True)
self.grid_clock.set_vexpand(True)
self.grid_clock.set_text(datetime.now().strftime("%H:%M"))
# Attach to top-left grid box
self.grid_segments[0][0].append(self.grid_clock)
# Create an overlay container
overlay = Gtk.Overlay()
overlay.set_child(self.video_widget)
overlay.add_overlay(self.grid_overlay)
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)
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.main_clock = Gtk.Label()
self.main_clock.set_name("digital-clock")
self.main_clock.set_halign(Gtk.Align.CENTER)
self.main_clock.set_valign(Gtk.Align.CENTER)
left_box.append(self.main_clock)
self.clock = Gtk.Label()
self.clock.set_name("main-clock")
self.clock.set_halign(Gtk.Align.CENTER)
self.clock.set_valign(Gtk.Align.CENTER)
# 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)
self.thumbnail_picture = Gtk.Picture()
self.thumbnail_picture.set_name("thumbnail-picture")
self.thumbnail_picture.set_size_request(384, 216)
self.thumbnail_picture.set_can_shrink(True)
self.thumbnail_picture.set_keep_aspect_ratio(True)
self.thumbnail_picture.set_resource(None)
# Create list model and view
self.list_model = FileListModel()
@ -178,35 +94,7 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
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.value
duration = file_item.saved_duration.value
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.value,
file_item.saved_audio_track.value,
)
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)
self.list_view.connect("activate", self._on_activate)
# Factory for list items
factory = Gtk.SignalListItemFactory()
@ -214,25 +102,52 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
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)
# 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()
@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
# Populate the list store.
self._populate_file_list()
@property
def selection(self) -> FileItem | None:
@ -285,6 +200,33 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
item.connect("notify::saved-duration", 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.full_path,
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)
@ -327,14 +269,14 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
if file_item.thumbnail.value:
gbytes = GLib.Bytes.new(cast(Any, file_item.thumbnail.value))
texture = Gdk.Texture.new_from_bytes(gbytes)
self.thumbnail_image.set_paintable(texture)
self.thumbnail_picture.set_paintable(texture)
else:
self.thumbnailer.generate_thumbnail(file_item)
self.thumbnail_image.set_paintable(None)
self.thumbnail_picture.set_paintable(None)
def _toggle_play_pause(self) -> None:
"""Toggle between play and pause states"""
self.video_player.toggle_play_pause()
self.player.toggle_play_pause()
def _on_player_key_pressed(
self,
@ -348,62 +290,62 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
elif keyval == Gdk.keyval_from_name("Escape"):
self._save_position()
self.video_player.stop()
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.video_player.seek_relative(-10)
self.player.seek_relative(-10)
return True
elif keyval == Gdk.keyval_from_name("Right"):
self.video_player.seek_relative(10)
self.player.seek_relative(10)
return True
elif keyval == Gdk.keyval_from_name("Down"):
self.video_player.seek_relative(-60)
self.player.seek_relative(-60)
return True
elif keyval == Gdk.keyval_from_name("Up"):
self.video_player.seek_relative(60)
self.player.seek_relative(60)
return True
elif keyval == Gdk.keyval_from_name("Home"):
self.video_player.seek_start()
self.player.seek_start()
return True
elif keyval == Gdk.keyval_from_name("End"):
self.video_player.seek_end()
self.player.seek_end()
return True
elif keyval == Gdk.keyval_from_name("j"):
has_subs, index, lang = self.video_player.cycle_subtitles()
has_subs, index, lang = self.player.cycle_subtitles()
if has_subs:
if index:
self.show_overlay_text(f"Subtitles #{index} ({lang})")
self.overlay.show_message(f"Subtitles #{index} ({lang})")
else:
self.show_overlay_text("Subtitles turned off")
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.show_overlay_text("No subtitles available")
self.overlay.show_message("No subtitles available")
return True
elif keyval == Gdk.keyval_from_name("a"):
has_audio, index, lang = self.video_player.cycle_audio()
has_audio, index, lang = self.player.cycle_audio()
if has_audio:
self.show_overlay_text(f"Audio #{index} ({lang})")
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.show_overlay_text("No audio tracks available")
self.overlay.show_message("No audio tracks available")
return True
@ -470,7 +412,7 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
def _save_position(self) -> None:
"""Save current playback position as xattr"""
if not self.video_player.is_playing:
if not self.player.is_playing:
return
file_item = self.selection
@ -478,55 +420,35 @@ class MainWindow(Gtk.ApplicationWindow, Watcher):
if file_item is None or file_item.file_type == FileType.DIRECTORY:
return
position = self.video_player.get_position()
position = self.player.get_position()
if position is not None:
file_item.saved_position.value = position
duration = self.video_player.get_duration()
duration = self.player.get_duration()
if duration is not None:
file_item.saved_duration.value = 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 _watch_player_state(self):
is_playing = self.video_player.is_playing.value
is_paused = self.video_player.is_paused.value
self.grid_overlay.set_visible(is_playing and is_paused)
def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool:
# Update all reactive values.
# Update all reactive values for whole application.
update_all_computed()
current_time = frame_clock.get_frame_time() / 1_000_000
self.clock.set_text(datetime.now().strftime("%H:%M"))
current_time_str = datetime.now().strftime("%H:%M")
self.main_clock.set_text(current_time_str)
self.grid_clock.set_text(current_time_str)
self.now = frame_clock.get_frame_time() / 1_000_000
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:
# Save playback position every 60 seconds.
if self.now - self.last_position_save >= 60.0:
self._save_position()
self.last_position_save = frame_time
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_image.get_paintable():
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_image.set_paintable(texture)
self.thumbnail_picture.set_paintable(texture)
return True

View file

@ -20,11 +20,11 @@ listview > row {
color: #0f0;
}
#black-overlay {
#overlay {
background-color: black;
}
#overlay-text {
#overlay-message {
color: white;
font-size: 24px;
font-family: monospace;
@ -34,19 +34,19 @@ listview > row {
margin: 32px;
}
#thumbnail-image {
#thumbnail-picture {
margin: 8px;
}
#digital-clock,
#grid-clock {
#main-clock,
#overlay-clock {
color: white;
font-size: 48px;
font-family: monospace;
padding: 12px;
}
#grid-clock {
#overlay-clock {
background-color: rgba(32, 32, 32, 0.5);
border-radius: 8px;
}

View file

@ -0,0 +1,113 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from gi.repository import Gdk, Gtk, Pango
from .reactive import Watcher
from .video_player import VideoPlayer
class VideoOverlay(Gtk.Overlay, Watcher):
player: VideoPlayer
message: Gtk.Label
message_expiration: float
grid: Gtk.Grid
grid_expiration: float
clock_box: Gtk.Box
clock: Gtk.Label
now: float
def __init__(self, player: VideoPlayer):
super().__init__()
self.now = 0.0
self.player = player
# Message is appears at the center of the screen,
# above everything else. Usually to indicate change
# of subtitle or audio track or something similar.
self.message = Gtk.Label()
self.message.set_name("overlay-message")
self.message.set_valign(Gtk.Align.CENTER)
self.message.set_halign(Gtk.Align.CENTER)
self.message.set_visible(False)
self.message.set_wrap(True)
self.message.set_wrap_mode(Pango.WrapMode.WORD_CHAR)
# Once specific time passes, message disappears.
self.message_expiration = 0.0
# Grid overlay is between the video at the bottom and
# the message at the top. It is only shown when user
# interacts with the player.
self.grid = Gtk.Grid()
self.grid.set_hexpand(True)
self.grid.set_vexpand(True)
self.grid.set_column_homogeneous(True)
self.grid.set_row_homogeneous(True)
# Grid visibility can also expire after a while.
self.grid_expiration = 0.0
# Create grid boxes.
self.clock_box = Gtk.Box(hexpand=True, vexpand=True)
self.grid.attach(self.clock_box, 0, 0, 1, 1)
self.grid.attach(Gtk.Box(), 1, 0, 2, 1)
self.grid.attach(Gtk.Box(), 0, 1, 3, 1)
self.grid.attach(Gtk.Box(), 0, 2, 3, 1)
# Add clock to the top-left grid box.
self.clock = Gtk.Label(hexpand=True, vexpand=True)
self.clock.set_name("overlay-clock")
self.clock.set_halign(Gtk.Align.CENTER)
self.clock.set_valign(Gtk.Align.START)
self.clock.set_text(datetime.now().strftime("%H:%M"))
self.clock_box.append(self.clock)
# Add children.
self.set_child(self.player.picture)
self.add_overlay(self.grid)
self.add_overlay(self.message)
# Consume ticks for the clock and overlay expiration.
self.add_tick_callback(self._on_tick, None)
# Register all watches.
self.watch_all()
def _on_tick(self, widget: Gtk.Widget, frame_clock: Gdk.FrameClock, data: Any) -> bool:
self.clock.set_text(datetime.now().strftime("%H:%M"))
self.now = frame_clock.get_frame_time() / 1_000_000
if self.grid_expiration <= self.now:
self.grid.hide()
if self.message_expiration <= self.now:
self.message.hide()
return True
def show_message(self, text: str, timeout: float = 1.0) -> None:
"""Show text in a centered overlay that disappears after timeout."""
self.message.set_text(text)
self.message.show()
self.message_expiration = self.now + timeout
def _watch_player_state(self):
is_playing = self.player.is_playing.value
is_paused = self.player.is_paused.value
if is_playing and is_paused:
self.grid.show()
self.grid_expiration = 1e20
else:
self.grid_expiration = 0

View file

@ -10,6 +10,7 @@ DEFAULT_SEEK_FLAGS = Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT
class VideoPlayer(GObject.Object):
picture: Gtk.Picture
pipeline: Gst.Pipeline
playbin: Gst.Element
@ -21,6 +22,8 @@ class VideoPlayer(GObject.Object):
def __init__(self, picture: Gtk.Picture):
super().__init__()
self.picture = picture
self.is_playing = Ref(False)
self.is_paused = Ref(True)
@ -41,7 +44,7 @@ class VideoPlayer(GObject.Object):
# Link picture to sink
paintable = video_sink.get_property("paintable")
picture.set_paintable(paintable)
self.picture.set_paintable(paintable)
def play(
self,