Initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(',');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('重试'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user