frontengine 1.0.26__tar.gz → 1.0.27__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. {frontengine-1.0.26 → frontengine-1.0.27}/PKG-INFO +28 -1
  2. {frontengine-1.0.26 → frontengine-1.0.27}/README.md +27 -0
  3. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/pet/desktop_pet.py +103 -5
  4. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/pet/pet_setting_ui.py +13 -3
  5. frontengine-1.0.27/frontengine/utils/audio_meter/audio_envelope.py +56 -0
  6. frontengine-1.0.27/frontengine/utils/audio_meter/screen_audio.py +130 -0
  7. frontengine-1.0.27/frontengine/utils/audio_meter/system_audio_meter.py +298 -0
  8. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/english.py +3 -0
  9. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/traditional_chinese.py +2 -0
  10. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine.egg-info/PKG-INFO +28 -1
  11. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine.egg-info/SOURCES.txt +2 -0
  12. {frontengine-1.0.26 → frontengine-1.0.27}/pyproject.toml +1 -1
  13. frontengine-1.0.26/frontengine/utils/audio_meter/system_audio_meter.py +0 -136
  14. {frontengine-1.0.26 → frontengine-1.0.27}/LICENSE +0 -0
  15. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/__init__.py +0 -0
  16. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/__main__.py +0 -0
  17. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/__init__.py +0 -0
  18. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/base_widget.py +0 -0
  19. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/gif/__init__.py +0 -0
  20. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/gif/paint_gif.py +0 -0
  21. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/image/__init__.py +0 -0
  22. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/image/paint_image.py +0 -0
  23. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/load/__init__.py +0 -0
  24. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/load/load_someone_make_ui.py +0 -0
  25. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/overlay_factory.py +0 -0
  26. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/particle/__init__.py +0 -0
  27. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/particle/particle_ui.py +0 -0
  28. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/pet/__init__.py +0 -0
  29. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/scene/__init__.py +0 -0
  30. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/scene/extend_graphic_scene.py +0 -0
  31. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/scene/extend_graphic_view.py +0 -0
  32. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/scene/scene.py +0 -0
  33. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/sound_player/__init__.py +0 -0
  34. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/sound_player/sound_effect.py +0 -0
  35. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/sound_player/sound_player.py +0 -0
  36. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/text/__init__.py +0 -0
  37. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/text/draw_text.py +0 -0
  38. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/video/__init__.py +0 -0
  39. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/video/video_player.py +0 -0
  40. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/web/__init__.py +0 -0
  41. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/web/webview.py +0 -0
  42. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/show/window_helpers.py +0 -0
  43. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/system_tray/__init__.py +0 -0
  44. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/system_tray/extend_system_tray.py +0 -0
  45. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/__init__.py +0 -0
  46. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/color/__init__.py +0 -0
  47. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/color/global_color.py +0 -0
  48. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/dialog/__init__.py +0 -0
  49. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/dialog/choose_file_dialog.py +0 -0
  50. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/dialog/hotkey_settings_dialog.py +0 -0
  51. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/dialog/save_file_dialog.py +0 -0
  52. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/main_ui.py +0 -0
  53. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/menu/__init__.py +0 -0
  54. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/menu/help_menu.py +0 -0
  55. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/menu/how_to_menu.py +0 -0
  56. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/menu/language_menu.py +0 -0
  57. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/menu/preset_menu.py +0 -0
  58. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/menu/settings_menu.py +0 -0
  59. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/__init__.py +0 -0
  60. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/control_center/__init__.py +0 -0
  61. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/control_center/control_center_ui.py +0 -0
  62. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/gif/__init__.py +0 -0
  63. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/gif/gif_setting_ui.py +0 -0
  64. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/image/__init__.py +0 -0
  65. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/image/image_setting_ui.py +0 -0
  66. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/particle/__init__.py +0 -0
  67. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/particle/particle_setting_ui.py +0 -0
  68. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/pet/__init__.py +0 -0
  69. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/__init__.py +0 -0
  70. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_manager.py +0 -0
  71. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/__init__.py +0 -0
  72. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/base_scene_page.py +0 -0
  73. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/gif.py +0 -0
  74. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/image.py +0 -0
  75. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/registry.py +0 -0
  76. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/sound.py +0 -0
  77. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/text.py +0 -0
  78. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/video.py +0 -0
  79. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_page/web.py +0 -0
  80. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/scene_setting/scene_setting_ui.py +0 -0
  81. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/sound_player/__init__.py +0 -0
  82. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/sound_player/sound_player_setting_ui.py +0 -0
  83. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/text/__init__.py +0 -0
  84. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/text/text_setting_ui.py +0 -0
  85. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/utils.py +0 -0
  86. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/video/__init__.py +0 -0
  87. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/video/video_setting_ui.py +0 -0
  88. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/web/__init__.py +0 -0
  89. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/ui/page/web/web_setting_ui.py +0 -0
  90. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/user_setting/__init__.py +0 -0
  91. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/user_setting/preset_repository.py +0 -0
  92. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/user_setting/scene_setting.py +0 -0
  93. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/user_setting/user_setting_file.py +0 -0
  94. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/__init__.py +0 -0
  95. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/audio_meter/__init__.py +0 -0
  96. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/browser/__init__.py +0 -0
  97. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/browser/browser.py +0 -0
  98. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/critical_exit/__init__.py +0 -0
  99. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/critical_exit/check_key_is_press.py +0 -0
  100. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/critical_exit/critical_exit.py +0 -0
  101. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/critical_exit/win32_vk.py +0 -0
  102. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/exception/__init__.py +0 -0
  103. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/exception/exception_tags.py +0 -0
  104. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/exception/exceptions.py +0 -0
  105. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/file/__init__.py +0 -0
  106. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/file/open/__init__.py +0 -0
  107. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/file/open/open_file.py +0 -0
  108. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/file/save/__init__.py +0 -0
  109. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/file/save/save_file.py +0 -0
  110. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/hotkey/__init__.py +0 -0
  111. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/hotkey/hotkey_service.py +0 -0
  112. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/json/__init__.py +0 -0
  113. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/json/json_file.py +0 -0
  114. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/json/json_repository.py +0 -0
  115. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/json_format/__init__.py +0 -0
  116. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/json_format/json_process.py +0 -0
  117. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/logging/__init__.py +0 -0
  118. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/logging/loggin_instance.py +0 -0
  119. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/__init__.py +0 -0
  120. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/france.py +0 -0
  121. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/germany.py +0 -0
  122. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/italy.py +0 -0
  123. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/language_wrapper.py +0 -0
  124. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/russian.py +0 -0
  125. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/multi_language/simplified_chinese.py +0 -0
  126. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/preset_schedule/__init__.py +0 -0
  127. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/preset_schedule/preset_schedule_service.py +0 -0
  128. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/redirect_manager/__init__.py +0 -0
  129. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/redirect_manager/redirect_manager_class.py +0 -0
  130. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/smart_pause/__init__.py +0 -0
  131. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/smart_pause/smart_pause_service.py +0 -0
  132. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/theme_schedule/__init__.py +0 -0
  133. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/theme_schedule/theme_schedule_service.py +0 -0
  134. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/utils/web_url.py +0 -0
  135. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/worker/__init__.py +0 -0
  136. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine/worker/qthread_worker.py +0 -0
  137. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine.egg-info/dependency_links.txt +0 -0
  138. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine.egg-info/entry_points.txt +0 -0
  139. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine.egg-info/requires.txt +0 -0
  140. {frontengine-1.0.26 → frontengine-1.0.27}/frontengine.egg-info/top_level.txt +0 -0
  141. {frontengine-1.0.26 → frontengine-1.0.27}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frontengine
3
- Version: 1.0.26
3
+ Version: 1.0.27
4
4
  Summary: FrontEngine is use-define frontview or only use like screen saver
5
5
  Author-email: JE-Chen <jechenmailman@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/Intergration-Automation-Testing/FrontEngine
@@ -53,6 +53,33 @@ It provides an intuitive interface and supports multiple media formats for inter
53
53
 
54
54
  ---
55
55
 
56
+ ## Desktop pet
57
+
58
+ Spawn an animated sprite that lives on your desktop, from the **Pet** tab.
59
+
60
+ - **Sprites** — pick a single GIF/WebP/PNG, or a *pet pack* folder whose file
61
+ names map to states: `walk`, `idle`, `sleep`, `climb`, `fall`, `drag`
62
+ (missing states fall back to `walk`).
63
+ - **Behaviour** — walk on the floor with gravity (throw it and it bounces),
64
+ wander freely, or chase the cursor. Floor pets can climb screen edges and
65
+ stand on the top edge of other windows.
66
+ - **Life** — mood, fullness and an affection level that persist between runs;
67
+ the pet grows as it levels up, chats in speech bubbles, naps while you are
68
+ away, and warns you about a low battery.
69
+ - **Interaction** — drag it around, right-click to clone/feed/set a reminder,
70
+ and **drop a file onto it**: an image or pet pack becomes its new look,
71
+ anything else is eaten as a snack.
72
+ - **React to audio** — the pet pulses to your speakers' output level. It reads
73
+ only the Windows output *meter* (WASAPI `IAudioMeterInformation`); no audio
74
+ is captured or recorded. Peaks are smoothed with an RMS window and a
75
+ fast-attack/slow-decay envelope so the pulse breathes instead of flickering.
76
+ On multi-monitor setups each pet follows the audio endpoint that matches its
77
+ own screen, falling back to the default output device.
78
+
79
+ Audio features are Windows-only and degrade to "no pulse" elsewhere.
80
+
81
+ ---
82
+
56
83
  ## Install
57
84
 
58
85
  - **System Requirements**
@@ -29,6 +29,33 @@ It provides an intuitive interface and supports multiple media formats for inter
29
29
 
30
30
  ---
31
31
 
32
+ ## Desktop pet
33
+
34
+ Spawn an animated sprite that lives on your desktop, from the **Pet** tab.
35
+
36
+ - **Sprites** — pick a single GIF/WebP/PNG, or a *pet pack* folder whose file
37
+ names map to states: `walk`, `idle`, `sleep`, `climb`, `fall`, `drag`
38
+ (missing states fall back to `walk`).
39
+ - **Behaviour** — walk on the floor with gravity (throw it and it bounces),
40
+ wander freely, or chase the cursor. Floor pets can climb screen edges and
41
+ stand on the top edge of other windows.
42
+ - **Life** — mood, fullness and an affection level that persist between runs;
43
+ the pet grows as it levels up, chats in speech bubbles, naps while you are
44
+ away, and warns you about a low battery.
45
+ - **Interaction** — drag it around, right-click to clone/feed/set a reminder,
46
+ and **drop a file onto it**: an image or pet pack becomes its new look,
47
+ anything else is eaten as a snack.
48
+ - **React to audio** — the pet pulses to your speakers' output level. It reads
49
+ only the Windows output *meter* (WASAPI `IAudioMeterInformation`); no audio
50
+ is captured or recorded. Peaks are smoothed with an RMS window and a
51
+ fast-attack/slow-decay envelope so the pulse breathes instead of flickering.
52
+ On multi-monitor setups each pet follows the audio endpoint that matches its
53
+ own screen, falling back to the default output device.
54
+
55
+ Audio features are Windows-only and degrade to "no pulse" elsewhere.
56
+
57
+ ---
58
+
32
59
  ## Install
33
60
 
34
61
  - **System Requirements**
@@ -12,6 +12,7 @@ from PySide6.QtWidgets import QMenu, QMessageBox, QWidget
12
12
  from frontengine.show.base_widget import BaseWidget
13
13
  from frontengine.show.window_helpers import apply_overlay_window_flags
14
14
  from frontengine.user_setting.user_setting_file import user_setting_dict
15
+ from frontengine.utils.audio_meter.audio_envelope import AudioEnvelope
15
16
  from frontengine.utils.logging.loggin_instance import front_engine_logger
16
17
  from frontengine.utils.multi_language.language_wrapper import language_wrapper
17
18
 
@@ -82,6 +83,28 @@ def scan_pet_pack(folder: str) -> dict:
82
83
  return result
83
84
 
84
85
 
86
+ # 拖進寵物的檔案分類 / What a file dropped onto the pet means
87
+ DROP_SPRITE = "sprite" # 圖片或動作包 -> 換一套外觀
88
+ DROP_FOOD = "food" # 其他檔案 -> 當成食物餵給寵物
89
+
90
+
91
+ def classify_drop(path) -> Optional[str]:
92
+ """
93
+ 判斷拖到寵物身上的檔案要換裝還是當食物;路徑無效或空資料夾回傳 None。
94
+ Classify a file dropped onto the pet: a sprite/pack to wear, food to eat,
95
+ or None when the path is unusable.
96
+ """
97
+ try:
98
+ target = Path(path)
99
+ if not target.exists():
100
+ return None
101
+ except (OSError, TypeError, ValueError):
102
+ return None
103
+ if target.is_dir():
104
+ return DROP_SPRITE if scan_pet_pack(str(target)) else None
105
+ return DROP_SPRITE if target.suffix.lower() in _PACK_EXTS else DROP_FOOD
106
+
107
+
85
108
  def derive_visual_state(dragging: bool, airborne: bool, surface: str, motion_state: str) -> str:
86
109
  """依目前情形推導應顯示的視覺狀態 / Derive which sprite state to show."""
87
110
  if dragging:
@@ -876,6 +899,7 @@ class DesktopPetWidget(BaseWidget):
876
899
  message_box.show()
877
900
 
878
901
  self.resize(self.pet_size, self.pet_size)
902
+ self.setAcceptDrops(True) # drop an image to dress it up, anything else to feed it
879
903
  self.motion = PetMotion(
880
904
  0, 0, self.pet_size, self.pet_size, (0, 0, 1920, 1080), speed, behaviour, climb=climb
881
905
  )
@@ -901,6 +925,9 @@ class DesktopPetWidget(BaseWidget):
901
925
  self._audio_react = bool(audio_react)
902
926
  self._audio_level_provider = None # callable -> level 0..1 or None
903
927
  self._audio_scale = 1.0
928
+ # 峰值抖動大,先過 RMS + 快起慢降包絡再驅動脈動
929
+ # Raw peaks jitter, so smooth them through an RMS + attack/decay envelope.
930
+ self._audio_envelope = AudioEnvelope()
904
931
  # 多寵物互動 / Multi-pet interaction
905
932
  self._peers_provider = None
906
933
  self._peer_greeted = False
@@ -992,19 +1019,50 @@ class DesktopPetWidget(BaseWidget):
992
1019
  self._peer_greeted = False
993
1020
 
994
1021
  def _load_sprite(self, path: str):
1022
+ """載入一張精靈;檔案損毀或格式不支援時回傳 None。"""
995
1023
  if Path(path).suffix.lower() in (".gif", ".webp"):
996
1024
  movie = QMovie(str(path))
1025
+ if not movie.isValid():
1026
+ return None
997
1027
  movie.frameChanged.connect(self.update)
998
1028
  return ("movie", movie)
999
- return ("pixmap", QPixmap(str(path)))
1029
+ pixmap = QPixmap(str(path))
1030
+ return None if pixmap.isNull() else ("pixmap", pixmap)
1000
1031
 
1001
1032
  def _load_sprites(self, image_path: Path) -> None:
1002
- """單一檔 -> 所有狀態共用;資料夾 -> 依檔名對應各狀態。"""
1033
+ """單一檔 -> 所有狀態共用;資料夾 -> 依檔名對應各狀態;載不起來的略過。"""
1003
1034
  if image_path.is_dir():
1004
1035
  for state, path in scan_pet_pack(str(image_path)).items():
1005
- self._sprites[state] = self._load_sprite(path)
1036
+ sprite = self._load_sprite(path)
1037
+ if sprite is not None:
1038
+ self._sprites[state] = sprite
1006
1039
  elif image_path.is_file():
1007
- self._sprites["default"] = self._load_sprite(str(image_path))
1040
+ sprite = self._load_sprite(str(image_path))
1041
+ if sprite is not None:
1042
+ self._sprites["default"] = sprite
1043
+
1044
+ def change_sprite(self, path) -> None:
1045
+ """換一套外觀(拖圖片或動作包到寵物身上時觸發);載不到就維持原樣。"""
1046
+ image_path = Path(path)
1047
+ previous = self._sprites
1048
+ self._sprites = {}
1049
+ self._load_sprites(image_path)
1050
+ if not self._sprites:
1051
+ front_engine_logger.warning(f"[DesktopPetWidget] change_sprite ignored: {image_path}")
1052
+ self._sprites = previous
1053
+ return
1054
+ front_engine_logger.info(f"[DesktopPetWidget] change_sprite | path={image_path}")
1055
+ for kind, obj in previous.values():
1056
+ if kind == "movie":
1057
+ obj.stop()
1058
+ self.image_path = image_path
1059
+ self._active = None
1060
+ self._active_state = None
1061
+ self._set_active_sprite(self._visual_state())
1062
+ self._gain_affection(2)
1063
+ pool = self._messages.get("costume") or []
1064
+ if pool:
1065
+ self.say(pool[self._chatter_rng.randrange(len(pool))])
1008
1066
 
1009
1067
  def _sprite_for(self, state: str):
1010
1068
  if "default" in self._sprites:
@@ -1042,16 +1100,23 @@ class DesktopPetWidget(BaseWidget):
1042
1100
  def set_audio_level_provider(self, provider) -> None:
1043
1101
  """設定回傳目前音量 (0~1 或 None) 的函式 / Provider of the current audio level."""
1044
1102
  self._audio_level_provider = provider
1103
+ self._audio_envelope.reset()
1045
1104
 
1046
1105
  def _update_audio_scale(self) -> None:
1106
+ """取一次音量取樣,經包絡平滑後換算成脈動比例(無音源則不脈動)。"""
1047
1107
  if not self._audio_react or self._audio_level_provider is None:
1048
1108
  self._audio_scale = 1.0
1109
+ self._audio_envelope.reset()
1049
1110
  return
1050
1111
  try:
1051
1112
  level = self._audio_level_provider()
1052
1113
  except Exception: # pragma: no cover - defensive
1053
1114
  level = None
1054
- self._audio_scale = 1.0 if level is None else audio_pulse_scale(level)
1115
+ if level is None:
1116
+ self._audio_scale = 1.0
1117
+ self._audio_envelope.reset()
1118
+ return
1119
+ self._audio_scale = audio_pulse_scale(self._audio_envelope.push(level))
1055
1120
 
1056
1121
  def draw_content(self, painter: QPainter) -> None:
1057
1122
  pixmap = self._current_pixmap()
@@ -1101,6 +1166,7 @@ class DesktopPetWidget(BaseWidget):
1101
1166
  "fed": lines("pet_chatter_fed", "Yum!|Thank you!|Delicious!"),
1102
1167
  "away": lines("pet_chatter_away", "Still there?|I'll nap till you're back~|Zzz..."),
1103
1168
  "levelup": lines("pet_chatter_levelup", "Level up!|I'm growing!|We're getting closer!"),
1169
+ "costume": lines("pet_chatter_costume", "New look!|How do I look?|Nice fit!"),
1104
1170
  "any": lines("pet_chatter_any", "Hi there!|(^_^)|Keep going!"),
1105
1171
  }
1106
1172
 
@@ -1389,6 +1455,38 @@ class DesktopPetWidget(BaseWidget):
1389
1455
  self.say() # a click (no drag) makes the pet talk
1390
1456
  super().mouseReleaseEvent(event)
1391
1457
 
1458
+ # --- drop a file onto the pet: wear it (image / pack) or eat it ---
1459
+ @staticmethod
1460
+ def dropped_path(mime_data) -> Optional[str]:
1461
+ """從拖曳資料取出第一個可用的本機路徑;沒有可用者回傳 None。"""
1462
+ if mime_data is None or not mime_data.hasUrls():
1463
+ return None
1464
+ for url in mime_data.urls():
1465
+ local_path = url.toLocalFile()
1466
+ if local_path and classify_drop(local_path) is not None:
1467
+ return local_path
1468
+ return None
1469
+
1470
+ def dragEnterEvent(self, event) -> None:
1471
+ if self.dropped_path(event.mimeData()) is not None:
1472
+ event.acceptProposedAction()
1473
+
1474
+ def dragMoveEvent(self, event) -> None:
1475
+ if self.dropped_path(event.mimeData()) is not None:
1476
+ event.acceptProposedAction()
1477
+
1478
+ def dropEvent(self, event) -> None:
1479
+ path = self.dropped_path(event.mimeData())
1480
+ if path is None:
1481
+ return
1482
+ front_engine_logger.info(f"[DesktopPetWidget] dropEvent | path={path}")
1483
+ self.motion.wake()
1484
+ if classify_drop(path) == DROP_SPRITE:
1485
+ self.change_sprite(path)
1486
+ else:
1487
+ self.feed()
1488
+ event.acceptProposedAction()
1489
+
1392
1490
  def contextMenuEvent(self, event) -> None:
1393
1491
  self.menu.popup(event.globalPos())
1394
1492
 
@@ -17,7 +17,7 @@ from frontengine.ui.page.utils import (
17
17
  resolve_preferred_monitor,
18
18
  )
19
19
  from frontengine.user_setting.user_setting_file import add_recent_file
20
- from frontengine.utils.audio_meter.system_audio_meter import system_audio_level
20
+ from frontengine.utils.audio_meter.screen_audio import audio_level_provider_for_screen
21
21
  from frontengine.utils.logging.loggin_instance import front_engine_logger
22
22
  from frontengine.utils.multi_language.language_wrapper import language_wrapper
23
23
 
@@ -93,6 +93,12 @@ class PetSettingUI(QWidget):
93
93
  self.volume_combobox.setCurrentText("100%")
94
94
  self.audio_react_checkbox = QCheckBox(
95
95
  language_wrapper.language_word_dict.get("pet_audio_label", "React to audio"))
96
+ self.drop_hint_label = QLabel(
97
+ language_wrapper.language_word_dict.get(
98
+ "pet_drop_hint",
99
+ "Tip: drop an image or pet pack onto the pet to change its look, "
100
+ "or any other file to feed it."))
101
+ self.drop_hint_label.setWordWrap(True)
96
102
 
97
103
  # Start
98
104
  self.start_button = QPushButton(language_wrapper.language_word_dict.get("pet_start", "Spawn pet"))
@@ -128,6 +134,7 @@ class PetSettingUI(QWidget):
128
134
  self.grid_layout.addWidget(self.volume_label, 6, 2)
129
135
  self.grid_layout.addWidget(self.volume_combobox, 7, 2)
130
136
  self.grid_layout.addWidget(self.audio_react_checkbox, 7, 0)
137
+ self.grid_layout.addWidget(self.drop_hint_label, 8, 0, 1, 3)
131
138
 
132
139
  def _spawn_pet(self) -> None:
133
140
  """建立、顯示並開始移動一隻寵物(供 Start 與右鍵複製共用)。"""
@@ -147,12 +154,15 @@ class PetSettingUI(QWidget):
147
154
  )
148
155
  pet.clone_requested.connect(self._spawn_pet)
149
156
  pet.set_peers_provider(lambda me=pet: self._peer_centers(me))
157
+ geometry = self._target_geometry()
150
158
  if self.audio_react_checkbox.isChecked():
151
- pet.set_audio_level_provider(system_audio_level) # real system output peak meter
159
+ # 跟隨寵物所在螢幕的音源(比對不到就用系統預設輸出裝置)
160
+ # Follow the audio of the screen the pet lives on; default endpoint otherwise.
161
+ pet.set_audio_level_provider(
162
+ audio_level_provider_for_screen(geometry[1] if geometry is not None else None))
152
163
  pet.set_pet_window_flag()
153
164
  self.pet_list.append(pet)
154
165
  pet.show()
155
- geometry = self._target_geometry()
156
166
  if geometry is not None:
157
167
  pet.setScreen(geometry[1])
158
168
  bounds = geometry[0]
@@ -0,0 +1,56 @@
1
+ """
2
+ 把逐次取樣的音量峰值平滑成自然的脈動包絡:先在短窗內取 RMS 壓掉抖動,
3
+ 再以「快起慢降」的包絡追蹤器塑形,避免忽大忽小的閃爍。純邏輯、不依賴 Qt。
4
+
5
+ Smooth per-tick peak samples into a natural pulse envelope: a short RMS window
6
+ removes jitter, then an asymmetric follower (fast attack, slow decay) shapes it.
7
+ Pure logic, no Qt dependency.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from collections import deque
12
+
13
+
14
+ def clamp_level(value) -> float:
15
+ """把任意輸入夾到 0~1;無法轉成數值時回傳 0.0。"""
16
+ try:
17
+ return max(0.0, min(1.0, float(value)))
18
+ except (TypeError, ValueError):
19
+ return 0.0
20
+
21
+
22
+ class AudioEnvelope:
23
+ """
24
+ 音量包絡追蹤器:push() 餵進一個 0~1 的峰值取樣,回傳平滑後的 0~1 音量。
25
+ window 為 RMS 取樣窗長度,attack/decay 為上升/下降的追隨係數 (0~1,越大越快)。
26
+
27
+ An envelope follower: push() takes one 0..1 peak sample and returns the
28
+ smoothed 0..1 level. `window` is the RMS window length; `attack`/`decay`
29
+ are the rise/fall follow coefficients (0..1, larger is faster).
30
+ """
31
+
32
+ def __init__(self, window: int = 4, attack: float = 0.6, decay: float = 0.12) -> None:
33
+ self._window = max(1, int(window))
34
+ self._samples: deque = deque(maxlen=self._window)
35
+ self.attack = clamp_level(attack)
36
+ self.decay = clamp_level(decay)
37
+ self.value = 0.0
38
+
39
+ def reset(self) -> None:
40
+ """清空取樣與輸出(音源消失或關閉律動時呼叫)。"""
41
+ self._samples.clear()
42
+ self.value = 0.0
43
+
44
+ def rms(self) -> float:
45
+ """目前取樣窗的均方根;窗內無取樣時回傳 0.0。"""
46
+ if not self._samples:
47
+ return 0.0
48
+ return (sum(sample * sample for sample in self._samples) / len(self._samples)) ** 0.5
49
+
50
+ def push(self, level) -> float:
51
+ """餵入一個峰值取樣並回傳平滑後的音量 / Feed one peak sample, return the smoothed level."""
52
+ self._samples.append(clamp_level(level))
53
+ target = self.rms()
54
+ coefficient = self.attack if target > self.value else self.decay
55
+ self.value = clamp_level(self.value + coefficient * (target - self.value))
56
+ return self.value
@@ -0,0 +1,130 @@
1
+ """
2
+ 把「螢幕」對應到「輸出音源端點」:多螢幕時,各螢幕常各自帶喇叭(HDMI/DP 音訊),
3
+ 本模組以端點顯示名稱與螢幕型號/製造商的共同字詞比對,讓每隻寵物跟著自己所在
4
+ 螢幕的聲音律動;比不到對應端點時退回預設輸出裝置。
5
+
6
+ Map a screen to an audio output endpoint. On multi-monitor setups each screen
7
+ often carries its own speakers (HDMI/DP audio); this matches endpoint friendly
8
+ names against the screen's model/manufacturer so a pet pulses to the sound of
9
+ the screen it lives on, falling back to the default endpoint when unmatched.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from typing import Dict, Iterable, List, Optional, Tuple
14
+
15
+ from frontengine.utils.audio_meter.system_audio_meter import (
16
+ SystemAudioMeter, list_output_devices, system_audio_level,
17
+ )
18
+ from frontengine.utils.logging.loggin_instance import front_engine_logger
19
+
20
+ # 比對時要忽略的通用字詞(裝置與螢幕名稱都常出現,沒有辨識力)
21
+ # Generic words that carry no identifying power when matching names.
22
+ _GENERIC_TOKENS = frozenset({
23
+ "audio", "sound", "speaker", "speakers", "headphone", "headphones", "headset",
24
+ "digital", "output", "high", "definition", "device", "generic", "pnp", "plug",
25
+ "play", "monitor", "display", "screen", "hdmi", "displayport", "the", "and",
26
+ "nvidia", "intel", "amd", "realtek", "usb", "default", "communications",
27
+ })
28
+
29
+
30
+ def name_tokens(text) -> set:
31
+ """把名稱切成可比對的小寫字詞,去掉通用字詞與過短片段。"""
32
+ if not text:
33
+ return set()
34
+ tokens = set()
35
+ word = []
36
+ for character in str(text).lower():
37
+ if character.isalnum():
38
+ word.append(character)
39
+ continue
40
+ if word:
41
+ tokens.add("".join(word))
42
+ word = []
43
+ if word:
44
+ tokens.add("".join(word))
45
+ return {token for token in tokens if len(token) >= 2 and token not in _GENERIC_TOKENS}
46
+
47
+
48
+ def match_device_for_screen(hints: Iterable[str], devices: Iterable[Tuple[str, str]]) -> Optional[str]:
49
+ """
50
+ 以共同字詞數挑出最像該螢幕的輸出端點,回傳 device_id;沒有共同字詞回傳 None。
51
+ Pick the endpoint sharing the most tokens with the screen hints, or None.
52
+ """
53
+ hint_tokens = set()
54
+ for hint in hints or ():
55
+ hint_tokens |= name_tokens(hint)
56
+ if not hint_tokens:
57
+ return None
58
+ best_id = None
59
+ best_score = 0
60
+ for device_id, name in devices or ():
61
+ score = len(hint_tokens & name_tokens(name))
62
+ if score > best_score:
63
+ best_score = score
64
+ best_id = device_id
65
+ return best_id
66
+
67
+
68
+ def screen_hints(screen) -> List[str]:
69
+ """從 QScreen 取出可比對的字串(型號、製造商、名稱),取不到就略過。"""
70
+ hints: List[str] = []
71
+ for attribute in ("model", "manufacturer", "name"):
72
+ getter = getattr(screen, attribute, None)
73
+ if not callable(getter):
74
+ continue
75
+ try:
76
+ value = getter()
77
+ except Exception as error: # pragma: no cover - defensive against Qt/duck-typed screens
78
+ # 某個欄位問不到不影響其他欄位的比對,記錄後繼續。
79
+ # One attribute refusing to answer must not stop the remaining hints.
80
+ front_engine_logger.debug(f"[screen_hints] {attribute} failed: {error!r}")
81
+ continue
82
+ if value:
83
+ hints.append(str(value))
84
+ return hints
85
+
86
+
87
+ class ScreenAudioMeters:
88
+ """依裝置 ID 快取電表,並提供「綁定某螢幕音源」的取樣函式。"""
89
+
90
+ def __init__(self) -> None:
91
+ self._meters: Dict[str, SystemAudioMeter] = {}
92
+
93
+ def device_for_screen(self, screen) -> Optional[str]:
94
+ """回傳該螢幕對應的輸出端點 ID;沒有對應者回傳 None。"""
95
+ if screen is None:
96
+ return None
97
+ return match_device_for_screen(screen_hints(screen), list_output_devices())
98
+
99
+ def level_for_device(self, device_id: str) -> Optional[float]:
100
+ """讀取指定端點的目前峰值(電表物件會重複使用)。"""
101
+ meter = self._meters.get(device_id)
102
+ if meter is None:
103
+ meter = SystemAudioMeter(device_id)
104
+ self._meters[device_id] = meter
105
+ return meter.level()
106
+
107
+ def provider_for_screen(self, screen):
108
+ """
109
+ 回傳取樣函式:優先跟隨該螢幕的音源,比對不到則沿用系統預設輸出裝置。
110
+ Return a level provider bound to the screen's endpoint, or the default one.
111
+ """
112
+ device_id = self.device_for_screen(screen)
113
+ if device_id is None:
114
+ return system_audio_level
115
+ front_engine_logger.info(f"[ScreenAudioMeters] screen audio endpoint | id={device_id}")
116
+ return lambda: self.level_for_device(device_id)
117
+
118
+ def close(self) -> None:
119
+ """釋放所有已建立的電表 / Release every meter created so far."""
120
+ for meter in self._meters.values():
121
+ meter.close()
122
+ self._meters.clear()
123
+
124
+
125
+ _screen_meters = ScreenAudioMeters()
126
+
127
+
128
+ def audio_level_provider_for_screen(screen):
129
+ """便利函式:取得跟隨指定螢幕音源的取樣函式(共用電表快取)。"""
130
+ return _screen_meters.provider_for_screen(screen)