Initial commit
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers/appearance_provider.dart';
|
||||
import '../../providers/sm_account_provider.dart';
|
||||
import '../../shared/app_info.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/widgets/app_card.dart';
|
||||
import '../../shared/widgets/app_sliver_header.dart';
|
||||
import '../../shared/widgets/page_background.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'settings_tab_key.dart';
|
||||
|
||||
class SettingsFullPage extends ConsumerWidget {
|
||||
const SettingsFullPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final account = ref.watch(smAccountProvider).value;
|
||||
final appVersion =
|
||||
ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback;
|
||||
final appearance = ref.watch(appearanceProvider).value;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
const Positioned.fill(child: PageBackground()),
|
||||
CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
slivers: [
|
||||
AppSliverHeader(
|
||||
title: '设置',
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.15),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
appVersion,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate.fixed([
|
||||
_SmAccountBanner(
|
||||
account: account,
|
||||
onTap: () => context.push('/account-center'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _SectionTitle('常规设置'),
|
||||
_SettingsGroupCard(
|
||||
children: [
|
||||
_SettingsItem(
|
||||
icon: Icons.palette_outlined,
|
||||
color: const Color(0xFF8B5CF6),
|
||||
title: '主题',
|
||||
trailing: _ThemeModeSelector(
|
||||
currentMode: appearance?.themeMode,
|
||||
onChanged: (mode) => ref
|
||||
.read(appearanceProvider.notifier)
|
||||
.setThemeMode(mode),
|
||||
),
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Icons.settings_outlined,
|
||||
color: const Color(0xFF6B7280),
|
||||
title: '通用',
|
||||
onTap: () => showSettingsSectionSheet(
|
||||
context,
|
||||
initialTab: kSettingsTabGeneral,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _SectionTitle('播放设置'),
|
||||
_SettingsGroupCard(
|
||||
children: [
|
||||
_SettingsItem(
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
color: const Color(0xFF22C55E),
|
||||
title: '播放',
|
||||
onTap: () => showSettingsSectionSheet(
|
||||
context,
|
||||
initialTab: kSettingsTabPlayback,
|
||||
),
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Icons.headphones_outlined,
|
||||
color: const Color(0xFFA855F7),
|
||||
title: '音频',
|
||||
onTap: () => showSettingsSectionSheet(
|
||||
context,
|
||||
initialTab: kSettingsTabAudio,
|
||||
),
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
color: const Color(0xFFEF4444),
|
||||
title: '弹幕',
|
||||
onTap: () => showSettingsSectionSheet(
|
||||
context,
|
||||
initialTab: kSettingsTabDanmaku,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const _SectionTitle('扩展服务'),
|
||||
_SettingsGroupCard(
|
||||
children: [
|
||||
_SettingsItem(
|
||||
icon: Icons.extension_rounded,
|
||||
color: const Color(0xFF6366F1),
|
||||
title: '模块中心',
|
||||
onTap: () => showSettingsSectionSheet(
|
||||
context,
|
||||
initialTab: kSettingsTabModules,
|
||||
),
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Icons.movie_filter_outlined,
|
||||
color: const Color(0xFF0EA5E9),
|
||||
title: 'TMDB',
|
||||
onTap: () => showSettingsSectionSheet(
|
||||
context,
|
||||
initialTab: kSettingsTabTmdb,
|
||||
),
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Icons.link_outlined,
|
||||
color: const Color(0xFFEF4444),
|
||||
title: 'Trakt',
|
||||
onTap: () => showSettingsSectionSheet(
|
||||
context,
|
||||
initialTab: kSettingsTabTrakt,
|
||||
),
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Icons.palette_outlined,
|
||||
color: const Color(0xFFF59E0B),
|
||||
title: '图标库',
|
||||
onTap: () => showSettingsSectionSheet(
|
||||
context,
|
||||
initialTab: kSettingsTabIconLibrary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmAccountBanner extends StatelessWidget {
|
||||
final SmAccountState? account;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SmAccountBanner({required this.account, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final loggedIn = account?.status == SmAuthStatus.authenticated;
|
||||
final email = account?.email ?? '';
|
||||
final isVip = account?.isVip ?? false;
|
||||
|
||||
final title = loggedIn
|
||||
? (email.isNotEmpty ? email : 'Player 账号')
|
||||
: '登录 Player 账号';
|
||||
final subtitle = loggedIn ? (isVip ? 'VIP 会员' : '已登录') : '登录以使用云同步等扩展服务';
|
||||
|
||||
return AppCard(
|
||||
onTap: onTap,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: loggedIn
|
||||
? (isVip
|
||||
? const Color(0xFFFAAD14).withValues(alpha: 0.15)
|
||||
: theme.colorScheme.primary.withValues(alpha: 0.12))
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
loggedIn
|
||||
? (isVip
|
||||
? Icons.workspace_premium_rounded
|
||||
: Icons.person_rounded)
|
||||
: Icons.person_outline_rounded,
|
||||
size: 24,
|
||||
color: loggedIn
|
||||
? (isVip
|
||||
? const Color(0xFFFAAD14)
|
||||
: theme.colorScheme.primary)
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isVip)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAAD14),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'VIP',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const _SectionTitle(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: context.appTokens.mutedForeground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsGroupCard extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const _SettingsGroupCard({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 56,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.08),
|
||||
),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SettingsItem({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
child: Icon(icon, size: 18, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [Text(title, style: theme.textTheme.bodyMedium)],
|
||||
),
|
||||
),
|
||||
if (trailing != null)
|
||||
trailing!
|
||||
else if (onTap != null)
|
||||
const Icon(Icons.chevron_right_rounded, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeModeSelector extends StatelessWidget {
|
||||
final ThemeMode? currentMode;
|
||||
final ValueChanged<ThemeMode> onChanged;
|
||||
|
||||
const _ThemeModeSelector({
|
||||
required this.currentMode,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mode = currentMode ?? ThemeMode.system;
|
||||
|
||||
Widget chip(ThemeMode m, IconData icon) {
|
||||
final selected = mode == m;
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(m),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.15)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: selected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
chip(ThemeMode.light, Icons.light_mode_outlined),
|
||||
chip(ThemeMode.system, Icons.settings_outlined),
|
||||
chip(ThemeMode.dark, Icons.dark_mode_outlined),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../shared/widgets/adaptive_modal.dart';
|
||||
import '../../shared/widgets/app_inline_alert.dart';
|
||||
import '../../shared/widgets/app_snack_bar.dart';
|
||||
import 'settings_tab_key.dart';
|
||||
import 'tabs/audio_settings_tab.dart';
|
||||
import 'tabs/danmaku_settings_tab.dart';
|
||||
import 'tabs/general_settings_tab.dart';
|
||||
import 'tabs/icon_library_settings_tab.dart';
|
||||
import 'tabs/modules_settings_tab.dart';
|
||||
import 'tabs/playback_settings_tab.dart';
|
||||
import 'tabs/tmdb_settings_tab.dart';
|
||||
import 'tabs/trakt_settings_tab.dart';
|
||||
|
||||
const _kSettingsTabMinWidth = 96.0;
|
||||
|
||||
void showSettingsDialog(BuildContext context, {String? initialTab}) {
|
||||
showAdaptiveModal<void>(
|
||||
context: context,
|
||||
maxWidth: 780,
|
||||
maxHeight: 620,
|
||||
compactHeightFactor: 0.92,
|
||||
builder: (_) => SettingsPage(initialTab: initialTab),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> showSettingsSectionSheet(
|
||||
BuildContext context, {
|
||||
required String initialTab,
|
||||
}) {
|
||||
return showAdaptiveModal<void>(
|
||||
context: context,
|
||||
maxWidth: 620,
|
||||
compactHeightFactor: 0.9,
|
||||
builder: (sheetContext) => _SettingsSectionSheet(tabKey: initialTab),
|
||||
);
|
||||
}
|
||||
|
||||
class SettingsPage extends ConsumerStatefulWidget {
|
||||
final String? initialTab;
|
||||
|
||||
const SettingsPage({super.key, this.initialTab});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
int _tabIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabIndex = settingsTabIndexFromKey(widget.initialTab);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SettingsPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialTab != oldWidget.initialTab) {
|
||||
final nextTabIndex = settingsTabIndexFromKey(widget.initialTab);
|
||||
if (nextTabIndex != _tabIndex) {
|
||||
setState(() {
|
||||
_tabIndex = nextTabIndex;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showFeedback(String message, {AppAlertTone tone = AppAlertTone.info}) {
|
||||
showAppToast(context, message, tone: tone);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final settingsBg = isDark
|
||||
? const Color(0xFF1C1C1E)
|
||||
: const Color(0xFFF9FAFB);
|
||||
|
||||
return Container(
|
||||
color: settingsBg,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(32, 20, 32, 12),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'设置',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scrollable =
|
||||
constraints.maxWidth <
|
||||
settingsTabData.length * _kSettingsTabMinWidth;
|
||||
return FTabs(
|
||||
scrollable: scrollable,
|
||||
expands: true,
|
||||
control: FTabControl.lifted(
|
||||
index: _tabIndex,
|
||||
onChange: (index) {
|
||||
if (index == _tabIndex) return;
|
||||
setState(() => _tabIndex = index);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
FTabEntry(
|
||||
label: _tabLabel(0, scrollable: scrollable),
|
||||
child: GeneralSettingsTab(onFeedback: _showFeedback),
|
||||
),
|
||||
FTabEntry(
|
||||
label: _tabLabel(1, scrollable: scrollable),
|
||||
child: const PlaybackSettingsTab(),
|
||||
),
|
||||
FTabEntry(
|
||||
label: _tabLabel(2, scrollable: scrollable),
|
||||
child: const AudioSettingsTab(),
|
||||
),
|
||||
FTabEntry(
|
||||
label: _tabLabel(3, scrollable: scrollable),
|
||||
child: const DanmakuSettingsTab(),
|
||||
),
|
||||
FTabEntry(
|
||||
label: _tabLabel(4, scrollable: scrollable),
|
||||
child: const ModulesSettingsTab(),
|
||||
),
|
||||
FTabEntry(
|
||||
label: _tabLabel(5, scrollable: scrollable),
|
||||
child: TmdbSettingsTab(onFeedback: _showFeedback),
|
||||
),
|
||||
FTabEntry(
|
||||
label: _tabLabel(6, scrollable: scrollable),
|
||||
child: TraktSettingsTab(onFeedback: _showFeedback),
|
||||
),
|
||||
FTabEntry(
|
||||
label: _tabLabel(7, scrollable: scrollable),
|
||||
child: const IconLibrarySettingsTab(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabLabel(int index, {required bool scrollable}) {
|
||||
final tab = settingsTabData[index];
|
||||
final label = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(tab.icon, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(tab.label),
|
||||
],
|
||||
);
|
||||
return scrollable
|
||||
? SizedBox(width: _kSettingsTabMinWidth, child: label)
|
||||
: label;
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsSectionSheet extends ConsumerWidget {
|
||||
final String tabKey;
|
||||
|
||||
const _SettingsSectionSheet({required this.tabKey});
|
||||
|
||||
void _showFeedback(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
AppAlertTone tone = AppAlertTone.info,
|
||||
}) {
|
||||
showAppToast(context, message, tone: tone);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
void feedback(String msg, {AppAlertTone tone = AppAlertTone.info}) =>
|
||||
_showFeedback(context, msg, tone: tone);
|
||||
|
||||
final (title, body) = _resolveSection(context, feedback);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Flexible(child: body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(String, Widget) _resolveSection(
|
||||
BuildContext context,
|
||||
void Function(String message, {AppAlertTone tone}) feedback,
|
||||
) {
|
||||
return switch (tabKey) {
|
||||
kSettingsTabGeneral => ('通用', GeneralSettingsTab(onFeedback: feedback)),
|
||||
kSettingsTabPlayback || kSettingsTabGestures => (
|
||||
'播放',
|
||||
const PlaybackSettingsTab(),
|
||||
),
|
||||
kSettingsTabAudio => ('音频', const AudioSettingsTab()),
|
||||
kSettingsTabDanmaku => ('弹幕', const DanmakuSettingsTab()),
|
||||
kSettingsTabModules => ('模块', const ModulesSettingsTab()),
|
||||
kSettingsTabTmdb => ('TMDB', TmdbSettingsTab(onFeedback: feedback)),
|
||||
kSettingsTabTrakt => ('Trakt', TraktSettingsTab(onFeedback: feedback)),
|
||||
kSettingsTabIconLibrary => ('图标库', const IconLibrarySettingsTab()),
|
||||
_ => ('设置', GeneralSettingsTab(onFeedback: feedback)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const kSettingsTabGeneral = 'general';
|
||||
const kSettingsTabPlayback = 'playback';
|
||||
const kSettingsTabGestures = 'gestures';
|
||||
const kSettingsTabAudio = 'audio';
|
||||
const kSettingsTabDanmaku = 'danmaku';
|
||||
const kSettingsTabModules = 'modules';
|
||||
const kSettingsTabTmdb = 'tmdb';
|
||||
const kSettingsTabTrakt = 'trakt';
|
||||
const kSettingsTabIconLibrary = 'icon-library';
|
||||
|
||||
typedef SettingsTabData = ({String label, IconData icon});
|
||||
|
||||
const settingsTabData = <SettingsTabData>[
|
||||
(label: '通用', icon: Icons.tune_outlined),
|
||||
(label: '播放', icon: Icons.play_circle_outline_rounded),
|
||||
(label: '音频', icon: Icons.headphones_outlined),
|
||||
(label: '弹幕', icon: Icons.subtitles_outlined),
|
||||
(label: '模块', icon: Icons.extension_rounded),
|
||||
(label: 'TMDB', icon: Icons.movie_filter_outlined),
|
||||
(label: 'Trakt', icon: Icons.link_outlined),
|
||||
(label: '图标库', icon: Icons.palette_outlined),
|
||||
];
|
||||
|
||||
int settingsTabIndexFromKey(String? key) => switch (key) {
|
||||
kSettingsTabGeneral => 0,
|
||||
kSettingsTabPlayback => 1,
|
||||
kSettingsTabGestures => 1,
|
||||
kSettingsTabAudio => 2,
|
||||
kSettingsTabDanmaku => 3,
|
||||
kSettingsTabModules => 4,
|
||||
kSettingsTabTmdb => 5,
|
||||
kSettingsTabTrakt => 6,
|
||||
kSettingsTabIconLibrary => 7,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
String? settingsTabKeyFromIndex(int index) => switch (index) {
|
||||
0 => kSettingsTabGeneral,
|
||||
1 => kSettingsTabPlayback,
|
||||
2 => kSettingsTabAudio,
|
||||
3 => kSettingsTabDanmaku,
|
||||
4 => kSettingsTabModules,
|
||||
5 => kSettingsTabTmdb,
|
||||
6 => kSettingsTabTrakt,
|
||||
7 => kSettingsTabIconLibrary,
|
||||
_ => null,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../providers/danmaku_settings_provider.dart';
|
||||
import '../../../providers/appearance_provider.dart';
|
||||
import '../../../providers/tmdb_settings_provider.dart';
|
||||
|
||||
String formatMenuSpeed(double v) {
|
||||
return '${v.toStringAsFixed(2)}X';
|
||||
}
|
||||
|
||||
String themeModeLabel(ThemeMode mode) => switch (mode) {
|
||||
ThemeMode.system => '跟随系统',
|
||||
ThemeMode.light => '亮色',
|
||||
ThemeMode.dark => '暗色',
|
||||
};
|
||||
|
||||
String headerModeLabel(HeaderMode mode) => switch (mode) {
|
||||
HeaderMode.solid => '纯色',
|
||||
HeaderMode.frosted => '磨砂',
|
||||
};
|
||||
|
||||
String startupPageLabel(StartupPage page) => switch (page) {
|
||||
StartupPage.home => '首页',
|
||||
StartupPage.servers => '服务器',
|
||||
StartupPage.aggregated => '聚合视界',
|
||||
StartupPage.discover => '发现',
|
||||
StartupPage.settings => '设置',
|
||||
};
|
||||
|
||||
String audioLangLabel(String lang) => switch (lang) {
|
||||
'chi' => '中文',
|
||||
'eng' => '英语',
|
||||
'jpn' => '日语',
|
||||
_ => '默认',
|
||||
};
|
||||
|
||||
String chineseConvertLabel(ChineseConvertMode mode) => switch (mode) {
|
||||
ChineseConvertMode.none => '不转换',
|
||||
ChineseConvertMode.toSimplified => '转简体',
|
||||
ChineseConvertMode.toTraditional => '转繁体',
|
||||
};
|
||||
|
||||
String tmdbAccessModeLabel(TmdbAccessMode mode) => switch (mode) {
|
||||
TmdbAccessMode.official => '官方 TMDB',
|
||||
TmdbAccessMode.smAccount => '内置代理(sm-server)',
|
||||
};
|
||||
|
||||
String tmdbAuthModeLabel(TmdbAuthMode mode) => switch (mode) {
|
||||
TmdbAuthMode.apiKey => 'API Key',
|
||||
TmdbAuthMode.bearer => 'Bearer Token',
|
||||
};
|
||||
|
||||
String tmdbLanguageLabel(String lang) => switch (lang) {
|
||||
'zh-CN' => '简体中文',
|
||||
'zh-TW' => '繁体中文',
|
||||
'en-US' => '英语',
|
||||
'ja-JP' => '日语',
|
||||
'ko-KR' => '韩语',
|
||||
_ => lang,
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../providers/danmaku_settings_provider.dart';
|
||||
import '../../../shared/constants/breakpoints.dart';
|
||||
import '../../../shared/widgets/adaptive_modal.dart';
|
||||
|
||||
void showBlockedKeywordsDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<String> keywords,
|
||||
) {
|
||||
showAdaptiveModal<void>(
|
||||
context: context,
|
||||
maxWidth: 420,
|
||||
compactMaxHeightFactor: 0.9,
|
||||
builder: (ctx) => _BlockedKeywordsDialog(
|
||||
keywords: keywords,
|
||||
onAdd: (kw) =>
|
||||
ref.read(danmakuSettingsProvider.notifier).addBlockedKeyword(kw),
|
||||
onRemove: (kw) =>
|
||||
ref.read(danmakuSettingsProvider.notifier).removeBlockedKeyword(kw),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _BlockedKeywordsDialog extends StatefulWidget {
|
||||
final List<String> keywords;
|
||||
final ValueChanged<String> onAdd;
|
||||
final ValueChanged<String> onRemove;
|
||||
|
||||
const _BlockedKeywordsDialog({
|
||||
required this.keywords,
|
||||
required this.onAdd,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_BlockedKeywordsDialog> createState() => _BlockedKeywordsDialogState();
|
||||
}
|
||||
|
||||
class _BlockedKeywordsDialogState extends State<_BlockedKeywordsDialog> {
|
||||
late final List<String> _keywords;
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_keywords = List.of(widget.keywords);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isEmpty || _keywords.contains(text)) return;
|
||||
setState(() => _keywords.add(text));
|
||||
widget.onAdd(text);
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
void _remove(String kw) {
|
||||
setState(() => _keywords.remove(kw));
|
||||
widget.onRemove(kw);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
|
||||
final chipsMaxHeight =
|
||||
MediaQuery.sizeOf(context).height * (isCompact ? 0.38 : 0.34);
|
||||
|
||||
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(
|
||||
'匹配到这些关键词的弹幕将被隐藏',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入关键词后回车添加',
|
||||
isDense: true,
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onSubmitted: (_) => _add(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
tooltip: '添加',
|
||||
onPressed: _add,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_keywords.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
'暂无屏蔽关键词',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: chipsMaxHeight),
|
||||
child: SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _keywords
|
||||
.map(
|
||||
(kw) => Chip(
|
||||
label: Text(kw),
|
||||
onDeleted: () => _remove(kw),
|
||||
deleteIcon: const Icon(Icons.close, size: 14),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (isCompact)
|
||||
FButton(
|
||||
onPress: () => Navigator.pop(context),
|
||||
child: const Text('完成'),
|
||||
)
|
||||
else
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: FButton(
|
||||
variant: FButtonVariant.ghost,
|
||||
onPress: () => Navigator.pop(context),
|
||||
child: const Text('完成'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/constants/breakpoints.dart';
|
||||
import '../../../shared/theme/app_theme_scope.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
|
||||
String danmakuSourcesSummary(List<DanmakuSource> sources) {
|
||||
if (sources.isEmpty) return '未配置';
|
||||
final enabled = sources.where((s) => s.enabled).length;
|
||||
if (enabled == 0) return '已配置 ${sources.length} 个,全部停用';
|
||||
final first = sources.firstWhere(
|
||||
(s) => s.enabled,
|
||||
orElse: () => sources.first,
|
||||
);
|
||||
return '$enabled/${sources.length} 个启用,优先:${first.displayName}';
|
||||
}
|
||||
|
||||
void showDanmakuSourcesDialog(
|
||||
BuildContext context,
|
||||
List<DanmakuSource> sources,
|
||||
) {
|
||||
final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
|
||||
if (isCompact) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => _DanmakuSourcesSheet(initialSources: sources),
|
||||
);
|
||||
} else {
|
||||
showFSheet<void>(
|
||||
context: context,
|
||||
side: FLayout.rtl,
|
||||
mainAxisMaxRatio: 0.42,
|
||||
draggable: false,
|
||||
builder: (_) => AppThemeScope(
|
||||
child: _DanmakuSourcesDesktopSheet(initialSources: sources),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mixin _DanmakuSourcesLogic<T extends ConsumerStatefulWidget>
|
||||
on ConsumerState<T> {
|
||||
late List<DanmakuSource> sources;
|
||||
|
||||
void initSources(List<DanmakuSource> initial) {
|
||||
sources = List.of(initial);
|
||||
}
|
||||
|
||||
Future<void> persist(List<DanmakuSource> next) async {
|
||||
final normalized = await ref
|
||||
.read(danmakuSettingsProvider.notifier)
|
||||
.setSources(next);
|
||||
if (!mounted) return;
|
||||
setState(() => sources = normalized);
|
||||
}
|
||||
|
||||
Future<void> showEditDialog(DanmakuSource? source) async {
|
||||
final nameCtrl = TextEditingController(text: source?.name ?? '');
|
||||
final urlCtrl = TextEditingController(text: source?.url ?? '');
|
||||
try {
|
||||
final saved = await showFDialog<DanmakuSource>(
|
||||
context: context,
|
||||
builder: (ctx, _, animation) => AppThemeScope(
|
||||
child: FDialog.adaptive(
|
||||
animation: animation,
|
||||
title: Text(source == null ? '添加弹幕源' : '编辑弹幕源'),
|
||||
body: SizedBox(
|
||||
width: 420,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '名称',
|
||||
hintText: '例如:主源、备用源',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: urlCtrl,
|
||||
autofocus: source == null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '服务地址',
|
||||
hintText: 'http://localhost:8080',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FButton(
|
||||
onPress: () {
|
||||
final url = urlCtrl.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
final id = source?.id.isNotEmpty == true
|
||||
? source!.id
|
||||
: 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}';
|
||||
Navigator.pop(
|
||||
ctx,
|
||||
DanmakuSource(
|
||||
id: id,
|
||||
name: nameCtrl.text.trim(),
|
||||
url: url,
|
||||
enabled: source?.enabled ?? true,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
FButton(
|
||||
onPress: () => Navigator.pop(ctx),
|
||||
variant: FButtonVariant.outline,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (saved == null) return;
|
||||
|
||||
final existing = sources.indexWhere((s) => s.id == saved.id);
|
||||
final next = [...sources];
|
||||
if (existing >= 0) {
|
||||
next[existing] = saved;
|
||||
} else {
|
||||
next.add(saved);
|
||||
}
|
||||
await persist(next);
|
||||
} finally {
|
||||
nameCtrl.dispose();
|
||||
urlCtrl.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeSource(DanmakuSource source) async {
|
||||
await persist(sources.where((s) => s.id != source.id).toList());
|
||||
}
|
||||
|
||||
Future<void> toggleSource(DanmakuSource source, bool enabled) async {
|
||||
await persist(
|
||||
sources
|
||||
.map((s) => s.id == source.id ? s.copyWith(enabled: enabled) : s)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reorderSource(int oldIndex, int newIndex) async {
|
||||
final next = [...sources];
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = next.removeAt(oldIndex);
|
||||
next.insert(newIndex, item);
|
||||
await persist(next);
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuSourcesSheet extends ConsumerStatefulWidget {
|
||||
final List<DanmakuSource> initialSources;
|
||||
|
||||
const _DanmakuSourcesSheet({required this.initialSources});
|
||||
|
||||
@override
|
||||
ConsumerState<_DanmakuSourcesSheet> createState() =>
|
||||
_DanmakuSourcesSheetState();
|
||||
}
|
||||
|
||||
class _DanmakuSourcesSheetState extends ConsumerState<_DanmakuSourcesSheet>
|
||||
with _DanmakuSourcesLogic<_DanmakuSourcesSheet> {
|
||||
bool _editing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initSources(widget.initialSources);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> persist(List<DanmakuSource> next) async {
|
||||
await super.persist(next);
|
||||
if (mounted && sources.isEmpty && _editing) {
|
||||
setState(() => _editing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'弹幕源',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (sources.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _editing = !_editing),
|
||||
child: Text(_editing ? '完成' : '编辑'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'按顺序自动匹配;启用的源会聚合显示候选,选中后从对应源加载弹幕。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (sources.isNotEmpty)
|
||||
Flexible(
|
||||
child: _editing
|
||||
? ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: sources.length,
|
||||
onReorder: (oldIdx, newIdx) =>
|
||||
unawaited(reorderSource(oldIdx, newIdx)),
|
||||
itemBuilder: (context, index) {
|
||||
final source = sources[index];
|
||||
return _DanmakuEditRow(
|
||||
key: ValueKey(source.id),
|
||||
source: source,
|
||||
index: index,
|
||||
onDelete: () => unawaited(removeSource(source)),
|
||||
onEdit: () => unawaited(showEditDialog(source)),
|
||||
);
|
||||
},
|
||||
)
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: sources.length,
|
||||
itemBuilder: (context, index) {
|
||||
final source = sources[index];
|
||||
final hasName = source.name.trim().isNotEmpty;
|
||||
return InkWell(
|
||||
onTap: () => unawaited(showEditDialog(source)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
hasName
|
||||
? source.name.trim()
|
||||
: source.url,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasName)
|
||||
Text(
|
||||
source.url,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FSwitch(
|
||||
value: source.enabled,
|
||||
onChange: (v) =>
|
||||
unawaited(toggleSource(source, v)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8,
|
||||
4,
|
||||
8,
|
||||
MediaQuery.paddingOf(context).bottom + 16,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => unawaited(showEditDialog(null)),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('添加弹幕源'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuEditRow extends StatefulWidget {
|
||||
final DanmakuSource source;
|
||||
final int index;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
const _DanmakuEditRow({
|
||||
required super.key,
|
||||
required this.source,
|
||||
required this.index,
|
||||
required this.onDelete,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DanmakuEditRow> createState() => _DanmakuEditRowState();
|
||||
}
|
||||
|
||||
class _DanmakuEditRowState extends State<_DanmakuEditRow>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _deleteWidth = 72.0;
|
||||
|
||||
late final AnimationController _controller;
|
||||
late final Animation<Offset> _slideAnim;
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
);
|
||||
_slideAnim = Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: const Offset(-0.2, 0),
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
setState(() => _expanded = !_expanded);
|
||||
if (_expanded) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
final source = widget.source;
|
||||
final hasName = source.name.trim().isNotEmpty;
|
||||
|
||||
return ClipRect(
|
||||
child: SizedBox(
|
||||
height: hasName ? 64 : 52,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: _deleteWidth,
|
||||
child: Material(
|
||||
color: Colors.red,
|
||||
child: InkWell(
|
||||
onTap: widget.onDelete,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SlideTransition(
|
||||
position: _slideAnim,
|
||||
child: Container(
|
||||
color: theme.colorScheme.surface,
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _toggle,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: AnimatedRotation(
|
||||
turns: _expanded ? 0.0 : -0.25,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: const Icon(
|
||||
Icons.remove_circle,
|
||||
color: Colors.red,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: widget.onEdit,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
hasName ? source.name.trim() : source.url,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (hasName)
|
||||
Text(
|
||||
source.url,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
ReorderableDragStartListener(
|
||||
index: widget.index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
size: 18,
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuSourcesDesktopSheet extends ConsumerStatefulWidget {
|
||||
final List<DanmakuSource> initialSources;
|
||||
|
||||
const _DanmakuSourcesDesktopSheet({required this.initialSources});
|
||||
|
||||
@override
|
||||
ConsumerState<_DanmakuSourcesDesktopSheet> createState() =>
|
||||
_DanmakuSourcesDesktopSheetState();
|
||||
}
|
||||
|
||||
class _DanmakuSourcesDesktopSheetState
|
||||
extends ConsumerState<_DanmakuSourcesDesktopSheet>
|
||||
with _DanmakuSourcesLogic<_DanmakuSourcesDesktopSheet> {
|
||||
bool _editing = false;
|
||||
DanmakuSource? _editingSource;
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _urlCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initSources(widget.initialSources);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_urlCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startEdit(DanmakuSource? source) {
|
||||
_nameCtrl.text = source?.name ?? '';
|
||||
_urlCtrl.text = source?.url ?? '';
|
||||
setState(() {
|
||||
_editing = true;
|
||||
_editingSource = source;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelEdit() {
|
||||
setState(() {
|
||||
_editing = false;
|
||||
_editingSource = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveEdit() async {
|
||||
final url = _urlCtrl.text.trim();
|
||||
if (url.isEmpty) return;
|
||||
final source = _editingSource;
|
||||
final id = source?.id.isNotEmpty == true
|
||||
? source!.id
|
||||
: 'src_${url.hashCode.toUnsigned(32).toRadixString(16)}';
|
||||
final saved = DanmakuSource(
|
||||
id: id,
|
||||
name: _nameCtrl.text.trim(),
|
||||
url: url,
|
||||
enabled: source?.enabled ?? true,
|
||||
);
|
||||
final existing = sources.indexWhere((s) => s.id == saved.id);
|
||||
final next = [...sources];
|
||||
if (existing >= 0) {
|
||||
next[existing] = saved;
|
||||
} else {
|
||||
next.add(saved);
|
||||
}
|
||||
await persist(next);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_editing = false;
|
||||
_editingSource = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surface,
|
||||
child: SafeArea(
|
||||
left: false,
|
||||
right: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(theme),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _editing ? _buildEditView() : _buildListView(theme)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(ThemeData theme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_editing)
|
||||
IconButton(
|
||||
tooltip: '返回',
|
||||
icon: const Icon(Icons.arrow_back, size: 20),
|
||||
onPressed: _cancelEdit,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_editing
|
||||
? (_editingSource == null ? '添加弹幕源' : '编辑弹幕源')
|
||||
: '弹幕源',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '关闭',
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListView(ThemeData theme) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'按顺序自动匹配;启用的源会聚合显示候选,选中后从对应源加载弹幕。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: sources.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'尚未配置弹幕源',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ReorderableListView.builder(
|
||||
buildDefaultDragHandles: false,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: sources.length,
|
||||
onReorder: (oldIndex, newIndex) =>
|
||||
unawaited(reorderSource(oldIndex, newIndex)),
|
||||
itemBuilder: (context, index) {
|
||||
final source = sources[index];
|
||||
return ListTile(
|
||||
key: ValueKey(source.id),
|
||||
leading: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(Icons.drag_handle, size: 18),
|
||||
),
|
||||
title: Text(
|
||||
source.displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: source.name.trim().isEmpty
|
||||
? null
|
||||
: Text(
|
||||
source.url,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FSwitch(
|
||||
value: source.enabled,
|
||||
onChange: (v) => unawaited(toggleSource(source, v)),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '编辑',
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
onPressed: () => _startEdit(source),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '移除',
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
onPressed: () => unawaited(removeSource(source)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FButton(
|
||||
onPress: () => _startEdit(null),
|
||||
prefix: const Icon(Icons.add, size: 16),
|
||||
child: const Text('添加'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEditView() {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '名称',
|
||||
hintText: '例如:主源、备用源',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _urlCtrl,
|
||||
autofocus: _editingSource == null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '服务地址',
|
||||
hintText: 'http://localhost:8080',
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FButton(
|
||||
onPress: _cancelEdit,
|
||||
variant: FButtonVariant.outline,
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FButton(
|
||||
onPress: () => unawaited(_saveEdit()),
|
||||
child: const Text('保存'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../providers/player_engine_override_provider.dart';
|
||||
import '../../../shared/widgets/setting_select.dart';
|
||||
|
||||
|
||||
class PlayerEngineRow extends StatelessWidget {
|
||||
final PlayerEngineOverride value;
|
||||
final ValueChanged<PlayerEngineOverride> onChanged;
|
||||
|
||||
|
||||
final bool? isAndroid;
|
||||
|
||||
const PlayerEngineRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.isAndroid,
|
||||
});
|
||||
|
||||
|
||||
static List<PlayerEngineOverride> visibleOptions({required bool isAndroid}) {
|
||||
return PlayerEngineOverride.values
|
||||
.where((v) => v != PlayerEngineOverride.media3 || isAndroid)
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
static PlayerEngineOverride coerceValue(
|
||||
PlayerEngineOverride value, {
|
||||
required bool isAndroid,
|
||||
}) {
|
||||
if (value == PlayerEngineOverride.media3 && !isAndroid) {
|
||||
return PlayerEngineOverride.auto;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final onAndroid = isAndroid ?? Platform.isAndroid;
|
||||
|
||||
final options = visibleOptions(isAndroid: onAndroid);
|
||||
final coercedValue = coerceValue(value, isAndroid: onAndroid);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'播放引擎',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingSelect<PlayerEngineOverride>(
|
||||
value: coercedValue,
|
||||
options: options,
|
||||
labelOf: (v) => v.label,
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/utils/user_error_formatter.dart';
|
||||
import '../../../shared/widgets/app_inline_alert.dart';
|
||||
|
||||
|
||||
Widget settingsLoadError(Object error) {
|
||||
return AppInlineAlert(message: '加载失败:${formatUserError(error)}');
|
||||
}
|
||||
|
||||
class SettingsTabScroll extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const SettingsTabScroll({super.key, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 0, 32),
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsCard extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const SettingsCard({super.key, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final dividerColor = isDark
|
||||
? Colors.white.withValues(alpha: 0.08)
|
||||
: Colors.black.withValues(alpha: 0.06);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF2C2C2E) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: dividerColor, width: 0.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < children.length; i++) ...[
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 0.5,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: dividerColor,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SettingRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SettingRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
Widget content = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
content = InkWell(onTap: onTap, child: content);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/contracts/player_gestures.dart';
|
||||
import '../../../shared/widgets/setting_select.dart';
|
||||
import '../../player/gestures/gesture_action.dart' as g;
|
||||
import '../utils/settings_labels.dart';
|
||||
|
||||
class SeekDurationRow extends StatelessWidget {
|
||||
final String title;
|
||||
final int seconds;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
static const _options = [5, 10, 15, 20, 30, 45, 60, 90, 120];
|
||||
|
||||
const SeekDurationRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.seconds,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingSelect<int>(
|
||||
value: seconds,
|
||||
options: _options,
|
||||
labelOf: (value) => '$value 秒',
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GestureSpeedRow extends StatelessWidget {
|
||||
final String title;
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
static const _options = [
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
1.25,
|
||||
1.5,
|
||||
1.75,
|
||||
2.0,
|
||||
2.5,
|
||||
3.0,
|
||||
4.0,
|
||||
8.0,
|
||||
];
|
||||
|
||||
const GestureSpeedRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingSelect<double>(
|
||||
value: value,
|
||||
options: _options,
|
||||
labelOf: formatMenuSpeed,
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GestureActionRow extends StatelessWidget {
|
||||
final String title;
|
||||
final g.GestureKind kind;
|
||||
final GestureAction value;
|
||||
final ValueChanged<GestureAction> onChanged;
|
||||
|
||||
const GestureActionRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.kind,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final options = g.allowedActionsFor(kind);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingSelect<GestureAction>(
|
||||
value: value,
|
||||
options: options,
|
||||
labelOf: g.gestureActionLabel,
|
||||
onChanged: (v) {
|
||||
if (v != null) onChanged(v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
|
||||
class StepperRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String display;
|
||||
final VoidCallback? onDecrement;
|
||||
final VoidCallback? onIncrement;
|
||||
|
||||
const StepperRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.display,
|
||||
this.onDecrement,
|
||||
this.onIncrement,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
_stepBtn(Icons.remove, onDecrement, theme),
|
||||
SizedBox(
|
||||
width: 52,
|
||||
child: Text(
|
||||
display,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
),
|
||||
_stepBtn(Icons.add, onIncrement, theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stepBtn(IconData icon, VoidCallback? onPressed, ThemeData theme) {
|
||||
return SizedBox.square(
|
||||
dimension: 28,
|
||||
child: FButton.icon(
|
||||
variant: FButtonVariant.ghost,
|
||||
size: FButtonSizeVariant.xs,
|
||||
onPress: onPressed,
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: onPressed != null
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.25),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsTextFieldRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String hintText;
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool obscureText;
|
||||
|
||||
const SettingsTextFieldRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.hintText,
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.onChanged,
|
||||
this.obscureText = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
isDense: true,
|
||||
suffixIcon: controller.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
tooltip: '清空',
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
onChanged('');
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../../providers/version_priority_provider.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/theme/app_theme_scope.dart';
|
||||
|
||||
class VersionPrioritySection extends StatelessWidget {
|
||||
final List<VersionPriority> activePriorities;
|
||||
final ValueChanged<VersionPriority> onToggle;
|
||||
final ValueChanged<List<VersionPriority>> onReorder;
|
||||
|
||||
const VersionPrioritySection({
|
||||
super.key,
|
||||
required this.activePriorities,
|
||||
required this.onToggle,
|
||||
required this.onReorder,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final tokens = context.appTokens;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择后按顺序排列详情页的视频版本',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: VersionPriority.values.map((p) {
|
||||
final isActive = activePriorities.contains(p);
|
||||
return FilterChip(
|
||||
label: Text(p.label),
|
||||
selected: isActive,
|
||||
onSelected: (_) => onToggle(p),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
if (activePriorities.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
activePriorities
|
||||
.map((p) => p.label.replaceAll('优先', ''))
|
||||
.join(' > '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: tokens.mutedForeground,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (activePriorities.length > 1) ...[
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () => _showReorderDialog(context),
|
||||
child: Text(
|
||||
'调整顺序',
|
||||
style: TextStyle(fontSize: 12, color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showReorderDialog(BuildContext context) {
|
||||
showFDialog(
|
||||
context: context,
|
||||
builder: (_, _, animation) => AppThemeScope(
|
||||
child: FDialog.raw(
|
||||
animation: animation,
|
||||
builder: (context, _) => PriorityOrderDialog(
|
||||
priorities: activePriorities,
|
||||
onChanged: onReorder,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PriorityOrderDialog extends StatefulWidget {
|
||||
final List<VersionPriority> priorities;
|
||||
final ValueChanged<List<VersionPriority>> onChanged;
|
||||
|
||||
const PriorityOrderDialog({
|
||||
super.key,
|
||||
required this.priorities,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PriorityOrderDialog> createState() => PriorityOrderDialogState();
|
||||
}
|
||||
|
||||
class PriorityOrderDialogState extends State<PriorityOrderDialog> {
|
||||
late final List<VersionPriority> _items;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_items = List.of(widget.priorities);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FDialog(
|
||||
title: const Text('调整优先级顺序'),
|
||||
body: SizedBox(
|
||||
width: 320,
|
||||
height: (_items.length * 56.0).clamp(0, 400),
|
||||
child: ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: _items.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _items.removeAt(oldIndex);
|
||||
_items.insert(newIndex, item);
|
||||
});
|
||||
widget.onChanged(List.of(_items));
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
key: ValueKey(_items[index].name),
|
||||
leading: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
_items[index].label,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: const Icon(Icons.drag_handle, size: 18),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FButton(onPress: () => Navigator.pop(context), child: const Text('完成')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user