diff --git a/.gitignore b/.gitignore
index de90263..05daf1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,5 @@ build/
# Project exclude paths
/venv/
.flatpak/
-*.flatpak
\ No newline at end of file
+*.flatpak
+.flatpak-builder/
diff --git a/io.github.jeffshee.Hidamari.json b/io.github.jeffshee.Hidamari.json
index 95306d2..4488e37 100644
--- a/io.github.jeffshee.Hidamari.json
+++ b/io.github.jeffshee.Hidamari.json
@@ -18,11 +18,15 @@
"--talk-name=org.freedesktop.ScreenSaver",
"--talk-name=org.kde.StatusNotifierWatcher",
"--talk-name=org.freedesktop.Flatpak",
- "--system-talk-name=org.freedesktop.login1"
+ "--system-talk-name=org.freedesktop.login1",
+ "--env=GI_TYPELIB_PATH=/app/lib/girepository-1.0:/app/lib64/girepository-1.0",
+ "--env=LD_LIBRARY_PATH=/app/lib:/app/lib64",
+ "--env=LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri/intel-vaapi-driver:/usr/lib/x86_64-linux-gnu/GL/lib/dri:/usr/lib/x86_64-linux-gnu/GL/default/lib/dri"
],
"cleanup": [
"/include",
"/lib/pkgconfig",
+ "/lib64/pkgconfig",
"/man",
"/share/doc",
"/share/gtk-doc",
@@ -162,12 +166,14 @@
"sources": [
{
"type": "git",
- "url": "file:///home/jeffshee/Dev/hidamari"
+ "url": "file:///home/rhafael/Documentos/Mantarraia/Estudos/hidamari"
}
]
}
],
"build-options": {
- "env": {}
+ "env": {
+ "PKG_CONFIG_PATH": "/app/lib/pkgconfig:/app/lib64/pkgconfig:/app/share/pkgconfig"
+ }
}
}
diff --git a/src/assets/control.ui b/src/assets/control.ui
index 5aa2075..d49b967 100644
--- a/src/assets/control.ui
+++ b/src/assets/control.ui
@@ -44,6 +44,13 @@ All Contributors https://github.com/jeffshee/hidamari/graphs/contributors
+
diff --git a/src/commons.py b/src/commons.py
index d965494..c3b60b4 100644
--- a/src/commons.py
+++ b/src/commons.py
@@ -47,8 +47,9 @@
MODE_VIDEO = "MODE_VIDEO"
MODE_STREAM = "MODE_STREAM"
MODE_WEBPAGE = "MODE_WEBPAGE"
+MODE_PLAYLIST = "MODE_PLAYLIST"
-CONFIG_VERSION = 4
+CONFIG_VERSION = 5
CONFIG_KEY_VERSION = "version"
CONFIG_KEY_MODE = "mode"
CONFIG_KEY_DATA_SOURCE = "data_source"
@@ -62,6 +63,8 @@
CONFIG_KEY_FADE_INTERVAL = "fade_interval"
CONFIG_KEY_SYSTRAY = "is_show_systray"
CONFIG_KEY_FIRST_TIME = "is_first_time"
+CONFIG_KEY_PLAYLIST = "playlist"
+CONFIG_KEY_PLAYLIST_REPEAT_COUNT = "playlist_repeat_count"
CONFIG_TEMPLATE = {
CONFIG_KEY_VERSION: CONFIG_VERSION,
CONFIG_KEY_MODE: MODE_NULL,
@@ -76,6 +79,8 @@
CONFIG_KEY_FADE_INTERVAL: 0.1,
CONFIG_KEY_SYSTRAY: False,
CONFIG_KEY_FIRST_TIME: True,
+ CONFIG_KEY_PLAYLIST: [],
+ CONFIG_KEY_PLAYLIST_REPEAT_COUNT: 1,
}
try:
diff --git a/src/gui/control.py b/src/gui/control.py
index 3cb8591..f104dac 100644
--- a/src/gui/control.py
+++ b/src/gui/control.py
@@ -1,3 +1,4 @@
+import json
import sys
import logging
import threading
@@ -20,12 +21,12 @@
sys.path.insert(1, os.path.join(sys.path[0], ".."))
from commons import *
from monitor import *
- from gui.gui_utils import get_thumbnail, debounce
+ from gui.gui_utils import get_thumbnail, get_thumbnail_pixbuf, get_video_duration, debounce
from utils import ConfigUtil, setup_autostart, is_gnome, is_wayland, get_video_paths
except ModuleNotFoundError:
from hidamari.monitor import *
from hidamari.commons import *
- from hidamari.gui.gui_utils import get_thumbnail, debounce
+ from hidamari.gui.gui_utils import get_thumbnail, get_thumbnail_pixbuf, get_video_duration, debounce
from hidamari.utils import (
ConfigUtil,
setup_autostart,
@@ -76,10 +77,14 @@ def __init__(self, version, *args, **kwargs):
self.all_key = "all"
self.is_autostart = os.path.isfile(AUTOSTART_DESKTOP_PATH)
+ self._playlist_paths = []
+ self.playlist_store = None
self._connect_server()
self._load_config()
+ self._playlist_paths = list(self.config.get(CONFIG_KEY_PLAYLIST, []))
+
# initialize monitors
self.monitors = Monitors()
# get video paths
@@ -108,11 +113,17 @@ def _setup_context_menu(self):
item.connect("activate", self.on_set_as, monitor)
self.contextMenu_monitors.append(item)
- # add all option
item = Gtk.MenuItem(label=f"Set For All")
item.connect("activate", self.on_set_as, self.all_key)
self.contextMenu_monitors.append(item)
+ separator = Gtk.SeparatorMenuItem()
+ self.contextMenu_monitors.append(separator)
+
+ item_playlist = Gtk.MenuItem(label="Add to Playlist")
+ item_playlist.connect("activate", self.on_add_to_playlist_context)
+ self.contextMenu_monitors.append(item_playlist)
+
def _load_config(self):
self.config = ConfigUtil().load()
@@ -144,6 +155,12 @@ def do_startup(self):
),
("about", self.on_about),
("quit", self.on_quit),
+ ("add_all_to_playlist", self.on_add_all_to_playlist),
+ ("playlist_clear", self.on_playlist_clear),
+ ("playlist_remove", self.on_playlist_remove),
+ ("playlist_move_up", self.on_playlist_move_up),
+ ("playlist_move_down", self.on_playlist_move_down),
+ ("playlist_apply", self.on_playlist_apply),
]
for action_name, handler in actions:
@@ -200,6 +217,18 @@ def do_activate(self):
self.window.set_position(Gtk.WindowPosition.CENTER)
self.window.present()
+ mode = self.config.get(CONFIG_KEY_MODE, MODE_NULL)
+ stack = self.builder.get_object("stack1")
+ mode_to_tab = {
+ MODE_VIDEO: "video",
+ MODE_PLAYLIST: "playlist",
+ MODE_STREAM: "stream",
+ MODE_WEBPAGE: "webpage",
+ }
+ tab_name = mode_to_tab.get(mode)
+ if stack and tab_name:
+ stack.set_visible_child_name(tab_name)
+
if self.server is None:
self._show_error("Couldn't connect to server")
@@ -477,6 +506,7 @@ def on_quit(self, *_):
def _reload_all_widgets(self):
self._reload_icon_view()
+ self._setup_playlist_tab()
self.set_mute_toggle_icon()
self.set_scale_volume_sensitive()
self.set_spin_blur_radius_sensitive()
@@ -500,6 +530,175 @@ def _reload_all_widgets(self):
toggle_mute: Gtk.ToggleButton = self.builder.get_object("ToggleAutostart")
toggle_mute.set_state = self.is_autostart
+ def _setup_playlist_tab(self):
+ tree_view: Gtk.TreeView = self.builder.get_object("PlaylistTreeView")
+
+ if not tree_view.get_model():
+ self._ensure_playlist_store()
+ tree_view.set_model(self.playlist_store)
+
+ renderer_thumb = Gtk.CellRendererPixbuf()
+ col_thumb = Gtk.TreeViewColumn("", renderer_thumb, pixbuf=0)
+ col_thumb.set_min_width(56)
+ tree_view.append_column(col_thumb)
+
+ renderer_index = Gtk.CellRendererText()
+ col_index = Gtk.TreeViewColumn("#", renderer_index, text=1)
+ col_index.set_min_width(40)
+ tree_view.append_column(col_index)
+
+ renderer_name = Gtk.CellRendererText()
+ col_name = Gtk.TreeViewColumn("Video", renderer_name, text=2)
+ col_name.set_expand(True)
+ tree_view.append_column(col_name)
+
+ renderer_duration = Gtk.CellRendererText()
+ col_duration = Gtk.TreeViewColumn("Duration", renderer_duration, text=3)
+ col_duration.set_min_width(70)
+ tree_view.append_column(col_duration)
+
+ spin_repeat: Gtk.SpinButton = self.builder.get_object("SpinPlaylistRepeatCount")
+ spin_repeat.set_value(self.config.get(CONFIG_KEY_PLAYLIST_REPEAT_COUNT, 1))
+
+ def _ensure_playlist_store(self):
+ if self.playlist_store is None:
+ self.playlist_store = Gtk.ListStore(GdkPixbuf.Pixbuf, str, str, str)
+ generic_icon = Gtk.IconTheme.get_default().load_icon("video-x-generic", 48, 0)
+ for idx, vp in enumerate(self._playlist_paths):
+ self.playlist_store.append([generic_icon, str(idx + 1), os.path.basename(vp), "--:--"])
+ thread = threading.Thread(
+ target=self._load_playlist_metadata, args=(vp, idx), daemon=True)
+ thread.start()
+
+ def _load_playlist_metadata(self, video_path, idx):
+ pixbuf = get_thumbnail_pixbuf(video_path, size=48)
+ duration = get_video_duration(video_path)
+ if self.playlist_store is not None:
+ try:
+ if pixbuf is not None:
+ self.playlist_store[idx][0] = pixbuf
+ self.playlist_store[idx][3] = duration
+ except (IndexError, ValueError):
+ pass
+
+ def on_add_to_playlist_context(self, *_):
+ selected = self.icon_view.get_selected_items()
+ if not selected:
+ return
+ index = selected[0].get_indices()[0]
+ video_path = self.video_paths[index]
+
+ if video_path in self._playlist_paths:
+ logger.info(f"[GUI] Video already in playlist: {video_path}")
+ return
+
+ self._ensure_playlist_store()
+ self._playlist_paths.append(video_path)
+ generic_icon = Gtk.IconTheme.get_default().load_icon("video-x-generic", 48, 0)
+ new_idx = len(self.playlist_store)
+ self.playlist_store.append([generic_icon, str(new_idx + 1), os.path.basename(video_path), "--:--"])
+ thread = threading.Thread(
+ target=self._load_playlist_metadata, args=(video_path, new_idx), daemon=True)
+ thread.start()
+ self._set_playlist_pending(True)
+ logger.info(f"[GUI] Added to playlist: {video_path}")
+
+ def on_add_all_to_playlist(self, *_):
+ if not self.video_paths:
+ return
+ self._ensure_playlist_store()
+ generic_icon = Gtk.IconTheme.get_default().load_icon("video-x-generic", 48, 0)
+ added = 0
+ for video_path in self.video_paths:
+ if video_path not in self._playlist_paths:
+ self._playlist_paths.append(video_path)
+ new_idx = len(self.playlist_store)
+ self.playlist_store.append([generic_icon, str(new_idx + 1), os.path.basename(video_path), "--:--"])
+ thread = threading.Thread(
+ target=self._load_playlist_metadata, args=(video_path, new_idx), daemon=True)
+ thread.start()
+ added += 1
+ if added > 0:
+ self._set_playlist_pending(True)
+ logger.info(f"[GUI] Added {added} videos to playlist")
+
+ def _playlist_renumber(self):
+ for i, row in enumerate(self.playlist_store):
+ row[1] = str(i + 1)
+
+ def _set_playlist_pending(self, pending):
+ label = self.builder.get_object("LabelPlaylistPending")
+ if label:
+ label.set_visible(pending)
+
+ def on_playlist_clear(self, *_):
+ if self.playlist_store is not None:
+ self.playlist_store.clear()
+ self._playlist_paths.clear()
+ self._set_playlist_pending(True)
+
+ def on_playlist_remove(self, *_):
+ tree_view: Gtk.TreeView = self.builder.get_object("PlaylistTreeView")
+ selection = tree_view.get_selection()
+ model, tree_iter = selection.get_selected()
+ if tree_iter is not None:
+ path = model.get_path(tree_iter)
+ index = path.get_indices()[0]
+ model.remove(tree_iter)
+ del self._playlist_paths[index]
+ self._playlist_renumber()
+ self._set_playlist_pending(True)
+
+ def on_playlist_move_up(self, *_):
+ tree_view: Gtk.TreeView = self.builder.get_object("PlaylistTreeView")
+ selection = tree_view.get_selection()
+ model, tree_iter = selection.get_selected()
+ if tree_iter is not None:
+ path = model.get_path(tree_iter)
+ index = path.get_indices()[0]
+ if index > 0:
+ prev_iter = model.get_iter(Gtk.TreePath(index - 1))
+ model.swap(tree_iter, prev_iter)
+ self._playlist_paths[index], self._playlist_paths[index - 1] = \
+ self._playlist_paths[index - 1], self._playlist_paths[index]
+ self._playlist_renumber()
+ self._set_playlist_pending(True)
+
+ def on_playlist_move_down(self, *_):
+ tree_view: Gtk.TreeView = self.builder.get_object("PlaylistTreeView")
+ selection = tree_view.get_selection()
+ model, tree_iter = selection.get_selected()
+ if tree_iter is not None:
+ path = model.get_path(tree_iter)
+ index = path.get_indices()[0]
+ if index < len(model) - 1:
+ next_iter = model.get_iter(Gtk.TreePath(index + 1))
+ model.swap(tree_iter, next_iter)
+ self._playlist_paths[index], self._playlist_paths[index + 1] = \
+ self._playlist_paths[index + 1], self._playlist_paths[index]
+ self._playlist_renumber()
+ self._set_playlist_pending(True)
+
+ def on_playlist_apply(self, *_):
+ if not self._playlist_paths:
+ self._show_error("Playlist is empty.\nPlease add videos first.")
+ return
+
+ spin_repeat: Gtk.SpinButton = self.builder.get_object("SpinPlaylistRepeatCount")
+ repeat_count = int(spin_repeat.get_value())
+
+ self.config[CONFIG_KEY_PLAYLIST] = list(self._playlist_paths)
+ self.config[CONFIG_KEY_PLAYLIST_REPEAT_COUNT] = repeat_count
+ self.config[CONFIG_KEY_MODE] = MODE_PLAYLIST
+ self._save_config()
+ self._set_playlist_pending(False)
+
+ logger.info(f"[GUI] Playlist applied: {len(self._playlist_paths)} videos, repeat={repeat_count}")
+
+ if self.server is not None:
+ playlist_json = json.dumps(self._playlist_paths)
+ self.server.playlist(playlist_json, repeat_count)
+
def _reload_icon_view(self, *_):
self.video_paths = get_video_paths()
list_store = Gtk.ListStore(GdkPixbuf.Pixbuf, str)
@@ -512,11 +711,24 @@ def _reload_icon_view(self, *_):
pixbuf = Gtk.IconTheme().get_default().load_icon("video-x-generic", 96, 0)
list_store.append([pixbuf, os.path.basename(video_path)])
thread = threading.Thread(
- target=get_thumbnail, args=(video_path, list_store, idx)
+ target=self._load_icon_view_item, args=(video_path, list_store, idx)
)
thread.daemon = True
thread.start()
+ def _load_icon_view_item(self, video_path, list_store, idx):
+ get_thumbnail(video_path, list_store, idx)
+ duration = get_video_duration(video_path)
+ name = os.path.basename(video_path)
+ GLib.idle_add(self._update_icon_view_text, list_store, idx, f"{name}\n{duration}")
+
+ def _update_icon_view_text(self, list_store, idx, text):
+ try:
+ list_store[idx][1] = text
+ except (IndexError, ValueError):
+ pass
+ return False
+
def main(
version="devel", pkgdatadir="/app/share/hidamari", localedir="/app/share/locale"
diff --git a/src/gui/gui_utils.py b/src/gui/gui_utils.py
index a3cc021..87cf82d 100644
--- a/src/gui/gui_utils.py
+++ b/src/gui/gui_utils.py
@@ -1,6 +1,7 @@
import os
import sys
import logging
+import subprocess
import threading
import gi
@@ -18,35 +19,82 @@
def generate_thumbnail(filename):
- factory = GnomeDesktop.DesktopThumbnailFactory()
- mtime = os.path.getmtime(filename)
- file = Gio.file_new_for_path(filename)
- uri = file.get_uri()
- info = file.query_info("standard::content-type",
- Gio.FileQueryInfoFlags.NONE, None)
- mime_type = info.get_content_type()
-
- if factory.lookup(uri, mtime) is not None:
+ try:
+ factory = GnomeDesktop.DesktopThumbnailFactory()
+ mtime = os.path.getmtime(filename)
+ file = Gio.file_new_for_path(filename)
+ uri = file.get_uri()
+ info = file.query_info("standard::content-type",
+ Gio.FileQueryInfoFlags.NONE, None)
+ mime_type = info.get_content_type()
+
+ if factory.lookup(uri, mtime) is not None:
+ return False
+
+ if not factory.can_thumbnail(uri, mime_type, mtime):
+ return False
+
+ thumbnail = factory.generate_thumbnail(uri, mime_type)
+ if thumbnail is None:
+ return False
+
+ factory.save_thumbnail(thumbnail, uri, mtime)
+ return True
+ except Exception as e:
+ logger.warning(f"[Thumbnail] Failed to generate for {filename}: {e}")
return False
- if not factory.can_thumbnail(uri, mime_type, mtime):
- return False
-
- thumbnail = factory.generate_thumbnail(uri, mime_type)
- if thumbnail is None:
- return False
-
- factory.save_thumbnail(thumbnail, uri, mtime)
- return True
-
def get_thumbnail(video_path, list_store, idx):
- file = Gio.File.new_for_path(video_path)
- info = file.query_info("*", Gio.FileQueryInfoFlags.NONE, None)
- thumbnail = info.get_attribute_byte_string("thumbnail::path")
- if thumbnail is not None or generate_thumbnail(video_path):
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(thumbnail, -1, 96)
- list_store[idx][0] = pixbuf
+ try:
+ file = Gio.File.new_for_path(video_path)
+ info = file.query_info("*", Gio.FileQueryInfoFlags.NONE, None)
+ thumbnail = info.get_attribute_byte_string("thumbnail::path")
+ if thumbnail is not None or generate_thumbnail(video_path):
+ if thumbnail is None:
+ file = Gio.File.new_for_path(video_path)
+ info = file.query_info("*", Gio.FileQueryInfoFlags.NONE, None)
+ thumbnail = info.get_attribute_byte_string("thumbnail::path")
+ if thumbnail is not None:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(thumbnail, -1, 96)
+ list_store[idx][0] = pixbuf
+ except Exception as e:
+ logger.warning(f"[Thumbnail] Failed to load for {video_path}: {e}")
+
+
+def get_thumbnail_pixbuf(video_path, size=48):
+ """Return a Pixbuf thumbnail for the video, or None."""
+ try:
+ file = Gio.File.new_for_path(video_path)
+ info = file.query_info("*", Gio.FileQueryInfoFlags.NONE, None)
+ thumbnail = info.get_attribute_byte_string("thumbnail::path")
+ if thumbnail is None:
+ generate_thumbnail(video_path)
+ info = file.query_info("*", Gio.FileQueryInfoFlags.NONE, None)
+ thumbnail = info.get_attribute_byte_string("thumbnail::path")
+ if thumbnail is not None:
+ return GdkPixbuf.Pixbuf.new_from_file_at_size(thumbnail, -1, size)
+ except Exception as e:
+ logger.warning(f"[Thumbnail] Failed for {video_path}: {e}")
+ return None
+
+
+def get_video_duration(video_path):
+ """Return formatted duration string (MM:SS or HH:MM:SS) using ffprobe."""
+ try:
+ output = subprocess.check_output([
+ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration',
+ '-of', 'default=noprint_wrappers=1:nokey=1', video_path
+ ], shell=False, encoding='UTF-8').strip()
+ duration = float(output)
+ hours = int(duration // 3600)
+ minutes = int((duration % 3600) // 60)
+ seconds = int(duration % 60)
+ if hours > 0:
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
+ return f"{minutes:02d}:{seconds:02d}"
+ except Exception:
+ return "--:--"
def debounce(wait_time):
diff --git a/src/player/base_player.py b/src/player/base_player.py
index 4672a39..6faaa2a 100644
--- a/src/player/base_player.py
+++ b/src/player/base_player.py
@@ -79,8 +79,10 @@ def _on_size_changed(self, *args):
for monitor in self.windows:
rect = monitor.get_geometry()
x, y, width, height = rect.x, rect.y, rect.width, rect.height
- monitor.win_resize(width, height)
- monitor.win_move(x, y)
+ window = self.windows[monitor]
+ if window:
+ window.set_size_request(width, height)
+ window.move(x, y)
def _on_monitor_added(self, _, gdk_monitor, *args):
logger.info("[Player] monitor-added")
diff --git a/src/player/video_player.py b/src/player/video_player.py
index e15406b..eca43c3 100644
--- a/src/player/video_player.py
+++ b/src/player/video_player.py
@@ -1,3 +1,4 @@
+import gc
import sys
import glob
import time
@@ -6,11 +7,12 @@
import logging
import pathlib
import subprocess
+import threading
from threading import Timer
import gi
gi.require_version("Gtk", "3.0")
-from gi.repository import Gtk, Gio, Gdk
+from gi.repository import Gtk, Gio, Gdk, GLib
import vlc
from pydbus import SessionBus
@@ -49,16 +51,20 @@ class Fade:
def __init__(self):
self.timer = None
self.is_active = False
+ self._cycle_id = 0
def start(self, cur, target, step, fade_interval, update_callback: callable = None,
complete_callback: callable = None):
- # Cancel any existing timer first
self.cancel()
+ self._cycle_id += 1
+ cycle = self._cycle_id
self.is_active = True
- self._fade_step(cur, target, step, fade_interval, update_callback, complete_callback)
+ self._fade_step(cycle, cur, target, step, fade_interval, update_callback, complete_callback)
- def _fade_step(self, cur, target, step, fade_interval, update_callback, complete_callback):
- if not self.is_active:
+ def _fade_step(self, cycle, cur, target, step, fade_interval, update_callback, complete_callback):
+ if not self.is_active or cycle != self._cycle_id:
+ if cycle != self._cycle_id:
+ logger.debug(f"[Fade] Stale cycle {cycle} ignored (current={self._cycle_id})")
return
new_cur = cur + step
@@ -73,8 +79,8 @@ def _fade_step(self, cur, target, step, fade_interval, update_callback, complete
if update_callback:
update_callback(int(new_cur))
self.timer = Timer(fade_interval, self._fade_step,
- args=[new_cur, target, step, fade_interval, update_callback, complete_callback])
- self.timer.daemon = True # Make timer daemon to prevent blocking shutdown
+ args=[cycle, new_cur, target, step, fade_interval, update_callback, complete_callback])
+ self.timer.daemon = True
self.timer.start()
def cancel(self):
@@ -115,6 +121,9 @@ def cleanup(self):
try:
if self.player:
self.player.stop()
+ media = self.player.get_media()
+ if media is not None:
+ media.release()
self.player.release()
self.player = None
if self.instance:
@@ -142,6 +151,11 @@ def __init__(self, name, width, height, *args, **kwargs):
# A timer that handling fade-in/out
self.fade = Fade()
+ self.fade_opacity = Fade()
+
+ self._pending_crop = None
+ self._crop_retries = 0
+ self._crop_max_retries = 10
self.menu = None
self.connect("button-press-event", self._on_button_press_event)
@@ -182,7 +196,10 @@ def media_new(self, *args):
return self.__vlc_widget.instance.media_new(*args)
def set_media(self, *args):
+ old_media = self.__vlc_widget.player.get_media()
self.__vlc_widget.player.set_media(*args)
+ if old_media is not None:
+ old_media.release()
def set_volume(self, *args):
self.__vlc_widget.player.audio_set_volume(*args)
@@ -231,6 +248,62 @@ def centercrop(self, video_width=None, video_height=None):
logger.debug(f"[CenterCrop] Crop geometry: {crop_geometry}")
self.__vlc_widget.player.video_set_crop_geometry(crop_geometry)
+ def schedule_centercrop(self, video_width, video_height, max_retries=10, interval_ms=150):
+ """Schedule centercrop to be applied once VLC's video output is ready."""
+ self._pending_crop = (video_width, video_height)
+ self._crop_retries = 0
+ self._crop_max_retries = max_retries
+
+ try:
+ em = self.__vlc_widget.player.event_manager()
+ em.event_detach(vlc.EventType.MediaPlayerVout)
+ except Exception:
+ pass
+ try:
+ em = self.__vlc_widget.player.event_manager()
+ em.event_attach(vlc.EventType.MediaPlayerVout, self._on_vout_ready)
+ except Exception as e:
+ logger.debug(f"[CenterCrop] Could not attach Vout event: {e}")
+
+ GLib.timeout_add(interval_ms, self._retry_centercrop)
+
+ def _on_vout_ready(self, event):
+ """Called by VLC when video output is created -- apply pending centercrop."""
+ if self._pending_crop:
+ w, h = self._pending_crop
+ self._pending_crop = None
+ logger.debug(f"[CenterCrop] Vout ready, applying crop {w}x{h}")
+ GLib.idle_add(self.centercrop, w, h)
+
+ def _retry_centercrop(self):
+ """Fallback timer: retry centercrop until VLC reports a valid video size."""
+ if not self._pending_crop:
+ return False
+ if self._crop_retries >= self._crop_max_retries:
+ w, h = self._pending_crop
+ self._pending_crop = None
+ logger.debug(f"[CenterCrop] Max retries reached, forcing crop {w}x{h}")
+ self.centercrop(w, h)
+ return False
+ self._crop_retries += 1
+ w, h = self._pending_crop
+ self.centercrop(w, h)
+ size = self.__vlc_widget.player.video_get_size()
+ if size[0] > 0 and size[1] > 0:
+ logger.debug(f"[CenterCrop] Retry #{self._crop_retries} succeeded (vout size {size[0]}x{size[1]})")
+ self._pending_crop = None
+ return False
+ return True
+
+ def _cancel_pending_crop(self):
+ """Cancel any pending centercrop schedule."""
+ self._pending_crop = None
+ try:
+ em = self.__vlc_widget.player.event_manager()
+ em.event_detach(vlc.EventType.MediaPlayerVout)
+ except Exception:
+ pass
+
def add_audio_track(self, audio):
self.__vlc_widget.player.add_slave(vlc.MediaSlaveType(1), audio, True)
@@ -245,12 +318,66 @@ def _on_button_press_event(self, widget, event):
def get_name(self):
return self.name
+ def get_event_manager(self):
+ return self.__vlc_widget.player.event_manager()
+
+ def stop(self):
+ self.__vlc_widget.player.stop()
+
+ def fade_out_opacity(self, duration=0.5, interval=0.05, callback=None):
+ cur = int(self.get_opacity() * 100)
+ target = 0
+ step = (target - cur) / max(duration / interval, 1)
+ self.fade_opacity.cancel()
+ self.fade_opacity.start(
+ cur=cur, target=target, step=step, fade_interval=interval,
+ update_callback=lambda v: GLib.idle_add(self.set_opacity, max(v / 100.0, 0.0)),
+ complete_callback=callback)
+
+ def fade_in_opacity(self, duration=0.5, interval=0.05, callback=None):
+ cur = int(self.get_opacity() * 100)
+ target = 100
+ step = (target - cur) / max(duration / interval, 1)
+ self.fade_opacity.cancel()
+ self.fade_opacity.start(
+ cur=cur, target=target, step=step, fade_interval=interval,
+ update_callback=lambda v: GLib.idle_add(self.set_opacity, min(v / 100.0, 1.0)),
+ complete_callback=callback)
+
def cleanup(self):
"""Cleanup resources to prevent memory leaks"""
+ self._cancel_pending_crop()
self.fade.cancel()
+ self.fade_opacity.cancel()
if self.__vlc_widget:
self.__vlc_widget.cleanup()
+ def replace_vlc_widget(self):
+ """Replace the VLC widget with a fresh instance.
+ Cleanup of old instance is synchronous to prevent GPU memory leaks --
+ the old VLC decoder must fully release GPU buffers before the new one allocates.
+ """
+ old_widget = self.__vlc_widget
+
+ self._cancel_pending_crop()
+ self.fade.cancel()
+ self.fade_opacity.cancel()
+
+ try:
+ old_widget.cleanup()
+ except Exception as e:
+ logger.warning(f"[PlayerWindow] Old widget cleanup error: {e}")
+
+ self.remove(old_widget)
+ del old_widget
+ gc.collect()
+
+ self.__vlc_widget = VLCWidget(self.width, self.height)
+ self.add(self.__vlc_widget)
+ self.__vlc_widget.show()
+ self.__vlc_widget.player.video_set_mouse_input(False)
+ self.__vlc_widget.player.video_set_key_input(False)
+
class VideoPlayer(BasePlayer):
"""
@@ -315,13 +442,27 @@ def __init__(self, *args, **kwargs):
self.is_any_maximized, self.is_any_fullscreen = False, False
self.is_paused_by_user = False
+ # Playlist state
+ self._playlist_index = 0
+ self._playlist_event_attached = False
+ self._is_transitioning = False
+ self._playlist_wallpaper_set = False
+ self._playlist_dimensions_cache = {}
+ self._last_watchdog_position = -1.0
+ self._media_end_count = 0
+ self._transition_count = 0
+ self._timers_active = False
+
def new_window(self, gdk_monitor):
rect = gdk_monitor.get_geometry()
return PlayerWindow(gdk_monitor.get_model(), rect.width, rect.height, application=self)
def do_activate(self):
super().do_activate()
- self.data_source = self.config[CONFIG_KEY_DATA_SOURCE]
+ if self.mode == MODE_PLAYLIST:
+ self._setup_playlist()
+ else:
+ self.data_source = self.config[CONFIG_KEY_DATA_SOURCE]
def _on_monitor_added(self, _, gdk_monitor, *args):
super()._on_monitor_added(_, gdk_monitor, *args)
@@ -450,7 +591,7 @@ def data_source(self, data_source):
# Only create WindowHandler on X11, not Wayland
self.window_handler = WindowHandler(self._on_window_state_changed)
- if self.config[CONFIG_KEY_STATIC_WALLPAPER] and self.mode == MODE_VIDEO:
+ if self.config[CONFIG_KEY_STATIC_WALLPAPER] and self.mode in (MODE_VIDEO, MODE_PLAYLIST):
self.set_static_wallpaper()
else:
self.set_original_wallpaper()
@@ -492,6 +633,227 @@ def start_playback(self):
window.play_fade(target=self.volume, fade_duration_sec=self.config[CONFIG_KEY_FADE_DURATION_SEC],
fade_interval=self.config[CONFIG_KEY_FADE_INTERVAL])
+ def _probe_video_dimensions(self, video_path):
+ """Get video dimensions via ffprobe, returns (width, height) or (None, None)."""
+ try:
+ dimension = subprocess.check_output([
+ 'ffprobe', '-v', 'error', '-select_streams', 'v:0',
+ '-show_entries', 'stream=width,height', '-of',
+ 'csv=s=x:p=0', video_path
+ ], shell=False, encoding='UTF-8').replace('\n', '')
+ parts = dimension.split("x")
+ return int(parts[0]), int(parts[1])
+ except (subprocess.CalledProcessError, IndexError, ValueError):
+ return None, None
+
+ def _playlist_health_check(self):
+ """Periodic health check logging for playlist diagnostics."""
+ if not self._timers_active:
+ return False
+ try:
+ thread_count = threading.active_count()
+ mem_mb = 0.0
+ try:
+ with open('/proc/self/status', 'r') as f:
+ for line in f:
+ if line.startswith('VmRSS:'):
+ mem_mb = int(line.split()[1]) / 1024
+ break
+ except (OSError, ValueError):
+ import resource
+ mem_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
+ logger.info(
+ f"[Playlist Health] threads={thread_count} rss={mem_mb:.1f}MB "
+ f"index={self._playlist_index} transitioning={self._is_transitioning} "
+ f"media_ends={self._media_end_count} transitions={self._transition_count}"
+ )
+ except Exception as e:
+ logger.debug(f"[Playlist Health] Error collecting stats: {e}")
+ return self._timers_active
+
+ def _setup_playlist(self):
+ """Initialize and start playlist playback."""
+ playlist = self.config.get(CONFIG_KEY_PLAYLIST, [])
+ if not playlist:
+ logger.warning("[Playlist] Empty playlist, falling back to normal video mode")
+ self.data_source = self.config[CONFIG_KEY_DATA_SOURCE]
+ return
+
+ self._playlist_index = 0
+ self._playlist_wallpaper_set = False
+ self._media_end_count = 0
+ self._transition_count = 0
+ self._playlist_dimensions_cache.clear()
+
+ logger.info(f"[Playlist] Starting playlist with {len(playlist)} videos")
+
+ if not self._timers_active:
+ self._timers_active = True
+ GLib.timeout_add_seconds(60, self._playlist_health_check)
+ GLib.timeout_add_seconds(30, self._playlist_watchdog)
+
+ self._playlist_play_current()
+
+ def _playlist_watchdog(self):
+ """Detect stuck VLC player by checking if playback position progresses."""
+ if not self._timers_active:
+ return False
+ try:
+ primary_window = None
+ for monitor, window in self.windows.items():
+ if monitor.is_primary():
+ primary_window = window
+ break
+ if not primary_window:
+ primary_window = next(iter(self.windows.values()), None)
+ if not primary_window:
+ return self._timers_active
+
+ current_pos = primary_window.get_position()
+ if self._is_transitioning:
+ self._last_watchdog_position = current_pos
+ return self._timers_active
+
+ if current_pos == self._last_watchdog_position and current_pos != -1.0:
+ logger.warning(
+ f"[Playlist Watchdog] Player stuck at position {current_pos:.4f} "
+ f"index={self._playlist_index}, forcing advance"
+ )
+ self._last_watchdog_position = -1.0
+ self._on_playlist_media_end(None)
+ else:
+ self._last_watchdog_position = current_pos
+ except Exception as e:
+ logger.debug(f"[Playlist Watchdog] Error: {e}")
+ return self._timers_active
+
+ def _detach_playlist_event(self):
+ """Detach MediaPlayerEndReached from the current primary player's event manager."""
+ if not self._playlist_event_attached:
+ return
+ for monitor, window in self.windows.items():
+ if monitor.is_primary():
+ try:
+ em = window.get_event_manager()
+ em.event_detach(vlc.EventType.MediaPlayerEndReached)
+ except Exception as e:
+ logger.debug(f"[Playlist] event_detach error (expected during cleanup): {e}")
+ break
+ self._playlist_event_attached = False
+
+ def _playlist_play_current(self):
+ """Play the current video in the playlist on all monitors."""
+ playlist = self.config.get(CONFIG_KEY_PLAYLIST, [])
+ if not playlist:
+ return
+
+ video_path = playlist[self._playlist_index]
+ repeat_count = self.config.get(CONFIG_KEY_PLAYLIST_REPEAT_COUNT, 1)
+ logger.info(f"[Playlist] Playing video {self._playlist_index + 1}/{len(playlist)} (repeat={repeat_count}x): {video_path}")
+
+ self.config[CONFIG_KEY_DATA_SOURCE]['Default'] = video_path
+
+ self._detach_playlist_event()
+
+ for monitor, window in self.windows.items():
+ window.replace_vlc_widget()
+
+ for monitor, window in self.windows.items():
+ media = window.media_new(video_path)
+ if repeat_count > 1:
+ media.add_option(f":input-repeat={repeat_count - 1}")
+ if not monitor.is_primary():
+ media.add_option("no-audio")
+ window.set_media(media)
+
+ for monitor, window in self.windows.items():
+ if monitor.is_primary():
+ em = window.get_event_manager()
+ em.event_attach(vlc.EventType.MediaPlayerEndReached, self._on_playlist_media_end)
+ self._playlist_event_attached = True
+ break
+
+ self.volume = self.config[CONFIG_KEY_VOLUME]
+ self.is_mute = self.config[CONFIG_KEY_MUTE]
+ self.start_playback()
+
+ cached = self._playlist_dimensions_cache.get(video_path)
+ if cached:
+ video_width, video_height = cached
+ if video_width and video_height:
+ for monitor, window in self.windows.items():
+ window.schedule_centercrop(video_width, video_height)
+ else:
+ self._probe_and_apply_centercrop(video_path)
+
+ if not self.active_handler:
+ self.active_handler = ActiveHandler(self._on_active_changed)
+ if not self.window_handler and not is_wayland():
+ self.window_handler = WindowHandler(self._on_window_state_changed)
+
+ if self.config[CONFIG_KEY_STATIC_WALLPAPER] and not self._playlist_wallpaper_set:
+ self._playlist_wallpaper_set = True
+ t = threading.Thread(target=self.set_static_wallpaper, daemon=True)
+ t.start()
+ elif not self.config[CONFIG_KEY_STATIC_WALLPAPER] and not self._playlist_wallpaper_set:
+ self.set_original_wallpaper()
+
+ def _probe_and_apply_centercrop(self, video_path):
+ """Probe video dimensions in background thread and apply centercrop via GTK main thread."""
+ def _probe_worker():
+ w, h = self._probe_video_dimensions(video_path)
+ self._playlist_dimensions_cache[video_path] = (w, h)
+ if w and h:
+ GLib.idle_add(self._apply_centercrop, w, h)
+
+ t = threading.Thread(target=_probe_worker, daemon=True)
+ t.start()
+
+ def _apply_centercrop(self, video_width, video_height):
+ """Apply centercrop to all windows (must be called from GTK main thread)."""
+ for monitor, window in self.windows.items():
+ window.schedule_centercrop(video_width, video_height)
+ return False
+
+ def _on_playlist_media_end(self, event):
+ """VLC callback when a video finishes. Runs in VLC thread, dispatches to GTK main thread."""
+ self._media_end_count += 1
+ logger.info(f"[Playlist] MediaEnd #{self._media_end_count} (transitioning={self._is_transitioning})")
+ GLib.idle_add(self._playlist_advance)
+
+ def _playlist_advance(self):
+ """Advance the playlist to the next video (VLC handles repeats via input-repeat)."""
+ playlist = self.config.get(CONFIG_KEY_PLAYLIST, [])
+ if not playlist:
+ return False
+
+ self._playlist_index = (self._playlist_index + 1) % len(playlist)
+ logger.info(
+ f"[Playlist] Advance: index={self._playlist_index}/{len(playlist)} "
+ f"transitioning={self._is_transitioning} threads={threading.active_count()}"
+ )
+ self._playlist_transition_to_next()
+
+ return False
+
+ def _playlist_transition_to_next(self):
+ """Switch to the next video instantly (no fade effect)."""
+ if self._is_transitioning:
+ logger.warning("[Playlist] Transition skipped: already transitioning")
+ return
+ self._transition_count += 1
+ self._is_transitioning = True
+ logger.info(f"[Playlist] Transition #{self._transition_count} starting (index={self._playlist_index})")
+
+ try:
+ self._playlist_play_current()
+ logger.info(f"[Playlist] Transition #{self._transition_count} complete (index={self._playlist_index})")
+ except Exception as e:
+ logger.error(f"[Playlist] Transition error: {e}", exc_info=True)
+ finally:
+ self._is_transitioning = False
+
+
def monitor_sync(self):
primary_monitor = None
for monitor, window in self.windows.items():
@@ -531,10 +893,10 @@ def set_static_wallpaper(self):
'-vframes', '1', static_wallpaper_path
], shell=False, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT)
if ret.returncode == 0 and os.path.isfile(static_wallpaper_path):
- blur_wallpaper = Image.open(static_wallpaper_path)
- blur_wallpaper = blur_wallpaper.filter(
- ImageFilter.GaussianBlur(self.config["static_wallpaper_blur_radius"]))
- blur_wallpaper.save(static_wallpaper_path)
+ with Image.open(static_wallpaper_path) as img:
+ blurred = img.filter(
+ ImageFilter.GaussianBlur(self.config["static_wallpaper_blur_radius"]))
+ blurred.save(static_wallpaper_path)
static_wallpaper_uri = pathlib.Path(
static_wallpaper_path).resolve().as_uri()
if is_flatpak():
@@ -577,9 +939,9 @@ def reload_config(self):
self.config = ConfigUtil().load()
def quit_player(self):
+ self._timers_active = False
self.set_original_wallpaper()
- # Cleanup handlers
if self.active_handler:
self.active_handler.cleanup()
self.active_handler = None
@@ -588,15 +950,19 @@ def quit_player(self):
self.window_handler.cleanup()
self.window_handler = None
- # Cleanup all windows
for monitor, window in self.windows.items():
if window:
window.cleanup()
+
+ gc.collect()
super().quit_player()
def main():
+ logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(name)s:%(message)s')
+ logger.info("[Player] Process starting")
+
bus = SessionBus()
app = VideoPlayer()
try:
diff --git a/src/server.py b/src/server.py
index 24d2aad..af9dbb6 100644
--- a/src/server.py
+++ b/src/server.py
@@ -1,3 +1,4 @@
+import json
import logging
import random
import signal
@@ -45,6 +46,10 @@ class HidamariServer(object):
+
+
+
+
@@ -117,7 +122,9 @@ def _setup_player(self, mode, data_source=None, monitor=None):
# Set data source if specified
if data_source and monitor:
self.config[CONFIG_KEY_DATA_SOURCE][monitor] = data_source
- self.config[CONFIG_KEY_DATA_SOURCE]['Default'] = data_source # always update default source
+ self.config[CONFIG_KEY_DATA_SOURCE]['Default'] = data_source
+
+ self._save_config()
# Quit current then create a new player
self._quit_player()
@@ -132,7 +139,7 @@ def _setup_player(self, mode, data_source=None, monitor=None):
self.player_process.join(timeout=2)
self.player_process = None
- if mode in [MODE_VIDEO, MODE_STREAM]:
+ if mode in [MODE_VIDEO, MODE_STREAM, MODE_PLAYLIST]:
self.player_process = Process(
name=f"hidamari-player-{self._player_count}", target=video_player_main)
elif mode == MODE_WEBPAGE:
@@ -176,6 +183,14 @@ def stream(self, stream_url=None):
def webpage(self, webpage_url=None):
self._setup_player(MODE_WEBPAGE, webpage_url)
+ def playlist(self, playlist_json, repeat_count):
+ video_list = json.loads(playlist_json)
+ self.config[CONFIG_KEY_PLAYLIST] = video_list
+ self.config[CONFIG_KEY_PLAYLIST_REPEAT_COUNT] = repeat_count
+
+ first_video = video_list[0] if video_list else None
+ self._setup_player(MODE_PLAYLIST, first_video)
+
@staticmethod
def pause_playback():
player = get_instance(DBUS_NAME_PLAYER)
@@ -195,6 +210,10 @@ def reload(self):
self.stream()
elif self.config[CONFIG_KEY_MODE] == MODE_WEBPAGE:
self.webpage()
+ elif self.config[CONFIG_KEY_MODE] == MODE_PLAYLIST:
+ playlist_data = self.config.get(CONFIG_KEY_PLAYLIST, [])
+ repeat_count = self.config.get(CONFIG_KEY_PLAYLIST_REPEAT_COUNT, 1)
+ self.playlist(json.dumps(playlist_data), repeat_count)
elif self.config[CONFIG_KEY_MODE] == MODE_NULL:
pass
else:
@@ -287,14 +306,14 @@ def is_playing(self):
@property
def is_paused_by_user(self):
player = get_instance(DBUS_NAME_PLAYER)
- if player is not None and player.mode in [MODE_VIDEO, MODE_STREAM]:
+ if player is not None and player.mode in [MODE_VIDEO, MODE_STREAM, MODE_PLAYLIST]:
return player.is_paused_by_user
return None
@is_paused_by_user.setter
def is_paused_by_user(self, is_paused_by_user):
player = get_instance(DBUS_NAME_PLAYER)
- if player is not None and player.mode in [MODE_VIDEO, MODE_STREAM]:
+ if player is not None and player.mode in [MODE_VIDEO, MODE_STREAM, MODE_PLAYLIST]:
player.is_paused_by_user = is_paused_by_user
@property
diff --git a/src/utils.py b/src/utils.py
index 5fce19c..b91e4c3 100644
--- a/src/utils.py
+++ b/src/utils.py
@@ -462,7 +462,13 @@ def _migrateV3To4(self, config: dict):
del config["is_detect_maximized"]
config['is_mute_when_maximized'] = CONFIG_TEMPLATE[CONFIG_KEY_MUTE_WHEN_MAXIMIZED]
config['version'] = 4
- # save config file
+ self.save(config)
+
+ def _migrateV4To5(self, config: dict):
+ logger.debug(f"[Config] Migration from version 4 to 5.")
+ config[CONFIG_KEY_PLAYLIST] = CONFIG_TEMPLATE[CONFIG_KEY_PLAYLIST]
+ config[CONFIG_KEY_PLAYLIST_REPEAT_COUNT] = CONFIG_TEMPLATE[CONFIG_KEY_PLAYLIST_REPEAT_COUNT]
+ config['version'] = 5
self.save(config)
def _checkMissingMonitors(self, old_config: dict, template: dict):
@@ -482,21 +488,20 @@ def _createMissingMonitors(self, keys: set, config: dict):
self.save(config)
def _checkDefaultSource(self, config: dict):
- # Check if the 'Default' source is empty
- default_source = config['data_source'].get('Default', '')
mode = config.get('mode')
+ if mode in (MODE_PLAYLIST, MODE_STREAM, MODE_WEBPAGE):
+ return
+
+ default_source = config['data_source'].get('Default') or ''
if mode == MODE_VIDEO and not os.path.isfile(default_source):
logger.warning("[Config] Default source is empty or not a valid file. Setting to the first on available.")
- # Get all values from the 'data_source' dictionary
values = list(config['data_source'].values())
- # If there are no values in 'data_source', return early
if not values:
return
- # Set the 'Default' source to the first value available
for value in values:
- if len(value) > 0 and os.path.isfile(value):
+ if value and os.path.isfile(value):
config['data_source']['Default'] = value
self.save(config)
break
@@ -510,6 +515,8 @@ def load(self):
# migration to version 4 for data_source type change
if config.get("version") <= 3 and CONFIG_VERSION >= 4:
self._migrateV3To4(config)
+ if config.get("version") == 4 and CONFIG_VERSION >= 5:
+ self._migrateV4To5(config)
self._checkDefaultSource(config)
self._checkMissingMonitors(config, CONFIG_TEMPLATE)
if self._check(config):