Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../constants/breakpoints.dart';
import '../theme/app_theme_scope.dart';
Future<T?> showAdaptiveModal<T>({
required BuildContext context,
required WidgetBuilder builder,
double maxWidth = 520,
double? maxHeight,
double compactMaxHeightFactor = 0.92,
double? compactHeightFactor,
bool barrierDismissible = true,
bool useRootNavigator = false,
}) {
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
if (isCompact) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
useSafeArea: true,
useRootNavigator: useRootNavigator,
isDismissible: barrierDismissible,
showDragHandle: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black54,
builder: (sheetContext) => AppThemeScope(
child: _AdaptiveBottomSheet(
maxHeightFactor: compactMaxHeightFactor,
heightFactor: compactHeightFactor,
child: builder(sheetContext),
),
),
);
}
const insetPadding = EdgeInsets.symmetric(horizontal: 40, vertical: 32);
return showFDialog<T>(
context: context,
useRootNavigator: useRootNavigator,
barrierDismissible: barrierDismissible,
builder: (dialogContext, _, animation) {
final screenHeight = MediaQuery.sizeOf(dialogContext).height;
return AppThemeScope(
child: FDialog.raw(
animation: animation,
clipBehavior: Clip.antiAlias,
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight ?? screenHeight - insetPadding.vertical,
),
builder: (_, _) => builder(dialogContext),
),
);
},
);
}
class _AdaptiveBottomSheet extends StatelessWidget {
final Widget child;
final double maxHeightFactor;
final double? heightFactor;
const _AdaptiveBottomSheet({
required this.child,
required this.maxHeightFactor,
required this.heightFactor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.sizeOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
final fixedHeight = heightFactor == null
? null
: size.height * heightFactor!;
final maxHeight = size.height * maxHeightFactor;
Widget sheet = Material(
color: theme.colorScheme.surface,
clipBehavior: Clip.antiAlias,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: fixedHeight == null
? MainAxisSize.min
: MainAxisSize.max,
children: [
if (fixedHeight == null)
Flexible(fit: FlexFit.loose, child: child)
else
Expanded(child: child),
],
),
),
);
sheet = ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: fixedHeight == null
? sheet
: SizedBox(height: fixedHeight, child: sheet),
);
return AnimatedPadding(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: Align(alignment: Alignment.bottomCenter, child: sheet),
);
}
}
+157
View File
@@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/contracts/server.dart';
import '../../providers/di_providers.dart';
import '../../providers/server_provider.dart';
import '../../providers/session_provider.dart';
import 'adaptive_modal.dart';
import 'connection_form.dart';
class EditingServer {
final String id;
final String baseUrl;
final String? userName;
final String? password;
final String? displayName;
final String iconUrl;
const EditingServer({
required this.id,
required this.baseUrl,
this.userName,
this.password,
this.displayName,
this.iconUrl = '',
});
factory EditingServer.fromServer(EmbyServer s) => EditingServer(
id: s.id,
baseUrl: s.baseUrl,
displayName: s.name,
iconUrl: s.iconUrl,
);
}
class _AddServerDialogContent extends ConsumerStatefulWidget {
final EditingServer? editingServer;
const _AddServerDialogContent({this.editingServer});
@override
ConsumerState<_AddServerDialogContent> createState() =>
_AddServerDialogContentState();
}
class _AddServerDialogContentState
extends ConsumerState<_AddServerDialogContent> {
bool _loading = false;
String? _error;
bool get _isEdit => widget.editingServer != null;
Future<void> _handleConnect(
String url,
String username,
String password,
String? displayName,
String iconUrl,
) async {
setState(() {
_loading = true;
_error = null;
});
try {
final probeUc = await ref.read(probeServerUseCaseProvider.future);
final probeResult = await probeUc.execute(
EmbyServerProbeReq(baseUrl: url),
);
final gw = await ref.read(embyGatewayProvider.future);
await gw.authenticateByName(
baseUrl: url,
username: username,
password: password,
);
final name = (displayName != null && displayName.isNotEmpty)
? displayName
: probeResult.serverName;
final server = await ref
.read(serverListProvider.notifier)
.save(
EmbyServerSaveReq(
id: widget.editingServer?.id,
name: name,
baseUrl: url,
iconUrl: iconUrl,
),
);
if (_isEdit) {
await ref.read(sessionProvider.notifier).logout(serverId: server.id);
}
await ref
.read(sessionProvider.notifier)
.login(serverId: server.id, username: username, password: password);
if (!mounted) return;
Navigator.of(context).pop(server);
} catch (e) {
if (mounted) setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _handleClose() {
if (_loading) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final editing = widget.editingServer;
return PopScope(
canPop: !_loading,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
child: ConnectionForm(
mode: _isEdit ? 'edit' : 'add',
initialUrl: editing?.baseUrl,
initialUsername: editing?.userName,
initialPassword: editing?.password,
initialDisplayName: editing?.displayName,
initialIconUrl: editing?.iconUrl ?? '',
onConnect: _handleConnect,
onCancel: _handleClose,
loading: _loading,
error: _error,
),
),
);
}
}
Future<EmbyServer?> showAddServerModal(
BuildContext context, {
EmbyServer? initial,
EditingServer? editingServer,
}) async {
final target =
editingServer ??
(initial != null ? EditingServer.fromServer(initial) : null);
return showAdaptiveModal<EmbyServer>(
context: context,
maxWidth: 520,
compactMaxHeightFactor: 0.92,
builder: (_) => _AddServerDialogContent(editingServer: target),
);
}
+320
View File
@@ -0,0 +1,320 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import '../utils/format_utils.dart';
import 'adaptive_modal.dart';
import 'app_loading_ring.dart';
import 'app_snack_bar.dart';
import 'version_filter.dart';
import 'version_grouping.dart';
import 'version_source_card.dart';
const int kCompactInlineVersionLimit = 6;
const int kDesktopInlineVersionLimit = 8;
int inlineVersionLimit() {
return Platform.isAndroid
? kCompactInlineVersionLimit
: kDesktopInlineVersionLimit;
}
Future<void> showAllVersionsSheet({
required BuildContext context,
required List<VersionSourceGroup> groups,
String title = '全部版本',
}) async {
final isSingleVersionPicker = groups.length == 1;
await showAdaptiveModal<void>(
context: context,
maxWidth: isSingleVersionPicker ? 520 : 720,
maxHeight: isSingleVersionPicker ? 300 : 640,
compactMaxHeightFactor: isSingleVersionPicker ? 0.55 : 0.92,
compactHeightFactor: isSingleVersionPicker ? null : 0.82,
builder: (modalContext) => AllVersionsSheet(groups: groups, title: title),
);
}
class AllVersionsSheet extends StatelessWidget {
final List<VersionSourceGroup> groups;
final String title;
const AllVersionsSheet({
super.key,
required this.groups,
this.title = '全部版本',
});
@override
Widget build(BuildContext context) {
final groupsByBucket = <String, List<VersionSourceGroup>>{};
for (final group in groups) {
final bucket = versionBucketOf(group.representative.resolutionLabel);
groupsByBucket.putIfAbsent(bucket, () => []).add(group);
}
final concreteSourceCount = groups.fold<int>(
0,
(count, group) => count + group.members.length,
);
return Material(
color: const Color(0xFF171A20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 8, 10),
child: Row(
children: [
const Icon(
Icons.video_library_outlined,
color: Colors.white70,
size: 20,
),
const SizedBox(width: 10),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 8),
Text(
'$concreteSourceCount 个来源',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
fontSize: 12,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close, color: Colors.white60),
tooltip: '关闭',
),
],
),
),
Divider(height: 1, color: Colors.white.withValues(alpha: 0.08)),
Flexible(
fit: FlexFit.loose,
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
for (final bucket in kVersionBucketOrder)
if (groupsByBucket[bucket] case final bucketGroups?) ...[
_BucketHeader(label: bucket, count: bucketGroups.length),
const SizedBox(height: 8),
for (
var index = 0;
index < bucketGroups.length;
index++
) ...[
_VersionGroupRow(group: bucketGroups[index]),
if (index < bucketGroups.length - 1)
const SizedBox(height: 10),
],
const SizedBox(height: 20),
],
],
),
),
],
),
);
}
}
class _BucketHeader extends StatelessWidget {
final String label;
final int count;
const _BucketHeader({required this.label, required this.count});
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 8),
Text(
'$count 个版本',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 12,
),
),
],
);
}
}
class _VersionGroupRow extends StatelessWidget {
final VersionSourceGroup group;
const _VersionGroupRow({required this.group});
@override
Widget build(BuildContext context) {
final representative = group.representative;
final sizeBytes = representative.sizeBytes;
final bitrate = representative.bitrate;
final details = <String>[
if (sizeBytes != null && sizeBytes > 0) formatFileSize(sizeBytes),
if (bitrate != null && bitrate > 0)
'${(bitrate / 1e6).toStringAsFixed(2)}Mbps',
];
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
representative.topLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
if (details.isNotEmpty)
Text(
details.join(' · '),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.58),
fontSize: 12,
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final member in group.members)
_ServerSourceChip(data: member),
],
),
],
),
);
}
}
class _ServerSourceChip extends StatefulWidget {
final VersionSourceCardData data;
const _ServerSourceChip({required this.data});
@override
State<_ServerSourceChip> createState() => _ServerSourceChipState();
}
class _ServerSourceChipState extends State<_ServerSourceChip> {
bool _loading = false;
Future<void> _selectSource() async {
final data = widget.data;
if (_loading || data.isCurrent || data.selected || data.onTap == null) {
return;
}
setState(() => _loading = true);
try {
await data.onTap!();
if (mounted) Navigator.of(context).pop();
} catch (_) {
if (mounted) {
setState(() => _loading = false);
showAppSnackBar(context, '切换服务器失败', tone: AppSnackTone.error);
}
}
}
@override
Widget build(BuildContext context) {
final data = widget.data;
final active = data.isCurrent || data.selected;
return Tooltip(
message: active ? '${data.serverName}(当前选择)' : '切换到 ${data.serverName}',
child: InkWell(
onTap: active ? null : _selectSource,
borderRadius: BorderRadius.circular(999),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: active
? const Color(0xFF4F8DFF).withValues(alpha: 0.24)
: Colors.white.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: active
? const Color(0xFF4F8DFF)
: Colors.white.withValues(alpha: 0.12),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_loading)
const AppLoadingRing(size: 12, color: Colors.white)
else
Icon(
active ? Icons.check_circle : Icons.dns_outlined,
size: 14,
color: active ? const Color(0xFF77A7FF) : Colors.white60,
),
const SizedBox(width: 6),
Text(
data.serverName,
style: TextStyle(
color: Colors.white.withValues(alpha: active ? 1 : 0.78),
fontSize: 12,
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
),
),
if (data.isCurrent) ...[
const SizedBox(width: 6),
const Text(
'当前',
style: TextStyle(
color: Color(0xFF9FC0FF),
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
);
}
}
+206
View File
@@ -0,0 +1,206 @@
import 'dart:io' show Platform;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../theme/app_theme.dart';
class AppBottomNav extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const AppBottomNav({super.key, required this.navigationShell});
static const _destinations = <_BottomDestination>[
_BottomDestination(
branchIndex: 0,
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library,
label: '媒体',
),
_BottomDestination(
branchIndex: 2,
icon: Icons.history_outlined,
selectedIcon: Icons.history,
label: '观看',
),
_BottomDestination(
branchIndex: 1,
icon: Icons.dns_outlined,
selectedIcon: Icons.dns,
label: '资源',
),
_BottomDestination(
branchIndex: 5,
icon: Icons.settings_outlined,
selectedIcon: Icons.settings,
label: '设置',
),
];
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
final theme = Theme.of(context);
final currentBranch = navigationShell.currentIndex;
final bottomPadding = MediaQuery.paddingOf(context).bottom;
return SizedBox(
height: 64 + bottomPadding + 28,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
child: ClipRRect(
borderRadius: BorderRadius.vertical(
top: Radius.circular(
Platform.isAndroid ? 24 : tokens.bottomNavRadius,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
color: tokens.shellBg.withValues(
alpha: Platform.isAndroid ? 0.88 : 0.78,
),
padding: EdgeInsets.only(bottom: bottomPadding),
height: 64 + bottomPadding,
child: Row(
children: [
for (var i = 0; i < _destinations.length; i++) ...[
if (i == 2) const SizedBox(width: 64),
Expanded(
child: _NavItem(
destination: _destinations[i],
selected:
_destinations[i].branchIndex == currentBranch,
onTap: () {
final target = _destinations[i].branchIndex;
navigationShell.goBranch(
target,
initialLocation:
target == navigationShell.currentIndex,
);
},
theme: theme,
tokens: tokens,
),
),
],
],
),
),
),
),
),
Positioned(
bottom: 64 + bottomPadding - 28,
left: 0,
right: 0,
child: Center(
child: _SearchFab(
onTap: () => context.push('/global-search'),
theme: theme,
),
),
),
],
),
);
}
}
class _NavItem extends StatelessWidget {
final _BottomDestination destination;
final bool selected;
final VoidCallback onTap;
final ThemeData theme;
final AppTokens tokens;
const _NavItem({
required this.destination,
required this.selected,
required this.onTap,
required this.theme,
required this.tokens,
});
@override
Widget build(BuildContext context) {
final color = selected ? theme.colorScheme.primary : tokens.mutedForeground;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
selected ? destination.selectedIcon : destination.icon,
size: 24,
color: color,
),
const SizedBox(height: 4),
Text(
destination.label,
style: TextStyle(
fontSize: 10,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: color,
),
),
],
),
);
}
}
class _SearchFab extends StatelessWidget {
final VoidCallback onTap;
final ThemeData theme;
const _SearchFab({required this.onTap, required this.theme});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.onSurface,
boxShadow: [
BoxShadow(
color: theme.colorScheme.onSurface.withValues(alpha: 0.25),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Icon(
Icons.search_rounded,
size: 26,
color: theme.colorScheme.surface,
),
),
);
}
}
class _BottomDestination {
final int branchIndex;
final IconData icon;
final IconData selectedIcon;
final String label;
const _BottomDestination({
required this.branchIndex,
required this.icon,
required this.selectedIcon,
required this.label,
});
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../theme/app_theme.dart';
import 'auto_dismiss_menu.dart';
class AppCard extends StatefulWidget {
final Widget child;
final VoidCallback? onTap;
final List<FItem>? contextMenuItems;
final bool enableLongPressMenu;
final bool filled;
final double? radius;
final EdgeInsetsGeometry? padding;
final bool enableHover;
final ValueChanged<bool>? onHoverChanged;
const AppCard({
super.key,
required this.child,
this.onTap,
this.contextMenuItems,
this.enableLongPressMenu = true,
this.filled = true,
this.radius,
this.padding,
this.enableHover = true,
this.onHoverChanged,
});
@override
State<AppCard> createState() => _AppCardState();
}
class _AppCardState extends State<AppCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final radius = widget.radius ?? tokens.radiusCard;
final base = theme.colorScheme.onSurface;
final double alpha;
if (widget.filled) {
alpha = _hovered ? tokens.cardFillHoverAlpha : tokens.cardFillAlpha;
} else {
alpha = _hovered ? tokens.cardFillAlpha : 0.0;
}
Widget result = MouseRegion(
onEnter: (_) {
if (widget.enableHover) setState(() => _hovered = true);
widget.onHoverChanged?.call(true);
},
onExit: (_) {
if (widget.enableHover) setState(() => _hovered = false);
widget.onHoverChanged?.call(false);
},
cursor: widget.onTap != null
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
padding: widget.padding,
decoration: BoxDecoration(
color: base.withValues(alpha: alpha),
borderRadius: BorderRadius.circular(radius),
),
clipBehavior: Clip.antiAlias,
child: widget.child,
),
),
);
final items = widget.contextMenuItems;
if (items != null && items.isNotEmpty) {
result = FContextMenu(
menuBuilder: autoDismissMenuBuilder,
menu: [FItemGroup(children: items)],
longPress: widget.enableLongPressMenu ? null : false,
child: result,
);
}
return result;
}
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../theme/app_theme_scope.dart';
Future<T?> showAppRawDialog<T>(
BuildContext context, {
required WidgetBuilder builder,
BoxConstraints constraints = const BoxConstraints(
minWidth: 280,
maxWidth: 560,
),
bool useRootNavigator = false,
bool barrierDismissible = true,
}) {
return showFDialog<T>(
context: context,
useRootNavigator: useRootNavigator,
barrierDismissible: barrierDismissible,
builder: (dialogContext, _, animation) => AppThemeScope(
child: FDialog.raw(
animation: animation,
clipBehavior: Clip.antiAlias,
constraints: constraints,
builder: (_, _) => builder(dialogContext),
),
),
);
}
Future<bool> showAppConfirmDialog(
BuildContext context, {
required String title,
required Widget body,
required String confirmLabel,
String cancelLabel = '取消',
bool destructive = false,
}) async {
final confirmed = await showFDialog<bool>(
context: context,
builder: (ctx, _, animation) => AppThemeScope(
child: FDialog.adaptive(
animation: animation,
title: Text(title),
body: body,
actions: [
FButton(
onPress: () => Navigator.of(ctx).pop(true),
variant: destructive
? FButtonVariant.destructive
: FButtonVariant.primary,
child: Text(confirmLabel),
),
FButton(
onPress: () => Navigator.of(ctx).pop(false),
variant: FButtonVariant.outline,
child: Text(cancelLabel),
),
],
),
),
);
return confirmed == true;
}
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
class AppErrorState extends StatelessWidget {
final String title;
final String? message;
final IconData icon;
final VoidCallback? onRetry;
final Widget? action;
final bool compact;
final bool panel;
final Color? foregroundColor;
const AppErrorState({
super.key,
this.title = '加载失败',
this.message,
this.icon = Icons.error_outline,
this.onRetry,
this.action,
this.compact = false,
this.panel = false,
this.foregroundColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final child = ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 420 : 520),
child: Padding(
padding: EdgeInsets.all(compact ? 16 : 28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: compact ? 36 : 48,
height: compact ? 36 : 48,
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
size: compact ? 20 : 26,
color: theme.colorScheme.onErrorContainer,
),
),
SizedBox(height: compact ? 10 : 14),
Text(
title,
textAlign: TextAlign.center,
style:
(compact
? theme.textTheme.titleSmall
: theme.textTheme.titleMedium)
?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w600,
),
),
if (message != null && message!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
message!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color:
foregroundColor?.withValues(alpha: 0.72) ??
theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
if (action != null || onRetry != null) ...[
SizedBox(height: compact ? 14 : 18),
action ?? _RetryButton(onPressed: onRetry!),
],
],
),
),
);
final content = panel
? DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.86),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.5),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.10),
blurRadius: 28,
offset: const Offset(0, 16),
),
],
),
child: child,
)
: child;
return Center(
child: Padding(
padding: EdgeInsets.all(compact ? 16 : 32),
child: content,
),
);
}
}
class _RetryButton extends StatelessWidget {
final VoidCallback onPressed;
const _RetryButton({required this.onPressed});
@override
Widget build(BuildContext context) {
return FilledButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重试'),
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
);
}
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
enum AppAlertTone { error, warning, info, success }
class AppInlineAlert extends StatelessWidget {
final String message;
final AppAlertTone tone;
final IconData? icon;
const AppInlineAlert({
super.key,
required this.message,
this.tone = AppAlertTone.error,
this.icon,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = _colors(theme.colorScheme);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: colors.background,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: colors.foreground.withValues(alpha: 0.16)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon ?? _defaultIcon, size: 16, color: colors.foreground),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.foreground,
height: 1.35,
),
),
),
],
),
);
}
IconData get _defaultIcon => switch (tone) {
AppAlertTone.error => Icons.error_outline,
AppAlertTone.warning => Icons.warning_amber_rounded,
AppAlertTone.info => Icons.info_outline,
AppAlertTone.success => Icons.check_circle_outline,
};
({Color background, Color foreground}) _colors(ColorScheme scheme) {
final isDark = scheme.brightness == Brightness.dark;
return switch (tone) {
AppAlertTone.error => (
background: scheme.errorContainer,
foreground: scheme.onErrorContainer,
),
AppAlertTone.warning =>
isDark
? (
background: const Color(0xFF3A2E12),
foreground: const Color(0xFFFFD68A),
)
: (
background: const Color(0xFFFFF3D6),
foreground: const Color(0xFF7A4D00),
),
AppAlertTone.info => (
background: scheme.secondaryContainer,
foreground: scheme.onSecondaryContainer,
),
AppAlertTone.success =>
isDark
? (
background: const Color(0xFF12331F),
foreground: const Color(0xFF7FE3A8),
)
: (
background: const Color(0xFFDDF7E8),
foreground: const Color(0xFF0B6B3A),
),
};
}
}
+114
View File
@@ -0,0 +1,114 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
class AppLoadingRing extends StatefulWidget {
const AppLoadingRing({super.key, this.size = 44, this.color});
final double size;
final Color? color;
@override
State<AppLoadingRing> createState() => _AppLoadingRingState();
}
class _AppLoadingRingState extends State<AppLoadingRing>
with SingleTickerProviderStateMixin {
late final AnimationController _rotationController;
@override
void initState() {
super.initState();
_rotationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
)..repeat();
}
@override
void dispose() {
_rotationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final ringColor =
widget.color ??
IconTheme.of(context).color ??
Theme.of(context).colorScheme.onSurface;
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0, end: 1),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder: (context, fadeInOpacity, child) =>
Opacity(opacity: fadeInOpacity, child: child),
child: RepaintBoundary(
child: SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _rotationController,
builder: (context, _) => CustomPaint(
painter: _LoadingRingPainter(
rotationTurns: _rotationController.value,
color: ringColor,
),
),
),
),
),
);
}
}
class _LoadingRingPainter extends CustomPainter {
_LoadingRingPainter({required this.rotationTurns, required this.color});
final double rotationTurns;
final Color color;
static const double strokeWidth = 3;
static const double _sweepAngle = math.pi * 5 / 3;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (math.min(size.width, size.height) - strokeWidth) / 2;
final arcRect = Rect.fromCircle(center: center, radius: radius);
final rotationAngle = rotationTurns * 2 * math.pi;
final gradient = SweepGradient(
startAngle: 0,
endAngle: 2 * math.pi,
transform: GradientRotation(rotationAngle),
colors: [
color.withValues(alpha: 0.0),
color.withValues(alpha: 0.25),
color,
],
stops: const [0.0, 0.5, _sweepAngle / (2 * math.pi)],
);
final arcPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..shader = gradient.createShader(arcRect);
canvas.drawArc(arcRect, rotationAngle, _sweepAngle, false, arcPaint);
}
@override
bool shouldRepaint(_LoadingRingPainter oldDelegate) =>
oldDelegate.rotationTurns != rotationTurns || oldDelegate.color != color;
}
+187
View File
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../providers/session_provider.dart';
import '../../providers/shell_backdrop_provider.dart';
import '../constants/breakpoints.dart';
import '../theme/app_theme.dart';
import 'app_bottom_nav.dart';
import 'shell_backdrop_layer.dart';
import 'sidebar.dart';
import 'window_chrome.dart';
import 'window_chrome_overlay.dart';
class AppShell extends ConsumerStatefulWidget {
final StatefulNavigationShell navigationShell;
final String location;
const AppShell({
super.key,
required this.navigationShell,
required this.location,
});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
@override
void initState() {
super.initState();
initSidebarState(ref);
}
@override
Widget build(BuildContext context) {
final immersive =
widget.location == '/' ||
widget.location == '/discover' ||
widget.location.startsWith('/library/');
final sessionAsync = ref.watch(sessionProvider);
final unauthenticatedHome =
widget.location == '/' &&
sessionAsync.hasValue &&
ref.watch(activeSessionProvider) == null;
final effectiveImmersive = immersive && !unauthenticatedHome;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ref.read(shellLocationProvider.notifier).set(widget.location);
});
final themeBrightness = Theme.of(context).brightness;
final overlayStyle = effectiveImmersive
? const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
)
: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: themeBrightness == Brightness.dark
? Brightness.light
: Brightness.dark,
statusBarBrightness: themeBrightness,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness:
themeBrightness == Brightness.dark
? Brightness.light
: Brightness.dark,
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: overlayStyle,
child: Scaffold(
backgroundColor: Colors.transparent,
body: LayoutBuilder(
builder: (context, constraints) {
final isCompact = Breakpoints.isCompact(constraints.maxWidth);
return Stack(
children: [
Positioned.fill(
child: ShellBackdropLayer(active: effectiveImmersive),
),
if (isCompact)
_CompactBody(navigationShell: widget.navigationShell)
else
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Sidebar(
selectedBranchIndex:
widget.navigationShell.currentIndex,
onNavigateBranch: (index, {initialLocation = false}) =>
widget.navigationShell.goBranch(
index,
initialLocation: initialLocation,
),
location: widget.location,
immersiveRoute: effectiveImmersive,
),
Expanded(
child: RepaintBoundary(child: widget.navigationShell),
),
],
),
_AppTitleBarOverlay(immersive: effectiveImmersive),
],
);
},
),
),
);
}
}
class _CompactBody extends StatelessWidget {
final StatefulNavigationShell navigationShell;
const _CompactBody({required this.navigationShell});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
extendBody: true,
body: RepaintBoundary(child: navigationShell),
bottomNavigationBar: AppBottomNav(navigationShell: navigationShell),
);
}
}
class _AppTitleBarOverlay extends ConsumerWidget {
final bool immersive;
const _AppTitleBarOverlay({required this.immersive});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (!WindowChrome.isDesktop) {
return const SizedBox.shrink();
}
final tokens = context.appTokens;
final immersiveBackdrop =
immersive && ref.watch(shellBackdropProvider) != null;
if (!immersiveBackdrop) {
return const WindowChromeOverlay();
}
return Positioned(
top: 0,
left: 0,
right: 0,
height: tokens.titleBarHeight,
child: const Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x2E000000), Color(0x00000000)],
),
),
),
),
),
WindowChromeOverlay(onDarkSurface: true),
],
),
);
}
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import '../layout/adaptive_card_grid.dart';
import '../theme/app_theme.dart';
class AppSkeleton extends StatefulWidget {
final double? width;
final double? height;
final double radius;
const AppSkeleton({super.key, this.width, this.height, this.radius = 8});
@override
State<AppSkeleton> createState() => _AppSkeletonState();
}
class _AppSkeletonState extends State<AppSkeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1100),
)..repeat(reverse: true);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final base = Theme.of(context).colorScheme.onSurface;
return AnimatedBuilder(
animation: _c,
builder: (context, _) {
final alpha = 0.04 + 0.05 * _c.value;
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: base.withValues(alpha: alpha),
borderRadius: BorderRadius.circular(widget.radius),
),
);
},
);
}
}
class SkeletonGrid extends StatelessWidget {
final int itemCount;
final double itemHeight;
final double minCardWidth;
final EdgeInsetsGeometry padding;
const SkeletonGrid({
super.key,
this.itemCount = 6,
this.itemHeight = 84,
this.minCardWidth = kRichCardMinWidth,
this.padding = const EdgeInsets.symmetric(horizontal: 24),
});
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
return Padding(
padding: padding,
child: LayoutBuilder(
builder: (_, constraints) {
final columns = cardColumnsFor(
constraints.maxWidth,
minCardWidth: minCardWidth,
);
final width =
(constraints.maxWidth - kCardGap * (columns - 1)) / columns;
return Wrap(
spacing: kCardGap,
runSpacing: kCardGap,
children: [
for (var i = 0; i < itemCount; i++)
SizedBox(
width: width,
child: AppSkeleton(
height: itemHeight,
radius: tokens.radiusCard,
),
),
],
);
},
),
);
}
}
+90
View File
@@ -0,0 +1,90 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/appearance_provider.dart';
import '../theme/app_theme.dart';
import 'window_chrome.dart';
class AppSliverHeader extends ConsumerWidget {
final String? title;
final Widget? titleWidget;
final Widget? leading;
final bool automaticallyImplyLeading;
final List<Widget> actions;
final PreferredSizeWidget? bottom;
final double? expandedHeight;
final double? toolbarHeight;
const AppSliverHeader({
super.key,
this.title,
this.titleWidget,
this.leading,
this.automaticallyImplyLeading = true,
this.actions = const [],
this.bottom,
this.expandedHeight,
this.toolbarHeight,
}) : assert(
title != null || titleWidget != null,
'AppSliverHeader requires either title or titleWidget',
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode =
ref.watch(appearanceProvider).value?.headerMode ?? HeaderMode.frosted;
final isFrosted = mode == HeaderMode.frosted;
final theme = Theme.of(context);
final tokens = context.appTokens;
final reserved = WindowChrome.reservedTrailingWidth(tokens);
final effectiveActions = <Widget>[
...actions,
if (reserved > 0) SizedBox(width: reserved),
];
return SliverAppBar(
pinned: true,
elevation: 0,
scrolledUnderElevation: 0,
toolbarHeight: toolbarHeight ?? kToolbarHeight,
leading: leading,
automaticallyImplyLeading: automaticallyImplyLeading,
title:
titleWidget ??
(title != null
? Text(
title!,
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null),
actions: effectiveActions,
bottom: bottom,
expandedHeight: expandedHeight,
backgroundColor: isFrosted
? theme.colorScheme.surface.withValues(alpha: 0.7)
: theme.colorScheme.surface,
shape: isFrosted
? Border(
bottom: BorderSide(
color: theme.colorScheme.onSurface.withValues(alpha: 0.08),
width: 1,
),
)
: null,
flexibleSpace: isFrosted
? ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: const SizedBox.expand(),
),
)
: null,
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'app_inline_alert.dart';
enum AppSnackTone { neutral, error, success, warning }
void showAppSnackBar(
BuildContext context,
String message, {
AppSnackTone tone = AppSnackTone.neutral,
}) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
backgroundColor: _backgroundColor(tone),
),
);
}
void showAppToast(
BuildContext context,
String message, {
AppAlertTone tone = AppAlertTone.info,
}) {
showFToast(
context: context,
title: Text(message),
icon: Icon(_toastIcon(tone), size: 16),
variant: tone == AppAlertTone.error
? FToastVariant.destructive
: FToastVariant.primary,
);
}
Color? _backgroundColor(AppSnackTone tone) {
return switch (tone) {
AppSnackTone.neutral => null,
AppSnackTone.error => const Color(0xFFB42318),
AppSnackTone.success => const Color(0xFF107C41),
AppSnackTone.warning => const Color(0xFF9A6700),
};
}
IconData _toastIcon(AppAlertTone tone) {
return switch (tone) {
AppAlertTone.error => Icons.error_outline,
AppAlertTone.warning => Icons.warning_amber_rounded,
AppAlertTone.info => Icons.info_outline,
AppAlertTone.success => Icons.check_circle_outline,
};
}
+45
View File
@@ -0,0 +1,45 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:forui/forui.dart';
List<FItemGroupMixin> autoDismissMenuBuilder(
BuildContext context,
FPopoverController controller,
List<FItemGroupMixin>? menu,
) {
final groups = menu ?? const <FItemGroupMixin>[];
if (groups.isEmpty) return groups;
return [
_AutoDismissMenuGroup(controller: controller, children: groups),
];
}
class _AutoDismissMenuGroup extends StatelessWidget with FItemGroupMixin {
final FPopoverController controller;
final List<FItemGroupMixin> children;
const _AutoDismissMenuGroup({
required this.controller,
required this.children,
});
@override
Widget build(BuildContext context) {
final parent = FInheritedItemCallbacks.maybeOf(context);
return FInheritedItemCallbacks(
onHoverEnter: parent?.onHoverEnter,
onHoverExit: parent?.onHoverExit,
onPress: () {
parent?.onPress?.call();
unawaited(controller.hide());
},
onLongPress: () {
parent?.onLongPress?.call();
unawaited(controller.hide());
},
child: FItemGroup.merge(children: children),
);
}
}
@@ -0,0 +1,209 @@
import 'dart:async';
import 'package:flutter/material.dart';
mixin BannerCarouselMixin<T extends StatefulWidget>
on State<T>, WidgetsBindingObserver {
final PageController pageController = PageController();
Timer? _autoPlayTimer;
Timer? _resumeTimer;
int currentIndex = 0;
bool hovering = false;
bool _manualPaused = false;
bool _appPaused = false;
bool _autoPageChangeInProgress = false;
double? lastBannerWidth;
bool _tickerVisible = true;
static const autoInterval = Duration(seconds: 5);
static const _manualPauseDuration = Duration(seconds: 8);
static const slideDuration = Duration(milliseconds: 450);
static const slideCurve = Curves.easeInOutCubic;
int get itemCount;
bool get isActive;
void emitActiveItem();
void precacheAdjacentSlides();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
emitActiveItem();
syncAutoPlay();
});
}
@override
void dispose() {
_autoPlayTimer?.cancel();
_resumeTimer?.cancel();
pageController.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final visible = TickerMode.of(context);
if (visible == _tickerVisible) return;
_tickerVisible = visible;
if (visible) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_tickerVisible) return;
emitActiveItem();
precacheAdjacentSlides();
});
syncAutoPlay();
} else {
stopAutoPlay();
_resumeTimer?.cancel();
_manualPaused = false;
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final paused =
state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.hidden;
if (paused == _appPaused) return;
_appPaused = paused;
paused ? stopAutoPlay() : syncAutoPlay();
}
bool get shouldAutoPlay =>
mounted &&
isActive &&
_tickerVisible &&
!_appPaused &&
!hovering &&
!_manualPaused &&
itemCount > 1;
void syncAutoPlay() {
if (!shouldAutoPlay) {
stopAutoPlay();
return;
}
_autoPlayTimer ??= Timer.periodic(
autoInterval,
(_) => nextPage(manual: false),
);
}
void stopAutoPlay() {
_autoPlayTimer?.cancel();
_autoPlayTimer = null;
}
void setHovering(bool value) {
hovering = value;
value ? stopAutoPlay() : syncAutoPlay();
if (mounted) setState(() {});
}
void pauseForManual() {
if (!_manualPaused) setState(() => _manualPaused = true);
stopAutoPlay();
_resumeTimer?.cancel();
_resumeTimer = Timer(_manualPauseDuration, () {
if (!mounted) return;
setState(() => _manualPaused = false);
syncAutoPlay();
});
}
void onPageChanged(int index) {
setState(() => currentIndex = index);
emitActiveItem();
precacheAdjacentSlides();
if (_autoPageChangeInProgress) {
_autoPageChangeInProgress = false;
return;
}
pauseForManual();
}
void goToPage(int index) {
if (index < 0 || index >= itemCount) return;
if (!pageController.hasClients) return;
pageController.animateToPage(
index,
duration: slideDuration,
curve: slideCurve,
);
pauseForManual();
}
void nextPage({bool manual = true}) {
if (itemCount <= 1) return;
if (!pageController.hasClients) return;
final nextIndex = (currentIndex + 1) % itemCount;
if (!manual) _autoPageChangeInProgress = true;
pageController.animateToPage(
nextIndex,
duration: slideDuration,
curve: slideCurve,
);
if (manual) pauseForManual();
}
void prevPage() {
if (itemCount <= 1) return;
if (!pageController.hasClients) return;
final previousIndex = (currentIndex - 1 + itemCount) % itemCount;
pageController.animateToPage(
previousIndex,
duration: slideDuration,
curve: slideCurve,
);
pauseForManual();
}
int? bannerCacheWidth() {
final width = lastBannerWidth;
if (width == null || !width.isFinite || width <= 0) return null;
return (width * MediaQuery.devicePixelRatioOf(context)).ceil();
}
void resetCarouselItems() {
stopAutoPlay();
_resumeTimer?.cancel();
_manualPaused = false;
if (itemCount > 0 && pageController.hasClients) {
pageController.jumpToPage(0);
}
currentIndex = 0;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
emitActiveItem();
precacheAdjacentSlides();
syncAutoPlay();
});
}
void activateCarousel() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
emitActiveItem();
precacheAdjacentSlides();
syncAutoPlay();
});
}
void deactivateCarousel() {
stopAutoPlay();
_resumeTimer?.cancel();
_manualPaused = false;
}
}
@@ -0,0 +1,601 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../constants/breakpoints.dart';
import '../theme/app_theme.dart';
import 'banner_carousel_mixin.dart';
import 'circle_nav_icon_button.dart';
import 'frosted_panel.dart';
import 'smart_image.dart';
class BannerSlideData {
final String? imageUrl;
final String? fallbackImageUrl;
final Map<String, String>? imageHeaders;
final String? logoUrl;
final String title;
final String? overview;
final String? rating;
final List<String> primaryMetaLabels;
final List<String> secondaryMetaLabels;
const BannerSlideData({
required this.imageUrl,
required this.title,
this.fallbackImageUrl,
this.imageHeaders,
this.logoUrl,
this.overview,
this.rating,
this.primaryMetaLabels = const [],
this.secondaryMetaLabels = const [],
});
}
class BannerCarouselScaffold extends StatelessWidget {
final BannerCarouselMixin controller;
final List<BannerSlideData> slides;
final ValueChanged<int> onSlideTap;
final bool reserveLogoArea;
const BannerCarouselScaffold({
super.key,
required this.controller,
required this.slides,
required this.onSlideTap,
this.reserveLogoArea = false,
});
@override
Widget build(BuildContext context) {
if (slides.isEmpty) return const SizedBox.shrink();
final compact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
if (compact) return _buildCompact(context);
return _buildDesktop(context);
}
Widget _contentSwitcher({required bool compact}) {
return IgnorePointer(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) => Stack(
fit: StackFit.expand,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
),
child: _BannerSlideContent(
key: ValueKey(controller.currentIndex),
data: slides[controller.currentIndex],
compact: compact,
reserveLogoArea: reserveLogoArea,
),
),
);
}
Widget _pageIndicator() {
return FrostedPanel(
radius: 999,
enableBlur: false,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
color: Colors.black.withValues(alpha: 0.24),
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < slides.length; index++)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => controller.goToPage(index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 240),
curve: Curves.easeOut,
width: controller.currentIndex == index ? 22 : 7,
height: 7,
margin: EdgeInsets.only(
right: index == slides.length - 1 ? 0 : 6,
),
decoration: BoxDecoration(
color: controller.currentIndex == index
? Colors.white
: const Color(0x66FFFFFF),
borderRadius: BorderRadius.circular(999),
),
),
),
],
),
);
}
Widget _pageView({required bool compact}) {
return PageView.builder(
controller: controller.pageController,
physics: compact ? null : const NeverScrollableScrollPhysics(),
itemCount: slides.length,
onPageChanged: controller.onPageChanged,
itemBuilder: (context, index) {
return _BannerSlide(data: slides[index], compact: compact);
},
);
}
Widget _buildCompact(BuildContext context) {
final screenHeight = MediaQuery.sizeOf(context).height;
final height = screenHeight * 0.55;
final screenWidth = MediaQuery.sizeOf(context).width;
controller.lastBannerWidth = screenWidth.isFinite && screenWidth > 0
? screenWidth
: null;
return SizedBox(
height: height,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSlideTap(controller.currentIndex),
child: Stack(
fit: StackFit.expand,
children: [
_pageView(compact: true),
_contentSwitcher(compact: true),
Positioned(
left: 0,
right: 0,
bottom: 20,
child: Center(child: _pageIndicator()),
),
],
),
),
);
}
Widget _buildDesktop(BuildContext context) {
final tokens = context.appTokens;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
controller.lastBannerWidth = width.isFinite && width > 0
? width
: null;
final height = (width * 7 / 16).clamp(260.0, 500.0);
return SizedBox(
height: height,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens.panelRadius + 6),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => controller.setHovering(true),
onExit: (_) => controller.setHovering(false),
child: Stack(
fit: StackFit.expand,
children: [
_BlurredBackdropLayer(
data: slides[controller.currentIndex],
animateSwitch: slides.length > 1,
),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSlideTap(controller.currentIndex),
child: _pageView(compact: false),
),
_contentSwitcher(compact: false),
Positioned(right: 22, bottom: 20, child: _pageIndicator()),
if (controller.hovering && slides.length > 1) ...[
Positioned(
left: 14,
top: 0,
bottom: 0,
child: Center(
child: CircleNavIconButton(
icon: Icons.chevron_left_rounded,
onPressed: controller.prevPage,
iconSize: 28,
),
),
),
Positioned(
right: 14,
top: 0,
bottom: 0,
child: Center(
child: CircleNavIconButton(
icon: Icons.chevron_right_rounded,
onPressed: controller.nextPage,
iconSize: 28,
),
),
),
],
],
),
),
),
);
},
),
);
}
}
class _BlurredBackdropLayer extends StatelessWidget {
final BannerSlideData data;
final bool animateSwitch;
const _BlurredBackdropLayer({
required this.data,
required this.animateSwitch,
});
@override
Widget build(BuildContext context) {
final image = _BlurredBackdropImage(
key: ValueKey<String>(data.imageUrl ?? '__none__'),
data: data,
);
if (!animateSwitch) return image;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
fit: StackFit.expand,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: image,
);
}
}
class _BlurredBackdropImage extends StatelessWidget {
final BannerSlideData data;
const _BlurredBackdropImage({super.key, required this.data});
@override
Widget build(BuildContext context) {
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 36,
sigmaY: 36,
tileMode: TileMode.clamp,
),
child: SmartImage(
url: data.imageUrl,
fallbackUrls: [
if (data.fallbackImageUrl != null) data.fallbackImageUrl!,
],
httpHeaders: data.imageHeaders,
borderRadius: 0,
fit: BoxFit.cover,
memCacheWidth: 800,
fallbackIcon: null,
placeholder: const ColoredBox(color: Colors.black),
),
),
IgnorePointer(
child: ColoredBox(color: Colors.black.withValues(alpha: 0.30)),
),
],
),
);
}
}
class _BannerSlide extends StatelessWidget {
final BannerSlideData data;
final bool compact;
const _BannerSlide({required this.data, this.compact = false});
@override
Widget build(BuildContext context) {
if (compact) {
return Stack(
fit: StackFit.expand,
children: [
SmartImage(
url: data.imageUrl,
fallbackUrls: [
if (data.fallbackImageUrl != null) data.fallbackImageUrl!,
],
borderRadius: 0,
fit: BoxFit.cover,
httpHeaders: data.imageHeaders,
fallbackIcon: Icons.movie_outlined,
placeholder: const SizedBox.expand(),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.12, 0.4, 0.75, 1.0],
colors: [
Colors.black.withValues(alpha: 0.35),
Colors.black.withValues(alpha: 0.12),
Colors.black.withValues(alpha: 0.0),
Colors.black.withValues(alpha: 0.35),
Colors.black.withValues(alpha: 0.70),
],
),
),
),
],
);
}
return Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 36,
sigmaY: 36,
tileMode: TileMode.mirror,
),
child: SmartImage(
url: data.imageUrl,
fallbackUrls: [
if (data.fallbackImageUrl != null) data.fallbackImageUrl!,
],
borderRadius: 0,
fit: BoxFit.cover,
httpHeaders: data.imageHeaders,
fallbackIcon: null,
placeholder: const SizedBox.expand(),
),
),
DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.32),
),
),
SmartImage(
url: data.imageUrl,
fallbackUrls: [
if (data.fallbackImageUrl != null) data.fallbackImageUrl!,
],
borderRadius: 0,
fit: BoxFit.contain,
httpHeaders: data.imageHeaders,
fallbackIcon: Icons.movie_outlined,
placeholder: const SizedBox.expand(),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.35, 0.6, 1.0],
colors: [
Colors.black.withValues(alpha: 0.0),
Colors.black.withValues(alpha: 0.03),
Colors.black.withValues(alpha: 0.25),
Colors.black.withValues(alpha: 0.55),
],
),
),
),
],
);
}
}
class _BannerSlideContent extends StatelessWidget {
final BannerSlideData data;
final bool compact;
final bool reserveLogoArea;
const _BannerSlideContent({
super.key,
required this.data,
required this.compact,
required this.reserveLogoArea,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final ratingColor = tokens.ratingColor;
final hasMetaInfo =
data.rating != null ||
data.primaryMetaLabels.isNotEmpty ||
data.secondaryMetaLabels.isNotEmpty;
final titleFontSize = compact ? 24.0 : 32.0;
final titleText = Text(
data.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
color: Colors.white,
fontSize: titleFontSize,
height: 1.0,
),
);
final padding = compact
? const EdgeInsets.only(left: 24, bottom: 64, right: 24)
: const EdgeInsets.only(left: 32, bottom: 32);
return Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: padding,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (reserveLogoArea) _buildLogoArea(titleText) else titleText,
if (hasMetaInfo) ...[
const SizedBox(height: 10),
_MetaRow(
rating: data.rating,
ratingColor: ratingColor,
primaryLabels: data.primaryMetaLabels,
secondaryLabels: data.secondaryMetaLabels,
),
],
if ((data.overview ?? '').trim().isNotEmpty) ...[
const SizedBox(height: 12),
Text(
data.overview!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.white.withValues(alpha: 0.78),
height: 1.5,
),
),
],
],
),
),
),
);
}
Widget _buildLogoArea(Widget titleText) {
final logoHeight = compact ? 56.0 : 72.0;
final logoMaxWidth = compact ? 240.0 : 320.0;
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: logoMaxWidth),
child: SizedBox(
width: double.infinity,
height: logoHeight,
child: data.logoUrl != null
? CachedNetworkImage(
imageUrl: data.logoUrl!,
httpHeaders: data.imageHeaders,
fit: BoxFit.contain,
alignment: Alignment.bottomLeft,
memCacheWidth: 960,
fadeInDuration: const Duration(milliseconds: 300),
placeholderFadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 300),
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) =>
Align(alignment: Alignment.bottomLeft, child: titleText),
)
: Align(alignment: Alignment.bottomLeft, child: titleText),
),
);
}
}
class _MetaRow extends StatelessWidget {
final String? rating;
final Color ratingColor;
final List<String> primaryLabels;
final List<String> secondaryLabels;
const _MetaRow({
required this.rating,
required this.ratingColor,
required this.primaryLabels,
required this.secondaryLabels,
});
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodySmall;
final children = <Widget>[];
void addSep() {
children.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'·',
style: style?.copyWith(color: Colors.white.withValues(alpha: 0.38)),
),
),
);
}
if (rating != null) {
children.add(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star_rounded, size: 13, color: ratingColor),
const SizedBox(width: 3),
Text(
rating!,
style: style?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
for (final label in primaryLabels) {
if (children.isNotEmpty) addSep();
children.add(
Text(
label,
style: style?.copyWith(color: Colors.white.withValues(alpha: 0.80)),
),
);
}
for (final label in secondaryLabels) {
if (children.isNotEmpty) addSep();
children.add(
Text(
label,
style: style?.copyWith(color: Colors.white.withValues(alpha: 0.62)),
),
);
}
return Row(mainAxisSize: MainAxisSize.min, children: children);
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class CardProgressBar extends StatelessWidget {
final double progress;
final EdgeInsets padding;
const CardProgressBar({
super.key,
required this.progress,
this.padding = const EdgeInsets.only(left: 8, right: 8, bottom: 6),
});
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
return Positioned(
left: padding.left,
right: padding.right,
bottom: padding.bottom,
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: progress,
minHeight: 3,
backgroundColor: Colors.black.withValues(alpha: 0.35),
valueColor: AlwaysStoppedAnimation(tokens.progressBarColor),
),
),
);
}
}
+128
View File
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import '../constants/breakpoints.dart';
import 'hover_scrollable_row.dart';
import 'smart_image.dart';
class CastEntry {
final String name;
final String? subtitle;
final String? imageUrl;
final Map<String, String>? imageHeaders;
final VoidCallback? onTap;
const CastEntry({
required this.name,
this.subtitle,
this.imageUrl,
this.imageHeaders,
this.onTap,
});
}
class CastScroller extends StatelessWidget {
final List<CastEntry> entries;
final EdgeInsets padding;
final int maxCount;
const CastScroller({
super.key,
required this.entries,
this.padding = EdgeInsets.zero,
this.maxCount = 20,
});
@override
Widget build(BuildContext context) {
final count = entries.length.clamp(0, maxCount);
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
return SizedBox(
height: compact ? 146 : 164,
child: HoverScrollableRow(
builder: (_, controller) => ListView.separated(
controller: controller,
scrollDirection: Axis.horizontal,
padding: padding,
itemCount: count,
separatorBuilder: (_, _) => SizedBox(width: compact ? 12 : 16),
itemBuilder: (_, i) => _CastCard(entry: entries[i], compact: compact),
),
),
);
}
}
class _CastCard extends StatelessWidget {
final CastEntry entry;
final bool compact;
const _CastCard({required this.entry, this.compact = false});
@override
Widget build(BuildContext context) {
final avatarSize = compact ? 60.0 : 72.0;
final card = SizedBox(
width: compact ? 74 : 84,
child: Column(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(avatarSize / 2),
child: SizedBox(
width: avatarSize,
height: avatarSize,
child: SmartImage(
url: entry.imageUrl,
httpHeaders: entry.imageHeaders,
fallbackIcon: Icons.person,
borderRadius: avatarSize / 2,
),
),
),
SizedBox(height: compact ? 6 : 8),
Text(
entry.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: compact ? 11 : 12,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
if (entry.subtitle != null && entry.subtitle!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
entry.subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: compact ? 10 : 11,
color: Colors.white.withValues(alpha: 0.6),
),
),
],
],
),
);
if (entry.onTap == null) return card;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: entry.onTap,
child: card,
),
);
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'frosted_panel.dart';
class CircleNavIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback? onPressed;
final double iconSize;
final bool enabled;
const CircleNavIconButton({
super.key,
required this.icon,
required this.onPressed,
this.iconSize = 26,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return Opacity(
opacity: enabled ? 1.0 : 0.35,
child: FrostedPanel(
radius: 999,
enableBlur: false,
padding: const EdgeInsets.all(8),
color: Colors.black.withValues(alpha: 0.26),
border: Border.all(color: Colors.white.withValues(alpha: 0.12)),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onPressed,
customBorder: const CircleBorder(),
child: Icon(icon, color: Colors.white, size: iconSize),
),
),
),
);
}
}
+504
View File
@@ -0,0 +1,504 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:forui/forui.dart';
import '../constants/breakpoints.dart';
import 'icon_picker_dialog.dart';
import 'app_inline_alert.dart';
import 'app_loading_ring.dart';
import 'server_avatar.dart';
import 'setting_select.dart';
class ConnectionForm extends StatefulWidget {
final String mode;
final String? initialUrl;
final String? initialUsername;
final String? initialPassword;
final String? initialDisplayName;
final String? initialIconUrl;
final void Function(
String url,
String username,
String password,
String? displayName,
String iconUrl,
)
onConnect;
final VoidCallback? onCancel;
final bool loading;
final String? error;
const ConnectionForm({
super.key,
this.mode = 'add',
this.initialUrl,
this.initialUsername,
this.initialPassword,
this.initialDisplayName,
this.initialIconUrl,
required this.onConnect,
this.onCancel,
this.loading = false,
this.error,
});
@override
State<ConnectionForm> createState() => _ConnectionFormState();
}
class _ConnectionFormState extends State<ConnectionForm> {
final _formKey = GlobalKey<FormState>();
late String _protocol;
late final TextEditingController _host;
late final TextEditingController _port;
late final TextEditingController _displayName;
late final TextEditingController _username;
late final TextEditingController _password;
bool _obscurePassword = true;
late String _iconUrl;
@override
void initState() {
super.initState();
final parsed = _parseUrl(widget.initialUrl ?? '');
_protocol = parsed['protocol'] ?? 'https';
_host = TextEditingController(text: parsed['host'] ?? '');
_port = TextEditingController(text: parsed['port'] ?? '');
_displayName = TextEditingController(text: widget.initialDisplayName ?? '');
_username = TextEditingController(text: widget.initialUsername ?? '');
_password = TextEditingController(text: widget.initialPassword ?? '');
_iconUrl = widget.initialIconUrl ?? '';
}
@override
void dispose() {
_host.dispose();
_port.dispose();
_displayName.dispose();
_username.dispose();
_password.dispose();
super.dispose();
}
static Map<String, String?> _parseUrl(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return {'protocol': 'https', 'host': '', 'port': null};
try {
final toParse = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.parse(toParse);
final scheme = (uri.scheme == 'http' || uri.scheme == 'https')
? uri.scheme
: 'https';
return {
'protocol': scheme,
'host': uri.host.isEmpty ? trimmed : uri.host,
'port': uri.hasPort ? uri.port.toString() : null,
};
} catch (_) {
return {'protocol': 'https', 'host': trimmed, 'port': null};
}
}
void _syncFromUrl(String value) {
if (!value.contains('://')) return;
final parsed = _parseUrl(value);
final host = parsed['host'] ?? '';
if (host.isEmpty || host == value.trim()) return;
setState(() {
if (parsed['protocol'] != null) _protocol = parsed['protocol']!;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_host.text = host;
_host.selection = TextSelection.fromPosition(
TextPosition(offset: host.length),
);
_port.text = parsed['port'] ?? '';
});
}
String _buildUrl() {
final host = _host.text.trim();
final port = _port.text.trim();
return port.isNotEmpty ? '$_protocol://$host:$port' : '$_protocol://$host';
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
final url = _buildUrl();
final displayName = _displayName.text.trim();
widget.onConnect(
url,
_username.text.trim(),
_password.text,
displayName.isEmpty ? null : displayName,
_iconUrl,
);
}
Future<void> _pickIcon() async {
if (widget.loading) return;
final result = await showIconPickerDialog(
context,
currentIconUrl: _iconUrl,
);
if (result == null) return;
if (!mounted) return;
setState(() => _iconUrl = result);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isEdit = widget.mode == 'edit';
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
final protocolTextStyle = theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.w600,
);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(isEdit ? '编辑服务器' : '添加服务器', style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
isEdit ? '修改 Emby 服务器信息并重新登录' : '输入 Emby 服务器信息以连接',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isCompact)
_buildCompactEndpointFields(protocolTextStyle)
else
_buildDesktopEndpointFields(protocolTextStyle),
const SizedBox(height: 10),
_DisplayNameWithAvatar(
iconUrl: _iconUrl,
serverName: _displayName.text.trim().isNotEmpty
? _displayName.text.trim()
: (_host.text.trim().isNotEmpty ? _host.text.trim() : 'S'),
controller: _displayName,
enabled: !widget.loading,
onPick: _pickIcon,
),
const SizedBox(height: 10),
TextFormField(
controller: _username,
decoration: const InputDecoration(labelText: '用户名'),
enabled: !widget.loading,
autofillHints: const [AutofillHints.username],
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
validator: (v) =>
(v == null || v.trim().isEmpty) ? '用户名不能为空' : null,
),
const SizedBox(height: 10),
TextFormField(
controller: _password,
decoration: InputDecoration(
labelText: '密码',
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: widget.loading
? null
: () => setState(
() => _obscurePassword = !_obscurePassword,
),
),
),
enabled: !widget.loading,
obscureText: _obscurePassword,
autofillHints: const [AutofillHints.password],
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onFieldSubmitted: (_) {
if (!widget.loading) _submit();
},
),
if (widget.error != null) ...[
const SizedBox(height: 12),
AppInlineAlert(message: widget.error!),
],
const SizedBox(height: 20),
_buildActions(isCompact),
],
),
),
],
);
}
Widget _buildDesktopEndpointFields(TextStyle? protocolTextStyle) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 124,
child: _ProtocolSelect(
value: _protocol,
enabled: !widget.loading,
onChanged: (v) {
if (v != null) setState(() => _protocol = v);
},
),
),
const SizedBox(width: 8),
Expanded(child: _buildHostField()),
const SizedBox(width: 8),
SizedBox(width: 80, child: _buildPortField()),
],
);
}
Widget _buildCompactEndpointFields(TextStyle? protocolTextStyle) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHostField(),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 5,
child: _ProtocolSelect(
value: _protocol,
enabled: !widget.loading,
onChanged: (v) {
if (v != null) setState(() => _protocol = v);
},
),
),
const SizedBox(width: 12),
Expanded(flex: 6, child: _buildPortField()),
],
),
],
);
}
Widget _buildHostField() {
return Focus(
onFocusChange: (hasFocus) {
if (!hasFocus) _syncFromUrl(_host.text);
},
child: TextFormField(
controller: _host,
decoration: const InputDecoration(
labelText: '服务器地址',
hintText: 'example.com',
),
enabled: !widget.loading,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
onChanged: _syncFromUrl,
validator: (v) => (v == null || v.trim().isEmpty) ? '服务器地址不能为空' : null,
),
);
}
Widget _buildPortField() {
return TextFormField(
controller: _port,
decoration: const InputDecoration(labelText: '端口', hintText: '443'),
enabled: !widget.loading,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (v) {
if (v == null || v.trim().isEmpty) return null;
final n = int.tryParse(v.trim());
if (n == null || n < 1 || n > 65535) return '1-65535';
return null;
},
);
}
Widget _buildActions(bool isCompact) {
final saveButton = FButton(
variant: FButtonVariant.primary,
onPress: widget.loading ? null : _submit,
child: widget.loading
? const AppLoadingRing(size: 16, color: Colors.white)
: const Text('保存'),
);
if (!isCompact) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (widget.onCancel != null)
FButton(
variant: FButtonVariant.outline,
onPress: widget.loading ? null : widget.onCancel,
child: const Text('取消'),
),
const SizedBox(width: 8),
saveButton,
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 48, child: saveButton),
if (widget.onCancel != null) ...[
const SizedBox(height: 8),
SizedBox(
height: 44,
child: FButton(
variant: FButtonVariant.outline,
onPress: widget.loading ? null : widget.onCancel,
child: const Text('取消'),
),
),
],
],
);
}
}
class _ProtocolSelect extends StatelessWidget {
final String value;
final bool enabled;
final ValueChanged<String?> onChanged;
const _ProtocolSelect({
required this.value,
required this.enabled,
required this.onChanged,
});
static const _protocols = ['https', 'http'];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'协议',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
SettingSelect<String>(
value: value,
enabled: enabled,
options: _protocols,
labelOf: (value) => value.toUpperCase(),
onChanged: onChanged,
),
],
);
}
}
class _DisplayNameWithAvatar extends StatelessWidget {
final String iconUrl;
final String serverName;
final TextEditingController controller;
final bool enabled;
final VoidCallback onPick;
const _DisplayNameWithAvatar({
required this.iconUrl,
required this.serverName,
required this.controller,
required this.enabled,
required this.onPick,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Tooltip(
message: '点击更换图标',
child: InkWell(
onTap: enabled ? onPick : null,
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 62,
height: 62,
child: Stack(
children: [
Positioned(
left: 0,
top: 0,
child: ServerAvatar(
name: serverName,
iconUrl: iconUrl,
size: 56,
),
),
Positioned(
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: theme.dividerColor,
width: 0.5,
),
),
child: Icon(
Icons.edit_outlined,
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: controller,
decoration: const InputDecoration(
labelText: '展示名称',
hintText: '留空则使用服务器名称',
),
enabled: enabled,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
),
),
],
);
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
class EmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String? message;
final Widget? action;
final Color? foregroundColor;
final bool compact;
const EmptyState({
super.key,
this.icon = Icons.inbox_outlined,
required this.title,
this.message,
this.action,
this.foregroundColor,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconSize = compact ? 28.0 : 56.0;
final pad = compact ? 16.0 : 32.0;
final iconGap = compact ? 8.0 : 18.0;
final messageGap = compact ? 8.0 : 10.0;
final actionGap = compact ? 8.0 : 20.0;
final iconAlpha = compact ? 0.5 : 0.4;
final titleStyle = compact
? theme.textTheme.bodyMedium
: theme.textTheme.titleMedium;
final messageText = message == null
? null
: Text(
message!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color:
foregroundColor?.withValues(alpha: 0.72) ??
theme.colorScheme.onSurfaceVariant,
),
);
return Center(
child: Padding(
padding: EdgeInsets.all(pad),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
color: (foregroundColor ?? theme.colorScheme.onSurfaceVariant)
.withValues(alpha: iconAlpha),
),
SizedBox(height: iconGap),
Text(title, style: titleStyle?.copyWith(color: foregroundColor)),
if (messageText != null) ...[
SizedBox(height: messageGap),
compact
? messageText
: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: messageText,
),
],
if (action != null) ...[SizedBox(height: actionGap), action!],
],
),
),
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../../core/contracts/library.dart';
import '../mappers/media_image_url.dart';
import '../mappers/tmdb_image_selector.dart';
import 'smart_image.dart';
class EpisodeThumb extends StatelessWidget {
final EmbyRawItem episode;
final String seriesId;
final String baseUrl;
final String token;
final String? tmdbStillUrl;
final double? aspectRatio;
final double borderRadius;
final int maxWidth;
final IconData? fallbackIcon;
final BoxFit fit;
const EpisodeThumb({
super.key,
required this.episode,
required this.seriesId,
required this.baseUrl,
required this.token,
this.tmdbStillUrl,
this.aspectRatio,
this.borderRadius = 0,
this.maxWidth = 480,
this.fallbackIcon = Icons.movie,
this.fit = BoxFit.cover,
});
@override
Widget build(BuildContext context) {
final embyThumbUrl = EmbyImageUrl.thumb(
baseUrl: baseUrl,
token: token,
item: episode,
maxWidth: maxWidth,
);
final primaryUrl = tmdbStillUrl ?? embyThumbUrl;
final fallbackUrls = TmdbImageSelector.buildFallbackUrls(
primaryUrl: primaryUrl,
candidates: [
embyThumbUrl,
EmbyImageUrl.backdrop(
baseUrl: baseUrl,
token: token,
item: episode,
maxWidth: maxWidth,
),
EmbyImageUrl.fanart(baseUrl: baseUrl, token: token, itemId: seriesId),
EmbyImageUrl.primaryById(
baseUrl: baseUrl,
token: token,
itemId: seriesId,
maxHeight: maxWidth,
),
],
);
return SmartImage(
url: primaryUrl,
fallbackUrls: fallbackUrls,
aspectRatio: aspectRatio,
borderRadius: borderRadius,
fallbackIcon: fallbackIcon,
fit: fit,
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'app_error_state.dart';
import 'media_row_metrics.dart';
import 'media_poster_card.dart';
class ErrorMediaRow extends StatelessWidget {
final String title;
final String message;
final VoidCallback onRetry;
final MediaCardVariant cardVariant;
final Color? titleColor;
const ErrorMediaRow({
super.key,
required this.title,
required this.onRetry,
this.message = '加载失败',
this.cardVariant = MediaCardVariant.poster,
this.titleColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final compact = MediaRowMetrics.isCompact(context);
final rowHeight = MediaRowMetrics.rowHeight(cardVariant, compact: compact);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(color: titleColor),
),
),
SizedBox(
height: rowHeight,
child: AppErrorState(
message: message,
compact: true,
action: FButton(
variant: FButtonVariant.outline,
onPress: onRetry,
child: const Text('重试'),
),
),
),
],
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
class FrostedPanel extends StatelessWidget {
final Widget child;
final double radius;
final double blurSigma;
final bool enableBlur;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final Color color;
final Gradient? gradient;
final BoxBorder? border;
final List<BoxShadow> boxShadow;
const FrostedPanel({
super.key,
required this.child,
this.radius = 24,
this.blurSigma = 18,
this.enableBlur = true,
this.padding = EdgeInsets.zero,
this.margin,
this.color = Colors.black26,
this.gradient,
this.border,
this.boxShadow = const <BoxShadow>[],
});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.circular(radius);
final content = DecoratedBox(
decoration: BoxDecoration(
color: gradient == null ? color : null,
gradient: gradient,
borderRadius: borderRadius,
border: border,
),
child: Padding(padding: padding, child: child),
);
return Padding(
padding: margin ?? EdgeInsets.zero,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: borderRadius,
boxShadow: boxShadow,
),
child: ClipRRect(
borderRadius: borderRadius,
child: enableBlur
? BackdropFilter(
filter: ImageFilter.blur(
sigmaX: blurSigma,
sigmaY: blurSigma,
),
child: content,
)
: content,
),
),
);
}
}
@@ -0,0 +1,191 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'circle_nav_icon_button.dart';
typedef HoverScrollableRowBuilder =
Widget Function(BuildContext context, ScrollController controller);
class _MouseDragScrollBehavior extends MaterialScrollBehavior {
const _MouseDragScrollBehavior();
@override
Set<PointerDeviceKind> get dragDevices => const {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
};
}
class HoverScrollableRow extends StatefulWidget {
final HoverScrollableRowBuilder builder;
final ScrollController? controller;
final double scrollFraction;
final double buttonInset;
final double iconSize;
const HoverScrollableRow({
super.key,
required this.builder,
this.controller,
this.scrollFraction = 0.6,
this.buttonInset = 10,
this.iconSize = 26,
});
@override
State<HoverScrollableRow> createState() => _HoverScrollableRowState();
}
class _HoverScrollableRowState extends State<HoverScrollableRow> {
late ScrollController _controller;
bool _ownsController = false;
bool _hovered = false;
bool _canScrollLeft = false;
bool _canScrollRight = false;
@override
void initState() {
super.initState();
_attachController(widget.controller);
WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState());
}
@override
void didUpdateWidget(covariant HoverScrollableRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
_detachController();
_attachController(widget.controller);
WidgetsBinding.instance.addPostFrameCallback((_) => _updateScrollState());
}
}
void _attachController(ScrollController? external) {
if (external != null) {
_controller = external;
_ownsController = false;
} else {
_controller = ScrollController();
_ownsController = true;
}
_controller.addListener(_updateScrollState);
}
void _detachController() {
_controller.removeListener(_updateScrollState);
if (_ownsController) {
_controller.dispose();
}
}
@override
void dispose() {
_detachController();
super.dispose();
}
void _updateScrollState() {
if (!mounted || !_controller.hasClients) return;
final maxScroll = _controller.position.maxScrollExtent;
final offset = _controller.offset;
final canLeft = offset > 1;
final canRight = maxScroll - offset > 1;
if (canLeft != _canScrollLeft || canRight != _canScrollRight) {
setState(() {
_canScrollLeft = canLeft;
_canScrollRight = canRight;
});
}
}
void _scrollByPage({required bool left}) {
if (!_controller.hasClients) return;
final viewport = _controller.position.viewportDimension;
final delta = viewport * widget.scrollFraction;
final target = (_controller.offset + (left ? -delta : delta)).clamp(
0.0,
_controller.position.maxScrollExtent,
);
_controller.animateTo(
target,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
final hasOverflow = _canScrollLeft || _canScrollRight;
final showPager = _hovered && hasOverflow;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: NotificationListener<ScrollMetricsNotification>(
onNotification: (_) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _updateScrollState(),
);
return false;
},
child: Stack(
children: [
ScrollConfiguration(
behavior: const _MouseDragScrollBehavior(),
child: widget.builder(context, _controller),
),
if (hasOverflow)
Positioned(
left: widget.buttonInset,
top: 0,
bottom: 0,
child: Center(
child: AnimatedOpacity(
opacity: showPager ? 1 : 0,
duration: const Duration(milliseconds: 140),
child: IgnorePointer(
ignoring: !showPager || !_canScrollLeft,
child: CircleNavIconButton(
icon: Icons.chevron_left_rounded,
iconSize: widget.iconSize,
enabled: _canScrollLeft,
onPressed: _canScrollLeft
? () => _scrollByPage(left: true)
: null,
),
),
),
),
),
if (hasOverflow)
Positioned(
right: widget.buttonInset,
top: 0,
bottom: 0,
child: Center(
child: AnimatedOpacity(
opacity: showPager ? 1 : 0,
duration: const Duration(milliseconds: 140),
child: IgnorePointer(
ignoring: !showPager || !_canScrollRight,
child: CircleNavIconButton(
icon: Icons.chevron_right_rounded,
iconSize: widget.iconSize,
enabled: _canScrollRight,
onPressed: _canScrollRight
? () => _scrollByPage(left: false)
: null,
),
),
),
),
),
],
),
),
);
}
}
+338
View File
@@ -0,0 +1,338 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/contracts/icon_library.dart';
import '../../features/settings/settings_page.dart';
import '../../providers/icon_library_provider.dart';
import 'adaptive_modal.dart';
import 'app_error_state.dart';
import 'app_loading_ring.dart';
import 'app_skeleton.dart';
import '../utils/user_error_formatter.dart';
Future<String?> showIconPickerDialog(
BuildContext context, {
String currentIconUrl = '',
}) {
return showAdaptiveModal<String>(
context: context,
maxWidth: 720,
maxHeight: 600,
compactHeightFactor: 0.88,
builder: (_) => _IconPickerDialog(currentIconUrl: currentIconUrl),
);
}
class _IconPickerDialog extends ConsumerStatefulWidget {
final String currentIconUrl;
const _IconPickerDialog({required this.currentIconUrl});
@override
ConsumerState<_IconPickerDialog> createState() => _IconPickerDialogState();
}
class _IconPickerDialogState extends ConsumerState<_IconPickerDialog> {
String _query = '';
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final state = ref.watch(iconLibraryProvider);
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'选择服务器图标',
style: theme.textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (widget.currentIconUrl.isNotEmpty) ...[
const SizedBox(width: 8),
TextButton.icon(
style: TextButton.styleFrom(
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
onPressed: () => Navigator.of(context).pop(''),
icon: const Icon(Icons.do_disturb_on_outlined, size: 18),
label: const Text('清除图标'),
),
],
IconButton(
tooltip: '关闭',
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _searchController,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search, size: 18),
hintText: '按名称搜索图标',
isDense: true,
),
textInputAction: TextInputAction.search,
autocorrect: false,
enableSuggestions: false,
onChanged: (v) => setState(() => _query = v.trim().toLowerCase()),
),
const SizedBox(height: 8),
Expanded(
child: state.when(
data: (data) => _buildBody(theme, data),
loading: () => const Center(child: AppLoadingRing()),
error: (e, _) => AppErrorState(
message: formatUserError(e, fallback: '图标库加载失败'),
compact: true,
onRetry: () =>
ref.read(iconLibraryProvider.notifier).refreshAll(),
),
),
),
],
),
);
}
Widget _buildBody(ThemeData theme, IconLibrarySettingsState data) {
if (data.entries.isEmpty) {
return _EmptyState(
title: '尚未配置图标库',
message: '前往「设置 → 图标库」添加 JSON 来源 URL',
actionLabel: '前往设置',
onAction: () {
Navigator.of(context).pop();
showSettingsDialog(context, initialTab: 'icon-library');
},
);
}
return FTabs(
scrollable: true,
expands: true,
contentPhysics: const NeverScrollableScrollPhysics(),
children: [
for (final entry in data.entries)
FTabEntry(
label: _libraryLabel(entry),
child: _buildLibraryTab(entry),
),
],
);
}
Widget _libraryLabel(IconLibraryEntryState entry) {
final name = entry.library?.name;
final label = (name != null && name.isNotEmpty)
? name
: Uri.tryParse(entry.url)?.host ?? entry.url;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
if (entry.status == IconLibraryStatus.loading) ...[
const SizedBox(width: 6),
const AppLoadingRing(size: 10),
],
],
);
}
Widget _buildLibraryTab(IconLibraryEntryState entry) {
final theme = Theme.of(context);
if (entry.status == IconLibraryStatus.loading) {
return const Center(child: AppLoadingRing());
}
if (entry.status == IconLibraryStatus.error) {
return AppErrorState(
message: entry.error ?? '',
compact: true,
onRetry: () =>
ref.read(iconLibraryProvider.notifier).refresh(entry.url),
);
}
final lib = entry.library;
if (lib == null) {
return const Center(child: AppLoadingRing());
}
final filtered = _query.isEmpty
? lib.icons
: lib.icons
.where((i) => i.name.toLowerCase().contains(_query))
.toList();
if (filtered.isEmpty) {
return Center(
child: Text(
_query.isNotEmpty ? '无匹配图标' : '图标库为空',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
return GridView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 76,
childAspectRatio: 0.85,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: filtered.length,
itemBuilder: (_, i) {
final icon = filtered[i];
return _IconTile(
entry: icon,
selected: icon.url == widget.currentIconUrl,
onTap: () => Navigator.of(context).pop(icon.url),
);
},
);
}
}
class _IconTile extends StatelessWidget {
final IconEntry entry;
final bool selected;
final VoidCallback onTap;
const _IconTile({
required this.entry,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final accent = theme.colorScheme.primary;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Tooltip(
message: entry.name,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: selected
? accent
: theme.colorScheme.outlineVariant.withValues(
alpha: 0.3,
),
width: selected ? 2 : 1,
),
borderRadius: BorderRadius.circular(8),
),
padding: const EdgeInsets.all(6),
child: CachedNetworkImage(
imageUrl: entry.url,
fit: BoxFit.contain,
memCacheWidth: 128,
placeholder: (_, _) =>
const SizedBox.expand(child: AppSkeleton(radius: 6)),
errorWidget: (_, _, _) => Icon(
Icons.broken_image_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.4,
),
),
),
),
),
const SizedBox(height: 4),
Text(
entry.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
fontSize: 10,
color: selected ? accent : theme.colorScheme.onSurfaceVariant,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
),
),
);
}
}
class _EmptyState extends StatelessWidget {
final String title;
final String message;
final String actionLabel;
final VoidCallback onAction;
const _EmptyState({
required this.title,
required this.message,
required this.actionLabel,
required this.onAction,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.image_search_outlined,
size: 36,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 12),
Text(title, style: theme.textTheme.titleSmall),
const SizedBox(height: 6),
Text(
message,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
OutlinedButton(onPressed: onAction, child: Text(actionLabel)),
],
),
);
}
}
+194
View File
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import '../../core/contracts/library.dart';
import '../theme/app_theme.dart';
import 'hover_scrollable_row.dart';
import 'media_row_metrics.dart';
import 'media_poster_card.dart';
class LatestMediaRow extends StatefulWidget {
final String title;
final String? subtitle;
final List<EmbyRawItem> items;
final String? Function(EmbyRawItem) imageUrlBuilder;
final String? Function(EmbyRawItem)? preloadImageUrlBuilder;
final void Function(EmbyRawItem) onItemTap;
final VoidCallback? onSeeAll;
final int? totalCount;
final MediaCardVariant cardVariant;
final bool enableContextMenu;
final Map<String, bool>? favoriteMap;
final Map<String, bool>? playedMap;
final Map<String, bool>? favoriteLoadingMap;
final Map<String, bool>? playedLoadingMap;
final void Function(EmbyRawItem)? onAddFavorite;
final void Function(EmbyRawItem)? onMarkPlayed;
final void Function(EmbyRawItem)? onHideFromResume;
final Map<String, bool>? hideFromResumeLoadingMap;
final bool showUnplayedCountBadge;
final bool showHoverPlayIcon;
final String? activeItemId;
final String? fallbackImageUrl;
final Map<String, String>? imageHeaders;
final Color? titleColor;
const LatestMediaRow({
super.key,
required this.title,
this.subtitle,
required this.items,
required this.imageUrlBuilder,
this.preloadImageUrlBuilder,
required this.onItemTap,
this.onSeeAll,
this.totalCount,
this.cardVariant = MediaCardVariant.poster,
this.enableContextMenu = false,
this.favoriteMap,
this.playedMap,
this.favoriteLoadingMap,
this.playedLoadingMap,
this.onAddFavorite,
this.onMarkPlayed,
this.onHideFromResume,
this.hideFromResumeLoadingMap,
this.showUnplayedCountBadge = true,
this.showHoverPlayIcon = true,
this.activeItemId,
this.fallbackImageUrl,
this.imageHeaders,
this.titleColor,
});
@override
State<LatestMediaRow> createState() => _LatestMediaRowState();
}
class _LatestMediaRowState extends State<LatestMediaRow> {
@override
Widget build(BuildContext context) {
final compact = MediaRowMetrics.isCompact(context);
final rowHeight = MediaRowMetrics.rowHeight(
widget.cardVariant,
compact: compact,
);
final cardWidth = MediaRowMetrics.cardWidth(
widget.cardVariant,
compact: compact,
);
final theme = Theme.of(context);
final tokens = context.appTokens;
final titleColor = widget.titleColor;
final mutedColor = titleColor != null
? titleColor.withValues(alpha: 0.55)
: tokens.mutedForeground;
final seeAllColor = titleColor != null
? titleColor.withValues(alpha: 0.78)
: theme.colorScheme.onSurface.withValues(alpha: 0.82);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 10),
child: Row(
children: [
Expanded(
child: Row(
children: [
Flexible(
child: Text(
widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: titleColor,
),
),
),
if (widget.subtitle != null) ...[
const SizedBox(width: 8),
Text(
'· ${widget.subtitle}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: mutedColor,
),
),
],
],
),
),
if (widget.onSeeAll != null)
TextButton(
onPressed: widget.onSeeAll,
style: TextButton.styleFrom(
foregroundColor: seeAllColor,
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
child: Text(
widget.totalCount != null && widget.totalCount! > 0
? '查看全部 ${widget.totalCount}'
: '查看全部',
),
),
],
),
),
SizedBox(
height: rowHeight,
child: HoverScrollableRow(
builder: (_, controller) => ListView.separated(
controller: controller,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 24),
itemCount: widget.items.length,
separatorBuilder: (_, index) => const SizedBox(width: 14),
itemBuilder: (_, index) {
final item = widget.items[index];
final isActive = widget.activeItemId == item.Id;
return MediaPosterCard(
item: item,
imageUrl: widget.imageUrlBuilder(item),
onTap: () => widget.onItemTap(item),
width: cardWidth,
variant: widget.cardVariant,
showHoverPlayIcon: widget.showHoverPlayIcon,
enableContextMenu: widget.enableContextMenu,
isFavoriteOverride:
widget.favoriteMap?[item.Id] ?? item.UserData?.IsFavorite,
isPlayedOverride:
widget.playedMap?[item.Id] ?? item.UserData?.Played,
favoriteLoading: widget.favoriteLoadingMap?[item.Id] ?? false,
playedLoading: widget.playedLoadingMap?[item.Id] ?? false,
onAddFavorite: widget.onAddFavorite != null
? () => widget.onAddFavorite!(item)
: null,
onMarkPlayed: widget.onMarkPlayed != null
? () => widget.onMarkPlayed!(item)
: null,
onHideFromResume: widget.onHideFromResume != null
? () => widget.onHideFromResume!(item)
: null,
hideFromResumeLoading:
widget.hideFromResumeLoadingMap?[item.Id] ?? false,
showUnplayedCountBadge: widget.showUnplayedCountBadge,
isActive: isActive,
fallbackImageUrl: widget.fallbackImageUrl,
preloadImageUrl: widget.preloadImageUrlBuilder?.call(item),
imageHeaders: widget.imageHeaders,
textColor: widget.titleColor,
);
},
),
),
),
],
);
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart';
import '../../core/contracts/server.dart';
import '../../providers/session_provider.dart';
import 'app_loading_ring.dart';
import 'app_inline_alert.dart';
class LoginForm extends ConsumerStatefulWidget {
final EmbyServer server;
final VoidCallback? onSuccess;
final VoidCallback? onBack;
const LoginForm({
super.key,
required this.server,
this.onSuccess,
this.onBack,
});
@override
ConsumerState<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends ConsumerState<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _username = TextEditingController();
final _password = TextEditingController();
bool _loading = false;
bool _obscure = true;
String? _error;
@override
void dispose() {
_username.dispose();
_password.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_loading = true;
_error = null;
});
try {
await ref
.read(sessionProvider.notifier)
.login(
serverId: widget.server.id,
username: _username.text.trim(),
password: _password.text,
);
if (!mounted) return;
widget.onSuccess?.call();
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('登录到 ${widget.server.name}', style: theme.textTheme.titleLarge),
const SizedBox(height: 4),
Text(
widget.server.baseUrl,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
TextFormField(
controller: _username,
decoration: const InputDecoration(labelText: '用户名'),
autofillHints: const [AutofillHints.username],
validator: (v) => (v == null || v.trim().isEmpty) ? '请输入用户名' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _password,
decoration: InputDecoration(
labelText: '密码',
suffixIcon: IconButton(
icon: Icon(
_obscure
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
obscureText: _obscure,
autofillHints: const [AutofillHints.password],
onFieldSubmitted: (_) => _submit(),
),
if (_error != null) ...[
const SizedBox(height: 12),
AppInlineAlert(message: _error!),
],
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (widget.onBack != null)
FButton(
variant: FButtonVariant.ghost,
onPress: _loading ? null : widget.onBack,
child: const Text('返回'),
),
const SizedBox(width: 8),
FButton(
variant: FButtonVariant.primary,
onPress: _loading ? null : _submit,
child: _loading
? const AppLoadingRing(size: 16, color: Colors.white)
: const Text('登录'),
),
],
),
],
),
);
}
}
+389
View File
@@ -0,0 +1,389 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../core/contracts/library.dart';
import '../theme/app_theme.dart';
import '../utils/emby_ticks.dart';
import '../utils/format_utils.dart';
import 'auto_dismiss_menu.dart';
import 'card_progress_bar.dart';
import 'smart_image.dart';
enum MediaCardVariant { poster, landscape }
class MediaPosterCard extends StatefulWidget {
final EmbyRawItem item;
final String? imageUrl;
final VoidCallback? onTap;
final double width;
final MediaCardVariant variant;
final bool showHoverPlayIcon;
final bool enableContextMenu;
final bool? isFavoriteOverride;
final bool? isPlayedOverride;
final VoidCallback? onAddFavorite;
final VoidCallback? onMarkPlayed;
final VoidCallback? onHideFromResume;
final bool favoriteLoading;
final bool playedLoading;
final bool hideFromResumeLoading;
final bool showUnplayedCountBadge;
final bool isActive;
final String? fallbackImageUrl;
final String? preloadImageUrl;
final Map<String, String>? imageHeaders;
final Color? textColor;
const MediaPosterCard({
super.key,
required this.item,
required this.imageUrl,
this.onTap,
this.width = 160,
this.variant = MediaCardVariant.poster,
this.showHoverPlayIcon = true,
this.enableContextMenu = false,
this.isFavoriteOverride,
this.isPlayedOverride,
this.onAddFavorite,
this.onMarkPlayed,
this.onHideFromResume,
this.favoriteLoading = false,
this.playedLoading = false,
this.hideFromResumeLoading = false,
this.showUnplayedCountBadge = true,
this.isActive = false,
this.fallbackImageUrl,
this.preloadImageUrl,
this.imageHeaders,
this.textColor,
});
@override
State<MediaPosterCard> createState() => _MediaPosterCardState();
}
class _MediaPosterCardState extends State<MediaPosterCard> {
bool _hovered = false;
String? _preloadedImageUrl;
bool get _isFavorite =>
widget.isFavoriteOverride ?? (widget.item.UserData?.IsFavorite ?? false);
bool get _isPlayed =>
widget.isPlayedOverride ?? (widget.item.UserData?.Played ?? false);
double get _aspectRatio =>
widget.variant == MediaCardVariant.landscape ? 16 / 9 : 0.66;
String? _subtitleText() {
final item = widget.item;
if (widget.variant == MediaCardVariant.landscape) {
if (item.Type == 'Episode' && item.IndexNumber != null) {
final season = _seasonNumber(item);
final epLabel = formatEpisodeNumber(item.IndexNumber)!;
final locator = season != null && season > 1
? '${formatSeasonNumber(season)} $epLabel'
: epLabel;
return [
locator,
item.Name,
].where((segment) => segment.isNotEmpty).join(' · ');
}
if (item.Type == 'Episode') {
final seasonLabel = _seasonLabel(item);
if (seasonLabel != null) {
return [
seasonLabel,
item.Name,
].where((segment) => segment.isNotEmpty).join(' · ');
}
}
if (item.ProductionYear != null) return '${item.ProductionYear}';
return null;
}
if (item.Type == 'Episode' && item.SeriesName != null) {
final season = item.SeasonName ?? '';
final episode = formatEpisodeNumber(item.IndexNumber) ?? '';
return [
item.SeriesName,
season,
episode,
].where((segment) => segment != null && segment.isNotEmpty).join(' · ');
}
if (item.ProductionYear != null) return '${item.ProductionYear}';
return null;
}
int? _seasonNumber(EmbyRawItem item) =>
(item.extra['ParentIndexNumber'] as num?)?.toInt();
String? _seasonLabel(EmbyRawItem item) {
final name = item.SeasonName;
if (name != null && name.isNotEmpty) return name;
final season = (item.extra['ParentIndexNumber'] as num?)?.toInt();
if (season != null) return formatSeasonNumber(season);
return null;
}
String get _displayTitle {
if (widget.variant == MediaCardVariant.landscape) {
return widget.item.SeriesName ?? widget.item.Name;
}
return widget.item.Name;
}
List<FItem> _contextMenuItems() => [
if (widget.onAddFavorite != null)
FItem(
prefix: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
size: 16,
),
enabled: !widget.favoriteLoading,
title: Text(_isFavorite ? '取消收藏' : '添加到收藏'),
onPress: () => widget.onAddFavorite?.call(),
),
if (widget.onMarkPlayed != null)
FItem(
prefix: Icon(
_isPlayed ? Icons.check_circle : Icons.check_circle_outline,
size: 16,
),
enabled: !widget.playedLoading,
title: Text(_isPlayed ? '取消已观看' : '标记为已观看'),
onPress: () => widget.onMarkPlayed?.call(),
),
if (widget.onHideFromResume != null)
FItem(
prefix: const Icon(Icons.visibility_off_outlined, size: 16),
enabled: !widget.hideFromResumeLoading,
title: const Text('从“继续观看”中移除'),
onPress: () => widget.onHideFromResume?.call(),
),
];
void _preloadImage() {
final url = widget.preloadImageUrl;
if (url == null || url.isEmpty || _preloadedImageUrl == url) return;
_preloadedImageUrl = url;
var logicalWidth = widget.width;
if (!logicalWidth.isFinite || logicalWidth <= 0) {
logicalWidth = context.size?.width ?? 0;
}
final scaledWidth = logicalWidth * MediaQuery.devicePixelRatioOf(context);
final memCacheWidth = scaledWidth.isFinite && scaledWidth > 0
? scaledWidth.ceil()
: null;
unawaited(
precacheImage(
ResizeImage.resizeIfNeeded(
memCacheWidth,
null,
CachedNetworkImageProvider(url, headers: widget.imageHeaders),
),
context,
onError: (_, _) {},
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final progress = playbackProgress(widget.item);
final unplayed = widget.item.UserData?.UnplayedItemCount ?? 0;
final showUnplayed = widget.showUnplayedCountBadge && unplayed > 0;
final resolvedImageUrl = widget.imageUrl ?? widget.fallbackImageUrl;
final radius = tokens.posterRadius;
final remainingLabel = formatRemainingLabel(widget.item);
final subtitleText = _subtitleText();
final menuItems = widget.enableContextMenu ? _contextMenuItems() : <FItem>[];
final hasMenu = menuItems.isNotEmpty;
Widget content = SizedBox(
width: widget.width,
child: MouseRegion(
onEnter: (_) {
_preloadImage();
setState(() => _hovered = true);
},
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap == null
? null
: () {
_preloadImage();
widget.onTap!();
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: AnimatedOpacity(
opacity: _hovered ? 0.85 : 1.0,
duration: const Duration(milliseconds: 160),
child: Stack(
children: [
SmartImage(
url: resolvedImageUrl,
aspectRatio: _aspectRatio,
borderRadius: radius,
httpHeaders: widget.imageHeaders,
),
if (widget.showHoverPlayIcon)
Positioned.fill(
child: AnimatedOpacity(
opacity: _hovered ? 1.0 : 0.0,
duration: const Duration(milliseconds: 160),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Container(
color: Colors.black.withValues(alpha: 0.3),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
size: 32,
color: Colors.white,
),
if (remainingLabel != null) ...[
const SizedBox(height: 4),
Text(
remainingLabel,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
),
),
),
if (progress > 0) CardProgressBar(progress: progress),
if (_isPlayed)
Positioned(
top: 6,
left: 6,
child: Icon(
Icons.check_circle,
size: 16,
color: Colors.white.withValues(alpha: 0.9),
),
),
if (_isFavorite)
Positioned(
top: 6,
right: showUnplayed ? 32 : 6,
child: Icon(
Icons.favorite,
size: 14,
color: Colors.white.withValues(alpha: 0.9),
),
),
if (showUnplayed)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(2),
),
child: Text(
'$unplayed',
style: const TextStyle(
color: Colors.black,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
),
if (widget.isActive)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 2,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(radius),
bottomRight: Radius.circular(radius),
),
),
),
),
],
),
),
),
const SizedBox(height: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
color: widget.textColor,
),
),
if (subtitleText != null) ...[
const SizedBox(height: 2),
Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: widget.textColor != null
? widget.textColor!.withValues(alpha: 0.55)
: tokens.mutedForeground,
),
),
],
],
),
],
),
),
),
);
if (hasMenu) {
content = FContextMenu(
menuBuilder: autoDismissMenuBuilder,
menu: [FItemGroup(children: menuItems)],
child: content,
);
}
return content;
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'dart:io' show Platform;
import 'package:flutter/widgets.dart';
import '../constants/breakpoints.dart';
import 'media_poster_card.dart';
abstract final class MediaRowMetrics {
static const landscapeCompactWidth = 185.0;
static const landscapeExpandedWidth = 232.0;
static const posterCompactWidth = 130.0;
static const posterExpandedWidth = 158.0;
static const landscapeCompactHeight = 171.0;
static const landscapeExpandedHeight = 214.0;
static const posterCompactHeight = 250.0;
static const posterExpandedHeight = 304.0;
static const _landscapeAndroidWidth = 148.0;
static const _posterAndroidWidth = 110.0;
static const _landscapeAndroidHeight = 137.0;
static const _posterAndroidHeight = 211.0;
static final bool _isAndroid = Platform.isAndroid;
static bool isCompact(BuildContext context) =>
Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
static double cardWidth(MediaCardVariant variant, {required bool compact}) {
if (compact && _isAndroid) {
return switch (variant) {
MediaCardVariant.landscape => _landscapeAndroidWidth,
MediaCardVariant.poster => _posterAndroidWidth,
};
}
return switch (variant) {
MediaCardVariant.landscape =>
compact ? landscapeCompactWidth : landscapeExpandedWidth,
MediaCardVariant.poster =>
compact ? posterCompactWidth : posterExpandedWidth,
};
}
static double rowHeight(MediaCardVariant variant, {required bool compact}) {
if (compact && _isAndroid) {
return switch (variant) {
MediaCardVariant.landscape => _landscapeAndroidHeight,
MediaCardVariant.poster => _posterAndroidHeight,
};
}
return switch (variant) {
MediaCardVariant.landscape =>
compact ? landscapeCompactHeight : landscapeExpandedHeight,
MediaCardVariant.poster =>
compact ? posterCompactHeight : posterExpandedHeight,
};
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class MetadataChip extends StatelessWidget {
final IconData? icon;
final String label;
final Color? foreground;
final Color? background;
final bool dense;
const MetadataChip({
super.key,
required this.label,
this.icon,
this.foreground,
this.background,
this.dense = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final fg = foreground ?? tokens.mutedForeground;
final bg =
background ?? theme.colorScheme.onSurface.withValues(alpha: 0.07);
return Container(
padding: EdgeInsets.symmetric(horizontal: dense ? 6 : 7, vertical: 2),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 12, color: fg),
const SizedBox(width: 3),
],
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
fontSize: 10,
letterSpacing: 0.2,
),
),
),
],
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class PageBackground extends StatelessWidget {
const PageBackground({super.key});
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
final scheme = Theme.of(context).colorScheme;
final glow = scheme.brightness == Brightness.dark
? Colors.transparent
: scheme.primary.withValues(alpha: 0.05);
return RepaintBoundary(
child: Stack(
fit: StackFit.expand,
children: [
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [tokens.pageGradientTop, tokens.pageGradientBottom],
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: const Alignment(0, -0.85),
radius: 1.2,
colors: [glow, Colors.transparent],
stops: const [0.0, 1.0],
),
),
),
],
),
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class PillTabBar extends StatelessWidget {
final List<String> tabs;
final int selectedIndex;
final ValueChanged<int> onSelect;
final EdgeInsetsGeometry padding;
const PillTabBar({
super.key,
required this.tabs,
required this.selectedIndex,
required this.onSelect,
this.padding = EdgeInsets.zero,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final isDark = theme.brightness == Brightness.dark;
final activeBg = theme.colorScheme.primary.withValues(alpha: 0.12);
final activeFg = theme.colorScheme.primary;
final idleBg = isDark
? Colors.white.withValues(alpha: 0.04)
: Colors.black.withValues(alpha: 0.03);
return Padding(
padding: padding,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(tabs.length, (i) {
final selected = i == selectedIndex;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => onSelect(i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: selected ? activeBg : idleBg,
borderRadius: BorderRadius.circular(20),
),
child: Text(
tabs[i],
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? activeFg : tokens.mutedForeground,
),
),
),
),
);
}),
),
),
);
}
}
+153
View File
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import '../../core/contracts/script_widget.dart';
import '../theme/app_theme.dart';
import 'app_card.dart';
import 'metadata_chip.dart';
import 'smart_image.dart';
class ScriptVideoCard extends StatefulWidget {
final ScriptVideoItem item;
final double width;
final VoidCallback? onTap;
final Color? textColor;
const ScriptVideoCard({
super.key,
required this.item,
required this.width,
this.onTap,
this.textColor,
});
@override
State<ScriptVideoCard> createState() => _ScriptVideoCardState();
}
class _ScriptVideoCardState extends State<ScriptVideoCard> {
bool _isHovered = false;
String? get _imageUrl {
final imageCandidates = <String>[
widget.item.posterPath,
widget.item.coverUrl,
widget.item.detailPoster,
];
for (final imageCandidate in imageCandidates) {
if (imageCandidate.trim().isNotEmpty) return imageCandidate;
}
return null;
}
String? get _subtitle {
final subtitleParts = <String>[
if (widget.item.releaseDate.trim().isNotEmpty) widget.item.releaseDate,
if (widget.item.genreTitle.trim().isNotEmpty) widget.item.genreTitle,
];
return subtitleParts.isEmpty ? null : subtitleParts.join(' · ');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final rating = widget.item.rating;
final title = widget.item.title.trim().isEmpty
? widget.item.id
: widget.item.title;
return SizedBox(
width: widget.width,
child: AppCard(
filled: false,
enableHover: false,
radius: tokens.radiusCard,
padding: EdgeInsets.zero,
onTap: widget.onTap,
onHoverChanged: (isHovered) {
if (_isHovered == isHovered) return;
setState(() => _isHovered = isHovered);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
AnimatedOpacity(
opacity: _isHovered ? 0.84 : 1,
duration: const Duration(milliseconds: 160),
child: SmartImage(
url: _imageUrl,
fallbackIcon: Icons.movie_outlined,
borderRadius: tokens.posterRadius,
),
),
Positioned.fill(
child: AnimatedOpacity(
opacity: _isHovered && widget.onTap != null ? 1 : 0,
duration: const Duration(milliseconds: 160),
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.30),
borderRadius: BorderRadius.circular(
tokens.posterRadius,
),
),
child: const Center(
child: Icon(
Icons.play_arrow_rounded,
size: 32,
color: Colors.white,
),
),
),
),
),
),
if (rating != null && rating > 0)
Positioned(
top: 7,
right: 7,
child: MetadataChip(
icon: Icons.star_rounded,
label: rating.toStringAsFixed(1),
foreground: tokens.ratingColor,
background: Colors.black.withValues(alpha: 0.68),
),
),
],
),
),
const SizedBox(height: 8),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: widget.textColor,
),
),
if (_subtitle case final subtitle?) ...[
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color:
widget.textColor?.withValues(alpha: 0.55) ??
tokens.mutedForeground,
),
),
],
],
),
),
);
}
}
+101
View File
@@ -0,0 +1,101 @@
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/appearance_provider.dart';
import '../theme/app_theme.dart';
import 'window_chrome.dart';
class ScrollFadeHeader extends ConsumerWidget {
final ValueNotifier<double> scrollProgress;
final List<Widget> actions;
const ScrollFadeHeader({
super.key,
required this.scrollProgress,
this.actions = const [],
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tokens = context.appTokens;
final isDesktop = Platform.isMacOS || Platform.isWindows;
final topPadding = isDesktop
? tokens.titleBarHeight
: math.max(MediaQuery.paddingOf(context).top, tokens.titleBarHeight);
final overlayHeight = topPadding + 12;
final headerMode =
ref.watch(appearanceProvider).value?.headerMode ?? HeaderMode.frosted;
final isFrosted = headerMode == HeaderMode.frosted;
final theme = Theme.of(context);
final background = ValueListenableBuilder<double>(
valueListenable: scrollProgress,
builder: (_, raw, _) {
final p = raw.clamp(0.0, 1.0);
if (isFrosted) {
final entryProgress = (p / 0.25).clamp(0.0, 1.0);
final glassProgress =
Curves.easeOutCubic.transform(entryProgress) * 0.42;
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: glassProgress * 10,
sigmaY: glassProgress * 10,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(
alpha: glassProgress * 0.88,
),
border: Border(
bottom: BorderSide(
color: theme.colorScheme.onSurface.withValues(
alpha: glassProgress * 0.08,
),
width: 1,
),
),
),
),
),
);
}
return ColoredBox(
color: theme.colorScheme.surface.withValues(alpha: p),
);
},
);
return SizedBox(
height: overlayHeight,
width: double.infinity,
child: Stack(
children: [
Positioned.fill(child: IgnorePointer(child: background)),
if (actions.isNotEmpty)
Positioned(
right: 0,
top: tokens.chromeInset,
height: WindowChrome.controlsHeight,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
...actions,
SizedBox(width: WindowChrome.reservedTrailingWidth(tokens)),
],
),
),
],
),
);
}
}
+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../core/contracts/library.dart';
const Color _defaultSeasonAccentColor = Color(0xFF4F8DFF);
const double _inlineSeasonHorizontalPadding = 14;
const double _inlineSeasonVerticalPadding = 6;
const double _inlineSeasonGap = 8;
const double _inlineWidthSafetyMargin = 2;
const TextStyle _inlineSeasonTextStyle = TextStyle(fontSize: 13);
class SeasonSelector extends StatelessWidget {
final List<EmbyRawSeason> seasons;
final String selectedId;
final ValueChanged<String> onSelect;
final Color accentColor;
final Color? backgroundColor;
const SeasonSelector({
super.key,
required this.seasons,
required this.selectedId,
required this.onSelect,
this.accentColor = _defaultSeasonAccentColor,
this.backgroundColor,
});
EmbyRawSeason _resolveSelectedSeason() {
for (final season in seasons) {
if (season.Id == selectedId) return season;
}
return seasons.first;
}
TextStyle _inlineTextStyle(bool selected) {
return _inlineSeasonTextStyle.copyWith(
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
color: selected ? Colors.white : Colors.white.withValues(alpha: 0.7),
);
}
double _calculateInlineWidth(BuildContext context) {
final textDirection = Directionality.of(context);
final textScaler = MediaQuery.textScalerOf(context);
var requiredWidth = 0.0;
for (var index = 0; index < seasons.length; index++) {
final season = seasons[index];
final textPainter = TextPainter(
text: TextSpan(
text: season.Name,
style: _inlineTextStyle(season.Id == selectedId),
),
maxLines: 1,
textDirection: textDirection,
textScaler: textScaler,
)..layout();
requiredWidth += textPainter.width + (_inlineSeasonHorizontalPadding * 2);
if (index > 0) requiredWidth += _inlineSeasonGap;
}
return requiredWidth + _inlineWidthSafetyMargin;
}
@override
Widget build(BuildContext context) {
if (seasons.isEmpty) return const SizedBox.shrink();
final selectedSeason = _resolveSelectedSeason();
return LayoutBuilder(
builder: (context, constraints) {
final inlineWidth = _calculateInlineWidth(context);
final hasEnoughInlineSpace =
!constraints.hasBoundedWidth || inlineWidth <= constraints.maxWidth;
final selector = hasEnoughInlineSpace
? _buildInlineOptions()
: _buildDropdownSelect(context, selectedSeason);
return Align(
alignment: Alignment.centerLeft,
widthFactor: 1,
child: selector,
);
},
);
}
Widget _buildInlineOptions() {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = 0; index < seasons.length; index++) ...[
if (index > 0) const SizedBox(width: _inlineSeasonGap),
_buildInlineOption(seasons[index]),
],
],
);
}
Widget _buildInlineOption(EmbyRawSeason season) {
final selected = season.Id == selectedId;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: selected ? null : () => onSelect(season.Id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.symmetric(
horizontal: _inlineSeasonHorizontalPadding,
vertical: _inlineSeasonVerticalPadding,
),
decoration: BoxDecoration(
color: selected
? accentColor.withValues(alpha: 0.28)
: backgroundColor ?? Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
),
child: Text(season.Name, style: _inlineTextStyle(selected)),
),
),
);
}
Widget _buildDropdownSelect(
BuildContext context,
EmbyRawSeason selectedSeason,
) {
final controlWidth = _calculateControlWidth(context, selectedSeason);
final menuWidth = _calculateMenuWidth(context, controlWidth);
return SizedBox(
width: controlWidth,
child: FSelect<String>.rich(
control: FSelectControl<String>.lifted(
value: selectedId,
onChange: (seasonId) {
if (seasonId != null && seasonId != selectedId) {
onSelect(seasonId);
}
},
),
format: _seasonNameForId,
contentConstraints: FPortalConstraints(
minWidth: menuWidth,
maxWidth: menuWidth,
maxHeight: 360,
),
children: [
for (final season in seasons)
FSelectItem<String>.item(
title: Text(season.Name),
subtitle: season.ChildCount == null
? null
: Text('${season.ChildCount}'),
value: season.Id,
),
],
),
);
}
double _calculateControlWidth(
BuildContext context,
EmbyRawSeason selectedSeason,
) {
final selectedTextWidth = _measureTextWidth(
context,
selectedSeason.Name,
_inlineSeasonTextStyle.copyWith(fontWeight: FontWeight.w600),
);
return (selectedTextWidth + 64).clamp(104.0, 200.0).toDouble();
}
double _calculateMenuWidth(BuildContext context, double controlWidth) {
var widestContent = 0.0;
for (final season in seasons) {
final seasonNameWidth = _measureTextWidth(
context,
season.Name,
const TextStyle(fontSize: 14),
);
final episodeCountWidth = season.ChildCount == null
? 0.0
: _measureTextWidth(
context,
'${season.ChildCount}',
const TextStyle(fontSize: 12),
);
widestContent = widestContent.clamp(
seasonNameWidth > episodeCountWidth
? seasonNameWidth
: episodeCountWidth,
double.infinity,
);
}
final screenWidth = MediaQuery.sizeOf(context).width;
final maximumSafeWidth = (screenWidth - 32).clamp(
controlWidth,
double.infinity,
);
return (widestContent + 72)
.clamp(controlWidth, maximumSafeWidth)
.toDouble();
}
double _measureTextWidth(BuildContext context, String text, TextStyle style) {
final textPainter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout();
return textPainter.width;
}
String _seasonNameForId(String seasonId) {
for (final season in seasons) {
if (season.Id == seasonId) return season.Name;
}
return _resolveSelectedSeason().Name;
}
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class SectionHeader extends StatelessWidget {
final String label;
final int? count;
final bool collapsible;
final bool collapsed;
final VoidCallback? onToggle;
final Widget? trailing;
final Color? labelColor;
const SectionHeader({
super.key,
required this.label,
this.count,
this.collapsible = false,
this.collapsed = false,
this.onToggle,
this.trailing,
this.labelColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final mutedColor = labelColor != null
? labelColor!.withValues(alpha: 0.55)
: tokens.mutedForeground;
final row = Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: labelColor,
),
),
if (count != null) ...[
const SizedBox(width: 8),
Text(
'$count',
style: theme.textTheme.bodySmall?.copyWith(color: mutedColor),
),
],
const Spacer(),
if (trailing != null) trailing!,
if (collapsible)
AnimatedRotation(
turns: collapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 180),
child: Icon(
Icons.keyboard_arrow_down_rounded,
size: 20,
color: mutedColor,
),
),
],
),
);
if (!collapsible) return row;
return InkWell(
onTap: onToggle,
borderRadius: BorderRadius.circular(6),
child: row,
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart';
import '../../core/contracts/auth.dart';
import '../../core/contracts/server.dart';
import '../../providers/item_counts_provider.dart';
import '../../providers/server_connectivity_provider.dart';
import '../../providers/server_provider.dart';
import '../../providers/server_sync_provider.dart';
import '../../providers/session_provider.dart';
import 'add_server_modal.dart';
import 'app_dialog.dart';
import 'icon_picker_dialog.dart';
import 'server_change_password_dialog.dart';
typedef ServerActionCallbacks = ({
VoidCallback? onRefresh,
VoidCallback? onChangeIcon,
VoidCallback? onEdit,
VoidCallback? onChangePassword,
VoidCallback? onTogglePause,
VoidCallback? onDelete,
});
ServerActionCallbacks buildServerActionCallbacks({
required BuildContext context,
required WidgetRef ref,
required EmbyServer? server,
AuthedSession? session,
}) {
Future<void> changeIcon(EmbyServer server) async {
final picked = await showIconPickerDialog(
context,
currentIconUrl: server.iconUrl,
);
if (picked == null) return;
await ref
.read(serverListProvider.notifier)
.save(
EmbyServerSaveReq(
id: server.id,
name: server.name,
baseUrl: server.baseUrl,
iconUrl: picked,
),
);
}
Future<void> togglePause(EmbyServer server) async {
final wasPaused = server.paused;
final sessions = ref.read(sessionProvider).value;
final wasActive = sessions?.activeServerId == server.id;
await ref.read(serverListProvider.notifier).togglePause(server.id);
if (!wasPaused && wasActive) {
final updatedServers = ref.read(serverListProvider).value ?? const [];
final pausedIds = {
for (final s in updatedServers)
if (s.paused) s.id,
};
final nextSession = sessions?.sessions
.where(
(s) => s.serverId != server.id && !pausedIds.contains(s.serverId),
)
.firstOrNull;
if (nextSession != null) {
await ref
.read(sessionProvider.notifier)
.switchSession(nextSession.serverId);
} else {
await ref.read(sessionProvider.notifier).logout(serverId: server.id);
}
}
await ref.read(serverSyncProvider.notifier).upload();
}
Future<void> delete(EmbyServer server) async {
final confirmed = await showAppConfirmDialog(
context,
title: '删除服务器',
body: Text('确定删除「${server.name}」?此操作不可撤销。'),
confirmLabel: '删除',
destructive: true,
);
if (!confirmed) return;
await ref.read(serverListProvider.notifier).delete(server.id);
await ref.read(sessionProvider.notifier).refresh();
}
final s = server;
return (
onRefresh: s == null
? null
: () {
ref.invalidate(serverConnectivityProvider(s.baseUrl));
ref.invalidate(serverItemCountsProvider(s.id));
},
onChangeIcon: s == null ? null : () => changeIcon(s),
onEdit: s == null ? null : () => showAddServerModal(context, initial: s),
onChangePassword: session == null
? null
: () => showServerChangePasswordDialog(
context: context,
serverId: session.serverId,
serverName: s?.name ?? session.serverName,
),
onTogglePause: s == null ? null : () => togglePause(s),
onDelete: s == null ? null : () => delete(s),
);
}
List<FItem> buildServerActionMenuItems({
VoidCallback? onRefresh,
VoidCallback? onChangeIcon,
VoidCallback? onEdit,
VoidCallback? onChangePassword,
bool paused = false,
VoidCallback? onTogglePause,
VoidCallback? onDelete,
}) => [
if (onRefresh != null)
FItem(
prefix: const Icon(Icons.refresh_outlined, size: 16),
title: const Text('刷新'),
onPress: onRefresh,
),
if (onChangeIcon != null)
FItem(
prefix: const Icon(Icons.image_outlined, size: 16),
title: const Text('更换图标'),
onPress: onChangeIcon,
),
if (onEdit != null)
FItem(
prefix: const Icon(Icons.edit_outlined, size: 16),
title: const Text('编辑'),
onPress: onEdit,
),
if (onChangePassword != null)
FItem(
prefix: const Icon(Icons.lock_outline, size: 16),
title: const Text('修改密码'),
onPress: onChangePassword,
),
if (onTogglePause != null)
FItem(
prefix: Icon(
paused ? Icons.play_circle_outline : Icons.pause_circle_outline,
size: 16,
),
title: Text(paused ? '恢复使用' : '暂停使用'),
onPress: onTogglePause,
),
if (onDelete != null)
FItem(
variant: FItemVariant.destructive,
prefix: const Icon(Icons.delete_outline, size: 16),
title: const Text('删除'),
onPress: onDelete,
),
];
+107
View File
@@ -0,0 +1,107 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
class ServerAvatar extends StatelessWidget {
final String name;
final String iconUrl;
final double size;
final bool isActive;
final bool showActiveBorder;
final ServerAvatarShape shape;
const ServerAvatar({
super.key,
required this.name,
this.iconUrl = '',
this.size = 38,
this.isActive = false,
this.showActiveBorder = false,
this.shape = ServerAvatarShape.roundedSquare,
});
static const _avatarColors = [
Color(0xFF6366F1),
Color(0xFF3B82F6),
Color(0xFF22C55E),
Color(0xFFA855F7),
Color(0xFFF97316),
Color(0xFF14B8A6),
Color(0xFFEC4899),
Color(0xFF84CC16),
];
static Color seedColorFor(String name) =>
_avatarColors[name.hashCode.abs() % _avatarColors.length];
@override
Widget build(BuildContext context) {
final color = seedColorFor(name);
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final hasIcon = iconUrl.isNotEmpty;
final radius = shape == ServerAvatarShape.circle ? size / 2 : size * 0.26;
final borderRadius = BorderRadius.circular(radius);
final borderColor = showActiveBorder && isActive
? color.withValues(alpha: 0.55)
: color.withValues(alpha: 0.25);
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: hasIcon ? Colors.transparent : color.withValues(alpha: 0.15),
borderRadius: borderRadius,
border: Border.all(
color: hasIcon ? Colors.transparent : borderColor,
width: showActiveBorder && isActive ? 1.5 : 1,
),
),
clipBehavior: Clip.antiAlias,
child: hasIcon
? CachedNetworkImage(
imageUrl: iconUrl,
fit: BoxFit.contain,
memCacheWidth: 128,
fadeInDuration: const Duration(milliseconds: 180),
placeholder: (_, _) => Center(
child: Text(
initial,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: size * 0.42,
),
),
),
errorWidget: (_, _, _) => Center(
child: Text(
initial,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: size * 0.42,
),
),
),
)
: Center(
child: Text(
initial,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: size * 0.42,
),
),
),
);
}
}
enum ServerAvatarShape { roundedSquare, circle }
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart';
import '../../core/domain/errors.dart';
import '../../providers/di_providers.dart';
import '../constants/breakpoints.dart';
import '../utils/user_error_formatter.dart';
import 'adaptive_modal.dart';
import 'app_inline_alert.dart';
import 'app_loading_ring.dart';
import 'app_snack_bar.dart';
Future<void> showServerChangePasswordDialog({
required BuildContext context,
required String serverId,
required String serverName,
}) => showAdaptiveModal<void>(
context: context,
maxWidth: 420,
compactMaxHeightFactor: 0.9,
builder: (_) =>
_ServerChangePasswordDialog(serverId: serverId, serverName: serverName),
);
class _ServerChangePasswordDialog extends ConsumerStatefulWidget {
final String serverId;
final String serverName;
const _ServerChangePasswordDialog({
required this.serverId,
required this.serverName,
});
@override
ConsumerState<_ServerChangePasswordDialog> createState() =>
_ServerChangePasswordDialogState();
}
class _ServerChangePasswordDialogState
extends ConsumerState<_ServerChangePasswordDialog> {
final _currentPwCtrl = TextEditingController();
final _newPwCtrl = TextEditingController();
final _confirmPwCtrl = TextEditingController();
bool _loading = false;
String? _error;
@override
void dispose() {
_currentPwCtrl.dispose();
_newPwCtrl.dispose();
_confirmPwCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
final currentPw = _currentPwCtrl.text;
final newPw = _newPwCtrl.text;
final confirmPw = _confirmPwCtrl.text;
if (currentPw.isEmpty || newPw.isEmpty || confirmPw.isEmpty) {
setState(() => _error = '所有字段不能为空');
return;
}
if (newPw != confirmPw) {
setState(() => _error = '两次输入的新密码不一致');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final uc = await ref.read(changePasswordUseCaseProvider.future);
await uc.execute(
serverId: widget.serverId,
currentPassword: currentPw,
newPassword: newPw,
);
if (mounted) Navigator.of(context).pop();
if (mounted) {
showAppSnackBar(context, '密码已修改', tone: AppSnackTone.neutral);
}
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e is DomainError ? formatUserError(e) : '修改失败,请检查当前密码是否正确';
});
}
}
@override
Widget build(BuildContext context) {
final isCompact = Breakpoints.isCompact(MediaQuery.sizeOf(context).width);
final title = Text('修改密码 · ${widget.serverName}');
final body = SingleChildScrollView(
padding: EdgeInsets.fromLTRB(24, isCompact ? 16 : 24, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
title,
const SizedBox(height: 18),
TextField(
controller: _currentPwCtrl,
obscureText: true,
enabled: !_loading,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
decoration: const InputDecoration(labelText: '当前密码'),
),
const SizedBox(height: 12),
TextField(
controller: _newPwCtrl,
obscureText: true,
enabled: !_loading,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
decoration: const InputDecoration(labelText: '新密码'),
),
const SizedBox(height: 12),
TextField(
controller: _confirmPwCtrl,
obscureText: true,
enabled: !_loading,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onSubmitted: (_) => _submit(),
decoration: const InputDecoration(labelText: '确认新密码'),
),
if (_error != null) ...[
const SizedBox(height: 12),
AppInlineAlert(message: _error!),
],
const SizedBox(height: 20),
if (isCompact)
FButton(
onPress: _loading ? null : _submit,
child: _loading
? const AppLoadingRing(size: 16, color: Colors.white)
: const Text('确认修改'),
)
else
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FButton(
variant: FButtonVariant.ghost,
onPress: _loading
? null
: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
const SizedBox(width: 8),
FButton(
onPress: _loading ? null : _submit,
child: _loading
? const AppLoadingRing(size: 16, color: Colors.white)
: const Text('确认修改'),
),
],
),
if (isCompact) ...[
const SizedBox(height: 8),
FButton(
variant: FButtonVariant.outline,
onPress: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('取消'),
),
],
],
),
);
return PopScope(canPop: !_loading, child: body);
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'auto_dismiss_menu.dart';
import 'server_action_menu.dart';
import 'server_avatar.dart';
class ServerMenuItem extends StatelessWidget {
final String serverName;
final String iconUrl;
final bool isActive;
final bool collapsed;
final Color foregroundActive;
final Color foregroundInactive;
final VoidCallback? onTap;
final VoidCallback? onChangeIcon;
final VoidCallback? onEdit;
final VoidCallback? onChangePassword;
final VoidCallback? onTogglePause;
final VoidCallback? onDelete;
const ServerMenuItem({
super.key,
required this.serverName,
required this.isActive,
required this.collapsed,
required this.foregroundActive,
required this.foregroundInactive,
this.iconUrl = '',
this.onTap,
this.onChangeIcon,
this.onEdit,
this.onChangePassword,
this.onTogglePause,
this.onDelete,
});
List<FItem> _menuItems() => buildServerActionMenuItems(
onChangeIcon: onChangeIcon,
onEdit: onEdit,
onChangePassword: onChangePassword,
onTogglePause: onTogglePause,
onDelete: onDelete,
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final foreground = isActive ? foregroundActive : foregroundInactive;
final child = collapsed
? _buildCollapsed(foreground)
: _buildExpanded(theme, foreground);
final items = _menuItems();
if (items.isEmpty) return GestureDetector(onTap: onTap, child: child);
return FContextMenu(
menuBuilder: autoDismissMenuBuilder,
menu: [FItemGroup(children: items)],
child: GestureDetector(
onTap: onTap,
child: MouseRegion(cursor: SystemMouseCursors.click, child: child),
),
);
}
Widget _buildCollapsed(Color foreground) {
return Tooltip(
message: serverName,
preferBelow: false,
child: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SizedBox(
width: 44,
height: 38,
child: Row(
children: [
Container(
width: 3,
height: 20,
decoration: BoxDecoration(
color: isActive ? foregroundActive : Colors.transparent,
borderRadius: BorderRadius.circular(1.5),
),
),
const Spacer(),
ServerAvatar(
name: serverName,
iconUrl: iconUrl,
size: 30,
isActive: isActive,
showActiveBorder: true,
shape: ServerAvatarShape.circle,
),
const Spacer(),
],
),
),
),
);
}
Widget _buildExpanded(ThemeData theme, Color foreground) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
Container(
width: 2,
height: 16,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: isActive ? foregroundActive : Colors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
ServerAvatar(
name: serverName,
iconUrl: iconUrl,
size: 28,
isActive: isActive,
showActiveBorder: false,
shape: ServerAvatarShape.roundedSquare,
),
const SizedBox(width: 10),
Expanded(
child: Text(
serverName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: foreground,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
),
),
),
],
),
);
}
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
class SettingSelect<T> extends StatelessWidget {
final T? value;
final List<T> options;
final String Function(T value) labelOf;
final ValueChanged<T?>? onChanged;
final bool enabled;
const SettingSelect({
super.key,
required this.value,
required this.options,
required this.labelOf,
required this.onChanged,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.maybeSizeOf(context)?.width;
final maxWidth = screenWidth == null
? 240.0
: (screenWidth * 0.45).clamp(120.0, 240.0).toDouble();
return ConstrainedBox(
constraints: BoxConstraints(minWidth: 96, maxWidth: maxWidth),
child: FSelect<T>.rich(
enabled: enabled && onChanged != null,
control: FSelectControl<T>.lifted(
value: value,
onChange: onChanged ?? (_) {},
),
format: labelOf,
children: [
for (final option in options)
FSelectItem<T>.item(title: Text(labelOf(option)), value: option),
],
),
);
}
}
@@ -0,0 +1,132 @@
import 'dart:ui' show ImageFilter;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/emby_headers_provider.dart';
import '../../providers/shell_backdrop_provider.dart';
class ShellBackdropLayer extends ConsumerWidget {
final bool active;
const ShellBackdropLayer({super.key, this.active = false});
static const double _blurSigma = 32;
static const Duration _crossfade = Duration(milliseconds: 350);
static const LinearGradient _immersiveDarkFallback = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.4, 1.0],
colors: [Color(0xFF1A1A1A), Color(0xFF0F0F0F), Color(0xFF000000)],
);
@override
Widget build(BuildContext context, WidgetRef ref) {
final backdrop = active ? ref.watch(shellBackdropProvider) : null;
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
final scheme = Theme.of(context).colorScheme;
final Widget child;
if (backdrop != null) {
child = _BackdropImage(
key: ValueKey<String>(backdrop.primaryImageUrl),
state: backdrop,
imageHeaders: imageHeaders,
);
} else if (active) {
child = const DecoratedBox(
key: ValueKey<String>('__immersive_dark_fallback__'),
decoration: BoxDecoration(gradient: _immersiveDarkFallback),
);
} else {
child = ColoredBox(
key: ValueKey<String>('__surface__${scheme.brightness.name}'),
color: scheme.surface,
);
}
return AnimatedSwitcher(
duration: _crossfade,
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) {
final seen = <Key?>{if (currentChild != null) currentChild.key};
final filtered = previousChildren.where((child) {
if (seen.contains(child.key)) return false;
seen.add(child.key);
return true;
}).toList();
return Stack(
fit: StackFit.expand,
children: [...filtered, if (currentChild != null) currentChild],
);
},
child: child,
);
}
}
class _BackdropImage extends StatelessWidget {
final ShellBackdropState state;
final Map<String, String>? imageHeaders;
const _BackdropImage({
super.key,
required this.state,
required this.imageHeaders,
});
@override
Widget build(BuildContext context) {
final fallbackUrl = state.fallbackImageUrls.isNotEmpty
? state.fallbackImageUrls.first
: state.primaryImageUrl;
final surface = Theme.of(context).colorScheme.surface;
return Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: ShellBackdropLayer._blurSigma,
sigmaY: ShellBackdropLayer._blurSigma,
tileMode: TileMode.clamp,
),
child: CachedNetworkImage(
imageUrl: state.primaryImageUrl,
httpHeaders: imageHeaders,
fit: BoxFit.cover,
memCacheWidth: 800,
fadeInDuration: const Duration(milliseconds: 200),
placeholder: (_, _) => ColoredBox(color: surface),
errorWidget: (_, _, _) => CachedNetworkImage(
imageUrl: fallbackUrl,
httpHeaders: imageHeaders,
fit: BoxFit.cover,
memCacheWidth: 800,
placeholder: (_, _) => ColoredBox(color: surface),
errorWidget: (_, _, _) => ColoredBox(color: surface),
),
),
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.4, 1.0],
colors: [Color(0x33000000), Color(0x59000000), Color(0xB3000000)],
),
),
),
],
);
}
}
+646
View File
@@ -0,0 +1,646 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../providers/home_banner_provider.dart';
import '../../providers/di_providers.dart';
import '../../providers/library_overview_provider.dart';
import '../../providers/server_provider.dart';
import '../../providers/session_provider.dart';
import '../../providers/shell_backdrop_provider.dart';
import '../../features/settings/settings_page.dart';
import '../../providers/tmdb_settings_provider.dart';
import '../../providers/sm_account_provider.dart';
import '../../providers/trakt_settings_provider.dart';
import '../theme/app_theme.dart';
import 'server_action_menu.dart';
import 'server_menu_item.dart';
import 'top_drag_area.dart';
const _kSidebarCollapsedKey = 'smplayer.sidebar_collapsed';
const double _kSidebarBlurSigma = 18;
class _SidebarPalette {
final Color fg;
final Color fgMuted;
final Color fgFaint;
final Color glassTint;
final Color hairline;
const _SidebarPalette({
required this.fg,
required this.fgMuted,
required this.fgFaint,
required this.glassTint,
required this.hairline,
});
static const dark = _SidebarPalette(
fg: Colors.white,
fgMuted: Color(0x99FFFFFF),
fgFaint: Color(0x66FFFFFF),
glassTint: Color(0x42000000),
hairline: Color(0x14FFFFFF),
);
static const light = _SidebarPalette(
fg: Color(0xFF1A1A1A),
fgMuted: Color(0x99000000),
fgFaint: Color(0x59000000),
glassTint: Color(0x8CFFFFFF),
hairline: Color(0x0F000000),
);
static const darkSurface = _SidebarPalette(
fg: Colors.white,
fgMuted: Color(0x99FFFFFF),
fgFaint: Color(0x66FFFFFF),
glassTint: Color(0x14FFFFFF),
hairline: Color(0x1FFFFFFF),
);
}
final sidebarCollapsedProvider = StateProvider<bool>((ref) => false);
Future<void> initSidebarState(WidgetRef ref) async {
final prefs = await SharedPreferences.getInstance();
final collapsed = prefs.getBool(_kSidebarCollapsedKey) ?? false;
ref.read(sidebarCollapsedProvider.notifier).state = collapsed;
}
Future<void> _persistSidebarState(bool collapsed) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kSidebarCollapsedKey, collapsed);
}
class Sidebar extends ConsumerStatefulWidget {
final int selectedBranchIndex;
final void Function(int index, {bool initialLocation}) onNavigateBranch;
final bool immersiveRoute;
final String location;
const Sidebar({
super.key,
required this.selectedBranchIndex,
required this.onNavigateBranch,
required this.location,
this.immersiveRoute = false,
});
@override
ConsumerState<Sidebar> createState() => _SidebarState();
}
class _SidebarState extends ConsumerState<Sidebar> {
bool _suppressBlur = false;
Timer? _blurTimer;
@override
void dispose() {
_blurTimer?.cancel();
super.dispose();
}
void _onCollapseToggled() {
setState(() => _suppressBlur = true);
_blurTimer?.cancel();
_blurTimer = Timer(const Duration(milliseconds: 300), () {
if (mounted) setState(() => _suppressBlur = false);
});
}
Widget _frostedGlass({required bool suppressBlur, required Widget child}) {
return BackdropFilter(
enabled: !suppressBlur,
filter: ImageFilter.blur(
sigmaX: _kSidebarBlurSigma,
sigmaY: _kSidebarBlurSigma,
),
child: child,
);
}
@override
Widget build(BuildContext context) {
ref.listen<bool>(sidebarCollapsedProvider, (prev, next) {
if (prev != next) _onCollapseToggled();
});
final theme = Theme.of(context);
final tokens = context.appTokens;
final hasBackdrop = ref.watch(
shellBackdropProvider.select((s) => s != null),
);
final immersive = widget.immersiveRoute && hasBackdrop;
final isDark = theme.brightness == Brightness.dark;
final palette = immersive
? _SidebarPalette.dark
: (isDark ? _SidebarPalette.darkSurface : _SidebarPalette.light);
final collapsed = ref.watch(sidebarCollapsedProvider);
final sessionState = ref.watch(sessionProvider);
final servers = ref.watch(serverListProvider).value ?? const [];
final serverById = {for (final s in servers) s.id: s};
final iconUrlByServer = {for (final s in servers) s.id: s.iconUrl};
final currentIndex = widget.selectedBranchIndex;
final locationSegments = Uri.parse(widget.location).pathSegments;
final activeModuleId =
locationSegments.length > 1 && locationSegments.first == 'modules'
? locationSegments[1]
: null;
final sessionByServerId = {
for (final session in sessionState.value?.sessions ?? const [])
session.serverId: session,
};
final sessionList = [
for (final server in servers)
if (!server.paused && sessionByServerId[server.id] != null)
sessionByServerId[server.id]!,
];
final tmdbConfigured =
ref.watch(tmdbSettingsProvider).value?.canRequest ?? false;
final traktConnected =
ref.watch(traktSettingsProvider).value?.isConnected ?? false;
final smAccountLoggedIn =
ref.watch(smAccountProvider).value?.loggedIn ?? false;
final enabledScriptWidgets =
(ref.watch(installedScriptWidgetsProvider).value ?? const [])
.where((installedWidget) => installedWidget.enabled)
.toList(growable: false);
final width = collapsed
? tokens.siderWidthCollapsed
: tokens.siderWidthExpanded;
return RepaintBoundary(
child: AnimatedContainer(
duration: tokens.siderTransition,
curve: Curves.easeOutCubic,
width: width,
child: ClipRect(
child: _frostedGlass(
suppressBlur: _suppressBlur,
child: AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: palette.glassTint,
border: Border(
right: BorderSide(color: palette.hairline, width: 1),
),
),
child: OverflowBox(
alignment: Alignment.topLeft,
minWidth: width,
maxWidth: width,
child: SizedBox(
width: width,
child: Padding(
padding: EdgeInsets.fromLTRB(
collapsed ? 8 : 16,
10,
collapsed ? 8 : 16,
10,
),
child: LayoutBuilder(
builder: (context, constraints) {
final effectiveCollapsed = constraints.maxWidth < 100;
const settingsNavReserveHeight = 48.0;
final reservedBottomHeight =
(sessionList.isNotEmpty ||
enabledScriptWidgets.isNotEmpty
? 16.0
: 0.0) +
4.0 +
settingsNavReserveHeight;
final availableHeaderHeight =
constraints.maxHeight - reservedBottomHeight;
final maxHeaderHeight = constraints.maxHeight.isFinite
? (availableHeaderHeight < 0
? 0.0
: availableHeaderHeight)
: double.infinity;
return Column(
crossAxisAlignment: effectiveCollapsed
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxHeaderHeight,
),
child: SingleChildScrollView(
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
alignment: Alignment.topCenter,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: effectiveCollapsed
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
_SidebarHeader(
collapsed: effectiveCollapsed,
palette: palette,
onToggle: () {
final next = !collapsed;
ref
.read(
sidebarCollapsedProvider
.notifier,
)
.state =
next;
_persistSidebarState(next);
},
),
const SizedBox(height: 16),
_buildSearchEntry(
context,
collapsed: effectiveCollapsed,
palette: palette,
),
const SizedBox(height: 12),
_navItem(
context,
icon: Icons.dns_outlined,
label: '服务器',
selected: currentIndex == 1,
collapsed: effectiveCollapsed,
palette: palette,
onTap: () => widget.onNavigateBranch(1),
),
_navItem(
context,
icon: Icons.dashboard_outlined,
label: '聚合视界',
selected: currentIndex == 2,
collapsed: effectiveCollapsed,
palette: palette,
onTap: () => widget.onNavigateBranch(2),
),
if (tmdbConfigured)
_navItem(
context,
icon: Icons.explore_outlined,
label: '发现',
selected: currentIndex == 3,
collapsed: effectiveCollapsed,
palette: palette,
onTap: () =>
widget.onNavigateBranch(3),
),
if (smAccountLoggedIn && traktConnected)
_navItem(
context,
icon: Icons.calendar_today_outlined,
label: '日历',
selected: currentIndex == 4,
collapsed: effectiveCollapsed,
palette: palette,
onTap: () =>
widget.onNavigateBranch(4),
),
],
),
),
),
),
if (sessionList.isNotEmpty ||
enabledScriptWidgets.isNotEmpty) ...[
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
...sessionList.map(
(session) => Padding(
padding: const EdgeInsets.only(
bottom: 4,
),
child: Builder(
builder: (context) {
final server =
serverById[session.serverId];
final actions =
buildServerActionCallbacks(
context: context,
ref: ref,
server: server,
session: session,
);
return ServerMenuItem(
serverName: session.serverName,
iconUrl:
iconUrlByServer[session
.serverId] ??
'',
isActive:
session.isActive &&
activeModuleId == null,
collapsed: effectiveCollapsed,
foregroundActive: palette.fg,
foregroundInactive:
palette.fgMuted,
onTap: () async {
if (!session.isActive) {
await ref
.read(
sessionProvider
.notifier,
)
.switchSession(
session.serverId,
);
widget.onNavigateBranch(
0,
initialLocation: true,
);
return;
}
if (widget.location == '/') {
await Future.wait([
ref
.read(
libraryOverviewProvider
.notifier,
)
.softRefresh(),
ref
.read(
homeBannerProvider
.notifier,
)
.softRefresh(),
]);
} else {
widget.onNavigateBranch(
0,
initialLocation: true,
);
}
},
onChangeIcon:
actions.onChangeIcon,
onEdit: actions.onEdit,
onChangePassword:
actions.onChangePassword,
onTogglePause:
actions.onTogglePause,
onDelete: actions.onDelete,
);
},
),
),
),
for (final installedWidget
in enabledScriptWidgets)
_navItem(
context,
icon: Icons.extension_rounded,
label: installedWidget.manifest.title,
selected:
activeModuleId ==
installedWidget.manifest.id,
collapsed: effectiveCollapsed,
palette: palette,
onTap: () => context.go(
'/modules/${Uri.encodeComponent(installedWidget.manifest.id)}',
),
),
],
),
),
),
] else
const Spacer(),
const SizedBox(height: 4),
_navItem(
context,
icon: Icons.settings_outlined,
label: '设置',
selected: false,
collapsed: effectiveCollapsed,
palette: palette,
onTap: () => showSettingsDialog(context),
),
],
);
},
),
),
),
),
),
),
),
),
);
}
Widget _buildSearchEntry(
BuildContext context, {
required bool collapsed,
required _SidebarPalette palette,
}) {
final theme = Theme.of(context);
if (collapsed) {
return Tooltip(
message: '搜索',
preferBelow: false,
child: GestureDetector(
onTap: () => context.push('/global-search'),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: SizedBox(
width: 44,
height: 38,
child: Center(
child: Icon(
Icons.search_outlined,
size: 20,
color: palette.fgFaint,
),
),
),
),
),
);
}
return GestureDetector(
onTap: () => context.push('/global-search'),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Row(
children: [
Icon(Icons.search_outlined, size: 18, color: palette.fgMuted),
const SizedBox(width: 10),
Expanded(
child: Text(
'搜索',
style: theme.textTheme.bodySmall?.copyWith(
color: palette.fgMuted,
),
),
),
Text(
'⌘K',
style: theme.textTheme.labelSmall?.copyWith(
color: palette.fgFaint,
fontSize: 10,
),
),
],
),
),
),
);
}
Widget _navItem(
BuildContext context, {
required IconData icon,
required String label,
required bool selected,
required bool collapsed,
required _SidebarPalette palette,
required VoidCallback onTap,
}) {
final theme = Theme.of(context);
final foreground = selected ? palette.fg : palette.fgMuted;
final child = GestureDetector(
onTap: onTap,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: collapsed ? 0 : 4,
vertical: 8,
),
child: collapsed
? SizedBox(
width: 44,
child: Center(child: Icon(icon, size: 20, color: foreground)),
)
: Row(
children: [
Container(
width: 2,
height: 16,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
color: selected ? palette.fg : Colors.transparent,
borderRadius: BorderRadius.circular(1),
),
),
Icon(icon, size: 18, color: foreground),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: foreground,
fontWeight: selected
? FontWeight.w600
: FontWeight.w400,
),
),
),
],
),
),
),
);
if (collapsed) {
return Tooltip(
message: label,
preferBelow: false,
child: Padding(padding: const EdgeInsets.only(bottom: 2), child: child),
);
}
return Padding(padding: const EdgeInsets.only(bottom: 2), child: child);
}
}
class _SidebarHeader extends StatelessWidget {
final bool collapsed;
final _SidebarPalette palette;
final VoidCallback onToggle;
const _SidebarHeader({
required this.collapsed,
required this.palette,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
if (collapsed) {
return Column(
children: [
TopDragArea(
child: SizedBox(
width: double.infinity,
height: tokens.titleBarHeight,
),
),
const SizedBox(height: 4),
GestureDetector(
onTap: onToggle,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Icon(Icons.menu, size: 18, color: palette.fgFaint),
),
),
],
);
}
return TopDragArea(
child: SizedBox(
height: tokens.titleBarHeight,
child: Row(
children: [
const Spacer(),
GestureDetector(
onTap: onToggle,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(Icons.menu, size: 18, color: palette.fgFaint),
),
),
),
],
),
),
);
}
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'media_row_metrics.dart';
import 'media_poster_card.dart';
class SkeletonMediaRow extends StatefulWidget {
final String title;
final MediaCardVariant cardVariant;
final Color? titleColor;
const SkeletonMediaRow({
super.key,
required this.title,
this.cardVariant = MediaCardVariant.poster,
this.titleColor,
});
@override
State<SkeletonMediaRow> createState() => _SkeletonMediaRowState();
}
class _SkeletonMediaRowState extends State<SkeletonMediaRow>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
double get _cardAspect =>
widget.cardVariant == MediaCardVariant.landscape ? 16 / 9 : 2 / 3;
bool get _showSubtitlePlaceholder =>
widget.cardVariant == MediaCardVariant.landscape;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isLight = theme.brightness == Brightness.light;
final compact = MediaRowMetrics.isCompact(context);
final rowHeight = MediaRowMetrics.rowHeight(
widget.cardVariant,
compact: compact,
);
final cardWidth = MediaRowMetrics.cardWidth(
widget.cardVariant,
compact: compact,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 10),
child: Text(
widget.title,
style: theme.textTheme.titleMedium?.copyWith(
color: widget.titleColor,
),
),
),
SizedBox(
height: rowHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24),
itemCount: 6,
separatorBuilder: (_, _) => const SizedBox(width: 14),
itemBuilder: (_, _) => _SkeletonCard(
width: cardWidth,
aspect: _cardAspect,
showSubtitle: _showSubtitlePlaceholder,
isLight: isLight,
animation: _controller,
),
),
),
],
);
}
}
class _SkeletonCard extends StatelessWidget {
final double width;
final double aspect;
final bool showSubtitle;
final bool isLight;
final Animation<double> animation;
const _SkeletonCard({
required this.width,
required this.aspect,
required this.showSubtitle,
required this.isLight,
required this.animation,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: aspect,
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final base = isLight
? const Color(0xFFF3F4F6)
: const Color(0xFF2A2A2A);
final highlight = isLight
? const Color(0xFFE5E7EB)
: const Color(0xFF3A3A3A);
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
gradient: LinearGradient(
begin: Alignment(-1.0 + 2.0 * animation.value, 0),
end: Alignment(-1.0 + 2.0 * animation.value + 1.0, 0),
colors: [base, highlight, base],
stops: const [0.0, 0.5, 1.0],
),
),
);
},
),
),
const SizedBox(height: 8),
AnimatedBuilder(
animation: animation,
builder: (context, child) {
final base = isLight
? const Color(0xFFF3F4F6)
: const Color(0xFF2A2A2A);
final highlight = isLight
? const Color(0xFFE5E7EB)
: const Color(0xFF3A3A3A);
return Container(
height: 12,
width: width * 0.7,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
begin: Alignment(-1.0 + 2.0 * animation.value, 0),
end: Alignment(-1.0 + 2.0 * animation.value + 1.0, 0),
colors: [base, highlight, base],
stops: const [0.0, 0.5, 1.0],
),
),
);
},
),
if (showSubtitle) ...[
const SizedBox(height: 6),
AnimatedBuilder(
animation: animation,
builder: (context, child) {
final base = isLight
? const Color(0xFFF3F4F6)
: const Color(0xFF2A2A2A);
final highlight = isLight
? const Color(0xFFE5E7EB)
: const Color(0xFF3A3A3A);
return Container(
height: 10,
width: width * 0.5,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: LinearGradient(
begin: Alignment(-1.0 + 2.0 * animation.value, 0),
end: Alignment(-1.0 + 2.0 * animation.value + 1.0, 0),
colors: [base, highlight, base],
stops: const [0.0, 0.5, 1.0],
),
),
);
},
),
],
],
),
);
}
}
+303
View File
@@ -0,0 +1,303 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/emby_headers_provider.dart';
import '../../providers/session_provider.dart';
class SmartImage extends ConsumerStatefulWidget {
final String? url;
final List<String> fallbackUrls;
final BoxFit fit;
final Alignment alignment;
final double? aspectRatio;
final double borderRadius;
final IconData? fallbackIcon;
final VoidCallback? onLoad;
final VoidCallback? onError;
final Map<String, String>? httpHeaders;
final Map<String, String>? Function(String url)? resolveHttpHeaders;
final int? memCacheWidth;
final Widget? placeholder;
const SmartImage({
super.key,
required this.url,
this.fallbackUrls = const [],
this.fit = BoxFit.cover,
this.alignment = Alignment.center,
this.aspectRatio,
this.borderRadius = 8,
this.fallbackIcon = Icons.image_outlined,
this.onLoad,
this.onError,
this.httpHeaders,
this.resolveHttpHeaders,
this.memCacheWidth,
this.placeholder,
});
@override
ConsumerState<SmartImage> createState() => _SmartImageState();
}
class _SmartImageState extends ConsumerState<SmartImage> {
List<String> _displayedUrls = const [];
int _failedCount = 0;
String? _pendingPrimaryUrl;
int? _lastMemCacheWidth;
String? _embyBaseUrl;
Map<String, String>? _embyHeaders;
List<String> _resolveAllUrls() {
final urls = <String>[];
if (widget.url != null) urls.add(widget.url!);
urls.addAll(widget.fallbackUrls);
return urls;
}
Map<String, String>? _headersFor(String url) {
if (widget.resolveHttpHeaders != null) {
return widget.resolveHttpHeaders!(url);
}
if (widget.httpHeaders != null) return widget.httpHeaders;
final base = _embyBaseUrl;
if (base != null && url.startsWith(base)) return _embyHeaders;
return null;
}
@override
void initState() {
super.initState();
_displayedUrls = _resolveAllUrls();
}
@override
void didUpdateWidget(covariant SmartImage oldWidget) {
super.didUpdateWidget(oldWidget);
final newAll = _resolveAllUrls();
final newPrimary = newAll.isEmpty ? null : newAll.first;
final currentPrimary = _displayedUrls.isEmpty ? null : _displayedUrls.first;
if (newPrimary == null) {
if (_displayedUrls.isNotEmpty || _pendingPrimaryUrl != null) {
setState(() {
_displayedUrls = const [];
_failedCount = 0;
_pendingPrimaryUrl = null;
});
}
return;
}
if (currentPrimary == null) {
setState(() {
_displayedUrls = newAll;
_failedCount = 0;
_pendingPrimaryUrl = null;
});
return;
}
if (newPrimary == currentPrimary) {
_displayedUrls = newAll;
return;
}
_preloadAndSwap(newPrimary, newAll);
}
Future<void> _preloadAndSwap(String newUrl, List<String> newAll) async {
if (_pendingPrimaryUrl == newUrl) return;
_pendingPrimaryUrl = newUrl;
final provider = ResizeImage.resizeIfNeeded(
_lastMemCacheWidth,
null,
CachedNetworkImageProvider(newUrl, headers: _headersFor(newUrl)),
);
try {
await precacheImage(provider, context);
} catch (_) {
if (_pendingPrimaryUrl == newUrl) _pendingPrimaryUrl = null;
return;
}
if (!mounted) return;
if (_pendingPrimaryUrl != newUrl) return;
setState(() {
_displayedUrls = newAll;
_failedCount = 0;
_pendingPrimaryUrl = null;
});
}
@override
Widget build(BuildContext context) {
if (widget.httpHeaders == null && widget.resolveHttpHeaders == null) {
_embyBaseUrl = ref.watch(activeSessionProvider)?.serverUrl;
_embyHeaders = ref.watch(embyImageHeadersProvider).value;
} else {
_embyBaseUrl = null;
_embyHeaders = null;
}
final scheme = Theme.of(context).colorScheme;
final isLight = scheme.brightness == Brightness.light;
Widget placeholderWidget() =>
widget.placeholder ?? _ShimmerPlaceholder(isLight: isLight);
final urls = _displayedUrls;
final effectiveUrl = _failedCount < urls.length ? urls[_failedCount] : null;
final fallback = widget.fallbackIcon != null
? Container(
color: scheme.surfaceContainerHighest,
child: Center(
child: Icon(
widget.fallbackIcon,
size: 32,
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
),
),
)
: const SizedBox.shrink();
final Widget child;
if (effectiveUrl == null) {
child = KeyedSubtree(
key: const ValueKey<String>('_smart_image_empty'),
child: fallback,
);
} else {
final effectiveHeaders = _headersFor(effectiveUrl);
child = LayoutBuilder(
builder: (context, constraints) {
final memCacheWidth =
widget.memCacheWidth ??
(constraints.maxWidth.isFinite
? (constraints.maxWidth *
MediaQuery.devicePixelRatioOf(context))
.ceil()
: null);
_lastMemCacheWidth = memCacheWidth;
return CachedNetworkImage(
key: ValueKey<String>(effectiveUrl),
imageUrl: effectiveUrl,
httpHeaders: effectiveHeaders,
fit: widget.fit,
alignment: widget.alignment,
memCacheWidth: memCacheWidth,
fadeInDuration: const Duration(milliseconds: 300),
fadeInCurve: Curves.easeOut,
placeholder: (_, _) => placeholderWidget(),
errorWidget: (_, e, st) {
if (_failedCount + 1 < urls.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _failedCount++);
});
return placeholderWidget();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onError?.call();
});
return fallback;
},
imageBuilder: (ctx, imageProvider) {
widget.onLoad?.call();
return Image(
image: imageProvider,
fit: widget.fit,
alignment: widget.alignment,
);
},
);
},
);
}
final switcher = AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
fit: StackFit.passthrough,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: child,
);
final clipped = ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: switcher,
);
if (widget.aspectRatio != null) {
return AspectRatio(aspectRatio: widget.aspectRatio!, child: clipped);
}
return clipped;
}
}
class _ShimmerPlaceholder extends StatefulWidget {
final bool isLight;
const _ShimmerPlaceholder({required this.isLight});
@override
State<_ShimmerPlaceholder> createState() => _ShimmerPlaceholderState();
}
class _ShimmerPlaceholderState extends State<_ShimmerPlaceholder>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final base = widget.isLight
? const Color(0xFFF3F4F6)
: const Color(0xFF2A2A2A);
final highlight = widget.isLight
? const Color(0xFFE5E7EB)
: const Color(0xFF3A3A3A);
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment(-1.0 + 2.0 * _controller.value, 0),
end: Alignment(-1.0 + 2.0 * _controller.value + 1.0, 0),
colors: [base, highlight, base],
stops: const [0.0, 0.5, 1.0],
),
),
);
},
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
enum ConnectivityStatus { online, offline, unknown }
Color statusColor(ConnectivityStatus status, AppTokens tokens) {
switch (status) {
case ConnectivityStatus.online:
return tokens.statusOnline;
case ConnectivityStatus.offline:
return tokens.statusOffline;
case ConnectivityStatus.unknown:
return tokens.mutedForeground;
}
}
class StatusDot extends StatelessWidget {
final ConnectivityStatus status;
final double size;
const StatusDot({super.key, required this.status, this.size = 9});
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
final color = statusColor(status, tokens);
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
width: size,
height: size,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.5),
blurRadius: 4,
spreadRadius: 0.5,
),
],
),
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../mappers/tmdb_image_url.dart';
import '../theme/app_theme.dart';
import 'app_card.dart';
import 'metadata_chip.dart';
import 'smart_image.dart';
class TmdbPosterCard extends StatelessWidget {
final String title;
final String? posterPath;
final double? voteAverage;
final String? mediaTypeLabel;
final String imageBaseUrl;
final double width;
final VoidCallback? onTap;
final Color? textColor;
const TmdbPosterCard({
super.key,
required this.title,
required this.imageBaseUrl,
required this.width,
this.posterPath,
this.voteAverage,
this.mediaTypeLabel,
this.onTap,
this.textColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final posterUrl = TmdbImageUrl.poster(imageBaseUrl, posterPath);
final chipFg = textColor?.withValues(alpha: 0.65);
final chipBg = textColor?.withValues(alpha: 0.12);
return SizedBox(
width: width,
child: AppCard(
filled: false,
enableHover: onTap != null,
radius: tokens.radiusCard,
padding: EdgeInsets.zero,
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SmartImage(
url: posterUrl,
fallbackIcon: Icons.movie_outlined,
borderRadius: 8,
),
),
const SizedBox(height: 8),
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
color: textColor,
),
),
if (voteAverage != null && voteAverage! > 0) ...[
const SizedBox(height: 2),
Row(
children: [
MetadataChip(
icon: Icons.star_rounded,
label: voteAverage!.toStringAsFixed(1),
foreground: tokens.ratingColor,
background: chipBg,
),
if (mediaTypeLabel != null) ...[
const SizedBox(width: 6),
MetadataChip(
label: mediaTypeLabel!,
foreground: chipFg,
background: chipBg,
),
],
],
),
],
],
),
),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
class TopDragArea extends StatefulWidget {
final Widget child;
final double topResizeZone;
final bool enabled;
const TopDragArea({
super.key,
required this.child,
this.topResizeZone = 6,
this.enabled = true,
});
@override
State<TopDragArea> createState() => _TopDragAreaState();
}
class _TopDragAreaState extends State<TopDragArea> with WindowListener {
bool _isMaximized = false;
bool get _supported =>
widget.enabled && (Platform.isMacOS || Platform.isWindows);
@override
void initState() {
super.initState();
if (_supported) {
windowManager.addListener(this);
_syncMaximized();
}
}
@override
void dispose() {
if (Platform.isMacOS || Platform.isWindows) {
windowManager.removeListener(this);
}
super.dispose();
}
Future<void> _syncMaximized() async {
try {
final maximized = await windowManager.isMaximized();
if (!mounted) return;
if (maximized != _isMaximized) {
setState(() => _isMaximized = maximized);
}
} catch (_) {}
}
@override
void onWindowMaximize() {
if (mounted) setState(() => _isMaximized = true);
}
@override
void onWindowUnmaximize() {
if (mounted) setState(() => _isMaximized = false);
}
@override
Widget build(BuildContext context) {
if (!_supported) return widget.child;
final showResizeZone =
Platform.isWindows && !_isMaximized && widget.topResizeZone > 0;
final deadZone = showResizeZone ? widget.topResizeZone : 0.0;
return Stack(
children: [
Padding(
padding: EdgeInsets.only(top: deadZone),
child: DragToMoveArea(child: widget.child),
),
if (showResizeZone)
Positioned(
top: 0,
left: 0,
right: 0,
height: deadZone,
child: MouseRegion(
cursor: SystemMouseCursors.resizeUp,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: (_) => windowManager.startResizing(ResizeEdge.top),
),
),
),
],
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
const kVersionBucketOrder = ['4K', '2K', '1080P', '其他'];
String versionBucketOf(String? resolutionLabel) {
switch (resolutionLabel) {
case '4K':
case '2K':
case '1080P':
return resolutionLabel!;
default:
return '其他';
}
}
class VersionFilterChip extends StatelessWidget {
final String label;
final bool selected;
final bool showDot;
final VoidCallback onTap;
const VersionFilterChip({
super.key,
required this.label,
required this.selected,
required this.onTap,
this.showDot = false,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.18)
: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withValues(alpha: selected ? 0.5 : 0.15),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showDot) ...[
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: Color(0xFF4F8DFF),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
],
Text(
label,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: selected ? 1 : 0.7),
),
),
],
),
),
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'version_source_card.dart';
class VersionSourceGroup {
final List<VersionSourceCardData> members;
VersionSourceGroup(List<VersionSourceCardData> members)
: assert(members.isNotEmpty),
members = List.unmodifiable(members);
VersionSourceCardData get representative => members.first;
VersionSourceCardData? get activeMember {
for (final member in members) {
if (member.isCurrent || member.selected) return member;
}
return null;
}
bool get hasActiveMember => activeMember != null;
bool get hasCurrentMember => members.any((member) => member.isCurrent);
List<String> get serverNames =>
List.unmodifiable([for (final member in members) member.serverName]);
VersionSourceCardData get displayData {
final visibleMember = activeMember ?? representative;
final names = serverNames;
final hasCurrent = hasCurrentMember;
final hasSelected = !hasCurrent && members.any((member) => member.selected);
final serverList = names.join('');
return VersionSourceCardData(
topLabel: visibleMember.topLabel,
sizeBytes: visibleMember.sizeBytes,
bitrate: visibleMember.bitrate,
serverName: visibleMember.serverName,
serverKey: visibleMember.serverKey,
isCurrent: hasCurrent,
selected: hasSelected,
onTap: visibleMember.onTap,
tooltip: names.length > 1 ? '可用服务器:$serverList' : visibleMember.tooltip,
resolutionLabel: visibleMember.resolutionLabel,
sourceKey: visibleMember.sourceKey,
mergedServerNames: names,
);
}
}
String? versionGroupKeyOf({
required int? sizeBytes,
required String resolutionLabel,
}) {
if (sizeBytes == null || sizeBytes <= 0) return null;
return '$sizeBytes|${resolutionLabel.trim().toUpperCase()}';
}
List<VersionSourceGroup> groupVersionEntries(
List<VersionSourceCardData> entries,
) {
final groupedMembers = <String, List<List<VersionSourceCardData>>>{};
final orderedMemberLists = <List<VersionSourceCardData>>[];
for (final entry in entries) {
final groupKey = versionGroupKeyOf(
sizeBytes: entry.sizeBytes,
resolutionLabel: entry.resolutionLabel,
);
if (groupKey == null) {
orderedMemberLists.add([entry]);
continue;
}
final candidateGroups = groupedMembers[groupKey] ?? const [];
List<VersionSourceCardData>? matchingGroup;
for (final candidateGroup in candidateGroups) {
final alreadyContainsServer = candidateGroup.any(
(member) => member.serverKey == entry.serverKey,
);
if (!alreadyContainsServer) {
matchingGroup = candidateGroup;
break;
}
}
if (matchingGroup != null && entry.serverKey.isNotEmpty) {
matchingGroup.add(entry);
continue;
}
final newMembers = <VersionSourceCardData>[entry];
groupedMembers.putIfAbsent(groupKey, () => []).add(newMembers);
orderedMemberLists.add(newMembers);
}
return [
for (final members in orderedMemberLists) VersionSourceGroup(members),
];
}
+493
View File
@@ -0,0 +1,493 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import '../../core/contracts/library.dart';
import '../utils/format_utils.dart';
import 'app_loading_ring.dart';
import 'app_snack_bar.dart';
import 'frosted_panel.dart';
@immutable
class VersionSourceCardData {
final String topLabel;
final int? sizeBytes;
final int? bitrate;
final String serverName;
final String serverKey;
final String sourceKey;
final List<String> mergedServerNames;
final bool isCurrent;
final bool selected;
final Future<void> Function()? onTap;
final String? tooltip;
final String resolutionLabel;
const VersionSourceCardData({
required this.topLabel,
required this.serverName,
this.serverKey = '',
this.sourceKey = '',
this.mergedServerNames = const [],
this.sizeBytes,
this.bitrate,
this.isCurrent = false,
this.selected = false,
this.onTap,
this.tooltip,
this.resolutionLabel = '',
});
factory VersionSourceCardData.fromCrossServer(
CrossServerSourceCard card, {
bool isCurrent = false,
bool selected = false,
Future<void> Function()? onTap,
}) {
final src = card.mediaSource;
return VersionSourceCardData(
topLabel: _topLabelFromQuality(card.quality),
serverName: card.serverName,
serverKey: card.serverId,
sourceKey: '${card.serverId}|${card.mediaSourceId}',
sizeBytes: src.Size,
bitrate: src.Bitrate,
isCurrent: isCurrent,
selected: selected,
onTap: onTap,
tooltip: '切换到 ${card.serverName}',
resolutionLabel: card.quality?.resolutionLabel ?? '',
);
}
factory VersionSourceCardData.fromLocalSource(
EmbyRawMediaSource source, {
required String serverName,
String serverKey = '',
bool isCurrent = false,
Future<void> Function()? onTap,
String? tooltip,
}) {
final quality = MediaQualityInfo.fromMediaSource(source);
return VersionSourceCardData(
topLabel: _topLabelFromQuality(quality),
serverName: serverName,
serverKey: serverKey,
sourceKey: '$serverKey|${source.Id ?? ''}',
sizeBytes: source.Size,
bitrate: source.Bitrate,
isCurrent: isCurrent,
onTap: onTap,
tooltip: tooltip,
resolutionLabel: quality.resolutionLabel,
);
}
}
String _abbrevRange(String label) {
if (label.isEmpty) return 'SDR';
if (label == 'Dolby Vision') return 'Dolby';
return label;
}
String _topLabelFromQuality(MediaQualityInfo? quality) {
final resolutionLabel = quality?.resolutionLabel ?? '';
final rangeLabel = _abbrevRange(quality?.dynamicRangeLabel ?? '');
final parts = <String>[];
if (resolutionLabel.isNotEmpty) parts.add(resolutionLabel);
parts.add(rangeLabel);
return parts.join(' ');
}
class VersionSourceCard extends StatefulWidget {
final VersionSourceCardData data;
final bool compact;
final VoidCallback? onMergedServersTap;
const VersionSourceCard({
super.key,
required this.data,
this.compact = false,
this.onMergedServersTap,
});
@override
State<VersionSourceCard> createState() => _VersionSourceCardState();
}
class _VersionSourceCardState extends State<VersionSourceCard> {
bool _loading = false;
Future<void> _open() async {
final data = widget.data;
if (_loading || data.isCurrent) return;
final tap = data.onTap;
if (tap == null) return;
setState(() => _loading = true);
try {
await tap();
} catch (_) {
if (!mounted) return;
showAppSnackBar(context, '切换服务器失败', tone: AppSnackTone.error);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final data = widget.data;
final sizeBytes = data.sizeBytes;
final bitrate = data.bitrate;
final hasSize = sizeBytes != null && sizeBytes > 0;
final hasBitrate = bitrate != null && bitrate > 0;
final showInfoRow = hasSize || hasBitrate;
final isCurrent = data.isCurrent;
final highlighted = isCurrent || data.selected;
final representedServerCount = data.mergedServerNames.length;
final hasMergedServers = representedServerCount > 1;
final isAndroid = Platform.isAndroid;
final compact = widget.compact;
final cardWidth = compact ? 130.0 : (isAndroid ? 170.0 : 220.0);
final cardHeight = compact ? 52.0 : (isAndroid ? 100.0 : 120.0);
final radius = compact ? 10.0 : 16.0;
final textColor = Colors.white.withValues(alpha: 0.85);
final card = SizedBox(
width: cardWidth,
height: cardHeight,
child: Stack(
fit: StackFit.expand,
children: [
FrostedPanel(
radius: radius,
padding: compact
? const EdgeInsets.symmetric(horizontal: 10, vertical: 6)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
blurSigma: 15,
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
color: highlighted
? const Color(0xFF4F8DFF).withValues(alpha: 0.18)
: Colors.white.withValues(alpha: 0.10),
child: compact
? _buildCompactContent(data, hasMergedServers)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Expanded(
child: Text(
data.topLabel,
style: TextStyle(
fontSize: isAndroid ? 16 : 18,
fontWeight: FontWeight.w700,
color: Colors.white,
height: 1.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (isCurrent)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFF4F8DFF),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'当前',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
),
if (showInfoRow)
Row(
children: [
if (hasSize) ...[
Icon(
Icons.movie_outlined,
size: isAndroid ? 13 : 14,
color: textColor,
),
SizedBox(width: isAndroid ? 3 : 4),
Flexible(
child: Text(
formatFileSize(sizeBytes),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isAndroid ? 12 : 13,
color: textColor,
),
),
),
],
if (hasSize && hasBitrate)
SizedBox(width: isAndroid ? 6 : 12),
if (hasBitrate) ...[
Icon(
Icons.equalizer,
size: isAndroid ? 13 : 14,
color: textColor,
),
SizedBox(width: isAndroid ? 3 : 4),
Flexible(
child: Text(
'${(bitrate / 1e6).toStringAsFixed(2)}Mbps',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: isAndroid ? 12 : 13,
color: textColor,
),
),
),
],
],
),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF4F8DFF),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
data.serverName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (hasMergedServers) ...[
const SizedBox(width: 6),
_MergedServersButton(
serverCount: representedServerCount,
onTap: widget.onMergedServersTap,
),
],
const SizedBox(width: 6),
Icon(
Icons.bolt,
size: 14,
color: Colors.white.withValues(alpha: 0.85),
),
],
),
],
),
),
if (highlighted)
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
border: Border.all(
color: const Color(0xFF4F8DFF),
width: 1.5,
),
),
),
),
),
if (_loading)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Container(
color: Colors.black.withValues(alpha: 0.3),
child: Center(
child: AppLoadingRing(
size: compact ? 14.0 : 18.0,
color: Colors.white,
),
),
),
),
),
],
),
);
final tappable = GestureDetector(onTap: _open, child: card);
final tooltip = data.tooltip;
if (tooltip == null) return tappable;
return Tooltip(message: tooltip, child: tappable);
}
Widget _buildCompactContent(
VersionSourceCardData data,
bool hasMergedServers,
) {
final sizeBytes = data.sizeBytes;
final secondaryColor = Colors.white.withValues(alpha: 0.65);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Expanded(
child: Text(
data.topLabel,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Colors.white,
height: 1.1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (data.isCurrent)
Container(
margin: const EdgeInsets.only(left: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFF4F8DFF),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'当前',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
if (hasMergedServers) ...[
const SizedBox(width: 4),
_MergedServersButton(
serverCount: data.mergedServerNames.length,
onTap: widget.onMergedServersTap,
compact: true,
),
],
],
),
const SizedBox(height: 3),
Text(
[
if (sizeBytes != null && sizeBytes > 0) formatFileSize(sizeBytes),
data.serverName,
].join(' · '),
style: TextStyle(fontSize: 11, color: secondaryColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
class _MergedServersButton extends StatelessWidget {
final int serverCount;
final VoidCallback? onTap;
final bool compact;
const _MergedServersButton({
required this.serverCount,
required this.onTap,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final content = Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 4 : 6,
vertical: compact ? 1 : 2,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$serverCount服',
style: TextStyle(
fontSize: compact ? 9 : 10,
fontWeight: FontWeight.w600,
color: Colors.white70,
),
),
const SizedBox(width: 2),
Icon(
Icons.keyboard_arrow_down_rounded,
size: compact ? 11 : 13,
color: Colors.white70,
),
],
),
);
final callback = onTap;
if (callback == null) return content;
return Tooltip(
message: '选择其他服务器',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: callback,
child: content,
),
),
);
}
}
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class WarmGradientBackground extends StatelessWidget {
const WarmGradientBackground({super.key});
@override
Widget build(BuildContext context) {
final colors = context.appTokens.warmGradientColors;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: colors,
),
),
child: const SizedBox.expand(),
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'dart:io';
import '../theme/app_theme.dart';
class WindowChrome {
WindowChrome._();
static const double buttonWidth = 36;
static const double controlsHeight = 30;
static const int buttonCount = 3;
static const double containerHorizontalPadding = 12;
static const double controlsWidth =
buttonWidth * buttonCount + containerHorizontalPadding;
static bool get isDesktop => Platform.isMacOS || Platform.isWindows;
static double reservedTrailingWidth(AppTokens tokens) =>
isDesktop ? controlsWidth + tokens.chromeInset + 8 : 0;
}
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../theme/app_theme.dart';
import 'top_drag_area.dart';
import 'window_chrome.dart';
import 'window_controls.dart';
class WindowChromeOverlay extends StatelessWidget {
final bool onDarkSurface;
const WindowChromeOverlay({super.key, this.onDarkSurface = false});
@override
Widget build(BuildContext context) {
if (!WindowChrome.isDesktop) {
return const SizedBox.shrink();
}
final AppTokens tokens = context.appTokens;
return Positioned(
top: 0,
left: 0,
right: 0,
height: tokens.titleBarHeight,
child: _HitTestPassThrough(
child: Stack(
children: [
const Positioned.fill(
child: TopDragArea(child: SizedBox.expand()),
),
Positioned(
top: tokens.chromeInset,
right: tokens.chromeInset,
child: onDarkSurface
? const WindowControls.forDarkSurface()
: const WindowControls.forShellOverlay(),
),
],
),
),
);
}
}
class WindowChromeHost extends StatelessWidget {
final Widget child;
final bool onDarkSurface;
const WindowChromeHost({
super.key,
required this.child,
this.onDarkSurface = false,
});
@override
Widget build(BuildContext context) {
if (!WindowChrome.isDesktop) {
return child;
}
return Stack(
fit: StackFit.expand,
children: [
child,
WindowChromeOverlay(onDarkSurface: onDarkSurface),
],
);
}
}
class _HitTestPassThrough extends SingleChildRenderObjectWidget {
const _HitTestPassThrough({required super.child});
@override
RenderObject createRenderObject(BuildContext context) =>
_RenderHitTestPassThrough();
}
class _RenderHitTestPassThrough extends RenderProxyBox {
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
hitTestChildren(result, position: position);
return false;
}
}
+276
View File
@@ -0,0 +1,276 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'window_chrome.dart';
final _windowControlsBinding = _WindowControlsBinding();
class WindowControls extends StatefulWidget {
final Brightness? brightness;
const WindowControls({super.key, this.brightness});
const WindowControls.forShellOverlay({super.key}) : brightness = null;
const WindowControls.forDarkSurface({super.key})
: brightness = Brightness.dark;
@override
State<WindowControls> createState() => _WindowControlsState();
}
class _WindowControlsState extends State<WindowControls> {
@override
void initState() {
super.initState();
unawaited(_windowControlsBinding.ensureInitialized());
}
Future<void> _toggleMaximize() async {
if (_windowControlsBinding.isMaximized.value) {
await windowManager.unmaximize();
return;
}
await windowManager.maximize();
}
@override
Widget build(BuildContext context) {
if (!Platform.isWindows && !Platform.isMacOS) {
return const SizedBox.shrink();
}
final resolvedBrightness =
widget.brightness ?? Theme.of(context).brightness;
final isLight = resolvedBrightness == Brightness.light;
return ValueListenableBuilder<bool>(
valueListenable: _windowControlsBinding.isMaximized,
builder: (context, isMaximized, _) {
final backgroundColor = isLight
? Colors.black.withValues(alpha: 0.14)
: const Color(0xFF08090c).withValues(alpha: 0.72);
final iconColor = isLight
? Colors.black.withValues(alpha: 0.65)
: Colors.white.withValues(alpha: 0.85);
final defaultHoverColor = isLight
? Colors.black.withValues(alpha: 0.10)
: Colors.white.withValues(alpha: 0.15);
const containerHeight = WindowChrome.controlsHeight;
const containerRadius = 20.0;
const buttonWidth = WindowChrome.buttonWidth;
const iconSize = 16.0;
return Container(
height: containerHeight,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(containerRadius),
boxShadow: isLight
? null
: const [
BoxShadow(
color: Color(0x2E000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ControlButton(
icon: Icons.remove_rounded,
tooltip: '最小化',
iconColor: iconColor,
defaultHoverColor: defaultHoverColor,
width: buttonWidth,
height: containerHeight,
iconSize: iconSize,
borderRadius: containerRadius,
onTap: windowManager.minimize,
),
_ControlButton(
icon: isMaximized
? Icons.filter_none_rounded
: Icons.crop_square_rounded,
tooltip: isMaximized ? '还原' : '最大化',
iconColor: iconColor,
defaultHoverColor: defaultHoverColor,
width: buttonWidth,
height: containerHeight,
iconSize: iconSize,
borderRadius: containerRadius,
onTap: _toggleMaximize,
),
_ControlButton(
icon: Icons.close_rounded,
tooltip: '关闭',
iconColor: iconColor,
defaultHoverColor: defaultHoverColor,
hoverColor: const Color(0xFFE81123),
pressedColor: const Color(0xFFC50F1F),
width: buttonWidth,
height: containerHeight,
iconSize: iconSize,
borderRadius: containerRadius,
onTap: windowManager.close,
),
],
),
);
},
);
}
}
class _WindowControlsBinding with WindowListener {
final ValueNotifier<bool> isMaximized = ValueNotifier(false);
Future<void> ensureInitialized() async {
if (!windowManager.listeners.contains(this)) {
windowManager.addListener(this);
}
try {
isMaximized.value = await windowManager.isMaximized();
} catch (_) {}
}
@override
void onWindowMaximize() {
isMaximized.value = true;
}
@override
void onWindowUnmaximize() {
isMaximized.value = false;
}
void reset() {
if (windowManager.listeners.contains(this)) {
windowManager.removeListener(this);
}
isMaximized.value = false;
}
}
class _ControlButton extends StatefulWidget {
final IconData icon;
final String tooltip;
final Color iconColor;
final Color defaultHoverColor;
final Color? hoverColor;
final Color? pressedColor;
final double width;
final double height;
final double iconSize;
final double borderRadius;
final VoidCallback onTap;
const _ControlButton({
required this.icon,
required this.tooltip,
required this.iconColor,
required this.defaultHoverColor,
this.hoverColor,
this.pressedColor,
this.width = 36,
this.height = 30,
this.iconSize = 16,
this.borderRadius = 20,
required this.onTap,
});
@override
State<_ControlButton> createState() => _ControlButtonState();
}
class _ControlButtonState extends State<_ControlButton> {
bool _hovering = false;
bool _pressing = false;
bool _focused = false;
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
void _onFocusChange() {
setState(() => _focused = _focusNode.hasFocus);
}
bool get _active => _hovering || _focused;
@override
Widget build(BuildContext context) {
final hoverBg = widget.hoverColor ?? widget.defaultHoverColor;
final pressedBg =
widget.pressedColor ??
widget.defaultHoverColor.withValues(
alpha: widget.defaultHoverColor.a * 1.8,
);
final Color bg;
if (_pressing) {
bg = widget.hoverColor != null
? (widget.pressedColor ?? hoverBg)
: pressedBg;
} else if (_active) {
bg = hoverBg;
} else {
bg = Colors.transparent;
}
final bool useBrightIcon =
(_active || _pressing) && widget.hoverColor != null;
return Tooltip(
message: widget.tooltip,
child: Focus(
focusNode: _focusNode,
child: MouseRegion(
onEnter: (_) => setState(() => _hovering = true),
onExit: (_) => setState(() {
_hovering = false;
_pressing = false;
}),
child: GestureDetector(
onTap: widget.onTap,
onTapDown: (_) => setState(() => _pressing = true),
onTapUp: (_) => setState(() => _pressing = false),
onTapCancel: () => setState(() => _pressing = false),
child: Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(widget.borderRadius),
),
alignment: Alignment.center,
child: Icon(
widget.icon,
size: widget.iconSize,
color: useBrightIcon ? Colors.white : widget.iconColor,
),
),
),
),
),
);
}
}