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.
Files changed (79) hide show
  1. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/PKG-INFO +4 -1
  2. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/pyproject.toml +8 -2
  3. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/__init__.py +1 -1
  4. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/cli.py +103 -0
  5. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/daemon.py +38 -0
  6. meeting_noter-3.0.0/src/meeting_noter/gui/__init__.py +51 -0
  7. meeting_noter-3.0.0/src/meeting_noter/gui/main_window.py +219 -0
  8. meeting_noter-3.0.0/src/meeting_noter/gui/menubar.py +248 -0
  9. meeting_noter-3.0.0/src/meeting_noter/gui/screens/__init__.py +17 -0
  10. meeting_noter-3.0.0/src/meeting_noter/gui/screens/dashboard.py +262 -0
  11. meeting_noter-3.0.0/src/meeting_noter/gui/screens/logs.py +184 -0
  12. meeting_noter-3.0.0/src/meeting_noter/gui/screens/recordings.py +279 -0
  13. meeting_noter-3.0.0/src/meeting_noter/gui/screens/search.py +229 -0
  14. meeting_noter-3.0.0/src/meeting_noter/gui/screens/settings.py +232 -0
  15. meeting_noter-3.0.0/src/meeting_noter/gui/screens/viewer.py +140 -0
  16. meeting_noter-3.0.0/src/meeting_noter/gui/theme/__init__.py +5 -0
  17. meeting_noter-3.0.0/src/meeting_noter/gui/theme/dark_theme.py +53 -0
  18. meeting_noter-3.0.0/src/meeting_noter/gui/theme/styles.qss +504 -0
  19. meeting_noter-3.0.0/src/meeting_noter/gui/utils/__init__.py +15 -0
  20. meeting_noter-3.0.0/src/meeting_noter/gui/utils/signals.py +82 -0
  21. meeting_noter-3.0.0/src/meeting_noter/gui/utils/workers.py +258 -0
  22. meeting_noter-3.0.0/src/meeting_noter/gui/widgets/__init__.py +6 -0
  23. meeting_noter-3.0.0/src/meeting_noter/gui/widgets/sidebar.py +210 -0
  24. meeting_noter-3.0.0/src/meeting_noter/gui/widgets/status_indicator.py +108 -0
  25. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/mic_monitor.py +29 -1
  26. meeting_noter-3.0.0/src/meeting_noter/ui/__init__.py +5 -0
  27. meeting_noter-3.0.0/src/meeting_noter/ui/app.py +68 -0
  28. meeting_noter-3.0.0/src/meeting_noter/ui/screens/__init__.py +17 -0
  29. meeting_noter-3.0.0/src/meeting_noter/ui/screens/logs.py +166 -0
  30. meeting_noter-3.0.0/src/meeting_noter/ui/screens/main.py +346 -0
  31. meeting_noter-3.0.0/src/meeting_noter/ui/screens/recordings.py +241 -0
  32. meeting_noter-3.0.0/src/meeting_noter/ui/screens/search.py +191 -0
  33. meeting_noter-3.0.0/src/meeting_noter/ui/screens/settings.py +184 -0
  34. meeting_noter-3.0.0/src/meeting_noter/ui/screens/viewer.py +116 -0
  35. meeting_noter-3.0.0/src/meeting_noter/ui/styles/app.tcss +257 -0
  36. meeting_noter-3.0.0/src/meeting_noter/ui/widgets/__init__.py +1 -0
  37. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/PKG-INFO +4 -1
  38. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/SOURCES.txt +32 -1
  39. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/requires.txt +4 -0
  40. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_cli.py +823 -1
  41. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_daemon.py +141 -0
  42. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_mic_monitor.py +356 -0
  43. meeting_noter-3.0.0/tests/test_update_checker.py +208 -0
  44. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/README.md +0 -0
  45. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/setup.cfg +0 -0
  46. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/__main__.py +0 -0
  47. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/audio/__init__.py +0 -0
  48. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/audio/capture.py +0 -0
  49. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/audio/encoder.py +0 -0
  50. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/audio/system_audio.py +0 -0
  51. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/config.py +0 -0
  52. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/install/__init__.py +0 -0
  53. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/install/macos.py +0 -0
  54. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/meeting_detector.py +0 -0
  55. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/output/__init__.py +0 -0
  56. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/output/favorites.py +0 -0
  57. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/output/searcher.py +0 -0
  58. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/output/writer.py +0 -0
  59. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/__init__.py +0 -0
  60. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon.icns +0 -0
  61. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon.png +0 -0
  62. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_128.png +0 -0
  63. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_16.png +0 -0
  64. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_256.png +0 -0
  65. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_32.png +0 -0
  66. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_512.png +0 -0
  67. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/resources/icon_64.png +0 -0
  68. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/transcription/__init__.py +0 -0
  69. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/transcription/engine.py +0 -0
  70. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/transcription/live_transcription.py +0 -0
  71. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter/update_checker.py +0 -0
  72. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/dependency_links.txt +0 -0
  73. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/entry_points.txt +0 -0
  74. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/src/meeting_noter.egg-info/top_level.txt +0 -0
  75. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_config.py +0 -0
  76. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_favorites.py +0 -0
  77. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_meeting_detector.py +0 -0
  78. {meeting_noter-1.3.0 → meeting_noter-3.0.0}/tests/test_output_writer.py +0 -0
  79. {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: 1.3.0
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 = "1.3.0"
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]
@@ -1,3 +1,3 @@
1
1
  """Meeting Noter - Offline meeting transcription with virtual audio devices."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "3.0.0"
@@ -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)