-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathmain.py
More file actions
1388 lines (1143 loc) · 56.4 KB
/
main.py
File metadata and controls
1388 lines (1143 loc) · 56.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import shutil
from src.backend.PluginManager.ActionBase import ActionBase
from src.backend.PluginManager.ActionHolder import ActionHolder
from src.backend.PluginManager.PluginBase import PluginBase
from src.backend.DeckManagement.InputIdentifier import Input
from src.backend.PluginManager.ActionInputSupport import ActionInputSupport
from src.backend.DeckManagement.DeckController import BackgroundImage, DeckController
from src.backend.PageManagement.Page import Page
# Import gtk modules
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Adw, GLib
import sys
import os
import io
from PIL import Image, ImageEnhance, ImageOps
import globals as gl
# Load our submodules
plugin_dir = os.path.dirname(__file__)
sys.path.insert(0, plugin_dir)
from settings import PluginSettings, KEY_LOG_LEVEL, DEFAULT_LOG_LEVEL, KEY_COMPOSITE_TIMEOUT, DEFAULT_COMPOSITE_TIMEOUT
from log_wrapper import log, set_log_level
from MediaController import MediaController
from MediaAction import MediaAction
class Play(MediaAction):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_key_down(self):
status = self.plugin_base.mc.status(self.get_player_name())
if status is None or status[0] != "Playing":
self.plugin_base.mc.play(self.get_player_name())
def on_key_up(self):
pass
def on_tick(self):
self.update_image()
def on_ready(self):
self.update_image()
def update_image(self):
if self.get_settings() == None:
# Page not yet fully loaded
return
status = self.plugin_base.mc.status(self.get_player_name())
if isinstance(status, list):
status = status[0]
if self.show_title():
size = 0.75
valign = -1
else:
size = 1
valign = 0
icon_path = os.path.join(self.plugin_base.PATH, "assets", "play.png")
if status == None:
if self.current_status == None:
self.current_status = "Playing"
image = Image.open(icon_path)
enhancer = ImageEnhance.Brightness(image)
image = enhancer.enhance(0.6)
self.set_media(image=image, size=size, valign=valign)
return
self.current_status = status
## Thumbnail
thumbnail = None
if self.get_settings().setdefault("show_thumbnail", True):
thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name())
if thumbnail == None:
thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0))
elif isinstance(thumbnail, list):
if thumbnail[0] == None:
return
if isinstance(thumbnail[0], io.BytesIO):
pass
elif not os.path.exists(thumbnail[0]):
return
try:
thumbnail = Image.open(thumbnail[0])
except:
return
image = Image.open(icon_path)
if status == "Playing":
enhancer = ImageEnhance.Brightness(image)
image = enhancer.enhance(0.6)
image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign)
self.set_media(image=image)
class Pause(MediaAction):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_key_down(self):
status = self.plugin_base.mc.status(self.get_player_name())
if status is None or status[0] == "Playing":
self.plugin_base.mc.pause(self.get_player_name())
def on_key_up(self):
pass
def on_tick(self):
self.update_image()
def on_ready(self):
self.update_image()
def update_image(self):
if self.get_settings() == None:
# Page not yet fully loaded
return
status = self.plugin_base.mc.status(self.get_player_name())
if isinstance(status, list):
status = status[0]
if self.show_title():
size = 0.75
valign = -1
else:
size = 1
valign = 0
icon_path = os.path.join(self.plugin_base.PATH, "assets", "pause.png")
if status == None:
if self.current_status == None:
self.current_status = "Playing"
image = Image.open(icon_path)
enhancer = ImageEnhance.Brightness(image)
image = enhancer.enhance(0.6)
self.set_media(image=image, size=size, valign=valign)
return
self.current_status = status
## Thumbnail
thumbnail = None
if self.get_settings().setdefault("show_thumbnail", True):
thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name())
if thumbnail == None:
thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0))
elif isinstance(thumbnail, list):
if thumbnail[0] == None:
return
if isinstance(thumbnail[0], io.BytesIO):
pass
elif not os.path.exists(thumbnail[0]):
return
try:
thumbnail = Image.open(thumbnail[0])
except:
return
image = Image.open(icon_path)
if status == "Paused":
enhancer = ImageEnhance.Brightness(image)
image = enhancer.enhance(0.6)
image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign)
self.set_media(image=image)
class PlayPause(MediaAction):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_key_down(self):
status = self.plugin_base.mc.status(self.get_player_name())
if status is None:
return
status = status[0]
if status == "Playing":
self.plugin_base.mc.pause(self.get_player_name())
else:
self.plugin_base.mc.play(self.get_player_name())
def on_key_up(self):
pass
def on_tick(self):
self.update_image()
def on_ready(self):
self.update_image()
def update_image(self):
if self.get_settings() == None:
# Page not yet fully loaded
return
status = self.plugin_base.mc.status(self.get_player_name())
if isinstance(status, list):
status = status[0]
file = {
"Playing": os.path.join(self.plugin_base.PATH, "assets", "pause.png"),
"Paused": os.path.join(self.plugin_base.PATH, "assets", "play.png"),
"Stopped": os.path.join(self.plugin_base.PATH, "assets", "stop.png"), #play.png might make more sense
}
if self.show_title():
size = 0.75
valign = -1
else:
size = 1
valign = 0
if status == None:
if self.current_status == None:
self.current_status = "Playing"
file_path = file[self.current_status]
image = Image.open(file_path)
enhancer = ImageEnhance.Brightness(image)
image = enhancer.enhance(0.6)
self.set_media(image=image, size=size, valign=valign)
return
self.current_status = status
## Thumbnail
thumbnail = None
if self.get_settings().setdefault("show_thumbnail", True):
thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name())
if thumbnail == None:
thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0))
elif isinstance(thumbnail, list):
if thumbnail[0] == None:
return
if isinstance(thumbnail[0], io.BytesIO):
pass
elif not os.path.exists(thumbnail[0]):
return
try:
thumbnail = Image.open(thumbnail[0])
except:
return
image = Image.open(file.get(status, file["Stopped"]))
image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign)
self.set_media(image=image)
class Next(MediaAction):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_ready(self):
self.update_image()
def on_key_down(self):
self.plugin_base.mc.next(self.get_player_name())
def on_tick(self):
self.update_image()
def update_image(self):
status = self.plugin_base.mc.status(self.get_player_name())
if isinstance(status, list):
status = status[0]
if self.show_title():
size = 0.75
valign = -1
else:
size = 1
valign = 0
image = Image.open(os.path.join(self.plugin_base.PATH, "assets", "next.png"))
if status == None:
enhancer = ImageEnhance.Brightness(image)
image = enhancer.enhance(0.6)
thumbnail = None
if self.get_settings() is None:
return
if self.get_settings().setdefault("show_thumbnail", True):
thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name())
if thumbnail == None:
thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0))
elif isinstance(thumbnail, list):
try:
thumbnail = Image.open(thumbnail[0])
except:
return
image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign)
self.set_media(image=image)
class Previous(MediaAction):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_ready(self):
self.update_image()
def on_key_down(self):
self.plugin_base.mc.previous(self.get_player_name())
def on_tick(self):
self.update_image()
def update_image(self):
status = self.plugin_base.mc.status(self.get_player_name())
if isinstance(status, list):
status = status[0]
if self.show_title():
size = 0.75
valign = -1
else:
size = 1
valign = 0
image = Image.open(os.path.join(self.plugin_base.PATH, "assets", "previous.png"))
if status == None:
enhancer = ImageEnhance.Brightness(image)
image = enhancer.enhance(0.6)
thumbnail = None
if self.get_settings() is None:
return
if self.get_settings().setdefault("show_thumbnail", True):
thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name())
if thumbnail == None:
thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0))
elif isinstance(thumbnail, list):
try:
thumbnail = Image.open(thumbnail[0])
except:
return
image = self.generate_image(background=thumbnail, icon=image, size=size, valign=valign)
self.set_media(image=image)
class Info(MediaAction):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def on_tick(self):
self.update_image()
def update_image(self):
title = self.plugin_base.mc.title(self.get_player_name())
artist = self.plugin_base.mc.artist(self.get_player_name())
if title is not None:
title = self.shorten_label(title[0], 10)
if title is not None:
artist = self.shorten_label(artist[0], 10)
if self.get_settings() is None:
return
self.set_top_label(str(title), font_size=12)
self.set_center_label(self.get_settings().get("seperator_text", "--"), font_size=12)
self.set_bottom_label(str(artist), font_size=12)
## Thumbnail
thumbnail = None
if self.get_settings().setdefault("show_thumbnail", True):
thumbnail = self.plugin_base.mc.thumbnail(self.get_player_name())
if thumbnail == None:
thumbnail = Image.new("RGBA", (256, 256), (255, 255, 255, 0))
elif isinstance(thumbnail, list):
if thumbnail[0] == None:
return
if isinstance(thumbnail[0], io.BytesIO):
pass
elif not os.path.exists(thumbnail[0]):
return
try:
thumbnail = Image.open(thumbnail[0])
except:
return
self.set_media(image=thumbnail)
def get_config_rows(self):
super_rows = super().get_config_rows()
super_rows.pop(1) # Remove label toggle row
self.seperator_text_entry = Adw.EntryRow(title=self.plugin_base.lm.get("actions.info.seperator.text"))
self.load_own_config_defaults()
self.seperator_text_entry.connect("notify::text", self.on_change_seperator_text)
return super_rows + [self.seperator_text_entry]
def load_own_config_defaults(self):
settings = self.get_settings()
settings.setdefault("seperator_text", "--")
self.set_settings(settings)
# Update ui
self.seperator_text_entry.set_text(settings["seperator_text"])
def on_change_seperator_text(self, entry, *args):
settings = self.get_settings()
settings["seperator_text"] = entry.get_text()
self.set_settings(settings)
# Update image
self.set_center_label(self.get_settings().get("seperator_text", "--"), font_size=12)
class ThumbnailBackground(MediaAction):
"""
Media action that renders one or more thumbnail images onto the deck background.
This class coordinates multiple `ThumbnailBackground` actions on the same page to
produce a single composited background image. It uses several class-level caches
and flags to avoid redundant work and reduce flicker:
* Action list cache: `_cached_actions` and `_cached_page_id` cache the set of
thumbnail actions for the current page so that repeated lookups are avoided.
* Background cache: `_original_background_image` and `_cached_background_path`
store the unmodified background so it can be reused while layering thumbnails
on top, instead of reloading or recomputing it for every instance.
* Batched compositing: `_pending_composite`, `_composite_in_progress`, and
`_idle_composite_id` implement a batched composite pattern where changes from
multiple actions are coalesced and applied once via a GLib idle callback.
Each instance tracks its own thumbnail path, size/placement mode, and last
contribution to the composite (`rendered_thumbnail`, `is_dirty`, etc.) so that
only changed thumbnails trigger a recomposite. The actual update of the deck
background is thus performed once per batch rather than once per action.
"""
# Class-level cache for action list optimization
_cached_actions = None # Cached list of all thumbnail actions
_cached_page_id = None # ID of page for which actions are cached
# Class-level coordinator for batched updates
_pending_composite = False # Flag indicating composite is needed
_composite_in_progress = False # Prevent recursive compositing
_idle_composite_id = None # GLib idle callback ID for deferred execution
# Class-level background cache (shared by all actions)
_original_background_image = None # Cached original background
_cached_background_path = None # Track which background is cached
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optimization: Track state to detect changes
self.last_thumbnail_path = None
self.last_size_mode = None
self.last_background_path = None
self.last_coords = None # Track position for grid modes
# Track rendering state for multi-thumbnail coordination
self.rendered_thumbnail = None # Store our rendered thumbnail for compositing
self.is_dirty = False # Flag indicating this action needs recompositing
def _get_all_thumbnail_actions(self):
"""Get all ThumbnailBackground actions on the current page, sorted by type and position."""
if not hasattr(self, 'page') or self.page is None:
return [self]
# Use page object id as cache key
current_page_id = id(self.page)
# Return cached list if valid
if (ThumbnailBackground._cached_actions is not None and
ThumbnailBackground._cached_page_id == current_page_id):
return ThumbnailBackground._cached_actions
# Cache miss - rebuild the list
actions = []
try:
# Iterate through all action objects on the page
for input_type in self.page.action_objects.values():
for identifier in input_type.values():
for state in identifier.values():
for action in state.values():
if isinstance(action, ThumbnailBackground):
actions.append(action)
except Exception as e:
log.error(f"Failed to collect all thumbnail actions while iterating through page.action_objects hierarchy: {e}")
return [self]
# If no actions found, at least include self (shouldn't happen if self is properly in action_objects)
if not actions:
log.warning("ThumbnailBackground: No thumbnail actions found on page, falling back to [self]")
return [self]
# Sort by layering order: Fill -> Stretch -> Grid (top-left to bottom-right)
def get_sort_key(action):
settings = action.get_settings()
size_mode = settings.get("size_mode", "stretch") if settings else "stretch"
# Priority order: fill=0, stretch=1, grid modes=2
if size_mode == "fill":
priority = 0
elif size_mode == "stretch":
priority = 1
else: # Grid modes (1x1, 2x2, 3x3, 4x4)
priority = 2
# Within same priority, sort by position (row, col)
if hasattr(action.input_ident, 'coords'):
row, col = action.input_ident.coords
return (priority, row, col)
# Handle badly configured actions without coordinates
return (priority, float("inf"), float("inf"))
actions.sort(key=get_sort_key)
# Cache the result
ThumbnailBackground._cached_actions = actions
ThumbnailBackground._cached_page_id = current_page_id
return actions
def _request_composite(self):
"""Request a composite operation. Will be batched with other requests."""
coords = self.input_ident.coords if hasattr(self.input_ident, 'coords') else None # type: ignore[attr-defined]
log.trace(f"ThumbnailBackground: _request_composite called by [{coords}] with is_dirty state: [{self.is_dirty}]")
# Mark this action as dirty
self.is_dirty = True
# Set the pending flag
ThumbnailBackground._pending_composite = True
# Cancel any existing timeout and schedule a new one
# Use a small delay to allow all actions in current tick cycle to update
if ThumbnailBackground._idle_composite_id is not None:
log.trace("ThumbnailBackground: _request_composite - cancelling existing timeout")
try:
GLib.source_remove(ThumbnailBackground._idle_composite_id)
except (OSError, ValueError):
pass # Timeout may have already fired or invalid ID
timeout = self.plugin_base.get_settings().get(KEY_COMPOSITE_TIMEOUT, DEFAULT_COMPOSITE_TIMEOUT)
log.trace(f"ThumbnailBackground: _request_composite - scheduling {timeout}ms timeout")
ThumbnailBackground._idle_composite_id = GLib.timeout_add(
timeout, # milliseconds
self._execute_composite_callback
)
def _execute_composite_callback(self):
"""Callback for GLib.timeout that executes the composite."""
log.trace("ThumbnailBackground: _execute_composite_callback - timeout fired")
# Clear the idle callback ID
ThumbnailBackground._idle_composite_id = None
try:
# Execute the composite
self._execute_composite_if_needed()
except Exception as e:
# Ensure we always reset the in_progress flag even if something goes wrong
ThumbnailBackground._composite_in_progress = False
log.error(f"ThumbnailBackground: Exception in _execute_composite_callback: {e}", exc_info=True)
# Return False to prevent this callback from being called again
return False
def _execute_composite_if_needed(self):
"""Execute composite if pending and not already in progress."""
log.trace(f"ThumbnailBackground: _execute_composite_if_needed - pending={ThumbnailBackground._pending_composite}, in_progress={ThumbnailBackground._composite_in_progress}")
# Check if composite is needed and not already running
if not ThumbnailBackground._pending_composite:
log.trace("ThumbnailBackground: _execute_composite_if_needed - not pending, returning")
return
if ThumbnailBackground._composite_in_progress:
log.trace("ThumbnailBackground: _execute_composite_if_needed - already in progress, returning")
return
# Mark as in progress to prevent recursion
ThumbnailBackground._composite_in_progress = True
ThumbnailBackground._pending_composite = False
try:
# Get all thumbnail actions on the page
all_actions = self._get_all_thumbnail_actions()
# Check if there are any actions with rendered thumbnails
actions_with_thumbnails = [a for a in all_actions if a.rendered_thumbnail is not None]
dirty_actions = [action for action in all_actions if action.is_dirty]
log.trace(f"ThumbnailBackground: _execute_composite_if_needed - {len(dirty_actions)} dirty actions, {len(actions_with_thumbnails)} with thumbnails, {len(all_actions)} total")
# If no actions have thumbnails to display, reload the page to restore the original background
if not actions_with_thumbnails:
log.trace("ThumbnailBackground: _execute_composite_if_needed - no thumbnails to display, reloading page to restore background")
# Clear dirty flags first
for action in all_actions:
action.is_dirty = False
# Trigger a page reload to restore the original background
if hasattr(self, 'page') and self.page is not None:
self.page.reload_similar_pages(reload_self=True)
return
if dirty_actions:
log.trace("ThumbnailBackground: _execute_composite_if_needed - calling _composite_all_thumbnails")
composite = None
try:
composite = self._composite_all_thumbnails()
# Apply the composite to the deck background
log.trace("ThumbnailBackground: _execute_composite_if_needed - applying composite to deck background")
self.deck_controller.background.set_image(
image=BackgroundImage(self.deck_controller, image=composite), # type: ignore[attr-defined]
update=True
)
log.trace("ThumbnailBackground: _execute_composite_if_needed - composite applied, clearing dirty flags")
finally:
# Always close the composite image to prevent memory leaks
if composite is not None:
try:
composite.close()
except Exception as e:
log.error(f"Failed to close composite image: {e}")
# Clear all dirty flags
for action in all_actions:
action.is_dirty = False
else:
log.trace("ThumbnailBackground: _execute_composite_if_needed - no dirty actions, skipping")
finally:
ThumbnailBackground._composite_in_progress = False
log.trace("ThumbnailBackground: _execute_composite_if_needed - complete")
def _composite_all_thumbnails(self):
"""Composite all thumbnail actions onto the base background."""
log.trace("ThumbnailBackground: _composite_all_thumbnails - starting")
full_width, full_height, _, _, _, _ = self.get_deck_dimensions()
# Start with the base background
log.trace("ThumbnailBackground: _composite_all_thumbnails - getting original background")
composite = self.get_original_background(full_width, full_height)
try:
# Layer each thumbnail action's rendered image
all_actions = self._get_all_thumbnail_actions()
actions_with_thumbnails = [a for a in all_actions if a.rendered_thumbnail is not None]
log.trace(f"ThumbnailBackground: _composite_all_thumbnails - compositing {len(actions_with_thumbnails)} thumbnails")
for action in all_actions:
if action.rendered_thumbnail is not None:
try:
# Ensure the thumbnail is in RGBA mode and matches the composite size
thumb = action.rendered_thumbnail
if thumb.mode != "RGBA":
thumb = thumb.convert("RGBA")
if thumb.size != composite.size:
thumb = thumb.resize(composite.size, Image.Resampling.LANCZOS)
# Use alpha_composite for proper RGBA compositing
composite.alpha_composite(thumb, (0, 0))
except Exception as e:
log.error(f"Failed to composite thumbnail: {e}")
log.trace("ThumbnailBackground: _composite_all_thumbnails - complete")
return composite
except Exception as e:
# If something goes wrong, clean up and re-raise
log.error(f"Unexpected error in _composite_all_thumbnails: {e}", exc_info=True)
try:
composite.close()
except Exception:
pass
raise
def _should_update(self) -> bool:
"""Check if update is needed based on state changes."""
# Check if media is playing
title = self.plugin_base.mc.title(self.get_player_name()) # type: ignore[attr-defined]
artist = self.plugin_base.mc.artist(self.get_player_name()) # type: ignore[attr-defined]
# If both title and artist are None, no media is playing
if title is None and artist is None:
# Check if we were previously showing a thumbnail
if self.last_thumbnail_path is not None:
return True
return False
# Get current settings
settings = self.get_settings()
if settings is None:
log.trace("ThumbnailBackground: No settings available, skipping update check")
return False
# Compare size mode change
size_mode = settings.get("size_mode", "stretch")
if size_mode != self.last_size_mode:
log.trace(f"ThumbnailBackground: Size mode changed from {self.last_size_mode} to {size_mode}")
return True
# Compare position
current_coords = self.input_ident.coords if hasattr(self.input_ident, 'coords') else None # type: ignore[attr-defined]
if current_coords != self.last_coords:
log.trace(f"ThumbnailBackground: Position changed from {self.last_coords} to {current_coords}")
return True
# Compare thumbnail path
thumbnail_path = self._get_thumbnail_path()
if thumbnail_path != self.last_thumbnail_path:
log.trace(f"ThumbnailBackground: Thumbnail path changed from {self.last_thumbnail_path} to {thumbnail_path}")
return True
# Compare background path
current_bg_path = self.get_background_path()
if current_bg_path != self.last_background_path:
log.trace(f"ThumbnailBackground: Background path changed from {self.last_background_path} to {current_bg_path}")
return True
# No relevant changes detected
return False
def _get_thumbnail_path(self) -> str | None:
"""
Extract the thumbnail file path from the media controller's thumbnail data.
Returns None if no thumbnail is available or if the data format is unexpected.
"""
try:
thumbnail_data = self.plugin_base.mc.thumbnail(self.get_player_name()) # type: ignore[attr-defined]
if isinstance(thumbnail_data, list) and thumbnail_data:
first_item = thumbnail_data[0]
# Validate that the first item is a non-empty string and a valid file
if isinstance(first_item, str) and first_item and first_item.lower() != "none":
if os.path.isfile(first_item):
return first_item
else:
log.trace(f"ThumbnailBackground: Thumbnail path '{first_item}' is not a valid file")
except Exception as e:
log.error(f"Failed to extract thumbnail path: {e}")
return None
def on_ready(self):
"""
Initialize optimization caches to track the current state.
Enables avoiding triggering an update each tick.
An initial update is performed to display the starting background
based on the current media state.
"""
# Invalidate action list cache when page loads
ThumbnailBackground._cached_actions = None
ThumbnailBackground._cached_page_id = None
# Clean up old background cache before resetting
if ThumbnailBackground._original_background_image is not None:
try:
ThumbnailBackground._original_background_image.close()
except Exception as e:
log.error(f"Failed to close cached background image: {e}")
# Always reset cache references
ThumbnailBackground._original_background_image = None
ThumbnailBackground._cached_background_path = None
try:
self._initialize_caches()
self.update_image()
except Exception as e:
log.error(f"Failed to initialize ThumbnailBackground: {e}", exc_info=True)
# Set defaults to ensure action is in a safe state
self.last_size_mode = "stretch"
self.last_thumbnail_path = None
self.last_background_path = ""
self.last_coords = None
def on_tick(self):
# Optimization: Only update if something changed
if self._should_update():
self.update_image()
def get_config_rows(self) -> "list[Adw.PreferencesRow]":
# Call parent to initialize player_selector (we only want this row, not label/thumbnail toggles)
try:
super().get_config_rows()
except Exception as e:
log.error(f"Failed to initialize parent config rows: {e}")
# Get player selector from parent initialization
if not hasattr(self, "player_selector") or self.player_selector is None:
log.warning("Player selector not initialized in config rows")
rows = []
else:
rows = [self.player_selector]
# Add size mode selector
self.size_mode_model = Gtk.StringList()
self.size_mode_selector = Adw.ComboRow(
model=self.size_mode_model,
title=self.plugin_base.lm.get("actions.thumbnail-background.size-mode.label"), # type: ignore[attr-defined]
subtitle=self.plugin_base.lm.get("actions.thumbnail-background.size-mode.subtitle") # type: ignore[attr-defined]
)
# Populate size options
size_options = [
("1x1", "1x1"),
("2x2", "2x2"),
("3x3", "3x3"),
("4x4", "4x4"),
("stretch", self.plugin_base.lm.get("actions.thumbnail-background.size-mode.stretch")), # type: ignore[attr-defined]
("fill", self.plugin_base.lm.get("actions.thumbnail-background.size-mode.fill")) # type: ignore[attr-defined]
]
self.size_mode_options = [opt[0] for opt in size_options]
for _, label in size_options:
self.size_mode_model.append(label)
self.load_size_mode_default()
self.size_mode_selector.connect("notify::selected", self.on_change_size_mode)
rows.append(self.size_mode_selector) # type: ignore[arg-type]
return rows
def load_size_mode_default(self):
"""
Load the default size mode setting and apply it to the size mode selector.
Load from actions settings, load and store ``"fill"`` as the default,
If an invalid option is stored, fall back to the index for ``"fill"``.
"""
settings = self.get_settings()
if settings is None:
return
size_mode = settings.setdefault("size_mode", "fill")
# Select the appropriate mode
try:
selected_index = self.size_mode_options.index(size_mode)
except ValueError:
# Default to "fill" if the stored mode is invalid
selected_index = self.size_mode_options.index("fill")
self.size_mode_selector.set_selected(selected_index)
def on_change_size_mode(self, combo, *args):
"""
When the user selects a different size for the thumbnail display in the UI:
trigger a background image refresh to apply the new sizing behavior.
:param combo: The size mode selector widget (e.g. an Adw.ComboRow) that
emitted the change notification.
:param args: Additional signal parameters provided by the toolkit,
which are currently ignored.
"""
settings = self.get_settings()
if settings is None or not hasattr(self, 'size_mode_options') or not self.size_mode_options:
log.warning("ThumbnailBackground: Cannot change size mode - settings or size_mode_options unavailable")
return
selected_index = combo.get_selected()
if selected_index < 0 or selected_index >= len(self.size_mode_options):
log.warning(f"ThumbnailBackground: Invalid size mode selection index {selected_index}")
return
# Invalidate cache since size mode affects sort order (fill/stretch/grid)
ThumbnailBackground._cached_actions = None
ThumbnailBackground._cached_page_id = None
settings["size_mode"] = self.size_mode_options[selected_index]
self.set_settings(settings)
self.update_image()
def update_image(self):
"""
Update the background image with a thumbnail based on current settings.
Retrieves the thumbnail path, loads the image, and applies the appropriate
sizing/positioning mode (stretch, fill, or grid-based).
Restore the original background if the thumbnail cannot be loaded.
"""
log.trace("ThumbnailBackground: update_image called")
if not self.get_is_present():
return
settings = self.get_settings()
if settings is None:
return
size_mode = settings.setdefault("size_mode", "fill")
self.last_size_mode = size_mode
# Get thumbnail path using helper method
thumbnail_path = self._get_thumbnail_path()
if thumbnail_path is None:
self.last_thumbnail_path = None
self.restore_original_background()
return
# Load thumbnail image
try:
thumbnail = Image.open(thumbnail_path)
except (OSError, ValueError) as e:
log.error(f"Failed to load thumbnail image from {thumbnail_path}: {e}")
self.last_thumbnail_path = None
self.restore_original_background()
return
# Track thumbnail path, background path, and position
self.last_thumbnail_path = thumbnail_path
self.last_background_path = self.get_background_path()
if hasattr(self.input_ident, 'coords'):
self.last_coords = self.input_ident.coords # type: ignore[attr-defined]
else:
self.last_coords = None
# Handle different size modes
if size_mode == "stretch":
# Stretch to exact deck dimensions (may distort aspect ratio)
log.trace("ThumbnailBackground: calling set_stretch_background")
self.set_stretch_background(thumbnail)
elif size_mode == "fill":
log.trace("ThumbnailBackground: calling set_fill_screen_background")
self.set_fill_screen_background(thumbnail)
else:
# Grid sizes (1x1, 2x2, 3x3, 4x4)
log.trace(f"ThumbnailBackground: calling set_grid_sized_background with mode {size_mode}")
self.set_grid_sized_background(thumbnail, size_mode)
# Close the thumbnail image to prevent memory leaks
thumbnail.close()
def _close_rendered_thumbnail(self) -> None:
"""Close and clear the rendered thumbnail to prevent memory leaks."""
if self.rendered_thumbnail is not None:
try:
self.rendered_thumbnail.close()
except Exception:
pass
self.rendered_thumbnail = None
def _initialize_caches(self) -> None:
"""Initialize tracking caches with current state."""
settings = self.get_settings()
self.last_size_mode = settings.get("size_mode", "fill") if settings else "fill"
self.last_thumbnail_path = self._get_thumbnail_path()
self.last_background_path = self.get_background_path()
self.last_coords = self.input_ident.coords if hasattr(self.input_ident, 'coords') else None # type: ignore[attr-defined]
def get_deck_dimensions(self):
"""Helper to get full deck dimensions."""
key_rows, key_cols = self.deck_controller.deck.key_layout()
key_width, key_height = self.deck_controller.get_key_image_size() # type: ignore
spacing_x, spacing_y = self.deck_controller.key_spacing
full_width = key_width * key_cols + spacing_x * (key_cols - 1)
full_height = key_height * key_rows + spacing_y * (key_rows - 1)
return full_width, full_height, key_width, key_height, spacing_x, spacing_y
def set_stretch_background(self, thumbnail: Image.Image):
"""Scale the given thumbnail to exactly match the full deck dimensions and set it"""
full_width, full_height, _, _, _, _ = self.get_deck_dimensions()
self._close_rendered_thumbnail()
self.rendered_thumbnail = thumbnail.resize((full_width, full_height), Image.Resampling.LANCZOS)
# Convert to RGBA to ensure it has alpha channel for compositing
if self.rendered_thumbnail.mode != 'RGBA':
new_img = self.rendered_thumbnail.convert('RGBA')
self.rendered_thumbnail.close()
self.rendered_thumbnail = new_img
self._request_composite()
def set_fill_screen_background(self, thumbnail: Image.Image):
"""Scale thumbnail to fill the screen by its longest side, centered."""
full_width, full_height, _, _, _, _ = self.get_deck_dimensions()
# Calculate scaling to fill by longest side
thumb_width, thumb_height = thumbnail.size
scale = max(full_width / thumb_width, full_height / thumb_height)
new_width = int(thumb_width * scale)
new_height = int(thumb_height * scale)
# Resize and center thumbnail
resized_thumbnail = thumbnail.resize((new_width, new_height), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (full_width, full_height), (0, 0, 0, 0))