meeting-noter 1.3.0__py3-none-any.whl → 3.0.0__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.
- meeting_noter/__init__.py +1 -1
- meeting_noter/cli.py +103 -0
- meeting_noter/daemon.py +38 -0
- meeting_noter/gui/__init__.py +51 -0
- meeting_noter/gui/main_window.py +219 -0
- meeting_noter/gui/menubar.py +248 -0
- meeting_noter/gui/screens/__init__.py +17 -0
- meeting_noter/gui/screens/dashboard.py +262 -0
- meeting_noter/gui/screens/logs.py +184 -0
- meeting_noter/gui/screens/recordings.py +279 -0
- meeting_noter/gui/screens/search.py +229 -0
- meeting_noter/gui/screens/settings.py +232 -0
- meeting_noter/gui/screens/viewer.py +140 -0
- meeting_noter/gui/theme/__init__.py +5 -0
- meeting_noter/gui/theme/dark_theme.py +53 -0
- meeting_noter/gui/theme/styles.qss +504 -0
- meeting_noter/gui/utils/__init__.py +15 -0
- meeting_noter/gui/utils/signals.py +82 -0
- meeting_noter/gui/utils/workers.py +258 -0
- meeting_noter/gui/widgets/__init__.py +6 -0
- meeting_noter/gui/widgets/sidebar.py +210 -0
- meeting_noter/gui/widgets/status_indicator.py +108 -0
- meeting_noter/mic_monitor.py +29 -1
- meeting_noter/ui/__init__.py +5 -0
- meeting_noter/ui/app.py +68 -0
- meeting_noter/ui/screens/__init__.py +17 -0
- meeting_noter/ui/screens/logs.py +166 -0
- meeting_noter/ui/screens/main.py +346 -0
- meeting_noter/ui/screens/recordings.py +241 -0
- meeting_noter/ui/screens/search.py +191 -0
- meeting_noter/ui/screens/settings.py +184 -0
- meeting_noter/ui/screens/viewer.py +116 -0
- meeting_noter/ui/styles/app.tcss +257 -0
- meeting_noter/ui/widgets/__init__.py +1 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/METADATA +4 -1
- meeting_noter-3.0.0.dist-info/RECORD +65 -0
- meeting_noter-1.3.0.dist-info/RECORD +0 -35
- {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/WHEEL +0 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/entry_points.txt +0 -0
- {meeting_noter-1.3.0.dist-info → meeting_noter-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Background worker threads for Meeting Noter GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from PySide6.QtCore import QThread, Signal
|
|
11
|
+
|
|
12
|
+
from meeting_noter.config import get_config
|
|
13
|
+
from meeting_noter.daemon import is_process_running, read_pid_file
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
|
|
17
|
+
WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
|
|
18
|
+
LOG_FILE = Path.home() / ".meeting-noter.log"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StatusPollingWorker(QThread):
|
|
22
|
+
"""Polls daemon/watcher status periodically."""
|
|
23
|
+
|
|
24
|
+
status_updated = Signal(bool, bool, str) # watcher_running, daemon_running, meeting_name
|
|
25
|
+
|
|
26
|
+
def __init__(self, interval_ms: int = 2000):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self._stop_flag = False
|
|
29
|
+
self._interval = interval_ms
|
|
30
|
+
|
|
31
|
+
def run(self):
|
|
32
|
+
"""Poll status until stopped."""
|
|
33
|
+
while not self._stop_flag:
|
|
34
|
+
watcher = self._check_watcher()
|
|
35
|
+
daemon, name = self._check_daemon()
|
|
36
|
+
self.status_updated.emit(watcher, daemon, name or "")
|
|
37
|
+
self.msleep(self._interval)
|
|
38
|
+
|
|
39
|
+
def stop(self):
|
|
40
|
+
"""Stop the worker."""
|
|
41
|
+
self._stop_flag = True
|
|
42
|
+
self.wait()
|
|
43
|
+
|
|
44
|
+
def _check_watcher(self) -> bool:
|
|
45
|
+
"""Check if watcher is running."""
|
|
46
|
+
if WATCHER_PID_FILE.exists():
|
|
47
|
+
try:
|
|
48
|
+
pid = int(WATCHER_PID_FILE.read_text().strip())
|
|
49
|
+
os.kill(pid, 0)
|
|
50
|
+
return True
|
|
51
|
+
except (ProcessLookupError, ValueError, FileNotFoundError, PermissionError):
|
|
52
|
+
pass
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
def _check_daemon(self) -> tuple[bool, Optional[str]]:
|
|
56
|
+
"""Check if daemon is running and get recording name."""
|
|
57
|
+
daemon_pid = read_pid_file(DEFAULT_PID_FILE)
|
|
58
|
+
if daemon_pid and is_process_running(daemon_pid):
|
|
59
|
+
recording_name = self._get_current_recording_name()
|
|
60
|
+
return True, recording_name
|
|
61
|
+
return False, None
|
|
62
|
+
|
|
63
|
+
def _get_current_recording_name(self) -> Optional[str]:
|
|
64
|
+
"""Get the name of the current recording from the log file."""
|
|
65
|
+
if not LOG_FILE.exists():
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with open(LOG_FILE, "r") as f:
|
|
70
|
+
lines = f.readlines()
|
|
71
|
+
|
|
72
|
+
for line in reversed(lines[-50:]):
|
|
73
|
+
if "Recording started:" in line:
|
|
74
|
+
parts = line.split("Recording started:")
|
|
75
|
+
if len(parts) > 1:
|
|
76
|
+
filename = parts[1].strip().replace(".mp3", "")
|
|
77
|
+
name_parts = filename.split("_", 2)
|
|
78
|
+
if len(name_parts) >= 3:
|
|
79
|
+
return name_parts[2]
|
|
80
|
+
return filename
|
|
81
|
+
elif "Recording saved:" in line or "Recording discarded" in line:
|
|
82
|
+
break
|
|
83
|
+
return None
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LiveTranscriptWatcher(QThread):
|
|
89
|
+
"""Watches live transcript file for changes."""
|
|
90
|
+
|
|
91
|
+
new_content = Signal(str) # new text content
|
|
92
|
+
file_changed = Signal(str) # new file name
|
|
93
|
+
recording_ended = Signal()
|
|
94
|
+
|
|
95
|
+
def __init__(self, interval_ms: int = 500):
|
|
96
|
+
super().__init__()
|
|
97
|
+
self._stop_flag = False
|
|
98
|
+
self._interval = interval_ms
|
|
99
|
+
self._current_file: Optional[Path] = None
|
|
100
|
+
self._last_content = ""
|
|
101
|
+
self._last_mtime = 0.0
|
|
102
|
+
|
|
103
|
+
def run(self):
|
|
104
|
+
"""Watch for live transcript updates."""
|
|
105
|
+
while not self._stop_flag:
|
|
106
|
+
self._check_live_transcript()
|
|
107
|
+
self.msleep(self._interval)
|
|
108
|
+
|
|
109
|
+
def stop(self):
|
|
110
|
+
"""Stop the worker."""
|
|
111
|
+
self._stop_flag = True
|
|
112
|
+
self.wait()
|
|
113
|
+
|
|
114
|
+
def _check_live_transcript(self) -> None:
|
|
115
|
+
"""Check for live transcript updates."""
|
|
116
|
+
config = get_config()
|
|
117
|
+
live_dir = config.recordings_dir / "live"
|
|
118
|
+
|
|
119
|
+
if not live_dir.exists():
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Find the most recent live file
|
|
123
|
+
try:
|
|
124
|
+
live_files = sorted(
|
|
125
|
+
live_dir.glob("*.live.txt"),
|
|
126
|
+
key=lambda p: p.stat().st_mtime,
|
|
127
|
+
reverse=True,
|
|
128
|
+
)
|
|
129
|
+
except Exception:
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if not live_files:
|
|
133
|
+
if self._current_file is not None:
|
|
134
|
+
self.recording_ended.emit()
|
|
135
|
+
self._current_file = None
|
|
136
|
+
self._last_content = ""
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
live_file = live_files[0]
|
|
140
|
+
|
|
141
|
+
# Check if file is recent (active within last 30 seconds)
|
|
142
|
+
try:
|
|
143
|
+
file_mtime = live_file.stat().st_mtime
|
|
144
|
+
if time.time() - file_mtime > 30:
|
|
145
|
+
if self._current_file is not None:
|
|
146
|
+
self.recording_ended.emit()
|
|
147
|
+
self._current_file = None
|
|
148
|
+
self._last_content = ""
|
|
149
|
+
return
|
|
150
|
+
except Exception:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Detect new file (new recording started)
|
|
154
|
+
if self._current_file != live_file:
|
|
155
|
+
self._current_file = live_file
|
|
156
|
+
self._last_content = ""
|
|
157
|
+
self._last_mtime = file_mtime
|
|
158
|
+
meeting_name = live_file.stem.replace(".live", "")
|
|
159
|
+
self.file_changed.emit(meeting_name)
|
|
160
|
+
|
|
161
|
+
# Read and emit new content
|
|
162
|
+
try:
|
|
163
|
+
content = live_file.read_text()
|
|
164
|
+
if len(content) > len(self._last_content):
|
|
165
|
+
new_content = content[len(self._last_content) :]
|
|
166
|
+
self.new_content.emit(new_content)
|
|
167
|
+
self._last_content = content
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TranscriptionWorker(QThread):
|
|
173
|
+
"""Runs transcription in background."""
|
|
174
|
+
|
|
175
|
+
progress = Signal(str) # status message
|
|
176
|
+
finished = Signal(str) # transcript path
|
|
177
|
+
error = Signal(str) # error message
|
|
178
|
+
|
|
179
|
+
def __init__(self, audio_path: Path, model: str = ""):
|
|
180
|
+
super().__init__()
|
|
181
|
+
self._audio_path = audio_path
|
|
182
|
+
self._model = model
|
|
183
|
+
|
|
184
|
+
def run(self):
|
|
185
|
+
"""Run transcription."""
|
|
186
|
+
import subprocess
|
|
187
|
+
import sys
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
self.progress.emit("Starting transcription...")
|
|
191
|
+
|
|
192
|
+
cmd = [sys.executable, "-m", "meeting_noter.cli", "transcribe", str(self._audio_path)]
|
|
193
|
+
if self._model:
|
|
194
|
+
cmd.extend(["--model", self._model])
|
|
195
|
+
|
|
196
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
197
|
+
|
|
198
|
+
if result.returncode == 0:
|
|
199
|
+
# Extract transcript path from output
|
|
200
|
+
output = result.stdout
|
|
201
|
+
for line in output.splitlines():
|
|
202
|
+
if "Transcript saved:" in line:
|
|
203
|
+
path = line.split("Transcript saved:")[-1].strip()
|
|
204
|
+
self.finished.emit(path)
|
|
205
|
+
return
|
|
206
|
+
self.finished.emit("")
|
|
207
|
+
else:
|
|
208
|
+
self.error.emit(result.stderr or "Transcription failed")
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
self.error.emit(str(e))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class LogWatcher(QThread):
|
|
215
|
+
"""Watches log file for new content."""
|
|
216
|
+
|
|
217
|
+
new_content = Signal(str) # new log lines
|
|
218
|
+
|
|
219
|
+
def __init__(self, interval_ms: int = 1000):
|
|
220
|
+
super().__init__()
|
|
221
|
+
self._stop_flag = False
|
|
222
|
+
self._interval = interval_ms
|
|
223
|
+
self._last_size = 0
|
|
224
|
+
|
|
225
|
+
def run(self):
|
|
226
|
+
"""Watch log file for changes."""
|
|
227
|
+
while not self._stop_flag:
|
|
228
|
+
self._check_log_file()
|
|
229
|
+
self.msleep(self._interval)
|
|
230
|
+
|
|
231
|
+
def stop(self):
|
|
232
|
+
"""Stop the worker."""
|
|
233
|
+
self._stop_flag = True
|
|
234
|
+
self.wait()
|
|
235
|
+
|
|
236
|
+
def _check_log_file(self) -> None:
|
|
237
|
+
"""Check for new log content."""
|
|
238
|
+
if not LOG_FILE.exists():
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
current_size = LOG_FILE.stat().st_size
|
|
243
|
+
if current_size > self._last_size:
|
|
244
|
+
with open(LOG_FILE, "r") as f:
|
|
245
|
+
f.seek(self._last_size)
|
|
246
|
+
new_content = f.read()
|
|
247
|
+
if new_content:
|
|
248
|
+
self.new_content.emit(new_content)
|
|
249
|
+
self._last_size = current_size
|
|
250
|
+
elif current_size < self._last_size:
|
|
251
|
+
# File was truncated, reset
|
|
252
|
+
self._last_size = 0
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
|
|
256
|
+
def reset(self):
|
|
257
|
+
"""Reset the file position."""
|
|
258
|
+
self._last_size = 0
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Navigation sidebar widget for Meeting Noter GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Qt, Signal
|
|
6
|
+
from PySide6.QtGui import QFont
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QButtonGroup,
|
|
9
|
+
QFrame,
|
|
10
|
+
QLabel,
|
|
11
|
+
QPushButton,
|
|
12
|
+
QSizePolicy,
|
|
13
|
+
QSpacerItem,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SidebarButton(QPushButton):
|
|
20
|
+
"""A navigation button for the sidebar."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, text: str, icon: str = "", parent=None):
|
|
23
|
+
super().__init__(parent)
|
|
24
|
+
display_text = f" {icon} {text}" if icon else f" {text}"
|
|
25
|
+
self.setText(display_text)
|
|
26
|
+
self.setCheckable(True)
|
|
27
|
+
self.setMinimumHeight(50)
|
|
28
|
+
self.setCursor(Qt.PointingHandCursor)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Sidebar(QWidget):
|
|
32
|
+
"""Navigation sidebar with icon buttons."""
|
|
33
|
+
|
|
34
|
+
screen_selected = Signal(str) # Emits screen name when clicked
|
|
35
|
+
quit_requested = Signal() # Emits when quit button clicked
|
|
36
|
+
|
|
37
|
+
# Navigation items with better Unicode icons
|
|
38
|
+
NAV_ITEMS = [
|
|
39
|
+
("dashboard", "Dashboard", "⌂"), # Home
|
|
40
|
+
("recordings", "Recordings", "◉"), # Record
|
|
41
|
+
("search", "Search", "⌕"), # Search
|
|
42
|
+
("settings", "Settings", "⚙"), # Gear
|
|
43
|
+
("logs", "Logs", "☰"), # Menu/logs
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
def __init__(self, parent=None):
|
|
47
|
+
super().__init__(parent)
|
|
48
|
+
self.setObjectName("sidebar")
|
|
49
|
+
self.setMinimumWidth(220)
|
|
50
|
+
self.setMaximumWidth(220)
|
|
51
|
+
|
|
52
|
+
self._setup_ui()
|
|
53
|
+
self._connect_signals()
|
|
54
|
+
|
|
55
|
+
def _setup_ui(self):
|
|
56
|
+
"""Set up the sidebar UI."""
|
|
57
|
+
layout = QVBoxLayout(self)
|
|
58
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
59
|
+
layout.setSpacing(0)
|
|
60
|
+
|
|
61
|
+
# Title/Logo area
|
|
62
|
+
title_frame = QFrame()
|
|
63
|
+
title_frame.setObjectName("sidebar-title-frame")
|
|
64
|
+
title_layout = QVBoxLayout(title_frame)
|
|
65
|
+
title_layout.setContentsMargins(20, 20, 20, 20)
|
|
66
|
+
|
|
67
|
+
title = QLabel("Meeting Noter")
|
|
68
|
+
title.setObjectName("sidebar-title")
|
|
69
|
+
title_font = QFont("Menlo, Monaco, Courier New, monospace", 18)
|
|
70
|
+
title_font.setBold(True)
|
|
71
|
+
title.setFont(title_font)
|
|
72
|
+
title.setAlignment(Qt.AlignCenter)
|
|
73
|
+
title_layout.addWidget(title)
|
|
74
|
+
|
|
75
|
+
subtitle = QLabel("v2.0")
|
|
76
|
+
subtitle.setObjectName("sidebar-subtitle")
|
|
77
|
+
subtitle.setAlignment(Qt.AlignCenter)
|
|
78
|
+
subtitle.setStyleSheet("color: #5a5a5a; font-size: 12px;")
|
|
79
|
+
title_layout.addWidget(subtitle)
|
|
80
|
+
|
|
81
|
+
layout.addWidget(title_frame)
|
|
82
|
+
|
|
83
|
+
# Separator
|
|
84
|
+
separator = QFrame()
|
|
85
|
+
separator.setFrameShape(QFrame.HLine)
|
|
86
|
+
separator.setStyleSheet("background-color: #3c3c3c; max-height: 1px;")
|
|
87
|
+
layout.addWidget(separator)
|
|
88
|
+
|
|
89
|
+
# Navigation section label
|
|
90
|
+
nav_label = QLabel(" NAVIGATION")
|
|
91
|
+
nav_label.setObjectName("sidebar-section-label")
|
|
92
|
+
nav_label.setStyleSheet(
|
|
93
|
+
"color: #5a5a5a; font-size: 11px; font-weight: 600; "
|
|
94
|
+
"letter-spacing: 1px; padding: 15px 0 5px 15px;"
|
|
95
|
+
)
|
|
96
|
+
layout.addWidget(nav_label)
|
|
97
|
+
|
|
98
|
+
# Navigation buttons
|
|
99
|
+
self._button_group = QButtonGroup(self)
|
|
100
|
+
self._button_group.setExclusive(True)
|
|
101
|
+
self._buttons: dict[str, SidebarButton] = {}
|
|
102
|
+
|
|
103
|
+
button_font = QFont("Menlo, Monaco, Courier New, monospace", 14)
|
|
104
|
+
|
|
105
|
+
for screen_id, label, icon in self.NAV_ITEMS:
|
|
106
|
+
btn = SidebarButton(label, icon)
|
|
107
|
+
btn.setFont(button_font)
|
|
108
|
+
btn.setProperty("screen_id", screen_id)
|
|
109
|
+
self._buttons[screen_id] = btn
|
|
110
|
+
self._button_group.addButton(btn)
|
|
111
|
+
layout.addWidget(btn)
|
|
112
|
+
|
|
113
|
+
# Spacer
|
|
114
|
+
layout.addItem(QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding))
|
|
115
|
+
|
|
116
|
+
# Status area at bottom
|
|
117
|
+
separator2 = QFrame()
|
|
118
|
+
separator2.setFrameShape(QFrame.HLine)
|
|
119
|
+
separator2.setStyleSheet("background-color: #3c3c3c; max-height: 1px;")
|
|
120
|
+
layout.addWidget(separator2)
|
|
121
|
+
|
|
122
|
+
self._status_frame = QFrame()
|
|
123
|
+
self._status_frame.setObjectName("sidebar-status")
|
|
124
|
+
status_layout = QVBoxLayout(self._status_frame)
|
|
125
|
+
status_layout.setContentsMargins(15, 15, 15, 15)
|
|
126
|
+
|
|
127
|
+
status_label = QLabel("STATUS")
|
|
128
|
+
status_label.setStyleSheet(
|
|
129
|
+
"color: #5a5a5a; font-size: 11px; font-weight: 600; letter-spacing: 1px;"
|
|
130
|
+
)
|
|
131
|
+
status_layout.addWidget(status_label)
|
|
132
|
+
|
|
133
|
+
self._status_text = QLabel("● Idle")
|
|
134
|
+
self._status_text.setObjectName("sidebar-status-text")
|
|
135
|
+
status_font = QFont("Menlo, Monaco, Courier New, monospace", 13)
|
|
136
|
+
self._status_text.setFont(status_font)
|
|
137
|
+
self._status_text.setStyleSheet("color: #5a5a5a; padding-top: 5px;")
|
|
138
|
+
status_layout.addWidget(self._status_text)
|
|
139
|
+
|
|
140
|
+
layout.addWidget(self._status_frame)
|
|
141
|
+
|
|
142
|
+
# Quit button at bottom
|
|
143
|
+
separator3 = QFrame()
|
|
144
|
+
separator3.setFrameShape(QFrame.HLine)
|
|
145
|
+
separator3.setStyleSheet("background-color: #3c3c3c; max-height: 1px;")
|
|
146
|
+
layout.addWidget(separator3)
|
|
147
|
+
|
|
148
|
+
self._quit_btn = QPushButton(" ⏻ Quit")
|
|
149
|
+
self._quit_btn.setFont(button_font)
|
|
150
|
+
self._quit_btn.setMinimumHeight(50)
|
|
151
|
+
self._quit_btn.setCursor(Qt.PointingHandCursor)
|
|
152
|
+
self._quit_btn.setStyleSheet("""
|
|
153
|
+
QPushButton {
|
|
154
|
+
background-color: transparent;
|
|
155
|
+
border: none;
|
|
156
|
+
border-radius: 6px;
|
|
157
|
+
padding: 12px 15px;
|
|
158
|
+
text-align: left;
|
|
159
|
+
color: #808080;
|
|
160
|
+
font-size: 14px;
|
|
161
|
+
margin: 3px 10px;
|
|
162
|
+
}
|
|
163
|
+
QPushButton:hover {
|
|
164
|
+
background-color: #2a1a1a;
|
|
165
|
+
color: #f14c4c;
|
|
166
|
+
}
|
|
167
|
+
""")
|
|
168
|
+
layout.addWidget(self._quit_btn)
|
|
169
|
+
|
|
170
|
+
# Bottom padding
|
|
171
|
+
layout.addSpacing(10)
|
|
172
|
+
|
|
173
|
+
# Select dashboard by default
|
|
174
|
+
self._buttons["dashboard"].setChecked(True)
|
|
175
|
+
|
|
176
|
+
def _connect_signals(self):
|
|
177
|
+
"""Connect button signals."""
|
|
178
|
+
for btn in self._buttons.values():
|
|
179
|
+
btn.clicked.connect(self._on_button_clicked)
|
|
180
|
+
self._quit_btn.clicked.connect(self._on_quit_clicked)
|
|
181
|
+
|
|
182
|
+
def _on_button_clicked(self):
|
|
183
|
+
"""Handle navigation button click."""
|
|
184
|
+
btn = self.sender()
|
|
185
|
+
if btn:
|
|
186
|
+
screen_id = btn.property("screen_id")
|
|
187
|
+
self.screen_selected.emit(screen_id)
|
|
188
|
+
|
|
189
|
+
def _on_quit_clicked(self):
|
|
190
|
+
"""Handle quit button click."""
|
|
191
|
+
self.quit_requested.emit()
|
|
192
|
+
|
|
193
|
+
def select_screen(self, screen_id: str):
|
|
194
|
+
"""Programmatically select a screen."""
|
|
195
|
+
if screen_id in self._buttons:
|
|
196
|
+
self._buttons[screen_id].setChecked(True)
|
|
197
|
+
|
|
198
|
+
def update_status(self, is_recording: bool, meeting_name: str = ""):
|
|
199
|
+
"""Update the status display."""
|
|
200
|
+
if is_recording:
|
|
201
|
+
if meeting_name:
|
|
202
|
+
display_name = meeting_name[:18] + "..." if len(meeting_name) > 18 else meeting_name
|
|
203
|
+
status = f"● {display_name}"
|
|
204
|
+
else:
|
|
205
|
+
status = "● Recording..."
|
|
206
|
+
self._status_text.setStyleSheet("color: #f14c4c; padding-top: 5px;")
|
|
207
|
+
else:
|
|
208
|
+
status = "● Idle"
|
|
209
|
+
self._status_text.setStyleSheet("color: #5a5a5a; padding-top: 5px;")
|
|
210
|
+
self._status_text.setText(status)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Status indicator widget for Meeting Noter GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Qt
|
|
6
|
+
from PySide6.QtGui import QFont
|
|
7
|
+
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
|
8
|
+
|
|
9
|
+
from meeting_noter.gui.theme.dark_theme import COLORS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StatusIndicator(QWidget):
|
|
13
|
+
"""Widget to display current recording status."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, parent=None):
|
|
16
|
+
super().__init__(parent)
|
|
17
|
+
self.setObjectName("status-indicator")
|
|
18
|
+
self._setup_ui()
|
|
19
|
+
self._set_idle_state()
|
|
20
|
+
|
|
21
|
+
def _setup_ui(self):
|
|
22
|
+
"""Set up the status indicator UI."""
|
|
23
|
+
self.setStyleSheet(
|
|
24
|
+
"background-color: #0d0d0d; border: 1px solid #2a2a2a; border-radius: 8px;"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
layout = QHBoxLayout(self)
|
|
28
|
+
layout.setContentsMargins(20, 20, 20, 20)
|
|
29
|
+
layout.setSpacing(20)
|
|
30
|
+
|
|
31
|
+
# Status dot
|
|
32
|
+
self._dot = QLabel("●")
|
|
33
|
+
self._dot.setObjectName("status-dot")
|
|
34
|
+
self._dot.setAlignment(Qt.AlignCenter)
|
|
35
|
+
dot_font = QFont("Menlo, Monaco, Courier New, monospace", 32)
|
|
36
|
+
self._dot.setFont(dot_font)
|
|
37
|
+
layout.addWidget(self._dot)
|
|
38
|
+
|
|
39
|
+
# Status text container
|
|
40
|
+
text_container = QWidget()
|
|
41
|
+
text_container.setStyleSheet("background-color: transparent; border: none;")
|
|
42
|
+
text_layout = QVBoxLayout(text_container)
|
|
43
|
+
text_layout.setContentsMargins(0, 0, 0, 0)
|
|
44
|
+
text_layout.setSpacing(4)
|
|
45
|
+
|
|
46
|
+
self._status_text = QLabel("Stopped")
|
|
47
|
+
self._status_text.setObjectName("status-text")
|
|
48
|
+
status_font = QFont("Menlo, Monaco, Courier New, monospace", 18)
|
|
49
|
+
status_font.setBold(True)
|
|
50
|
+
self._status_text.setFont(status_font)
|
|
51
|
+
text_layout.addWidget(self._status_text)
|
|
52
|
+
|
|
53
|
+
self._details_text = QLabel("Watcher: stopped | Recorder: idle")
|
|
54
|
+
self._details_text.setObjectName("status-details")
|
|
55
|
+
details_font = QFont("Menlo, Monaco, Courier New, monospace", 12)
|
|
56
|
+
self._details_text.setFont(details_font)
|
|
57
|
+
self._details_text.setStyleSheet("color: #808080; background-color: transparent;")
|
|
58
|
+
text_layout.addWidget(self._details_text)
|
|
59
|
+
|
|
60
|
+
layout.addWidget(text_container)
|
|
61
|
+
layout.addStretch()
|
|
62
|
+
|
|
63
|
+
def _set_idle_state(self):
|
|
64
|
+
"""Set the idle/stopped state."""
|
|
65
|
+
self._dot.setStyleSheet(f"color: #5a5a5a; background-color: transparent;")
|
|
66
|
+
self._status_text.setText("Stopped")
|
|
67
|
+
self._status_text.setStyleSheet("color: #808080; background-color: transparent;")
|
|
68
|
+
self._details_text.setText("Watcher: stopped | Recorder: idle")
|
|
69
|
+
|
|
70
|
+
def update_status(
|
|
71
|
+
self, watcher_running: bool, daemon_running: bool, meeting_name: str = ""
|
|
72
|
+
):
|
|
73
|
+
"""Update the status display."""
|
|
74
|
+
if daemon_running:
|
|
75
|
+
# Recording state - red
|
|
76
|
+
self._dot.setStyleSheet("color: #f14c4c; background-color: transparent;")
|
|
77
|
+
self._status_text.setStyleSheet("color: #f14c4c; background-color: transparent;")
|
|
78
|
+
if meeting_name:
|
|
79
|
+
self._status_text.setText(f"Recording: {meeting_name}")
|
|
80
|
+
else:
|
|
81
|
+
self._status_text.setText("Recording...")
|
|
82
|
+
elif watcher_running:
|
|
83
|
+
# Ready state - green
|
|
84
|
+
self._dot.setStyleSheet("color: #4ec9b0; background-color: transparent;")
|
|
85
|
+
self._status_text.setStyleSheet("color: #4ec9b0; background-color: transparent;")
|
|
86
|
+
self._status_text.setText("Ready (watcher active)")
|
|
87
|
+
else:
|
|
88
|
+
# Stopped state - gray
|
|
89
|
+
self._dot.setStyleSheet("color: #5a5a5a; background-color: transparent;")
|
|
90
|
+
self._status_text.setStyleSheet("color: #808080; background-color: transparent;")
|
|
91
|
+
self._status_text.setText("Stopped")
|
|
92
|
+
|
|
93
|
+
# Update details
|
|
94
|
+
watcher_status = "running" if watcher_running else "stopped"
|
|
95
|
+
recorder_status = "recording" if daemon_running else "idle"
|
|
96
|
+
self._details_text.setText(f"Watcher: {watcher_status} | Recorder: {recorder_status}")
|
|
97
|
+
|
|
98
|
+
def set_recording(self, meeting_name: str = ""):
|
|
99
|
+
"""Set recording state."""
|
|
100
|
+
self.update_status(False, True, meeting_name)
|
|
101
|
+
|
|
102
|
+
def set_ready(self):
|
|
103
|
+
"""Set ready (watcher active) state."""
|
|
104
|
+
self.update_status(True, False)
|
|
105
|
+
|
|
106
|
+
def set_stopped(self):
|
|
107
|
+
"""Set stopped state."""
|
|
108
|
+
self.update_status(False, False)
|
meeting_noter/mic_monitor.py
CHANGED
|
@@ -36,11 +36,21 @@ class _AudioObjectPropertyAddress(Structure):
|
|
|
36
36
|
_core_audio = None
|
|
37
37
|
_AudioObjectGetPropertyDataSize = None
|
|
38
38
|
_AudioObjectGetPropertyData = None
|
|
39
|
+
_coreaudio_init_time = 0 # Track when CoreAudio was initialized
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reset_coreaudio():
|
|
43
|
+
"""Reset CoreAudio framework (for reinitializing after long idle)."""
|
|
44
|
+
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData, _coreaudio_init_time
|
|
45
|
+
_core_audio = None
|
|
46
|
+
_AudioObjectGetPropertyDataSize = None
|
|
47
|
+
_AudioObjectGetPropertyData = None
|
|
48
|
+
_coreaudio_init_time = 0
|
|
39
49
|
|
|
40
50
|
|
|
41
51
|
def _init_coreaudio():
|
|
42
52
|
"""Initialize CoreAudio framework."""
|
|
43
|
-
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData
|
|
53
|
+
global _core_audio, _AudioObjectGetPropertyDataSize, _AudioObjectGetPropertyData, _coreaudio_init_time
|
|
44
54
|
|
|
45
55
|
if _core_audio is not None:
|
|
46
56
|
return True
|
|
@@ -60,11 +70,26 @@ def _init_coreaudio():
|
|
|
60
70
|
]
|
|
61
71
|
_AudioObjectGetPropertyData.restype = c_int32
|
|
62
72
|
|
|
73
|
+
_coreaudio_init_time = time.time()
|
|
63
74
|
return True
|
|
64
75
|
except Exception:
|
|
65
76
|
return False
|
|
66
77
|
|
|
67
78
|
|
|
79
|
+
# Reinitialize CoreAudio every 30 minutes to prevent stale handles
|
|
80
|
+
_COREAUDIO_REFRESH_INTERVAL = 30 * 60
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _maybe_refresh_coreaudio():
|
|
84
|
+
"""Reinitialize CoreAudio if it's been too long since last init.
|
|
85
|
+
|
|
86
|
+
This prevents stale CoreAudio handles after system sleep or long idle.
|
|
87
|
+
"""
|
|
88
|
+
global _coreaudio_init_time
|
|
89
|
+
if _core_audio is not None and time.time() - _coreaudio_init_time > _COREAUDIO_REFRESH_INTERVAL:
|
|
90
|
+
_reset_coreaudio()
|
|
91
|
+
|
|
92
|
+
|
|
68
93
|
def is_mic_in_use_by_another_app() -> bool:
|
|
69
94
|
"""Check if the microphone is being used by another application.
|
|
70
95
|
|
|
@@ -74,6 +99,9 @@ def is_mic_in_use_by_another_app() -> bool:
|
|
|
74
99
|
Returns:
|
|
75
100
|
True if another app is using the microphone
|
|
76
101
|
"""
|
|
102
|
+
# Refresh CoreAudio if it's been too long (prevents stale handles)
|
|
103
|
+
_maybe_refresh_coreaudio()
|
|
104
|
+
|
|
77
105
|
if not _init_coreaudio():
|
|
78
106
|
return False
|
|
79
107
|
|