diff --git a/README.md b/README.md index aafbaa8..373444c 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ then everyone (you included) uses Join. | `--[no-]yt-dlp` | on | read web pages (YouTube, X, …) with yt-dlp when the server fetches a link | | `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) | | `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube | -| `--log-dir` | `logs` next to `--config`, else next to the binary (systemd: the unit's `StateDirectory`) | folder for the log files (`server-YYYY-MM-DD.log`, kept 7 days; passwords, tokens and cookies left out) | +| `--log-dir` | `logs` next to `--config`, else next to the binary (systemd: the unit's `StateDirectory`) | folder for the log files (`server-YYYY-MM-DD.log` and the session log `session-YYYY-MM-DD.log`, kept 7 days; passwords, tokens and cookies left out) | | `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags | | `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit | | `--check-update` | | print whether a newer release exists, then exit | @@ -251,6 +251,19 @@ Something not playing? Settings → About → **Copy diagnostics** puts the app's recent log (passwords, tokens and cookies left out) on the clipboard: paste it to whoever helps you, from both sides if you can. +While you are in a server, the app also sends its log to that server, which +merges everyone's lines and its own into one session log +(`session-YYYY-MM-DD.log` next to the server's logs; the hosting app keeps +it in its own logs folder), so a problem can be read afterwards from every +side. Chat is never logged anywhere, nor passwords, tokens and cookies. +When something goes wrong, press **Mark a problem** (Ctrl+Shift+B, also in +the player's ⚙ and Settings → About) and add a few words if you like: a +marker with your name goes in the session log at that moment. In a server, +Copy diagnostics can add the session log of the last 6 hours (any member +may read it). Don't want to share? Settings → About → **Share diagnostics +with the server** off: your log stays on your machine (the server only +notes that you don't share; a problem you mark still goes in). + ## Join Join in the strip. Servers you got into are listed first (up to 6), with @@ -281,7 +294,8 @@ overrides it); in the player also the subtitle and audio track. Every change applies right away. Anywhere in a server (unless you are typing): Space = ready / unready, -Tab = chat & activity (Tab never moves between buttons). Keys while the +Tab = chat & activity (Tab never moves between buttons). Anywhere: +Ctrl+Shift+B = mark a problem in the session log. Keys while the player fills the window: Left/Right = seek 10 s, Up/Down = volume, Shift+Right = skip 1:30, PgUp/PgDn = previous / next chapter (as in mpv), F = fullscreen, Esc = close the chat, then fullscreen, then shrink the player to mini; from the diff --git a/apps/desktop/lib/src/controls/player_settings.dart b/apps/desktop/lib/src/controls/player_settings.dart index 294069b..e1ce673 100644 --- a/apps/desktop/lib/src/controls/player_settings.dart +++ b/apps/desktop/lib/src/controls/player_settings.dart @@ -8,6 +8,7 @@ import '../jellyfin/track_prefs.dart' show MpvTrack; import '../playback/playback_controller.dart' show PlaybackController, RenderMode; import '../shaders/shader_presets.dart'; import '../state/session_state.dart'; +import '../shell/mark_problem.dart'; import '../shell/motion.dart'; import '../state/settings.dart'; import '../widgets/app_menu.dart'; @@ -332,9 +333,16 @@ class PlayerSettingsCard extends StatelessWidget { const TrackSetting(type: JellyfinStreamType.audio), ], const SizedBox(height: 4), - Text( - 'Custom shader files: Settings → Player', - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12), + Row( + children: [ + Expanded( + child: Text( + 'Custom shader files: Settings → Player', + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12), + ), + ), + const MarkProblemButton(), + ], ), ], ), diff --git a/apps/desktop/lib/src/platform/app_log.dart b/apps/desktop/lib/src/platform/app_log.dart index c318a17..e9fbace 100644 --- a/apps/desktop/lib/src/platform/app_log.dart +++ b/apps/desktop/lib/src/platform/app_log.dart @@ -44,8 +44,10 @@ Future startAppLogging() async { } /// The text "Copy diagnostics" gives: versions, then the last [since] of -/// the app log (its hosted server's records included), already redacted. -Future diagnosticsText({Duration since = const Duration(hours: 6)}) async { +/// the app log (its hosted server's records included), already redacted; +/// with [sessionLog], the server's session log of the same hours after it +/// (every member's shared lines and the server's, merged). +Future diagnosticsText({Duration since = const Duration(hours: 6), bool sessionLog = false}) async { final b = StringBuffer() ..writeln('$appName ${displayVersion()}') ..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}') @@ -68,6 +70,13 @@ Future diagnosticsText({Duration since = const Duration(hours: 6)}) asyn b.writeln('Log: ${files?.currentPath ?? 'none (console only)'}'); b.writeln('--- last ${since.inHours} h of the log ---'); b.write(files?.recent(since) ?? ''); + if (sessionLog) { + final server = await SessionState.instance.coordinator.sessionLog(hours: since.inHours); + b + ..writeln() + ..writeln("--- last ${since.inHours} h of the server's session log ---") + ..write(server ?? "(none: the server keeps no session log, or it couldn't be reached)"); + } return redactLog(b.toString()); } diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index 7c83bb2..992bfcc 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:signals/signals_flutter.dart'; import '../../platform/app_log.dart'; +import '../../shell/mark_problem.dart'; +import '../../state/session_state.dart'; +import '../../state/settings.dart'; import '../../update/update_controller.dart'; +import '../../widgets/app_menu.dart'; import '../../widgets/copy_open.dart'; import 'settings_page.dart'; import 'ui.dart'; @@ -45,18 +50,24 @@ class AboutPanel extends StatelessWidget { ), const SizedBox(height: kGap), Text('Something went wrong? Copy diagnostics and send them to whoever helps you: ' - 'versions and the last hours of the log, passwords, tokens and cookies left out.', + 'versions and the last hours of the log, passwords, tokens and cookies left out. ' + 'In a session it can add the server\'s session log, everyone\'s lines merged. ' + 'Mark a problem (Ctrl+Shift+B) when it happens so the spot is easy to find.', style: t.bodySmall?.copyWith(color: Colors.grey)), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ - FilledButton.tonalIcon( - onPressed: _copyDiagnostics, - icon: const Icon(Icons.bug_report_outlined, size: 18), - label: const Text('Copy diagnostics'), + Builder( + builder: (context) => FilledButton.tonalIcon( + key: const ValueKey('copy-diagnostics'), + onPressed: () => _copyDiagnostics(context), + icon: const Icon(Icons.bug_report_outlined, size: 18), + label: const Text('Copy diagnostics'), + ), ), + const MarkProblemButton(outlined: true), OutlinedButton.icon( onPressed: appLogDir == null ? null : () => _openLogs(), icon: const Icon(Icons.folder_open, size: 18), @@ -64,13 +75,36 @@ class AboutPanel extends StatelessWidget { ), ], ), + const SizedBox(height: 8), + SignalBuilder( + builder: (context) => SwitchListTile( + key: const ValueKey('share-diagnostics'), + contentPadding: EdgeInsets.zero, + title: const Text('Share diagnostics with the server'), + subtitle: const Text('In a session, this app\'s log goes to the server\'s session log, ' + 'merged with everyone\'s, so a problem can be read afterwards. Never chat; ' + 'passwords and tokens left out. Off: only a problem you mark is sent.'), + value: AppSettings.instance.shareDiagnostics.value, + onChanged: AppSettings.instance.setShareDiagnostics, + ), + ), ], ), ); } - static Future _copyDiagnostics() async { - final text = await diagnosticsText(); + /// In a session it asks whether to add the server's session log. + static Future _copyDiagnostics(BuildContext context) async { + var withServer = false; + if (SessionState.instance.route.peek() == AppRoute.session) { + final pick = await showAppMenu(context, items: const [ + AppMenuItem(value: false, label: "This app's log"), + AppMenuItem(value: true, label: "This app's and the server's session log"), + ]); + if (pick == null) return; + withServer = pick; + } + final text = await diagnosticsText(sessionLog: withServer); final saved = await saveDiagnostics(text); await copyToClipboard(text, done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)'); diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index a3fe6e5..b8605f3 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -23,6 +23,7 @@ const sessionShortcuts = [ ('Ctrl+1…6', 'Shaders: Anime4K mode A, B, C, A+A, B+B, C+A'), ('Ctrl+0', 'Shaders off'), ('Esc', 'Close the panel or fullscreen, shrink the player, then ask to leave'), + ('Ctrl+Shift+B', 'Mark a problem in the session log (anywhere)'), ]; /// One titled card of the Settings tab (rounded Material, so list tiles diff --git a/apps/desktop/lib/src/session/session_coordinator.dart b/apps/desktop/lib/src/session/session_coordinator.dart index 72be63c..1b9d315 100644 --- a/apps/desktop/lib/src/session/session_coordinator.dart +++ b/apps/desktop/lib/src/session/session_coordinator.dart @@ -17,6 +17,7 @@ import '../playback/playback_controller.dart'; import '../platform/attention.dart'; import '../playback/yt_dlp_app.dart'; import '../schedule/anilist.dart'; +import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/picked_media.dart'; import '../sfx/sfx.dart'; import '../state/settings.dart'; @@ -174,6 +175,32 @@ class SessionCoordinator { /// The app's yt-dlp and link probes (tests pass their own). final AppYtDlp ytDlp; final SyncClient sync = SyncClient(); + /// This app's log lines to the server's session log (Settings: "Share + /// diagnostics with the server"), and "Mark a problem". + late final DiagUploader diag = DiagUploader( + target: _diagTarget, version: '$appName ${displayVersion()} on ${Platform.operatingSystem}'); + + DiagTarget? _diagTarget() { + final uri = sync.wsUri; + if (sync.token.isEmpty || uri == null || !sync.clockSync.hasSample) return null; + return (base: DiagUploader.baseOf(uri), token: sync.token, offsetMs: sync.clockSync.offsetMs); + } + + /// "Mark a problem": a marker with [note] in the server's session log + /// (and this app's log). False when not in a session: then only this + /// app's log has it. + Future markProblem(String note) async { + if (!diag.running) { + createLogger(DiagUploader.markLogger).warning('=== PROBLEM marked${note.trim().isEmpty ? '' : ': ${note.trim()}'} ==='); + return false; + } + await diag.markProblem(note); + return true; + } + + /// The server's session log of the last [hours], null when not in a + /// session or the server has none. + Future sessionLog({int hours = 6}) => diag.fetchSessionLog(hours: hours); /// Up next: the server's queue, adding to it, and putting items up. late final QueueController queue = QueueController(this); /// The server has a password (it fetches links only then). @@ -498,6 +525,9 @@ class SessionCoordinator { } markInput(); _subscribe(); + diag + ..share = AppSettings.instance.shareDiagnostics.peek() + ..start(); feedLog.value = []; toasts.value = []; await initPlayer(); @@ -1201,6 +1231,9 @@ class SessionCoordinator { toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList(); final r = serverState.peek(); if (sync.token.isNotEmpty) sync.sendPing(); + diag + ..share = AppSettings.instance.shareDiagnostics.peek() + ..tick(); if (sync.token.isNotEmpty && r != null && _playerReady) { _sendReport(r); if (!_lastOut) { @@ -2235,6 +2268,8 @@ class SessionCoordinator { try { await playback.dispose(); } catch (_) {} + // The last lines go before the token does. + await diag.stop(); try { await sync.disconnect(); } catch (_) {} diff --git a/apps/desktop/lib/src/shell/mark_problem.dart b/apps/desktop/lib/src/shell/mark_problem.dart new file mode 100644 index 0000000..3bef1c7 --- /dev/null +++ b/apps/desktop/lib/src/shell/mark_problem.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:sync_relay_core/core.dart' show RelayConstants; + +import '../state/session_state.dart'; +import '../widgets/app_menu.dart' show AppGlass; +import 'motion.dart'; + +// "Mark a problem" (Ctrl+Shift+B, the player's ⚙, Settings → About): a +// marker line in the server's session log at this moment, with an +// optional short note, so whoever reads the log finds the spot. + +/// The key that opens it. +const markProblemKey = SingleActivator(LogicalKeyboardKey.keyB, control: true, shift: true); + +/// Asks for the note, then marks; the toast says where it went. +Future showMarkProblem(BuildContext context) async { + final note = await showPopover(context, at: Alignment.center, child: const MarkProblemCard()); + if (note == null) return; + final coord = SessionState.instance.coordinator; + final inSession = await coord.markProblem(note); + coord.showToast(inSession ? 'Marked in the session log' : "Marked in this app's log"); +} + +/// The small card: a one-line note and Mark. Pops with the note (maybe +/// empty), or nothing on Cancel / Esc. +class MarkProblemCard extends StatefulWidget { + const MarkProblemCard({super.key}); + + @override + State createState() => _MarkProblemCardState(); +} + +class _MarkProblemCardState extends State { + final _note = TextEditingController(); + + @override + void dispose() { + _note.dispose(); + super.dispose(); + } + + void _mark() => Navigator.of(context).pop(_note.text.trim()); + + @override + Widget build(BuildContext context) { + final muted = Theme.of(context).colorScheme.onSurfaceVariant; + return AppGlass( + key: const ValueKey('mark-problem-card'), + child: SizedBox( + width: 380, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('Mark a problem', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + const SizedBox(height: 4), + Text("Puts a marker in the server's session log right now, so whoever reads it finds the moment.", + style: TextStyle(color: muted, fontSize: 12)), + const SizedBox(height: 8), + TextField( + key: const ValueKey('mark-problem-note'), + controller: _note, + autofocus: true, + maxLength: RelayConstants.maxProblemNoteLength, + decoration: const InputDecoration( + isDense: true, + hintText: 'What happened? (optional)', + counterText: '', + ), + onSubmitted: (_) => _mark(), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), + const SizedBox(width: 8), + FilledButton.tonal( + key: const ValueKey('mark-problem-go'), + onPressed: _mark, + child: const Text('Mark'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// "Mark a problem" as a quiet row button (the ⚙ card, Settings → About). +class MarkProblemButton extends StatelessWidget { + final bool outlined; + const MarkProblemButton({super.key, this.outlined = false}); + + @override + Widget build(BuildContext context) { + const icon = Icon(Icons.flag_outlined, size: 18); + const label = Text('Mark a problem'); + void open() => showMarkProblem(context); + return Tooltip( + message: 'Ctrl+Shift+B', + child: outlined + ? OutlinedButton.icon(key: const ValueKey('mark-problem'), onPressed: open, icon: icon, label: label) + : TextButton.icon(key: const ValueKey('mark-problem'), onPressed: open, icon: icon, label: label), + ); + } +} diff --git a/apps/desktop/lib/src/shell/session_player.dart b/apps/desktop/lib/src/shell/session_player.dart index 3e041bc..ca49041 100644 --- a/apps/desktop/lib/src/shell/session_player.dart +++ b/apps/desktop/lib/src/shell/session_player.dart @@ -29,6 +29,7 @@ import '../widgets/app_keys.dart'; import '../widgets/text_focus.dart'; import '../widgets/window_buttons.dart'; import 'avatar.dart'; +import 'mark_problem.dart'; import 'motion.dart'; import 'now_playing_title.dart'; import 'player_status.dart'; @@ -627,6 +628,7 @@ class _SessionPlayerState extends State { (title: 'Change video', hint: null, run: widget.onChangeVideo), (title: 'Chat & activity', hint: 'Tab', run: _toggleChat), (title: 'Reload video', hint: 'Ctrl+R', run: _reloadVideo), + (title: 'Mark a problem', hint: 'Ctrl+Shift+B', run: () => showMarkProblem(context)), (title: 'Mark everyone ready', hint: null, run: coord.forcePlayAll), (title: 'Fullscreen', hint: 'F', run: widget.onToggleFullscreen), (title: 'Leave server', hint: 'Esc', run: widget.onLeave), diff --git a/apps/desktop/lib/src/state/server_isolate.dart b/apps/desktop/lib/src/state/server_isolate.dart index 937efd4..ab038b0 100644 --- a/apps/desktop/lib/src/state/server_isolate.dart +++ b/apps/desktop/lib/src/state/server_isolate.dart @@ -33,10 +33,12 @@ class ServerIsolate { /// Spawns the server and waits until it listens. Throws a /// [StateError] with the server's message if it cannot start. - /// [loopbackOnly]: 127.0.0.1 only (the solo session). + /// [loopbackOnly]: 127.0.0.1 only (the solo session). [logDir]: where + /// the server writes its session log (none when null). static Future start( AppConfig config, { bool loopbackOnly = false, + String? logDir, required void Function(String line) onLog, required void Function(String error) onCrash, Duration timeout = const Duration(seconds: 15), @@ -85,7 +87,7 @@ class ServerIsolate { final Isolate isolate; try { isolate = await Isolate.spawn( - _serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true}, events.sendPort), + _serverMain, ({...config.toMap(), if (loopbackOnly) 'loopbackOnly': true, 'logDir': ?logDir}, events.sendPort), errorsAreFatal: false, onError: errors.sendPort, onExit: exit.sendPort, diff --git a/apps/desktop/lib/src/state/session_state.dart b/apps/desktop/lib/src/state/session_state.dart index 937a6b4..89c2e70 100644 --- a/apps/desktop/lib/src/state/session_state.dart +++ b/apps/desktop/lib/src/state/session_state.dart @@ -10,6 +10,7 @@ import '../jellyfin/accounts.dart'; import '../jellyfin/session_media.dart'; import '../screens/home/health_probe.dart'; import '../screens/home/picked_media.dart'; +import '../platform/app_log.dart' show appLogDir; import '../playback/yt_dlp_app.dart'; import '../sfx/sfx.dart'; import '../sfx/sound_bank.dart'; @@ -379,6 +380,9 @@ class SessionState { ytDlpDir: await _sharedYtDlpDir(), ), loopbackOnly: soloSession, + // Its session log (every member's shared diagnostics and its own + // records) goes next to the app's; not for a solo session. + logDir: soloSession ? null : appLogDir, // Its records join the app's log as `server.`. onLog: (line) => relogLine('server', line), onCrash: (cause) => _onServerCrash(server, cause), diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index 1f08114..4f7a66d 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -28,6 +28,7 @@ const _keySoundPresetFor = 'soundPresetFor'; const _keyUnreadyOnSwap = 'unreadyOnSwap'; const _keyIdleSitsOut = 'idleSitsOut'; const _keyChatPopups = 'chatPopups'; +const _keyShareDiagnostics = 'shareDiagnostics'; enum HwdecMode { auto, off, custom } @@ -70,6 +71,9 @@ class AppSettings { /// A 2 px progress line at the bottom of the video while the controls /// are hidden (both designs). Applies live. final progressLine = signal(true); + /// Send this app's log lines to the server's session log while in a + /// session (never chat). Off: nothing is sent but a note that it is off. + final shareDiagnostics = signal(true); /// Chat message toasts (default: only when paused). Applies live. final chatPopups = signal(ChatPopups.paused); /// App sounds (`sfx/`): 0..1, 0 = silent. @@ -129,6 +133,8 @@ class AppSettings { if (hideRe is bool) hideReReleases.value = hideRe; final line = box.get(_keyProgressLine); if (line is bool) progressLine.value = line; + final share = box.get(_keyShareDiagnostics); + if (share is bool) shareDiagnostics.value = share; final popups = box.get(_keyChatPopups); chatPopups.value = ChatPopups.values.where((m) => m.name == popups).firstOrNull ?? ChatPopups.paused; final vol = box.get(_keySoundVolume); @@ -203,6 +209,11 @@ class AppSettings { _put(_keyProgressLine, on); } + void setShareDiagnostics(bool on) { + shareDiagnostics.value = on; + _put(_keyShareDiagnostics, on); + } + void setChatPopups(ChatPopups mode) { chatPopups.value = mode; _put(_keyChatPopups, mode.name); diff --git a/apps/desktop/lib/src/widgets/app_keys.dart b/apps/desktop/lib/src/widgets/app_keys.dart index 0ede7fd..0f3a379 100644 --- a/apps/desktop/lib/src/widgets/app_keys.dart +++ b/apps/desktop/lib/src/widgets/app_keys.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../shell/mark_problem.dart'; import '../state/session_state.dart'; import 'text_focus.dart'; @@ -15,11 +16,12 @@ Map get appShortcuts => { const SingleActivator(LogicalKeyboardKey.space): const DoNothingIntent(), }; -/// The window's two app-wide keys, ahead of whatever has focus: while in a +/// The window's app-wide keys, ahead of whatever has focus: while in a /// server, Space is ready / not ready and Tab opens or closes the chat, /// on every tab and in every player mode. Not while a text field has /// focus (the chat field handles Tab and Esc itself) nor while a dialog or -/// menu is in front of [child]. +/// menu is in front of [child]. Ctrl+Shift+B ([markProblemKey]) marks a +/// problem anywhere, a text field focused or not. class AppKeys extends StatefulWidget { /// Who toggles the chat, latest first: the strip while it is shown, the /// full player in fullscreen (both hold the shell's toggle). @@ -53,6 +55,11 @@ class _AppKeysState extends State { bool _key(KeyEvent event) { if (event is! KeyDownEvent || !mounted) return false; final key = event.logicalKey; + if (markProblemKey.accepts(event, HardwareKeyboard.instance)) { + if (ModalRoute.of(context)?.isCurrent == false) return false; + showMarkProblem(context); + return true; + } if (key != LogicalKeyboardKey.space && key != LogicalKeyboardKey.tab) return false; if (SessionState.instance.route.peek() != AppRoute.session) return false; if (ModalRoute.of(context)?.isCurrent == false) return false; diff --git a/apps/desktop/test/app_keys_test.dart b/apps/desktop/test/app_keys_test.dart index 99c7434..4c3f660 100644 --- a/apps/desktop/test/app_keys_test.dart +++ b/apps/desktop/test/app_keys_test.dart @@ -65,4 +65,24 @@ void main() { expect(readies, 1); expect(panel, findsNothing, reason: 'Space pressed a focused chat button'); }); + + testWidgets('Ctrl+Shift+B opens Mark a problem; Esc closes it unmarked', (tester) async { + await tester.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + shortcuts: appShortcuts, + home: const AppKeys(child: Scaffold(body: SizedBox())), + )); + final card = find.byKey(const ValueKey('mark-problem-card')); + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(card, findsOneWidget); + expect(textInputFocused(), isTrue, reason: 'the note field takes the keys'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(card, findsNothing); + }); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c2b5852..3789bb8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1517,9 +1517,14 @@ dedicated server (`package:logging` loggers named by area: `relay`, `--config`, else `logs` next to the binary (systemd: `$STATE_DIRECTORY/logs`), else `~/.cache/watch-together/logs`; it prints the folder at start. Both also print every line to stdout. + The session log (below) sits next to them: the hosting app's server + writes it in the app's logs folder (not for a solo session), the + dedicated one in its log folder. - **Files** (`LogFiles`): one per day, the next `.1.log`, `.2.log` once a file passes 10 MiB; files older than 7 days, then the oldest beyond - 100 MiB in all, are deleted whenever one is opened. A line is + 100 MiB in all (the app's or server's files and the session log's + together, the oldest of either first, never the other writer's file of + today), are deleted whenever one is opened. A line is `2026-10-12 18:44:27.202 LEVEL logger: message`, the error and stack trace on the lines below. Writes are synchronous appends (nothing lost in a crash). Lines from before the folder is known (the app finds it @@ -1527,7 +1532,7 @@ dedicated server (`package:logging` loggers named by area: `relay`, - **Redaction** (`redactLog`): every line, in one place (`formatLogLine`), before console or file: URL query strings cut to their parameter names (signed CDN links, Jellyfin `api_key`), - `user:pass@` in URLs, member tokens in `/r /m /j /a /i` paths, and the + `user:pass@` in URLs, member tokens in `/r /m /j /a /i /l` paths, and the values of passwords, secrets, tokens, API keys, cookies, `Authorization` / `X-Emby-*` headers (`name=value`, `"name":"value"`, `Name="value"`, `Name: value`) and `Bearer` credentials. Code logs @@ -1554,12 +1559,51 @@ dedicated server (`package:logging` loggers named by area: `relay`, source (media opened and how long it took, channel up and down, reconnects, upstream refusals with the header names sent, re-resolves of expired format URLs). +- **Never logged**: chat text (the server relays it without a log line, + the app does not log it), and no user-typed free text other than a + problem mark's note. Names, media titles and links (query cut) are. +- **The session log** (`packages/server/lib/src/session_log.dart`, + `packages/core/lib/src/diag_share.dart`): one shared log per server so + a problem can be read afterwards from every side. While in a session + the app (`DiagUploader`, owned by the coordinator) keeps its own + records (not its hosted server's `server.*`, which the server logs + itself), redacted, and sends them every 10 s from the session's 1 s + ticker and when it leaves (before its token goes, at most 1.5 s), as + gzip batches to `POST /l//` (PROTOCOL + "Diagnostics"), timestamps moved to the server's clock with the ping + offset (`ClockSync`), the app version in every batch. Until it can + send (reconnecting, no clock sample yet) it keeps up to 2 MiB, the + oldest dropped and counted. The server (`SessionLog`) takes every + member's lines and its own records, holds them 15 s and writes them in + server-time order to `session-YYYY-MM-DD.log` (day and time on the + server's clock; a line later than that, a reconnect's backlog, goes in + with its own time, a little out of order). A line is + `HH:MM:SS.mmm [who] logger: message` (`LEVEL logger` from WARNING up), + `who` = `name (id)` or `server`, continuation lines indented; every + line is redacted again on the server. The server notes once per member + `Bob (m2) shares diagnostics ()` or `Bob (m2) doesn't share + diagnostics`, and `N lines dropped` when the app's buffer overflowed. + Limits and rate caps: PROTOCOL "Diagnostics"; a refused batch stays in + the app's buffer for the next try. +- **Opting out**: Settings → About → "Share diagnostics with the server" + (default on). Off: no lines are kept or sent; one batch per connection + says so (`share:false`). The local log is unchanged. +- **Mark a problem**: Ctrl+Shift+B (`AppKeys`), the player's ⚙ card and + Settings → About open a small card with an optional note (200 + characters, one line); the mark goes at once with the lines waiting, + opted out or not, and the server writes `[who] === PROBLEM marked by + : ===` at its time. The app's own log gets + `=== PROBLEM marked: ===` (logger `problem`, never sent). Out + of a session only the app's log gets it. The toast says which. - **Getting it**: Settings → About → "Copy diagnostics" puts the app and OS versions, mpv/ffmpeg and yt-dlp versions, whether yt-dlp has cookies, and the last 6 hours of the log (up to 4 MiB) on the clipboard, already redacted, and saves the same text as `diagnostics-