Files
sm-emby-share/lib/shared/widgets/sidebar.dart
T
2026-07-14 11:11:36 +08:00

647 lines
25 KiB
Dart

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),
),
),
),
],
),
),
);
}
}