diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index ef337603..b0fc9dd3 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -49,6 +49,7 @@ class SettingsService extends BaseSharedPreferencesService { static const String _keyCustomDownloadPathType = 'custom_download_path_type'; static const String _keyDownloadOnWifiOnly = 'download_on_wifi_only'; static const String _keyVideoPlayerNavigationEnabled = 'video_player_navigation_enabled'; + static const String _keyShowPerformanceOverlay = 'show_performance_overlay'; SettingsService._(); @@ -431,6 +432,15 @@ class SettingsService extends BaseSharedPreferencesService { return prefs.getBool(_keyVideoPlayerNavigationEnabled) ?? TvDetectionService.isTVSync(); } + // Performance Overlay (show debug stats on video player) + Future setShowPerformanceOverlay(bool enabled) async { + await prefs.setBool(_keyShowPerformanceOverlay, enabled); + } + + bool getShowPerformanceOverlay() { + return prefs.getBool(_keyShowPerformanceOverlay) ?? false; // Default: disabled + } + // Helper methods for HotKey serialization static const _modifierMap = { 'alt': HotKeyModifier.alt, @@ -830,6 +840,7 @@ class SettingsService extends BaseSharedPreferencesService { prefs.remove(_keyCustomDownloadPathType), prefs.remove(_keyDownloadOnWifiOnly), prefs.remove(_keyVideoPlayerNavigationEnabled), + prefs.remove(_keyShowPerformanceOverlay), ]); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index ee07180e..4855db8a 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -111,6 +111,7 @@ class _VideoSettingsSheetState extends State { late int _audioSyncOffset; late int _subtitleSyncOffset; bool _enableHDR = true; + bool _showPerformanceOverlay = false; late final FocusNode _initialFocusNode; @override @@ -119,7 +120,7 @@ class _VideoSettingsSheetState extends State { _audioSyncOffset = widget.audioSyncOffset; _subtitleSyncOffset = widget.subtitleSyncOffset; _initialFocusNode = FocusNode(debugLabel: 'VideoSettingsInitialFocus'); - _loadHDRSetting(); + _loadSettings(); } @override @@ -128,10 +129,11 @@ class _VideoSettingsSheetState extends State { super.dispose(); } - Future _loadHDRSetting() async { + Future _loadSettings() async { final settings = await SettingsService.getInstance(); setState(() { _enableHDR = settings.getEnableHDR(); + _showPerformanceOverlay = settings.getShowPerformanceOverlay(); }); } @@ -146,6 +148,15 @@ class _VideoSettingsSheetState extends State { await widget.player.setProperty('hdr-enabled', newValue ? 'yes' : 'no'); } + Future _togglePerformanceOverlay() async { + final newValue = !_showPerformanceOverlay; + final settings = await SettingsService.getInstance(); + await settings.setShowPerformanceOverlay(newValue); + setState(() { + _showPerformanceOverlay = newValue; + }); + } + void _navigateTo(_SettingsView view) { setState(() { _currentView = view; @@ -289,6 +300,22 @@ class _VideoSettingsSheetState extends State { ); }, ), + + // Performance Overlay Toggle + ListTile( + leading: AppIcon( + Symbols.analytics_rounded, + fill: 1, + color: _showPerformanceOverlay ? Colors.amber : Colors.white70, + ), + title: const Text('Performance Overlay', style: TextStyle(color: Colors.white)), + trailing: Switch( + value: _showPerformanceOverlay, + onChanged: (_) => _togglePerformanceOverlay(), + activeThumbColor: Colors.amber, + ), + onTap: _togglePerformanceOverlay, + ), ], ); } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 8e9031a3..9353f52e 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -39,6 +39,7 @@ import '../../utils/app_logger.dart'; import '../../i18n/strings.g.dart'; import '../../focus/input_mode_tracker.dart'; import 'widgets/track_chapter_controls.dart'; +import 'widgets/performance_overlay/performance_overlay.dart'; import 'mobile_video_controls.dart'; import 'desktop_video_controls.dart'; @@ -166,6 +167,8 @@ class _PlexVideoControlsState extends State with WindowListen double _autoSkipProgress = 0.0; // Video player navigation (use arrow keys to navigate controls) bool _videoPlayerNavigationEnabled = false; + // Performance overlay + bool _showPerformanceOverlay = false; @override void initState() { @@ -355,6 +358,7 @@ class _PlexVideoControlsState extends State with WindowListen _autoSkipCredits = settingsService.getAutoSkipCredits(); _autoSkipDelay = settingsService.getAutoSkipDelay(); _videoPlayerNavigationEnabled = settingsService.getVideoPlayerNavigationEnabled(); + _showPerformanceOverlay = settingsService.getShowPerformanceOverlay(); }); // Apply rotation lock setting @@ -1295,6 +1299,15 @@ class _PlexVideoControlsState extends State with WindowListen bottom: isMobile ? 80 : 115, child: AnimatedOpacity(opacity: 1.0, duration: tokens(context).slow, child: _buildSkipMarkerButton()), ), + // Performance overlay (top-left) + if (_showPerformanceOverlay) + Positioned( + top: isMobile ? 60 : 16, + left: 16, + child: IgnorePointer( + child: PlayerPerformanceOverlay(player: widget.player), + ), + ), ], ), ), diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_card.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_card.dart new file mode 100644 index 00000000..67276747 --- /dev/null +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_card.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/widgets/app_icon.dart'; + +/// A single metric row for display in the performance card. +class PerformanceMetric { + final String label; + final String value; + + const PerformanceMetric({required this.label, required this.value}); +} + +/// A card widget displaying a group of performance metrics. +/// +/// Used in the performance overlay to show video, audio, performance, +/// and buffer statistics. +class PerformanceCard extends StatelessWidget { + final IconData icon; + final String title; + final List metrics; + + const PerformanceCard({ + super.key, + required this.icon, + required this.title, + required this.metrics, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Header row with icon and title + Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(icon, fill: 1, color: Colors.white70, size: 16), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 8), + // Metrics + ...metrics.map(_buildMetricRow), + ], + ), + ); + } + + Widget _buildMetricRow(PerformanceMetric metric) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${metric.label}: ', + style: const TextStyle( + color: Colors.white60, + fontSize: 11, + ), + ), + Text( + metric.value, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + fontFamily: 'monospace', + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart new file mode 100644 index 00000000..b867461a --- /dev/null +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../../mpv/mpv.dart'; +import '../../../../widgets/app_icon.dart'; +import 'performance_stats.dart'; +import 'performance_stats_service.dart'; + +/// A toggleable overlay displaying real-time video player performance statistics. +/// +/// Shows a single card with two columns of metrics organized by section. +/// Positioned in the top-left corner of the video player. +class PlayerPerformanceOverlay extends StatefulWidget { + final Player player; + + const PlayerPerformanceOverlay({ + super.key, + required this.player, + }); + + @override + State createState() => + _PlayerPerformanceOverlayState(); +} + +class _PlayerPerformanceOverlayState extends State { + late final PerformanceStatsService _statsService; + PerformanceStats _stats = const PerformanceStats.empty(); + + @override + void initState() { + super.initState(); + _statsService = PerformanceStatsService(widget.player); + _statsService.statsStream.listen((stats) { + if (mounted) { + setState(() { + _stats = stats; + }); + } + }); + _statsService.startPolling(); + } + + @override + void dispose() { + _statsService.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 380), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Left column + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildSection(Symbols.videocam_rounded, 'Video', [ + _metric('Codec', _stats.videoCodec ?? 'N/A'), + _metric('Resolution', _stats.resolution), + _metric('FPS', _stats.videoFpsFormatted), + _metric('Bitrate', _stats.videoBitrateFormatted), + _metric('Decoder', _stats.hwdecFormatted), + if (_stats.aspectName != null && + _stats.aspectName!.isNotEmpty) + _metric('Aspect', _stats.aspectName!), + if (_stats.rotate != null && _stats.rotate != 0) + _metric('Rotation', _stats.rotateFormatted), + ]), + const SizedBox(height: 12), + _buildSection(Symbols.palette_rounded, 'Color', [ + _metric('Pixel Fmt', _stats.pixelformat ?? 'N/A'), + if (_stats.hwPixelformat != null && + _stats.hwPixelformat != _stats.pixelformat) + _metric('HW Fmt', _stats.hwPixelformat!), + _metric('Matrix', _stats.colormatrix ?? 'N/A'), + _metric('Primaries', _stats.primaries ?? 'N/A'), + _metric('Transfer', _stats.gamma ?? 'N/A'), + ]), + if (_stats.hasHdrMetadata) ...[ + const SizedBox(height: 12), + _buildSection(Symbols.hdr_on_rounded, 'HDR', [ + if (_stats.maxLuma != null) + _metric('Max Luma', _stats.maxLumaFormatted), + if (_stats.minLuma != null) + _metric('Min Luma', _stats.minLumaFormatted), + if (_stats.maxCll != null) + _metric('MaxCLL', _stats.maxCllFormatted), + if (_stats.maxFall != null) + _metric('MaxFALL', _stats.maxFallFormatted), + ]), + ], + ], + ), + ), + const SizedBox(width: 24), + // Right column + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildSection(Symbols.volume_up_rounded, 'Audio', [ + _metric('Codec', _stats.audioCodec ?? 'N/A'), + _metric('Sample Rate', _stats.sampleRateFormatted), + _metric('Channels', _stats.audioChannels ?? 'N/A'), + _metric('Bitrate', _stats.audioBitrateFormatted), + ]), + const SizedBox(height: 12), + _buildSection(Symbols.speed_rounded, 'Performance', [ + _metric('Render FPS', _stats.actualFpsFormatted), + _metric('Display FPS', _stats.displayFpsFormatted), + _metric('A/V Sync', _stats.avsyncFormatted), + _metric('Dropped', _stats.droppedFramesFormatted), + ]), + const SizedBox(height: 12), + _buildSection(Symbols.memory_rounded, 'Buffer', [ + _metric('Duration', _stats.cacheDurationFormatted), + _metric('Cache Used', _stats.cacheUsedFormatted), + _metric('Speed', _stats.cacheSpeedFormatted), + ]), + const SizedBox(height: 12), + _buildSection(Symbols.apps_rounded, 'App', [ + _metric('Memory', _stats.appMemoryFormatted), + _metric('UI FPS', _stats.uiFpsFormatted), + ]), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSection(IconData icon, String title, List<_Metric> metrics) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(icon, fill: 1, color: Colors.white70, size: 12), + const SizedBox(width: 4), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 4), + ...metrics.map(_buildMetricRow), + ], + ); + } + + Widget _buildMetricRow(_Metric metric) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${metric.label}: ', + style: const TextStyle( + color: Colors.white60, + fontSize: 10, + ), + ), + Flexible( + child: Text( + metric.value, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + _Metric _metric(String label, String value) => _Metric(label, value); +} + +class _Metric { + final String label; + final String value; + const _Metric(this.label, this.value); +} diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart new file mode 100644 index 00000000..959643b8 --- /dev/null +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart @@ -0,0 +1,252 @@ +/// Data model for video player performance statistics. +/// +/// Contains metrics queried from libmpv including video/audio codec info, +/// playback performance, and buffer state. +class PerformanceStats { + // Video metrics + final String? videoCodec; + final int? videoWidth; + final int? videoHeight; + final double? videoFps; + final String? hwdecCurrent; + final int? videoBitrate; + final String? aspectName; + final int? rotate; + + // Color/Format metrics + final String? pixelformat; + final String? hwPixelformat; + final String? colormatrix; + final String? primaries; + final String? gamma; + + // HDR metadata + final double? maxLuma; + final double? minLuma; + final double? maxCll; + final double? maxFall; + + // Audio metrics + final String? audioCodec; + final int? audioSamplerate; + final String? audioChannels; + final int? audioBitrate; + + // Performance metrics + final double? actualFps; + final double? avsyncChange; + final double? displayFps; + final int? frameDropCount; + final int? decoderFrameDropCount; + + // Buffer metrics + final int? cacheUsed; + final double? cacheSpeed; + final double? cacheDuration; + + // App metrics + final int? appMemoryBytes; + final double? uiFps; + + const PerformanceStats({ + this.videoCodec, + this.videoWidth, + this.videoHeight, + this.videoFps, + this.hwdecCurrent, + this.videoBitrate, + this.aspectName, + this.rotate, + this.pixelformat, + this.hwPixelformat, + this.colormatrix, + this.primaries, + this.gamma, + this.maxLuma, + this.minLuma, + this.maxCll, + this.maxFall, + this.audioCodec, + this.audioSamplerate, + this.audioChannels, + this.audioBitrate, + this.actualFps, + this.avsyncChange, + this.displayFps, + this.frameDropCount, + this.decoderFrameDropCount, + this.cacheUsed, + this.cacheSpeed, + this.cacheDuration, + this.appMemoryBytes, + this.uiFps, + }); + + /// Creates an empty stats object (used as initial state). + const PerformanceStats.empty() + : videoCodec = null, + videoWidth = null, + videoHeight = null, + videoFps = null, + hwdecCurrent = null, + videoBitrate = null, + aspectName = null, + rotate = null, + pixelformat = null, + hwPixelformat = null, + colormatrix = null, + primaries = null, + gamma = null, + maxLuma = null, + minLuma = null, + maxCll = null, + maxFall = null, + audioCodec = null, + audioSamplerate = null, + audioChannels = null, + audioBitrate = null, + actualFps = null, + avsyncChange = null, + displayFps = null, + frameDropCount = null, + decoderFrameDropCount = null, + cacheUsed = null, + cacheSpeed = null, + cacheDuration = null, + appMemoryBytes = null, + uiFps = null; + + /// Format video resolution as "WxH". + String get resolution { + if (videoWidth == null || videoHeight == null) return 'N/A'; + return '${videoWidth}x$videoHeight'; + } + + /// Format video bitrate in Mbps. + String get videoBitrateFormatted { + if (videoBitrate == null || videoBitrate == 0) return 'N/A'; + final mbps = videoBitrate! / 1000000; + return '${mbps.toStringAsFixed(1)} Mbps'; + } + + /// Format audio bitrate in kbps. + String get audioBitrateFormatted { + if (audioBitrate == null || audioBitrate == 0) return 'N/A'; + final kbps = audioBitrate! / 1000; + return '${kbps.toStringAsFixed(0)} kbps'; + } + + /// Format audio sample rate in kHz. + String get sampleRateFormatted { + if (audioSamplerate == null) return 'N/A'; + final khz = audioSamplerate! / 1000; + return '${khz.toStringAsFixed(1)} kHz'; + } + + /// Format FPS with 2 decimal places. + String get actualFpsFormatted { + if (actualFps == null) return 'N/A'; + return actualFps!.toStringAsFixed(2); + } + + /// Format source FPS with 2 decimal places. + String get videoFpsFormatted { + if (videoFps == null) return 'N/A'; + return videoFps!.toStringAsFixed(2); + } + + /// Format A/V sync in milliseconds. + String get avsyncFormatted { + if (avsyncChange == null) return 'N/A'; + final ms = (avsyncChange! * 1000).round(); + return '${ms > 0 ? '+' : ''}${ms}ms'; + } + + /// Format cache used in MB. + String get cacheUsedFormatted { + if (cacheUsed == null) return 'N/A'; + final mb = cacheUsed! / (1024 * 1024); + return '${mb.toStringAsFixed(1)} MB'; + } + + /// Format cache speed in MB/s. + String get cacheSpeedFormatted { + if (cacheSpeed == null) return 'N/A'; + final mbps = cacheSpeed! / (1024 * 1024); + return '${mbps.toStringAsFixed(1)} MB/s'; + } + + /// Format cache duration in seconds. + String get cacheDurationFormatted { + if (cacheDuration == null) return 'N/A'; + return '${cacheDuration!.toStringAsFixed(1)}s'; + } + + /// Format display FPS. + String get displayFpsFormatted { + if (displayFps == null) return 'N/A'; + return displayFps!.toStringAsFixed(0); + } + + /// Format dropped frames count. + String get droppedFramesFormatted { + final total = (frameDropCount ?? 0) + (decoderFrameDropCount ?? 0); + return total.toString(); + } + + /// Format hardware decoding mode. + String get hwdecFormatted { + if (hwdecCurrent == null || hwdecCurrent!.isEmpty || hwdecCurrent == 'no') { + return 'Software'; + } + return hwdecCurrent!; + } + + /// Format app memory usage in MB. + String get appMemoryFormatted { + if (appMemoryBytes == null) return 'N/A'; + final mb = appMemoryBytes! / (1024 * 1024); + return '${mb.toStringAsFixed(1)} MB'; + } + + /// Format UI FPS with 1 decimal place. + String get uiFpsFormatted { + if (uiFps == null) return 'N/A'; + return uiFps!.toStringAsFixed(1); + } + + /// Format rotation in degrees. + String get rotateFormatted { + if (rotate == null || rotate == 0) return 'N/A'; + return '$rotate°'; + } + + /// Format luminance value in cd/m². + String get maxLumaFormatted { + if (maxLuma == null) return 'N/A'; + return '${maxLuma!.toStringAsFixed(0)} cd/m²'; + } + + /// Format minimum luminance value in cd/m². + String get minLumaFormatted { + if (minLuma == null) return 'N/A'; + return '${minLuma!.toStringAsFixed(4)} cd/m²'; + } + + /// Format MaxCLL value in cd/m². + String get maxCllFormatted { + if (maxCll == null) return 'N/A'; + return '${maxCll!.toStringAsFixed(0)} cd/m²'; + } + + /// Format MaxFALL value in cd/m². + String get maxFallFormatted { + if (maxFall == null) return 'N/A'; + return '${maxFall!.toStringAsFixed(0)} cd/m²'; + } + + /// Check if HDR metadata is available. + bool get hasHdrMetadata { + return maxLuma != null || maxCll != null; + } +} diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart new file mode 100644 index 00000000..07e0ff35 --- /dev/null +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -0,0 +1,201 @@ +import 'dart:async'; +import 'dart:io' show ProcessInfo; + +import 'package:flutter/scheduler.dart'; + +import '../../../../mpv/mpv.dart'; +import '../../../../utils/app_logger.dart'; +import 'performance_stats.dart'; + +/// Service that polls MPV properties and provides performance stats via a stream. +/// +/// Usage: +/// ```dart +/// final service = PerformanceStatsService(player); +/// service.startPolling(); +/// service.statsStream.listen((stats) => print(stats.resolution)); +/// service.stopPolling(); +/// service.dispose(); +/// ``` +class PerformanceStatsService { + final Player player; + Timer? _pollingTimer; + final _statsController = StreamController.broadcast(); + + /// The interval between stats updates. + static const pollInterval = Duration(milliseconds: 500); + + // FPS tracking + int _frameCount = 0; + DateTime _lastFpsUpdate = DateTime.now(); + double? _currentUiFps; + + PerformanceStatsService(this.player); + + /// Stream of performance stats updates. + Stream get statsStream => _statsController.stream; + + /// Start polling for stats at regular intervals. + void startPolling() { + _pollingTimer?.cancel(); + // Start FPS tracking + _startFpsTracking(); + // Fetch immediately, then poll + _fetchStats(); + _pollingTimer = Timer.periodic(pollInterval, (_) => _fetchStats()); + } + + /// Start tracking UI frame rate. + void _startFpsTracking() { + _frameCount = 0; + _lastFpsUpdate = DateTime.now(); + SchedulerBinding.instance.addPersistentFrameCallback(_onFrame); + } + + /// Called every frame to count FPS. + void _onFrame(Duration timestamp) { + _frameCount++; + final now = DateTime.now(); + final elapsed = now.difference(_lastFpsUpdate); + if (elapsed.inMilliseconds >= 1000) { + _currentUiFps = _frameCount * 1000 / elapsed.inMilliseconds; + _frameCount = 0; + _lastFpsUpdate = now; + } + } + + /// Stop polling for stats. + void stopPolling() { + _pollingTimer?.cancel(); + _pollingTimer = null; + } + + /// Fetch all performance stats from MPV. + Future _fetchStats() async { + try { + // Fetch all properties in parallel for efficiency + final results = await Future.wait([ + player.getProperty('video-codec'), // 0 + player.getProperty('video-params/w'), // 1 + player.getProperty('video-params/h'), // 2 + player.getProperty('container-fps'), // 3 + player.getProperty('estimated-vf-fps'), // 4 + player.getProperty('video-bitrate'), // 5 + player.getProperty('hwdec-current'), // 6 + player.getProperty('audio-codec-name'), // 7 + player.getProperty('audio-params/samplerate'), // 8 + player.getProperty('audio-params/hr-channels'), // 9 + player.getProperty('audio-bitrate'), // 10 + player.getProperty('total-avsync-change'), // 11 + player.getProperty('cache-used'), // 12 + player.getProperty('cache-speed'), // 13 + player.getProperty('display-fps'), // 14 + player.getProperty('frame-drop-count'), // 15 + player.getProperty('decoder-frame-drop-count'), // 16 + player.getProperty('demuxer-cache-duration'), // 17 + // Color/Format properties + player.getProperty('video-params/pixelformat'), // 18 + player.getProperty('video-params/hw-pixelformat'), // 19 + player.getProperty('video-params/colormatrix'), // 20 + player.getProperty('video-params/primaries'), // 21 + player.getProperty('video-params/gamma'), // 22 + // HDR metadata + player.getProperty('video-params/max-luma'), // 23 + player.getProperty('video-params/min-luma'), // 24 + player.getProperty('video-params/max-cll'), // 25 + player.getProperty('video-params/max-fall'), // 26 + // Other + player.getProperty('video-params/aspect-name'), // 27 + player.getProperty('video-params/rotate'), // 28 + ]); + + // Get app memory usage + int? appMemory; + try { + appMemory = ProcessInfo.currentRss; + } catch (_) { + // ProcessInfo not available on all platforms + } + + final stats = PerformanceStats( + videoCodec: _formatCodecName(results[0]), + videoWidth: _parseInt(results[1]), + videoHeight: _parseInt(results[2]), + videoFps: _parseDouble(results[3]), + actualFps: _parseDouble(results[4]), + videoBitrate: _parseInt(results[5]), + hwdecCurrent: results[6], + audioCodec: _formatCodecName(results[7]), + audioSamplerate: _parseInt(results[8]), + audioChannels: results[9], + audioBitrate: _parseInt(results[10]), + avsyncChange: _parseDouble(results[11]), + cacheUsed: _parseInt(results[12]), + cacheSpeed: _parseDouble(results[13]), + displayFps: _parseDouble(results[14]), + frameDropCount: _parseInt(results[15]), + decoderFrameDropCount: _parseInt(results[16]), + cacheDuration: _parseDouble(results[17]), + // Color/Format properties + pixelformat: results[18], + hwPixelformat: results[19], + colormatrix: results[20], + primaries: results[21], + gamma: results[22], + // HDR metadata + maxLuma: _parseDouble(results[23]), + minLuma: _parseDouble(results[24]), + maxCll: _parseDouble(results[25]), + maxFall: _parseDouble(results[26]), + // Other + aspectName: results[27], + rotate: _parseInt(results[28]), + appMemoryBytes: appMemory, + uiFps: _currentUiFps, + ); + + _statsController.add(stats); + } catch (e) { + appLogger.w('Failed to fetch performance stats', error: e); + } + } + + /// Parse a string to int, returning null if parsing fails. + int? _parseInt(String? value) { + if (value == null || value.isEmpty) return null; + return int.tryParse(value); + } + + /// Parse a string to double, returning null if parsing fails. + double? _parseDouble(String? value) { + if (value == null || value.isEmpty) return null; + return double.tryParse(value); + } + + /// Format codec name for display (uppercase common codecs). + String? _formatCodecName(String? codec) { + if (codec == null || codec.isEmpty) return null; + // Common codec name mappings + final upper = codec.toUpperCase(); + if (upper.contains('HEVC') || upper.contains('H265')) return 'HEVC'; + if (upper.contains('H264') || upper.contains('AVC')) return 'H.264'; + if (upper.contains('AV1')) return 'AV1'; + if (upper.contains('VP9')) return 'VP9'; + if (upper.contains('AAC')) return 'AAC'; + if (upper.contains('AC3') || upper.contains('AC-3')) return 'AC3'; + if (upper.contains('EAC3') || upper.contains('E-AC-3')) return 'EAC3'; + if (upper.contains('DTS')) return 'DTS'; + if (upper.contains('TRUEHD')) return 'TrueHD'; + if (upper.contains('FLAC')) return 'FLAC'; + if (upper.contains('OPUS')) return 'Opus'; + if (upper.contains('VORBIS')) return 'Vorbis'; + if (upper.contains('MP3')) return 'MP3'; + return codec; + } + + /// Dispose of the service and release resources. + void dispose() { + stopPolling(); + _statsController.close(); + } +}