meeting-noter 1.3.0__tar.gz → 3.0.0__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.
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/PKG-INFO +4 -1
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/pyproject.toml +8 -2
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/__init__.py +1 -1
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/cli.py +103 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/daemon.py +38 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/__init__.py +51 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/main_window.py +219 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/menubar.py +248 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/screens/__init__.py +17 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/screens/dashboard.py +262 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/screens/logs.py +184 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/screens/recordings.py +279 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/screens/search.py +229 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/screens/settings.py +232 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/screens/viewer.py +140 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/theme/__init__.py +5 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/theme/dark_theme.py +53 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/theme/styles.qss +504 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/utils/__init__.py +15 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/utils/signals.py +82 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/utils/workers.py +258 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/widgets/__init__.py +6 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/widgets/sidebar.py +210 -0
- meeting_noter-3.0.0/src/meeting_noter/gui/widgets/status_indicator.py +108 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/mic_monitor.py +29 -1
- meeting_noter-3.0.0/src/meeting_noter/ui/__init__.py +5 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/app.py +68 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/screens/__init__.py +17 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/screens/logs.py +166 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/screens/main.py +346 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/screens/recordings.py +241 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/screens/search.py +191 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/screens/settings.py +184 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/screens/viewer.py +116 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/styles/app.tcss +257 -0
- meeting_noter-3.0.0/src/meeting_noter/ui/widgets/__init__.py +1 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/PKG-INFO +4 -1
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/SOURCES.txt +32 -1
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/requires.txt +4 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_cli.py +823 -1
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_daemon.py +141 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_mic_monitor.py +356 -0
- meeting_noter-3.0.0/tests/test_update_checker.py +208 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/README.md +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/setup.cfg +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/__main__.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/audio/__init__.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/audio/capture.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/audio/encoder.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/audio/system_audio.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/config.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/install/__init__.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/install/macos.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/meeting_detector.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/output/__init__.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/output/favorites.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/output/searcher.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/output/writer.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/__init__.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon.icns +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon.png +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_128.png +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_16.png +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_256.png +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_32.png +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_512.png +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_64.png +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/transcription/__init__.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/transcription/engine.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/transcription/live_transcription.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/update_checker.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/dependency_links.txt +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/entry_points.txt +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/top_level.txt +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_config.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_favorites.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_meeting_detector.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_output_writer.py +0 -0
- {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_searcher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meeting-noter
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Offline meeting transcription for macOS with automatic meeting detection
|
|
5
5
|
Author: Victor
|
|
6
6
|
License: MIT
|
|
@@ -27,6 +27,7 @@ Requires-Dist: sounddevice>=0.4.6
|
|
|
27
27
|
Requires-Dist: numpy>=1.24
|
|
28
28
|
Requires-Dist: faster-whisper>=1.0.0
|
|
29
29
|
Requires-Dist: imageio-ffmpeg>=0.4.9
|
|
30
|
+
Requires-Dist: textual>=0.47.0
|
|
30
31
|
Requires-Dist: pyobjc-framework-Cocoa>=9.0; sys_platform == "darwin"
|
|
31
32
|
Requires-Dist: pyobjc-framework-Quartz>=9.0; sys_platform == "darwin"
|
|
32
33
|
Requires-Dist: pyobjc-framework-ScreenCaptureKit>=9.0; sys_platform == "darwin"
|
|
@@ -35,6 +36,8 @@ Requires-Dist: pyobjc-framework-CoreMedia>=9.0; sys_platform == "darwin"
|
|
|
35
36
|
Requires-Dist: pyobjc-framework-libdispatch>=9.0; sys_platform == "darwin"
|
|
36
37
|
Provides-Extra: offline
|
|
37
38
|
Requires-Dist: meeting-noter-models>=0.1.0; extra == "offline"
|
|
39
|
+
Provides-Extra: gui
|
|
40
|
+
Requires-Dist: PySide6>=6.5.0; extra == "gui"
|
|
38
41
|
Provides-Extra: dev
|
|
39
42
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
40
43
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meeting-noter"
|
|
7
|
-
version = "
|
|
7
|
+
version = "3.0.0"
|
|
8
8
|
description = "Offline meeting transcription for macOS with automatic meeting detection"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -34,6 +34,7 @@ dependencies = [
|
|
|
34
34
|
"numpy>=1.24",
|
|
35
35
|
"faster-whisper>=1.0.0",
|
|
36
36
|
"imageio-ffmpeg>=0.4.9", # Bundles ffmpeg binary for MP3 encoding
|
|
37
|
+
"textual>=0.47.0", # Terminal UI framework
|
|
37
38
|
"pyobjc-framework-Cocoa>=9.0; sys_platform == 'darwin'",
|
|
38
39
|
"pyobjc-framework-Quartz>=9.0; sys_platform == 'darwin'",
|
|
39
40
|
"pyobjc-framework-ScreenCaptureKit>=9.0; sys_platform == 'darwin'",
|
|
@@ -46,6 +47,9 @@ dependencies = [
|
|
|
46
47
|
offline = [
|
|
47
48
|
"meeting-noter-models>=0.1.0",
|
|
48
49
|
]
|
|
50
|
+
gui = [
|
|
51
|
+
"PySide6>=6.5.0",
|
|
52
|
+
]
|
|
49
53
|
dev = [
|
|
50
54
|
"pytest>=7.0",
|
|
51
55
|
"pytest-cov",
|
|
@@ -68,7 +72,7 @@ Issues = "https://github.com/tech4vision/meeting-noter/issues"
|
|
|
68
72
|
where = ["src"]
|
|
69
73
|
|
|
70
74
|
[tool.setuptools.package-data]
|
|
71
|
-
meeting_noter = ["resources/*.png", "resources/*.icns"]
|
|
75
|
+
meeting_noter = ["resources/*.png", "resources/*.icns", "ui/styles/*.tcss", "gui/theme/*.qss"]
|
|
72
76
|
|
|
73
77
|
[tool.black]
|
|
74
78
|
line-length = 100
|
|
@@ -98,6 +102,8 @@ branch = true
|
|
|
98
102
|
omit = [
|
|
99
103
|
"*/__main__.py",
|
|
100
104
|
"*/audio/system_audio.py", # ScreenCaptureKit-based, requires macOS runtime
|
|
105
|
+
"*/ui/screens/*.py", # Textual UI screens require async testing
|
|
106
|
+
"*/ui/app.py", # Textual app requires async testing
|
|
101
107
|
]
|
|
102
108
|
|
|
103
109
|
[tool.coverage.report]
|
|
@@ -804,6 +804,25 @@ def watcher(foreground: bool):
|
|
|
804
804
|
click.echo("Use 'meeting-noter shutdown' to stop.")
|
|
805
805
|
|
|
806
806
|
|
|
807
|
+
def _disable_app_nap():
|
|
808
|
+
"""Disable App Nap for this process to prevent macOS from throttling it.
|
|
809
|
+
|
|
810
|
+
App Nap can suspend background processes, causing meeting detection to fail
|
|
811
|
+
after long idle periods.
|
|
812
|
+
"""
|
|
813
|
+
try:
|
|
814
|
+
from Foundation import NSProcessInfo
|
|
815
|
+
info = NSProcessInfo.processInfo()
|
|
816
|
+
# Disable App Nap and sudden termination
|
|
817
|
+
# NSActivityUserInitiated keeps the process responsive
|
|
818
|
+
info.beginActivityWithOptions_reason_(
|
|
819
|
+
0x00FFFFFF, # NSActivityUserInitiatedAllowingIdleSystemSleep
|
|
820
|
+
"Meeting detection watcher"
|
|
821
|
+
)
|
|
822
|
+
except Exception:
|
|
823
|
+
pass # Not critical if this fails
|
|
824
|
+
|
|
825
|
+
|
|
807
826
|
def _run_watcher_loop():
|
|
808
827
|
"""Run the watcher loop (foreground)."""
|
|
809
828
|
import time
|
|
@@ -813,6 +832,9 @@ def _run_watcher_loop():
|
|
|
813
832
|
from meeting_noter.mic_monitor import MicrophoneMonitor, get_meeting_window_title, is_meeting_app_active
|
|
814
833
|
from meeting_noter.daemon import is_process_running, read_pid_file, stop_daemon
|
|
815
834
|
|
|
835
|
+
# Disable App Nap to prevent macOS from throttling this process
|
|
836
|
+
_disable_app_nap()
|
|
837
|
+
|
|
816
838
|
# Write PID file
|
|
817
839
|
WATCHER_PID_FILE.write_text(str(os.getpid()))
|
|
818
840
|
atexit.register(lambda: WATCHER_PID_FILE.unlink(missing_ok=True))
|
|
@@ -824,8 +846,23 @@ def _run_watcher_loop():
|
|
|
824
846
|
mic_monitor = MicrophoneMonitor()
|
|
825
847
|
current_meeting_name = None
|
|
826
848
|
|
|
849
|
+
# Heartbeat tracking - log every 30 minutes to confirm watcher is alive
|
|
850
|
+
last_heartbeat = time.time()
|
|
851
|
+
heartbeat_interval = 30 * 60 # 30 minutes
|
|
852
|
+
|
|
827
853
|
try:
|
|
828
854
|
while True:
|
|
855
|
+
# Periodic heartbeat to confirm watcher is running
|
|
856
|
+
now = time.time()
|
|
857
|
+
if now - last_heartbeat >= heartbeat_interval:
|
|
858
|
+
# Write to log file so user can verify watcher is alive
|
|
859
|
+
log_path = Path.home() / ".meeting-noter.log"
|
|
860
|
+
with open(log_path, "a") as f:
|
|
861
|
+
from datetime import datetime
|
|
862
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
863
|
+
f.write(f"{timestamp} Watcher heartbeat - still monitoring for meetings\n")
|
|
864
|
+
last_heartbeat = now
|
|
865
|
+
|
|
829
866
|
mic_started, mic_stopped, app_name = mic_monitor.check()
|
|
830
867
|
|
|
831
868
|
is_recording = read_pid_file(DEFAULT_PID_FILE) is not None and \
|
|
@@ -990,6 +1027,72 @@ def open_folder(what: str):
|
|
|
990
1027
|
click.echo(f"Opened: {path}")
|
|
991
1028
|
|
|
992
1029
|
|
|
1030
|
+
@cli.command()
|
|
1031
|
+
def ui():
|
|
1032
|
+
"""Launch the Terminal User Interface.
|
|
1033
|
+
|
|
1034
|
+
Opens an interactive TUI for managing recordings, searching transcripts,
|
|
1035
|
+
and configuring settings.
|
|
1036
|
+
|
|
1037
|
+
\b
|
|
1038
|
+
Examples:
|
|
1039
|
+
meeting-noter ui # Launch the TUI
|
|
1040
|
+
|
|
1041
|
+
\b
|
|
1042
|
+
Keyboard shortcuts:
|
|
1043
|
+
1-5 Switch between screens
|
|
1044
|
+
q Quit
|
|
1045
|
+
? Help
|
|
1046
|
+
r Start/stop recording (on dashboard)
|
|
1047
|
+
"""
|
|
1048
|
+
from meeting_noter.ui import MeetingNoterApp
|
|
1049
|
+
|
|
1050
|
+
app = MeetingNoterApp()
|
|
1051
|
+
app.run()
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
@cli.command()
|
|
1055
|
+
@click.option("--foreground", "-f", is_flag=True, help="Run in foreground (block terminal)")
|
|
1056
|
+
def gui(foreground: bool):
|
|
1057
|
+
"""Launch the Desktop GUI.
|
|
1058
|
+
|
|
1059
|
+
Opens the Meeting Noter desktop application with a modern dark theme.
|
|
1060
|
+
Runs in background by default so your terminal stays free.
|
|
1061
|
+
Requires PySide6: pip install meeting-noter[gui]
|
|
1062
|
+
|
|
1063
|
+
\b
|
|
1064
|
+
Examples:
|
|
1065
|
+
meeting-noter gui # Launch GUI in background
|
|
1066
|
+
meeting-noter gui -f # Launch GUI in foreground (blocks terminal)
|
|
1067
|
+
mn gui # Same with short alias
|
|
1068
|
+
"""
|
|
1069
|
+
try:
|
|
1070
|
+
# Check if PySide6 is available
|
|
1071
|
+
import PySide6 # noqa: F401
|
|
1072
|
+
except ImportError:
|
|
1073
|
+
click.echo(click.style("Error: ", fg="red") + "PySide6 not installed.")
|
|
1074
|
+
click.echo("Install with: pip install meeting-noter[gui]")
|
|
1075
|
+
click.echo("Or: pipx install 'meeting-noter[gui]'")
|
|
1076
|
+
raise SystemExit(1)
|
|
1077
|
+
|
|
1078
|
+
if foreground:
|
|
1079
|
+
# Run in foreground (blocks terminal)
|
|
1080
|
+
from meeting_noter.gui import run_gui
|
|
1081
|
+
run_gui()
|
|
1082
|
+
else:
|
|
1083
|
+
# Run in background
|
|
1084
|
+
import subprocess
|
|
1085
|
+
import sys
|
|
1086
|
+
|
|
1087
|
+
subprocess.Popen(
|
|
1088
|
+
[sys.executable, "-m", "meeting_noter.cli", "gui", "-f"],
|
|
1089
|
+
stdout=subprocess.DEVNULL,
|
|
1090
|
+
stderr=subprocess.DEVNULL,
|
|
1091
|
+
start_new_session=True,
|
|
1092
|
+
)
|
|
1093
|
+
click.echo("Meeting Noter GUI launched.")
|
|
1094
|
+
|
|
1095
|
+
|
|
993
1096
|
@cli.command()
|
|
994
1097
|
@click.option("--shell", type=click.Choice(["zsh", "bash", "fish"]), default="zsh")
|
|
995
1098
|
def completion(shell: str):
|
|
@@ -174,6 +174,7 @@ def _run_capture_loop(
|
|
|
174
174
|
from meeting_noter.audio.encoder import RecordingSession
|
|
175
175
|
|
|
176
176
|
config = get_config()
|
|
177
|
+
saved_filepath = None # Track saved file for auto-transcription
|
|
177
178
|
|
|
178
179
|
# Live transcription (imported here to avoid loading Whisper before fork)
|
|
179
180
|
live_transcriber = None
|
|
@@ -306,6 +307,23 @@ def _run_capture_loop(
|
|
|
306
307
|
filepath, duration = session.stop()
|
|
307
308
|
if filepath:
|
|
308
309
|
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
310
|
+
saved_filepath = filepath
|
|
311
|
+
# Auto-transcribe if enabled
|
|
312
|
+
if config.auto_transcribe:
|
|
313
|
+
print("Auto-transcribing...")
|
|
314
|
+
sys.stdout.flush()
|
|
315
|
+
try:
|
|
316
|
+
from meeting_noter.transcription.engine import transcribe_file
|
|
317
|
+
transcribe_file(
|
|
318
|
+
str(filepath),
|
|
319
|
+
output_dir,
|
|
320
|
+
config.whisper_model,
|
|
321
|
+
config.transcripts_dir,
|
|
322
|
+
)
|
|
323
|
+
print("Transcription complete.")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
print(f"Transcription error: {e}")
|
|
326
|
+
sys.stdout.flush()
|
|
309
327
|
else:
|
|
310
328
|
print("Recording discarded (too short)")
|
|
311
329
|
silence_detector.reset()
|
|
@@ -313,6 +331,8 @@ def _run_capture_loop(
|
|
|
313
331
|
|
|
314
332
|
except Exception as e:
|
|
315
333
|
print(f"Error in capture loop: {e}")
|
|
334
|
+
import sys
|
|
335
|
+
sys.stdout.flush()
|
|
316
336
|
finally:
|
|
317
337
|
capture.stop()
|
|
318
338
|
|
|
@@ -325,6 +345,24 @@ def _run_capture_loop(
|
|
|
325
345
|
filepath, duration = session.stop()
|
|
326
346
|
if filepath:
|
|
327
347
|
print(f"Recording saved: {filepath.name} ({duration:.1f}s)")
|
|
348
|
+
saved_filepath = filepath
|
|
349
|
+
# Auto-transcribe if enabled
|
|
350
|
+
if config.auto_transcribe:
|
|
351
|
+
print("Auto-transcribing...")
|
|
352
|
+
import sys
|
|
353
|
+
sys.stdout.flush()
|
|
354
|
+
try:
|
|
355
|
+
from meeting_noter.transcription.engine import transcribe_file
|
|
356
|
+
transcribe_file(
|
|
357
|
+
str(filepath),
|
|
358
|
+
output_dir,
|
|
359
|
+
config.whisper_model,
|
|
360
|
+
config.transcripts_dir,
|
|
361
|
+
)
|
|
362
|
+
print("Transcription complete.")
|
|
363
|
+
except Exception as e:
|
|
364
|
+
print(f"Transcription error: {e}")
|
|
365
|
+
sys.stdout.flush()
|
|
328
366
|
|
|
329
367
|
print("Daemon stopped.")
|
|
330
368
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Meeting Noter Desktop GUI - Modern PySide6 interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_gui():
|
|
7
|
+
"""Launch the GUI application with system tray icon."""
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import Qt
|
|
12
|
+
from PySide6.QtGui import QIcon
|
|
13
|
+
from PySide6.QtWidgets import QApplication
|
|
14
|
+
|
|
15
|
+
from meeting_noter.gui.main_window import MainWindow
|
|
16
|
+
from meeting_noter.gui.menubar import MenuBarIcon
|
|
17
|
+
|
|
18
|
+
# Enable High DPI scaling
|
|
19
|
+
QApplication.setHighDpiScaleFactorRoundingPolicy(
|
|
20
|
+
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
app = QApplication(sys.argv)
|
|
24
|
+
app.setApplicationName("Meeting Noter")
|
|
25
|
+
app.setOrganizationName("Meeting Noter")
|
|
26
|
+
|
|
27
|
+
# Set application icon (for dock)
|
|
28
|
+
resources_dir = Path(__file__).parent.parent / "resources"
|
|
29
|
+
icon_path = resources_dir / "icon.icns"
|
|
30
|
+
if not icon_path.exists():
|
|
31
|
+
icon_path = resources_dir / "icon.png"
|
|
32
|
+
if icon_path.exists():
|
|
33
|
+
app.setWindowIcon(QIcon(str(icon_path)))
|
|
34
|
+
|
|
35
|
+
# Don't quit when last window closes (we have tray icon)
|
|
36
|
+
app.setQuitOnLastWindowClosed(False)
|
|
37
|
+
|
|
38
|
+
# Create main window
|
|
39
|
+
window = MainWindow()
|
|
40
|
+
|
|
41
|
+
# Create system tray icon
|
|
42
|
+
tray_icon = MenuBarIcon(window)
|
|
43
|
+
tray_icon.show()
|
|
44
|
+
|
|
45
|
+
# Give window reference to tray icon
|
|
46
|
+
window.set_tray_icon(tray_icon)
|
|
47
|
+
|
|
48
|
+
# Show window
|
|
49
|
+
window.show()
|
|
50
|
+
|
|
51
|
+
sys.exit(app.exec())
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Main window for Meeting Noter GUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Qt
|
|
8
|
+
from PySide6.QtWidgets import (
|
|
9
|
+
QApplication,
|
|
10
|
+
QHBoxLayout,
|
|
11
|
+
QMainWindow,
|
|
12
|
+
QStackedWidget,
|
|
13
|
+
QWidget,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# macOS dock visibility control
|
|
17
|
+
if platform.system() == "Darwin":
|
|
18
|
+
try:
|
|
19
|
+
from AppKit import NSApp, NSApplicationActivationPolicyAccessory, NSApplicationActivationPolicyRegular
|
|
20
|
+
HAS_APPKIT = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
HAS_APPKIT = False
|
|
23
|
+
else:
|
|
24
|
+
HAS_APPKIT = False
|
|
25
|
+
|
|
26
|
+
from meeting_noter.gui.screens import (
|
|
27
|
+
DashboardScreen,
|
|
28
|
+
LogsScreen,
|
|
29
|
+
RecordingsScreen,
|
|
30
|
+
SearchScreen,
|
|
31
|
+
SettingsScreen,
|
|
32
|
+
ViewerScreen,
|
|
33
|
+
)
|
|
34
|
+
from meeting_noter.gui.theme import apply_theme
|
|
35
|
+
from meeting_noter.gui.utils.signals import get_app_state
|
|
36
|
+
from meeting_noter.gui.utils.workers import StatusPollingWorker
|
|
37
|
+
from meeting_noter.gui.widgets import Sidebar
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MainWindow(QMainWindow):
|
|
41
|
+
"""Main application window with sidebar navigation."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, tray_icon=None):
|
|
44
|
+
super().__init__()
|
|
45
|
+
self.setWindowTitle("Meeting Noter")
|
|
46
|
+
self.setMinimumSize(1000, 700)
|
|
47
|
+
self.resize(1200, 800)
|
|
48
|
+
|
|
49
|
+
self._tray_icon = tray_icon
|
|
50
|
+
self._force_quit = False
|
|
51
|
+
|
|
52
|
+
# Apply dark theme
|
|
53
|
+
apply_theme(self.app())
|
|
54
|
+
|
|
55
|
+
self._setup_ui()
|
|
56
|
+
self._setup_workers()
|
|
57
|
+
self._connect_signals()
|
|
58
|
+
|
|
59
|
+
# Select dashboard initially
|
|
60
|
+
self._show_screen("dashboard")
|
|
61
|
+
|
|
62
|
+
def app(self):
|
|
63
|
+
"""Get the QApplication instance."""
|
|
64
|
+
return QApplication.instance()
|
|
65
|
+
|
|
66
|
+
def set_tray_icon(self, tray_icon):
|
|
67
|
+
"""Set the tray icon reference."""
|
|
68
|
+
self._tray_icon = tray_icon
|
|
69
|
+
|
|
70
|
+
def _setup_ui(self):
|
|
71
|
+
"""Set up the main UI."""
|
|
72
|
+
# Central widget
|
|
73
|
+
central = QWidget()
|
|
74
|
+
self.setCentralWidget(central)
|
|
75
|
+
|
|
76
|
+
# Main layout (horizontal: sidebar + content)
|
|
77
|
+
layout = QHBoxLayout(central)
|
|
78
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
79
|
+
layout.setSpacing(0)
|
|
80
|
+
|
|
81
|
+
# Sidebar
|
|
82
|
+
self._sidebar = Sidebar()
|
|
83
|
+
self._sidebar.quit_requested.connect(self._quit_app)
|
|
84
|
+
layout.addWidget(self._sidebar)
|
|
85
|
+
|
|
86
|
+
# Content area (stacked widget)
|
|
87
|
+
self._content = QStackedWidget()
|
|
88
|
+
self._content.setObjectName("content-area")
|
|
89
|
+
layout.addWidget(self._content, 1) # Stretch factor 1
|
|
90
|
+
|
|
91
|
+
# Create screens
|
|
92
|
+
self._screens: dict[str, QWidget] = {}
|
|
93
|
+
self._create_screens()
|
|
94
|
+
|
|
95
|
+
def _create_screens(self):
|
|
96
|
+
"""Create all screen widgets."""
|
|
97
|
+
# Dashboard
|
|
98
|
+
self._screens["dashboard"] = DashboardScreen()
|
|
99
|
+
self._content.addWidget(self._screens["dashboard"])
|
|
100
|
+
|
|
101
|
+
# Recordings
|
|
102
|
+
self._screens["recordings"] = RecordingsScreen()
|
|
103
|
+
self._content.addWidget(self._screens["recordings"])
|
|
104
|
+
|
|
105
|
+
# Search
|
|
106
|
+
self._screens["search"] = SearchScreen()
|
|
107
|
+
self._content.addWidget(self._screens["search"])
|
|
108
|
+
|
|
109
|
+
# Settings
|
|
110
|
+
self._screens["settings"] = SettingsScreen()
|
|
111
|
+
self._content.addWidget(self._screens["settings"])
|
|
112
|
+
|
|
113
|
+
# Logs
|
|
114
|
+
self._screens["logs"] = LogsScreen()
|
|
115
|
+
self._content.addWidget(self._screens["logs"])
|
|
116
|
+
|
|
117
|
+
# Viewer (for transcripts)
|
|
118
|
+
self._screens["viewer"] = ViewerScreen()
|
|
119
|
+
self._content.addWidget(self._screens["viewer"])
|
|
120
|
+
|
|
121
|
+
def _setup_workers(self):
|
|
122
|
+
"""Set up background workers."""
|
|
123
|
+
# Status polling
|
|
124
|
+
self._status_worker = StatusPollingWorker()
|
|
125
|
+
self._status_worker.status_updated.connect(self._on_status_updated)
|
|
126
|
+
self._status_worker.start()
|
|
127
|
+
|
|
128
|
+
def _connect_signals(self):
|
|
129
|
+
"""Connect signals."""
|
|
130
|
+
# Sidebar navigation
|
|
131
|
+
self._sidebar.screen_selected.connect(self._show_screen)
|
|
132
|
+
|
|
133
|
+
# App state signals
|
|
134
|
+
app_state = get_app_state()
|
|
135
|
+
app_state.navigate_to.connect(self._show_screen)
|
|
136
|
+
app_state.open_transcript.connect(self._open_transcript)
|
|
137
|
+
|
|
138
|
+
def _show_screen(self, screen_id: str):
|
|
139
|
+
"""Show a screen by ID."""
|
|
140
|
+
if screen_id in self._screens:
|
|
141
|
+
self._content.setCurrentWidget(self._screens[screen_id])
|
|
142
|
+
self._sidebar.select_screen(screen_id)
|
|
143
|
+
|
|
144
|
+
# Refresh screen if it has a refresh method
|
|
145
|
+
screen = self._screens[screen_id]
|
|
146
|
+
if hasattr(screen, "refresh"):
|
|
147
|
+
screen.refresh()
|
|
148
|
+
|
|
149
|
+
def _open_transcript(self, filepath: str):
|
|
150
|
+
"""Open a transcript in the viewer."""
|
|
151
|
+
viewer = self._screens.get("viewer")
|
|
152
|
+
if viewer and hasattr(viewer, "load_transcript"):
|
|
153
|
+
viewer.load_transcript(filepath)
|
|
154
|
+
self._show_screen("viewer")
|
|
155
|
+
|
|
156
|
+
def _on_status_updated(self, watcher_running: bool, daemon_running: bool, meeting_name: str):
|
|
157
|
+
"""Handle status update from worker."""
|
|
158
|
+
# Update sidebar status
|
|
159
|
+
self._sidebar.update_status(daemon_running, meeting_name)
|
|
160
|
+
|
|
161
|
+
# Update dashboard status indicator
|
|
162
|
+
dashboard = self._screens.get("dashboard")
|
|
163
|
+
if dashboard and hasattr(dashboard, "update_status"):
|
|
164
|
+
dashboard.update_status(watcher_running, daemon_running, meeting_name)
|
|
165
|
+
|
|
166
|
+
# Update tray icon
|
|
167
|
+
if self._tray_icon:
|
|
168
|
+
self._tray_icon.update_status(watcher_running, daemon_running, meeting_name)
|
|
169
|
+
|
|
170
|
+
# Update app state
|
|
171
|
+
app_state = get_app_state()
|
|
172
|
+
app_state._is_recording = daemon_running
|
|
173
|
+
app_state._current_meeting = meeting_name
|
|
174
|
+
app_state._watcher_running = watcher_running
|
|
175
|
+
|
|
176
|
+
def _quit_app(self):
|
|
177
|
+
"""Quit the application completely."""
|
|
178
|
+
self._force_quit = True
|
|
179
|
+
self._cleanup()
|
|
180
|
+
QApplication.instance().quit()
|
|
181
|
+
|
|
182
|
+
def _cleanup(self):
|
|
183
|
+
"""Clean up workers and resources."""
|
|
184
|
+
# Stop workers
|
|
185
|
+
if hasattr(self, "_status_worker"):
|
|
186
|
+
self._status_worker.stop()
|
|
187
|
+
|
|
188
|
+
# Stop screen workers
|
|
189
|
+
for screen in self._screens.values():
|
|
190
|
+
if hasattr(screen, "stop_workers"):
|
|
191
|
+
screen.stop_workers()
|
|
192
|
+
|
|
193
|
+
def closeEvent(self, event):
|
|
194
|
+
"""Handle window close - hide to tray instead of quitting."""
|
|
195
|
+
if self._force_quit or self._tray_icon is None:
|
|
196
|
+
# Actually quit
|
|
197
|
+
self._cleanup()
|
|
198
|
+
event.accept()
|
|
199
|
+
else:
|
|
200
|
+
# Hide to tray instead of closing
|
|
201
|
+
event.ignore()
|
|
202
|
+
self.hide()
|
|
203
|
+
self._hide_from_dock()
|
|
204
|
+
|
|
205
|
+
def show(self):
|
|
206
|
+
"""Show window and add to dock."""
|
|
207
|
+
super().show()
|
|
208
|
+
self._show_in_dock()
|
|
209
|
+
|
|
210
|
+
def _hide_from_dock(self):
|
|
211
|
+
"""Hide app from macOS dock."""
|
|
212
|
+
if HAS_APPKIT:
|
|
213
|
+
NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
|
214
|
+
|
|
215
|
+
def _show_in_dock(self):
|
|
216
|
+
"""Show app in macOS dock."""
|
|
217
|
+
if HAS_APPKIT:
|
|
218
|
+
NSApp.setActivationPolicy_(NSApplicationActivationPolicyRegular)
|
|
219
|
+
NSApp.activateIgnoringOtherApps_(True)
|