import 'dart:convert'; import '../media/ids.dart'; import '../media/playback_rate.dart'; import '../media/media_version_preference.dart'; import 'dart:io'; import 'package:flutter/foundation.dart'; 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/app_locale_utils.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 'sensitive_prefs.dart'; import 'device_performance.dart'; import 'shortcut_action.dart'; export 'base_shared_preferences_service.dart' show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref; import '../models/audio_quality_preset.dart'; import '../models/transcode_quality_preset.dart'; import '../navigation/navigation_tabs.dart'; import '../utils/platform_detector.dart'; import 'trackers/tracker_constants.dart'; import '../profiles/profile.dart'; import '../watch_together/services/watch_together_relay_endpoint.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 ContinueWatchingAction { play, details } enum EpisodeAction { play, details } enum SubAssOverride { no, yes, scale, force, strip } /// Resolution ASS/image subtitles are rasterized at. /// /// iOS/tvOS (avfoundation VO) uses the [screen] vs [video] basis (video is much /// cheaper on 4K displays; subs can't carry more detail than the video they're /// typeset against). Android (libass overlay) instead downscales by a fixed /// fraction of the surface — [screen] is full, and [threeQuarter]/[half]/[third]/ /// [quarter] trade sharpness for raster throughput on render-bound low-end TVs. enum SubtitleRenderResolution { screen, video, threeQuarter, half, third, quarter } extension SubtitleRenderScale on SubtitleRenderResolution { /// Android libass overlay render scale (fraction of the surface resolution). /// Only Android reads this; the iOS-only [video] basis maps to full scale here. double get androidRenderScale => switch (this) { SubtitleRenderResolution.screen => 1.0, SubtitleRenderResolution.video => 1.0, SubtitleRenderResolution.threeQuarter => 0.75, SubtitleRenderResolution.half => 0.5, SubtitleRenderResolution.third => 1 / 3, SubtitleRenderResolution.quarter => 0.25, }; } enum DvConversionModePreference { auto, disabled, dv81, hevcStrip } extension DvConversionModePreferenceNativeValue on DvConversionModePreference { String get nativeValue => switch (this) { DvConversionModePreference.auto => 'auto', DvConversionModePreference.disabled => 'disabled', DvConversionModePreference.dv81 => 'dv81', DvConversionModePreference.hevcStrip => 'hevc_strip', }; } const String _bufferSizeMigratedKey = 'buffer_size_migrated_to_auto'; const String _legacyUseSeasonPosterKey = 'use_season_poster'; const String _legacyMpvConfigEntriesKey = 'mpv_config_entries'; /// One-time auto-reset migration for buffer size. class _BufferSizePref extends IntPref { const _BufferSizePref() : super('buffer_size'); @override int readFrom(BaseSharedPreferencesService svc) { // SharedPreferences updates in-memory cache synchronously, so the // unawaited disk-flush futures are safe here (idempotent if re-run). if (svc.readNullableBool(_bufferSizeMigratedKey) != true) { svc.prefs.remove(key); svc.prefs.setBool(_bufferSizeMigratedKey, true); } return super.readFrom(svc); } } /// Migrates from the legacy enum-string format and clamps to 1..5. class _LibraryDensityPref extends Pref { const _LibraryDensityPref() : super('library_density'); @override int readFrom(BaseSharedPreferencesService svc) { try { final intVal = svc.prefs.getInt(key); 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. } String? strVal; try { strVal = svc.prefs.getString(key); } on TypeError { // Value exists but isn't a String either. } final result = switch (strVal) { 'compact' => 2, 'comfortable' => 4, _ => LibraryDensity.defaultValue, }; svc.prefs.setInt(key, result); return result; } @override Future writeTo(BaseSharedPreferencesService svc, int value) => svc.writeInt(key, value.clamp(LibraryDensity.min, LibraryDensity.max)); } /// Migrates from the legacy `use_season_poster` boolean key. class _EpisodePosterModePref extends EnumPref { const _EpisodePosterModePref() : super('episode_poster_mode', values: EpisodePosterMode.values, defaultValue: EpisodePosterMode.episodeThumbnail); @override EpisodePosterMode readFrom(BaseSharedPreferencesService svc) { final legacyValue = svc.readNullableBool(_legacyUseSeasonPosterKey); if (legacyValue != null) { final migrated = legacyValue ? EpisodePosterMode.seasonPoster : EpisodePosterMode.seriesPoster; svc.prefs.remove(_legacyUseSeasonPosterKey); svc.prefs.setString(key, migrated.name); return migrated; } return super.readFrom(svc); } } /// Stored as the locale enum name; null/empty falls back to the device locale. class _AppLocalePref extends Pref { const _AppLocalePref() : super('app_locale'); @override AppLocale readFrom(BaseSharedPreferencesService svc) { final code = svc.readNullableString(key); if (code == null || code.isEmpty) { return resolvePreferredAppLocale(PlatformDispatcher.instance.locales); } return AppLocale.values.asNameMap()[code] ?? AppLocale.en; } @override Future writeTo(BaseSharedPreferencesService svc, AppLocale value) => svc.writeString(key, value.name); } /// Uses a macOS-disabled default and is forced off when [PlatformDetector] disables PiP. class _AutoPipPref extends Pref { const _AutoPipPref() : super('auto_pip'); @override bool readFrom(BaseSharedPreferencesService svc) { if (!PlatformDetector.supportsPictureInPicture()) return false; return svc.readNullableBool(key) ?? !Platform.isMacOS; } @override Future writeTo(BaseSharedPreferencesService svc, bool value) => svc.writeBool(key, value); } class _UseExternalPlayerPref extends Pref { const _UseExternalPlayerPref() : super('use_external_player'); @override bool readFrom(BaseSharedPreferencesService svc) { if (!PlatformDetector.supportsExternalPlayers()) return false; return svc.readNullableBool(key) ?? false; } @override Future writeTo(BaseSharedPreferencesService svc, bool value) => svc.writeBool(key, value); } /// Experimental native Dolby playback. Keep opt-in on Apple TV until the /// EAC3+JOC sample-buffer renderer (#1300) is verified on real receivers. class _AudioPassthroughPref extends Pref { const _AudioPassthroughPref() : super('audio_passthrough'); @override bool readFrom(BaseSharedPreferencesService svc) { final stored = svc.readNullableBool(key); if (stored != null) return stored; // Android TV on ExoPlayer defaults to bitstreaming AC3/EAC3/DTS to the TV/AVR // (Media3 picks bitstream vs PCM via AudioCapabilities), preserving surround. // Scoped to ExoPlayer — the mpv backend force-sets audio-spdif with no decode // fallback. (#1458) // TODO: Default Apple TV to on once the #1300 Atmos sink is hardware-verified. return Platform.isAndroid && PlatformDetector.isTV() && svc.read(SettingsService.useExoPlayer); } @override Future writeTo(BaseSharedPreferencesService svc, bool value) => svc.writeBool(key, value); } String? _trimEmptyAsNull(String? v) { final t = v?.trim(); return (t == null || t.isEmpty) ? null : t; } String? _normalizeRelayBaseUrl(String? value) { if (value == null || value.trim().isEmpty) return null; final endpoint = WatchTogetherRelayEndpoint.tryParseCustom(value); if (endpoint == null) { throw FormatException('Invalid Watch Together relay base URL'); } return endpoint.canonicalBaseUrl; } String _legacyMpvEntriesToText(List entries) { final lines = []; for (final item in entries) { if (item is Map) { final k = item['key'] as String? ?? ''; final v = item['value'] as String? ?? ''; final enabled = item['isEnabled'] as bool? ?? true; if (k.isNotEmpty) { lines.add(enabled ? '$k=$v' : '#$k=$v'); } } } return lines.join('\n'); } class _MpvConfigTextPref extends StringPref { const _MpvConfigTextPref() : super('mpv_config_text'); @override String readFrom(BaseSharedPreferencesService svc) { final text = svc.readNullableString(key); if (text != null) return text; final legacyJson = svc.readNullableString(_legacyMpvConfigEntriesKey); if (legacyJson == null) return ''; try { final migrated = _legacyMpvEntriesToText(json.decode(legacyJson) as List); svc.prefs.setString(key, migrated); return migrated; } catch (e, st) { appLogger.w('SettingsService: failed to migrate mpv config', error: e, stackTrace: st); return ''; } } } List _decodeMpvPresets(dynamic raw) { return (raw as List).map((e) { final map = e as Map; if (map.containsKey('entries') && !map.containsKey('text')) { map['text'] = _legacyMpvEntriesToText(map['entries'] as List); } return MpvPreset.fromJson(map); }).toList(); } Map _defaultKeyboardHotkeys() => { for (final action in ShortcutAction.values) action.id: action.defaultHotKey, }; Map _decodeKeyboardHotkeys(dynamic raw) { final result = {}; for (final entry in (raw as Map).entries) { final value = entry.value; if (value is! Map) continue; if (value['disabled'] == true) { result[entry.key] = null; continue; } final hotkey = SettingsService.deserializeHotKey(value); if (hotkey != null) result[entry.key] = hotkey; } return {..._defaultKeyboardHotkeys(), ...result}; } class SettingsService extends BaseSharedPreferencesService { 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 enableDebugLogging = BoolPref('enable_debug_logging', onWrite: setLoggerLevel); // Source URL for the Apple TV Atmos diagnostics screen; deliberately not // resettable so a tester keeps it across "Reset All Settings". static const atmosProbeUrl = StringPref('atmos_probe_url', defaultValue: ''); // Session-mode A/B for the Atmos diagnostics screen. Off = the mode Dolby's // application guide prescribes; on = the mode the audio output used before. // Not resettable, for the same reason as the URL above. static const atmosProbeMoviePlaybackMode = BoolPref('atmos_probe_movie_playback_mode'); static const crashReporting = BoolPref('crash_reporting', defaultValue: true); static const enableHardwareDecoding = BoolPref('enable_hardware_decoding', defaultValue: true); static const enableHDR = BoolPref('enable_hdr', defaultValue: true); static const preferredVideoCodec = StringPref('preferred_video_codec', defaultValue: 'auto'); static const preferredAudioCodec = StringPref('preferred_audio_codec', defaultValue: 'auto'); static const viewMode = EnumPref('view_mode', values: ViewMode.values, defaultValue: ViewMode.grid); static const seekTimeSmall = IntPref('seek_time_small', defaultValue: 10); static const seekTimeLarge = IntPref('seek_time_large', defaultValue: 30); static const rewindOnResume = IntPref('rewind_on_resume'); static const showHeroSection = BoolPref('show_hero_section', defaultValue: true); static const tvFullCardLayout = BoolPref('tv_full_card_layout', defaultValue: false); static const focusGlow = BoolPref('focus_glow', defaultValue: true); static const useGlobalHubs = BoolPref('use_global_hubs', defaultValue: true); static const showServerNameOnHubs = BoolPref('show_server_name_on_hubs'); static const groupLibrariesByServer = BoolPref('group_libraries_by_server', defaultValue: true); static const sleepTimerDuration = IntPref('sleep_timer_duration', defaultValue: 30); static const audioSyncOffset = IntPref('audio_sync_offset'); static const subtitleSyncOffset = IntPref('subtitle_sync_offset'); static const subtitleSearchLanguage = NullableStringPref('subtitle_search_language'); static const volume = DoublePref('volume', defaultValue: 100.0); static const rotationLocked = BoolPref('rotation_locked', defaultValue: true); static const subtitleFontSize = IntPref('subtitle_font_size', defaultValue: 38); static const subtitleTextColor = StringPref('subtitle_text_color', defaultValue: '#FFFFFF'); static const subtitleBorderSize = IntPref('subtitle_border_size', defaultValue: 3); static const subtitleBorderColor = StringPref('subtitle_border_color', defaultValue: '#000000'); static const subtitleBackgroundColor = StringPref('subtitle_background_color', defaultValue: '#000000'); static const subtitleBackgroundOpacity = IntPref('subtitle_background_opacity'); static const subAssOverride = EnumPref( 'sub_ass_override', values: SubAssOverride.values, defaultValue: SubAssOverride.no, ); static const subtitleRenderResolution = EnumPref( 'subtitle_render_resolution', values: SubtitleRenderResolution.values, defaultValue: SubtitleRenderResolution.screen, ); static const subtitleBold = BoolPref('subtitle_bold'); static const subtitleItalic = BoolPref('subtitle_italic'); static const cleanedOldImageCache = BoolPref('cleaned_old_image_cache'); static const rememberTrackSelections = BoolPref('remember_track_selections', defaultValue: true); /// Episode advance follows the server's per-episode audio/subtitle /// selections instead of carrying the current choice over (#1717). static const followServerTrackSelections = BoolPref('follow_server_track_selections'); static const showChapterMarkersOnTimeline = BoolPref('show_chapter_markers_on_timeline', defaultValue: true); static const clickVideoTogglesPlayback = BoolPref('click_video_toggles_playback'); static const autoSkipIntro = BoolPref('auto_skip_intro'); static const autoSkipCredits = BoolPref('auto_skip_credits'); static const forceSkipMarkerFallback = BoolPref('force_skip_marker_fallback'); static const autoSkipDelay = IntPref('auto_skip_delay', defaultValue: 5); static const introPattern = StringPref('intro_pattern', defaultValue: defaultIntroPattern); static const creditsPattern = StringPref('credits_pattern', defaultValue: defaultCreditsPattern); static const customDownloadPathType = NullableStringPref('custom_download_path_type'); static const downloadOnWifiOnly = BoolPref('download_on_wifi_only'); static const autoRemoveWatchedDownloads = BoolPref('auto_remove_watched_downloads'); /// Set once the user has seen the pre-flight "background downloads are /// blocked" dialog. The persistent Downloads-screen banner covers repeat /// offenders, so the interrupting dialog is shown exactly once. static const backgroundDownloadWarningAcknowledged = BoolPref('background_download_warning_ack'); /// Remembered state of the "Include Specials" toggle on the show download /// dialog. Defaults to true (include) so existing behavior is unchanged; /// turning it off persists so the next download keeps the choice. static const downloadIncludeSpecials = BoolPref('download_include_specials', defaultValue: true); static const autoCheckUpdatesOnStartup = BoolPref('auto_check_updates_on_startup', defaultValue: true); static const showPerformanceOverlay = BoolPref('show_performance_overlay'); static const autoHidePerformanceOverlay = BoolPref('auto_hide_performance_overlay', defaultValue: true); static const enableDiscordRPC = BoolPref('enable_discord_rpc'); static const enableTraktWatchedSync = BoolPref('enable_trakt_watched_sync', defaultValue: true); static const matchContentFrameRate = BoolPref('match_content_frame_rate'); static const tunneledPlayback = BoolPref('tunneled_playback', defaultValue: true); static const dvConversionMode = EnumPref( 'dv_conversion_mode', values: DvConversionModePreference.values, defaultValue: DvConversionModePreference.auto, ); static const defaultQualityPreset = EnumPref( 'default_quality_preset', values: TranscodeQualityPreset.values, defaultValue: TranscodeQualityPreset.original, ); static const musicQualityPreset = EnumPref( 'music_quality_preset', values: AudioQualityPreset.values, defaultValue: AudioQualityPreset.original, ); /// Music player volume (0–100), independent of the video player's /// [volume] so desktop music listening levels don't drag video loudness /// around. static const musicVolume = DoublePref('music_volume', defaultValue: 100.0); static const autoPlayNextEpisode = BoolPref('auto_play_next_episode', defaultValue: true); static const useExoPlayer = BoolPref('use_exoplayer', defaultValue: true); static const startupSection = EnumPref( 'startup_section', values: NavigationTabId.values, defaultValue: NavigationTabId.discover, ); static const alwaysKeepSidebarOpen = BoolPref('always_keep_sidebar_open'); static const showUnwatchedCount = BoolPref('show_unwatched_count', defaultValue: true); static const showEpisodeNumberOnCards = BoolPref('show_episode_number_on_cards', defaultValue: true); static const showSeasonPostersOnTabs = BoolPref('show_season_posters_on_tabs'); static const hideSpoilers = BoolPref('hide_spoilers'); static const showNavBarLabels = BoolPref('show_nav_bar_labels', defaultValue: true); static const globalShaderPreset = StringPref('global_shader_preset', defaultValue: 'none'); static const requireProfileSelectionOnOpen = BoolPref('require_profile_selection_on_open'); static const useExternalPlayer = _UseExternalPlayerPref(); static const forceTvMode = BoolPref('force_tv_mode'); static const visualEffects = EnumPref( 'visual_effects', values: VisualEffectsSetting.values, defaultValue: VisualEffectsSetting.auto, ); static const ambientLighting = BoolPref('ambient_lighting'); static const audioPassthrough = _AudioPassthroughPref(); static const audioNormalization = BoolPref('audio_normalization'); static const audioDownmix = BoolPref('audio_downmix'); static const audioDownmixNormalize = BoolPref('audio_downmix_normalize', defaultValue: true); static const liveTvDefaultFavorites = BoolPref('live_tv_default_favorites'); static const matchRefreshRate = BoolPref('match_refresh_rate'); static const matchDynamicRange = BoolPref('match_dynamic_range'); static const appLocale = _AppLocalePref(); static const autoPip = _AutoPipPref(); static const customDownloadPath = NullableStringPref('custom_download_path'); static final customRelayUrl = NullableStringPref('custom_relay_url', transform: _normalizeRelayBaseUrl); static NullableStringPref recentRoomsForProfile(String profileId) { if (profileId.trim().isEmpty) { throw ArgumentError.value(profileId, 'profileId', 'Must not be empty'); } return NullableStringPref(profileScopedPrefsKey(profileId, 'watch_together_recent_rooms')); } static final companionRemoteLastHostAddress = NullableStringPref( 'companion_remote_last_host_address', transform: _trimEmptyAsNull, ); static final maxVolume = IntPref('max_volume', defaultValue: 100, transform: (v) => v.clamp(100, 300)); static final downmixCenterBoost = IntPref('downmix_center_boost', transform: (v) => v.clamp(0, 12)); static final subtitlePosition = IntPref('subtitle_position', defaultValue: 100, transform: (v) => v.clamp(0, 100)); static final defaultPlaybackSpeed = DoublePref( 'default_playback_speed', defaultValue: 1.0, transform: (v) => v.clamp(minimumPlaybackRate, maximumPlaybackRate), ); static final defaultBoxFitMode = IntPref('default_box_fit_mode', transform: (v) => v.clamp(0, 2)); static final displaySwitchDelay = IntPref('display_switch_delay', transform: (v) => v.clamp(0, 10)); static ThemeMode _tvAwareThemeModeDefault() => TvDetectionService.isTVSync() ? ThemeMode.oled : ThemeMode.system; static const themeMode = EnumPref( 'theme_mode', values: ThemeMode.values, defaultValueProvider: _tvAwareThemeModeDefault, ); static const videoPlayerNavigationEnabled = BoolPref( 'video_player_navigation_enabled', defaultValueProvider: TvDetectionService.isTVSync, ); static const enableCompanionRemoteServer = BoolPref( 'enable_companion_remote_server', defaultValueProvider: PlatformDetector.isDesktopOS, ); static const startInFullscreen = BoolPref('start_in_fullscreen'); static const exitFullscreenOnPlayerClose = BoolPref('exit_fullscreen_on_player_close'); static const bufferSize = _BufferSizePref(); static const libraryDensity = _LibraryDensityPref(); static const tvCornerSpotlightBackdrop = BoolPref('tv_corner_spotlight_backdrop'); static const episodePosterMode = _EpisodePosterModePref(); static const continueWatchingAction = EnumPref( 'continue_watching_action', values: ContinueWatchingAction.values, defaultValue: ContinueWatchingAction.play, ); static const episodeAction = EnumPref( 'episode_action', values: EpisodeAction.values, defaultValue: EpisodeAction.play, ); static const mpvConfigText = _MpvConfigTextPref(); static final keyboardHotkeys = JsonPref>( 'keyboard_hotkeys', defaultValue: {..._defaultKeyboardHotkeys()}, encode: (values) => json.encode( values.map((key, hotkey) => MapEntry(key, hotkey == null ? const {'disabled': true} : serializeHotKey(hotkey))), ), decode: _decodeKeyboardHotkeys, ); static final mediaVersionPreferences = JsonPref>( 'media_version_preferences', defaultValue: const {}, encode: (v) => json.encode(v.map((k, pref) => MapEntry(k, pref.toJson()))), // Legacy values were bare ints; MediaVersionPreference.fromJson accepts both. decode: (raw) => (raw as Map).map((k, v) => MapEntry(k, MediaVersionPreference.fromJson(v))), ); /// Local record of when items were last played on this device /// (item/show globalKey → epoch ms). Written by LocalPlaybackHistory; used /// to pick the last-played sibling in the Continue Watching dedup (#1492). static final localLastPlayedAt = JsonPref>( 'local_last_played_at', defaultValue: const {}, encode: json.encode, decode: (raw) => (raw as Map).map((k, v) => MapEntry(k, v as int)), ); static final customShaderPresets = JsonPref>>( 'custom_shader_presets', defaultValue: const [], encode: json.encode, decode: (raw) => (raw as List).cast>(), ); static final selectedExternalPlayer = JsonPref( 'selected_external_player', defaultValue: KnownPlayers.systemDefault, encode: (p) => json.encode(p.toJson()), decode: (raw) => ExternalPlayer.fromJson(raw as Map), ); static final customExternalPlayers = JsonPref>( 'custom_external_players', defaultValue: const [], encode: (v) => json.encode(v.map((p) => p.toJson()).toList()), decode: (raw) => (raw as List).map((e) => ExternalPlayer.fromJson(e as Map)).toList(), ); static final mpvPresets = JsonPref>( 'mpv_config_presets', defaultValue: const [], encode: (v) => json.encode(v.map((p) => p.toJson()).toList()), decode: _decodeMpvPresets, ); static IntPref watchedThresholdPref(ServerId serverId) => IntPref('watched_threshold_$serverId', defaultValue: 90); /// Library section the user last picked as a DVR recording target, keyed by /// subscription type (movie/show) so the two don't clobber each other. /// 0 = unset (only explicit picks are written; the server template default /// keeps applying until the user chooses). static IntPref dvrTargetSectionPref(ServerId serverId, int type) => IntPref('dvr_target_section_${type}_$serverId'); /// Per-service "scrobble to this tracker" toggle. Trakt's second toggle /// ([enableTraktWatchedSync]) has no counterpart on the other services and /// stays a standalone constant. static BoolPref scrobblePref(TrackerService s) => BoolPref('enable_${s.name}_scrobble', defaultValue: true); static EnumPref trackerFilterModePref(TrackerService s) => EnumPref( 'tracker_library_filter_mode_${s.name}', values: TrackerLibraryFilterMode.values, defaultValue: TrackerLibraryFilterMode.blacklist, ); static StringListPref trackerFilterIdsPref(TrackerService s) => StringListPref('tracker_library_filter_ids_${s.name}'); SettingsService._(); static SettingsService? _cachedInstance; static Future 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; /// Synchronous access to the bootstrapped singleton. static SettingsService get instance { final instance = _cachedInstance; if (instance == null) { throw StateError('SettingsService has not been initialized. Call SettingsService.getInstance() first.'); } return instance; } /// Drop the cached singleton so the next [getInstance] call rebuilds against /// the current SharedPreferences state. Test-only — pair with /// [BaseSharedPreferencesService.resetForTesting]. @visibleForTesting static void resetForTesting() { _cachedInstance = null; } @override Future onInit() async { _assertCredentialsReadable(); const legacyRecentRoomsKey = 'watch_together_recent_rooms'; await prefs.remove(legacyRecentRoomsKey); final storedRelay = readNullableString(customRelayUrl.key); if (storedRelay == null) return; final endpoint = WatchTogetherRelayEndpoint.tryParseCustom(storedRelay); if (endpoint == null) { await prefs.remove(customRelayUrl.key); } else if (endpoint.canonicalBaseUrl != storedRelay) { await prefs.setString(customRelayUrl.key, endpoint.canonicalBaseUrl); } } /// Raises [UnreadableSensitivePreferenceException] if any stored credential /// has a type we cannot read. /// /// The credential stores themselves — `CredentialVault`, `TrackerAccountStore`, /// `SeerrSessionStore` — are consulted long after startup, where a throw /// would surface as an unhandled provider error rather than the repair /// prompt. Checking here puts the failure inside a fatal gate step, while /// the store is open and a surgical single-key repair is still possible /// (#1732). /// /// One pass over the already-cached key set; no I/O. void _assertCredentialsReadable() { for (final key in prefs.keys) { if (isSensitivePrefKey(key)) readTolerantString(prefs, key); } } /// Resolves a video mute toggle without replacing the saved volume with 0. /// /// `persistedVolume` is the non-zero value callers should keep in [volume], /// while `playerVolume` is the value to apply to the active player. ({double playerVolume, double persistedVolume}) resolveMuteToggle(double currentVolume) { if (currentVolume.isFinite && currentVolume > 0) { return (playerVolume: 0, persistedVolume: currentVolume); } final previousVolume = read(volume); final candidate = previousVolume.isFinite && previousVolume > 0 ? previousVolume : volume.defaultValue; final restoredVolume = candidate.clamp(0.0, read(maxVolume).toDouble()).toDouble(); return (playerVolume: restoredVolume, persistedVolume: restoredVolume); } static Map defaultKeyboardHotkeys() => _defaultKeyboardHotkeys(); /// Unknown libraries are allowed only when no filter is configured. bool isLibraryAllowedForTracker(TrackerService service, String? libraryGlobalKey) { final filterIds = read(trackerFilterIdsPref(service)); final mode = read(trackerFilterModePref(service)); if (libraryGlobalKey == null) { return mode == TrackerLibraryFilterMode.blacklist && filterIds.isEmpty; } final inList = filterIds.contains(libraryGlobalKey); return mode == TrackerLibraryFilterMode.blacklist ? !inList : inList; } Future removeCustomExternalPlayer(String id) async { final players = read(customExternalPlayers).where((p) => p.id != id).toList(); await write(customExternalPlayers, players); if (read(selectedExternalPlayer).id == id) { await write(selectedExternalPlayer, KnownPlayers.systemDefault); } } /// Parse raw config text into a `Map` (skip blanks and # comments). static Map parseMpvConfigText(String text) { final result = {}; 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 k = trimmed.substring(0, eqIndex).trim(); final v = trimmed.substring(eqIndex + 1).trim(); if (k.isNotEmpty) result[k] = v; } return result; } /// Save a new preset (overwrites existing with same name). Future saveMpvPreset(String name, String text) async { final presets = read(mpvPresets).where((p) => p.name != name).toList(); presets.add(MpvPreset(name: name, text: text, createdAt: DateTime.now())); await write(mpvPresets, presets); } Future deleteMpvPreset(String name) async { final presets = read(mpvPresets).where((p) => p.name != name).toList(); await write(mpvPresets, presets); } static const _modifierMap = { 'alt': HotKeyModifier.alt, 'control': HotKeyModifier.control, 'shift': HotKeyModifier.shift, 'meta': HotKeyModifier.meta, 'capsLock': HotKeyModifier.capsLock, 'fn': HotKeyModifier.fn, }; static Map 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() ?? []}; } static HotKey? deserializeHotKey(Map data) { try { final keyString = data['key'] as String; final modifierNames = (data['modifiers'] as List).cast(); final modifiers = modifierNames .map((name) => _modifierMap[name]) .where((m) => m != null) .cast() .toList(); // Try parsing as USB HID code first (new format), fall back to string parsing. 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 (_) { // Ignore deserialization errors. } return null; } // Pattern-based key name matching (lowercase keys for case-insensitive matching). static const _keyNameMap = { '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, }; static const _functionKeyMap = { '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, }; static const _digitKeyMap = { '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, }; static const _letterKeyMap = { '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, }; static PhysicalKeyboardKey? _findKeyByString(String keyString) { final normalized = keyString.toLowerCase(); // Try extracting USB HID code from toString() output: // 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); } for (final entry in _keyNameMap.entries) { if (normalized.contains(entry.key)) return entry.value; } // Function keys before digits to avoid f1 matching f10. for (final entry in _functionKeyMap.entries) { if (normalized.contains(entry.key)) return entry.value; } for (final entry in _digitKeyMap.entries) { if (normalized.contains(entry.key)) return entry.value; } for (final entry in _letterKeyMap.entries) { if (normalized.contains(entry.key)) return entry.value; } return null; } /// Preference registry behind "Reset All Settings" and settings export. /// Every participating preference is named exactly once, in the group that /// states its policy; anything absent from all three groups takes part in /// neither surface (credentials, runtime state, migration sentinels). /// /// Group one: reset *and* exported — the ordinary case. static final List> _resetAndPortablePrefs = [ enableDebugLogging, enableHardwareDecoding, enableHDR, preferredVideoCodec, preferredAudioCodec, viewMode, seekTimeSmall, seekTimeLarge, showHeroSection, sleepTimerDuration, audioSyncOffset, subtitleSyncOffset, subtitleSearchLanguage, volume, subtitleFontSize, subtitleTextColor, subtitleBorderSize, subtitleBorderColor, subtitleBackgroundColor, subtitleBackgroundOpacity, rememberTrackSelections, followServerTrackSelections, downloadOnWifiOnly, backgroundDownloadWarningAcknowledged, downloadIncludeSpecials, autoCheckUpdatesOnStartup, showPerformanceOverlay, autoHidePerformanceOverlay, enableDiscordRPC, enableTraktWatchedSync, // Scrobble toggle, one per tracker service. for (final s in TrackerService.values) scrobblePref(s), matchContentFrameRate, tunneledPlayback, dvConversionMode, musicVolume, autoPlayNextEpisode, useExoPlayer, startupSection, alwaysKeepSidebarOpen, showUnwatchedCount, showEpisodeNumberOnCards, showSeasonPostersOnTabs, hideSpoilers, showNavBarLabels, globalShaderPreset, requireProfileSelectionOnOpen, useExternalPlayer, forceTvMode, visualEffects, ambientLighting, audioPassthrough, audioNormalization, audioDownmix, audioDownmixNormalize, appLocale, autoPip, maxVolume, downmixCenterBoost, subtitlePosition, defaultPlaybackSpeed, defaultBoxFitMode, themeMode, videoPlayerNavigationEnabled, bufferSize, libraryDensity, tvCornerSpotlightBackdrop, episodePosterMode, continueWatchingAction, episodeAction, keyboardHotkeys, // Library filters, one pair per tracker service. for (final s in TrackerService.values) ...[trackerFilterModePref(s), trackerFilterIdsPref(s)], ]; /// Group two: exported but *not* reset. Mirrors the original reset surface — /// user-customized data (intro/credits regex patterns) and opt-in toggles /// prior versions didn't reset, so behavior stays identical for users. static final List> _portableOnlyPrefs = [ rewindOnResume, tvFullCardLayout, focusGlow, useGlobalHubs, showServerNameOnHubs, groupLibrariesByServer, rotationLocked, subAssOverride, subtitleRenderResolution, subtitleBold, subtitleItalic, showChapterMarkersOnTimeline, clickVideoTogglesPlayback, autoSkipIntro, autoSkipCredits, forceSkipMarkerFallback, autoSkipDelay, introPattern, creditsPattern, autoRemoveWatchedDownloads, defaultQualityPreset, musicQualityPreset, liveTvDefaultFavorites, matchRefreshRate, matchDynamicRange, displaySwitchDelay, enableCompanionRemoteServer, startInFullscreen, exitFullscreenOnPlayerClose, ]; /// Group three: reset but *not* exported — device-local paths, endpoints and /// per-device state plus user-authored player configuration, none of which /// should travel between installations. static final List> _resetOnlyPrefs = [ customDownloadPathType, mediaVersionPreferences, localLastPlayedAt, customDownloadPath, mpvConfigText, mpvPresets, customShaderPresets, selectedExternalPlayer, customExternalPlayers, customRelayUrl, companionRemoteLastHostAddress, ]; /// Settings that "Reset All Settings" actually resets. static List> get _resettablePrefs => [..._resetAndPortablePrefs, ..._resetOnlyPrefs]; /// Settings carried by settings export/import files. static List> get portablePrefs => [..._resetAndPortablePrefs, ..._portableOnlyPrefs]; Future resetAllSettings() async { await Future.wait([ ..._resettablePrefs.map((p) => prefs.remove(p.key)), // Legacy migration sentinels — removed alongside the keys they guarded. prefs.remove(_legacyUseSeasonPosterKey), prefs.remove(_legacyMpvConfigEntriesKey), prefs.remove(_bufferSizeMigratedKey), ]); refreshListenables(); } /// Push current stored values into every active listenable. Use after bulk /// operations that bypass [write] (e.g. import-from-file rewrites the /// underlying SharedPreferences directly). void refreshListenables() { refreshActiveListenables(); } Future clearImageCache() async { PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clearLiveImages(); await PlexImageCacheManager.instance.emptyCache(); } }