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
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
const String kSmPlayerVersionDefine = String.fromEnvironment('SMPLAYER_VERSION');
const String kSmPlayerVersionFallback = '0.2.7';
final appVersionProvider = FutureProvider<String>(
(ref) => getSmPlayerVersion(),
);
Future<String> getSmPlayerVersion() async {
final configuredVersion = kSmPlayerVersionDefine.trim();
if (configuredVersion.isNotEmpty) return configuredVersion;
try {
final info = await PackageInfo.fromPlatform();
final version = info.version.trim();
if (version.isNotEmpty) return version;
} catch (_) {}
return kSmPlayerVersionFallback;
}
+10
View File
@@ -0,0 +1,10 @@
abstract final class Breakpoints {
static const double compact = 600;
static const double detail = 720;
static bool isCompact(double width) => width < compact;
}
@@ -0,0 +1,11 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
SliverGridDelegateWithMaxCrossAxisExtent get mediaGridDelegate =>
SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: Platform.isAndroid ? 130 : 180,
childAspectRatio: 0.55,
crossAxisSpacing: 12,
mainAxisSpacing: 16,
);
+21
View File
@@ -0,0 +1,21 @@
import 'dart:io' show Platform;
const double kRichCardMinWidth = 300;
final double kPosterCardMinWidth = Platform.isAndroid ? 130 : 180;
const double kCardGap = 12;
int cardColumnsFor(
double width, {
double minCardWidth = kRichCardMinWidth,
int maxCols = 4,
}) {
if (width <= 0) return 1;
final cols = (width / minCardWidth).floor();
return cols.clamp(1, maxCols);
}
+318
View File
@@ -0,0 +1,318 @@
import '../../core/contracts/library.dart';
import '../utils/url_utils.dart';
class EmbyImageUrl {
static final RegExp _typedUlidPattern = RegExp(
r'^[a-z][0-9A-HJKMNP-TV-Z]{26}$',
);
static String? primary({
required String baseUrl,
required String token,
required EmbyRawItem item,
int? maxWidth,
int? maxHeight,
int? quality,
String format = 'webp',
}) {
final tag = item.PrimaryImageTag ?? item.ImageTags?['Primary'];
if (tag == null) return null;
return _build(
baseUrl: baseUrl,
itemId: item.Id,
type: 'Primary',
tag: tag,
token: token,
maxWidth: maxWidth,
maxHeight: maxHeight,
quality: quality,
format: format,
);
}
static String? library({
required String baseUrl,
required String token,
required EmbyRawLibrary lib,
int? maxHeight,
int? quality,
}) {
final tag = lib.ImageTags?['Primary'];
if (tag == null) return null;
return _build(
baseUrl: baseUrl,
itemId: lib.Id,
type: 'Primary',
tag: tag,
token: token,
maxHeight: maxHeight,
quality: quality,
);
}
static String? backdrop({
required String baseUrl,
required String token,
required EmbyRawItem item,
int? maxWidth,
int? quality,
}) {
final tags = item.BackdropImageTags ?? const [];
if (tags.isEmpty) return null;
return _build(
baseUrl: baseUrl,
itemId: item.Id,
type: 'Backdrop',
tag: tags.first,
token: token,
maxWidth: maxWidth,
quality: quality,
);
}
static String detailBanner({
required String baseUrl,
required String token,
required EmbyRawItem item,
}) {
final artworkItemId = (item.SeriesId?.isNotEmpty ?? false)
? item.SeriesId!
: item.Id;
return backdrop(
baseUrl: baseUrl,
token: token,
item: item,
maxWidth: 1600,
quality: 90,
) ??
fanart(baseUrl: baseUrl, token: token, itemId: artworkItemId);
}
static String fanart({
required String baseUrl,
required String token,
required String itemId,
}) {
return _buildDirectImage(
baseUrl: baseUrl,
itemId: itemId,
imageKind: 'fanart',
extension: 'jpg',
token: token,
);
}
static String? logo({
required String baseUrl,
required String token,
required String itemId,
String? tag,
}) {
if (tag != null) {
return _build(
baseUrl: baseUrl,
itemId: itemId,
type: 'Logo',
tag: tag,
token: token,
);
}
final base = stripTrailingSlash(baseUrl);
return '$base/Items/$itemId/Images/Logo?api_key=$token';
}
static String? thumb({
required String baseUrl,
required String token,
required EmbyRawItem item,
int? maxWidth,
int? quality,
}) {
final tag = item.ImageTags?['Thumb'];
if (tag != null) {
return _build(
baseUrl: baseUrl,
itemId: item.Id,
type: 'Thumb',
tag: tag,
token: token,
maxWidth: maxWidth,
quality: quality,
);
}
return primary(
baseUrl: baseUrl,
token: token,
item: item,
quality: quality,
);
}
static String primaryById({
required String baseUrl,
required String token,
required String itemId,
int? maxHeight,
int? quality,
}) {
final base = stripTrailingSlash(baseUrl);
final params = <String, String>{
'api_key': token,
if (maxHeight != null) 'maxHeight': '$maxHeight',
'quality': '${quality ?? 90}',
'format': 'webp',
};
final query = params.entries.map((e) => '${e.key}=${e.value}').join('&');
return '$base/Items/$itemId/Images/Primary?$query';
}
static String? season({
required String baseUrl,
required String token,
required EmbyRawSeason season,
int? maxHeight,
int? quality,
}) {
final tag = season.PrimaryImageTag ?? season.ImageTags?['Primary'];
if (tag == null) return null;
return _build(
baseUrl: baseUrl,
itemId: season.Id,
type: 'Primary',
tag: tag,
token: token,
maxHeight: maxHeight,
quality: quality,
);
}
static String? landscapeCard({
required String baseUrl,
required String token,
required EmbyRawItem item,
int? maxWidth,
int? quality,
}) {
String? pick(String? itemId, String type, String? tag) =>
(itemId == null || tag == null)
? null
: _build(
baseUrl: baseUrl,
itemId: itemId,
type: type,
tag: tag,
token: token,
maxWidth: maxWidth,
quality: quality,
);
String? self(String type, String? tag) => pick(item.Id, type, tag);
final primary = item.PrimaryImageTag ?? item.ImageTags?['Primary'];
final thumb = item.ImageTags?['Thumb'];
final backdropTags = item.BackdropImageTags ?? const <String>[];
final backdrop = backdropTags.isEmpty ? null : backdropTags.first;
if (item.Type == 'Movie') {
return self('Backdrop', backdrop) ??
self('Thumb', thumb) ??
self('Primary', primary) ??
_build(
baseUrl: baseUrl,
itemId: item.Id,
type: 'Primary',
tag: null,
token: token,
maxWidth: maxWidth,
quality: quality,
);
}
final extra = item.extra;
final parentBackdropTags = extra['ParentBackdropImageTags'];
return self('Primary', primary) ??
self('Thumb', thumb) ??
self('Backdrop', backdrop) ??
pick(
extra['ParentThumbItemId'] as String?,
'Thumb',
extra['ParentThumbImageTag'] as String?,
) ??
pick(
extra['ParentBackdropItemId'] as String?,
'Backdrop',
parentBackdropTags is List && parentBackdropTags.isNotEmpty
? parentBackdropTags.first as String
: null,
) ??
pick(
extra['SeriesId'] as String?,
'Primary',
extra['SeriesPrimaryImageTag'] as String?,
);
}
static int targetPixelWidth(
double logicalWidth,
double devicePixelRatio, {
double maxScale = 3.0,
}) {
final scale = devicePixelRatio.clamp(1.0, maxScale);
return (logicalWidth * scale).ceil();
}
static String _build({
required String baseUrl,
required String itemId,
required String type,
required String? tag,
required String token,
int? maxWidth,
int? maxHeight,
int? quality,
String format = 'webp',
}) {
final base = stripTrailingSlash(baseUrl);
final params = <String, String>{
if (tag != null) 'tag': tag,
'api_key': token,
if (maxWidth != null) 'maxWidth': '$maxWidth',
if (maxHeight != null) 'maxHeight': '$maxHeight',
'quality': '${quality ?? 90}',
'format': format,
};
final query = params.entries.map((e) => '${e.key}=${e.value}').join('&');
return '$base/Items/$itemId/Images/$type?$query';
}
static String _buildDirectImage({
required String baseUrl,
required String itemId,
required String imageKind,
required String extension,
required String token,
}) {
final uri = Uri.parse(baseUrl);
final origin =
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
final id = Uri.encodeComponent(_directImageId(itemId));
final key = Uri.encodeQueryComponent(token);
return '$origin/img/i/$imageKind/$id.$extension?api_key=$key';
}
static String _directImageId(String itemId) {
if (_typedUlidPattern.hasMatch(itemId)) {
return itemId.substring(1);
}
return itemId;
}
}
+105
View File
@@ -0,0 +1,105 @@
import '../../core/contracts/tmdb.dart';
import 'tmdb_image_url.dart';
class TmdbImageSelector {
static TmdbImage? bestImage(
List<TmdbImage> images, {
bool preferNeutral = false,
}) {
if (images.isEmpty) return null;
if (preferNeutral) {
final neutrals = images
.where((i) => i.filePath.isNotEmpty && i.isLanguageNeutral)
.toList();
if (neutrals.isNotEmpty) return _pickBest(neutrals);
}
return _pickBest(images);
}
static TmdbImage? _pickBest(List<TmdbImage> images) {
TmdbImage? best;
for (final image in images) {
if (image.filePath.isEmpty) continue;
if (best == null || _isBetter(image, best)) best = image;
}
return best;
}
static String? backdropUrl(
TmdbImageSet? imageSet,
String? imageBaseUrl, {
String size = 'original',
}) {
if (imageBaseUrl == null || imageBaseUrl.isEmpty) return null;
final image = bestImage(
imageSet?.backdrops ?? const [],
preferNeutral: true,
);
return TmdbImageUrl.backdrop(imageBaseUrl, image?.filePath, size: size);
}
static String? posterUrl(
TmdbImageSet? imageSet,
String? imageBaseUrl, {
String size = 'w500',
}) {
if (imageBaseUrl == null || imageBaseUrl.isEmpty) return null;
final image = bestImage(imageSet?.posters ?? const []);
return TmdbImageUrl.poster(imageBaseUrl, image?.filePath, size: size);
}
static String? logoUrl(
TmdbImageSet? imageSet,
String? imageBaseUrl, {
String? language,
String size = 'w500',
}) {
if (imageBaseUrl == null || imageBaseUrl.isEmpty) return null;
final logos = imageSet?.logos ?? const <TmdbImage>[];
if (logos.isEmpty) return null;
final lang = _iso639Of(language);
final image =
(lang != null ? _pickBest(_withLang(logos, lang)) : null) ??
_pickBest(_withLang(logos, 'en')) ??
_pickBest(logos.where((i) => i.isLanguageNeutral).toList()) ??
_pickBest(logos);
return TmdbImageUrl.logo(imageBaseUrl, image?.filePath, size: size);
}
static List<TmdbImage> _withLang(List<TmdbImage> images, String iso) =>
images.where((i) => i.iso639 == iso).toList();
static String? _iso639Of(String? language) {
if (language == null || language.isEmpty) return null;
final dash = language.indexOf('-');
final code = dash >= 0 ? language.substring(0, dash) : language;
return code.isEmpty ? null : code.toLowerCase();
}
static List<String> buildFallbackUrls({
required String? primaryUrl,
required Iterable<String?> candidates,
}) {
final urls = <String>[];
for (final url in candidates) {
if (url == null || url.isEmpty || url == primaryUrl) continue;
if (!urls.contains(url)) urls.add(url);
}
return urls;
}
static bool _isBetter(TmdbImage candidate, TmdbImage current) {
final voteCompare = (candidate.voteAverage ?? 0).compareTo(
current.voteAverage ?? 0,
);
if (voteCompare != 0) return voteCompare > 0;
final pixelsCompare = (candidate.width * candidate.height).compareTo(
current.width * current.height,
);
if (pixelsCompare != 0) return pixelsCompare > 0;
return candidate.filePath.compareTo(current.filePath) < 0;
}
}
+50
View File
@@ -0,0 +1,50 @@
class TmdbImageUrl {
static String? profile(
String imageBaseUrl,
String? path, {
String size = 'w185',
}) {
return _build(imageBaseUrl, path, size);
}
static String? poster(
String imageBaseUrl,
String? path, {
String size = 'w500',
}) {
return _build(imageBaseUrl, path, size);
}
static String? backdrop(
String imageBaseUrl,
String? path, {
String size = 'original',
}) {
return _build(imageBaseUrl, path, size);
}
static String? still(
String imageBaseUrl,
String? path, {
String size = 'w300',
}) {
return _build(imageBaseUrl, path, size);
}
static String? logo(
String imageBaseUrl,
String? path, {
String size = 'w500',
}) {
return _build(imageBaseUrl, path, size);
}
static String? _build(String imageBaseUrl, String? path, String size) {
if (path == null || path.isEmpty) return null;
final base = imageBaseUrl.endsWith('/')
? imageBaseUrl.substring(0, imageBaseUrl.length - 1)
: imageBaseUrl;
final normalizedPath = path.startsWith('/') ? path : '/$path';
return '$base/$size$normalizedPath';
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart' as material;
import 'package:flutter/widgets.dart' as widgets;
class AppScrollBehavior extends material.MaterialScrollBehavior {
const AppScrollBehavior();
@override
widgets.Widget buildScrollbar(
widgets.BuildContext context,
widgets.Widget child,
widgets.ScrollableDetails details,
) {
switch (details.direction) {
case widgets.AxisDirection.up:
case widgets.AxisDirection.down:
return child;
case widgets.AxisDirection.left:
case widgets.AxisDirection.right:
return super.buildScrollbar(context, child, details);
}
}
}
+299
View File
@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
class AppTokens {
final double siderWidthExpanded;
final double siderWidthCollapsed;
final Duration siderTransition;
final Color progressBarColor;
final Color ratingColor;
final Color shellBg;
final Color mutedForeground;
final Color pageGradientTop;
final Color pageGradientBottom;
final Color statusOnline;
final Color statusWarn;
final Color statusOffline;
final double panelRadius;
final double posterRadius;
final double titleBarHeight;
final Color separatorColor;
final double chromeInset;
final double radiusCard;
final double cardFillAlpha;
final double cardFillHoverAlpha;
final double radiusCardCompact;
final double bottomNavRadius;
final List<Color> warmGradientColors;
const AppTokens({
required this.siderWidthExpanded,
required this.siderWidthCollapsed,
required this.siderTransition,
required this.progressBarColor,
required this.ratingColor,
required this.shellBg,
required this.mutedForeground,
required this.pageGradientTop,
required this.pageGradientBottom,
required this.statusOnline,
required this.statusWarn,
required this.statusOffline,
required this.panelRadius,
required this.posterRadius,
required this.titleBarHeight,
required this.separatorColor,
required this.chromeInset,
required this.radiusCard,
required this.cardFillAlpha,
required this.cardFillHoverAlpha,
required this.radiusCardCompact,
required this.bottomNavRadius,
required this.warmGradientColors,
});
}
class AppTheme {
static const _seedColor = Color(0xFF7C99C9);
static const _fontFamilyFallback = <String>['Noto Sans SC'];
static const lightTokens = AppTokens(
siderWidthExpanded: 220,
siderWidthCollapsed: 64,
siderTransition: Duration(milliseconds: 240),
progressBarColor: Color(0xFFFFFFFF),
ratingColor: Color(0xFFF6C453),
shellBg: Color(0xFFFFFFFF),
mutedForeground: Color(0xFF8C8C8C),
pageGradientTop: Color(0xFFF3F5FA),
pageGradientBottom: Color(0xFFFFFFFF),
statusOnline: Color(0xFF22C55E),
statusWarn: Color(0xFFFB9E0B),
statusOffline: Color(0xFFEF4444),
panelRadius: 0,
posterRadius: 4,
titleBarHeight: 46,
separatorColor: Color(0x18000000),
chromeInset: 12,
radiusCard: 8,
cardFillAlpha: 0.025,
cardFillHoverAlpha: 0.05,
radiusCardCompact: 16,
bottomNavRadius: 24,
warmGradientColors: [
Color(0xFFFFF8F0),
Color(0xFFFFE8D6),
Color(0xFFFFD6C0),
],
);
static const darkTokens = AppTokens(
siderWidthExpanded: 220,
siderWidthCollapsed: 64,
siderTransition: Duration(milliseconds: 240),
progressBarColor: Color(0xFFFFFFFF),
ratingColor: Color(0xFFF6C453),
shellBg: Color(0xFF000000),
mutedForeground: Color(0xFFA3A3A3),
pageGradientTop: Color(0xFF06080C),
pageGradientBottom: Color(0xFF000000),
statusOnline: Color(0xFF22C55E),
statusWarn: Color(0xFFFB9E0B),
statusOffline: Color(0xFFEF4444),
panelRadius: 0,
posterRadius: 4,
titleBarHeight: 46,
separatorColor: Color(0x1AFFFFFF),
chromeInset: 12,
radiusCard: 8,
cardFillAlpha: 0.025,
cardFillHoverAlpha: 0.05,
radiusCardCompact: 16,
bottomNavRadius: 24,
warmGradientColors: [
Color(0xFF2A1810),
Color(0xFF1A1208),
Color(0xFF0A0A0A),
],
);
static ThemeData light() {
final scheme =
ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.light,
).copyWith(
surface: const Color(0xFFFFFFFF),
surfaceContainerHighest: const Color(0xFFF5F5F5),
outlineVariant: const Color(0xFFE8E8E8),
);
return _build(scheme, lightTokens);
}
static ThemeData dark() {
final scheme =
ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.dark,
).copyWith(
surface: const Color(0xFF000000),
surfaceContainerHighest: const Color(0xFF1A1A1A),
outlineVariant: const Color(0xFF2A2A2A),
);
return _build(scheme, darkTokens);
}
static ThemeData _build(ColorScheme scheme, AppTokens tokens) {
final isLight = scheme.brightness == Brightness.light;
final onSurface = isLight
? const Color(0xFF0A0A0A)
: const Color(0xFFF5F5F5);
final secondaryText = isLight
? const Color(0xFF6B6B6B)
: const Color(0xFF8C8C8C);
return ThemeData(
colorScheme: scheme,
useMaterial3: true,
fontFamilyFallback: _fontFamilyFallback,
scaffoldBackgroundColor: tokens.shellBg,
canvasColor: tokens.shellBg,
cardColor: tokens.shellBg,
dividerColor: tokens.separatorColor,
visualDensity: VisualDensity.adaptivePlatformDensity,
textTheme: TextTheme(
titleLarge: TextStyle(
fontSize: 36,
height: 1.05,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: onSurface,
),
titleMedium: TextStyle(
fontSize: 20,
height: 1.15,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: onSurface,
),
titleSmall: TextStyle(
fontSize: 14,
height: 1.2,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
color: onSurface,
),
bodyLarge: TextStyle(
fontSize: 15,
height: 1.55,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
color: onSurface,
),
bodyMedium: TextStyle(
fontSize: 13.5,
height: 1.45,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
color: onSurface,
),
bodySmall: TextStyle(
fontSize: 12,
height: 1.35,
fontWeight: FontWeight.w400,
letterSpacing: 0.15,
color: secondaryText,
),
labelLarge: TextStyle(
fontSize: 13,
height: 1.2,
fontWeight: FontWeight.w600,
letterSpacing: 0.1,
color: onSurface,
),
labelSmall: TextStyle(
fontSize: 11,
height: 1.2,
fontWeight: FontWeight.w500,
letterSpacing: 0.6,
color: secondaryText,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: tokens.shellBg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.posterRadius),
),
clipBehavior: Clip.antiAlias,
margin: EdgeInsets.zero,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: onSurface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: false,
hintStyle: TextStyle(color: secondaryText.withValues(alpha: 0.6)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: tokens.separatorColor),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: tokens.separatorColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: scheme.primary.withValues(alpha: 0.6)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
dividerTheme: DividerThemeData(
color: tokens.separatorColor,
thickness: 1,
space: 1,
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return isLight ? Colors.white : const Color(0xFF1C1C1E);
}
return isLight ? const Color(0xFFBDBDBD) : const Color(0xFF6B6B6B);
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return isLight ? const Color(0xFF1C1C1E) : Colors.white;
}
return isLight ? const Color(0xFFDEDEDE) : const Color(0xFF3A3A3A);
}),
trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
),
);
}
}
extension AppTokensContext on BuildContext {
AppTokens get appTokens => Theme.of(this).brightness == Brightness.dark
? AppTheme.darkTokens
: AppTheme.lightTokens;
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/appearance_provider.dart';
import 'app_theme.dart';
class AppThemeScope extends ConsumerWidget {
final Widget child;
const AppThemeScope({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mode = ref.watch(
appearanceProvider.select((a) => a.value?.themeMode ?? ThemeMode.system),
);
final brightness = switch (mode) {
ThemeMode.light => Brightness.light,
ThemeMode.dark => Brightness.dark,
ThemeMode.system => MediaQuery.platformBrightnessOf(context),
};
return Theme(
data: brightness == Brightness.dark ? AppTheme.dark() : AppTheme.light(),
child: Material(type: MaterialType.transparency, child: child),
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/foundation.dart';
abstract final class AppLogger {
static void debug(String tag, String message, [Object? error]) {
if (!kDebugMode) return;
_emit('D', tag, message, error);
}
static void info(String tag, String message) {
if (!kDebugMode) return;
_emit('I', tag, message);
}
static void warn(String tag, String message, [Object? error]) {
if (!kDebugMode) return;
_emit('W', tag, message, error);
}
static void error(
String tag,
String message,
Object error, [
StackTrace? stack,
]) {
_emit('E', tag, message, error);
if (stack != null && kDebugMode) {
debugPrint(
'[$tag] stack: ${stack.toString().split('\n').take(8).join('\n')}',
);
}
}
static void _emit(String level, String tag, String message, [Object? error]) {
final suffix = error != null ? ' err=$error' : '';
debugPrint('[$level/$tag] $message$suffix');
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'dart:async';
class AsyncLock {
Future<void>? _lastOp;
Future<T> run<T>(Future<T> Function() action) async {
final previous = _lastOp;
final completer = Completer<void>();
_lastOp = completer.future;
if (previous != null) {
await previous;
}
try {
return await action();
} finally {
completer.complete();
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/painting.dart';
Color accentColorForSeed(String seed, {required bool isDark}) {
final hash = seed.runes.fold<int>(0, (value, rune) => value * 37 + rune);
final hue = (hash % 360).toDouble();
final saturation = isDark ? 0.48 : 0.40;
final lightness = isDark ? 0.42 : 0.70;
return HSLColor.fromAHSL(1, hue, saturation, lightness).toColor();
}
@@ -0,0 +1,39 @@
import '../../core/contracts/library.dart';
CrossServerSearchReq? buildEpisodeCrossServerReq({
required EmbyRawItem episode,
EmbyRawItem? seriesItem,
int? seasonNumberOverride,
}) {
final seasonNumber =
seasonNumberOverride ??
(episode.extra['ParentIndexNumber'] as num?)?.toInt();
final episodeNumber = episode.IndexNumber;
if (seasonNumber == null || episodeNumber == null) return null;
final providerIds = providerIdsOfItem(episode);
final seriesProviderIds = seriesItem != null
? providerIdsOfItem(seriesItem)
: const <String, String>{};
if (providerIds.isEmpty && seriesProviderIds.isEmpty) return null;
return CrossServerSearchReq(
anchorType: 'Episode',
itemName: episode.Name,
providerIds: providerIds,
seriesProviderIds: seriesProviderIds,
parentIndexNumber: seasonNumber,
indexNumber: episodeNumber,
);
}
String encodeProviderIdsForKey(Map<String, String> ids) {
if (ids.isEmpty) return '';
final entries = ids.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
return entries
.where((e) => e.value.isNotEmpty)
.map((e) => '${e.key}.${e.value}')
.join(',');
}
+28
View File
@@ -0,0 +1,28 @@
import '../../core/contracts/library.dart';
const int kTicksPerSecond = 10000000;
const int kTicksPerMinute = 600000000;
int ticksFromDuration(Duration duration) => duration.inMicroseconds * 10;
Duration durationFromTicks(int ticks) => Duration(microseconds: ticks ~/ 10);
double playbackProgress(EmbyRawItem item) {
final position = item.UserData?.PlaybackPositionTicks;
final total = item.RunTimeTicks;
if (position == null || total == null || total <= 0) return 0;
final value = position / total;
if (value.isNaN || value.isInfinite) return 0;
return value.clamp(0.0, 1.0);
}
String? formatRemainingLabel(EmbyRawItem item) {
final position = item.UserData?.PlaybackPositionTicks;
final total = item.RunTimeTicks;
if (position == null || total == null || total <= 0) return null;
final minutes = ((total - position) / kTicksPerMinute).ceil();
return minutes > 0 ? '剩余 $minutes 分钟' : null;
}
+69
View File
@@ -0,0 +1,69 @@
import 'emby_ticks.dart';
String? formatRuntimeMinutes(int? minutes) {
if (minutes == null || minutes <= 0) return null;
final h = minutes ~/ 60;
final m = minutes % 60;
if (h > 0) return m > 0 ? '$h小时$m分钟' : '$h小时';
return '$m分钟';
}
String formatFileSize(int bytes) {
if (bytes >= 1073741824) {
return '${(bytes / 1073741824).toStringAsFixed(2)}G';
}
if (bytes >= 1048576) {
return '${(bytes / 1048576).toStringAsFixed(0)}M';
}
return '${(bytes / 1024).toStringAsFixed(0)}K';
}
String? formatEpisodeNumber(int? index) {
if (index == null) return null;
return '$index集';
}
String? formatSeasonNumber(int? season) {
if (season == null) return null;
return '$season季';
}
double? downloadProgressValue({required int received, required int total}) {
if (total <= 0) return null;
final value = received / total;
return value.clamp(0, 1).toDouble();
}
String formatDownloadBytes(int bytes) {
if (bytes <= 0) return '-- MB';
return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB';
}
String formatDownloadSpeed(double bytesPerSec) {
if (bytesPerSec <= 0) return '0 B/s';
if (bytesPerSec >= 1024 * 1024) {
return '${(bytesPerSec / (1024 * 1024)).toStringAsFixed(1)} MB/s';
}
if (bytesPerSec >= 1024) {
return '${(bytesPerSec / 1024).toStringAsFixed(0)} KB/s';
}
return '${bytesPerSec.toStringAsFixed(0)} B/s';
}
String formatDurationClock(Duration duration) {
final h = duration.inHours;
final m = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
String formatTicksAsClock(int ticks) =>
formatDurationClock(ticks <= 0 ? Duration.zero : durationFromTicks(ticks));
+23
View File
@@ -0,0 +1,23 @@
void evictOldestEntries(Map<String, dynamic> map, int maxSize) {
if (map.length <= maxSize) return;
final entries = map.entries.toList()
..sort((a, b) {
final aTime =
DateTime.tryParse(
(a.value as Map<String, dynamic>)['updatedAt'] as String? ?? '',
) ??
DateTime(2000);
final bTime =
DateTime.tryParse(
(b.value as Map<String, dynamic>)['updatedAt'] as String? ?? '',
) ??
DateTime(2000);
return aTime.compareTo(bTime);
});
final removeCount = map.length - maxSize;
for (var i = 0; i < removeCount; i++) {
map.remove(entries[i].key);
}
}
+131
View File
@@ -0,0 +1,131 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/contracts/library.dart';
import '../../core/contracts/tmdb.dart';
import '../../core/infra/emby_api/emby_headers.dart';
import '../../features/player/player_launcher.dart';
import '../../providers/di_providers.dart';
import '../../providers/session_provider.dart';
import '../../providers/tmdb_prefetch_provider.dart';
import 'platform_device_name.dart';
const _kContainerTypes = <String>{
'BoxSet',
'CollectionFolder',
'Folder',
'Playlist',
'UserView',
};
void goMediaDetail(BuildContext context, EmbyRawItem item, {WidgetRef? ref}) {
if (_kContainerTypes.contains(item.Type)) {
context.push(
Uri(
path: '/library/${item.Id}',
queryParameters: {
'collection': '1',
if (item.Name.isNotEmpty) 'title': item.Name,
},
).toString(),
);
return;
}
if (ref != null) {
ref.read(tmdbPrefetchProvider.notifier).prefetchItem(item, priority: true);
}
final isEpisode = item.Type == 'Episode';
final seriesId = isEpisode && (item.SeriesId?.isNotEmpty ?? false)
? item.SeriesId
: null;
final seasonId = isEpisode && (item.SeasonId?.isNotEmpty ?? false)
? item.SeasonId
: null;
final query = <String, String>{
if (seriesId != null) 'seriesId': seriesId,
if (seasonId != null) 'seasonId': seasonId,
};
context.push(
Uri(
path: '/media/${item.Id}',
queryParameters: query.isNotEmpty ? query : null,
).toString(),
extra: item,
);
}
Future<PlayerExitResult?> playFromTmdbOnServer(
BuildContext context,
WidgetRef ref, {
required String serverId,
required String itemId,
String? mediaSourceId,
int preferredSubtitleStreamIndex = -1,
int? preferredAudioStreamIndex,
bool fromStart = false,
int? startPositionTicks,
String? backdropUrl,
}) async {
final active = ref.read(activeSessionProvider);
if (active?.serverId != serverId) {
await ref.read(sessionProvider.notifier).switchSession(serverId);
}
if (!context.mounted) return null;
return PlayerLauncher.launch(
context: context,
itemId: itemId,
serverId: serverId,
mediaSourceId: mediaSourceId,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
startPositionTicks: startPositionTicks,
fromStart: fromStart,
backdropUrl: backdropUrl,
ref: ref,
);
}
void goTmdbDetail(
BuildContext context, {
required int tmdbId,
required String mediaType,
TmdbRecommendation? seed,
}) {
if (mediaType != 'movie' && mediaType != 'tv') return;
context.push('/tmdb/$mediaType/$tmdbId', extra: seed);
}
Future<void> goMediaDetailOnServer(
BuildContext context,
WidgetRef ref, {
required String serverId,
required EmbyRawItem item,
}) async {
final activeId = ref.read(sessionProvider).value?.activeServerId;
if (serverId != activeId) {
await ref.read(sessionProvider.notifier).switchSession(serverId);
}
if (context.mounted) {
goMediaDetail(context, item);
}
}
Map<String, String> buildEmbyImageHeaders(
WidgetRef ref, {
required String token,
required String userId,
}) {
final deviceId = ref.read(deviceIdProvider).value ?? '';
return EmbyRequestHeaders.image(
deviceId: deviceId,
deviceName: kEmbyDeviceName,
token: token,
userId: userId,
);
}
@@ -0,0 +1,3 @@
const String kEmbyDeviceName = 'Mac';
+91
View File
@@ -0,0 +1,91 @@
class ParsedVersion {
final int major;
final int minor;
final int patch;
final String? prerelease;
final String? build;
const ParsedVersion({
required this.major,
required this.minor,
required this.patch,
this.prerelease,
this.build,
});
bool get hasPrerelease => prerelease != null && prerelease!.isNotEmpty;
bool sameCore(ParsedVersion other) {
return major == other.major && minor == other.minor && patch == other.patch;
}
}
final RegExp _versionPattern = RegExp(
r'^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$',
);
ParsedVersion? parseVersion(String tag) {
final match = _versionPattern.firstMatch(tag.trim());
if (match == null) return null;
return ParsedVersion(
major: int.parse(match.group(1)!),
minor: int.parse(match.group(2)!),
patch: int.parse(match.group(3)!),
prerelease: match.group(4),
build: match.group(5),
);
}
bool isNewerVersion(String localTag, String remoteTag) {
final local = parseVersion(localTag);
final remote = parseVersion(remoteTag);
if (local == null || remote == null) return false;
if (remote.hasPrerelease) {
if (!local.hasPrerelease || !local.sameCore(remote)) return false;
return _comparePrerelease(remote.prerelease!, local.prerelease!) > 0;
}
final coreCompare = _compareCore(remote, local);
if (coreCompare != 0) return coreCompare > 0;
return local.hasPrerelease;
}
int _compareCore(ParsedVersion a, ParsedVersion b) {
final major = a.major.compareTo(b.major);
if (major != 0) return major;
final minor = a.minor.compareTo(b.minor);
if (minor != 0) return minor;
return a.patch.compareTo(b.patch);
}
int _comparePrerelease(String a, String b) {
final left = a.split('.');
final right = b.split('.');
final length = left.length > right.length ? left.length : right.length;
for (var i = 0; i < length; i++) {
if (i >= left.length) return -1;
if (i >= right.length) return 1;
final leftPart = left[i];
final rightPart = right[i];
final leftNumber = int.tryParse(leftPart);
final rightNumber = int.tryParse(rightPart);
if (leftNumber != null && rightNumber != null) {
final numberCompare = leftNumber.compareTo(rightNumber);
if (numberCompare != 0) return numberCompare;
continue;
}
if (leftNumber != null) return -1;
if (rightNumber != null) return 1;
final textCompare = leftPart.compareTo(rightPart);
if (textCompare != 0) return textCompare;
}
return 0;
}
+97
View File
@@ -0,0 +1,97 @@
library;
enum TimeBucketScheme { watched, added }
class TimeBucket {
final String key;
final String label;
final int order;
const TimeBucket({
required this.key,
required this.label,
required this.order,
});
}
const _bucketToday = TimeBucket(key: 'today', label: '今天', order: 0);
const _bucketYesterday = TimeBucket(key: 'yesterday', label: '昨天', order: 1);
const _bucketThisWeekWatched = TimeBucket(
key: 'this_week',
label: '本周',
order: 2,
);
const _bucketEarlierWatched = TimeBucket(key: 'earlier', label: '更早', order: 3);
const _bucketThisWeekAdded = TimeBucket(
key: 'this_week',
label: '本周',
order: 1,
);
const _bucketThisMonthAdded = TimeBucket(
key: 'this_month',
label: '本月',
order: 2,
);
const _bucketEarlierAdded = TimeBucket(key: 'earlier', label: '更早', order: 3);
DateTime? parseEmbyDate(Object? raw) {
if (raw is DateTime) return raw.toLocal();
if (raw is! String || raw.isEmpty) return null;
final parsed = DateTime.tryParse(raw);
return parsed?.toLocal();
}
DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
DateTime _startOfWeek(DateTime now) {
final today = _dateOnly(now);
return today.subtract(Duration(days: today.weekday - DateTime.monday));
}
TimeBucket bucketFor(DateTime? when, DateTime now, TimeBucketScheme scheme) {
final earlier = scheme == TimeBucketScheme.watched
? _bucketEarlierWatched
: _bucketEarlierAdded;
if (when == null) return earlier;
final whenDay = _dateOnly(when);
final todayDay = _dateOnly(now);
final dayDiff = todayDay.difference(whenDay).inDays;
switch (scheme) {
case TimeBucketScheme.watched:
if (dayDiff <= 0) return _bucketToday;
if (dayDiff == 1) return _bucketYesterday;
if (!whenDay.isBefore(_startOfWeek(now))) return _bucketThisWeekWatched;
return _bucketEarlierWatched;
case TimeBucketScheme.added:
if (dayDiff <= 0) return _bucketToday;
if (!whenDay.isBefore(_startOfWeek(now))) return _bucketThisWeekAdded;
if (whenDay.year == todayDay.year && whenDay.month == todayDay.month) {
return _bucketThisMonthAdded;
}
return _bucketEarlierAdded;
}
}
String? relativeTimeLabel(DateTime? when, DateTime now) {
if (when == null) return null;
final diff = now.difference(when);
if (diff.isNegative) return '刚刚';
if (diff.inMinutes < 1) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前';
if (diff.inHours < 24) return '${diff.inHours} 小时前';
if (diff.inDays < 7) return '${diff.inDays} 天前';
if (diff.inDays < 35) return '${(diff.inDays / 7).floor()} 周前';
final m = when.month.toString().padLeft(2, '0');
final d = when.day.toString().padLeft(2, '0');
return '${when.year}-$m-$d';
}
+12
View File
@@ -0,0 +1,12 @@
import '../../core/contracts/library.dart';
String? buildTmdbCtaLabel({
required String mediaType,
required TmdbLibraryHit? primaryHit,
required int hitCount,
}) {
if (primaryHit == null) return null;
final hasProgress = (primaryHit.playbackPositionTicks ?? 0) > 0;
return hasProgress ? '继续播放' : '播放';
}
@@ -0,0 +1,36 @@
import '../../core/contracts/library.dart';
import '../../core/contracts/tmdb.dart';
import '../mappers/tmdb_image_url.dart';
int? seasonNumberFrom(EmbyRawSeason season) {
final indexNumber = season.IndexNumber;
if (indexNumber != null) return indexNumber;
final name = season.Name.trim().toLowerCase();
if (name == 'specials' || name == '特别篇' || name == 'sp') return 0;
final match = RegExp(r'\d+').firstMatch(season.Name);
if (match == null) return null;
return int.tryParse(match.group(0)!);
}
Map<int, String> tmdbStillUrlsByEpisode(
TmdbSeasonDetail? season,
String? imageBaseUrl,
) {
if (season == null || imageBaseUrl == null || imageBaseUrl.isEmpty) {
return const {};
}
final urls = <int, String>{};
for (final episode in season.episodes) {
final url = TmdbImageUrl.still(
imageBaseUrl,
episode.stillPath,
size: 'w500',
);
if (url != null && url.isNotEmpty) {
urls[episode.episodeNumber] = url;
}
}
return urls;
}
+28
View File
@@ -0,0 +1,28 @@
import '../../core/contracts/library.dart';
import '../../providers/di_providers.dart';
String tmdbIdFromItem(EmbyRawItem item) {
final rawProviderIds = item.extra['ProviderIds'];
if (rawProviderIds is! Map) return '';
for (final entry in rawProviderIds.entries) {
final key = entry.key?.toString().trim().toLowerCase() ?? '';
final value = entry.value?.toString().trim() ?? '';
if (key == 'tmdb' && value.isNotEmpty) return value;
}
return '';
}
TmdbMediaKey? tmdbMediaKeyFrom(EmbyRawItem item) {
final mediaType = switch (item.Type) {
'Movie' => 'movie',
'Series' || 'Episode' || 'Season' => 'tv',
_ => null,
};
if (mediaType == null) return null;
final tmdbId = tmdbIdFromItem(item);
if (tmdbId.isEmpty) return null;
return (tmdbId: tmdbId, mediaType: mediaType);
}
+27
View File
@@ -0,0 +1,27 @@
import '../../core/contracts/library.dart';
TmdbLibraryHit? selectPrimaryTmdbHit(
List<TmdbLibraryHit> hits, {
String? activeServerId,
}) {
if (hits.isEmpty) return null;
int score(TmdbLibraryHit h) {
var s = 0;
if ((h.playbackPositionTicks ?? 0) > 0) s += 2;
if (activeServerId != null && h.serverId == activeServerId) s += 1;
return s;
}
var best = hits.first;
var bestScore = score(best);
for (final h in hits.skip(1)) {
final s = score(h);
if (s > bestScore) {
best = h;
bestScore = s;
}
}
return best;
}
+1
View File
@@ -0,0 +1 @@
String stripTrailingSlash(String s) => s.replaceFirst(RegExp(r'/+$'), '');
@@ -0,0 +1,13 @@
import '../../core/domain/errors.dart';
String formatUserError(Object? error, {String fallback = '操作失败'}) {
if (error == null) return fallback;
if (error is DomainError) return error.message;
final raw = error.toString().trim();
if (raw.isEmpty) return fallback;
return raw
.replaceFirst(RegExp(r'^(Exception|StateError|TimeoutException):\s*'), '')
.trim();
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
const String kWinWidthKey = 'window_width';
const String kWinHeightKey = 'window_height';
const String kWinXKey = 'window_x';
const String kWinYKey = 'window_y';
Future<void> saveWindowBounds() async {
try {
final size = await windowManager.getSize();
final position = await windowManager.getPosition();
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(kWinWidthKey, size.width);
await prefs.setDouble(kWinHeightKey, size.height);
await prefs.setDouble(kWinXKey, position.dx);
await prefs.setDouble(kWinYKey, position.dy);
} catch (_) {}
}
+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,
),
),
),
),
),
);
}
}