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.
Potentially problematic release.
This version of meeting-noter might be problematic. Click here for more details.
- 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
meeting_noter/ui/app.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Main Textual application for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.widgets import Footer, Header
|
|
8
|
+
|
|
9
|
+
from meeting_noter.ui.screens.main import MainScreen
|
|
10
|
+
from meeting_noter.ui.screens.recordings import RecordingsScreen
|
|
11
|
+
from meeting_noter.ui.screens.search import SearchScreen
|
|
12
|
+
from meeting_noter.ui.screens.settings import SettingsScreen
|
|
13
|
+
from meeting_noter.ui.screens.logs import LogsScreen
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MeetingNoterApp(App):
|
|
17
|
+
"""Meeting Noter Terminal UI Application."""
|
|
18
|
+
|
|
19
|
+
TITLE = "Meeting Noter"
|
|
20
|
+
CSS_PATH = "styles/app.tcss"
|
|
21
|
+
|
|
22
|
+
BINDINGS = [
|
|
23
|
+
Binding("q", "quit", "Quit"),
|
|
24
|
+
Binding("question_mark", "help", "Help", key_display="?"),
|
|
25
|
+
Binding("1", "switch_screen('main')", "Dashboard", show=True),
|
|
26
|
+
Binding("2", "switch_screen('recordings')", "Recordings", show=True),
|
|
27
|
+
Binding("3", "switch_screen('search')", "Search", show=True),
|
|
28
|
+
Binding("4", "switch_screen('settings')", "Settings", show=True),
|
|
29
|
+
Binding("5", "switch_screen('logs')", "Logs", show=True),
|
|
30
|
+
Binding("escape", "go_back", "Back", show=False),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
SCREENS = {
|
|
34
|
+
"main": MainScreen,
|
|
35
|
+
"recordings": RecordingsScreen,
|
|
36
|
+
"search": SearchScreen,
|
|
37
|
+
"settings": SettingsScreen,
|
|
38
|
+
"logs": LogsScreen,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def compose(self) -> ComposeResult:
|
|
42
|
+
"""Compose the app layout."""
|
|
43
|
+
yield Header()
|
|
44
|
+
yield Footer()
|
|
45
|
+
|
|
46
|
+
def on_mount(self) -> None:
|
|
47
|
+
"""Handle app mount - show the main screen."""
|
|
48
|
+
self.push_screen("main")
|
|
49
|
+
|
|
50
|
+
def action_switch_screen(self, screen_name: str) -> None:
|
|
51
|
+
"""Switch to a named screen."""
|
|
52
|
+
# Pop all screens except the base and push the new one
|
|
53
|
+
while len(self.screen_stack) > 1:
|
|
54
|
+
self.pop_screen()
|
|
55
|
+
self.push_screen(screen_name)
|
|
56
|
+
|
|
57
|
+
def action_go_back(self) -> None:
|
|
58
|
+
"""Go back to the previous screen."""
|
|
59
|
+
if len(self.screen_stack) > 1:
|
|
60
|
+
self.pop_screen()
|
|
61
|
+
|
|
62
|
+
def action_help(self) -> None:
|
|
63
|
+
"""Show help information."""
|
|
64
|
+
self.notify(
|
|
65
|
+
"Keys: 1-5 switch screens, q quit, ? help",
|
|
66
|
+
title="Meeting Noter Help",
|
|
67
|
+
timeout=5,
|
|
68
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""UI Screens for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from meeting_noter.ui.screens.main import MainScreen
|
|
4
|
+
from meeting_noter.ui.screens.recordings import RecordingsScreen
|
|
5
|
+
from meeting_noter.ui.screens.search import SearchScreen
|
|
6
|
+
from meeting_noter.ui.screens.viewer import ViewerScreen
|
|
7
|
+
from meeting_noter.ui.screens.settings import SettingsScreen
|
|
8
|
+
from meeting_noter.ui.screens.logs import LogsScreen
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"MainScreen",
|
|
12
|
+
"RecordingsScreen",
|
|
13
|
+
"SearchScreen",
|
|
14
|
+
"ViewerScreen",
|
|
15
|
+
"SettingsScreen",
|
|
16
|
+
"LogsScreen",
|
|
17
|
+
]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Logs viewer screen for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Container, Horizontal
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import Button, Label, RichLog, Static, Switch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
LOG_FILE = Path.home() / ".meeting-noter.log"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LogsScreen(Screen):
|
|
17
|
+
"""Logs viewer screen."""
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
("l", "toggle_follow", "Follow"),
|
|
21
|
+
("c", "clear_display", "Clear"),
|
|
22
|
+
("r", "refresh", "Refresh"),
|
|
23
|
+
("escape", "go_back", "Back"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
def __init__(self, *args, **kwargs):
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
self._follow_mode = True
|
|
29
|
+
self._last_size = 0
|
|
30
|
+
|
|
31
|
+
def compose(self) -> ComposeResult:
|
|
32
|
+
"""Compose the logs screen layout."""
|
|
33
|
+
yield Container(
|
|
34
|
+
Static("[bold]Logs[/bold]", classes="title"),
|
|
35
|
+
Label(f"[dim]{LOG_FILE}[/dim]", id="log-path"),
|
|
36
|
+
Static("", classes="spacer"),
|
|
37
|
+
RichLog(id="log-content", highlight=True, markup=True),
|
|
38
|
+
Static("", classes="spacer"),
|
|
39
|
+
Horizontal(
|
|
40
|
+
Label("Follow: "),
|
|
41
|
+
Switch(value=True, id="follow-switch"),
|
|
42
|
+
Button("Refresh", id="refresh-btn"),
|
|
43
|
+
Button("Clear", id="clear-btn"),
|
|
44
|
+
Button("Back", id="back-btn"),
|
|
45
|
+
classes="button-row",
|
|
46
|
+
),
|
|
47
|
+
id="logs-container",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def on_mount(self) -> None:
|
|
51
|
+
"""Start log refresh on mount."""
|
|
52
|
+
self._load_logs()
|
|
53
|
+
self.set_interval(1.0, self._check_for_updates)
|
|
54
|
+
|
|
55
|
+
def _load_logs(self, lines: int = 100) -> None:
|
|
56
|
+
"""Load the last N lines from the log file."""
|
|
57
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
58
|
+
|
|
59
|
+
if not LOG_FILE.exists():
|
|
60
|
+
log_content.write("[yellow]No log file found[/yellow]")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
content = LOG_FILE.read_text()
|
|
65
|
+
all_lines = content.splitlines()
|
|
66
|
+
recent_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
|
67
|
+
|
|
68
|
+
log_content.clear()
|
|
69
|
+
for line in recent_lines:
|
|
70
|
+
# Color-code different log levels
|
|
71
|
+
if "Error" in line or "error" in line:
|
|
72
|
+
log_content.write(f"[red]{line}[/red]")
|
|
73
|
+
elif "Warning" in line or "warning" in line:
|
|
74
|
+
log_content.write(f"[yellow]{line}[/yellow]")
|
|
75
|
+
elif "Recording started" in line:
|
|
76
|
+
log_content.write(f"[green]{line}[/green]")
|
|
77
|
+
elif "Recording saved" in line:
|
|
78
|
+
log_content.write(f"[cyan]{line}[/cyan]")
|
|
79
|
+
else:
|
|
80
|
+
log_content.write(line)
|
|
81
|
+
|
|
82
|
+
self._last_size = LOG_FILE.stat().st_size
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
log_content.write(f"[red]Error reading log: {e}[/red]")
|
|
86
|
+
|
|
87
|
+
def _check_for_updates(self) -> None:
|
|
88
|
+
"""Check for new log content if in follow mode."""
|
|
89
|
+
if not self._follow_mode:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
follow_switch = self.query_one("#follow-switch", Switch)
|
|
93
|
+
self._follow_mode = follow_switch.value
|
|
94
|
+
|
|
95
|
+
if not self._follow_mode:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if not LOG_FILE.exists():
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
current_size = LOG_FILE.stat().st_size
|
|
103
|
+
if current_size > self._last_size:
|
|
104
|
+
# Read new content
|
|
105
|
+
with open(LOG_FILE, "r") as f:
|
|
106
|
+
f.seek(self._last_size)
|
|
107
|
+
new_content = f.read()
|
|
108
|
+
|
|
109
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
110
|
+
for line in new_content.splitlines():
|
|
111
|
+
if line.strip():
|
|
112
|
+
if "Error" in line or "error" in line:
|
|
113
|
+
log_content.write(f"[red]{line}[/red]")
|
|
114
|
+
elif "Warning" in line or "warning" in line:
|
|
115
|
+
log_content.write(f"[yellow]{line}[/yellow]")
|
|
116
|
+
elif "Recording started" in line:
|
|
117
|
+
log_content.write(f"[green]{line}[/green]")
|
|
118
|
+
elif "Recording saved" in line:
|
|
119
|
+
log_content.write(f"[cyan]{line}[/cyan]")
|
|
120
|
+
else:
|
|
121
|
+
log_content.write(line)
|
|
122
|
+
|
|
123
|
+
self._last_size = current_size
|
|
124
|
+
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
129
|
+
"""Handle button presses."""
|
|
130
|
+
button_id = event.button.id
|
|
131
|
+
|
|
132
|
+
if button_id == "refresh-btn":
|
|
133
|
+
self.action_refresh()
|
|
134
|
+
elif button_id == "clear-btn":
|
|
135
|
+
self.action_clear_display()
|
|
136
|
+
elif button_id == "back-btn":
|
|
137
|
+
self.action_go_back()
|
|
138
|
+
|
|
139
|
+
def on_switch_changed(self, event: Switch.Changed) -> None:
|
|
140
|
+
"""Handle follow switch changes."""
|
|
141
|
+
if event.switch.id == "follow-switch":
|
|
142
|
+
self._follow_mode = event.value
|
|
143
|
+
if event.value:
|
|
144
|
+
self.notify("Follow mode enabled")
|
|
145
|
+
else:
|
|
146
|
+
self.notify("Follow mode disabled")
|
|
147
|
+
|
|
148
|
+
def action_toggle_follow(self) -> None:
|
|
149
|
+
"""Toggle follow mode."""
|
|
150
|
+
switch = self.query_one("#follow-switch", Switch)
|
|
151
|
+
switch.value = not switch.value
|
|
152
|
+
|
|
153
|
+
def action_clear_display(self) -> None:
|
|
154
|
+
"""Clear the log display."""
|
|
155
|
+
log_content = self.query_one("#log-content", RichLog)
|
|
156
|
+
log_content.clear()
|
|
157
|
+
self.notify("Display cleared")
|
|
158
|
+
|
|
159
|
+
def action_refresh(self) -> None:
|
|
160
|
+
"""Refresh the log display."""
|
|
161
|
+
self._load_logs()
|
|
162
|
+
self.notify("Refreshed")
|
|
163
|
+
|
|
164
|
+
def action_go_back(self) -> None:
|
|
165
|
+
"""Go back to the previous screen."""
|
|
166
|
+
self.app.pop_screen()
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Main dashboard screen for Meeting Noter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.containers import Container, Horizontal, Vertical, Center
|
|
12
|
+
from textual.screen import Screen
|
|
13
|
+
from textual.widgets import Button, Input, Label, RichLog, Static, Switch
|
|
14
|
+
|
|
15
|
+
from meeting_noter.config import get_config, generate_meeting_name
|
|
16
|
+
from meeting_noter.daemon import read_pid_file, is_process_running
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# PID file paths
|
|
20
|
+
DEFAULT_PID_FILE = Path.home() / ".meeting-noter.pid"
|
|
21
|
+
WATCHER_PID_FILE = Path.home() / ".meeting-noter-watcher.pid"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StatusDisplay(Static):
|
|
25
|
+
"""Widget to display current status."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *args, **kwargs):
|
|
28
|
+
super().__init__(*args, **kwargs)
|
|
29
|
+
self._status = "Checking..."
|
|
30
|
+
|
|
31
|
+
def on_mount(self) -> None:
|
|
32
|
+
"""Start status refresh timer."""
|
|
33
|
+
self.set_interval(2.0, self.refresh_status)
|
|
34
|
+
self.refresh_status()
|
|
35
|
+
|
|
36
|
+
def refresh_status(self) -> None:
|
|
37
|
+
"""Refresh the status display."""
|
|
38
|
+
watcher_running = self._check_watcher()
|
|
39
|
+
daemon_running, recording_name = self._check_daemon()
|
|
40
|
+
|
|
41
|
+
if daemon_running:
|
|
42
|
+
status_icon = "[red]●[/red]"
|
|
43
|
+
status_text = f"Recording: {recording_name or 'In progress'}"
|
|
44
|
+
elif watcher_running:
|
|
45
|
+
status_icon = "[green]●[/green]"
|
|
46
|
+
status_text = "Ready (watcher active)"
|
|
47
|
+
else:
|
|
48
|
+
status_icon = "[dim]●[/dim]"
|
|
49
|
+
status_text = "Stopped"
|
|
50
|
+
|
|
51
|
+
self.update(
|
|
52
|
+
f"{status_icon} {status_text}\n\n"
|
|
53
|
+
f"[dim]Watcher:[/dim] {'running' if watcher_running else 'stopped'}\n"
|
|
54
|
+
f"[dim]Recorder:[/dim] {'recording' if daemon_running else 'idle'}"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Notify parent screen about recording state
|
|
58
|
+
try:
|
|
59
|
+
screen = self.screen
|
|
60
|
+
if hasattr(screen, "_update_recording_state"):
|
|
61
|
+
screen._update_recording_state(daemon_running)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
def _check_watcher(self) -> bool:
|
|
66
|
+
"""Check if watcher is running."""
|
|
67
|
+
if WATCHER_PID_FILE.exists():
|
|
68
|
+
try:
|
|
69
|
+
pid = int(WATCHER_PID_FILE.read_text().strip())
|
|
70
|
+
os.kill(pid, 0)
|
|
71
|
+
return True
|
|
72
|
+
except (ProcessLookupError, ValueError, FileNotFoundError):
|
|
73
|
+
pass
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
def _check_daemon(self) -> Tuple[bool, Optional[str]]:
|
|
77
|
+
"""Check if daemon is running and get recording name."""
|
|
78
|
+
daemon_pid = read_pid_file(DEFAULT_PID_FILE)
|
|
79
|
+
if daemon_pid and is_process_running(daemon_pid):
|
|
80
|
+
recording_name = self._get_current_recording_name()
|
|
81
|
+
return True, recording_name
|
|
82
|
+
return False, None
|
|
83
|
+
|
|
84
|
+
def _get_current_recording_name(self) -> Optional[str]:
|
|
85
|
+
"""Get the name of the current recording from the log file."""
|
|
86
|
+
log_path = Path.home() / ".meeting-noter.log"
|
|
87
|
+
if not log_path.exists():
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with open(log_path, "r") as f:
|
|
92
|
+
lines = f.readlines()
|
|
93
|
+
|
|
94
|
+
for line in reversed(lines[-50:]):
|
|
95
|
+
if "Recording started:" in line:
|
|
96
|
+
parts = line.split("Recording started:")
|
|
97
|
+
if len(parts) > 1:
|
|
98
|
+
filename = parts[1].strip().replace(".mp3", "")
|
|
99
|
+
name_parts = filename.split("_", 2)
|
|
100
|
+
if len(name_parts) >= 3:
|
|
101
|
+
return name_parts[2]
|
|
102
|
+
return filename
|
|
103
|
+
elif "Recording saved:" in line or "Recording discarded" in line:
|
|
104
|
+
break
|
|
105
|
+
return None
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class LiveTranscriptDisplay(RichLog):
|
|
111
|
+
"""Widget to display live transcription."""
|
|
112
|
+
|
|
113
|
+
def __init__(self, *args, **kwargs):
|
|
114
|
+
super().__init__(*args, highlight=True, markup=True, **kwargs)
|
|
115
|
+
self._live_file: Optional[Path] = None
|
|
116
|
+
self._last_content = ""
|
|
117
|
+
self._is_recording = False
|
|
118
|
+
|
|
119
|
+
def on_mount(self) -> None:
|
|
120
|
+
"""Start watching for live transcript updates."""
|
|
121
|
+
self.set_interval(0.5, self._check_live_transcript)
|
|
122
|
+
|
|
123
|
+
def set_recording(self, is_recording: bool) -> None:
|
|
124
|
+
"""Set the recording state."""
|
|
125
|
+
self._is_recording = is_recording
|
|
126
|
+
if not is_recording:
|
|
127
|
+
self._live_file = None
|
|
128
|
+
self._last_content = ""
|
|
129
|
+
|
|
130
|
+
def _check_live_transcript(self) -> None:
|
|
131
|
+
"""Check for live transcript updates."""
|
|
132
|
+
if not self._is_recording:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
config = get_config()
|
|
136
|
+
live_dir = config.recordings_dir / "live"
|
|
137
|
+
|
|
138
|
+
if not live_dir.exists():
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# Find the most recent live file
|
|
142
|
+
live_files = sorted(
|
|
143
|
+
live_dir.glob("*.live.txt"),
|
|
144
|
+
key=lambda p: p.stat().st_mtime,
|
|
145
|
+
reverse=True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if not live_files:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
live_file = live_files[0]
|
|
152
|
+
|
|
153
|
+
# Read and display new content
|
|
154
|
+
try:
|
|
155
|
+
content = live_file.read_text()
|
|
156
|
+
if len(content) > len(self._last_content):
|
|
157
|
+
new_content = content[len(self._last_content):]
|
|
158
|
+
for line in new_content.splitlines():
|
|
159
|
+
if line.strip() and line.startswith("["):
|
|
160
|
+
self.write(f"[cyan]{line}[/cyan]")
|
|
161
|
+
self._last_content = content
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class MainScreen(Screen):
|
|
167
|
+
"""Main dashboard screen."""
|
|
168
|
+
|
|
169
|
+
BINDINGS = [
|
|
170
|
+
("r", "toggle_recording", "Record"),
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
def __init__(self, *args, **kwargs):
|
|
174
|
+
super().__init__(*args, **kwargs)
|
|
175
|
+
self._is_recording = False
|
|
176
|
+
|
|
177
|
+
def compose(self) -> ComposeResult:
|
|
178
|
+
"""Compose the main screen layout."""
|
|
179
|
+
yield Container(
|
|
180
|
+
Static("[bold]Meeting Noter[/bold]", classes="title"),
|
|
181
|
+
Static("", classes="spacer"),
|
|
182
|
+
StatusDisplay(id="status"),
|
|
183
|
+
Static("", classes="spacer"),
|
|
184
|
+
Center(
|
|
185
|
+
Vertical(
|
|
186
|
+
Horizontal(
|
|
187
|
+
Label("Meeting name:", classes="input-label"),
|
|
188
|
+
Input(
|
|
189
|
+
placeholder=generate_meeting_name(),
|
|
190
|
+
id="meeting-name",
|
|
191
|
+
classes="meeting-input",
|
|
192
|
+
),
|
|
193
|
+
classes="centered-input-row",
|
|
194
|
+
),
|
|
195
|
+
Horizontal(
|
|
196
|
+
Label("Live transcription:", classes="input-label"),
|
|
197
|
+
Switch(value=True, id="live-transcription"),
|
|
198
|
+
classes="centered-switch-row",
|
|
199
|
+
),
|
|
200
|
+
classes="input-section",
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
Static("", classes="spacer"),
|
|
204
|
+
Center(
|
|
205
|
+
Horizontal(
|
|
206
|
+
Button("Start Recording", id="record-btn", variant="success"),
|
|
207
|
+
Button("Stop", id="stop-btn", variant="error"),
|
|
208
|
+
classes="centered-button-row",
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
Static("", classes="spacer"),
|
|
212
|
+
Static("[dim]Live Transcription[/dim]", id="live-header", classes="section-header"),
|
|
213
|
+
LiveTranscriptDisplay(id="live-transcript"),
|
|
214
|
+
Static("", classes="spacer"),
|
|
215
|
+
Static("[dim]Quick Navigation[/dim]", classes="section-header"),
|
|
216
|
+
Center(
|
|
217
|
+
Horizontal(
|
|
218
|
+
Button("Recordings", id="nav-recordings"),
|
|
219
|
+
Button("Search", id="nav-search"),
|
|
220
|
+
Button("Settings", id="nav-settings"),
|
|
221
|
+
Button("Logs", id="nav-logs"),
|
|
222
|
+
classes="centered-button-row",
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
id="main-container",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _update_recording_state(self, is_recording: bool) -> None:
|
|
229
|
+
"""Update UI based on recording state."""
|
|
230
|
+
if is_recording != self._is_recording:
|
|
231
|
+
self._is_recording = is_recording
|
|
232
|
+
live_display = self.query_one("#live-transcript", LiveTranscriptDisplay)
|
|
233
|
+
live_switch = self.query_one("#live-transcription", Switch)
|
|
234
|
+
|
|
235
|
+
if is_recording and live_switch.value:
|
|
236
|
+
live_display.set_recording(True)
|
|
237
|
+
live_display.clear()
|
|
238
|
+
live_display.write("[dim]Waiting for transcription...[/dim]")
|
|
239
|
+
else:
|
|
240
|
+
live_display.set_recording(False)
|
|
241
|
+
|
|
242
|
+
def action_toggle_recording(self) -> None:
|
|
243
|
+
"""Toggle recording state."""
|
|
244
|
+
daemon_pid = read_pid_file(DEFAULT_PID_FILE)
|
|
245
|
+
if daemon_pid and is_process_running(daemon_pid):
|
|
246
|
+
self._on_stop_button()
|
|
247
|
+
else:
|
|
248
|
+
self._on_record_button()
|
|
249
|
+
|
|
250
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
251
|
+
"""Handle button presses."""
|
|
252
|
+
button_id = event.button.id
|
|
253
|
+
|
|
254
|
+
if button_id == "record-btn":
|
|
255
|
+
self._on_record_button()
|
|
256
|
+
elif button_id == "stop-btn":
|
|
257
|
+
self._on_stop_button()
|
|
258
|
+
elif button_id == "nav-recordings":
|
|
259
|
+
self.app.action_switch_screen("recordings")
|
|
260
|
+
elif button_id == "nav-search":
|
|
261
|
+
self.app.action_switch_screen("search")
|
|
262
|
+
elif button_id == "nav-settings":
|
|
263
|
+
self.app.action_switch_screen("settings")
|
|
264
|
+
elif button_id == "nav-logs":
|
|
265
|
+
self.app.action_switch_screen("logs")
|
|
266
|
+
|
|
267
|
+
def _on_record_button(self) -> None:
|
|
268
|
+
"""Handle record button press."""
|
|
269
|
+
import subprocess
|
|
270
|
+
import sys
|
|
271
|
+
|
|
272
|
+
# Check if already recording
|
|
273
|
+
daemon_pid = read_pid_file(DEFAULT_PID_FILE)
|
|
274
|
+
if daemon_pid and is_process_running(daemon_pid):
|
|
275
|
+
self.notify("Already recording. Stop first.", severity="warning")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Get meeting name
|
|
279
|
+
name_input = self.query_one("#meeting-name", Input)
|
|
280
|
+
meeting_name = name_input.value.strip() or generate_meeting_name()
|
|
281
|
+
|
|
282
|
+
# Clear live transcript display
|
|
283
|
+
live_display = self.query_one("#live-transcript", LiveTranscriptDisplay)
|
|
284
|
+
live_display.clear()
|
|
285
|
+
live_display.write(f"[green]Starting recording: {meeting_name}[/green]")
|
|
286
|
+
|
|
287
|
+
# Start recording
|
|
288
|
+
subprocess.Popen(
|
|
289
|
+
[sys.executable, "-m", "meeting_noter.cli", "daemon", "--name", meeting_name],
|
|
290
|
+
stdout=subprocess.DEVNULL,
|
|
291
|
+
stderr=subprocess.DEVNULL,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
self.notify(f"Recording started: {meeting_name}", severity="information")
|
|
295
|
+
|
|
296
|
+
# Clear the input
|
|
297
|
+
name_input.value = ""
|
|
298
|
+
|
|
299
|
+
# Refresh status
|
|
300
|
+
self.query_one("#status", StatusDisplay).refresh_status()
|
|
301
|
+
|
|
302
|
+
def _on_stop_button(self) -> None:
|
|
303
|
+
"""Handle stop button press - gracefully stop daemon."""
|
|
304
|
+
# Get daemon PID and send SIGTERM for graceful shutdown
|
|
305
|
+
daemon_pid = read_pid_file(DEFAULT_PID_FILE)
|
|
306
|
+
watcher_pid = None
|
|
307
|
+
|
|
308
|
+
if WATCHER_PID_FILE.exists():
|
|
309
|
+
try:
|
|
310
|
+
watcher_pid = int(WATCHER_PID_FILE.read_text().strip())
|
|
311
|
+
except (ValueError, FileNotFoundError):
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
stopped = []
|
|
315
|
+
|
|
316
|
+
# Stop daemon gracefully (allows auto-transcribe to run)
|
|
317
|
+
if daemon_pid and is_process_running(daemon_pid):
|
|
318
|
+
try:
|
|
319
|
+
os.kill(daemon_pid, signal.SIGTERM)
|
|
320
|
+
stopped.append("recording")
|
|
321
|
+
self.notify("Stopping recording (will auto-transcribe if enabled)...", severity="information")
|
|
322
|
+
except ProcessLookupError:
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
# Stop watcher
|
|
326
|
+
if watcher_pid:
|
|
327
|
+
try:
|
|
328
|
+
os.kill(watcher_pid, signal.SIGTERM)
|
|
329
|
+
stopped.append("watcher")
|
|
330
|
+
except ProcessLookupError:
|
|
331
|
+
pass
|
|
332
|
+
WATCHER_PID_FILE.unlink(missing_ok=True)
|
|
333
|
+
|
|
334
|
+
# Update live display
|
|
335
|
+
live_display = self.query_one("#live-transcript", LiveTranscriptDisplay)
|
|
336
|
+
live_display.set_recording(False)
|
|
337
|
+
if stopped:
|
|
338
|
+
live_display.write(f"\n[yellow]Stopped: {', '.join(stopped)}[/yellow]")
|
|
339
|
+
|
|
340
|
+
if not stopped:
|
|
341
|
+
self.notify("No processes running", severity="warning")
|
|
342
|
+
else:
|
|
343
|
+
self.notify(f"Stopped: {', '.join(stopped)}", severity="information")
|
|
344
|
+
|
|
345
|
+
# Refresh status after a short delay
|
|
346
|
+
self.set_timer(1.0, lambda: self.query_one("#status", StatusDisplay).refresh_status())
|