318 lines
12 KiB
Dart
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,
|
||
|
|
);
|