From 4180ab6248d14242367a6f8066b0e93ee8ea8958 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 5 Nov 2025 08:19:50 +0100 Subject: [PATCH] feat: log viewer --- lib/main.dart | 6 + lib/screens/logs_screen.dart | 318 +++++++++++++++++++++++++++ lib/screens/settings_screen.dart | 15 ++ lib/screens/video_player_screen.dart | 41 ++++ lib/services/settings_service.dart | 3 + lib/utils/app_logger.dart | 225 ++++++++++++++++++- 6 files changed, 607 insertions(+), 1 deletion(-) create mode 100644 lib/screens/logs_screen.dart diff --git a/lib/main.dart b/lib/main.dart index 8d704f7f..8aab1ae3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'services/server_connection_service.dart'; import 'services/macos_titlebar_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'services/update_service.dart'; +import 'services/settings_service.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; import 'providers/theme_provider.dart'; @@ -48,6 +49,11 @@ void main() async { // Initialize language codes for track selection await LanguageCodes.initialize(); + // Initialize logger level based on debug setting + final settings = await SettingsService.getInstance(); + final debugEnabled = settings.getEnableDebugLogging(); + setLoggerLevel(debugEnabled); + // Start global fullscreen state monitoring FullscreenStateManager().startMonitoring(); diff --git a/lib/screens/logs_screen.dart b/lib/screens/logs_screen.dart new file mode 100644 index 00000000..aaea8d0e --- /dev/null +++ b/lib/screens/logs_screen.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:logger/logger.dart'; +import '../utils/app_logger.dart'; +import '../widgets/desktop_app_bar.dart'; + +class LogsScreen extends StatefulWidget { + const LogsScreen({super.key}); + + @override + State createState() => _LogsScreenState(); +} + +class _LogsScreenState extends State { + List _logs = []; + + @override + void initState() { + super.initState(); + _loadLogs(); + } + + void _loadLogs() { + setState(() { + _logs = MemoryLogOutput.getLogs(); + }); + } + + String _formatTime(DateTime time) { + final hour = time.hour.toString().padLeft(2, '0'); + final minute = time.minute.toString().padLeft(2, '0'); + final second = time.second.toString().padLeft(2, '0'); + final millisecond = time.millisecond.toString().padLeft(3, '0'); + return '$hour:$minute:$second.$millisecond'; + } + + void _clearLogs() { + setState(() { + MemoryLogOutput.clearLogs(); + _logs = []; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Logs cleared')), + ); + } + + void _copyAllLogs() { + final buffer = StringBuffer(); + bool isFirst = true; + for (final log in _logs.reversed) { + if (!isFirst) { + buffer.write('\n'); + } + isFirst = false; + + buffer.write( + '[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}'); + if (log.error != null) { + buffer.write('\nError: ${log.error}'); + } + if (log.stackTrace != null) { + buffer.write('\nStack trace:\n${log.stackTrace}'); + } + } + Clipboard.setData(ClipboardData(text: buffer.toString())); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Logs copied to clipboard')), + ); + } + + Color _getLevelColor(Level level) { + switch (level) { + case Level.error: + case Level.fatal: + return Colors.red; + case Level.warning: + return Colors.orange; + case Level.info: + return Colors.blue; + case Level.debug: + case Level.trace: + return Colors.grey; + default: + return Colors.grey; + } + } + + IconData _getLevelIcon(Level level) { + switch (level) { + case Level.error: + case Level.fatal: + return Icons.error; + case Level.warning: + return Icons.warning; + case Level.info: + return Icons.info; + case Level.debug: + case Level.trace: + return Icons.bug_report; + default: + return Icons.circle; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: const Text('Logs'), + pinned: true, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadLogs, + tooltip: 'Refresh', + ), + IconButton( + icon: const Icon(Icons.copy), + onPressed: _logs.isNotEmpty ? _copyAllLogs : null, + tooltip: 'Copy All', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: _logs.isNotEmpty ? _clearLogs : null, + tooltip: 'Clear Logs', + ), + ], + ), + if (_logs.isEmpty) + const SliverFillRemaining( + child: Center( + child: Text('No logs available'), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final log = _logs[index]; + return _LogEntryCard( + log: log, + formatTime: _formatTime, + levelColor: _getLevelColor(log.level), + levelIcon: _getLevelIcon(log.level), + ); + }, + childCount: _logs.length, + ), + ), + ), + ], + ), + ); + } +} + +class _LogEntryCard extends StatefulWidget { + final LogEntry log; + final String Function(DateTime) formatTime; + final Color levelColor; + final IconData levelIcon; + + const _LogEntryCard({ + required this.log, + required this.formatTime, + required this.levelColor, + required this.levelIcon, + }); + + @override + State<_LogEntryCard> createState() => _LogEntryCardState(); +} + +class _LogEntryCardState extends State<_LogEntryCard> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + final hasErrorOrStackTrace = + widget.log.error != null || widget.log.stackTrace != null; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0), + child: InkWell( + onTap: hasErrorOrStackTrace + ? () => setState(() => _isExpanded = !_isExpanded) + : null, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + widget.levelIcon, + color: widget.levelColor, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.log.level.name.toUpperCase(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: widget.levelColor, + fontSize: 12, + ), + ), + const SizedBox(width: 8), + Text( + widget.formatTime(widget.log.timestamp), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color + ?.withValues(alpha: 0.6), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + widget.log.message, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + if (hasErrorOrStackTrace) + Icon( + _isExpanded + ? Icons.expand_less + : Icons.expand_more, + color: Theme.of(context) + .iconTheme + .color + ?.withValues(alpha: 0.6), + ), + ], + ), + if (_isExpanded && hasErrorOrStackTrace) ...[ + const SizedBox(height: 12), + const Divider(), + const SizedBox(height: 8), + if (widget.log.error != null) ...[ + Text( + 'Error:', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: widget.levelColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[900] + : Colors.grey[200], + borderRadius: BorderRadius.circular(4), + ), + child: SelectableText( + widget.log.error.toString(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + if (widget.log.stackTrace != null) ...[ + const SizedBox(height: 12), + Text( + 'Stack Trace:', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: widget.levelColor, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[900] + : Colors.grey[200], + borderRadius: BorderRadius.circular(4), + ), + child: SelectableText( + widget.log.stackTrace.toString(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 08f1fb24..7e7bd8cf 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -10,6 +10,7 @@ import '../services/update_service.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hotkey_recorder_widget.dart'; import 'about_screen.dart'; +import 'logs_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -264,6 +265,20 @@ class _SettingsScreenState extends State { await _settingsService.setEnableDebugLogging(value); }, ), + ListTile( + leading: const Icon(Icons.article), + title: const Text('View Logs'), + subtitle: const Text('View application logs'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const LogsScreen(), + ), + ); + }, + ), ListTile( leading: const Icon(Icons.cleaning_services), title: const Text('Clear Cache'), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 939d5b31..07981813 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -50,6 +50,8 @@ class _VideoPlayerScreenState extends State { PlexClientProvider? _cachedClientProvider; bool _isPhone = false; List _availableVersions = []; + StreamSubscription? _logSubscription; + StreamSubscription? _errorSubscription; @override void initState() { @@ -105,6 +107,7 @@ class _VideoPlayerScreenState extends State { final bufferSizeBytes = bufferSizeMB * 1024 * 1024; final enableHardwareDecoding = settingsService .getEnableHardwareDecoding(); + final debugLoggingEnabled = settingsService.getEnableDebugLogging(); // Create player with configuration player = Player( @@ -113,6 +116,7 @@ class _VideoPlayerScreenState extends State { libassAndroidFont: 'assets/droid-sans.ttf', libassAndroidFontName: 'Droid Sans Fallback', bufferSize: bufferSizeBytes, + logLevel: debugLoggingEnabled ? MPVLogLevel.debug : MPVLogLevel.error, ), ); controller = VideoController( @@ -151,6 +155,12 @@ class _VideoPlayerScreenState extends State { // Listen to completion player!.stream.completed.listen(_onVideoCompleted); + // Listen to MPV logs + _logSubscription = player!.stream.log.listen(_onPlayerLog); + + // Listen to MPV errors + _errorSubscription = player!.stream.error.listen(_onPlayerError); + // Start periodic progress updates _startProgressTracking(); @@ -282,6 +292,10 @@ class _VideoPlayerScreenState extends State { // Stop progress tracking _progressTimer?.cancel(); + // Cancel stream subscriptions + _logSubscription?.cancel(); + _errorSubscription?.cancel(); + // Send final stopped state _sendProgress('stopped'); @@ -907,6 +921,33 @@ class _VideoPlayerScreenState extends State { } } + void _onPlayerLog(PlayerLog log) { + // Map MPV log levels to app logger levels + switch (log.level.toLowerCase()) { + case 'fatal': + case 'error': + appLogger.e('[MPV:${log.prefix}] ${log.text}'); + break; + case 'warn': + appLogger.w('[MPV:${log.prefix}] ${log.text}'); + break; + case 'info': + appLogger.i('[MPV:${log.prefix}] ${log.text}'); + break; + case 'debug': + case 'trace': + case 'v': + appLogger.d('[MPV:${log.prefix}] ${log.text}'); + break; + default: + appLogger.d('[MPV:${log.prefix}:${log.level}] ${log.text}'); + } + } + + void _onPlayerError(String error) { + appLogger.e('[MPV ERROR] $error'); + } + Future _playNext() async { if (_nextEpisode == null || _isLoadingNext) return; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index f491f781..1c35f8e5 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:plezy/utils/app_logger.dart'; enum ThemeMode { system, light, dark } @@ -56,6 +57,8 @@ class SettingsService { // Debug Logging Future setEnableDebugLogging(bool enabled) async { await _prefs.setBool(_keyEnableDebugLogging, enabled); + // Update logger level immediately when setting changes + setLoggerLevel(enabled); } bool getEnableDebugLogging() { diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index d15ac7e9..43be6817 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -1,5 +1,204 @@ import 'package:logger/logger.dart'; +/// Redacts sensitive information from log messages +String _redactSensitiveData(String message) { + String redacted = message; + + // Redact Plex tokens (alphanumeric strings typically 20+ characters) + // Pattern: X-Plex-Token=... or token=... or accessToken=... or similar + redacted = redacted.replaceAllMapped( + RegExp(r'([Tt]oken[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), + (match) => '${match.group(1)}[REDACTED]', + ); + + // Redact authorization headers + redacted = redacted.replaceAllMapped( + RegExp(r'([Aa]uthorization[=:]\s*)([A-Za-z0-9_\-\.]+)', caseSensitive: false), + (match) => '${match.group(1)}[REDACTED]', + ); + + // Redact API keys + redacted = redacted.replaceAllMapped( + RegExp(r'([Aa]pi[Kk]ey[=:]\s*)([A-Za-z0-9_-]{10,})', caseSensitive: false), + (match) => '${match.group(1)}[REDACTED]', + ); + + // Redact passwords + redacted = redacted.replaceAllMapped( + RegExp(r'([Pp]assword[=:]\s*)([^\s&,;]+)', caseSensitive: false), + (match) => '${match.group(1)}[REDACTED]', + ); + + // Redact full URLs with tokens in query parameters + redacted = redacted.replaceAllMapped( + RegExp(r'(https?://[^\s]*[?&])([Xx]-[Pp]lex-[Tt]oken|token)=([A-Za-z0-9_-]+)'), + (match) => '${match.group(1)}${match.group(2)}=[REDACTED]', + ); + + // Redact IP addresses in dot notation (e.g., 192.168.1.100) + redacted = redacted.replaceAllMapped( + RegExp(r'\b(\d{1,3}\.)(\d{1,3}\.)(\d{1,3}\.)(\d{1,3})\b'), + (match) => '${match.group(1)}***.***.${match.group(4)}', + ); + + // Redact IP addresses in dash notation (e.g., 192-168-1-11) + redacted = redacted.replaceAllMapped( + RegExp(r'\b(\d{1,3}-)(\d{1,3}-)(\d{1,3}-)(\d{1,3})\b'), + (match) => '${match.group(1)}***-***-${match.group(4)}', + ); + + // Redact standalone token-like strings (20+ alphanumeric characters) + // Only if they appear in common token contexts + redacted = redacted.replaceAllMapped( + RegExp(r'\b([A-Za-z0-9_-]{20,})\b'), + (match) { + final token = match.group(1)!; + // Only redact if it looks like a token (mixed case or contains hyphens/underscores) + if (token.contains(RegExp(r'[A-Z]')) && token.contains(RegExp(r'[a-z]')) || + token.contains('_') || token.contains('-')) { + return '[REDACTED_TOKEN]'; + } + return token; + }, + ); + + return redacted; +} + +/// Represents a single log entry stored in memory +class LogEntry { + final DateTime timestamp; + final Level level; + final String message; + final Object? error; + final StackTrace? stackTrace; + + LogEntry({ + required this.timestamp, + required this.level, + required this.message, + this.error, + this.stackTrace, + }); + + /// Estimate the memory size of this log entry in bytes + int get estimatedSize { + int size = 0; + // DateTime: ~8 bytes + size += 8; + // Level enum: ~4 bytes + size += 4; + // Message string: 2 bytes per character (UTF-16) + size += message.length * 2; + // Error string: 2 bytes per character if present + if (error != null) { + size += error.toString().length * 2; + } + // Stack trace string: 2 bytes per character if present + if (stackTrace != null) { + size += stackTrace.toString().length * 2; + } + return size; + } +} + +/// Custom log output that stores logs in memory with a circular buffer +class MemoryLogOutput extends LogOutput { + static const int maxLogSizeBytes = 5 * 1024 * 1024; // 5 MB + static final List _logs = []; + static int _currentSize = 0; + + /// Get all stored logs (newest first) + static List getLogs() => List.unmodifiable(_logs.reversed); + + /// Clear all stored logs + static void clearLogs() { + _logs.clear(); + _currentSize = 0; + } + + /// Get current log buffer size in bytes + static int getCurrentSize() => _currentSize; + + /// Get current log buffer size in MB + static double getCurrentSizeMB() => _currentSize / (1024 * 1024); + + @override + void output(OutputEvent event) { + // Extract relevant information from the log event + for (var line in event.lines) { + final logEntry = LogEntry( + timestamp: DateTime.now(), + level: event.level, + message: _redactSensitiveData(line), + ); + + _logs.add(logEntry); + _currentSize += logEntry.estimatedSize; + + // Maintain buffer size limit (remove oldest entries) + while (_currentSize > maxLogSizeBytes && _logs.isNotEmpty) { + final removed = _logs.removeAt(0); + _currentSize -= removed.estimatedSize; + } + } + } +} + +/// Custom log printer that also stores error and stack trace information +class MemoryAwareLogPrinter extends LogPrinter { + final LogPrinter _wrappedPrinter; + + MemoryAwareLogPrinter(this._wrappedPrinter); + + @override + List log(LogEvent event) { + // Store the log with error and stack trace if available + final message = _redactSensitiveData(event.message.toString()); + final error = event.error != null + ? _redactSensitiveData(event.error.toString()) + : null; + + final logEntry = LogEntry( + timestamp: DateTime.now(), + level: event.level, + message: message, + error: error, + stackTrace: event.stackTrace, + ); + + MemoryLogOutput._logs.add(logEntry); + MemoryLogOutput._currentSize += logEntry.estimatedSize; + + // Maintain buffer size limit (remove oldest entries) + while (MemoryLogOutput._currentSize > MemoryLogOutput.maxLogSizeBytes && + MemoryLogOutput._logs.isNotEmpty) { + final removed = MemoryLogOutput._logs.removeAt(0); + MemoryLogOutput._currentSize -= removed.estimatedSize; + } + + // Delegate to wrapped printer for console output + return _wrappedPrinter.log(event); + } +} + +/// Custom production filter that respects our level setting even in release mode +class ProductionFilter extends LogFilter { + Level _currentLevel = Level.debug; + + void setLevel(Level level) { + _currentLevel = level; + } + + @override + bool shouldLog(LogEvent event) { + return event.level.value >= _currentLevel.value; + } +} + +/// Global filter instance +final _productionFilter = ProductionFilter(); + /// Centralized logger instance for the application. /// /// Usage: @@ -11,4 +210,28 @@ import 'package:logger/logger.dart'; /// appLogger.w('Warning message'); /// appLogger.e('Error message', error: e, stackTrace: stackTrace); /// ``` -final appLogger = Logger(printer: SimplePrinter(), level: Level.debug); +Logger appLogger = Logger( + printer: MemoryAwareLogPrinter(SimplePrinter()), + filter: _productionFilter, + level: Level.debug, +); + +/// Update the logger's level dynamically based on debug setting +/// Recreates the logger instance to ensure it works in release mode +void setLoggerLevel(bool debugEnabled) { + final newLevel = debugEnabled ? Level.debug : Level.info; + + // Update the filter level + _productionFilter.setLevel(newLevel); + + // Recreate the logger instance with the new level + // This ensures it works in release mode where Logger.level might be optimized away + appLogger = Logger( + printer: MemoryAwareLogPrinter(SimplePrinter()), + filter: _productionFilter, + level: newLevel, + ); + + // Also set the static level for consistency + Logger.level = newLevel; +}