feat: performance overlay

This commit is contained in:
edde746
2025-12-19 13:06:58 +01:00
parent c6727baf07
commit 8db30c1568
7 changed files with 817 additions and 2 deletions
+11
View File
@@ -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<void> 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 = <String, HotKeyModifier>{
'alt': HotKeyModifier.alt,
@@ -830,6 +840,7 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_keyCustomDownloadPathType),
prefs.remove(_keyDownloadOnWifiOnly),
prefs.remove(_keyVideoPlayerNavigationEnabled),
prefs.remove(_keyShowPerformanceOverlay),
]);
}
@@ -111,6 +111,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
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<VideoSettingsSheet> {
_audioSyncOffset = widget.audioSyncOffset;
_subtitleSyncOffset = widget.subtitleSyncOffset;
_initialFocusNode = FocusNode(debugLabel: 'VideoSettingsInitialFocus');
_loadHDRSetting();
_loadSettings();
}
@override
@@ -128,10 +129,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
super.dispose();
}
Future<void> _loadHDRSetting() async {
Future<void> _loadSettings() async {
final settings = await SettingsService.getInstance();
setState(() {
_enableHDR = settings.getEnableHDR();
_showPerformanceOverlay = settings.getShowPerformanceOverlay();
});
}
@@ -146,6 +148,15 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
await widget.player.setProperty('hdr-enabled', newValue ? 'yes' : 'no');
}
Future<void> _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<VideoSettingsSheet> {
);
},
),
// 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,
),
],
);
}
@@ -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<PlexVideoControls> 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<PlexVideoControls> 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<PlexVideoControls> 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),
),
),
],
),
),
@@ -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<PerformanceMetric> 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',
),
),
],
),
);
}
}
@@ -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<PlayerPerformanceOverlay> createState() =>
_PlayerPerformanceOverlayState();
}
class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
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);
}
@@ -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;
}
}
@@ -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<PerformanceStats>.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<PerformanceStats> 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<void> _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();
}
}