Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,74 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/audio_settings_provider.dart';
import '../../../shared/widgets/setting_select.dart';
import '../utils/settings_labels.dart';
import '../widgets/settings_card.dart';
class AudioSettingsTab extends ConsumerWidget {
const AudioSettingsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final audioSettings = ref.watch(audioSettingsProvider);
return SettingsTabScroll(
children: [
audioSettings.when(
data: (data) => Column(
children: [
SettingsCard(
children: [
SettingRow(
title: '记住所选音轨',
trailing: FSwitch(
value: data.rememberAudioTrack,
onChange: (v) => ref
.read(audioSettingsProvider.notifier)
.setRememberAudioTrack(v),
),
),
SettingRow(
title: '音频首选语言',
trailing: SettingSelect<String>(
value: data.preferredAudioLanguage,
options: const ['', 'chi', 'eng', 'jpn'],
labelOf: audioLangLabel,
onChanged: (v) {
if (v != null) {
ref
.read(audioSettingsProvider.notifier)
.setPreferredAudioLanguage(v);
}
},
),
),
],
),
if (!Platform.isAndroid)
SettingsCard(
children: [
SettingRow(
title: '音频增强',
trailing: FSwitch(
value: data.volumeBoost,
onChange: (v) => ref
.read(audioSettingsProvider.notifier)
.setVolumeBoost(v),
),
),
],
),
],
),
loading: () => const SizedBox.shrink(),
error: (e, _) => settingsLoadError(e),
),
],
);
}
}
@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/contracts/danmaku.dart';
import '../../../providers/danmaku_settings_provider.dart';
import '../../../shared/widgets/setting_select.dart';
import '../utils/settings_labels.dart';
import '../widgets/blocked_keywords_dialog.dart';
import '../widgets/danmaku_sources_dialog.dart';
import '../widgets/settings_card.dart';
import '../widgets/settings_stepper_row.dart';
class DanmakuSettingsTab extends ConsumerWidget {
const DanmakuSettingsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final danmakuSettings = ref.watch(danmakuSettingsProvider);
return SettingsTabScroll(
children: [
danmakuSettings.when(
data: (data) => Column(
children: [
SettingsCard(
children: [
SettingRow(
title: '全局启用弹幕',
trailing: FSwitch(
value: data.enabled,
onChange: (v) => ref
.read(danmakuSettingsProvider.notifier)
.setEnabled(v),
),
),
],
),
if (data.enabled) ...[
_buildDanmakuDisplayCard(context, ref, data.panelSettings),
SettingsCard(
children: [
SettingRow(
title: '简繁转换',
subtitle: '仅对兼容原版 dandanplay 的源生效',
trailing: SettingSelect<ChineseConvertMode>(
value: data.chineseConvert,
options: ChineseConvertMode.values,
labelOf: chineseConvertLabel,
onChanged: (v) {
if (v != null) {
ref
.read(danmakuSettingsProvider.notifier)
.setChineseConvert(v);
}
},
),
),
SettingRow(
title: '弹幕源',
subtitle: danmakuSourcesSummary(data.sources),
trailing: const Icon(Icons.chevron_right, size: 18),
onTap: () =>
showDanmakuSourcesDialog(context, data.sources),
),
SettingRow(
title: '合并重复弹幕',
trailing: FSwitch(
value: data.mergeDuplicates,
onChange: (v) => ref
.read(danmakuSettingsProvider.notifier)
.setMergeDuplicates(v),
),
),
SettingRow(
title: '屏蔽关键词',
subtitle: data.blockedKeywords.isEmpty
? '未设置'
: '${data.blockedKeywords.length}',
trailing: const Icon(Icons.chevron_right, size: 18),
onTap: () => showBlockedKeywordsDialog(
context,
ref,
data.blockedKeywords,
),
),
],
),
],
],
),
loading: () => const SizedBox.shrink(),
error: (e, _) => settingsLoadError(e),
),
],
);
}
Widget _buildDanmakuDisplayCard(
BuildContext context,
WidgetRef ref,
DanmakuPanelSettings p,
) {
void emit(DanmakuPanelSettings next) =>
ref.read(danmakuSettingsProvider.notifier).updatePanelSettings(next);
double inc(double v, double step, double min, double max) =>
double.parse((v + step).clamp(min, max).toStringAsFixed(2));
double dec(double v, double step, double min, double max) =>
double.parse((v - step).clamp(min, max).toStringAsFixed(2));
String opacityStr(double v) =>
(v * 10).round() == 10 ? '1.0' : '0.${(v * 10).round()}';
return Column(
children: [
SettingsCard(
children: [
StepperRow(
title: '字号大小',
display: p.fontSize.round().toString(),
onDecrement: p.fontSize > 12
? () => emit(p.copyWith(fontSize: dec(p.fontSize, 2, 12, 40)))
: null,
onIncrement: p.fontSize < 40
? () => emit(p.copyWith(fontSize: inc(p.fontSize, 2, 12, 40)))
: null,
),
StepperRow(
title: '基础速度',
display: '${p.speed.toStringAsFixed(2)}x',
onDecrement: p.speed > 0.25
? () => emit(p.copyWith(speed: dec(p.speed, 0.25, 0.25, 2.0)))
: null,
onIncrement: p.speed < 2.0
? () => emit(p.copyWith(speed: inc(p.speed, 0.25, 0.25, 2.0)))
: null,
),
StepperRow(
title: '不透明度',
display: opacityStr(p.opacity),
onDecrement: p.opacity > 0.1
? () =>
emit(p.copyWith(opacity: dec(p.opacity, 0.1, 0.1, 1.0)))
: null,
onIncrement: p.opacity < 1.0
? () =>
emit(p.copyWith(opacity: inc(p.opacity, 0.1, 0.1, 1.0)))
: null,
),
StepperRow(
title: '描边大小',
display: p.strokeWidth.toStringAsFixed(1),
onDecrement: p.strokeWidth > 0.0
? () => emit(
p.copyWith(
strokeWidth: dec(p.strokeWidth, 0.5, 0.0, 3.0),
),
)
: null,
onIncrement: p.strokeWidth < 3.0
? () => emit(
p.copyWith(
strokeWidth: inc(p.strokeWidth, 0.5, 0.0, 3.0),
),
)
: null,
),
StepperRow(
title: '行间距',
display: p.lineHeight.toStringAsFixed(1),
onDecrement: p.lineHeight > 1.0
? () => emit(
p.copyWith(lineHeight: dec(p.lineHeight, 0.1, 1.0, 3.0)),
)
: null,
onIncrement: p.lineHeight < 3.0
? () => emit(
p.copyWith(lineHeight: inc(p.lineHeight, 0.1, 1.0, 3.0)),
)
: null,
),
StepperRow(
title: '弹幕行数',
display: p.maxRows == 0 ? '自动' : '${p.maxRows}',
onDecrement: p.maxRows > 0
? () => emit(p.copyWith(maxRows: p.maxRows - 1))
: null,
onIncrement: p.maxRows < 10
? () => emit(p.copyWith(maxRows: p.maxRows + 1))
: null,
),
],
),
SettingsCard(
children: [
SettingRow(
title: '粗体',
trailing: FSwitch(
value: p.bold,
onChange: (v) => emit(p.copyWith(bold: v)),
),
),
SettingRow(
title: '固定弹幕转滚动',
trailing: FSwitch(
value: p.fixedToScroll,
onChange: (v) => emit(p.copyWith(fixedToScroll: v)),
),
),
],
),
],
);
}
}
@@ -0,0 +1,478 @@
import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../shared/utils/format_utils.dart';
import '../../../providers/appearance_provider.dart';
import '../../../providers/danmaku_settings_provider.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/server_provider.dart';
import '../../../providers/session_provider.dart';
import '../../../providers/sm_account_provider.dart';
import '../../../providers/update_provider.dart';
import '../../../shared/app_info.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/utils/user_error_formatter.dart';
import '../../../shared/widgets/app_inline_alert.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../../../shared/widgets/setting_select.dart';
import '../../account/account_center_page.dart';
import '../utils/settings_labels.dart';
import '../widgets/settings_card.dart';
class GeneralSettingsTab extends ConsumerStatefulWidget {
final void Function(String message, {AppAlertTone tone}) onFeedback;
const GeneralSettingsTab({super.key, required this.onFeedback});
@override
ConsumerState<GeneralSettingsTab> createState() => _GeneralSettingsTabState();
}
class _GeneralSettingsTabState extends ConsumerState<GeneralSettingsTab> {
bool _installTriggered = false;
@override
Widget build(BuildContext context) {
final appearance = ref.watch(appearanceProvider);
final updateState = ref.watch(updateProvider);
final appVersion =
ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback;
if (updateState.status != UpdateStatus.readyToInstall) {
_installTriggered = false;
} else if (!updateState.silent && !_installTriggered) {
_installTriggered = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(updateProvider.notifier).install();
});
}
final account = ref.watch(smAccountProvider).value;
return SettingsTabScroll(
children: [
SettingsCard(
children: [
SettingRow(
title: account?.loggedIn == true
? (account!.email.isNotEmpty ? account.email : 'Player 账号')
: '账号',
subtitle: account?.loggedIn == true ? null : '登录以使用云同步等服务',
trailing: const Icon(Icons.chevron_right, size: 18),
onTap: _openAccountSheet,
),
],
),
appearance.when(
data: (data) => SettingsCard(
children: [
SettingRow(
title: '主题模式',
trailing: SettingSelect<ThemeMode>(
value: data.themeMode,
options: ThemeMode.values,
labelOf: themeModeLabel,
onChanged: (v) {
if (v != null) {
ref.read(appearanceProvider.notifier).setThemeMode(v);
}
},
),
),
SettingRow(
title: '顶栏背景',
trailing: SettingSelect<HeaderMode>(
value: data.headerMode,
options: HeaderMode.values,
labelOf: headerModeLabel,
onChanged: (v) {
if (v != null) {
ref.read(appearanceProvider.notifier).setHeaderMode(v);
}
},
),
),
SettingRow(
title: '启动页面',
subtitle: '下次打开软件时进入的页面',
trailing: SettingSelect<StartupPage>(
value: data.startupPage,
options: StartupPage.values,
labelOf: startupPageLabel,
onChanged: (v) {
if (v != null) {
ref.read(appearanceProvider.notifier).setStartupPage(v);
}
},
),
),
],
),
loading: () => const SizedBox.shrink(),
error: (e, _) => settingsLoadError(e),
),
SettingsCard(
children: [
SettingRow(
title: '检查更新',
subtitle: _updateStatusText(updateState, appVersion),
trailing: FilledButton.tonalIcon(
onPressed: _canManualCheck(updateState)
? _manualCheckUpdate
: null,
icon:
updateState.status == UpdateStatus.checking &&
!updateState.silent
? const AppLoadingRing(size: 14)
: const Icon(Icons.system_update_alt_rounded, size: 16),
label: const Text('检查更新'),
),
),
if (_shouldShowInlineUpdate(updateState))
_InlineUpdatePanel(
state: updateState,
appVersion: appVersion,
onCheckAgain: _manualCheckUpdate,
onDownload: () =>
ref.read(updateProvider.notifier).startDownload(),
onCancel: () => ref.read(updateProvider.notifier).cancel(),
onOpenDownloadPage: _openDownloadPage,
),
],
),
SettingsCard(
children: [
SettingRow(
title: '导出到文件',
trailing: const Icon(Icons.upload_file, size: 18),
onTap: () => _exportBackup(context),
),
SettingRow(
title: '从文件导入',
trailing: const Icon(Icons.download, size: 18),
onTap: () => _importBackup(context),
),
],
),
],
);
}
bool _canManualCheck(UpdateState state) {
return state.status != UpdateStatus.checking &&
state.status != UpdateStatus.downloading &&
state.status != UpdateStatus.readyToInstall;
}
bool _shouldShowInlineUpdate(UpdateState state) {
if (state.silent) return false;
return state.status != UpdateStatus.idle;
}
String _updateStatusText(UpdateState state, String appVersion) {
if (!state.silent) {
switch (state.status) {
case UpdateStatus.checking:
return '当前版本 $appVersion,正在检查';
case UpdateStatus.upToDate:
return '当前版本 $appVersion,已是最新版本';
case UpdateStatus.error:
return '当前版本 $appVersion${state.error ?? '检查失败'}';
case UpdateStatus.available:
return '当前版本 $appVersion,发现新版本 ${state.info?.version ?? ''}';
case UpdateStatus.downloading:
return '当前版本 $appVersion,正在下载更新';
case UpdateStatus.readyToInstall:
return '当前版本 $appVersion,正在打开安装程序';
case UpdateStatus.idle:
break;
}
}
return '当前版本 $appVersion';
}
Future<void> _manualCheckUpdate() async {
await ref.read(updateProvider.notifier).manualCheck();
}
Future<void> _openDownloadPage(Uri uri) async {
if (!uri.hasScheme) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<void> _openAccountSheet() async {
final message = await showAccountCenterSheet(context);
if (!mounted || message == null || message.isEmpty) return;
widget.onFeedback(message, tone: AppAlertTone.success);
}
Future<void> _exportBackup(BuildContext context) async {
try {
final location = await getSaveLocation(
suggestedName: _backupFileName(DateTime.now()),
acceptedTypeGroups: const [
XTypeGroup(label: 'JSON', extensions: ['json']),
],
);
if (location == null) return;
final usecase = await ref.read(exportBackupUseCaseProvider.future);
final result = await usecase.execute();
await File(location.path).writeAsString(result.json, flush: true);
if (!context.mounted) return;
widget.onFeedback(
'已导出 ${result.serverCount} 个服务器、${result.sessionCount} 个登录态、'
'${result.danmakuSourceCount} 个弹幕源、'
'${result.scriptWidgetCount} 个模块、${result.scriptWidgetSubscriptionCount} 个订阅。'
'备份文件含登录凭据与模块用户参数,请妥善保管。',
tone: AppAlertTone.success,
);
} catch (e) {
if (!context.mounted) return;
widget.onFeedback('导出失败:${formatUserError(e)}', tone: AppAlertTone.error);
}
}
Future<void> _importBackup(BuildContext context) async {
try {
final file = await openFile(
acceptedTypeGroups: const [
XTypeGroup(label: 'JSON', extensions: ['json']),
],
);
if (file == null) return;
final raw = await File(file.path).readAsString();
final usecase = await ref.read(importBackupUseCaseProvider.future);
final result = await usecase.execute(raw);
await ref.read(serverListProvider.notifier).refresh();
await ref.read(sessionProvider.notifier).refresh();
ref.invalidate(danmakuSettingsProvider);
ref.invalidate(installedScriptWidgetsProvider);
ref.invalidate(scriptWidgetSubscriptionsProvider);
if (!context.mounted) return;
widget.onFeedback(
'已导入 ${result.serverCount} 个服务器、${result.sessionCount} 个登录态、'
'${result.danmakuSourceCount} 个弹幕源、'
'${result.scriptWidgetCount} 个模块、${result.scriptWidgetSubscriptionCount} 个订阅',
tone: AppAlertTone.success,
);
} catch (e) {
if (!context.mounted) return;
widget.onFeedback('导入失败:${formatUserError(e)}', tone: AppAlertTone.error);
}
}
static String _backupFileName(DateTime now) {
String two(int v) => v.toString().padLeft(2, '0');
return 'smplayer-backup-${now.year}${two(now.month)}${two(now.day)}'
'-${two(now.hour)}${two(now.minute)}.json';
}
}
class _InlineUpdatePanel extends StatelessWidget {
final UpdateState state;
final String appVersion;
final VoidCallback onCheckAgain;
final VoidCallback onDownload;
final VoidCallback onCancel;
final ValueChanged<Uri> onOpenDownloadPage;
const _InlineUpdatePanel({
required this.state,
required this.appVersion,
required this.onCheckAgain,
required this.onDownload,
required this.onCancel,
required this.onOpenDownloadPage,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final info = state.info;
final isChecking = state.status == UpdateStatus.checking;
final isUpToDate = state.status == UpdateStatus.upToDate;
final isError = state.status == UpdateStatus.error;
final isDownloading = state.status == UpdateStatus.downloading;
final isReady = state.status == UpdateStatus.readyToInstall;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: Column(
key: ValueKey(state.status),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isChecking)
_StatusLine(
icon: Icons.sync_rounded,
iconColor: tokens.mutedForeground,
text: '正在检查更新...',
)
else if (isUpToDate)
_StatusLine(
icon: Icons.check_circle_outline_rounded,
iconColor: Colors.green,
text: '当前版本 $appVersion 已是最新版本。',
)
else if (isError)
_ErrorBlock(
message: state.error ?? '检查更新失败,请稍后重试',
onRetry: onCheckAgain,
)
else if (info != null) ...[
Text(
'发现新版本 ${info.version}',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'当前版本 $appVersion → 最新版本 ${info.version}',
style: theme.textTheme.bodySmall?.copyWith(
color: tokens.mutedForeground,
),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150),
child: SingleChildScrollView(
child: Text(
info.releaseNotes.trim().isEmpty
? '暂无更新说明'
: info.releaseNotes.trim(),
style: theme.textTheme.bodySmall?.copyWith(height: 1.45),
),
),
),
if (isDownloading) ...[
const SizedBox(height: 16),
FDeterminateProgress(
value: downloadProgressValue(
received: state.received,
total: state.total,
) ?? 0,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${formatDownloadBytes(state.received)} / ${formatDownloadBytes(state.total)}',
style: theme.textTheme.bodySmall?.copyWith(
color: tokens.mutedForeground,
),
),
Text(
formatDownloadSpeed(state.speedBytesPerSec),
style: theme.textTheme.bodySmall?.copyWith(
color: tokens.mutedForeground,
),
),
],
),
],
if (isReady) ...[
const SizedBox(height: 16),
_StatusLine(
icon: Icons.install_desktop_rounded,
iconColor: tokens.mutedForeground,
text: '安装程序已下载,正在打开。',
),
],
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (isDownloading)
OutlinedButton(
onPressed: onCancel,
child: const Text('取消下载'),
)
else if (info.noAssetForPlatform)
FilledButton.tonalIcon(
onPressed: () => onOpenDownloadPage(info.htmlUrl),
icon: const Icon(Icons.open_in_new_rounded, size: 16),
label: const Text('前往下载页'),
)
else
FilledButton.tonalIcon(
onPressed: isReady ? null : onDownload,
icon: const Icon(Icons.download_rounded, size: 16),
label: const Text('下载并安装'),
),
TextButton(
onPressed: isDownloading || isReady ? null : onCheckAgain,
child: const Text('重新检查'),
),
],
),
],
],
),
),
);
}
}
class _StatusLine extends StatelessWidget {
final IconData icon;
final Color iconColor;
final String text;
const _StatusLine({
required this.icon,
required this.iconColor,
required this.text,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: iconColor),
const SizedBox(width: 8),
Expanded(child: Text(text)),
],
);
}
}
class _ErrorBlock extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorBlock({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.error;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline_rounded, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(message, style: TextStyle(color: color)),
),
const SizedBox(width: 8),
TextButton(onPressed: onRetry, child: const Text('重试')),
],
);
}
}
@@ -0,0 +1,283 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/icon_library_provider.dart';
import '../../../shared/constants/breakpoints.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/utils/user_error_formatter.dart';
import '../../../shared/widgets/adaptive_modal.dart';
import '../../../shared/widgets/app_dialog.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../widgets/settings_card.dart';
class IconLibrarySettingsTab extends ConsumerWidget {
const IconLibrarySettingsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const SettingsTabScroll(
children: [
SettingsCard(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: IconLibrarySection(),
),
],
),
],
);
}
}
class IconLibrarySection extends ConsumerWidget {
const IconLibrarySection({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final state = ref.watch(iconLibraryProvider);
return state.when(
data: (data) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'配置图标库 JSON URL,添加/编辑服务器时可从中选择图标',
style: theme.textTheme.bodySmall?.copyWith(
color: tokens.mutedForeground,
),
),
const SizedBox(height: 12),
if (data.entries.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'尚未配置图标库',
style: theme.textTheme.bodySmall?.copyWith(
color: tokens.mutedForeground,
),
),
)
else
...data.entries.map((entry) => IconLibraryRow(entry: entry)),
const SizedBox(height: 8),
Row(
children: [
FilledButton.tonalIcon(
onPressed: () => _showAddDialog(context, ref),
icon: const Icon(Icons.add, size: 16),
label: const Text('添加图标库'),
),
const SizedBox(width: 8),
if (data.entries.isNotEmpty)
TextButton.icon(
onPressed: () =>
ref.read(iconLibraryProvider.notifier).refreshAll(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('刷新全部'),
),
const Spacer(),
Text(
'${data.totalIcons} 个图标',
style: theme.textTheme.bodySmall?.copyWith(
color: tokens.mutedForeground,
),
),
],
),
],
),
loading: () => const SizedBox.shrink(),
error: (e, _) => settingsLoadError(e),
);
}
void _showAddDialog(BuildContext context, WidgetRef ref) {
final controller = TextEditingController();
void submit(BuildContext ctx) {
final url = controller.text.trim();
if (url.isEmpty) return;
ref.read(iconLibraryProvider.notifier).addUrl(url);
Navigator.pop(ctx);
}
showAdaptiveModal<void>(
context: context,
maxWidth: 420,
compactMaxHeightFactor: 0.9,
builder: (ctx) {
final theme = Theme.of(ctx);
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(ctx).width);
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(24, isCompact ? 16 : 24, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('添加图标库', style: theme.textTheme.titleLarge),
const SizedBox(height: 8),
Text(
'输入图标库 JSON URL',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onSubmitted: (_) => submit(ctx),
decoration: const InputDecoration(
hintText: 'https://example.com/icons.json',
isDense: true,
),
),
const SizedBox(height: 20),
if (isCompact) ...[
SizedBox(
height: 48,
child: FilledButton(
onPressed: () => submit(ctx),
child: const Text('添加'),
),
),
const SizedBox(height: 8),
SizedBox(
height: 44,
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
),
] else
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => submit(ctx),
child: const Text('添加'),
),
],
),
],
),
);
},
).whenComplete(controller.dispose);
}
}
class IconLibraryRow extends ConsumerWidget {
final IconLibraryEntryState entry;
const IconLibraryRow({super.key, required this.entry});
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final notifier = ref.read(iconLibraryProvider.notifier);
final lib = entry.library;
final title = lib?.name.isNotEmpty == true ? lib!.name : entry.url;
final (statusText, statusColor) = switch (entry.status) {
IconLibraryStatus.loading => ('加载中…', tokens.mutedForeground),
IconLibraryStatus.loaded => (
'${entry.iconCount} 个图标',
tokens.mutedForeground,
),
IconLibraryStatus.error => (
'加载失败:${formatUserError(entry.error, fallback: "未知错误")}',
theme.colorScheme.error,
),
IconLibraryStatus.idle => ('未加载', tokens.mutedForeground),
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 2),
if (lib?.name.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
entry.url,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: tokens.mutedForeground,
),
),
),
Text(
statusText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: statusColor,
),
),
],
),
),
if (entry.status == IconLibraryStatus.loading)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: AppLoadingRing(size: 14),
)
else
IconButton(
tooltip: '刷新',
icon: const Icon(Icons.refresh, size: 18),
onPressed: () => notifier.refresh(entry.url),
),
IconButton(
tooltip: '移除',
icon: const Icon(Icons.delete_outline, size: 18),
onPressed: () => _confirmRemove(context, notifier, entry.url),
),
],
),
);
}
Future<void> _confirmRemove(
BuildContext context,
IconLibrarySettingsNotifier notifier,
String url,
) async {
final confirmed = await showAppConfirmDialog(
context,
title: '移除图标库',
body: Text('确定移除「$url」?已选用此库图标的服务器将回退为首字母显示。'),
confirmLabel: '移除',
destructive: true,
);
if (confirmed) await notifier.removeUrl(url);
}
}
@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import '../../modules/modules_home_page.dart';
class ModulesSettingsTab extends StatelessWidget {
const ModulesSettingsTab({super.key});
@override
Widget build(BuildContext context) {
return const ModuleCenterContent(embedded: true);
}
}
@@ -0,0 +1,194 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/player_engine_override_provider.dart';
import '../../../providers/player_settings_provider.dart';
import '../../../providers/version_priority_provider.dart';
import '../../player/gestures/gesture_action.dart' as g;
import '../widgets/player_engine_row.dart';
import '../widgets/settings_card.dart';
import '../widgets/settings_rows.dart';
import '../widgets/version_priority_section.dart';
class PlaybackSettingsTab extends ConsumerWidget {
const PlaybackSettingsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final playerSettings = ref.watch(playerSettingsProvider);
final versionPriority = ref.watch(versionPriorityProvider);
final engineOverride = ref.watch(playerEngineOverrideProvider);
final theme = Theme.of(context);
return SettingsTabScroll(
children: [
engineOverride.when(
data: (current) => SettingsCard(
children: [
PlayerEngineRow(
value: current,
onChanged: (v) => ref
.read(playerEngineOverrideProvider.notifier)
.setOverride(v),
),
],
),
loading: () => const SizedBox.shrink(),
error: (e, _) => settingsLoadError(e),
),
playerSettings.when(
data: (data) => Column(
children: [
SettingsCard(
children: [
SeekDurationRow(
title: '快退时间',
seconds: data.seekBackwardSeconds,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setSeekBackwardSeconds(v),
),
SeekDurationRow(
title: '快进时间',
seconds: data.seekForwardSeconds,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setSeekForwardSeconds(v),
),
],
),
if (Platform.isAndroid)
SettingsCard(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Text(
'触控',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
GestureSpeedRow(
title: '长按左侧倍速',
value: data.keyboardHoldLeftSpeed,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setKeyboardHoldLeftSpeed(v),
),
GestureSpeedRow(
title: '长按右侧倍速',
value: data.keyboardHoldRightSpeed,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setKeyboardHoldRightSpeed(v),
),
],
)
else ...[
SettingsCard(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Text(
'键盘',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
GestureSpeedRow(
title: '长按左方向键倍速',
value: data.keyboardHoldLeftSpeed,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setKeyboardHoldLeftSpeed(v),
),
GestureSpeedRow(
title: '长按右方向键倍速',
value: data.keyboardHoldRightSpeed,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setKeyboardHoldRightSpeed(v),
),
],
),
SettingsCard(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Text(
'鼠标',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
GestureActionRow(
title: '左键单击',
kind: g.GestureKind.leftClick,
value: data.mouseLeftClickAction,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setMouseLeftClickAction(v),
),
GestureActionRow(
title: '左键双击',
kind: g.GestureKind.leftDoubleClick,
value: data.mouseLeftDoubleClickAction,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setMouseLeftDoubleClickAction(v),
),
GestureActionRow(
title: '右键单击',
kind: g.GestureKind.rightClick,
value: data.mouseRightClickAction,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setMouseRightClickAction(v),
),
GestureActionRow(
title: '右键双击',
kind: g.GestureKind.rightDoubleClick,
value: data.mouseRightDoubleClickAction,
onChanged: (v) => ref
.read(playerSettingsProvider.notifier)
.setMouseRightDoubleClickAction(v),
),
],
),
],
],
),
loading: () => const SizedBox.shrink(),
error: (e, _) => settingsLoadError(e),
),
versionPriority.when(
data: (data) => SettingsCard(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: VersionPrioritySection(
activePriorities: data.activePriorities,
onToggle: (p) =>
ref.read(versionPriorityProvider.notifier).toggle(p),
onReorder: (list) => ref
.read(versionPriorityProvider.notifier)
.setPriorities(list),
),
),
],
),
loading: () => const SizedBox.shrink(),
error: (e, _) => settingsLoadError(e),
),
],
);
}
}
@@ -0,0 +1,383 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/domain/ports/tmdb_gateway.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/sm_account_provider.dart';
import '../../../providers/tmdb_settings_provider.dart';
import '../../../shared/widgets/app_inline_alert.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../../../shared/widgets/setting_select.dart';
import '../utils/settings_labels.dart';
import '../widgets/settings_card.dart';
import '../widgets/settings_text_field_row.dart';
class TmdbSettingsTab extends ConsumerWidget {
final void Function(String message, {AppAlertTone tone}) onFeedback;
const TmdbSettingsTab({super.key, required this.onFeedback});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tmdb = ref.watch(tmdbSettingsProvider);
return SettingsTabScroll(
children: [
tmdb.when(
data: (data) =>
TmdbSettingsSection(state: data, onFeedback: onFeedback),
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing()),
),
error: (e, _) => SettingsCard(children: [settingsLoadError(e)]),
),
],
);
}
}
class TmdbSettingsSection extends ConsumerStatefulWidget {
final TmdbSettingsState state;
final void Function(String message, {AppAlertTone tone}) onFeedback;
const TmdbSettingsSection({
super.key,
required this.state,
required this.onFeedback,
});
@override
ConsumerState<TmdbSettingsSection> createState() =>
TmdbSettingsSectionState();
}
class TmdbSettingsSectionState extends ConsumerState<TmdbSettingsSection> {
late final TextEditingController _apiKeyCtrl;
late final TextEditingController _accessTokenCtrl;
final _apiKeyFocus = FocusNode();
final _accessTokenFocus = FocusNode();
bool _testingConnection = false;
@override
void initState() {
super.initState();
_apiKeyCtrl = TextEditingController(text: widget.state.apiKey);
_accessTokenCtrl = TextEditingController(text: widget.state.accessToken);
}
@override
void didUpdateWidget(covariant TmdbSettingsSection oldWidget) {
super.didUpdateWidget(oldWidget);
if (!_apiKeyFocus.hasFocus && _apiKeyCtrl.text != widget.state.apiKey) {
_apiKeyCtrl.text = widget.state.apiKey;
}
if (!_accessTokenFocus.hasFocus &&
_accessTokenCtrl.text != widget.state.accessToken) {
_accessTokenCtrl.text = widget.state.accessToken;
}
}
@override
void dispose() {
_apiKeyCtrl.dispose();
_accessTokenCtrl.dispose();
_apiKeyFocus.dispose();
_accessTokenFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.state;
final account = ref.watch(smAccountProvider).value;
final accountStatus = account?.status ?? SmAuthStatus.unauthenticated;
final canRequest = s.canRequestWithAccount(
dataPlaneReady: account?.canUseDataPlane ?? false,
);
final String statusSubtitle;
if (!s.enabled) {
statusSubtitle = '关闭时不请求 TMDB 数据';
} else if (s.accessMode == TmdbAccessMode.smAccount) {
statusSubtitle = switch (accountStatus) {
SmAuthStatus.authenticated => '已启用(内置代理),使用账号 ${account!.email}',
SmAuthStatus.restoring => '已启用(内置代理),正在恢复登录…',
SmAuthStatus.expired => '已启用(内置代理),登录已过期,请重新登录',
SmAuthStatus.degraded => '已启用(内置代理),账号凭据不完整,请重新登录',
SmAuthStatus.unauthenticated => '已启用(内置代理),需登录 sm-server 账号',
};
} else {
statusSubtitle = canRequest ? '已启用,媒体详情和发现页会请求 TMDB 数据' : '已启用,请补全认证信息';
}
return Column(
children: [
SettingsCard(
children: [
SettingRow(
title: '启用 TMDB',
subtitle: statusSubtitle,
trailing: FSwitch(
value: s.enabled,
onChange: (v) =>
ref.read(tmdbSettingsProvider.notifier).setEnabled(v),
),
),
if (s.enabled)
SettingRow(
title: '接入方式',
subtitle: s.accessMode == TmdbAccessMode.smAccount
? null
: '数据接口:${TmdbSettingsState.officialApiBaseUrl}',
trailing: SettingSelect<TmdbAccessMode>(
value: s.accessMode,
options: TmdbAccessMode.values,
labelOf: tmdbAccessModeLabel,
onChanged: (v) {
if (v != null) {
ref.read(tmdbSettingsProvider.notifier).setAccessMode(v);
}
},
),
),
],
),
if (s.enabled && s.accessMode == TmdbAccessMode.smAccount) ...[
_SmAccountTmdbSection(
accountState: account,
language: s.language,
onFeedback: widget.onFeedback,
onTestConnection: _testConnectionSmAccount,
testingConnection: _testingConnection,
),
_LanguageCard(language: s.language),
],
if (s.enabled && s.accessMode == TmdbAccessMode.official) ...[
SettingsCard(
children: [
SettingRow(
title: '认证方式',
trailing: SettingSelect<TmdbAuthMode>(
value: s.authMode,
options: TmdbAuthMode.values,
labelOf: tmdbAuthModeLabel,
onChanged: (v) {
if (v != null) {
ref.read(tmdbSettingsProvider.notifier).setAuthMode(v);
}
},
),
),
if (s.authMode == TmdbAuthMode.apiKey)
SettingsTextFieldRow(
title: 'API Key',
hintText: '粘贴 TMDB API Key',
controller: _apiKeyCtrl,
focusNode: _apiKeyFocus,
onChanged: (v) =>
ref.read(tmdbSettingsProvider.notifier).setApiKey(v),
)
else
SettingsTextFieldRow(
title: 'Bearer Token',
hintText: '粘贴 TMDB Read Access Token',
controller: _accessTokenCtrl,
focusNode: _accessTokenFocus,
obscureText: true,
onChanged: (v) =>
ref.read(tmdbSettingsProvider.notifier).setAccessToken(v),
),
_LanguageRow(language: s.language),
],
),
SettingsCard(
children: [
SettingRow(
title: '配置状态',
subtitle: s.isConfigured ? '认证信息已填写' : '请填写当前认证方式对应的密钥',
trailing: Icon(
s.canRequest ? Icons.check_circle : Icons.info_outline,
size: 18,
color: s.canRequest
? const Color(0xFF52C41A)
: const Color(0xFFFAAD14),
),
),
SettingRow(
title: '测试连接',
subtitle: '请求 TMDB /configuration 验证认证和网络',
trailing: FilledButton.tonalIcon(
onPressed: s.isConfigured && !_testingConnection
? _testConnectionOfficial
: null,
icon: _testingConnection
? const AppLoadingRing(size: 14)
: const Icon(Icons.network_check_rounded, size: 16),
label: const Text('测试'),
),
),
],
),
],
],
);
}
Future<void> _testConnectionOfficial() async {
final s = widget.state;
final accessToken = _accessTokenCtrl.text.trim();
final apiKey = _apiKeyCtrl.text.trim();
final hasCredential = switch (s.authMode) {
TmdbAuthMode.bearer => accessToken.isNotEmpty,
TmdbAuthMode.apiKey => apiKey.isNotEmpty,
};
if (!hasCredential) {
widget.onFeedback('请先填写当前认证方式对应的密钥', tone: AppAlertTone.warning);
return;
}
setState(() => _testingConnection = true);
final ok = await ref
.read(tmdbGatewayProvider)
.testConnection(
TmdbRequestConfig(
apiBaseUrl: TmdbSettingsState.officialApiBaseUrl,
imageBaseUrl: TmdbSettingsState.officialImageBaseUrl,
accessToken: s.authMode == TmdbAuthMode.bearer ? accessToken : '',
apiKey: s.authMode == TmdbAuthMode.apiKey ? apiKey : '',
),
language: s.language,
);
if (!mounted) return;
setState(() => _testingConnection = false);
widget.onFeedback(
ok ? 'TMDB 连接测试成功' : 'TMDB 连接测试失败,请检查密钥或网络',
tone: ok ? AppAlertTone.success : AppAlertTone.error,
);
}
Future<void> _testConnectionSmAccount() async {
setState(() => _testingConnection = true);
final token = await ref
.read(smAccountProvider.notifier)
.getAccessToken(force: true);
if (!mounted) return;
if (token == null) {
setState(() => _testingConnection = false);
widget.onFeedback('无法获取访问凭据,请重新登录账号', tone: AppAlertTone.error);
return;
}
final account = ref.read(smAccountProvider).value;
final accountState = account ?? const SmAccountState();
final ok = await ref
.read(tmdbGatewayProvider)
.testConnection(
TmdbRequestConfig(
apiBaseUrl: accountState.tmdbApiBaseUrl,
imageBaseUrl: TmdbSettingsState.officialImageBaseUrl,
accessToken: token,
apiKey: '',
),
language: widget.state.language,
);
if (!mounted) return;
setState(() => _testingConnection = false);
widget.onFeedback(
ok ? 'TMDB 内置代理测试成功' : 'TMDB 内置代理测试失败,请检查账号或网络',
tone: ok ? AppAlertTone.success : AppAlertTone.error,
);
}
}
class _SmAccountTmdbSection extends ConsumerWidget {
final SmAccountState? accountState;
final String language;
final void Function(String message, {AppAlertTone tone}) onFeedback;
final VoidCallback onTestConnection;
final bool testingConnection;
const _SmAccountTmdbSection({
required this.accountState,
required this.language,
required this.onFeedback,
required this.onTestConnection,
required this.testingConnection,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataPlaneReady = accountState?.canUseDataPlane == true;
return SettingsCard(
children: [
SettingRow(
title: '测试连接',
subtitle: '验证当前账号凭据与 TMDB 代理可达性',
trailing: FilledButton.tonalIcon(
onPressed: dataPlaneReady && !testingConnection
? onTestConnection
: null,
icon: testingConnection
? const AppLoadingRing(size: 14)
: const Icon(Icons.network_check_rounded, size: 16),
label: const Text('测试'),
),
),
],
);
}
}
class _LanguageRow extends ConsumerWidget {
final String language;
const _LanguageRow({required this.language});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SettingRow(
title: '语言',
trailing: _LanguageSelect(language: language),
);
}
}
class _LanguageCard extends ConsumerWidget {
final String language;
const _LanguageCard({required this.language});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SettingsCard(
children: [
SettingRow(
title: '语言',
trailing: _LanguageSelect(language: language),
),
],
);
}
}
class _LanguageSelect extends ConsumerWidget {
final String language;
const _LanguageSelect({required this.language});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SettingSelect<String>(
value: language,
options: const ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'ko-KR'],
labelOf: tmdbLanguageLabel,
onChanged: (v) {
if (v != null) {
ref.read(tmdbSettingsProvider.notifier).setLanguage(v);
}
},
);
}
}
@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/trakt_scrobble_status_provider.dart';
import '../../../providers/trakt_settings_provider.dart';
import '../../../shared/widgets/app_inline_alert.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../widgets/settings_card.dart';
class TraktSettingsTab extends ConsumerWidget {
final void Function(String message, {AppAlertTone tone}) onFeedback;
const TraktSettingsTab({super.key, required this.onFeedback});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trakt = ref.watch(traktSettingsProvider);
return SettingsTabScroll(
children: [
trakt.when(
data: (data) => TraktSection(state: data, onFeedback: onFeedback),
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing()),
),
error: (e, _) => SettingsCard(children: [settingsLoadError(e)]),
),
],
);
}
}
class TraktSection extends ConsumerStatefulWidget {
final TraktState state;
final void Function(String message, {AppAlertTone tone}) onFeedback;
const TraktSection({
super.key,
required this.state,
required this.onFeedback,
});
@override
ConsumerState<TraktSection> createState() => TraktSectionState();
}
class TraktSectionState extends ConsumerState<TraktSection> {
bool _busy = false;
@override
Widget build(BuildContext context) {
final s = widget.state;
if (s.isConnected) {
return SettingsCard(
children: [
SettingRow(
title: '已连接',
subtitle: s.username.isEmpty ? 'Trakt 账号' : '@${s.username}',
trailing: const Icon(
Icons.check_circle,
size: 18,
color: Color(0xFF52C41A),
),
),
SettingRow(
title: '断开连接',
subtitle: '清除本地保存的 Trakt 令牌',
trailing: _busy
? const AppLoadingRing(size: 18)
: const Icon(Icons.logout, size: 18),
onTap: _busy ? null : _disconnect,
),
],
);
}
return SettingsCard(
children: [
SettingRow(
title: '连接 Trakt',
subtitle: '通过浏览器授权你的 Trakt 账号',
trailing: _busy
? const AppLoadingRing(size: 18)
: const Icon(Icons.open_in_new, size: 18),
onTap: _busy ? null : _connect,
),
],
);
}
Future<void> _connect() async {
setState(() => _busy = true);
String? message;
var tone = AppAlertTone.success;
try {
await ref.read(traktSettingsProvider.notifier).connect();
message = '已连接 Trakt';
unawaited(ref.read(traktScrobbleStatusProvider.notifier).retryPending());
} on TraktConnectException catch (e) {
message = e.message;
tone = AppAlertTone.error;
} catch (_) {
message = '连接失败';
tone = AppAlertTone.error;
}
if (!mounted) return;
setState(() => _busy = false);
_toast(message, tone: tone);
}
Future<void> _disconnect() async {
setState(() => _busy = true);
await ref.read(traktSettingsProvider.notifier).disconnect();
if (!mounted) return;
setState(() => _busy = false);
_toast('已断开 Trakt', tone: AppAlertTone.success);
}
void _toast(String message, {AppAlertTone tone = AppAlertTone.info}) {
widget.onFeedback(message, tone: tone);
}
}