1537 lines
54 KiB
Dart
1537 lines
54 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'package:flutter/painting.dart';
|
||
import 'package:flutter/services.dart';
|
||
import '../models/hotkey_model.dart';
|
||
import 'image_cache_service.dart';
|
||
import 'package:plezy/utils/app_logger.dart';
|
||
import '../i18n/strings.g.dart';
|
||
import '../models/mpv_config_models.dart';
|
||
import '../models/external_player_models.dart';
|
||
import 'base_shared_preferences_service.dart';
|
||
import '../utils/platform_detector.dart';
|
||
|
||
enum ThemeMode { system, light, dark, oled }
|
||
|
||
/// Library density is now an int 1–5 (1 = most compact, 5 = most comfortable).
|
||
/// Default is 3.
|
||
class LibraryDensity {
|
||
static const int min = 1;
|
||
static const int max = 5;
|
||
static const int defaultValue = 3;
|
||
|
||
/// Returns a 0.0–1.0 factor for interpolation (0 = most compact, 1 = most comfortable).
|
||
static double factor(int density) => (density.clamp(min, max) - min) / (max - min);
|
||
}
|
||
|
||
enum ViewMode { grid, list }
|
||
|
||
enum EpisodePosterMode { seriesPoster, seasonPoster, episodeThumbnail }
|
||
|
||
enum SubAssOverride { no, yes, scale, force, strip }
|
||
|
||
class SettingsService extends BaseSharedPreferencesService {
|
||
static const String _keyThemeMode = 'theme_mode';
|
||
static const String _keyEnableDebugLogging = 'enable_debug_logging';
|
||
static const String _keyCrashReporting = 'crash_reporting';
|
||
static const String _keyBufferSize = 'buffer_size';
|
||
static const String _keyBufferSizeMigratedToAuto = 'buffer_size_migrated_to_auto';
|
||
static const String _keyKeyboardShortcuts = 'keyboard_shortcuts';
|
||
static const String _keyKeyboardHotkeys = 'keyboard_hotkeys';
|
||
static const String _keyEnableHardwareDecoding = 'enable_hardware_decoding';
|
||
static const String _keyEnableHDR = 'enable_hdr';
|
||
static const String _keyPreferredVideoCodec = 'preferred_video_codec';
|
||
static const String _keyPreferredAudioCodec = 'preferred_audio_codec';
|
||
static const String _keyLibraryDensity = 'library_density';
|
||
static const String _keyViewMode = 'view_mode';
|
||
static const String _keyUseSeasonPoster = 'use_season_poster'; // Legacy key for migration
|
||
static const String _keyEpisodePosterMode = 'episode_poster_mode';
|
||
static const String _keySeekTimeSmall = 'seek_time_small';
|
||
static const String _keySeekTimeLarge = 'seek_time_large';
|
||
static const String _keyRewindOnResume = 'rewind_on_resume';
|
||
static const String _keyMediaVersionPreferences = 'media_version_preferences';
|
||
static const String _keyShowHeroSection = 'show_hero_section';
|
||
static const String _keyUseGlobalHubs = 'use_global_hubs';
|
||
static const String _keyShowServerNameOnHubs = 'show_server_name_on_hubs';
|
||
static const String _keySleepTimerDuration = 'sleep_timer_duration';
|
||
static const String _keyAudioSyncOffset = 'audio_sync_offset';
|
||
static const String _keySubtitleSyncOffset = 'subtitle_sync_offset';
|
||
static const String _keyVolume = 'volume';
|
||
static const String _keyRotationLocked = 'rotation_locked';
|
||
static const String _keySubtitleFontSize = 'subtitle_font_size';
|
||
static const String _keySubtitleTextColor = 'subtitle_text_color';
|
||
static const String _keySubtitleBorderSize = 'subtitle_border_size';
|
||
static const String _keySubtitleBorderColor = 'subtitle_border_color';
|
||
static const String _keySubtitleBackgroundColor = 'subtitle_background_color';
|
||
static const String _keySubtitleBackgroundOpacity = 'subtitle_background_opacity';
|
||
static const String _keySubtitlePosition = 'subtitle_position';
|
||
static const String _keySubAssOverride = 'sub_ass_override';
|
||
static const String _keySubtitleBold = 'subtitle_bold';
|
||
static const String _keySubtitleItalic = 'subtitle_italic';
|
||
static const String _keyAppLocale = 'app_locale';
|
||
static const String _keyRememberTrackSelections = 'remember_track_selections';
|
||
static const String _keyClickVideoTogglesPlayback = 'click_video_toggles_playback';
|
||
static const String _keyAutoSkipIntro = 'auto_skip_intro';
|
||
static const String _keyAutoSkipCredits = 'auto_skip_credits';
|
||
static const String _keyAutoSkipDelay = 'auto_skip_delay';
|
||
static const String _keyIntroPattern = 'intro_pattern';
|
||
static const String _keyCreditsPattern = 'credits_pattern';
|
||
|
||
static const String defaultIntroPattern = r'(?:^|\b)(?:intro(?:duction)?|opening)(?:\b|$)|^op(?:\s?\d+)?$';
|
||
static const String defaultCreditsPattern = r'(?:^|\b)(?:outro|closing|credits?|ending)(?:\b|$)|^ed(?:\s?\d+)?$';
|
||
static const String _keyCustomDownloadPath = 'custom_download_path';
|
||
static const String _keyCustomDownloadPathType = 'custom_download_path_type';
|
||
static const String _keyDownloadOnWifiOnly = 'download_on_wifi_only';
|
||
static const String _keyAutoRemoveWatchedDownloads = 'auto_remove_watched_downloads';
|
||
static const String _prefixWatchedThreshold = 'watched_threshold_';
|
||
static const String _keyVideoPlayerNavigationEnabled = 'video_player_navigation_enabled';
|
||
static const String _keyShowPerformanceOverlay = 'show_performance_overlay';
|
||
static const String _keyAutoHidePerformanceOverlay = 'auto_hide_performance_overlay';
|
||
static const String _keyMpvConfigEntries = 'mpv_config_entries'; // Legacy
|
||
static const String _keyMpvConfigText = 'mpv_config_text';
|
||
static const String _keyMpvConfigPresets = 'mpv_config_presets';
|
||
static const String _keyMaxVolume = 'max_volume';
|
||
static const String _keyEnableDiscordRPC = 'enable_discord_rpc';
|
||
static const String _keyEnableTraktScrobble = 'enable_trakt_scrobble';
|
||
static const String _keyEnableTraktWatchedSync = 'enable_trakt_watched_sync';
|
||
static const String _keyEnableCompanionRemoteServer = 'enable_companion_remote_server';
|
||
static const String _keyAutoPip = 'auto_pip';
|
||
static const String _keyMatchContentFrameRate = 'match_content_frame_rate';
|
||
static const String _keyTunneledPlayback = 'tunneled_playback';
|
||
static const String _keyDefaultPlaybackSpeed = 'default_playback_speed';
|
||
static const String _keyDefaultBoxFitMode = 'default_box_fit_mode';
|
||
static const String _keyAutoPlayNextEpisode = 'auto_play_next_episode';
|
||
static const String _keyUseExoPlayer = 'use_exoplayer';
|
||
static const String _keyAlwaysKeepSidebarOpen = 'always_keep_sidebar_open';
|
||
static const String _keyShowUnwatchedCount = 'show_unwatched_count';
|
||
static const String _keyHideSpoilers = 'hide_spoilers';
|
||
static const String _keyShowNavBarLabels = 'show_nav_bar_labels';
|
||
static const String _keyGlobalShaderPreset = 'global_shader_preset';
|
||
static const String _keyRequireProfileSelectionOnOpen = 'require_profile_selection_on_open';
|
||
static const String _keyUseExternalPlayer = 'use_external_player';
|
||
static const String _keySelectedExternalPlayer = 'selected_external_player';
|
||
static const String _keyCustomExternalPlayers = 'custom_external_players';
|
||
static const String _keyConfirmExitOnBack = 'confirm_exit_on_back';
|
||
static const String _keyForceTvMode = 'force_tv_mode';
|
||
static const String _keyAmbientLighting = 'ambient_lighting';
|
||
static const String _keyAudioPassthrough = 'audio_passthrough';
|
||
static const String _keyAudioNormalization = 'audio_normalization';
|
||
static const String _keyCustomShaderPresets = 'custom_shader_presets';
|
||
static const String _keyLiveTvDefaultFavorites = 'live_tv_default_favorites';
|
||
static const String _keyCustomRelayUrl = 'custom_relay_url';
|
||
static const String _keyRecentRooms = 'watch_together_recent_rooms';
|
||
static const String _keyMatchRefreshRate = 'match_refresh_rate';
|
||
static const String _keyMatchDynamicRange = 'match_dynamic_range';
|
||
static const String _keyDisplaySwitchDelay = 'display_switch_delay';
|
||
|
||
SettingsService._();
|
||
|
||
static SettingsService? _cachedInstance;
|
||
|
||
static Future<SettingsService> getInstance() async {
|
||
_cachedInstance ??= await BaseSharedPreferencesService.initializeInstance(() => SettingsService._());
|
||
return _cachedInstance!;
|
||
}
|
||
|
||
/// Synchronous access to the singleton, or null if not yet initialized.
|
||
static SettingsService? get instanceOrNull => _cachedInstance;
|
||
|
||
/// Generic helper to get an enum value from preferences
|
||
T _getEnumValue<T extends Enum>(String key, List<T> values, T defaultValue) {
|
||
final stored = prefs.getString(key);
|
||
if (stored == null) return defaultValue;
|
||
return values.firstWhere((v) => v.name == stored, orElse: () => defaultValue);
|
||
}
|
||
|
||
// Theme Mode
|
||
Future<void> setThemeMode(ThemeMode mode) async {
|
||
await prefs.setString(_keyThemeMode, mode.name);
|
||
}
|
||
|
||
ThemeMode getThemeMode() {
|
||
final stored = prefs.getString(_keyThemeMode);
|
||
if (stored == null) {
|
||
// Default to OLED on Android TV, system elsewhere
|
||
return TvDetectionService.isTVSync() ? ThemeMode.oled : ThemeMode.system;
|
||
}
|
||
return _getEnumValue(_keyThemeMode, ThemeMode.values, ThemeMode.system);
|
||
}
|
||
|
||
// Debug Logging
|
||
Future<void> setEnableDebugLogging(bool enabled) async {
|
||
await prefs.setBool(_keyEnableDebugLogging, enabled);
|
||
// Update logger level immediately when setting changes
|
||
setLoggerLevel(enabled);
|
||
}
|
||
|
||
bool getEnableDebugLogging() {
|
||
return prefs.getBool(_keyEnableDebugLogging) ?? false;
|
||
}
|
||
|
||
// Crash Reporting
|
||
Future<void> setCrashReporting(bool enabled) async {
|
||
await prefs.setBool(_keyCrashReporting, enabled);
|
||
}
|
||
|
||
bool getCrashReporting() {
|
||
return prefs.getBool(_keyCrashReporting) ?? true; // Default enabled
|
||
}
|
||
|
||
// Buffer Size (in MB)
|
||
Future<void> setBufferSize(int sizeInMB) async {
|
||
await prefs.setInt(_keyBufferSize, sizeInMB);
|
||
}
|
||
|
||
int getBufferSize() {
|
||
// One-time migration: reset existing users to Auto.
|
||
// SharedPreferences updates in-memory cache synchronously, so the
|
||
// unawaited disk-flush futures are safe here (idempotent if re-run).
|
||
if (prefs.getBool(_keyBufferSizeMigratedToAuto) != true) {
|
||
prefs.remove(_keyBufferSize);
|
||
prefs.setBool(_keyBufferSizeMigratedToAuto, true);
|
||
}
|
||
return prefs.getInt(_keyBufferSize) ?? 0; // 0 = Auto
|
||
}
|
||
|
||
// Hardware Decoding
|
||
Future<void> setEnableHardwareDecoding(bool enabled) async {
|
||
await prefs.setBool(_keyEnableHardwareDecoding, enabled);
|
||
}
|
||
|
||
bool getEnableHardwareDecoding() {
|
||
return prefs.getBool(_keyEnableHardwareDecoding) ?? true; // Default enabled
|
||
}
|
||
|
||
// HDR (High Dynamic Range)
|
||
Future<void> setEnableHDR(bool enabled) async {
|
||
await prefs.setBool(_keyEnableHDR, enabled);
|
||
}
|
||
|
||
bool getEnableHDR() {
|
||
return prefs.getBool(_keyEnableHDR) ?? true; // Default enabled
|
||
}
|
||
|
||
// Preferred Video Codec
|
||
Future<void> setPreferredVideoCodec(String codec) async {
|
||
await prefs.setString(_keyPreferredVideoCodec, codec);
|
||
}
|
||
|
||
String getPreferredVideoCodec() {
|
||
return prefs.getString(_keyPreferredVideoCodec) ?? 'auto';
|
||
}
|
||
|
||
// Preferred Audio Codec
|
||
Future<void> setPreferredAudioCodec(String codec) async {
|
||
await prefs.setString(_keyPreferredAudioCodec, codec);
|
||
}
|
||
|
||
String getPreferredAudioCodec() {
|
||
return prefs.getString(_keyPreferredAudioCodec) ?? 'auto';
|
||
}
|
||
|
||
// Library Density (int 1–5)
|
||
Future<void> setLibraryDensity(int density) async {
|
||
await prefs.setInt(_keyLibraryDensity, density.clamp(LibraryDensity.min, LibraryDensity.max));
|
||
}
|
||
|
||
int getLibraryDensity() {
|
||
// New int format — getInt throws if the stored value is a different type
|
||
try {
|
||
final intVal = prefs.getInt(_keyLibraryDensity);
|
||
if (intVal != null) return intVal.clamp(LibraryDensity.min, LibraryDensity.max);
|
||
} on TypeError {
|
||
// Stored value is a String from old enum format — fall through to migration
|
||
}
|
||
// Migrate from old enum string format
|
||
String? strVal;
|
||
try {
|
||
strVal = prefs.getString(_keyLibraryDensity);
|
||
} on TypeError {
|
||
// Value exists but isn't a String either
|
||
}
|
||
final result = switch (strVal) {
|
||
'compact' => 2,
|
||
'comfortable' => 4,
|
||
_ => LibraryDensity.defaultValue,
|
||
};
|
||
prefs.setInt(_keyLibraryDensity, result);
|
||
return result;
|
||
}
|
||
|
||
// View Mode
|
||
Future<void> setViewMode(ViewMode mode) async {
|
||
await prefs.setString(_keyViewMode, mode.name);
|
||
}
|
||
|
||
ViewMode getViewMode() {
|
||
return _getEnumValue(_keyViewMode, ViewMode.values, ViewMode.grid);
|
||
}
|
||
|
||
// Episode Poster Mode
|
||
Future<void> setEpisodePosterMode(EpisodePosterMode mode) async {
|
||
await prefs.setString(_keyEpisodePosterMode, mode.name);
|
||
}
|
||
|
||
EpisodePosterMode getEpisodePosterMode() {
|
||
// Migration: check old boolean key first
|
||
final legacyValue = prefs.getBool(_keyUseSeasonPoster);
|
||
if (legacyValue != null) {
|
||
// Migrate old setting: true = seasonPoster, false = seriesPoster
|
||
final migratedMode = legacyValue ? EpisodePosterMode.seasonPoster : EpisodePosterMode.seriesPoster;
|
||
// Clear old key and save new format (fire and forget)
|
||
prefs.remove(_keyUseSeasonPoster);
|
||
prefs.setString(_keyEpisodePosterMode, migratedMode.name);
|
||
return migratedMode;
|
||
}
|
||
return _getEnumValue(_keyEpisodePosterMode, EpisodePosterMode.values, EpisodePosterMode.episodeThumbnail);
|
||
}
|
||
|
||
// Show Hero Section
|
||
Future<void> setShowHeroSection(bool enabled) async {
|
||
await prefs.setBool(_keyShowHeroSection, enabled);
|
||
}
|
||
|
||
bool getShowHeroSection() {
|
||
return prefs.getBool(_keyShowHeroSection) ?? true; // Default: true (show hero section)
|
||
}
|
||
|
||
// Use Global Hubs (true = global /hubs endpoint, false = per-library hubs)
|
||
Future<void> setUseGlobalHubs(bool enabled) async {
|
||
await prefs.setBool(_keyUseGlobalHubs, enabled);
|
||
}
|
||
|
||
bool getUseGlobalHubs() {
|
||
return prefs.getBool(_keyUseGlobalHubs) ?? true; // Default: true (use global hubs like official Plex)
|
||
}
|
||
|
||
// Show Server Name on Hubs (false = only on duplicates, true = always)
|
||
Future<void> setShowServerNameOnHubs(bool enabled) async {
|
||
await prefs.setBool(_keyShowServerNameOnHubs, enabled);
|
||
}
|
||
|
||
bool getShowServerNameOnHubs() {
|
||
return prefs.getBool(_keyShowServerNameOnHubs) ?? false; // Default: false (only show on duplicates)
|
||
}
|
||
|
||
// Seek Time Small (in seconds)
|
||
Future<void> setSeekTimeSmall(int seconds) async {
|
||
await prefs.setInt(_keySeekTimeSmall, seconds);
|
||
}
|
||
|
||
int getSeekTimeSmall() {
|
||
return prefs.getInt(_keySeekTimeSmall) ?? 10; // Default: 10 seconds
|
||
}
|
||
|
||
// Seek Time Large (in seconds)
|
||
Future<void> setSeekTimeLarge(int seconds) async {
|
||
await prefs.setInt(_keySeekTimeLarge, seconds);
|
||
}
|
||
|
||
int getSeekTimeLarge() {
|
||
return prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds
|
||
}
|
||
|
||
// Rewind on Resume (in seconds, 0 = disabled)
|
||
Future<void> setRewindOnResume(int seconds) async {
|
||
await prefs.setInt(_keyRewindOnResume, seconds);
|
||
}
|
||
|
||
int getRewindOnResume() {
|
||
return prefs.getInt(_keyRewindOnResume) ?? 0; // Default: 0 (disabled)
|
||
}
|
||
|
||
// Sleep Timer Duration (in minutes)
|
||
Future<void> setSleepTimerDuration(int minutes) async {
|
||
await prefs.setInt(_keySleepTimerDuration, minutes);
|
||
}
|
||
|
||
int getSleepTimerDuration() {
|
||
return prefs.getInt(_keySleepTimerDuration) ?? 30; // Default: 30 minutes
|
||
}
|
||
|
||
// Audio Sync Offset (in milliseconds)
|
||
Future<void> setAudioSyncOffset(int milliseconds) async {
|
||
await prefs.setInt(_keyAudioSyncOffset, milliseconds);
|
||
}
|
||
|
||
int getAudioSyncOffset() {
|
||
return prefs.getInt(_keyAudioSyncOffset) ?? 0; // Default: 0ms (no offset)
|
||
}
|
||
|
||
// Subtitle Sync Offset (in milliseconds)
|
||
Future<void> setSubtitleSyncOffset(int milliseconds) async {
|
||
await prefs.setInt(_keySubtitleSyncOffset, milliseconds);
|
||
}
|
||
|
||
int getSubtitleSyncOffset() {
|
||
return prefs.getInt(_keySubtitleSyncOffset) ?? 0; // Default: 0ms (no offset)
|
||
}
|
||
|
||
// Volume (0.0 to 100.0)
|
||
Future<void> setVolume(double volume) async {
|
||
await prefs.setDouble(_keyVolume, volume);
|
||
}
|
||
|
||
double getVolume() {
|
||
return prefs.getDouble(_keyVolume) ?? 100.0; // Default: full volume
|
||
}
|
||
|
||
// Max Volume (100-300%, for volume boost)
|
||
Future<void> setMaxVolume(int percent) async {
|
||
await prefs.setInt(_keyMaxVolume, percent.clamp(100, 300));
|
||
}
|
||
|
||
int getMaxVolume() {
|
||
return prefs.getInt(_keyMaxVolume) ?? 100; // Default: 100% (no boost)
|
||
}
|
||
|
||
// Rotation Lock (mobile only)
|
||
Future<void> setRotationLocked(bool locked) async {
|
||
await prefs.setBool(_keyRotationLocked, locked);
|
||
}
|
||
|
||
bool getRotationLocked() {
|
||
return prefs.getBool(_keyRotationLocked) ?? true; // Default: locked (landscape only)
|
||
}
|
||
|
||
// Subtitle Styling Settings
|
||
|
||
// Font Size (30-80, default 55)
|
||
Future<void> setSubtitleFontSize(int size) async {
|
||
await prefs.setInt(_keySubtitleFontSize, size);
|
||
}
|
||
|
||
int getSubtitleFontSize() {
|
||
return prefs.getInt(_keySubtitleFontSize) ?? 38;
|
||
}
|
||
|
||
// Text Color (hex format #RRGGBB, default white)
|
||
Future<void> setSubtitleTextColor(String color) async {
|
||
await prefs.setString(_keySubtitleTextColor, color);
|
||
}
|
||
|
||
String getSubtitleTextColor() {
|
||
return prefs.getString(_keySubtitleTextColor) ?? '#FFFFFF';
|
||
}
|
||
|
||
// Border Size (0-5, default 3)
|
||
Future<void> setSubtitleBorderSize(int size) async {
|
||
await prefs.setInt(_keySubtitleBorderSize, size);
|
||
}
|
||
|
||
int getSubtitleBorderSize() {
|
||
return prefs.getInt(_keySubtitleBorderSize) ?? 3;
|
||
}
|
||
|
||
// Border Color (hex format #RRGGBB, default black)
|
||
Future<void> setSubtitleBorderColor(String color) async {
|
||
await prefs.setString(_keySubtitleBorderColor, color);
|
||
}
|
||
|
||
String getSubtitleBorderColor() {
|
||
return prefs.getString(_keySubtitleBorderColor) ?? '#000000';
|
||
}
|
||
|
||
// Background Color (hex format #RRGGBB, default black)
|
||
Future<void> setSubtitleBackgroundColor(String color) async {
|
||
await prefs.setString(_keySubtitleBackgroundColor, color);
|
||
}
|
||
|
||
String getSubtitleBackgroundColor() {
|
||
return prefs.getString(_keySubtitleBackgroundColor) ?? '#000000';
|
||
}
|
||
|
||
// Background Opacity (0-100, default 0 for transparent)
|
||
Future<void> setSubtitleBackgroundOpacity(int opacity) async {
|
||
await prefs.setInt(_keySubtitleBackgroundOpacity, opacity);
|
||
}
|
||
|
||
int getSubtitleBackgroundOpacity() {
|
||
return prefs.getInt(_keySubtitleBackgroundOpacity) ?? 0;
|
||
}
|
||
|
||
// Subtitle Position (0 = top, 100 = bottom, default 100)
|
||
Future<void> setSubtitlePosition(int position) async {
|
||
await prefs.setInt(_keySubtitlePosition, position.clamp(0, 100));
|
||
}
|
||
|
||
int getSubtitlePosition() {
|
||
return prefs.getInt(_keySubtitlePosition) ?? 100; // Default: bottom
|
||
}
|
||
|
||
// Sub ASS Override
|
||
Future<void> setSubAssOverride(SubAssOverride mode) async {
|
||
await prefs.setString(_keySubAssOverride, mode.name);
|
||
}
|
||
|
||
SubAssOverride getSubAssOverride() {
|
||
return _getEnumValue(_keySubAssOverride, SubAssOverride.values, SubAssOverride.no);
|
||
}
|
||
|
||
// Bold subtitles
|
||
Future<void> setSubtitleBold(bool bold) async {
|
||
await prefs.setBool(_keySubtitleBold, bold);
|
||
}
|
||
|
||
bool getSubtitleBold() {
|
||
return prefs.getBool(_keySubtitleBold) ?? false;
|
||
}
|
||
|
||
// Italic subtitles
|
||
Future<void> setSubtitleItalic(bool italic) async {
|
||
await prefs.setBool(_keySubtitleItalic, italic);
|
||
}
|
||
|
||
bool getSubtitleItalic() {
|
||
return prefs.getBool(_keySubtitleItalic) ?? false;
|
||
}
|
||
|
||
// Keyboard Shortcuts (Legacy String-based)
|
||
Map<String, String> getDefaultKeyboardShortcuts() {
|
||
return {
|
||
'play_pause': 'Space',
|
||
'volume_up': 'Arrow Up',
|
||
'volume_down': 'Arrow Down',
|
||
'seek_forward': 'Arrow Right',
|
||
'seek_backward': 'Arrow Left',
|
||
'seek_forward_large': 'Shift+Arrow Right',
|
||
'seek_backward_large': 'Shift+Arrow Left',
|
||
'fullscreen_toggle': 'F',
|
||
'mute_toggle': 'M',
|
||
'subtitle_toggle': 'S',
|
||
'audio_track_next': 'A',
|
||
'subtitle_track_next': 'Shift+S',
|
||
'chapter_next': 'N',
|
||
'chapter_previous': 'P',
|
||
'speed_increase': 'Plus',
|
||
'speed_decrease': 'Minus',
|
||
'speed_reset': 'R',
|
||
'sub_seek_next': 'Ctrl+Right',
|
||
'sub_seek_prev': 'Ctrl+Left',
|
||
};
|
||
}
|
||
|
||
// HotKey Objects (New implementation)
|
||
Map<String, HotKey> getDefaultKeyboardHotkeys() {
|
||
return {
|
||
'play_pause': const HotKey(key: PhysicalKeyboardKey.space),
|
||
'volume_up': const HotKey(key: PhysicalKeyboardKey.arrowUp),
|
||
'volume_down': const HotKey(key: PhysicalKeyboardKey.arrowDown),
|
||
'seek_forward': const HotKey(key: PhysicalKeyboardKey.arrowRight),
|
||
'seek_backward': const HotKey(key: PhysicalKeyboardKey.arrowLeft),
|
||
'seek_forward_large': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]),
|
||
'seek_backward_large': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]),
|
||
'fullscreen_toggle': const HotKey(key: PhysicalKeyboardKey.keyF),
|
||
'mute_toggle': const HotKey(key: PhysicalKeyboardKey.keyM),
|
||
'subtitle_toggle': const HotKey(key: PhysicalKeyboardKey.keyS),
|
||
'audio_track_next': const HotKey(key: PhysicalKeyboardKey.keyA),
|
||
'subtitle_track_next': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]),
|
||
'chapter_next': const HotKey(key: PhysicalKeyboardKey.keyN),
|
||
'chapter_previous': const HotKey(key: PhysicalKeyboardKey.keyP),
|
||
'speed_increase': const HotKey(key: PhysicalKeyboardKey.equal),
|
||
'speed_decrease': const HotKey(key: PhysicalKeyboardKey.minus),
|
||
'speed_reset': const HotKey(key: PhysicalKeyboardKey.keyR),
|
||
'sub_seek_next': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]),
|
||
'sub_seek_prev': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]),
|
||
'shader_toggle': const HotKey(key: PhysicalKeyboardKey.keyG),
|
||
'skip_marker': const HotKey(key: PhysicalKeyboardKey.enter),
|
||
};
|
||
}
|
||
|
||
Future<void> setKeyboardShortcuts(Map<String, String> shortcuts) async {
|
||
final jsonString = json.encode(shortcuts);
|
||
await prefs.setString(_keyKeyboardShortcuts, jsonString);
|
||
}
|
||
|
||
Map<String, String> getKeyboardShortcuts() {
|
||
final jsonString = prefs.getString(_keyKeyboardShortcuts);
|
||
if (jsonString == null) return getDefaultKeyboardShortcuts();
|
||
|
||
final decoded = decodeJsonStringToMap(jsonString);
|
||
if (decoded.isEmpty) return getDefaultKeyboardShortcuts();
|
||
|
||
final shortcuts = decoded.map((key, value) => MapEntry(key, value.toString()));
|
||
|
||
// Merge with defaults to ensure all keys exist
|
||
final defaults = getDefaultKeyboardShortcuts();
|
||
defaults.addAll(shortcuts);
|
||
return defaults;
|
||
}
|
||
|
||
Future<void> setKeyboardShortcut(String action, String key) async {
|
||
final shortcuts = getKeyboardShortcuts();
|
||
shortcuts[action] = key;
|
||
await setKeyboardShortcuts(shortcuts);
|
||
}
|
||
|
||
String getKeyboardShortcut(String action) {
|
||
final shortcuts = getKeyboardShortcuts();
|
||
return shortcuts[action] ?? '';
|
||
}
|
||
|
||
Future<void> resetKeyboardShortcuts() async {
|
||
await setKeyboardShortcuts(getDefaultKeyboardShortcuts());
|
||
}
|
||
|
||
// HotKey Objects Methods
|
||
Future<void> setKeyboardHotkeys(Map<String, HotKey> hotkeys) async {
|
||
final Map<String, Map<String, dynamic>> serializedHotkeys = {};
|
||
for (final entry in hotkeys.entries) {
|
||
serializedHotkeys[entry.key] = _serializeHotKey(entry.value);
|
||
}
|
||
final jsonString = json.encode(serializedHotkeys);
|
||
await prefs.setString(_keyKeyboardHotkeys, jsonString);
|
||
}
|
||
|
||
Future<Map<String, HotKey>> getKeyboardHotkeys() async {
|
||
final jsonString = prefs.getString(_keyKeyboardHotkeys);
|
||
if (jsonString == null) {
|
||
return getDefaultKeyboardHotkeys();
|
||
}
|
||
|
||
try {
|
||
final decoded = json.decode(jsonString) as Map<String, dynamic>;
|
||
final Map<String, HotKey> hotkeys = {};
|
||
|
||
for (final entry in decoded.entries) {
|
||
final hotKey = _deserializeHotKey(entry.value as Map<String, dynamic>);
|
||
if (hotKey != null) {
|
||
hotkeys[entry.key] = hotKey;
|
||
}
|
||
}
|
||
|
||
// Merge with defaults to ensure all keys exist, but keep saved hotkeys priority
|
||
final defaults = getDefaultKeyboardHotkeys();
|
||
final result = <String, HotKey>{};
|
||
|
||
// Start with defaults
|
||
result.addAll(defaults);
|
||
// Override with saved hotkeys (this preserves user customizations)
|
||
result.addAll(hotkeys);
|
||
|
||
return result;
|
||
} catch (e) {
|
||
appLogger.d('Failed to parse keyboard hotkeys', error: e);
|
||
return getDefaultKeyboardHotkeys();
|
||
}
|
||
}
|
||
|
||
Future<void> setKeyboardHotkey(String action, HotKey hotKey) async {
|
||
final hotkeys = await getKeyboardHotkeys();
|
||
hotkeys[action] = hotKey;
|
||
await setKeyboardHotkeys(hotkeys);
|
||
}
|
||
|
||
Future<HotKey?> getKeyboardHotkey(String action) async {
|
||
final hotkeys = await getKeyboardHotkeys();
|
||
return hotkeys[action];
|
||
}
|
||
|
||
Future<void> resetKeyboardHotkeys() async {
|
||
await setKeyboardHotkeys(getDefaultKeyboardHotkeys());
|
||
}
|
||
|
||
// Video Player Navigation (use arrow keys to navigate video player controls)
|
||
Future<void> setVideoPlayerNavigationEnabled(bool enabled) async {
|
||
await prefs.setBool(_keyVideoPlayerNavigationEnabled, enabled);
|
||
}
|
||
|
||
bool getVideoPlayerNavigationEnabled() {
|
||
// Default: enabled on Android TV, disabled elsewhere
|
||
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
|
||
}
|
||
|
||
// Auto-hide performance overlay (fade with playback controls)
|
||
Future<void> setAutoHidePerformanceOverlay(bool enabled) async {
|
||
await prefs.setBool(_keyAutoHidePerformanceOverlay, enabled);
|
||
}
|
||
|
||
bool getAutoHidePerformanceOverlay() {
|
||
return prefs.getBool(_keyAutoHidePerformanceOverlay) ?? true; // Default: enabled
|
||
}
|
||
|
||
// Helper methods for HotKey serialization
|
||
static const _modifierMap = <String, HotKeyModifier>{
|
||
'alt': HotKeyModifier.alt,
|
||
'control': HotKeyModifier.control,
|
||
'shift': HotKeyModifier.shift,
|
||
'meta': HotKeyModifier.meta,
|
||
'capsLock': HotKeyModifier.capsLock,
|
||
'fn': HotKeyModifier.fn,
|
||
};
|
||
|
||
Map<String, dynamic> _serializeHotKey(HotKey hotKey) {
|
||
// Use USB HID code for reliable serialization across debug/release modes
|
||
final usbHidCode = hotKey.key.usbHidUsage.toRadixString(16).padLeft(8, '0');
|
||
return {'key': usbHidCode, 'modifiers': hotKey.modifiers?.map((m) => m.name).toList() ?? []};
|
||
}
|
||
|
||
HotKey? _deserializeHotKey(Map<String, dynamic> data) {
|
||
try {
|
||
final keyString = data['key'] as String;
|
||
final modifierNames = (data['modifiers'] as List<dynamic>).cast<String>();
|
||
|
||
final modifiers = modifierNames
|
||
.map((name) => _modifierMap[name])
|
||
.where((m) => m != null)
|
||
.cast<HotKeyModifier>()
|
||
.toList();
|
||
|
||
// Try parsing as USB HID code first (new format), fall back to string parsing (backwards compat)
|
||
final usbHidCode = int.tryParse(keyString, radix: 16);
|
||
final key = usbHidCode != null ? PhysicalKeyboardKey(usbHidCode) : _findKeyByString(keyString);
|
||
if (key != null) {
|
||
return HotKey(key: key, modifiers: modifiers.isNotEmpty ? modifiers : null);
|
||
}
|
||
} catch (e) {
|
||
// Ignore deserialization errors
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Map for pattern-based key name matching (lowercase keys for case-insensitive matching)
|
||
static const _keyNameMap = <String, PhysicalKeyboardKey>{
|
||
'space': PhysicalKeyboardKey.space,
|
||
'backspace': PhysicalKeyboardKey.backspace,
|
||
'delete': PhysicalKeyboardKey.delete,
|
||
'enter': PhysicalKeyboardKey.enter,
|
||
'escape': PhysicalKeyboardKey.escape,
|
||
'tab': PhysicalKeyboardKey.tab,
|
||
'capslock': PhysicalKeyboardKey.capsLock,
|
||
'arrowleft': PhysicalKeyboardKey.arrowLeft,
|
||
'arrowup': PhysicalKeyboardKey.arrowUp,
|
||
'arrowright': PhysicalKeyboardKey.arrowRight,
|
||
'arrowdown': PhysicalKeyboardKey.arrowDown,
|
||
'home': PhysicalKeyboardKey.home,
|
||
'end': PhysicalKeyboardKey.end,
|
||
'pageup': PhysicalKeyboardKey.pageUp,
|
||
'pagedown': PhysicalKeyboardKey.pageDown,
|
||
'equal': PhysicalKeyboardKey.equal,
|
||
'minus': PhysicalKeyboardKey.minus,
|
||
};
|
||
|
||
// Function keys map
|
||
static const _functionKeyMap = <String, PhysicalKeyboardKey>{
|
||
'f1': PhysicalKeyboardKey.f1,
|
||
'f2': PhysicalKeyboardKey.f2,
|
||
'f3': PhysicalKeyboardKey.f3,
|
||
'f4': PhysicalKeyboardKey.f4,
|
||
'f5': PhysicalKeyboardKey.f5,
|
||
'f6': PhysicalKeyboardKey.f6,
|
||
'f7': PhysicalKeyboardKey.f7,
|
||
'f8': PhysicalKeyboardKey.f8,
|
||
'f9': PhysicalKeyboardKey.f9,
|
||
'f10': PhysicalKeyboardKey.f10,
|
||
'f11': PhysicalKeyboardKey.f11,
|
||
'f12': PhysicalKeyboardKey.f12,
|
||
};
|
||
|
||
// Digit keys map
|
||
static const _digitKeyMap = <String, PhysicalKeyboardKey>{
|
||
'digit0': PhysicalKeyboardKey.digit0,
|
||
'digit1': PhysicalKeyboardKey.digit1,
|
||
'digit2': PhysicalKeyboardKey.digit2,
|
||
'digit3': PhysicalKeyboardKey.digit3,
|
||
'digit4': PhysicalKeyboardKey.digit4,
|
||
'digit5': PhysicalKeyboardKey.digit5,
|
||
'digit6': PhysicalKeyboardKey.digit6,
|
||
'digit7': PhysicalKeyboardKey.digit7,
|
||
'digit8': PhysicalKeyboardKey.digit8,
|
||
'digit9': PhysicalKeyboardKey.digit9,
|
||
};
|
||
|
||
// Letter keys map
|
||
static const _letterKeyMap = <String, PhysicalKeyboardKey>{
|
||
'keya': PhysicalKeyboardKey.keyA,
|
||
'keyb': PhysicalKeyboardKey.keyB,
|
||
'keyc': PhysicalKeyboardKey.keyC,
|
||
'keyd': PhysicalKeyboardKey.keyD,
|
||
'keye': PhysicalKeyboardKey.keyE,
|
||
'keyf': PhysicalKeyboardKey.keyF,
|
||
'keyg': PhysicalKeyboardKey.keyG,
|
||
'keyh': PhysicalKeyboardKey.keyH,
|
||
'keyi': PhysicalKeyboardKey.keyI,
|
||
'keyj': PhysicalKeyboardKey.keyJ,
|
||
'keyk': PhysicalKeyboardKey.keyK,
|
||
'keyl': PhysicalKeyboardKey.keyL,
|
||
'keym': PhysicalKeyboardKey.keyM,
|
||
'keyn': PhysicalKeyboardKey.keyN,
|
||
'keyo': PhysicalKeyboardKey.keyO,
|
||
'keyp': PhysicalKeyboardKey.keyP,
|
||
'keyq': PhysicalKeyboardKey.keyQ,
|
||
'keyr': PhysicalKeyboardKey.keyR,
|
||
'keys': PhysicalKeyboardKey.keyS,
|
||
'keyt': PhysicalKeyboardKey.keyT,
|
||
'keyu': PhysicalKeyboardKey.keyU,
|
||
'keyv': PhysicalKeyboardKey.keyV,
|
||
'keyw': PhysicalKeyboardKey.keyW,
|
||
'keyx': PhysicalKeyboardKey.keyX,
|
||
'keyy': PhysicalKeyboardKey.keyY,
|
||
'keyz': PhysicalKeyboardKey.keyZ,
|
||
};
|
||
|
||
// Helper method to find PhysicalKeyboardKey by string representation
|
||
PhysicalKeyboardKey? _findKeyByString(String keyString) {
|
||
final normalized = keyString.toLowerCase();
|
||
|
||
// Try extracting USB HID code from toString() output
|
||
// Format: PhysicalKeyboardKey#ec9ed(usbHidUsage: "0x0007002c", debugName: "Space")
|
||
final usbHidMatch = RegExp(r'usbhidusage: "0x([0-9a-f]+)"').firstMatch(normalized);
|
||
if (usbHidMatch != null) {
|
||
final code = int.tryParse(usbHidMatch.group(1)!, radix: 16);
|
||
if (code != null) return PhysicalKeyboardKey(code);
|
||
}
|
||
|
||
// Try direct name matches
|
||
for (final entry in _keyNameMap.entries) {
|
||
if (normalized.contains(entry.key)) {
|
||
return entry.value;
|
||
}
|
||
}
|
||
|
||
// Try function keys (check longer patterns first to avoid f1 matching f10)
|
||
for (final entry in _functionKeyMap.entries) {
|
||
if (normalized.contains(entry.key)) {
|
||
return entry.value;
|
||
}
|
||
}
|
||
|
||
// Try digit keys
|
||
for (final entry in _digitKeyMap.entries) {
|
||
if (normalized.contains(entry.key)) {
|
||
return entry.value;
|
||
}
|
||
}
|
||
|
||
// Try letter keys
|
||
for (final entry in _letterKeyMap.entries) {
|
||
if (normalized.contains(entry.key)) {
|
||
return entry.value;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// Media Version Preferences
|
||
/// Save media version preference for a series
|
||
/// [seriesRatingKey] is the grandparentRatingKey for TV series, or ratingKey for movies
|
||
/// [mediaIndex] is the index of the selected media version
|
||
Future<void> setMediaVersionPreference(String seriesRatingKey, int mediaIndex) async {
|
||
final preferences = _getMediaVersionPreferences();
|
||
preferences[seriesRatingKey] = mediaIndex;
|
||
|
||
final jsonString = json.encode(preferences);
|
||
await prefs.setString(_keyMediaVersionPreferences, jsonString);
|
||
}
|
||
|
||
/// Get saved media version preference for a series
|
||
/// Returns null if no preference is saved
|
||
int? getMediaVersionPreference(String seriesRatingKey) {
|
||
final preferences = _getMediaVersionPreferences();
|
||
return preferences[seriesRatingKey];
|
||
}
|
||
|
||
/// Get all media version preferences
|
||
Map<String, int> _getMediaVersionPreferences() {
|
||
final jsonString = prefs.getString(_keyMediaVersionPreferences);
|
||
if (jsonString == null) return {};
|
||
|
||
final decoded = decodeJsonStringToMap(jsonString);
|
||
return decoded.map((key, value) => MapEntry(key, value as int));
|
||
}
|
||
|
||
// App Locale
|
||
Future<void> setAppLocale(AppLocale locale) async {
|
||
await prefs.setString(_keyAppLocale, locale.languageCode);
|
||
}
|
||
|
||
AppLocale getAppLocale() {
|
||
final localeString = prefs.getString(_keyAppLocale);
|
||
if (localeString == null) return AppLocaleUtils.findDeviceLocale();
|
||
|
||
return AppLocale.values.asNameMap()[localeString] ?? AppLocale.en;
|
||
}
|
||
|
||
// Track Selection Settings
|
||
|
||
/// Remember Track Selections - Save per-media audio/subtitle language preferences
|
||
Future<void> setRememberTrackSelections(bool value) async {
|
||
await prefs.setBool(_keyRememberTrackSelections, value);
|
||
}
|
||
|
||
bool getRememberTrackSelections() {
|
||
return prefs.getBool(_keyRememberTrackSelections) ?? true;
|
||
}
|
||
|
||
// Click on Video Player Settings
|
||
Future<void> setClickVideoTogglesPlayback(bool value) async {
|
||
await prefs.setBool(_keyClickVideoTogglesPlayback, value);
|
||
}
|
||
|
||
bool getClickVideoTogglesPlayback() {
|
||
return prefs.getBool(_keyClickVideoTogglesPlayback) ?? false;
|
||
}
|
||
|
||
// Auto Skip Intro
|
||
Future<void> setAutoSkipIntro(bool value) async {
|
||
await prefs.setBool(_keyAutoSkipIntro, value);
|
||
}
|
||
|
||
bool getAutoSkipIntro() {
|
||
return prefs.getBool(_keyAutoSkipIntro) ?? false; // Default: disabled
|
||
}
|
||
|
||
// Auto Skip Credits
|
||
Future<void> setAutoSkipCredits(bool value) async {
|
||
await prefs.setBool(_keyAutoSkipCredits, value);
|
||
}
|
||
|
||
bool getAutoSkipCredits() {
|
||
return prefs.getBool(_keyAutoSkipCredits) ?? false; // Default: disabled
|
||
}
|
||
|
||
// Auto Skip Delay (in seconds)
|
||
Future<void> setAutoSkipDelay(int seconds) async {
|
||
await prefs.setInt(_keyAutoSkipDelay, seconds);
|
||
}
|
||
|
||
int getAutoSkipDelay() {
|
||
return prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds
|
||
}
|
||
|
||
// Intro Pattern
|
||
Future<void> setIntroPattern(String value) async {
|
||
await prefs.setString(_keyIntroPattern, value);
|
||
}
|
||
|
||
String getIntroPattern() {
|
||
return prefs.getString(_keyIntroPattern) ?? defaultIntroPattern;
|
||
}
|
||
|
||
// Credits Pattern
|
||
Future<void> setCreditsPattern(String value) async {
|
||
await prefs.setString(_keyCreditsPattern, value);
|
||
}
|
||
|
||
String getCreditsPattern() {
|
||
return prefs.getString(_keyCreditsPattern) ?? defaultCreditsPattern;
|
||
}
|
||
|
||
// Custom Download Path
|
||
Future<void> setCustomDownloadPath(String? path, {String type = 'file'}) async {
|
||
if (path == null) {
|
||
await prefs.remove(_keyCustomDownloadPath);
|
||
await prefs.remove(_keyCustomDownloadPathType);
|
||
} else {
|
||
await prefs.setString(_keyCustomDownloadPath, path);
|
||
await prefs.setString(_keyCustomDownloadPathType, type);
|
||
}
|
||
}
|
||
|
||
String? getCustomDownloadPath() {
|
||
return prefs.getString(_keyCustomDownloadPath);
|
||
}
|
||
|
||
String getCustomDownloadPathType() {
|
||
return prefs.getString(_keyCustomDownloadPathType) ?? 'file';
|
||
}
|
||
|
||
bool hasCustomDownloadPath() {
|
||
return prefs.containsKey(_keyCustomDownloadPath);
|
||
}
|
||
|
||
// Custom Relay URL
|
||
Future<void> setCustomRelayUrl(String? url) async {
|
||
if (url == null || url.trim().isEmpty) {
|
||
await prefs.remove(_keyCustomRelayUrl);
|
||
} else {
|
||
await prefs.setString(_keyCustomRelayUrl, url.trim());
|
||
}
|
||
}
|
||
|
||
String? getCustomRelayUrl() {
|
||
return prefs.getString(_keyCustomRelayUrl);
|
||
}
|
||
|
||
// Recent Watch Together rooms
|
||
Future<void> setRecentRooms(String json) async {
|
||
await prefs.setString(_keyRecentRooms, json);
|
||
}
|
||
|
||
String? getRecentRooms() {
|
||
return prefs.getString(_keyRecentRooms);
|
||
}
|
||
|
||
// Download on WiFi Only
|
||
Future<void> setDownloadOnWifiOnly(bool value) async {
|
||
await prefs.setBool(_keyDownloadOnWifiOnly, value);
|
||
}
|
||
|
||
bool getDownloadOnWifiOnly() {
|
||
return prefs.getBool(_keyDownloadOnWifiOnly) ?? false;
|
||
}
|
||
|
||
// Auto-remove watched downloads
|
||
Future<void> setAutoRemoveWatchedDownloads(bool value) async {
|
||
await prefs.setBool(_keyAutoRemoveWatchedDownloads, value);
|
||
}
|
||
|
||
bool getAutoRemoveWatchedDownloads() {
|
||
return prefs.getBool(_keyAutoRemoveWatchedDownloads) ?? false;
|
||
}
|
||
|
||
// Per-server watched threshold (cached from server prefs)
|
||
Future<void> setWatchedThreshold(String serverId, int percent) async {
|
||
await prefs.setInt('$_prefixWatchedThreshold$serverId', percent);
|
||
}
|
||
|
||
int getWatchedThreshold(String serverId) {
|
||
return prefs.getInt('$_prefixWatchedThreshold$serverId') ?? 90;
|
||
}
|
||
|
||
// MPV Config (raw text)
|
||
|
||
/// Get the raw MPV config text. Migrates from legacy JSON entries on first read.
|
||
String getMpvConfigText() {
|
||
final text = prefs.getString(_keyMpvConfigText);
|
||
if (text != null) return text;
|
||
|
||
// Migrate from legacy JSON entries
|
||
final legacyJson = prefs.getString(_keyMpvConfigEntries);
|
||
if (legacyJson != null) {
|
||
try {
|
||
final List<dynamic> decoded = json.decode(legacyJson);
|
||
final lines = <String>[];
|
||
for (final item in decoded) {
|
||
if (item is Map<String, dynamic>) {
|
||
final key = item['key'] as String? ?? '';
|
||
final value = item['value'] as String? ?? '';
|
||
final enabled = item['isEnabled'] as bool? ?? true;
|
||
if (key.isNotEmpty) {
|
||
lines.add(enabled ? '$key=$value' : '#$key=$value');
|
||
}
|
||
}
|
||
}
|
||
final migrated = lines.join('\n');
|
||
prefs.setString(_keyMpvConfigText, migrated);
|
||
return migrated;
|
||
} catch (_) {}
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
/// Save the raw MPV config text
|
||
Future<void> setMpvConfigText(String text) async {
|
||
await prefs.setString(_keyMpvConfigText, text);
|
||
}
|
||
|
||
/// Parse raw config text into a `Map<String, String>` (skip blanks and # comments)
|
||
static Map<String, String> parseMpvConfigText(String text) {
|
||
final result = <String, String>{};
|
||
for (final line in text.split('\n')) {
|
||
final trimmed = line.trim();
|
||
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
|
||
final eqIndex = trimmed.indexOf('=');
|
||
if (eqIndex <= 0) continue;
|
||
final key = trimmed.substring(0, eqIndex).trim();
|
||
final value = trimmed.substring(eqIndex + 1).trim();
|
||
if (key.isNotEmpty) result[key] = value;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// Get enabled MPV config entries (for player initialization)
|
||
Map<String, String> getEnabledMpvConfigEntries() {
|
||
return parseMpvConfigText(getMpvConfigText());
|
||
}
|
||
|
||
// 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) {
|
||
final map = e as Map<String, dynamic>;
|
||
// Migrate legacy presets with entries list to text
|
||
if (map.containsKey('entries') && !map.containsKey('text')) {
|
||
final entries = map['entries'] as List;
|
||
final lines = <String>[];
|
||
for (final item in entries) {
|
||
if (item is Map<String, dynamic>) {
|
||
final key = item['key'] as String? ?? '';
|
||
final value = item['value'] as String? ?? '';
|
||
final enabled = item['isEnabled'] as bool? ?? true;
|
||
if (key.isNotEmpty) {
|
||
lines.add(enabled ? '$key=$value' : '#$key=$value');
|
||
}
|
||
}
|
||
}
|
||
map['text'] = lines.join('\n');
|
||
}
|
||
return MpvPreset.fromJson(map);
|
||
}).toList();
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/// Save a new preset (overwrites existing with same name)
|
||
Future<void> saveMpvPreset(String name, String text) async {
|
||
final presets = getMpvPresets();
|
||
presets.removeWhere((p) => p.name == name);
|
||
presets.add(MpvPreset(name: name, text: text, 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 config text)
|
||
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 setMpvConfigText(preset.text);
|
||
}
|
||
|
||
// Discord Rich Presence
|
||
Future<void> setEnableDiscordRPC(bool enabled) async {
|
||
await prefs.setBool(_keyEnableDiscordRPC, enabled);
|
||
}
|
||
|
||
bool getEnableDiscordRPC() {
|
||
return prefs.getBool(_keyEnableDiscordRPC) ?? false; // Default disabled
|
||
}
|
||
|
||
// Trakt Scrobbling (sends play/pause/stop during playback to Trakt)
|
||
Future<void> setEnableTraktScrobble(bool enabled) async {
|
||
await prefs.setBool(_keyEnableTraktScrobble, enabled);
|
||
}
|
||
|
||
bool getEnableTraktScrobble() {
|
||
return prefs.getBool(_keyEnableTraktScrobble) ?? true; // Default enabled when account is linked
|
||
}
|
||
|
||
// Trakt Watched-Status Sync (Plezy → Trakt only)
|
||
Future<void> setEnableTraktWatchedSync(bool enabled) async {
|
||
await prefs.setBool(_keyEnableTraktWatchedSync, enabled);
|
||
}
|
||
|
||
bool getEnableTraktWatchedSync() {
|
||
return prefs.getBool(_keyEnableTraktWatchedSync) ?? true; // Default enabled when account is linked
|
||
}
|
||
|
||
// Companion Remote Server
|
||
Future<void> setEnableCompanionRemoteServer(bool enabled) async {
|
||
await prefs.setBool(_keyEnableCompanionRemoteServer, enabled);
|
||
}
|
||
|
||
bool getEnableCompanionRemoteServer() {
|
||
// Default enabled on desktop/TV, disabled on mobile
|
||
return prefs.getBool(_keyEnableCompanionRemoteServer) ??
|
||
(Platform.isMacOS || Platform.isWindows || Platform.isLinux);
|
||
}
|
||
|
||
// Auto Picture-in-Picture (Android & iOS)
|
||
Future<void> setAutoPip(bool enabled) async {
|
||
await prefs.setBool(_keyAutoPip, enabled);
|
||
}
|
||
|
||
bool getAutoPip() {
|
||
if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) return false;
|
||
if (PlatformDetector.isTV()) return false;
|
||
// Default enabled on mobile, disabled on macOS
|
||
return prefs.getBool(_keyAutoPip) ?? !Platform.isMacOS;
|
||
}
|
||
|
||
// Match Content Frame Rate (Android only)
|
||
Future<void> setMatchContentFrameRate(bool enabled) async {
|
||
await prefs.setBool(_keyMatchContentFrameRate, enabled);
|
||
}
|
||
|
||
bool getMatchContentFrameRate() {
|
||
return prefs.getBool(_keyMatchContentFrameRate) ?? false; // Default disabled
|
||
}
|
||
|
||
// Match Refresh Rate (Windows)
|
||
Future<void> setMatchRefreshRate(bool enabled) async {
|
||
await prefs.setBool(_keyMatchRefreshRate, enabled);
|
||
}
|
||
|
||
bool getMatchRefreshRate() {
|
||
return prefs.getBool(_keyMatchRefreshRate) ?? false;
|
||
}
|
||
|
||
// Match Dynamic Range (Windows)
|
||
Future<void> setMatchDynamicRange(bool enabled) async {
|
||
await prefs.setBool(_keyMatchDynamicRange, enabled);
|
||
}
|
||
|
||
bool getMatchDynamicRange() {
|
||
return prefs.getBool(_keyMatchDynamicRange) ?? false;
|
||
}
|
||
|
||
// Display Switch Delay (Windows, seconds)
|
||
Future<void> setDisplaySwitchDelay(int seconds) async {
|
||
await prefs.setInt(_keyDisplaySwitchDelay, seconds.clamp(0, 10));
|
||
}
|
||
|
||
int getDisplaySwitchDelay() {
|
||
return prefs.getInt(_keyDisplaySwitchDelay) ?? 0;
|
||
}
|
||
|
||
// Tunneled Playback (Android ExoPlayer only)
|
||
Future<void> setTunneledPlayback(bool enabled) async {
|
||
await prefs.setBool(_keyTunneledPlayback, enabled);
|
||
}
|
||
|
||
bool getTunneledPlayback() {
|
||
return prefs.getBool(_keyTunneledPlayback) ?? true; // Default: enabled
|
||
}
|
||
|
||
// Default Playback Speed (0.5 to 3.0)
|
||
Future<void> setDefaultPlaybackSpeed(double speed) async {
|
||
await prefs.setDouble(_keyDefaultPlaybackSpeed, speed.clamp(0.5, 3.0));
|
||
}
|
||
|
||
double getDefaultPlaybackSpeed() {
|
||
return prefs.getDouble(_keyDefaultPlaybackSpeed) ?? 1.0; // Default: normal speed
|
||
}
|
||
|
||
// Default BoxFit Mode (0=contain, 1=cover, 2=fill)
|
||
Future<void> setDefaultBoxFitMode(int mode) async {
|
||
await prefs.setInt(_keyDefaultBoxFitMode, mode.clamp(0, 2));
|
||
}
|
||
|
||
int getDefaultBoxFitMode() {
|
||
return prefs.getInt(_keyDefaultBoxFitMode) ?? 0; // Default: contain
|
||
}
|
||
|
||
// Auto-Play Next Episode
|
||
Future<void> setAutoPlayNextEpisode(bool enabled) async {
|
||
await prefs.setBool(_keyAutoPlayNextEpisode, enabled);
|
||
}
|
||
|
||
bool getAutoPlayNextEpisode() {
|
||
return prefs.getBool(_keyAutoPlayNextEpisode) ?? true; // Default enabled
|
||
}
|
||
|
||
// Use ExoPlayer on Android (default: true)
|
||
// When false, uses MPV as the player backend
|
||
Future<void> setUseExoPlayer(bool enabled) async {
|
||
await prefs.setBool(_keyUseExoPlayer, enabled);
|
||
}
|
||
|
||
bool getUseExoPlayer() {
|
||
return prefs.getBool(_keyUseExoPlayer) ?? true; // Default: ExoPlayer
|
||
}
|
||
|
||
// Always Keep Sidebar Open (Desktop/TV only)
|
||
Future<void> setAlwaysKeepSidebarOpen(bool enabled) async {
|
||
await prefs.setBool(_keyAlwaysKeepSidebarOpen, enabled);
|
||
}
|
||
|
||
bool getAlwaysKeepSidebarOpen() {
|
||
return prefs.getBool(_keyAlwaysKeepSidebarOpen) ?? false; // Default: collapsed
|
||
}
|
||
|
||
// Show Unwatched Count (show unwatched episode count on shows/seasons)
|
||
Future<void> setShowUnwatchedCount(bool enabled) async {
|
||
await prefs.setBool(_keyShowUnwatchedCount, enabled);
|
||
}
|
||
|
||
bool getShowUnwatchedCount() {
|
||
return prefs.getBool(_keyShowUnwatchedCount) ?? true; // Default: enabled (show counts)
|
||
}
|
||
|
||
// Hide Spoilers (blur thumbnails and hide descriptions for unwatched episodes)
|
||
Future<void> setHideSpoilers(bool enabled) async {
|
||
await prefs.setBool(_keyHideSpoilers, enabled);
|
||
}
|
||
|
||
bool getHideSpoilers() {
|
||
return prefs.getBool(_keyHideSpoilers) ?? false; // Default: disabled
|
||
}
|
||
|
||
// Show Navigation Bar Labels (mobile bottom nav)
|
||
Future<void> setShowNavBarLabels(bool enabled) async {
|
||
await prefs.setBool(_keyShowNavBarLabels, enabled);
|
||
}
|
||
|
||
bool getShowNavBarLabels() {
|
||
return prefs.getBool(_keyShowNavBarLabels) ?? true; // Default: show labels
|
||
}
|
||
|
||
Future<void> setLiveTvDefaultFavorites(bool enabled) async {
|
||
await prefs.setBool(_keyLiveTvDefaultFavorites, enabled);
|
||
}
|
||
|
||
bool getLiveTvDefaultFavorites() {
|
||
return prefs.getBool(_keyLiveTvDefaultFavorites) ?? false;
|
||
}
|
||
|
||
// Global Shader Preset (for MPV video enhancement)
|
||
Future<void> setGlobalShaderPreset(String presetId) async {
|
||
await prefs.setString(_keyGlobalShaderPreset, presetId);
|
||
}
|
||
|
||
String getGlobalShaderPreset() {
|
||
return prefs.getString(_keyGlobalShaderPreset) ?? 'none'; // Default: no shader
|
||
}
|
||
|
||
// Custom Shader Presets
|
||
List<Map<String, dynamic>> getCustomShaderPresets() {
|
||
final jsonString = prefs.getString(_keyCustomShaderPresets);
|
||
if (jsonString == null) return [];
|
||
try {
|
||
final List<dynamic> decoded = json.decode(jsonString);
|
||
return decoded.cast<Map<String, dynamic>>();
|
||
} catch (_) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
Future<void> setCustomShaderPresets(List<Map<String, dynamic>> presets) async {
|
||
await prefs.setString(_keyCustomShaderPresets, json.encode(presets));
|
||
}
|
||
|
||
// Require Profile Selection on App Open
|
||
Future<void> setRequireProfileSelectionOnOpen(bool enabled) async {
|
||
await prefs.setBool(_keyRequireProfileSelectionOnOpen, enabled);
|
||
}
|
||
|
||
bool getRequireProfileSelectionOnOpen() {
|
||
return prefs.getBool(_keyRequireProfileSelectionOnOpen) ?? false;
|
||
}
|
||
|
||
// External Player
|
||
Future<void> setUseExternalPlayer(bool enabled) async {
|
||
await prefs.setBool(_keyUseExternalPlayer, enabled);
|
||
}
|
||
|
||
bool getUseExternalPlayer() {
|
||
return prefs.getBool(_keyUseExternalPlayer) ?? false;
|
||
}
|
||
|
||
Future<void> setSelectedExternalPlayer(ExternalPlayer player) async {
|
||
await prefs.setString(_keySelectedExternalPlayer, player.toJsonString());
|
||
}
|
||
|
||
ExternalPlayer getSelectedExternalPlayer() {
|
||
final jsonString = prefs.getString(_keySelectedExternalPlayer);
|
||
if (jsonString == null) return KnownPlayers.systemDefault;
|
||
try {
|
||
return ExternalPlayer.fromJsonString(jsonString);
|
||
} catch (e) {
|
||
appLogger.d('Failed to parse external player', error: e);
|
||
return KnownPlayers.systemDefault;
|
||
}
|
||
}
|
||
|
||
Future<void> setCustomExternalPlayers(List<ExternalPlayer> players) async {
|
||
final jsonString = json.encode(players.map((p) => p.toJson()).toList());
|
||
await prefs.setString(_keyCustomExternalPlayers, jsonString);
|
||
}
|
||
|
||
List<ExternalPlayer> getCustomExternalPlayers() {
|
||
final jsonString = prefs.getString(_keyCustomExternalPlayers);
|
||
if (jsonString == null) return [];
|
||
try {
|
||
final List<dynamic> decoded = json.decode(jsonString);
|
||
return decoded.map((e) => ExternalPlayer.fromJson(e as Map<String, dynamic>)).toList();
|
||
} catch (e) {
|
||
appLogger.d('Failed to parse custom external players', error: e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
Future<void> addCustomExternalPlayer(ExternalPlayer player) async {
|
||
final players = getCustomExternalPlayers();
|
||
players.add(player);
|
||
await setCustomExternalPlayers(players);
|
||
}
|
||
|
||
Future<void> removeCustomExternalPlayer(String id) async {
|
||
final players = getCustomExternalPlayers();
|
||
players.removeWhere((p) => p.id == id);
|
||
await setCustomExternalPlayers(players);
|
||
// If the removed player was selected, reset to system default
|
||
if (getSelectedExternalPlayer().id == id) {
|
||
await setSelectedExternalPlayer(KnownPlayers.systemDefault);
|
||
}
|
||
}
|
||
|
||
// Confirm Exit on Back (Android TV)
|
||
Future<void> setConfirmExitOnBack(bool value) async {
|
||
await prefs.setBool(_keyConfirmExitOnBack, value);
|
||
}
|
||
|
||
bool getConfirmExitOnBack() {
|
||
return prefs.getBool(_keyConfirmExitOnBack) ?? true; // Default: enabled
|
||
}
|
||
|
||
// Force TV Mode (Android)
|
||
Future<void> setForceTvMode(bool value) async {
|
||
await prefs.setBool(_keyForceTvMode, value);
|
||
}
|
||
|
||
bool getForceTvMode() => prefs.getBool(_keyForceTvMode) ?? false;
|
||
|
||
// Ambient Lighting
|
||
Future<void> setAmbientLighting(bool enabled) async {
|
||
await prefs.setBool(_keyAmbientLighting, enabled);
|
||
}
|
||
|
||
bool getAmbientLighting() {
|
||
return prefs.getBool(_keyAmbientLighting) ?? false;
|
||
}
|
||
|
||
// Audio Passthrough
|
||
Future<void> setAudioPassthrough(bool enabled) async {
|
||
await prefs.setBool(_keyAudioPassthrough, enabled);
|
||
}
|
||
|
||
bool getAudioPassthrough() {
|
||
return prefs.getBool(_keyAudioPassthrough) ?? false;
|
||
}
|
||
|
||
// Audio Normalization
|
||
Future<void> setAudioNormalization(bool enabled) async {
|
||
await prefs.setBool(_keyAudioNormalization, enabled);
|
||
}
|
||
|
||
bool getAudioNormalization() {
|
||
return prefs.getBool(_keyAudioNormalization) ?? false;
|
||
}
|
||
|
||
// Reset all settings to defaults
|
||
Future<void> resetAllSettings() async {
|
||
await Future.wait([
|
||
prefs.remove(_keyThemeMode),
|
||
prefs.remove(_keyEnableDebugLogging),
|
||
prefs.remove(_keyBufferSize),
|
||
prefs.remove(_keyKeyboardShortcuts),
|
||
prefs.remove(_keyKeyboardHotkeys),
|
||
prefs.remove(_keyEnableHardwareDecoding),
|
||
prefs.remove(_keyEnableHDR),
|
||
prefs.remove(_keyPreferredVideoCodec),
|
||
prefs.remove(_keyPreferredAudioCodec),
|
||
prefs.remove(_keyLibraryDensity),
|
||
prefs.remove(_keyViewMode),
|
||
prefs.remove(_keyUseSeasonPoster), // Legacy key
|
||
prefs.remove(_keyEpisodePosterMode),
|
||
prefs.remove(_keyShowHeroSection),
|
||
prefs.remove(_keySeekTimeSmall),
|
||
prefs.remove(_keySeekTimeLarge),
|
||
prefs.remove(_keyMediaVersionPreferences),
|
||
prefs.remove(_keySleepTimerDuration),
|
||
prefs.remove(_keyAudioSyncOffset),
|
||
prefs.remove(_keySubtitleSyncOffset),
|
||
prefs.remove(_keyVolume),
|
||
prefs.remove(_keyMaxVolume),
|
||
prefs.remove(_keySubtitleFontSize),
|
||
prefs.remove(_keySubtitleTextColor),
|
||
prefs.remove(_keySubtitleBorderSize),
|
||
prefs.remove(_keySubtitleBorderColor),
|
||
prefs.remove(_keySubtitleBackgroundColor),
|
||
prefs.remove(_keySubtitleBackgroundOpacity),
|
||
prefs.remove(_keySubtitlePosition),
|
||
prefs.remove(_keyAppLocale),
|
||
prefs.remove(_keyRememberTrackSelections),
|
||
prefs.remove(_keyCustomDownloadPath),
|
||
prefs.remove(_keyCustomDownloadPathType),
|
||
prefs.remove(_keyDownloadOnWifiOnly),
|
||
prefs.remove(_keyVideoPlayerNavigationEnabled),
|
||
prefs.remove(_keyShowPerformanceOverlay),
|
||
prefs.remove(_keyAutoHidePerformanceOverlay),
|
||
prefs.remove(_keyMpvConfigEntries),
|
||
prefs.remove(_keyMpvConfigText),
|
||
prefs.remove(_keyMpvConfigPresets),
|
||
prefs.remove(_keyEnableDiscordRPC),
|
||
prefs.remove(_keyEnableTraktScrobble),
|
||
prefs.remove(_keyEnableTraktWatchedSync),
|
||
prefs.remove(_keyAutoPip),
|
||
prefs.remove(_keyMatchContentFrameRate),
|
||
prefs.remove(_keyTunneledPlayback),
|
||
prefs.remove(_keyDefaultPlaybackSpeed),
|
||
prefs.remove(_keyDefaultBoxFitMode),
|
||
prefs.remove(_keyAutoPlayNextEpisode),
|
||
prefs.remove(_keyUseExoPlayer),
|
||
prefs.remove(_keyAlwaysKeepSidebarOpen),
|
||
prefs.remove(_keyShowUnwatchedCount),
|
||
prefs.remove(_keyHideSpoilers),
|
||
prefs.remove(_keyShowNavBarLabels),
|
||
prefs.remove(_keyGlobalShaderPreset),
|
||
prefs.remove(_keyCustomShaderPresets),
|
||
prefs.remove(_keyRequireProfileSelectionOnOpen),
|
||
prefs.remove(_keyUseExternalPlayer),
|
||
prefs.remove(_keySelectedExternalPlayer),
|
||
prefs.remove(_keyCustomExternalPlayers),
|
||
prefs.remove(_keyConfirmExitOnBack),
|
||
prefs.remove(_keyForceTvMode),
|
||
prefs.remove(_keyAmbientLighting),
|
||
prefs.remove(_keyAudioPassthrough),
|
||
prefs.remove(_keyAudioNormalization),
|
||
prefs.remove(_keyBufferSizeMigratedToAuto),
|
||
prefs.remove(_keyCustomRelayUrl),
|
||
]);
|
||
}
|
||
|
||
// Clear cache (for storage cleanup)
|
||
Future<void> clearCache() async {
|
||
PaintingBinding.instance.imageCache.clear();
|
||
PaintingBinding.instance.imageCache.clearLiveImages();
|
||
await PlexImageCacheManager.instance.emptyCache();
|
||
}
|
||
|
||
// Get all settings as a map for debugging/export
|
||
Future<Map<String, dynamic>> getAllSettings() async {
|
||
final hotkeys = await getKeyboardHotkeys();
|
||
return {
|
||
'themeMode': getThemeMode().name,
|
||
'enableDebugLogging': getEnableDebugLogging(),
|
||
'bufferSize': getBufferSize(),
|
||
'enableHardwareDecoding': getEnableHardwareDecoding(),
|
||
'preferredVideoCodec': getPreferredVideoCodec(),
|
||
'preferredAudioCodec': getPreferredAudioCodec(),
|
||
'libraryDensity': getLibraryDensity(),
|
||
'viewMode': getViewMode().name,
|
||
'episodePosterMode': getEpisodePosterMode().name,
|
||
'seekTimeSmall': getSeekTimeSmall(),
|
||
'seekTimeLarge': getSeekTimeLarge(),
|
||
'keyboardShortcuts': getKeyboardShortcuts(),
|
||
'keyboardHotkeys': hotkeys.map((key, value) => MapEntry(key, _serializeHotKey(value))),
|
||
'rememberTrackSelections': getRememberTrackSelections(),
|
||
'clickVideoTogglesPlayback': getClickVideoTogglesPlayback(),
|
||
'autoSkipIntro': getAutoSkipIntro(),
|
||
'autoSkipCredits': getAutoSkipCredits(),
|
||
'autoSkipDelay': getAutoSkipDelay(),
|
||
'introPattern': getIntroPattern(),
|
||
'creditsPattern': getCreditsPattern(),
|
||
};
|
||
}
|
||
}
|