Initial commit
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user