import 'dart:convert'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../core/contracts/danmaku.dart'; import 'shared/settings_persistence.dart'; enum ChineseConvertMode { none, toSimplified, toTraditional } DanmakuPanelSettings defaultDanmakuPanelSettings({bool? isAndroid}) { final android = isAndroid ?? Platform.isAndroid; return DanmakuPanelSettings( opacity: android ? 0.9 : 1.0, fontSize: android ? 18 : 20, strokeWidth: android ? 1.2 : 1.5, lineHeight: android ? 1.4 : 1.6, maxRows: android ? 3 : 0, ); } class DanmakuSettingsState { final bool enabled; final ChineseConvertMode chineseConvert; final List sources; final List blockedKeywords; final bool mergeDuplicates; final DanmakuPanelSettings panelSettings; const DanmakuSettingsState({ this.enabled = false, this.chineseConvert = ChineseConvertMode.none, this.sources = const [], this.blockedKeywords = const [], this.mergeDuplicates = false, this.panelSettings = const DanmakuPanelSettings(), }); List get enabledSources => sources.where((s) => s.enabled && s.url.trim().isNotEmpty).toList(); String get sourceUrl => enabledSources.isNotEmpty ? enabledSources.first.url : ''; DanmakuSettingsState copyWith({ bool? enabled, ChineseConvertMode? chineseConvert, List? sources, List? blockedKeywords, bool? mergeDuplicates, DanmakuPanelSettings? panelSettings, }) => DanmakuSettingsState( enabled: enabled ?? this.enabled, chineseConvert: chineseConvert ?? this.chineseConvert, sources: sources ?? this.sources, blockedKeywords: blockedKeywords ?? this.blockedKeywords, mergeDuplicates: mergeDuplicates ?? this.mergeDuplicates, panelSettings: panelSettings ?? this.panelSettings, ); } class DanmakuSettingsNotifier extends AsyncNotifier with SettingsPersistence { static const _keyEnabled = 'smplayer.danmaku_enabled'; static const _keyChineseConvert = 'smplayer.danmaku_chinese_convert'; static const _keySourceUrl = 'smplayer.danmaku_source_url'; static const _keySources = 'smplayer.danmaku_sources'; static const _keyBlockedKeywords = 'smplayer.danmaku_blocked_keywords'; static const _keyMergeDuplicates = 'smplayer.danmaku_merge_duplicates'; static const _keyPanelEnabled = 'smplayer.danmaku_panel_enabled'; static const _keyPanelOpacity = 'smplayer.danmaku_panel_opacity'; static const _keyPanelArea = 'smplayer.danmaku_panel_area'; static const _keyPanelFontSize = 'smplayer.danmaku_panel_font_size'; static const _keyPanelSpeed = 'smplayer.danmaku_panel_speed'; static const _keyPanelOffset = 'smplayer.danmaku_panel_offset'; static const _keyPanelShowTop = 'smplayer.danmaku_panel_show_top'; static const _keyPanelShowScroll = 'smplayer.danmaku_panel_show_scroll'; static const _keyPanelShowBottom = 'smplayer.danmaku_panel_show_bottom'; static const _keyPanelColored = 'smplayer.danmaku_panel_colored'; static const _keyPanelStrokeLegacy = 'smplayer.danmaku_panel_stroke'; static const _keyPanelStrokeWidth = 'smplayer.danmaku_panel_stroke_width'; static const _keyPanelBold = 'smplayer.danmaku_panel_bold'; static const _keyPanelFontFamily = 'smplayer.danmaku_panel_font_family'; static const _keyPanelLineHeight = 'smplayer.danmaku_panel_line_height'; static const _keyPanelFixedToScroll = 'smplayer.danmaku_panel_fixed_to_scroll'; static const _keyPanelMaxRows = 'smplayer.danmaku_panel_max_rows'; static const _keyPanelHeatmapEnabled = 'smplayer.danmaku_panel_heatmap_enabled'; @override Future build() async { final prefs = await getPrefs(); return DanmakuSettingsState( enabled: prefs.getBool(_keyEnabled) ?? false, chineseConvert: _parseConvert(prefs.getString(_keyChineseConvert)), sources: _parseSources( prefs.getStringList(_keySources), legacyUrl: prefs.getString(_keySourceUrl), ), blockedKeywords: prefs.getStringList(_keyBlockedKeywords) ?? [], mergeDuplicates: prefs.getBool(_keyMergeDuplicates) ?? false, panelSettings: _loadPanelSettings(prefs), ); } DanmakuPanelSettings _loadPanelSettings(SharedPreferences prefs) { final defaults = defaultDanmakuPanelSettings(); return DanmakuPanelSettings( enabled: prefs.getBool(_keyPanelEnabled) ?? defaults.enabled, opacity: prefs.getDouble(_keyPanelOpacity) ?? defaults.opacity, area: prefs.getDouble(_keyPanelArea) ?? defaults.area, fontSize: prefs.getDouble(_keyPanelFontSize) ?? defaults.fontSize, speed: prefs.getDouble(_keyPanelSpeed) ?? defaults.speed, offset: prefs.getDouble(_keyPanelOffset) ?? defaults.offset, showTop: prefs.getBool(_keyPanelShowTop) ?? defaults.showTop, showScroll: prefs.getBool(_keyPanelShowScroll) ?? defaults.showScroll, showBottom: prefs.getBool(_keyPanelShowBottom) ?? defaults.showBottom, colored: prefs.getBool(_keyPanelColored) ?? defaults.colored, strokeWidth: prefs.getDouble(_keyPanelStrokeWidth) ?? ((prefs.getBool(_keyPanelStrokeLegacy) ?? true) ? defaults.strokeWidth : 0.0), bold: prefs.getBool(_keyPanelBold) ?? defaults.bold, fontFamily: prefs.getString(_keyPanelFontFamily) ?? defaults.fontFamily, lineHeight: prefs.getDouble(_keyPanelLineHeight) ?? defaults.lineHeight, fixedToScroll: prefs.getBool(_keyPanelFixedToScroll) ?? defaults.fixedToScroll, maxRows: prefs.getInt(_keyPanelMaxRows) ?? defaults.maxRows, heatmapEnabled: prefs.getBool(_keyPanelHeatmapEnabled) ?? defaults.heatmapEnabled, ); } static ChineseConvertMode _parseConvert(String? s) => switch (s) { 'simplified' => ChineseConvertMode.toSimplified, 'traditional' => ChineseConvertMode.toTraditional, _ => ChineseConvertMode.none, }; static String _convertToStr(ChineseConvertMode m) => switch (m) { ChineseConvertMode.toSimplified => 'simplified', ChineseConvertMode.toTraditional => 'traditional', ChineseConvertMode.none => 'none', }; static List _parseSources( List? raw, { required String? legacyUrl, }) { final parsed = []; for (final item in raw ?? const []) { try { final data = jsonDecode(item); if (data is Map) { final source = DanmakuSource.fromJson(data); if (source.url.isNotEmpty) parsed.add(source); } } catch (_) { final url = item.trim(); if (url.isNotEmpty) { parsed.add( DanmakuSource(id: _sourceIdFromUrl(url), name: '', url: url), ); } } } if (parsed.isEmpty) { final url = legacyUrl?.trim() ?? ''; if (url.isNotEmpty) { parsed.add( DanmakuSource(id: _sourceIdFromUrl(url), name: '默认源', url: url), ); } else { parsed.add( DanmakuSource( id: _sourceIdFromUrl('https://api.dandanplay.net'), name: '弹弹play', url: 'https://api.dandanplay.net', ), ); } } return _normalizeSources(parsed); } static List _normalizeSources(List sources) { final seen = {}; final result = []; for (final source in sources) { final url = source.url.trim(); if (url.isEmpty || seen.contains(url)) continue; seen.add(url); result.add( source.copyWith( id: source.id.trim().isEmpty ? _sourceIdFromUrl(url) : source.id, url: url, ), ); } return result; } static String _sourceIdFromUrl(String url) => 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}'; Future setEnabled(bool v) async { final current = state.value ?? const DanmakuSettingsState(); state = AsyncValue.data(current.copyWith(enabled: v)); await persistField(_keyEnabled, v); } Future setChineseConvert(ChineseConvertMode mode) async { final current = state.value ?? const DanmakuSettingsState(); state = AsyncValue.data(current.copyWith(chineseConvert: mode)); await persistField(_keyChineseConvert, _convertToStr(mode)); } Future> setSources(List sources) async { final current = state.value ?? const DanmakuSettingsState(); final normalized = _normalizeSources(sources); state = AsyncValue.data(current.copyWith(sources: normalized)); final enabled = normalized.where((s) => s.enabled).toList(); final enabledUrl = enabled.isEmpty ? null : enabled.first.url; await persistFields({ _keySources: normalized.map((s) => jsonEncode(s.toJson())).toList(), _keySourceUrl: enabledUrl, }); return normalized; } Future addSource({required String name, required String url}) async { final current = state.value ?? const DanmakuSettingsState(); final trimmed = url.trim(); if (trimmed.isEmpty) return; final source = DanmakuSource( id: _sourceIdFromUrl(trimmed), name: name.trim(), url: trimmed, ); final existing = current.sources.indexWhere((s) => s.url == trimmed); final next = [...current.sources]; if (existing >= 0) { next[existing] = source.copyWith(id: next[existing].id); } else { next.add(source); } await setSources(next); } Future removeSource(String id) async { final current = state.value ?? const DanmakuSettingsState(); await setSources(current.sources.where((s) => s.id != id).toList()); } Future addBlockedKeyword(String keyword) async { final current = state.value ?? const DanmakuSettingsState(); if (current.blockedKeywords.contains(keyword)) return; final updated = [...current.blockedKeywords, keyword]; state = AsyncValue.data(current.copyWith(blockedKeywords: updated)); await persistField(_keyBlockedKeywords, updated); } Future removeBlockedKeyword(String keyword) async { final current = state.value ?? const DanmakuSettingsState(); final updated = current.blockedKeywords.where((k) => k != keyword).toList(); state = AsyncValue.data(current.copyWith(blockedKeywords: updated)); await persistField(_keyBlockedKeywords, updated); } Future setMergeDuplicates(bool v) async { final current = state.value ?? const DanmakuSettingsState(); state = AsyncValue.data(current.copyWith(mergeDuplicates: v)); await persistField(_keyMergeDuplicates, v); } Future updatePanelSettings(DanmakuPanelSettings panel) async { setPanelSettings(panel); await persistPanelSettings(panel); } void setPanelSettings(DanmakuPanelSettings panel) { final current = state.value ?? const DanmakuSettingsState(); state = AsyncValue.data(current.copyWith(panelSettings: panel)); } Future persistPanelSettings(DanmakuPanelSettings panel) async { await persistFields({ _keyPanelEnabled: panel.enabled, _keyPanelOpacity: panel.opacity, _keyPanelArea: panel.area, _keyPanelFontSize: panel.fontSize, _keyPanelSpeed: panel.speed, _keyPanelOffset: panel.offset, _keyPanelShowTop: panel.showTop, _keyPanelShowScroll: panel.showScroll, _keyPanelShowBottom: panel.showBottom, _keyPanelColored: panel.colored, _keyPanelStrokeWidth: panel.strokeWidth, _keyPanelBold: panel.bold, _keyPanelFontFamily: panel.fontFamily, _keyPanelLineHeight: panel.lineHeight, _keyPanelFixedToScroll: panel.fixedToScroll, _keyPanelMaxRows: panel.maxRows, _keyPanelHeatmapEnabled: panel.heatmapEnabled, }); } } final danmakuSettingsProvider = AsyncNotifierProvider( DanmakuSettingsNotifier.new, );