feat: custom mpv config

This commit is contained in:
edde746
2025-12-20 15:43:14 +01:00
parent c018cfef03
commit 1bce10a6a4
15 changed files with 13748 additions and 6758 deletions
+13023 -6685
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -39,6 +39,7 @@
"licenses": "Licenses",
"switchProfile": "Switch Profile",
"subtitleStyling": "Subtitle Styling",
"mpvConfig": "MPV Configuration",
"search": "Search",
"logs": "Logs"
},
@@ -296,6 +297,32 @@
"backgroundOpacity": "Background Opacity",
"backgroundColor": "Background Color"
},
"mpvConfig": {
"title": "MPV Configuration",
"description": "Advanced video player settings",
"properties": "Properties",
"presets": "Presets",
"noProperties": "No properties configured",
"noPresets": "No saved presets",
"addProperty": "Add Property",
"editProperty": "Edit Property",
"deleteProperty": "Delete Property",
"propertyKey": "Property Key",
"propertyKeyHint": "e.g., hwdec, demuxer-max-bytes",
"propertyValue": "Property Value",
"propertyValueHint": "e.g., auto, 256000000",
"saveAsPreset": "Save as Preset...",
"presetName": "Preset Name",
"presetNameHint": "Enter a name for this preset",
"loadPreset": "Load",
"deletePreset": "Delete",
"presetSaved": "Preset saved",
"presetLoaded": "Preset loaded",
"presetDeleted": "Preset deleted",
"confirmDeletePreset": "Are you sure you want to delete this preset?",
"confirmDeleteProperty": "Are you sure you want to delete this property?",
"entriesCount": "${count} entries"
},
"dialog": {
"confirmAction": "Confirm Action",
"cancel": "Cancel",
+27
View File
@@ -40,6 +40,7 @@
"selectServer": "Server auswählen",
"switchProfile": "Profil wechseln",
"subtitleStyling": "Untertitel-Stil",
"mpvConfig": "MPV-Konfiguration",
"search": "Suche",
"logs": "Protokolle"
},
@@ -297,6 +298,32 @@
"backgroundOpacity": "Hintergrunddeckkraft",
"backgroundColor": "Hintergrundfarbe"
},
"mpvConfig": {
"title": "MPV-Konfiguration",
"description": "Erweiterte Videoplayer-Einstellungen",
"properties": "Eigenschaften",
"presets": "Voreinstellungen",
"noProperties": "Keine Eigenschaften konfiguriert",
"noPresets": "Keine gespeicherten Voreinstellungen",
"addProperty": "Eigenschaft hinzufügen",
"editProperty": "Eigenschaft bearbeiten",
"deleteProperty": "Eigenschaft löschen",
"propertyKey": "Eigenschaftsschlüssel",
"propertyKeyHint": "z.B. hwdec, demuxer-max-bytes",
"propertyValue": "Eigenschaftswert",
"propertyValueHint": "z.B. auto, 256000000",
"saveAsPreset": "Als Voreinstellung speichern...",
"presetName": "Name der Voreinstellung",
"presetNameHint": "Namen für diese Voreinstellung eingeben",
"loadPreset": "Laden",
"deletePreset": "Löschen",
"presetSaved": "Voreinstellung gespeichert",
"presetLoaded": "Voreinstellung geladen",
"presetDeleted": "Voreinstellung gelöscht",
"confirmDeletePreset": "Möchten Sie diese Voreinstellung wirklich löschen?",
"confirmDeleteProperty": "Möchten Sie diese Eigenschaft wirklich löschen?",
"entriesCount": "${count} Einträge"
},
"dialog": {
"confirmAction": "Aktion bestätigen",
"cancel": "Abbrechen",
+27
View File
@@ -40,6 +40,7 @@
"selectServer": "Seleziona server",
"switchProfile": "Cambia profilo",
"subtitleStyling": "Stile sottotitoli",
"mpvConfig": "Configurazione MPV",
"search": "Cerca",
"logs": "Registro"
},
@@ -297,6 +298,32 @@
"backgroundOpacity": "Opacità sfondo",
"backgroundColor": "Colore sfondo"
},
"mpvConfig": {
"title": "Configurazione MPV",
"description": "Impostazioni avanzate del lettore video",
"properties": "Proprietà",
"presets": "Preset",
"noProperties": "Nessuna proprietà configurata",
"noPresets": "Nessun preset salvato",
"addProperty": "Aggiungi proprietà",
"editProperty": "Modifica proprietà",
"deleteProperty": "Elimina proprietà",
"propertyKey": "Chiave proprietà",
"propertyKeyHint": "es. hwdec, demuxer-max-bytes",
"propertyValue": "Valore proprietà",
"propertyValueHint": "es. auto, 256000000",
"saveAsPreset": "Salva come preset...",
"presetName": "Nome preset",
"presetNameHint": "Inserisci un nome per questo preset",
"loadPreset": "Carica",
"deletePreset": "Elimina",
"presetSaved": "Preset salvato",
"presetLoaded": "Preset caricato",
"presetDeleted": "Preset eliminato",
"confirmDeletePreset": "Sei sicuro di voler eliminare questo preset?",
"confirmDeleteProperty": "Sei sicuro di voler eliminare questa proprietà?",
"entriesCount": "${count} voci"
},
"dialog": {
"confirmAction": "Conferma azione",
"cancel": "Cancella",
+27
View File
@@ -40,6 +40,7 @@
"selectServer": "Selecteer server",
"switchProfile": "Wissel van profiel",
"subtitleStyling": "Ondertitel opmaak",
"mpvConfig": "MPV-configuratie",
"search": "Zoeken",
"logs": "Logbestanden"
},
@@ -297,6 +298,32 @@
"backgroundOpacity": "Achtergrond transparantie",
"backgroundColor": "Achtergrondkleur"
},
"mpvConfig": {
"title": "MPV-configuratie",
"description": "Geavanceerde videospeler-instellingen",
"properties": "Eigenschappen",
"presets": "Voorinstellingen",
"noProperties": "Geen eigenschappen geconfigureerd",
"noPresets": "Geen opgeslagen voorinstellingen",
"addProperty": "Eigenschap toevoegen",
"editProperty": "Eigenschap bewerken",
"deleteProperty": "Eigenschap verwijderen",
"propertyKey": "Eigenschapssleutel",
"propertyKeyHint": "bijv. hwdec, demuxer-max-bytes",
"propertyValue": "Eigenschapswaarde",
"propertyValueHint": "bijv. auto, 256000000",
"saveAsPreset": "Opslaan als voorinstelling...",
"presetName": "Naam voorinstelling",
"presetNameHint": "Voer een naam in voor deze voorinstelling",
"loadPreset": "Laden",
"deletePreset": "Verwijderen",
"presetSaved": "Voorinstelling opgeslagen",
"presetLoaded": "Voorinstelling geladen",
"presetDeleted": "Voorinstelling verwijderd",
"confirmDeletePreset": "Weet je zeker dat je deze voorinstelling wilt verwijderen?",
"confirmDeleteProperty": "Weet je zeker dat je deze eigenschap wilt verwijderen?",
"entriesCount": "${count} items"
},
"dialog": {
"confirmAction": "Bevestig actie",
"cancel": "Annuleren",
+27
View File
@@ -40,6 +40,7 @@
"selectServer": "Välj server",
"switchProfile": "Byt profil",
"subtitleStyling": "Undertext-styling",
"mpvConfig": "MPV-konfiguration",
"search": "Sök",
"logs": "Loggar"
},
@@ -297,6 +298,32 @@
"backgroundOpacity": "Bakgrundsopacitet",
"backgroundColor": "Bakgrundsfärg"
},
"mpvConfig": {
"title": "MPV-konfiguration",
"description": "Avancerade videospelares inställningar",
"properties": "Egenskaper",
"presets": "Förval",
"noProperties": "Inga egenskaper konfigurerade",
"noPresets": "Inga sparade förval",
"addProperty": "Lägg till egenskap",
"editProperty": "Redigera egenskap",
"deleteProperty": "Ta bort egenskap",
"propertyKey": "Egenskapsnyckel",
"propertyKeyHint": "t.ex. hwdec, demuxer-max-bytes",
"propertyValue": "Egenskapsvärde",
"propertyValueHint": "t.ex. auto, 256000000",
"saveAsPreset": "Spara som förval...",
"presetName": "Förvalnamn",
"presetNameHint": "Ange ett namn för detta förval",
"loadPreset": "Ladda",
"deletePreset": "Ta bort",
"presetSaved": "Förval sparat",
"presetLoaded": "Förval laddat",
"presetDeleted": "Förval borttaget",
"confirmDeletePreset": "Är du säker på att du vill ta bort detta förval?",
"confirmDeleteProperty": "Är du säker på att du vill ta bort denna egenskap?",
"entriesCount": "${count} poster"
},
"dialog": {
"confirmAction": "Bekräfta åtgärd",
"cancel": "Avbryt",
+27
View File
@@ -40,6 +40,7 @@
"selectServer": "选择服务器",
"switchProfile": "切换用户",
"subtitleStyling": "字幕样式",
"mpvConfig": "MPV 配置",
"search": "搜索",
"logs": "日志"
},
@@ -297,6 +298,32 @@
"backgroundOpacity": "背景不透明度",
"backgroundColor": "背景颜色"
},
"mpvConfig": {
"title": "MPV 配置",
"description": "高级视频播放器设置",
"properties": "属性",
"presets": "预设",
"noProperties": "未配置任何属性",
"noPresets": "没有保存的预设",
"addProperty": "添加属性",
"editProperty": "编辑属性",
"deleteProperty": "删除属性",
"propertyKey": "属性键",
"propertyKeyHint": "例如 hwdec, demuxer-max-bytes",
"propertyValue": "属性值",
"propertyValueHint": "例如 auto, 256000000",
"saveAsPreset": "保存为预设...",
"presetName": "预设名称",
"presetNameHint": "输入此预设的名称",
"loadPreset": "加载",
"deletePreset": "删除",
"presetSaved": "预设已保存",
"presetLoaded": "预设已加载",
"presetDeleted": "预设已删除",
"confirmDeletePreset": "确定要删除此预设吗?",
"confirmDeleteProperty": "确定要删除此属性吗?",
"entriesCount": "${count} 条目"
},
"dialog": {
"confirmAction": "确认操作",
"cancel": "取消",
+45
View File
@@ -0,0 +1,45 @@
/// Represents a single MPV configuration entry
class MpvConfigEntry {
final String key;
final String value;
final bool isEnabled;
const MpvConfigEntry({required this.key, required this.value, this.isEnabled = true});
factory MpvConfigEntry.fromJson(Map<String, dynamic> json) {
return MpvConfigEntry(
key: json['key'] as String,
value: json['value'] as String,
isEnabled: json['isEnabled'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() => {'key': key, 'value': value, 'isEnabled': isEnabled};
MpvConfigEntry copyWith({String? key, String? value, bool? isEnabled}) {
return MpvConfigEntry(key: key ?? this.key, value: value ?? this.value, isEnabled: isEnabled ?? this.isEnabled);
}
}
/// Represents a saved preset of MPV configurations
class MpvPreset {
final String name;
final List<MpvConfigEntry> entries;
final DateTime createdAt;
const MpvPreset({required this.name, required this.entries, required this.createdAt});
factory MpvPreset.fromJson(Map<String, dynamic> json) {
return MpvPreset(
name: json['name'] as String,
entries: (json['entries'] as List).map((e) => MpvConfigEntry.fromJson(e as Map<String, dynamic>)).toList(),
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
Map<String, dynamic> toJson() => {
'name': name,
'entries': entries.map((e) => e.toJson()).toList(),
'createdAt': createdAt.toIso8601String(),
};
}
+376
View File
@@ -0,0 +1,376 @@
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../i18n/strings.g.dart';
import '../../models/mpv_config_models.dart';
import '../../services/settings_service.dart';
import '../../widgets/desktop_app_bar.dart';
class MpvConfigScreen extends StatefulWidget {
const MpvConfigScreen({super.key});
@override
State<MpvConfigScreen> createState() => _MpvConfigScreenState();
}
class _MpvConfigScreenState extends State<MpvConfigScreen> {
late SettingsService _settingsService;
bool _isLoading = true;
List<MpvConfigEntry> _entries = [];
List<MpvPreset> _presets = [];
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
_settingsService = await SettingsService.getInstance();
setState(() {
_entries = _settingsService.getMpvConfigEntries();
_presets = _settingsService.getMpvPresets();
_isLoading = false;
});
}
Future<void> _saveEntries() async {
await _settingsService.setMpvConfigEntries(_entries);
}
void _toggleEntry(int index) {
setState(() {
_entries[index] = _entries[index].copyWith(isEnabled: !_entries[index].isEnabled);
});
_saveEntries();
}
void _deleteEntry(int index) {
setState(() {
_entries.removeAt(index);
});
_saveEntries();
}
void _addEntry(MpvConfigEntry entry) {
setState(() {
_entries.add(entry);
});
_saveEntries();
}
void _updateEntry(int index, MpvConfigEntry entry) {
setState(() {
_entries[index] = entry;
});
_saveEntries();
}
Future<void> _showEntryDialog({int? editIndex}) async {
final isEdit = editIndex != null;
final existingEntry = isEdit ? _entries[editIndex] : null;
final keyController = TextEditingController(text: existingEntry?.key ?? '');
final valueController = TextEditingController(text: existingEntry?.value ?? '');
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(isEdit ? t.mpvConfig.editProperty : t.mpvConfig.addProperty),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: keyController,
decoration: InputDecoration(labelText: t.mpvConfig.propertyKey, hintText: t.mpvConfig.propertyKeyHint),
autofocus: true,
),
const SizedBox(height: 16),
TextField(
controller: valueController,
decoration: InputDecoration(
labelText: t.mpvConfig.propertyValue,
hintText: t.mpvConfig.propertyValueHint,
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
TextButton(
onPressed: () {
if (keyController.text.isNotEmpty && valueController.text.isNotEmpty) {
Navigator.pop(context, true);
}
},
child: Text(t.common.save),
),
],
),
);
if (result == true) {
final entry = MpvConfigEntry(
key: keyController.text.trim(),
value: valueController.text.trim(),
isEnabled: existingEntry?.isEnabled ?? true,
);
if (isEdit) {
_updateEntry(editIndex, entry);
} else {
_addEntry(entry);
}
}
keyController.dispose();
valueController.dispose();
}
Future<void> _showDeleteEntryDialog(int index) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.mpvConfig.deleteProperty),
content: Text(t.mpvConfig.confirmDeleteProperty),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.delete)),
],
),
);
if (result == true) {
_deleteEntry(index);
}
}
Future<void> _showSavePresetDialog() async {
if (_entries.isEmpty) return;
final nameController = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.mpvConfig.saveAsPreset),
content: TextField(
controller: nameController,
decoration: InputDecoration(labelText: t.mpvConfig.presetName, hintText: t.mpvConfig.presetNameHint),
autofocus: true,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
TextButton(
onPressed: () {
if (nameController.text.isNotEmpty) {
Navigator.pop(context, true);
}
},
child: Text(t.common.save),
),
],
),
);
if (result == true) {
await _settingsService.saveMpvPreset(nameController.text.trim(), _entries);
setState(() {
_presets = _settingsService.getMpvPresets();
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.mpvConfig.presetSaved)));
}
}
nameController.dispose();
}
Future<void> _loadPreset(MpvPreset preset) async {
await _settingsService.loadMpvPreset(preset.name);
setState(() {
_entries = _settingsService.getMpvConfigEntries();
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.mpvConfig.presetLoaded)));
}
}
Future<void> _deletePreset(MpvPreset preset) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.mpvConfig.deletePreset),
content: Text(t.mpvConfig.confirmDeletePreset),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)),
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.delete)),
],
),
);
if (result == true) {
await _settingsService.deleteMpvPreset(preset.name);
setState(() {
_presets = _settingsService.getMpvPresets();
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.mpvConfig.presetDeleted)));
}
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
body: CustomScrollView(
slivers: [
CustomAppBar(title: Text(t.screens.mpvConfig), pinned: true),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildListDelegate([
_buildPresetsCard(),
const SizedBox(height: 16),
_buildEntriesCard(),
const SizedBox(height: 24),
]),
),
),
],
),
);
}
Widget _buildPresetsCard() {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
t.mpvConfig.presets,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
ListTile(
leading: const AppIcon(Symbols.save_rounded, fill: 1),
title: Text(t.mpvConfig.saveAsPreset),
enabled: _entries.isNotEmpty,
onTap: _entries.isNotEmpty ? _showSavePresetDialog : null,
),
if (_presets.isNotEmpty) ...[
const Divider(),
..._presets.map(
(preset) => ListTile(
leading: const AppIcon(Symbols.folder_rounded, fill: 1),
title: Text(preset.name),
subtitle: Text(t.mpvConfig.entriesCount(count: preset.entries.length)),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'load') {
_loadPreset(preset);
} else if (value == 'delete') {
_deletePreset(preset);
}
},
itemBuilder: (context) => [
PopupMenuItem(value: 'load', child: Text(t.mpvConfig.loadPreset)),
PopupMenuItem(value: 'delete', child: Text(t.mpvConfig.deletePreset)),
],
),
onTap: () => _loadPreset(preset),
),
),
] else
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(
t.mpvConfig.noPresets,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
],
),
);
}
Widget _buildEntriesCard() {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Text(
t.mpvConfig.properties,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const AppIcon(Symbols.add_rounded, fill: 1),
onPressed: () => _showEntryDialog(),
tooltip: t.mpvConfig.addProperty,
),
],
),
),
if (_entries.isNotEmpty) ...[
const Divider(height: 1),
...List.generate(_entries.length, (index) {
final entry = _entries[index];
return ListTile(
leading: Switch(value: entry.isEnabled, onChanged: (_) => _toggleEntry(index)),
title: Text(
entry.key,
style: TextStyle(color: entry.isEnabled ? null : Theme.of(context).colorScheme.onSurfaceVariant),
),
subtitle: Text(
entry.value,
style: TextStyle(color: entry.isEnabled ? null : Theme.of(context).colorScheme.onSurfaceVariant),
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
_showEntryDialog(editIndex: index);
} else if (value == 'delete') {
_showDeleteEntryDialog(index);
}
},
itemBuilder: (context) => [
PopupMenuItem(value: 'edit', child: Text(t.mpvConfig.editProperty)),
PopupMenuItem(value: 'delete', child: Text(t.mpvConfig.deleteProperty)),
],
),
);
}),
] else
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Text(
t.mpvConfig.noProperties,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
),
],
),
);
}
}
+10
View File
@@ -21,6 +21,7 @@ import '../../widgets/desktop_app_bar.dart';
import 'hotkey_recorder_widget.dart';
import 'about_screen.dart';
import 'logs_screen.dart';
import 'mpv_config_screen.dart';
import 'subtitle_styling_screen.dart';
/// Helper class for option selection dialog items
@@ -250,6 +251,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Navigator.push(context, MaterialPageRoute(builder: (context) => const SubtitleStylingScreen()));
},
),
ListTile(
leading: const AppIcon(Symbols.tune_rounded, fill: 1),
title: Text(t.mpvConfig.title),
subtitle: Text(t.mpvConfig.description),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const MpvConfigScreen()));
},
),
ListTile(
leading: const AppIcon(Symbols.replay_10_rounded, fill: 1),
title: Text(t.settings.smallSkipDuration),
+11
View File
@@ -313,6 +313,17 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
await player!.setProperty('sub-delay', offsetSeconds.toString());
}
// Apply custom MPV config entries
final customMpvConfig = settingsService.getEnabledMpvConfigEntries();
for (final entry in customMpvConfig.entries) {
try {
await player!.setProperty(entry.key, entry.value);
appLogger.d('Applied custom MPV property: ${entry.key}=${entry.value}');
} catch (e) {
appLogger.w('Failed to set MPV property ${entry.key}', error: e);
}
}
// Apply saved volume
final savedVolume = settingsService.getVolume();
player!.setVolume(savedVolume);
+77
View File
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:plezy/utils/app_logger.dart';
import '../i18n/strings.g.dart';
import '../models/mpv_config_models.dart';
import 'base_shared_preferences_service.dart';
import '../utils/platform_detector.dart';
@@ -50,6 +51,8 @@ class SettingsService extends BaseSharedPreferencesService {
static const String _keyDownloadOnWifiOnly = 'download_on_wifi_only';
static const String _keyVideoPlayerNavigationEnabled = 'video_player_navigation_enabled';
static const String _keyShowPerformanceOverlay = 'show_performance_overlay';
static const String _keyMpvConfigEntries = 'mpv_config_entries';
static const String _keyMpvConfigPresets = 'mpv_config_presets';
SettingsService._();
@@ -805,6 +808,78 @@ class SettingsService extends BaseSharedPreferencesService {
return prefs.getBool(_keyDownloadOnWifiOnly) ?? false;
}
// MPV Config Entries
/// Get all MPV config entries
List<MpvConfigEntry> getMpvConfigEntries() {
final jsonString = prefs.getString(_keyMpvConfigEntries);
if (jsonString == null) return [];
try {
final List<dynamic> decoded = json.decode(jsonString);
return decoded.map((e) => MpvConfigEntry.fromJson(e as Map<String, dynamic>)).toList();
} catch (e) {
return [];
}
}
/// Save all MPV config entries
Future<void> setMpvConfigEntries(List<MpvConfigEntry> entries) async {
final jsonString = json.encode(entries.map((e) => e.toJson()).toList());
await prefs.setString(_keyMpvConfigEntries, jsonString);
}
/// Get only enabled MPV config entries (for player initialization)
Map<String, String> getEnabledMpvConfigEntries() {
final entries = getMpvConfigEntries();
return Map.fromEntries(entries.where((e) => e.isEnabled).map((e) => MapEntry(e.key, e.value)));
}
// MPV Presets
/// Get all saved presets
List<MpvPreset> getMpvPresets() {
final jsonString = prefs.getString(_keyMpvConfigPresets);
if (jsonString == null) return [];
try {
final List<dynamic> decoded = json.decode(jsonString);
return decoded.map((e) => MpvPreset.fromJson(e as Map<String, dynamic>)).toList();
} catch (e) {
return [];
}
}
/// Save a new preset (overwrites existing with same name)
Future<void> saveMpvPreset(String name, List<MpvConfigEntry> entries) async {
final presets = getMpvPresets();
// Remove existing preset with same name
presets.removeWhere((p) => p.name == name);
presets.add(MpvPreset(name: name, entries: entries, createdAt: DateTime.now()));
final jsonString = json.encode(presets.map((p) => p.toJson()).toList());
await prefs.setString(_keyMpvConfigPresets, jsonString);
}
/// Delete a preset by name
Future<void> deleteMpvPreset(String name) async {
final presets = getMpvPresets();
presets.removeWhere((p) => p.name == name);
final jsonString = json.encode(presets.map((p) => p.toJson()).toList());
await prefs.setString(_keyMpvConfigPresets, jsonString);
}
/// Load a preset (replaces current entries)
Future<void> loadMpvPreset(String name) async {
final presets = getMpvPresets();
final preset = presets.firstWhere((p) => p.name == name, orElse: () => throw Exception('Preset not found: $name'));
await setMpvConfigEntries(preset.entries);
}
// Reset all settings to defaults
Future<void> resetAllSettings() async {
await Future.wait([
@@ -841,6 +916,8 @@ class SettingsService extends BaseSharedPreferencesService {
prefs.remove(_keyDownloadOnWifiOnly),
prefs.remove(_keyVideoPlayerNavigationEnabled),
prefs.remove(_keyShowPerformanceOverlay),
prefs.remove(_keyMpvConfigEntries),
prefs.remove(_keyMpvConfigPresets),
]);
}
@@ -1306,9 +1306,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
Positioned(
top: isMobile ? 60 : 16,
left: 16,
child: IgnorePointer(
child: PlayerPerformanceOverlay(player: widget.player),
),
child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)),
),
],
),
@@ -13,14 +13,10 @@ import 'performance_stats_service.dart';
class PlayerPerformanceOverlay extends StatefulWidget {
final Player player;
const PlayerPerformanceOverlay({
super.key,
required this.player,
});
const PlayerPerformanceOverlay({super.key, required this.player});
@override
State<PlayerPerformanceOverlay> createState() =>
_PlayerPerformanceOverlayState();
State<PlayerPerformanceOverlay> createState() => _PlayerPerformanceOverlayState();
}
class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
@@ -55,12 +51,7 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
),
],
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -78,17 +69,13 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
_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),
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)
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'),
@@ -97,14 +84,10 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
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),
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),
]),
],
],
@@ -161,11 +144,7 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
const SizedBox(width: 4),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600),
),
],
),
@@ -181,13 +160,7 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${metric.label}: ',
style: const TextStyle(
color: Colors.white60,
fontSize: 10,
),
),
Text('${metric.label}: ', style: const TextStyle(color: Colors.white60, fontSize: 10)),
Flexible(
child: Text(
metric.value,
@@ -84,37 +84,37 @@ class PerformanceStats {
/// 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;
: 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 {