Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6903541
feature: add playlist mode with repeat count for all monitors
rhafaelcm Mar 19, 2026
4c82608
fix: resolve TypeError in config load and improve playlist UX
rhafaelcm Mar 20, 2026
c7aa0e1
feat: enable VA-API hardware acceleration in Flatpak
rhafaelcm Mar 20, 2026
692ca64
feat: smooth playlist transitions, thumbnails/duration in UI, add-all…
rhafaelcm Mar 20, 2026
5b46ad3
fix: resolve freezing during playlist transitions
rhafaelcm Mar 20, 2026
60740b2
fix: handle thumbnail generation errors gracefully
rhafaelcm Mar 20, 2026
6b8f46b
feat: add clear playlist button and unsaved changes warning
rhafaelcm Mar 20, 2026
186e572
fix: resolve playlist freezing caused by Fade timer race condition an…
rhafaelcm Mar 20, 2026
7398aba
fix: resolve playlist freeze caused by blocking ffprobe and config race
rhafaelcm Mar 20, 2026
7962ffb
fix: use VLC input-repeat to prevent VA-API decoder leak on playlist …
rhafaelcm Mar 20, 2026
9d33b14
fix: stop players before loading new media and add stuck-video watchdog
rhafaelcm Mar 20, 2026
8462add
fix: remove explicit stop() that caused GTK main thread deadlock
rhafaelcm Mar 20, 2026
134880a
refactor: remove fade transition effect from playlist video switching
rhafaelcm Mar 20, 2026
2eff7a0
fix: recreate VLCWidget per playlist transition to prevent deadlock
rhafaelcm Mar 20, 2026
43b56f8
feat: open GUI on the tab matching the active playback mode
rhafaelcm Mar 20, 2026
350e46e
fix: prevent GPU memory leak during playlist transitions
rhafaelcm Mar 20, 2026
10ef8ff
fix: apply centercrop after VLC vout is ready to prevent intermittent…
rhafaelcm Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ build/
# Project exclude paths
/venv/
.flatpak/
*.flatpak
*.flatpak
.flatpak-builder/
12 changes: 9 additions & 3 deletions io.github.jeffshee.Hidamari.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
}
288 changes: 277 additions & 11 deletions src/assets/control.ui

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src/commons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand All @@ -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:
Expand Down
220 changes: 216 additions & 4 deletions src/gui/control.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import sys
import logging
import threading
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand All @@ -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"
Expand Down
Loading