feat: performance overlay
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+201
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user