Files
sm-emby-share/lib/providers/danmaku_settings_provider.dart
2026-07-14 11:11:36 +08:00

318 lines
12 KiB
Dart

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<DanmakuSource> sources;
final List<String> 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<DanmakuSource> 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<DanmakuSource>? sources,
List<String>? 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<DanmakuSettingsState>
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<DanmakuSettingsState> 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<DanmakuSource> _parseSources(
List<String>? raw, {
required String? legacyUrl,
}) {
final parsed = <DanmakuSource>[];
for (final item in raw ?? const <String>[]) {
try {
final data = jsonDecode(item);
if (data is Map<String, dynamic>) {
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<DanmakuSource> _normalizeSources(List<DanmakuSource> sources) {
final seen = <String>{};
final result = <DanmakuSource>[];
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<void> setEnabled(bool v) async {
final current = state.value ?? const DanmakuSettingsState();
state = AsyncValue.data(current.copyWith(enabled: v));
await persistField(_keyEnabled, v);
}
Future<void> setChineseConvert(ChineseConvertMode mode) async {
final current = state.value ?? const DanmakuSettingsState();
state = AsyncValue.data(current.copyWith(chineseConvert: mode));
await persistField(_keyChineseConvert, _convertToStr(mode));
}
Future<List<DanmakuSource>> setSources(List<DanmakuSource> 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<void> 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<void> removeSource(String id) async {
final current = state.value ?? const DanmakuSettingsState();
await setSources(current.sources.where((s) => s.id != id).toList());
}
Future<void> 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<void> 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<void> setMergeDuplicates(bool v) async {
final current = state.value ?? const DanmakuSettingsState();
state = AsyncValue.data(current.copyWith(mergeDuplicates: v));
await persistField(_keyMergeDuplicates, v);
}
Future<void> 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<void> 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, DanmakuSettingsState>(
DanmakuSettingsNotifier.new,
);