messageflight 0.2.4__py3-none-any.whl

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.
message_flight/tts.py ADDED
@@ -0,0 +1,298 @@
1
+ """Text-to-speech notification reader.
2
+
3
+ Provides ``TTSReader`` abstract base class and two concrete implementations:
4
+ - ``SAPIReader`` – Windows SAPI via pywin32 (falls back to no-op if unavailable)
5
+ - ``MiniMaxReader`` – MiniMax online TTS engine via Qt async network + pygame audio
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import binascii
10
+ import json
11
+ import logging
12
+ import os
13
+ import sys
14
+ import tempfile
15
+ import uuid
16
+
17
+ from PyQt6.QtCore import QByteArray, QObject, QTimer, QUrl, pyqtSignal
18
+ from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class TTSReader:
24
+ """Abstract TTS reader that formats a message through a template and
25
+ delegates speaking to :meth:`_speak_impl`.
26
+ """
27
+
28
+ def __init__(self, enabled: bool = True, title_template: str = "{message}"):
29
+ self._enabled = enabled
30
+ self._title_template = title_template
31
+
32
+ def speak(self, message: str) -> None:
33
+ """If enabled, format *message* with :attr:`_title_template` and speak it."""
34
+ if not self._enabled:
35
+ logger.debug("TTSReader.speak: skipped (disabled)")
36
+ return
37
+ try:
38
+ text = self._title_template.replace("{message}", message)
39
+ except Exception as e:
40
+ logger.error("TTS format error: %s", e)
41
+ return
42
+ try:
43
+ logger.debug("TTSReader.speak: speaking text=%r", text)
44
+ self._speak_impl(text)
45
+ except Exception as e:
46
+ logger.error("TTS speak error: %s", e)
47
+
48
+ def _speak_impl(self, text: str) -> None:
49
+ """Concrete subclasses must override this method."""
50
+ raise NotImplementedError
51
+
52
+
53
+ class SAPIReader(TTSReader):
54
+ """Windows SAPI TTS via ``win32com.client``.
55
+
56
+ If ``pywin32`` is not installed or the platform is not Windows,
57
+ initialization silently fails and :meth:`speak` becomes a no-op.
58
+ """
59
+
60
+ def __init__(self, **kwargs):
61
+ super().__init__(**kwargs)
62
+ if self._enabled:
63
+ self._init_sapi()
64
+
65
+ def _init_sapi(self) -> None:
66
+ if sys.platform != "win32":
67
+ logger.warning("SAPIReader: not on Windows, disabling")
68
+ self._enabled = False
69
+ return
70
+ try:
71
+ import win32com.client
72
+
73
+ self._speaker = win32com.client.Dispatch("SAPI.SpVoice")
74
+ logger.info("SAPIReader: SAPI initialized successfully")
75
+ except Exception as e:
76
+ logger.warning("SAPIReader: failed to initialize SAPI: %s", e)
77
+ self._enabled = False
78
+
79
+ # SVSFlagsAsync = 1 — speak asynchronously so we don't block the UI
80
+ _SVS_FLAGS_ASYNC = 1
81
+
82
+ def _speak_impl(self, text: str) -> None:
83
+ logger.debug("SAPIReader._speak_impl: speaking asynchronously")
84
+ self._speaker.Speak(text, self._SVS_FLAGS_ASYNC)
85
+
86
+
87
+ class MiniMaxReader(TTSReader, QObject):
88
+ """MiniMax TTS via QNetworkAccessManager + pygame.mixer.
89
+
90
+ Asynchronously calls the MiniMax API and plays the returned MP3.
91
+ On any error, emits ``error_occurred`` so TTSManager can fall back
92
+ to SAPIReader.
93
+ """
94
+
95
+ playback_finished = pyqtSignal()
96
+ # error_occurred(error_message: str, original_text: str)
97
+ # original_text is passed so TTSManager can fall back with the correct message
98
+ error_occurred = pyqtSignal(str, str)
99
+
100
+ _ENDPOINT = "https://api.minimaxi.com/v1/t2a_v2"
101
+ _DEFAULT_VOICE = "male-qn-qingse"
102
+ _TIMEOUT_MS = 10000
103
+
104
+ def __init__(
105
+ self,
106
+ api_key: str = "",
107
+ voice_id: str = _DEFAULT_VOICE,
108
+ speed: float = 1.0,
109
+ vol: float = 1.0,
110
+ enabled: bool = True,
111
+ title_template: str = "{message}",
112
+ ):
113
+ TTSReader.__init__(self, enabled=enabled, title_template=title_template)
114
+ QObject.__init__(self)
115
+ self._api_key = api_key
116
+ self._voice_id = voice_id
117
+ self._speed = speed
118
+ self._vol = vol
119
+ self._network = QNetworkAccessManager()
120
+ self._active_audio_files: set[str] = set() # Track temp files for cleanup
121
+ self._last_text = "" # Last text sent to MiniMax, for error fallback
122
+ self._reply_text_map: dict[int, str] = {} # id(reply) -> original text
123
+
124
+ self._network.finished.connect(self._on_reply_finished)
125
+ from PyQt6.QtWidgets import QApplication
126
+ app = QApplication.instance()
127
+ if app is not None:
128
+ app.aboutToQuit.connect(self.cleanup)
129
+ logger.info("MiniMaxReader: initialized with voice_id=%s", voice_id)
130
+
131
+ def _speak_impl(self, text: str) -> None:
132
+ self._last_text = text
133
+ if not self._api_key:
134
+ logger.error("MiniMaxReader._speak_impl: api_key is empty")
135
+ self.error_occurred.emit("MiniMax API key is empty", text)
136
+ return
137
+
138
+ logger.info("MiniMaxReader._speak_impl: sending TTS request for text=%r", text[:50])
139
+
140
+ request = QNetworkRequest(QUrl(self._ENDPOINT))
141
+ request.setTransferTimeout(self._TIMEOUT_MS)
142
+ request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json")
143
+ request.setRawHeader(b"Authorization", f"Bearer {self._api_key}".encode())
144
+
145
+ payload = {
146
+ "model": "speech-2.8-hd",
147
+ "text": text,
148
+ "stream": False,
149
+ "voice_setting": {
150
+ "voice_id": self._voice_id,
151
+ "speed": self._speed,
152
+ "vol": self._vol,
153
+ "pitch": 0,
154
+ },
155
+ "audio_setting": {
156
+ "sample_rate": 32000,
157
+ "bitrate": 128000,
158
+ "format": "mp3",
159
+ "channel": 1,
160
+ },
161
+ }
162
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
163
+ logger.debug("MiniMaxReader._speak_impl: request body=%s", body.decode("utf-8")[:200])
164
+ reply = self._network.post(request, body)
165
+ self._reply_text_map[id(reply)] = text
166
+
167
+ def _on_reply_finished(self, reply: QNetworkReply) -> None:
168
+ status_code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
169
+ logger.info("MiniMaxReader._on_reply_finished: HTTP status=%s", status_code)
170
+
171
+ original_text = self._reply_text_map.pop(id(reply), self._last_text)
172
+
173
+ if reply.error() != QNetworkReply.NetworkError.NoError:
174
+ err_msg = f"MiniMax network error: {reply.errorString()}"
175
+ logger.error("MiniMaxReader._on_reply_finished: %s", err_msg)
176
+ self.error_occurred.emit(err_msg, original_text)
177
+ reply.deleteLater()
178
+ return
179
+
180
+ data = reply.readAll()
181
+ if data.isEmpty():
182
+ logger.error("MiniMaxReader._on_reply_finished: empty response")
183
+ self.error_occurred.emit("MiniMax returned empty response", original_text)
184
+ reply.deleteLater()
185
+ return
186
+
187
+ try:
188
+ response_text = data.data().decode("utf-8")
189
+ logger.debug("MiniMaxReader._on_reply_finished: response=%s", response_text[:500])
190
+ response_json = json.loads(response_text)
191
+ except (UnicodeDecodeError, json.JSONDecodeError) as e:
192
+ logger.error("MiniMaxReader._on_reply_finished: failed to parse response: %s", e)
193
+ self.error_occurred.emit(f"MiniMax response parse error: {e}", original_text)
194
+ reply.deleteLater()
195
+ return
196
+
197
+ # Check base_resp for API errors
198
+ base_resp = response_json.get("base_resp", {})
199
+ status_code_api = base_resp.get("status_code", 0)
200
+ if status_code_api != 0:
201
+ status_msg = base_resp.get("status_msg", "unknown error")
202
+ err_msg = f"MiniMax API error {status_code_api}: {status_msg}"
203
+ logger.error("MiniMaxReader._on_reply_finished: %s", err_msg)
204
+ self.error_occurred.emit(err_msg, original_text)
205
+ reply.deleteLater()
206
+ return
207
+
208
+ # Extract hex-encoded audio
209
+ audio_hex = response_json.get("data", {}).get("audio", "")
210
+ if not audio_hex:
211
+ logger.error("MiniMaxReader._on_reply_finished: no audio in response")
212
+ self.error_occurred.emit("MiniMax returned no audio data", self._last_text)
213
+ reply.deleteLater()
214
+ return
215
+
216
+ try:
217
+ audio_bytes = binascii.unhexlify(audio_hex)
218
+ logger.info("MiniMaxReader._on_reply_finished: decoded %d bytes of audio", len(audio_bytes))
219
+ except binascii.Error as e:
220
+ logger.error("MiniMaxReader._on_reply_finished: failed to decode audio hex: %s", e)
221
+ self.error_occurred.emit(f"MiniMax audio decode error: {e}", self._last_text)
222
+ reply.deleteLater()
223
+ return
224
+
225
+ self._buffer = QByteArray(audio_bytes)
226
+ # Use uuid to avoid filename collisions between concurrent requests
227
+ audio_path = os.path.join(tempfile.gettempdir(), f"messageflight_{uuid.uuid4().hex}.mp3")
228
+ with open(audio_path, "wb") as f:
229
+ f.write(self._buffer.data())
230
+
231
+ self._active_audio_files.add(audio_path)
232
+ logger.info("MiniMaxReader._on_reply_finished: saved audio to %s (%d bytes)", audio_path, len(audio_bytes))
233
+
234
+ # Use pygame.mixer to play audio (reliable cross-platform MP3 playback)
235
+ self._play_audio_with_pygame(audio_path)
236
+ reply.deleteLater()
237
+
238
+ def _play_audio_with_pygame(self, audio_path: str) -> None:
239
+ """Play audio file using pygame.mixer."""
240
+ try:
241
+ import pygame
242
+
243
+ # Initialize pygame mixer if not already initialized
244
+ if not pygame.mixer.get_init():
245
+ pygame.mixer.init(frequency=32000)
246
+ logger.info("MiniMaxReader: pygame.mixer initialized")
247
+
248
+ # Load and play the audio
249
+ sound = pygame.mixer.Sound(audio_path)
250
+ sound.set_volume(self._vol)
251
+ sound.play()
252
+ logger.info("MiniMaxReader: playing audio from %s", audio_path)
253
+
254
+ # Track this file for cleanup
255
+ timer = QTimer(self)
256
+ timer.setSingleShot(True)
257
+ # Capture audio_path by value to avoid closure issues
258
+ timer.timeout.connect(lambda _path=audio_path, _timer=timer: self._cleanup_after_playback(_path, _timer))
259
+ timer.start(100) # Check every 100ms
260
+
261
+ except Exception as e:
262
+ logger.error("MiniMaxReader: failed to play audio with pygame: %s", e)
263
+ self._remove_audio_file(audio_path)
264
+ self.error_occurred.emit(f"Audio playback error: {e}", self._last_text)
265
+
266
+ def _cleanup_after_playback(self, audio_path: str, timer: QTimer) -> None:
267
+ """Check if pygame audio playback has finished and clean up the file."""
268
+ try:
269
+ import pygame
270
+ if not pygame.mixer.get_busy():
271
+ logger.debug("MiniMaxReader: playback finished for %s", audio_path)
272
+ self.playback_finished.emit()
273
+ timer.stop()
274
+ timer.deleteLater()
275
+ self._remove_audio_file(audio_path)
276
+ else:
277
+ # Still playing, check again
278
+ timer.start(100)
279
+ except Exception:
280
+ timer.stop()
281
+ timer.deleteLater()
282
+ self._remove_audio_file(audio_path)
283
+
284
+ def _remove_audio_file(self, audio_path: str) -> None:
285
+ """Remove a temporary audio file and update tracking."""
286
+ self._active_audio_files.discard(audio_path)
287
+ if os.path.exists(audio_path):
288
+ try:
289
+ os.remove(audio_path)
290
+ logger.debug("MiniMaxReader: removed temp file %s", audio_path)
291
+ except OSError as e:
292
+ logger.warning("MiniMaxReader: failed to remove temp file %s: %s", audio_path, e)
293
+
294
+ def cleanup(self) -> None:
295
+ """Remove all active temp audio files. Called on application exit."""
296
+ for path in list(self._active_audio_files):
297
+ self._remove_audio_file(path)
298
+ self._active_audio_files.clear()
@@ -0,0 +1,98 @@
1
+ """TTS provider manager with automatic fallback."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Optional
6
+
7
+ from PyQt6.QtCore import QObject, pyqtSignal
8
+
9
+ from message_flight.config import AppConfig
10
+ from message_flight.tts import MiniMaxReader, SAPIReader, TTSReader
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class TTSManager(QObject):
16
+ """Manages TTS providers and handles automatic fallback.
17
+
18
+ Usage:
19
+ mgr = TTSManager(config)
20
+ mgr.speak("notification text")
21
+
22
+ On MiniMax error, automatically falls back to SAPIReader.
23
+ """
24
+
25
+ provider_changed = pyqtSignal(str)
26
+ fallback_triggered = pyqtSignal(str)
27
+
28
+ def __init__(self, config: AppConfig, parent: Optional[QObject] = None):
29
+ super().__init__(parent)
30
+ self._config = config
31
+ self._current_provider_name = config.tts_provider
32
+ logger.info("TTSManager: initialized with provider=%s", self._current_provider_name)
33
+ self._providers: dict[str, TTSReader] = {}
34
+ self._last_message = ""
35
+ self._init_providers()
36
+
37
+ def _init_providers(self) -> None:
38
+ """Initialize all known providers."""
39
+ self._providers["sapi"] = SAPIReader(
40
+ enabled=True,
41
+ title_template="您有新消息了。{message}",
42
+ )
43
+ key_preview = self._config.minimax_subscription_key[:8] + "..." if self._config.minimax_subscription_key else "(empty)"
44
+ logger.info("TTSManager._init_providers: creating MiniMaxReader with key=%s", key_preview)
45
+ self._providers["minimax"] = MiniMaxReader(
46
+ api_key=self._config.minimax_subscription_key,
47
+ enabled=True,
48
+ title_template="您有新消息了。{message}",
49
+ )
50
+ # Connect MiniMax error signal to fallback handler
51
+ minimax = self._providers.get("minimax")
52
+ if isinstance(minimax, MiniMaxReader):
53
+ minimax.error_occurred.connect(self._on_minimax_error)
54
+
55
+ def speak(self, message: str) -> None:
56
+ """Speak a message using the current provider."""
57
+ logger.info("TTSManager.speak: provider=%s message=%r", self._current_provider_name, message[:50])
58
+ self._last_message = message
59
+ provider = self._providers.get(self._current_provider_name)
60
+ if provider is None:
61
+ logger.warning("TTSManager.speak: unknown provider %r, falling back to sapi", self._current_provider_name)
62
+ provider = self._providers.get("sapi")
63
+ if provider is not None:
64
+ provider.speak(message)
65
+
66
+ def update_config(self, config: AppConfig) -> None:
67
+ """Hot-update configuration (e.g. after settings dialog)."""
68
+ logger.info("TTSManager.update_config: provider=%s -> %s", self._current_provider_name, config.tts_provider)
69
+ self._config = config
70
+ old_provider = self._current_provider_name
71
+ self._current_provider_name = config.tts_provider
72
+
73
+ # Update MiniMaxReader API key if changed
74
+ minimax = self._providers.get("minimax")
75
+ if isinstance(minimax, MiniMaxReader):
76
+ old_key = minimax._api_key[:8] + "..." if minimax._api_key else "(empty)"
77
+ new_key = config.minimax_subscription_key[:8] + "..." if config.minimax_subscription_key else "(empty)"
78
+ logger.info("TTSManager.update_config: updating MiniMax key %s -> %s", old_key, new_key)
79
+ minimax._api_key = config.minimax_subscription_key
80
+
81
+ if old_provider != self._current_provider_name:
82
+ self.provider_changed.emit(self._current_provider_name)
83
+
84
+ def _on_minimax_error(self, error_msg: str, original_text: str) -> None:
85
+ """Handle MiniMax error by falling back to SAPI.
86
+
87
+ Args:
88
+ error_msg: Human-readable error description.
89
+ original_text: The text that was supposed to be spoken;
90
+ passed explicitly to avoid race conditions with
91
+ rapid successive speak() calls.
92
+ """
93
+ logger.error("TTSManager: MiniMax failed (%s), falling back to SAPI", error_msg)
94
+ self.fallback_triggered.emit(error_msg)
95
+ sapi = self._providers.get("sapi")
96
+ if sapi is not None and original_text:
97
+ logger.info("TTSManager: falling back to SAPI for message=%r", original_text[:50])
98
+ sapi.speak(original_text)
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: messageflight
3
+ Version: 0.2.4
4
+ Summary: Let Windows notifications fly across your screen like a little plane.
5
+ Project-URL: Homepage, https://github.com/wx528/MessageFlight
6
+ Project-URL: Issues, https://github.com/wx528/MessageFlight/issues
7
+ Author: MessageFlight Contributors
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 MessageFlight Contributors
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Requires-Python: >=3.8
31
+ Requires-Dist: pygame>=2.5.0
32
+ Requires-Dist: pyqt6>=6.5.0
33
+ Requires-Dist: pywin32>=227; sys_platform == 'win32'
34
+ Requires-Dist: winsdk>=1.0.0b10
35
+ Provides-Extra: dev
36
+ Requires-Dist: mypy<1.15,>=1.8; extra == 'dev'
37
+ Requires-Dist: pytest-qt>=4.0; extra == 'dev'
38
+ Requires-Dist: pytest<9,>=7.0; extra == 'dev'
39
+ Requires-Dist: types-pywin32>=311; (python_version >= '3.9') and extra == 'dev'
40
+ Description-Content-Type: text/markdown
41
+
42
+ # MessageFlight
43
+
44
+ [中文](README.zh.md) | English | [日本語](README.ja.md) | [한국어](README.ko.md) | [Bahasa Indonesia](README.id.md) | [ไทย](README.th.md) | [Tiếng Việt](README.vi.md) | [Bahasa Melayu](README.ms.md)
45
+
46
+ <p align="center">
47
+ <img src="https://img.shields.io/badge/Python-3.8%2B-3776AB?style=flat&logo=python&logoColor=white" alt="Python">
48
+ <img src="https://img.shields.io/badge/GUI-PyQt6-41CD52?style=flat" alt="PyQt6">
49
+ <img src="https://img.shields.io/badge/Platform-Windows%2010%2F11-0078D6?style=flat&logo=windows&logoColor=white" alt="Windows">
50
+ <a href="https://pypi.org/project/messageflight/"><img src="https://img.shields.io/pypi/v/messageflight?style=flat&logo=pypi&logoColor=white" alt="PyPI"></a>
51
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow?style=flat" alt="License"></a>
52
+ <a href="https://github.com/wx528/MessageFlight/actions/workflows/ci.yml"><img src="https://github.com/wx528/MessageFlight/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
53
+ <br>
54
+ <img src="https://img.shields.io/badge/Languages-zh%20%7C%20en%20%7C%20ja%20%7C%20ko%20%7C%20id%20%7C%20th%20%7C%20vi%20%7C%20ms-8A2BE2?style=flat" alt="Languages">
55
+ </p>
56
+
57
+ Let Windows notifications fly across your screen like a little plane.
58
+
59
+ ## Screenshots
60
+
61
+ | | | |
62
+ |:---:|:---:|:---:|
63
+ | ![Plane flying above a game window](screenshots/screen_top_on_game01.png) | ![Plane flying above a game window](screenshots/screen_top_on_game02.png) | ![Plane flying across the desktop](screenshots/screen_top_on_screen.png) |
64
+ | ![Plane in cyber preset](screenshots/screen_other_color.png) | | |
65
+
66
+ ## Features
67
+
68
+ - Animated plane overlay for real Windows notifications
69
+ - System tray controls for pause, demo notification, do-not-disturb, settings, autostart, and quit
70
+ - Lightweight UI languages: Chinese, English, Japanese, Korean, Indonesian, Thai, Vietnamese, and Malay
71
+ - Custom colors, flight paths, and vehicle presets
72
+ - Optional TTS support through SAPI or MiniMax
73
+
74
+ ## Quick Start
75
+
76
+ Requires Windows 10/11 and Python 3.8+.
77
+
78
+ ```bash
79
+ git clone https://github.com/wx528/MessageFlight.git
80
+ cd MessageFlight
81
+ python -m venv .venv
82
+ .venv\Scripts\activate
83
+ pip install .
84
+ python message_flight.py
85
+ ```
86
+
87
+ Using `uv`:
88
+
89
+ ```bash
90
+ uv sync
91
+ uv run python message_flight.py
92
+ ```
93
+
94
+ [MIT License](LICENSE)
@@ -0,0 +1,24 @@
1
+ message_flight/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ message_flight/autostart.py,sha256=ugzxII8aih_kXl85d4fHvoZd6p0F1TNU4_sG3tCmzWw,1150
3
+ message_flight/config.py,sha256=8pA-EEJpnV3wnbIt-G-XO8hTby1rwx7g8LrAJktcAAk,17287
4
+ message_flight/demo_notifications.py,sha256=c_XqbRTZ37Ojej7obppyA-cB_RNYmLSndo9uEQhZxZs,350
5
+ message_flight/flight_widget.py,sha256=SSHfs_8KFi-Lf-GKZz_AqEjTBiHDbQ3vFPs0Co9N-1w,19306
6
+ message_flight/i18n.py,sha256=vxk8thbAPLXVyhZKFQvvdRteAL9dbkEVWwfXKpY6qws,18681
7
+ message_flight/notification_queue.py,sha256=hqoJaAxmLNNJnK61HawblhSu0_PaOcKwT1ON83KiD3A,2315
8
+ message_flight/notification_worker.py,sha256=xPEBVRiRBb9tx1ivfQbfJNtOFOzj3JxAuVbACOy40HI,3687
9
+ message_flight/plane_banner.py,sha256=YrdwOi5Ymjl3VPO4CxG1a7ySOLpTQ5h6aqm5p1q7F9w,14369
10
+ message_flight/preset_editor.py,sha256=az2JNA-LdeuXPBLE5B-MBeb2XVLqn52L53HKcIRQ-Cw,13239
11
+ message_flight/settings_dialog.py,sha256=WyttfOB9CnP8saV-AfRtBOT0cugy8mFZMHaTG79MBh0,10600
12
+ message_flight/tray_app.py,sha256=y89A5Wm0IqtlPyGpR-U62PJh3mUnDeXvDDkqX05_MsQ,11116
13
+ message_flight/tts.py,sha256=A5vU8A5e-bqXQbn90Lrm5jYH84ufGyhMFgoeYuXqt_Y,12248
14
+ message_flight/tts_manager.py,sha256=UkUJ1xLHJeQbgMPRAz16UaJAAPmxULBuD7nVV-ZzwP4,4307
15
+ message_flight/plane_presets/__init__.py,sha256=zQxkbsNgPTWICirpcYnqXz1Ad3D-FmyyktqWOhcaVE0,602
16
+ message_flight/plane_presets/airplane.py,sha256=jhfoctq3GCW9FZzKkaQI6fCmd92Une1xbVaOr7ndOXA,4287
17
+ message_flight/plane_presets/base.py,sha256=Lw6HeHIvwPitaG9mvQDQtzqkviTtSngPUA8hIIt5NHM,668
18
+ message_flight/plane_presets/bird.py,sha256=gVvAjuwUk6BpV3unr9zjevV4-GfvPUm7SuHi-4fmniA,3287
19
+ message_flight/plane_presets/rocket.py,sha256=HJ3PGbDjYZEUgquypx5jn9KngAsu2v8oUoMWwhpMXaI,3509
20
+ message_flight/plane_presets/ufo.py,sha256=cDXYpy-QnNV2vw58j85LEFfd5_UAHWtgPQwfnbJyXPk,2676
21
+ messageflight-0.2.4.dist-info/METADATA,sha256=sEpeAHBo7IrPFzP4Z7ayyHmdYotfzCvP9Cg4AnYn31Q,4324
22
+ messageflight-0.2.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
23
+ messageflight-0.2.4.dist-info/licenses/LICENSE,sha256=B_KZ3UmKfNSncgBrsubFqqHRhWwFIPt2gunQzzoCLg0,1104
24
+ messageflight-0.2.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MessageFlight Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.