feat: log viewer

This commit is contained in:
edde746
2025-11-05 08:25:28 +01:00
parent 597640ece5
commit 4180ab6248
6 changed files with 607 additions and 1 deletions
+6
View File
@@ -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();
+318
View File
@@ -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<LogsScreen> createState() => _LogsScreenState();
}
class _LogsScreenState extends State<LogsScreen> {
List<LogEntry> _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',
),
),
),
],
],
],
),
),
),
);
}
}
+15
View File
@@ -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<SettingsScreen> {
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'),
+41
View File
@@ -50,6 +50,8 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
PlexClientProvider? _cachedClientProvider;
bool _isPhone = false;
List<PlexMediaVersion> _availableVersions = [];
StreamSubscription<PlayerLog>? _logSubscription;
StreamSubscription<String>? _errorSubscription;
@override
void initState() {
@@ -105,6 +107,7 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
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<VideoPlayerScreen> {
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<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
}
}
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<void> _playNext() async {
if (_nextEpisode == null || _isLoadingNext) return;
+3
View File
@@ -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<void> setEnableDebugLogging(bool enabled) async {
await _prefs.setBool(_keyEnableDebugLogging, enabled);
// Update logger level immediately when setting changes
setLoggerLevel(enabled);
}
bool getEnableDebugLogging() {
+224 -1
View File
@@ -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<LogEntry> _logs = [];
static int _currentSize = 0;
/// Get all stored logs (newest first)
static List<LogEntry> 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<String> 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;
}