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 + + 1 + 100 + 1 + 1 + 5 + 20 1 @@ -428,16 +435,45 @@ Videos located in Hidamari folder - - Apply + True - True - True - end - app.local_video_apply - + False + 8 + + + Add All to Playlist + True + True + True + Add all videos to the playlist + start + app.add_all_to_playlist + + + False + True + 0 + + + + + Apply + True + True + True + end + app.local_video_apply + + + + True + True + end + 1 + + False @@ -452,6 +488,236 @@ Videos located in Hidamari folder Local Video + + + True + False + vertical + 8 + + + True + False + 8 + + + True + False + <span size="x-large"><b>Playlist</b></span> +Rotate videos on all monitors. +To add videos, right-click on a video in the Local Video tab. + True + 0 + + + True + True + 0 + + + + + True + True + True + Clear playlist + center + center + app.playlist_clear + + + True + False + edit-clear-all-symbolic + + + + + False + True + end + 1 + + + + + True + True + True + Remove selected video + center + center + app.playlist_remove + + + True + False + list-remove-symbolic + + + + + False + True + end + 2 + + + + + True + True + True + Move up + center + center + app.playlist_move_up + + + True + False + go-up-symbolic + + + + + False + True + end + 2 + + + + + True + True + True + Move down + center + center + app.playlist_move_down + + + True + False + go-down-symbolic + + + + + False + True + end + 3 + + + + + False + True + 0 + + + + + True + True + in + + + True + True + True + True + + + + + True + True + 1 + + + + + False + False + <i>Unsaved changes. Click "Apply Playlist" to save.</i> + True + start + + + False + True + end + 2 + + + + + True + False + 8 + + + True + False + Repeat count per video + + + False + True + 0 + + + + + True + True + AdjustmentPlaylistRepeat + True + 1 + + + False + True + 1 + + + + + Apply Playlist + True + True + True + end + app.playlist_apply + + + + True + True + end + 2 + + + + + False + True + end + 3 + + + + + playlist + Playlist + 1 + + True @@ -589,7 +855,7 @@ Videos from streaming services stream Streaming - 1 + 2 @@ -788,7 +1054,7 @@ Set a web page as wallpaper webpage Web Page - 2 + 3 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):