Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,141 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/contracts/library.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/emby_headers_provider.dart';
import '../../../providers/session_provider.dart';
import '../../../shared/mappers/media_image_url.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
import '../../../shared/widgets/media_poster_card.dart';
import 'section_container.dart';
typedef AdditionalPartTap = void Function(EmbyRawItem item);
class AdditionalPartsSection extends ConsumerWidget {
final String itemId;
final bool embedded;
final String? serverId;
final AdditionalPartTap? onItemTap;
final Widget? leading;
const AdditionalPartsSection({
super.key,
required this.itemId,
this.embedded = false,
this.serverId,
this.onItemTap,
this.leading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scopedServerId = serverId;
final AsyncValue<List<EmbyRawItem>> asyncItems;
final String? baseUrl;
final String? token;
final Map<String, String>? imageHeaders;
if (scopedServerId != null) {
asyncItems = ref.watch(
tmdbServerScopedAdditionalPartsProvider((
serverId: scopedServerId,
itemId: itemId,
)),
);
final auth = ref
.watch(tmdbServerScopedAuthProvider(scopedServerId))
.value;
baseUrl = auth?.baseUrl;
token = auth?.token;
imageHeaders = auth?.imageHeaders;
} else {
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
asyncItems = ref.watch(additionalPartsDataProvider(itemId));
baseUrl = session.serverUrl;
token = session.token;
imageHeaders = ref.watch(embyImageHeadersProvider).value;
}
return asyncItems.when(
loading: () => const SizedBox.shrink(),
error: (error, stackTrace) => const SizedBox.shrink(),
data: (items) {
if (items.isEmpty || baseUrl == null || token == null) {
return const SizedBox.shrink();
}
final hPad = SectionContainer.hPadOf(context);
final resolvedBaseUrl = baseUrl;
final resolvedToken = token;
final isAndroid = Platform.isAndroid;
return SectionContainer(
title: '附加片段',
embedded: embedded,
leadingDivider: embedded,
leading: leading,
child: SizedBox(
height: isAndroid ? 210 : 300,
child: Theme(
data: AppTheme.dark(),
child: HoverScrollableRow(
builder: (_, controller) => ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: hPad),
itemCount: items.length,
itemBuilder: (_, i) {
final item = items[i];
final imageUrl = EmbyImageUrl.primary(
baseUrl: resolvedBaseUrl,
token: resolvedToken,
item: item,
maxHeight: 480,
);
return Padding(
padding: EdgeInsets.only(
right: i < items.length - 1 ? 12 : 0,
),
child: MediaPosterCard(
item: item,
imageUrl: imageUrl,
width: isAndroid ? 105 : 160,
showHoverPlayIcon: false,
enableContextMenu: false,
imageHeaders: imageHeaders,
onTap: () {
final tap = onItemTap;
if (tap != null) {
tap(item);
} else {
context.pushReplacement(
'/media/${item.Id}',
extra: item,
);
}
},
),
);
},
),
),
),
),
);
},
);
}
}
@@ -0,0 +1,201 @@
import 'dart:io' show Platform;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import '../../../shared/widgets/smart_image.dart';
class DetailBackdrop extends StatefulWidget {
const DetailBackdrop({
super.key,
required this.scrollProgress,
required this.backdropUrl,
required this.fallbackUrls,
required this.embyBaseUrl,
required this.imageHeaders,
this.gradientEndColor = const Color(0xFF08090c),
});
final ValueNotifier<double> scrollProgress;
final String backdropUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
final Color gradientEndColor;
@override
State<DetailBackdrop> createState() => _DetailBackdropState();
}
class _DetailBackdropState extends State<DetailBackdrop> {
bool _backdropFailed = false;
@override
void didUpdateWidget(DetailBackdrop oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.backdropUrl != widget.backdropUrl) {
_backdropFailed = false;
}
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: _backdropFailed
? _buildFallbackGradient()
: RepaintBoundary(
child: ValueListenableBuilder<double>(
valueListenable: widget.scrollProgress,
builder: (_, p, child) {
final maxSigma = Platform.isAndroid ? 6.0 : 10.0;
var sigma = p > 0.01 ? p * maxSigma : 0.0;
if (Platform.isAndroid && sigma > 0) {
sigma = (sigma / 1.5).roundToDouble() * 1.5;
}
final blurred = ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: sigma,
sigmaY: sigma,
),
child: child,
);
if (Platform.isAndroid) return blurred;
return Transform.scale(
scale: 1.0 + p * 0.15,
child: blurred,
);
},
child: _buildImageStack(),
),
),
),
Positioned.fill(
child: ValueListenableBuilder<double>(
valueListenable: widget.scrollProgress,
builder: (_, p, _) {
return IgnorePointer(
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.10 + p * 0.3),
),
);
},
),
),
Positioned.fill(
child: IgnorePointer(child: _buildBottomGradient(context)),
),
],
);
}
Widget _buildBottomGradient(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final coverage = (size.width * 9.0 / 16.0 / size.height).clamp(0.0, 1.0);
final rampStart = coverage >= 0.95
? 0.5
: (coverage * 0.55).clamp(0.1, 0.5);
final rampEnd = coverage >= 0.95
? 0.75
: (coverage * 0.85).clamp(0.25, 0.75);
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, rampStart, rampEnd, 1.0],
colors: [
Colors.transparent,
Colors.transparent,
widget.gradientEndColor.withValues(alpha: 0.85),
widget.gradientEndColor,
],
),
),
);
}
Widget _buildFallbackGradient() {
return IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.6, 1.0],
colors: [
widget.gradientEndColor.withValues(alpha: 0.5),
widget.gradientEndColor.withValues(alpha: 0.85),
widget.gradientEndColor,
],
),
),
),
);
}
Widget _buildImageStack() {
return _buildArtworkImage(
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
);
}
Widget _buildArtworkImage({
required BoxFit fit,
required Alignment alignment,
int? memCacheWidth,
}) {
final placeholder = DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
widget.gradientEndColor.withValues(alpha: 0.3),
widget.gradientEndColor,
],
),
),
);
final imageStack = Stack(
fit: StackFit.expand,
children: [
ColoredBox(color: widget.gradientEndColor),
SmartImage(
url: widget.backdropUrl,
fallbackUrls: widget.fallbackUrls,
fit: fit,
alignment: alignment,
borderRadius: 0,
memCacheWidth: memCacheWidth,
placeholder: placeholder,
onError: () {
if (mounted && !_backdropFailed) {
setState(() => _backdropFailed = true);
}
},
resolveHttpHeaders: (url) =>
widget.embyBaseUrl.isNotEmpty &&
url.startsWith(widget.embyBaseUrl)
? widget.imageHeaders
: null,
),
],
);
return imageStack;
}
}
@@ -0,0 +1,402 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../../shared/constants/breakpoints.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/widgets/app_dialog.dart';
import '../../../shared/widgets/smart_image.dart';
class DetailHeroPanel extends StatelessWidget {
final String title;
final String? logoUrl;
final String? posterUrl;
final Map<String, String>? imageHeaders;
final double? rating;
final List<String> metaParts;
final List<String> genres;
final String? overview;
final String? tagline;
final Widget? actions;
const DetailHeroPanel({
super.key,
required this.title,
this.logoUrl,
this.posterUrl,
this.imageHeaders,
this.rating,
this.metaParts = const [],
this.genres = const [],
this.overview,
this.tagline,
this.actions,
});
bool get _hasOverview =>
(overview?.trim().isNotEmpty ?? false) ||
(tagline?.trim().isNotEmpty ?? false);
@override
Widget build(BuildContext context) {
final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
final titleBlock = _HeroTitle(
title: title,
logoUrl: logoUrl,
posterUrl: posterUrl,
imageHeaders: imageHeaders,
compact: compact,
);
final pills = _MetaPills(
rating: rating,
metaParts: metaParts,
genres: genres,
);
if (!compact && _hasOverview) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
titleBlock,
if (actions != null) ...[const SizedBox(height: 24), actions!],
],
),
),
const SizedBox(width: 48),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
pills,
const SizedBox(height: 16),
_Overview(overview: overview, tagline: tagline),
],
),
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
titleBlock,
const SizedBox(height: 12),
pills,
if (actions != null) ...[const SizedBox(height: 20), actions!],
if (_hasOverview) ...[
const SizedBox(height: 20),
_Overview(overview: overview, tagline: tagline),
],
],
);
}
}
class _HeroTitle extends StatefulWidget {
final String title;
final String? logoUrl;
final String? posterUrl;
final Map<String, String>? imageHeaders;
final bool compact;
const _HeroTitle({
required this.title,
required this.logoUrl,
required this.posterUrl,
required this.imageHeaders,
required this.compact,
});
@override
State<_HeroTitle> createState() => _HeroTitleState();
}
class _HeroTitleState extends State<_HeroTitle> {
bool _logoFailed = false;
@override
void didUpdateWidget(covariant _HeroTitle oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.logoUrl != widget.logoUrl) _logoFailed = false;
}
@override
Widget build(BuildContext context) {
final logo = widget.logoUrl;
if (logo != null && logo.isNotEmpty && !_logoFailed) {
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: widget.compact ? 240 : 400,
maxHeight: widget.compact ? 80 : 120,
),
child: CachedNetworkImage(
imageUrl: logo,
httpHeaders: widget.imageHeaders,
fit: BoxFit.contain,
alignment: Alignment.bottomLeft,
memCacheWidth: widget.compact ? 720 : 1200,
fadeInDuration: const Duration(milliseconds: 300),
placeholder: (_, _) => _TitleText(title: widget.title),
errorWidget: (_, _, _) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && !_logoFailed) {
setState(() => _logoFailed = true);
}
});
return _TitleText(title: widget.title);
},
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.posterUrl != null && widget.posterUrl!.isNotEmpty) ...[
SizedBox(
width: widget.compact ? 104 : 130,
child: AspectRatio(
aspectRatio: 2 / 3,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: SmartImage(
url: widget.posterUrl,
httpHeaders: widget.imageHeaders,
fallbackIcon: Icons.movie_outlined,
borderRadius: 10,
),
),
),
),
const SizedBox(height: 14),
],
_TitleText(title: widget.title),
],
);
}
}
class _TitleText extends StatelessWidget {
final String title;
const _TitleText({required this.title});
@override
Widget build(BuildContext context) {
return Text(
title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 26,
fontWeight: FontWeight.w700,
height: 1.15,
),
);
}
}
class _MetaPills extends StatelessWidget {
final double? rating;
final List<String> metaParts;
final List<String> genres;
const _MetaPills({
required this.rating,
required this.metaParts,
required this.genres,
});
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
final r = rating;
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (r != null && r > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: tokens.ratingColor.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star_rounded, size: 14, color: Colors.black87),
const SizedBox(width: 3),
Text(
r.toStringAsFixed(1),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
],
),
),
for (final part in [
...metaParts,
for (final g in genres.take(4))
if (g.isNotEmpty) g,
])
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
),
child: Text(
part,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.85),
),
),
),
],
);
}
}
class _Overview extends StatelessWidget {
final String? overview;
final String? tagline;
const _Overview({required this.overview, required this.tagline});
void _showDialog(BuildContext context, String text) {
showAppRawDialog<void>(
context,
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 480),
builder: (ctx) => ColoredBox(
color: const Color(0xFF1a1c22),
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'简介',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Text(
text,
style: TextStyle(
fontSize: 14,
height: 1.8,
color: Colors.white.withValues(alpha: 0.82),
),
),
),
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(
'关闭',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
),
),
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final body = overview?.trim();
final line = tagline?.trim();
if ((body == null || body.isEmpty) && (line == null || line.isEmpty)) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (line != null && line.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
line,
style: TextStyle(
fontSize: 15,
fontStyle: FontStyle.italic,
color: Colors.white.withValues(alpha: 0.6),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (body != null && body.isNotEmpty)
GestureDetector(
onTap: () => _showDialog(context, body),
child: Text(
body,
style: TextStyle(
fontSize: 14,
height: 1.7,
color: Colors.white.withValues(alpha: 0.82),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
@@ -0,0 +1,153 @@
import 'dart:io' show Platform;
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'detail_backdrop.dart';
class DetailImmersiveScaffold extends StatelessWidget {
final ScrollController scrollController;
final ValueNotifier<double> scrollProgress;
final String? backdropUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
final Widget child;
final bool compact;
final double horizontalPadding;
const DetailImmersiveScaffold({
super.key,
required this.scrollController,
required this.scrollProgress,
required this.child,
this.backdropUrl,
this.fallbackUrls = const <String>[],
this.embyBaseUrl = '',
this.imageHeaders,
this.compact = false,
this.horizontalPadding = 32,
});
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.sizeOf(context);
final screenHeight = screenSize.height;
final screenWidth = screenSize.width;
final bannerHeight = compact
? screenHeight * 0.30
: (screenWidth * 9.0 / 16.0).clamp(280.0, screenHeight * 0.6);
final backdrop = backdropUrl;
return Stack(
children: [
if (backdrop != null)
DetailBackdrop(
scrollProgress: scrollProgress,
backdropUrl: backdrop,
fallbackUrls: fallbackUrls,
embyBaseUrl: embyBaseUrl,
imageHeaders: imageHeaders,
),
SingleChildScrollView(
controller: scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: bannerHeight - 80),
Stack(
children: [
Positioned.fill(child: _FrostedPanelVeil()),
Padding(
padding: EdgeInsets.fromLTRB(
horizontalPadding,
64,
horizontalPadding,
48,
),
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: child,
),
),
),
],
),
],
),
),
],
);
}
}
class _FrostedPanelVeil extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (Platform.isAndroid) {
return IgnorePointer(
child: ShaderMask(
shaderCallback: (rect) {
final ratio = (140.0 / rect.height).clamp(0.02, 0.5);
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Colors.transparent, Colors.black],
stops: [0.0, ratio],
).createShader(rect);
},
blendMode: BlendMode.dstIn,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.12, 1.0],
colors: [
const Color(0xFF0a0c10).withValues(alpha: 0.0),
const Color(0xFF0a0c10).withValues(alpha: 0.88),
const Color(0xFF0a0c10).withValues(alpha: 0.96),
],
),
),
),
),
);
}
return IgnorePointer(
child: ShaderMask(
shaderCallback: (rect) {
final ratio = (140.0 / rect.height).clamp(0.02, 0.5);
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: const [Colors.transparent, Colors.black],
stops: [0.0, ratio],
).createShader(rect);
},
blendMode: BlendMode.dstIn,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.20, 1.0],
colors: [
const Color(0xFF0a0c10).withValues(alpha: 0.0),
const Color(0xFF0a0c10).withValues(alpha: 0.12),
const Color(0xFF0a0c10).withValues(alpha: 0.20),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,135 @@
import 'dart:io' show Platform;
import 'dart:math' as math;
import 'dart:ui' show ImageFilter;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/appearance_provider.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/widgets/top_drag_area.dart';
import '../../../shared/widgets/window_chrome.dart';
import '../../../shared/widgets/window_controls.dart';
const double _kDesktopTitleBarHeight = 38.0;
class DetailNavBar extends ConsumerWidget {
final ValueListenable<double> scrollProgress;
final String title;
final VoidCallback onBack;
final Color chromeColor;
const DetailNavBar({
super.key,
required this.scrollProgress,
required this.title,
required this.onBack,
this.chromeColor = const Color(0xFF08090c),
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDesktop = WindowChrome.isDesktop;
final topPadding = isDesktop
? _kDesktopTitleBarHeight
: math.max(MediaQuery.paddingOf(context).top, _kDesktopTitleBarHeight);
final tokens = context.appTokens;
final headerMode =
ref.watch(appearanceProvider).value?.headerMode ?? HeaderMode.frosted;
final isFrosted = headerMode == HeaderMode.frosted;
final navHeight = topPadding + 30;
return Stack(
children: [
SizedBox(
height: navHeight,
width: double.infinity,
child: ValueListenableBuilder<double>(
valueListenable: scrollProgress,
builder: (_, raw, _) {
final p = raw.clamp(0.0, 1.0);
if (isFrosted) {
if (Platform.isAndroid && p < 0.05) {
return const SizedBox.expand();
}
final maxSigma = Platform.isAndroid ? 10.0 : 18.0;
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: p * maxSigma,
sigmaY: p * maxSigma,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: chromeColor.withValues(alpha: p * 0.7),
border: Border(
bottom: BorderSide(
color: const Color(
0xFFFFFFFF,
).withValues(alpha: p * 0.08),
width: 1,
),
),
),
),
),
);
}
return ColoredBox(color: chromeColor.withValues(alpha: p));
},
),
),
const Positioned.fill(child: TopDragArea(child: SizedBox.expand())),
Positioned(
top: isDesktop
? tokens.chromeInset
: MediaQuery.paddingOf(context).top,
left: 0,
right: 0,
child: Row(
children: [
SizedBox(width: tokens.chromeInset),
IconButton(
icon: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 18,
),
onPressed: onBack,
tooltip: '返回',
constraints: const BoxConstraints(minWidth: 30, minHeight: 30),
padding: const EdgeInsets.all(6),
),
const SizedBox(width: 4),
Expanded(
child: IgnorePointer(
child: ValueListenableBuilder<double>(
valueListenable: scrollProgress,
builder: (_, p, child) {
return Opacity(opacity: p.clamp(0.0, 1.0), child: child);
},
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
),
),
const SizedBox(width: 4),
if (isDesktop) const WindowControls.forDarkSurface(),
SizedBox(width: tokens.chromeInset),
],
),
),
],
);
}
}
@@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
double computeDetailScrollProgress(
BuildContext context,
double offset, {
double? collapseExtent,
}) {
final mq = MediaQuery.of(context);
if (mq.disableAnimations) return offset > 10 ? 1.0 : 0.0;
final maxScroll = collapseExtent ?? mq.size.height * 0.8;
final raw = (offset / maxScroll).clamp(0.0, 1.0);
return (raw * 100).roundToDouble() / 100;
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'detail_hero_panel.dart';
import '../model/detail_view_state.dart';
import '../../../shared/widgets/app_loading_ring.dart';
class DetailViewRenderer extends StatelessWidget {
final DetailViewState state;
const DetailViewRenderer({super.key, required this.state});
@override
Widget build(BuildContext context) {
final hero = state.hero;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DetailHeroPanel(
title: hero.title,
logoUrl: hero.logoUrl,
posterUrl: hero.posterUrl,
imageHeaders: hero.imageHeaders,
rating: hero.rating,
metaParts: hero.metaParts,
genres: hero.genres,
overview: hero.overview,
tagline: hero.tagline,
actions: hero.actions,
),
for (final section in state.sections) section.child,
if (state.showLoadingBelowHero)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing()),
),
const SizedBox(height: 32),
],
);
}
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/contracts/library.dart';
import '../../../providers/emby_headers_provider.dart';
import '../../../providers/session_provider.dart';
import '../../../shared/mappers/media_image_url.dart';
import '../../../shared/widgets/cast_scroller.dart';
import 'section_container.dart';
class EmbyCastSection extends ConsumerWidget {
final EmbyRawItem item;
final bool embedded;
final Widget? leading;
const EmbyCastSection({
super.key,
required this.item,
this.embedded = false,
this.leading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = _extractActors(item);
if (people.isEmpty) return const SizedBox.shrink();
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
final headers = ref.watch(embyImageHeadersProvider).value;
final hPad = embedded ? 0.0 : SectionContainer.hPadOf(context);
final entries = people.map((person) {
final name = person['Name'] as String? ?? '';
final role = person['Role'] as String?;
final personId = person['Id'] as String?;
final imageTag = person['PrimaryImageTag'] as String?;
final photoUrl = personId != null
? _personImageUrl(
session.serverUrl,
session.token,
personId,
imageTag,
)
: null;
final tappable = personId != null && personId.isNotEmpty;
return CastEntry(
name: name,
subtitle: role,
imageUrl: photoUrl,
imageHeaders: headers,
onTap: tappable ? () => context.push('/person/$personId') : null,
);
}).toList();
return SectionContainer(
title: '演职人员',
embedded: embedded,
leadingDivider: embedded,
leading: leading,
child: CastScroller(
entries: entries,
padding: EdgeInsets.symmetric(horizontal: hPad),
),
);
}
static List<Map<String, dynamic>> _extractActors(EmbyRawItem item) {
final raw = item.extra['People'];
if (raw is! List) return const [];
return raw.whereType<Map<String, dynamic>>().toList();
}
static String? _personImageUrl(
String baseUrl,
String token,
String personId,
String? tag,
) {
if (tag == null) return null;
return EmbyImageUrl.primaryById(
baseUrl: baseUrl,
token: token,
itemId: personId,
maxHeight: 200,
);
}
}
@@ -0,0 +1,853 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/contracts/library.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/session_provider.dart';
import '../../../providers/tmdb_settings_provider.dart';
import '../../../shared/constants/breakpoints.dart';
import '../../../shared/utils/emby_ticks.dart';
import '../../../shared/utils/format_utils.dart';
import '../../../shared/utils/tmdb_episode_helpers.dart';
import '../../../shared/widgets/adaptive_modal.dart';
import '../../../shared/widgets/app_dialog.dart';
import '../../../shared/widgets/app_inline_alert.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../../../shared/widgets/card_progress_bar.dart';
import '../../../shared/widgets/episode_thumb.dart';
import '../../../shared/widgets/frosted_panel.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
import '../../../shared/widgets/season_selector.dart';
import 'episode_selection_resolver.dart';
typedef EpisodeTapWithSeason =
void Function(EmbyRawItem episode, int? seasonNumber);
class EpisodeListSection extends ConsumerStatefulWidget {
final String seriesId;
final String? tmdbSeriesId;
final String? currentEpisodeId;
final String? currentEpisodeSeasonId;
final int? currentEpisodeSeasonNumber;
final int? currentEpisodeIndexNumber;
final String? initialSeasonId;
final void Function(EmbyRawItem episode) onEpisodeTap;
final EpisodeTapWithSeason? onEpisodeTapWithSeason;
final void Function(EmbyRawItem episode)? onPlaybackPrefetch;
final bool embedded;
final String? serverId;
final bool autoSelectFirstEpisode;
final String? autoSelectEpisodeId;
final String? autoSelectSeasonId;
final Map<String, String>? showProviderIds;
final String? showTitle;
final int? showYear;
final Widget? leading;
const EpisodeListSection({
super.key,
required this.seriesId,
this.tmdbSeriesId,
this.currentEpisodeId,
this.currentEpisodeSeasonId,
this.currentEpisodeSeasonNumber,
this.currentEpisodeIndexNumber,
this.initialSeasonId,
required this.onEpisodeTap,
this.onEpisodeTapWithSeason,
this.onPlaybackPrefetch,
this.embedded = false,
this.serverId,
this.autoSelectFirstEpisode = true,
this.autoSelectEpisodeId,
this.autoSelectSeasonId,
this.showProviderIds,
this.showTitle,
this.showYear,
this.leading,
});
@override
ConsumerState<EpisodeListSection> createState() => _EpisodeListSectionState();
}
class _EpisodeListSectionState extends ConsumerState<EpisodeListSection> {
String? _selectedSeasonId;
void _selectSeason(String seasonId) {
if (_selectedSeasonId == seasonId) return;
setState(() => _selectedSeasonId = seasonId);
}
@override
Widget build(BuildContext context) {
final scopedServerId = widget.serverId;
late final String targetServerId;
late final String baseUrl;
late final String token;
if (scopedServerId != null) {
final authAsync = ref.watch(tmdbServerScopedAuthProvider(scopedServerId));
final auth = authAsync.value;
if (auth == null) {
return authAsync.isLoading
? const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing()),
)
: const SizedBox.shrink();
}
targetServerId = scopedServerId;
baseUrl = auth.baseUrl;
token = auth.token;
} else {
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
targetServerId = session.serverId;
baseUrl = session.serverUrl;
token = session.token;
}
final width = MediaQuery.sizeOf(context).width;
final compact = width < Breakpoints.detail;
final hPad = compact ? 16.0 : 32.0;
final seasonsAsync = ref.watch(
seriesSeasonsDataProvider((
serverId: targetServerId,
seriesId: widget.seriesId,
)),
);
return seasonsAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing()),
),
error: (error, stackTrace) => const SizedBox.shrink(),
data: (seasons) {
if (seasons.isEmpty) return const SizedBox.shrink();
final String? effectiveSeasonId =
_selectedSeasonId ??
resolveInitialSeasonId(
seasons: seasons,
autoSelectSeasonId: widget.autoSelectSeasonId,
initialSeasonId: widget.initialSeasonId,
currentEpisodeSeasonId: widget.currentEpisodeSeasonId,
currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber,
);
EmbyRawSeason? selectedSeason;
for (final season in seasons) {
if (season.Id == effectiveSeasonId) {
selectedSeason = season;
break;
}
}
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.embedded && widget.leading != null) widget.leading!,
const SizedBox(height: 16),
if (effectiveSeasonId != null)
_EpisodesBody(
seasonTabsWidget: seasons.length > 1
? SeasonSelector(
seasons: seasons,
selectedId: effectiveSeasonId,
onSelect: _selectSeason,
)
: null,
seriesId: widget.seriesId,
seasonId: effectiveSeasonId,
seasonNumber: selectedSeason == null
? null
: seasonNumberFrom(selectedSeason),
tmdbSeriesId: widget.tmdbSeriesId,
currentEpisodeId: widget.currentEpisodeId,
currentEpisodeSeasonId: widget.currentEpisodeSeasonId,
currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber,
currentEpisodeIndexNumber: widget.currentEpisodeIndexNumber,
serverId: targetServerId,
baseUrl: baseUrl,
token: token,
autoSelectFirstEpisode: widget.autoSelectFirstEpisode,
autoSelectEpisodeId: widget.autoSelectEpisodeId,
onEpisodeTap: widget.onEpisodeTap,
onEpisodeTapWithSeason: widget.onEpisodeTapWithSeason,
onPlaybackPrefetch: widget.onPlaybackPrefetch,
showProviderIds: widget.showProviderIds,
showTitle: widget.showTitle,
showYear: widget.showYear,
),
],
);
if (widget.embedded) return content;
return Padding(
padding: EdgeInsets.symmetric(horizontal: hPad),
child: FrostedPanel(
radius: 16,
padding: EdgeInsets.all(hPad),
blurSigma: 15,
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
color: Colors.black.withValues(alpha: 0.3),
child: content,
),
);
},
);
}
}
class _EpisodesBody extends ConsumerStatefulWidget {
final String seriesId;
final String seasonId;
final int? seasonNumber;
final String? tmdbSeriesId;
final String? currentEpisodeId;
final String? currentEpisodeSeasonId;
final int? currentEpisodeSeasonNumber;
final int? currentEpisodeIndexNumber;
final String serverId;
final String baseUrl;
final String token;
final bool autoSelectFirstEpisode;
final String? autoSelectEpisodeId;
final void Function(EmbyRawItem episode) onEpisodeTap;
final EpisodeTapWithSeason? onEpisodeTapWithSeason;
final void Function(EmbyRawItem episode)? onPlaybackPrefetch;
final Map<String, String>? showProviderIds;
final String? showTitle;
final int? showYear;
final Widget? seasonTabsWidget;
const _EpisodesBody({
required this.seriesId,
required this.seasonId,
this.seasonNumber,
this.tmdbSeriesId,
required this.currentEpisodeId,
this.currentEpisodeSeasonId,
this.currentEpisodeSeasonNumber,
this.currentEpisodeIndexNumber,
required this.serverId,
required this.baseUrl,
required this.token,
required this.autoSelectFirstEpisode,
this.autoSelectEpisodeId,
required this.onEpisodeTap,
this.onEpisodeTapWithSeason,
this.onPlaybackPrefetch,
this.showProviderIds,
this.showTitle,
this.showYear,
this.seasonTabsWidget,
});
@override
ConsumerState<_EpisodesBody> createState() => _EpisodesBodyState();
}
class _EpisodesBodyState extends ConsumerState<_EpisodesBody> {
static final double _itemWidth = Platform.isAndroid ? 180.0 : 260.0;
static const _itemGap = 12.0;
static final double _rowHeight = Platform.isAndroid ? 150.0 : 210.0;
final ScrollController _scrollController = ScrollController();
bool _hasScrolledToActive = false;
bool _hasAutoSelected = false;
@override
void didUpdateWidget(covariant _EpisodesBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.seasonId != widget.seasonId ||
oldWidget.currentEpisodeId != widget.currentEpisodeId) {
_hasScrolledToActive = false;
}
if (oldWidget.seasonId != widget.seasonId ||
oldWidget.autoSelectEpisodeId != widget.autoSelectEpisodeId) {
_hasAutoSelected = false;
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
String? _resolveActiveEpisodeId(List<EmbyRawItem> episodes) =>
resolveActiveEpisodeId(
episodes: episodes,
currentEpisodeId: widget.currentEpisodeId,
displayedSeasonId: widget.seasonId,
displayedSeasonNumber: widget.seasonNumber,
currentEpisodeSeasonId: widget.currentEpisodeSeasonId,
currentEpisodeSeasonNumber: widget.currentEpisodeSeasonNumber,
currentEpisodeIndexNumber: widget.currentEpisodeIndexNumber,
);
void _scrollToActive(List<EmbyRawItem> episodes, String? activeEpisodeId) {
if (_hasScrolledToActive) return;
if (activeEpisodeId == null) return;
final idx = episodes.indexWhere((e) => e.Id == activeEpisodeId);
if (idx < 0) return;
_hasScrolledToActive = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) return;
final position = _scrollController.position;
final itemExtent = _itemWidth + _itemGap;
final viewport = position.viewportDimension;
final target = idx * itemExtent - (viewport - _itemWidth) / 2;
_scrollController.jumpTo(target.clamp(0.0, position.maxScrollExtent));
});
}
EmbyRawItem? _prefetchTarget(
List<EmbyRawItem> episodes,
String? activeEpisodeId,
) {
if (episodes.isEmpty) return null;
if (activeEpisodeId != null && activeEpisodeId.isNotEmpty) {
for (final episode in episodes) {
if (episode.Id == activeEpisodeId) return episode;
}
}
return episodes.first;
}
void _handleEpisodeTap(EmbyRawItem episode) {
final tapWithSeason = widget.onEpisodeTapWithSeason;
if (tapWithSeason != null) {
tapWithSeason(episode, widget.seasonNumber);
return;
}
widget.onEpisodeTap(episode);
}
void _showAllEpisodes(
List<EmbyRawItem> episodes,
String? activeEpisodeId,
Map<int, String> tmdbStillUrls,
) {
showAdaptiveModal<void>(
context: context,
maxWidth: 720,
builder: (sheetContext) => _EpisodeGridSheet(
episodes: episodes,
seriesId: widget.seriesId,
activeEpisodeId: activeEpisodeId,
tmdbStillUrls: tmdbStillUrls,
baseUrl: widget.baseUrl,
token: widget.token,
onEpisodeTap: (ep) {
Navigator.of(sheetContext).pop();
_handleEpisodeTap(ep);
},
),
);
}
@override
Widget build(BuildContext context) {
final tmdbSettings = ref.watch(tmdbSettingsProvider).value;
final canUseTmdbSeason =
(tmdbSettings?.canRequest ?? false) &&
widget.tmdbSeriesId != null &&
widget.tmdbSeriesId!.isNotEmpty &&
widget.seasonNumber != null;
final tmdbSeason = canUseTmdbSeason
? ref
.watch(
tmdbSeasonDetailProvider((
tmdbId: widget.tmdbSeriesId!,
seasonNumber: widget.seasonNumber!,
)),
)
.value
: null;
final tmdbStillUrls = tmdbStillUrlsByEpisode(
tmdbSeason,
tmdbSettings?.effectiveImageBaseUrl,
);
final episodesAsync = ref.watch(
seriesEpisodesDataProvider((
serverId: widget.serverId,
seriesId: widget.seriesId,
seasonId: widget.seasonId,
)),
);
return episodesAsync.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: AppLoadingRing()),
),
error: (error, stackTrace) => const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: AppInlineAlert(message: '剧集列表加载失败'),
),
data: (episodes) {
if (episodes.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'暂无分集',
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.6),
),
),
);
}
if (widget.currentEpisodeId == null &&
!_hasAutoSelected &&
episodes.isNotEmpty) {
final targetId = widget.autoSelectEpisodeId;
EmbyRawItem? target;
if (targetId != null && targetId.isNotEmpty) {
for (final e in episodes) {
if (e.Id == targetId) {
target = e;
break;
}
}
}
final pick =
target ?? (widget.autoSelectFirstEpisode ? episodes.first : null);
if (pick != null) {
_hasAutoSelected = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _handleEpisodeTap(pick);
});
}
}
final activeEpisodeId = _resolveActiveEpisodeId(episodes);
final target = _prefetchTarget(episodes, activeEpisodeId);
if (target != null) widget.onPlaybackPrefetch?.call(target);
_scrollToActive(episodes, activeEpisodeId);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (widget.seasonTabsWidget != null)
Expanded(child: widget.seasonTabsWidget!)
else
const Spacer(),
const SizedBox(width: 12),
TextButton(
onPressed: () => _showAllEpisodes(
episodes,
activeEpisodeId,
tmdbStillUrls,
),
style: TextButton.styleFrom(
foregroundColor: Colors.white.withValues(alpha: 0.6),
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: const Text('查看全部', style: TextStyle(fontSize: 13)),
),
],
),
const SizedBox(height: 8),
SizedBox(
height: _rowHeight,
child: HoverScrollableRow(
controller: _scrollController,
buttonInset: 4,
scrollFraction: 0.5,
builder: (_, controller) => ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
itemCount: episodes.length,
itemBuilder: (_, i) {
final ep = episodes[i];
final tmdbStillUrl = ep.IndexNumber == null
? null
: tmdbStillUrls[ep.IndexNumber];
return Padding(
padding: EdgeInsets.only(
right: i < episodes.length - 1 ? _itemGap : 0,
),
child: _EpisodeCard(
episode: ep,
seriesId: widget.seriesId,
baseUrl: widget.baseUrl,
token: widget.token,
tmdbStillUrl: tmdbStillUrl,
isActive: ep.Id == activeEpisodeId,
onTap: () => _handleEpisodeTap(ep),
),
);
},
),
),
),
],
);
},
);
}
}
class _EpisodeCard extends StatefulWidget {
final EmbyRawItem episode;
final String seriesId;
final String baseUrl;
final String token;
final String? tmdbStillUrl;
final bool isActive;
final VoidCallback onTap;
final double? width;
final int overviewMaxLines;
const _EpisodeCard({
required this.episode,
required this.seriesId,
required this.baseUrl,
required this.token,
required this.tmdbStillUrl,
required this.isActive,
required this.onTap,
this.width,
this.overviewMaxLines = 1,
});
@override
State<_EpisodeCard> createState() => _EpisodeCardState();
}
class _EpisodeCardState extends State<_EpisodeCard> {
bool _hovered = false;
void _showEpisodeOverviewDialog(BuildContext context, String overview) {
showAppRawDialog<void>(
context,
constraints: const BoxConstraints(maxWidth: 480, maxHeight: 400),
builder: (ctx) => ColoredBox(
color: const Color(0xFF1a1c22),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.episode.IndexNumber != null
? '${formatEpisodeNumber(widget.episode.IndexNumber)} ${widget.episode.Name}'
: widget.episode.Name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 14),
Flexible(
child: SingleChildScrollView(
child: Text(
overview,
style: TextStyle(
fontSize: 13,
height: 1.75,
color: Colors.white.withValues(alpha: 0.82),
),
),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(
'关闭',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
),
),
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
final ep = widget.episode;
final progress = playbackProgress(widget.episode);
final epNum = formatEpisodeNumber(ep.IndexNumber);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: SizedBox(
width: widget.width ?? _EpisodesBodyState._itemWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.expand,
children: [
EpisodeThumb(
episode: widget.episode,
seriesId: widget.seriesId,
baseUrl: widget.baseUrl,
token: widget.token,
tmdbStillUrl: widget.tmdbStillUrl,
aspectRatio: 16 / 9,
),
if (_hovered)
Container(
color: Colors.black.withValues(alpha: 0.35),
child: const Center(
child: Icon(
Icons.play_circle_outline,
color: Colors.white,
size: 36,
),
),
),
if (progress > 0) CardProgressBar(progress: progress),
if (epNum != null)
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(4),
),
child: Text(
epNum,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
),
if (widget.isActive)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Colors.white,
width: 2.5,
),
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
),
),
const SizedBox(height: 6),
Text(
ep.IndexNumber != null
? '${formatEpisodeNumber(ep.IndexNumber)} ${ep.Name}'
: ep.Name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: widget.isActive
? FontWeight.w600
: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.9),
),
),
if (ep.Overview != null && ep.Overview!.isNotEmpty)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () =>
_showEpisodeOverviewDialog(context, ep.Overview!),
child: Text(
ep.Overview!,
maxLines: widget.overviewMaxLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.6),
),
),
),
],
),
),
),
);
}
}
class _EpisodeGridSheet extends StatefulWidget {
final List<EmbyRawItem> episodes;
final String seriesId;
final String? activeEpisodeId;
final Map<int, String> tmdbStillUrls;
final String baseUrl;
final String token;
final void Function(EmbyRawItem episode) onEpisodeTap;
const _EpisodeGridSheet({
required this.episodes,
required this.seriesId,
required this.activeEpisodeId,
required this.tmdbStillUrls,
required this.baseUrl,
required this.token,
required this.onEpisodeTap,
});
@override
State<_EpisodeGridSheet> createState() => _EpisodeGridSheetState();
}
class _EpisodeGridSheetState extends State<_EpisodeGridSheet> {
static const _maxCrossAxisExtent = 240.0;
static const _gridSpacing = 12.0;
static const _childAspectRatio = 1.05;
static const _hPad = 20.0;
ScrollController? _controller;
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
ScrollController _controllerFor(double gridWidth) {
final existing = _controller;
if (existing != null) return existing;
double offset = 0;
final idx = widget.episodes.indexWhere(
(e) => e.Id == widget.activeEpisodeId,
);
if (idx > 0) {
final columns = (gridWidth / (_maxCrossAxisExtent + _gridSpacing))
.ceil()
.clamp(1, 100);
final itemWidth = (gridWidth - (columns - 1) * _gridSpacing) / columns;
final rowHeight = itemWidth / _childAspectRatio + _gridSpacing;
offset = (idx ~/ columns) * rowHeight;
}
return _controller = ScrollController(initialScrollOffset: offset);
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: const Color(0xFF1a1c22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(_hPad, 16, _hPad, 12),
child: Text(
'${widget.episodes.length}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
Flexible(
child: LayoutBuilder(
builder: (context, constraints) {
final gridWidth = constraints.maxWidth - _hPad * 2;
return GridView.builder(
controller: _controllerFor(gridWidth),
padding: const EdgeInsets.fromLTRB(_hPad, 0, _hPad, _hPad),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _maxCrossAxisExtent,
mainAxisSpacing: _gridSpacing,
crossAxisSpacing: _gridSpacing,
childAspectRatio: _childAspectRatio,
),
itemCount: widget.episodes.length,
itemBuilder: (_, i) {
final ep = widget.episodes[i];
final tmdbStillUrl = ep.IndexNumber == null
? null
: widget.tmdbStillUrls[ep.IndexNumber];
return _EpisodeCard(
episode: ep,
seriesId: widget.seriesId,
baseUrl: widget.baseUrl,
token: widget.token,
tmdbStillUrl: tmdbStillUrl,
isActive: ep.Id == widget.activeEpisodeId,
width: double.infinity,
overviewMaxLines: 2,
onTap: () => widget.onEpisodeTap(ep),
);
},
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,50 @@
import '../../../core/contracts/library.dart';
import '../../../shared/utils/tmdb_episode_helpers.dart';
String? resolveInitialSeasonId({
required List<EmbyRawSeason> seasons,
String? autoSelectSeasonId,
String? initialSeasonId,
String? currentEpisodeSeasonId,
int? currentEpisodeSeasonNumber,
}) {
if (seasons.isEmpty) return null;
bool has(String? id) => id != null && seasons.any((s) => s.Id == id);
if (has(autoSelectSeasonId)) return autoSelectSeasonId;
if (has(initialSeasonId)) return initialSeasonId;
if (has(currentEpisodeSeasonId)) return currentEpisodeSeasonId;
if (currentEpisodeSeasonNumber != null) {
for (final s in seasons) {
if (seasonNumberFrom(s) == currentEpisodeSeasonNumber) return s.Id;
}
}
return seasons.first.Id;
}
String? resolveActiveEpisodeId({
required List<EmbyRawItem> episodes,
String? currentEpisodeId,
String? displayedSeasonId,
int? displayedSeasonNumber,
String? currentEpisodeSeasonId,
int? currentEpisodeSeasonNumber,
int? currentEpisodeIndexNumber,
}) {
final id = currentEpisodeId;
if (id == null || id.isEmpty) return null;
if (episodes.any((e) => e.Id == id)) return id;
final seasonMatches =
(currentEpisodeSeasonId != null &&
currentEpisodeSeasonId == displayedSeasonId) ||
(currentEpisodeSeasonNumber != null &&
displayedSeasonNumber != null &&
currentEpisodeSeasonNumber == displayedSeasonNumber);
if (seasonMatches && currentEpisodeIndexNumber != null) {
for (final e in episodes) {
if (e.IndexNumber == currentEpisodeIndexNumber) return e.Id;
}
}
return id;
}
@@ -0,0 +1,348 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../../../shared/widgets/auto_dismiss_menu.dart';
class HeroIconActionButton extends StatelessWidget {
final IconData icon;
final double height;
final VoidCallback onPressed;
final String? label;
const HeroIconActionButton({
super.key,
required this.icon,
required this.height,
required this.onPressed,
this.label,
});
@override
Widget build(BuildContext context) {
if (label != null) {
return GestureDetector(
onTap: onPressed,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: height,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 24, color: Colors.white),
const SizedBox(height: 4),
Text(
label!,
style: TextStyle(
fontSize: 11,
color: Colors.white.withValues(alpha: 0.7),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
return SizedBox(
height: height,
child: OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withValues(alpha: 0.4)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.zero,
),
child: Icon(icon, size: height * 0.44),
),
);
}
}
final _heroPillBorder = StadiumBorder(
side: BorderSide(color: Colors.white.withValues(alpha: 0.55)),
);
const _heroDropdownMenuMaxHeight = 320.0;
class HeroPillDropdownButton extends StatelessWidget {
final IconData icon;
final List<FItem> menuChildren;
final double height;
const HeroPillDropdownButton({
super.key,
required this.icon,
required this.menuChildren,
this.height = 52,
});
@override
Widget build(BuildContext context) {
return FPopoverMenu(
menuBuilder: autoDismissMenuBuilder,
menuAnchor: Alignment.topCenter,
childAnchor: Alignment.bottomCenter,
maxHeight: _heroDropdownMenuMaxHeight,
menu: [FItemGroup(children: menuChildren)],
builder: (context, controller, child) => GestureDetector(
onTap: controller.toggle,
child: child,
),
child: Material(
color: Colors.transparent,
shape: _heroPillBorder,
clipBehavior: Clip.antiAlias,
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Icon(icon, size: 22, color: Colors.white),
),
),
),
);
}
}
class DetailHeroActions extends StatelessWidget {
final bool showReplay;
final VoidCallback? onReplay;
final bool isPlayed;
final VoidCallback onTogglePlayed;
final bool isFavorite;
final VoidCallback onToggleFavorite;
final List<Widget> extraIconActions;
final VoidCallback? onPlay;
final String playLabel;
final double? progressPercent;
final String? trailingLabel;
final bool isPlayLoading;
final bool compact;
const DetailHeroActions({
super.key,
this.showReplay = false,
this.onReplay,
required this.isPlayed,
required this.onTogglePlayed,
required this.isFavorite,
required this.onToggleFavorite,
this.extraIconActions = const [],
this.onPlay,
this.playLabel = '播放',
this.progressPercent,
this.trailingLabel,
this.isPlayLoading = false,
required this.compact,
});
@override
Widget build(BuildContext context) {
final btnH = compact ? 44.0 : 52.0;
final iconRow = Row(
children: [
if (showReplay) ...[
Expanded(
child: HeroIconActionButton(
icon: Icons.replay,
height: btnH,
label: compact ? '重播' : null,
onPressed: onReplay ?? () {},
),
),
SizedBox(width: compact ? 0 : 10),
],
Expanded(
child: HeroIconActionButton(
icon: isPlayed ? Icons.check_circle : Icons.check_circle_outline,
height: btnH,
label: compact ? '已看' : null,
onPressed: onTogglePlayed,
),
),
SizedBox(width: compact ? 0 : 10),
Expanded(
child: HeroIconActionButton(
icon: isFavorite ? Icons.favorite : Icons.favorite_border,
height: btnH,
label: compact ? '收藏' : null,
onPressed: onToggleFavorite,
),
),
for (final extra in extraIconActions) ...[
SizedBox(width: compact ? 0 : 10),
Expanded(child: extra),
],
],
);
final playButton = onPlay != null
? HeroPlayButton(
height: btnH,
label: playLabel,
progressPercent: progressPercent,
trailingLabel: trailingLabel,
isLoading: isPlayLoading,
compact: compact,
onPressed: onPlay,
)
: null;
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? double.infinity : 360),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: compact
? [
if (playButton != null) ...[
playButton,
const SizedBox(height: 16),
],
iconRow,
]
: [
iconRow,
if (playButton != null) ...[
const SizedBox(height: 12),
playButton,
],
],
),
);
}
}
class HeroPlayButton extends StatelessWidget {
final double height;
final String label;
final IconData icon;
final double? progressPercent;
final String? trailingLabel;
final bool isLoading;
final String loadingLabel;
final bool compact;
final VoidCallback? onPressed;
const HeroPlayButton({
super.key,
required this.height,
required this.label,
this.icon = Icons.play_arrow,
this.progressPercent,
this.trailingLabel,
this.isLoading = false,
this.loadingLabel = '启动中',
this.compact = false,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(12);
final hasProgress = progressPercent != null && progressPercent! > 0;
final buttonChild = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
isLoading
? const AppLoadingRing(size: 18, color: Colors.black54)
: Icon(icon, size: compact ? 22 : 26),
const SizedBox(width: 6),
Text(
isLoading ? loadingLabel : label,
style: TextStyle(
fontSize: compact ? 15 : 16,
fontWeight: FontWeight.w600,
),
),
if (trailingLabel != null && !isLoading) ...[
const SizedBox(width: 10),
Text(
trailingLabel!,
style: TextStyle(
fontSize: compact ? 13 : 14,
color: Colors.black.withValues(alpha: 0.5),
),
),
],
],
);
if (hasProgress) {
final fraction = (progressPercent! / 100).clamp(0.0, 1.0);
return ClipRRect(
borderRadius: radius,
child: SizedBox(
height: height,
child: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: const [Color(0xFFE3E3E3), Colors.white],
stops: [fraction, fraction],
),
),
),
),
Positioned.fill(
child: FilledButton(
onPressed: isLoading ? null : onPressed,
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
overlayColor: Colors.black.withValues(alpha: 0.08),
shadowColor: Colors.transparent,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: radius),
),
child: buttonChild,
),
),
],
),
),
);
}
return SizedBox(
height: height,
child: FilledButton(
onPressed: isLoading ? null : onPressed,
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: radius),
),
child: buttonChild,
),
);
}
}
@@ -0,0 +1,282 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../core/contracts/library.dart';
import '../../../shared/constants/breakpoints.dart';
import '../../../shared/widgets/frosted_panel.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
class MediaInfoSection extends StatelessWidget {
final EmbyRawItem item;
final int selectedSourceIndex;
final EmbyRawMediaSource? selectedSource;
final bool embedded;
const MediaInfoSection({
super.key,
required this.item,
this.selectedSourceIndex = 0,
this.selectedSource,
this.embedded = false,
});
static bool hasVisibleContent({
required EmbyRawItem item,
int selectedSourceIndex = 0,
EmbyRawMediaSource? selectedSource,
}) {
if (item.Type == 'Series') return false;
final EmbyRawMediaSource source;
if (selectedSource != null) {
source = selectedSource;
} else {
final sources = mediaSourcesOfItem(item);
if (sources.isEmpty) return false;
source = sources[selectedSourceIndex.clamp(0, sources.length - 1)];
}
final streams = source.MediaStreams ?? [];
if (streams.isEmpty) return false;
return streams.any(
(stream) =>
stream.Type == 'Video' ||
stream.Type == 'Audio' ||
stream.Type == 'Subtitle',
);
}
@override
Widget build(BuildContext context) {
if (!hasVisibleContent(
item: item,
selectedSourceIndex: selectedSourceIndex,
selectedSource: selectedSource,
)) {
return const SizedBox.shrink();
}
final EmbyRawMediaSource source;
if (selectedSource != null) {
source = selectedSource!;
} else {
final sources = mediaSourcesOfItem(item);
if (sources.isEmpty) return const SizedBox.shrink();
source = sources[selectedSourceIndex.clamp(0, sources.length - 1)];
}
final streams = source.MediaStreams ?? [];
if (streams.isEmpty) return const SizedBox.shrink();
final hPad = embedded
? 0.0
: (MediaQuery.sizeOf(context).width < Breakpoints.detail ? 16.0 : 32.0);
final defaultAudioIdx = source.DefaultAudioStreamIndex;
final cards = <Widget>[];
for (final s in streams.where((s) => s.Type == 'Video')) {
cards.add(_StreamCard(stream: s, kind: _Kind.video));
}
for (final s in streams.where((s) => s.Type == 'Audio')) {
final isDefault =
(s.Index != null && s.Index == defaultAudioIdx) ||
(s.IsDefault ?? false);
cards.add(
_StreamCard(stream: s, kind: _Kind.audio, isDefault: isDefault),
);
}
for (final s in streams.where((s) => s.Type == 'Subtitle')) {
cards.add(
_StreamCard(
stream: s,
kind: _Kind.subtitle,
isDefault: s.IsDefault ?? false,
),
);
}
if (cards.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const FDivider(style: .delta(color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.symmetric(vertical: 24)))),
Padding(
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 16),
child: Text(
'媒体信息',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
),
SizedBox(
height: 280,
child: HoverScrollableRow(
builder: (_, controller) => ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: hPad),
itemCount: cards.length,
itemBuilder: (_, i) => Padding(
padding: EdgeInsets.only(right: i < cards.length - 1 ? 12 : 0),
child: cards[i],
),
),
),
),
],
);
}
}
enum _Kind { video, audio, subtitle }
class _StreamCard extends StatelessWidget {
final EmbyRawMediaStream stream;
final _Kind kind;
final bool isDefault;
const _StreamCard({
required this.stream,
required this.kind,
this.isDefault = false,
});
List<(String label, String value)> _fields() {
final ex = stream.extra;
final rows = <(String, String)>[];
switch (kind) {
case _Kind.video:
_add(rows, '编码', stream.Codec);
final w = ex['Width'] as int?;
final h = ex['Height'] as int?;
if (w != null && h != null) rows.add(('分辨率', '${w}x$h'));
final fr =
(ex['RealFrameRate'] as num?) ?? (ex['AverageFrameRate'] as num?);
if (fr != null) rows.add(('帧率', fr.toDouble().toStringAsFixed(1)));
final br = ex['BitRate'] as int?;
if (br != null) rows.add(('比特率', '${(br / 1000).round()}kbps'));
_add(rows, '动态范围', ex['VideoRange'] as String?);
_add(rows, '配置', ex['Profile'] as String?);
case _Kind.audio:
_add(rows, '标题', stream.Title);
_add(rows, '内嵌标题', stream.DisplayTitle);
_add(rows, '语言', stream.Language);
_add(rows, '布局', ex['ChannelLayout'] as String?);
if (stream.Channels != null) rows.add(('声道', '${stream.Channels}'));
_add(rows, '编码', stream.Codec);
final br = ex['BitRate'] as int?;
if (br != null) rows.add(('比特率', '${(br / 1000).round()}kbps'));
final sr = ex['SampleRate'] as int?;
if (sr != null) rows.add(('采样率', '${sr}Hz'));
rows.add(('外部', (stream.IsExternal ?? false) ? '' : ''));
rows.add(('默认', (stream.IsDefault ?? false) ? '' : ''));
case _Kind.subtitle:
_add(rows, '标题', stream.Title);
_add(rows, '内嵌标题', stream.DisplayTitle);
_add(rows, '语言', stream.Language);
_add(rows, '格式', stream.Codec);
rows.add(('外部', (stream.IsExternal ?? false) ? '' : ''));
if (stream.IsForced ?? false) rows.add(('强制', ''));
rows.add(('默认', (stream.IsDefault ?? false) ? '' : ''));
}
return rows;
}
static void _add(List<(String, String)> rows, String label, String? value) {
if (value != null && value.isNotEmpty) rows.add((label, value));
}
@override
Widget build(BuildContext context) {
final fields = _fields();
final (IconData icon, String name) = switch (kind) {
_Kind.video => (Icons.videocam_outlined, '视频'),
_Kind.audio => (Icons.music_note_outlined, '音频'),
_Kind.subtitle => (Icons.subtitles_outlined, '字幕'),
};
return SizedBox(
width: 260,
height: 280,
child: FrostedPanel(
radius: 12,
padding: const EdgeInsets.all(16),
blurSigma: 15,
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
color: Colors.black26,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16, color: Colors.white),
const SizedBox(width: 6),
Text(
name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
if (isDefault) ...[
const SizedBox(width: 6),
Icon(
Icons.check,
size: 14,
color: Colors.white.withValues(alpha: 0.7),
),
],
],
),
const SizedBox(height: 12),
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
for (final (label, value) in fields)
Padding(
padding: const EdgeInsets.only(bottom: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 60,
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.55),
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.85),
),
),
),
],
),
),
],
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,87 @@
library;
import '../../../core/contracts/library.dart';
import '../../../providers/version_priority_provider.dart';
import '../../../shared/widgets/version_filter.dart';
import '../../../shared/widgets/version_source_card.dart';
class MultiSourceVersionEntry {
final String bucket;
final VersionSourceCardData data;
final EmbyRawMediaSource source;
final bool isLocal;
final bool isActive;
final int localIndex;
final String serverName;
final String serverId;
final String itemId;
final String mediaSourceId;
const MultiSourceVersionEntry({
required this.bucket,
required this.data,
required this.source,
required this.isLocal,
required this.isActive,
required this.localIndex,
required this.serverName,
required this.serverId,
required this.itemId,
required this.mediaSourceId,
});
}
void sortMultiSourceEntries(
List<MultiSourceVersionEntry> entries,
List<VersionPriority> priorities,
) {
entries.sort((a, b) => compareMultiSourceEntries(a, b, priorities));
}
int compareMultiSourceEntries(
MultiSourceVersionEntry a,
MultiSourceVersionEntry b,
List<VersionPriority> priorities,
) {
final ba = kVersionBucketOrder.indexOf(a.bucket);
final bb = kVersionBucketOrder.indexOf(b.bucket);
if (ba != bb) return ba.compareTo(bb);
if (priorities.isNotEmpty) {
final cmp = compareMediaSourcesByPriority(a.source, b.source, priorities);
if (cmp != 0) return cmp;
}
if (a.isLocal != b.isLocal) return a.isLocal ? -1 : 1;
if (a.isLocal && b.isLocal) return a.localIndex.compareTo(b.localIndex);
final byServerName = a.serverName.compareTo(b.serverName);
if (byServerName != 0) return byServerName;
final byServerId = a.serverId.compareTo(b.serverId);
if (byServerId != 0) return byServerId;
final byItemId = a.itemId.compareTo(b.itemId);
if (byItemId != 0) return byItemId;
return a.mediaSourceId.compareTo(b.mediaSourceId);
}
String? defaultSelectedBucketForEntries(List<MultiSourceVersionEntry> entries) {
if (entries.isEmpty) return null;
for (final e in entries) {
if (e.isActive) return e.bucket;
}
final present = entries.map((e) => e.bucket).toSet();
for (final bucket in kVersionBucketOrder) {
if (present.contains(bucket)) return bucket;
}
return entries.first.bucket;
}
@@ -0,0 +1,19 @@
library;
double multiSourceScrollOffset({
required int activeIndex,
required double viewportWidth,
required double leadingPad,
required double maxScrollExtent,
double cardWidth = 220.0,
double cardGap = 12.0,
}) {
if (activeIndex <= 0) return 0.0;
final target =
leadingPad +
activeIndex * (cardWidth + cardGap) -
(viewportWidth - cardWidth) / 2;
return target.clamp(0.0, maxScrollExtent).toDouble();
}
@@ -0,0 +1,536 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart' as forui;
import '../../../core/contracts/library.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/session_provider.dart';
import '../../../providers/version_priority_provider.dart';
import '../../../shared/constants/breakpoints.dart';
import '../../../shared/utils/cross_server_search_key.dart';
import '../../../shared/widgets/all_versions_sheet.dart';
import '../../../shared/widgets/app_loading_ring.dart';
import '../../../shared/widgets/frosted_panel.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
import '../../../shared/widgets/version_filter.dart';
import '../../../shared/widgets/version_grouping.dart';
import '../../../shared/widgets/version_source_card.dart';
import 'multi_source_ordering.dart';
import 'multi_source_scroll.dart';
typedef MultiSourceTap =
Future<void> Function(
String serverId,
String itemId, {
String? mediaSourceId,
CrossServerSourceCard? card,
});
typedef LocalSourceTap = void Function(EmbyRawMediaSource src, int index);
class MultiSourceSection extends ConsumerStatefulWidget {
final CrossServerSearchReq req;
final MultiSourceTap? onSourceTap;
final bool embedded;
final List<EmbyRawMediaSource> localSources;
final String? currentLocalSourceId;
final String? currentServerName;
final LocalSourceTap? onLocalSourceTap;
final String? excludedServerIdOverride;
final String title;
final String? selectedCrossServerKey;
final bool showLoadingWhenEmpty;
final bool externalLoading;
final String loadingText;
const MultiSourceSection({
super.key,
required this.req,
this.onSourceTap,
this.embedded = false,
this.localSources = const [],
this.currentLocalSourceId,
this.currentServerName,
this.onLocalSourceTap,
this.excludedServerIdOverride,
this.title = '多源聚合',
this.selectedCrossServerKey,
this.showLoadingWhenEmpty = false,
this.externalLoading = false,
this.loadingText = '正在查找可用版本…',
});
@override
ConsumerState<MultiSourceSection> createState() => _MultiSourceSectionState();
}
String _bucketOf(CrossServerSourceCard card) {
return versionBucketOf(card.quality?.resolutionLabel);
}
String _bucketOfLocal(EmbyRawMediaSource src) {
return versionBucketOf(MediaQualityInfo.fromMediaSource(src).resolutionLabel);
}
class _MultiSourceSectionState extends ConsumerState<MultiSourceSection> {
static final double _cardWidth = Platform.isAndroid ? 170.0 : 220.0;
static const double _cardGap = 12.0;
final ScrollController _scrollController = ScrollController();
String? _selectedBucket;
String? _autoScrolledKey;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
final req = widget.req;
final key = (
activeServerId: session.serverId,
anchorType: req.anchorType,
providerIdQuery: encodeProviderIdsForKey(req.providerIds),
seriesProviderIdQuery: encodeProviderIdsForKey(
req.seriesProviderIds ?? const {},
),
parentIndexNumber: req.parentIndexNumber ?? -1,
indexNumber: req.indexNumber ?? -1,
itemName: req.itemName,
excludedServerId: widget.excludedServerIdOverride ?? session.serverId,
);
final asyncResult = ref.watch(crossServerSearchDataProvider(key));
return _renderContent(
asyncResult,
widget.excludedServerIdOverride ?? session.serverId,
);
}
Widget _renderContent(
AsyncValue<List<CrossServerSourceCard>> asyncResult,
String currentServerId,
) {
final cards = asyncResult.maybeWhen(
data: (list) => list,
orElse: () => asyncResult.value ?? const <CrossServerSourceCard>[],
);
final priorities =
ref.watch(versionPriorityProvider).value?.activePriorities ??
const <VersionPriority>[];
final entries = <MultiSourceVersionEntry>[];
final localSources = widget.localSources;
for (var i = 0; i < localSources.length; i++) {
final src = localSources[i];
final isCurrent =
widget.currentLocalSourceId != null &&
src.Id == widget.currentLocalSourceId;
entries.add(
MultiSourceVersionEntry(
bucket: _bucketOfLocal(src),
data: _localCardData(src, i, currentServerId),
source: src,
isLocal: true,
isActive: isCurrent,
localIndex: i,
serverName: widget.currentServerName ?? '本服务器',
serverId: '',
itemId: '',
mediaSourceId: src.Id ?? '',
),
);
}
for (final c in cards) {
final isSelected =
widget.selectedCrossServerKey != null &&
widget.selectedCrossServerKey == '${c.serverId}|${c.mediaSourceId}';
entries.add(
MultiSourceVersionEntry(
bucket: _bucketOf(c),
data: _crossServerCardData(c),
source: c.mediaSource,
isLocal: false,
isActive: isSelected,
localIndex: -1,
serverName: c.serverName,
serverId: c.serverId,
itemId: c.landingItemId,
mediaSourceId: c.mediaSourceId,
),
);
}
sortMultiSourceEntries(entries, priorities);
final allGroups = groupVersionEntries([
for (final entry in entries) entry.data,
]);
final bucketsPresent = <String>{};
for (final e in entries) {
bucketsPresent.add(e.bucket);
}
final buckets = kVersionBucketOrder
.where(bucketsPresent.contains)
.toList(growable: false);
final canonicalSelected =
_selectedBucket != null && bucketsPresent.contains(_selectedBucket)
? _selectedBucket!
: defaultSelectedBucketForEntries(entries);
final filteredGroups = canonicalSelected == null
? allGroups
: allGroups
.where(
(group) =>
versionBucketOf(group.representative.resolutionLabel) ==
canonicalSelected,
)
.toList(growable: false);
final hasMergedGroup = allGroups.any((group) => group.members.length > 1);
final showAllVersionsButton =
filteredGroups.length > inlineVersionLimit() || hasMergedGroup;
final width = MediaQuery.sizeOf(context).width;
final compact = width < Breakpoints.detail;
final hPad = compact ? 16.0 : 32.0;
if (entries.isEmpty) {
final loading = widget.externalLoading || asyncResult.isLoading;
if (widget.showLoadingWhenEmpty && loading) {
return _wrapContent(
_buildLoadingContent(widget.embedded ? 0.0 : hPad),
hPad,
);
}
return const SizedBox.shrink();
}
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(
buckets,
canonicalSelected,
allGroups,
entries.length,
showAllVersionsButton,
widget.embedded ? 0.0 : hPad,
),
const SizedBox(height: 16),
_buildCardList(
filteredGroups,
canonicalSelected,
widget.embedded ? 0.0 : hPad,
),
],
);
return _wrapContent(content, hPad);
}
Widget _wrapContent(Widget content, double hPad) {
if (widget.embedded) return content;
return Padding(
padding: EdgeInsets.symmetric(horizontal: hPad),
child: FrostedPanel(
radius: 16,
padding: EdgeInsets.all(hPad),
blurSigma: 15,
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
color: Colors.black.withValues(alpha: 0.3),
child: content,
),
);
}
Widget _buildLoadingContent(double hPad) {
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: hPad),
child: Row(
children: [
Text(
widget.title,
style: TextStyle(
fontSize: compact ? 17 : 20,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
const Spacer(),
const AppLoadingRing(size: 16, color: Colors.white),
const SizedBox(width: 8),
Flexible(
child: Text(
widget.loadingText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.62),
),
),
),
],
),
),
const SizedBox(height: 16),
SizedBox(height: Platform.isAndroid ? 100 : 120),
],
);
}
Widget _buildHeader(
List<String> buckets,
String? selectedBucket,
List<VersionSourceGroup> allGroups,
int sourceCount,
bool showAllVersionsButton,
double hPad,
) {
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
final titleAndAction = Wrap(
spacing: 10,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
widget.title,
style: TextStyle(
fontSize: compact ? 17 : 20,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
if (showAllVersionsButton)
forui.FButton(
variant: forui.FButtonVariant.secondary,
size: forui.FButtonSizeVariant.xs,
mainAxisSize: MainAxisSize.min,
onPress: () =>
showAllVersionsSheet(context: context, groups: allGroups),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('全部 $sourceCount 个版本'),
const SizedBox(width: 2),
const Icon(Icons.chevron_right_rounded, size: 16),
],
),
),
],
);
final filterChips = Row(
mainAxisSize: MainAxisSize.min,
children: [
for (
var bucketIndex = 0;
bucketIndex < buckets.length;
bucketIndex++
) ...[
if (bucketIndex > 0) const SizedBox(width: 8),
VersionFilterChip(
label: buckets[bucketIndex],
selected: selectedBucket == buckets[bucketIndex],
showDot: true,
onTap: () => setState(() => _selectedBucket = buckets[bucketIndex]),
),
],
],
);
return Padding(
padding: EdgeInsets.symmetric(horizontal: hPad),
child: compact
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
titleAndAction,
if (buckets.isNotEmpty) ...[
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: filterChips,
),
],
],
)
: Row(
children: [
titleAndAction,
if (buckets.isNotEmpty) ...[
const SizedBox(width: 16),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: filterChips,
),
),
),
],
],
),
);
}
Widget _buildCardList(
List<VersionSourceGroup> groups,
String? bucket,
double hPad,
) {
if (groups.isEmpty) return const SizedBox.shrink();
final inlineLimit = inlineVersionLimit();
final inlineGroups = groups.take(inlineLimit).toList(growable: true);
final activeGroupIndex = groups.indexWhere(
(group) => group.hasActiveMember,
);
if (activeGroupIndex >= inlineLimit && inlineGroups.isNotEmpty) {
inlineGroups[inlineGroups.length - 1] = groups[activeGroupIndex];
}
_maybeScrollActiveIntoView(inlineGroups, bucket, hPad);
final itemCount = inlineGroups.length;
return SizedBox(
height: Platform.isAndroid ? 100 : 120,
child: HoverScrollableRow(
controller: _scrollController,
builder: (_, controller) => ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.fromLTRB(hPad, 0, hPad, 0),
itemCount: itemCount,
itemBuilder: (_, i) => Padding(
padding: EdgeInsets.only(right: i < itemCount - 1 ? 12 : 0),
child: VersionSourceCard(
data: inlineGroups[i].displayData,
onMergedServersTap: inlineGroups[i].members.length > 1
? () => showAllVersionsSheet(
context: context,
groups: [inlineGroups[i]],
title: '选择服务器',
)
: null,
),
),
),
),
);
}
void _maybeScrollActiveIntoView(
List<VersionSourceGroup> groups,
String? bucket,
double leadingPad,
) {
final activeGroupIndex = groups.indexWhere(
(group) => group.hasActiveMember,
);
final activeSourceKey = activeGroupIndex < 0
? '<none>'
: groups[activeGroupIndex].activeMember?.sourceKey ?? '<none>';
final key = '${bucket ?? '<all>'}|$activeSourceKey';
if (key == _autoScrolledKey) return;
_autoScrolledKey = key;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) return;
final position = _scrollController.position;
_scrollController.jumpTo(
multiSourceScrollOffset(
activeIndex: activeGroupIndex,
viewportWidth: position.viewportDimension,
leadingPad: leadingPad,
maxScrollExtent: position.maxScrollExtent,
cardWidth: _cardWidth,
cardGap: _cardGap,
),
);
});
}
VersionSourceCardData _localCardData(
EmbyRawMediaSource src,
int index,
String currentServerId,
) {
final tap = widget.onLocalSourceTap;
final isCurrent =
widget.currentLocalSourceId != null &&
src.Id == widget.currentLocalSourceId;
return VersionSourceCardData.fromLocalSource(
src,
serverName: widget.currentServerName ?? '本服务器',
serverKey: currentServerId,
isCurrent: isCurrent,
tooltip: isCurrent ? null : '切换到该版本',
onTap: (tap == null || isCurrent) ? null : () async => tap(src, index),
);
}
VersionSourceCardData _crossServerCardData(CrossServerSourceCard card) {
final selectedKey = widget.selectedCrossServerKey;
final isSelected =
selectedKey != null &&
selectedKey == '${card.serverId}|${card.mediaSourceId}';
final data = VersionSourceCardData.fromCrossServer(
card,
selected: isSelected,
onTap: _tapHandlerFor(card),
);
return data;
}
Future<void> Function()? _tapHandlerFor(CrossServerSourceCard card) {
final tap = widget.onSourceTap;
if (tap == null) return null;
return () => tap(
card.serverId,
card.landingItemId,
mediaSourceId: card.mediaSourceId,
card: card,
);
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../shared/constants/breakpoints.dart';
import '../../../shared/widgets/frosted_panel.dart';
class SectionContainer extends StatelessWidget {
final String title;
final Widget child;
final bool embedded;
final bool leadingDivider;
final Widget? leading;
const SectionContainer({
super.key,
required this.title,
required this.child,
this.embedded = false,
this.leadingDivider = false,
this.leading,
});
static double hPadOf(BuildContext context) =>
MediaQuery.sizeOf(context).width < Breakpoints.detail ? 16.0 : 32.0;
@override
Widget build(BuildContext context) {
final hPad = embedded ? 0.0 : hPadOf(context);
final innerPad = embedded ? 0.0 : hPadOf(context);
final compact = MediaQuery.sizeOf(context).width < Breakpoints.compact;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.fromLTRB(
innerPad,
0,
innerPad,
compact ? 12 : 16,
),
child: Text(
title,
style: TextStyle(
fontSize: compact ? 17 : 20,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.9),
),
),
),
child,
],
);
if (embedded) {
if (leading != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [leading!, content],
);
}
if (leadingDivider) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const FDivider(style: .delta(color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.symmetric(vertical: 24)))),
content,
],
);
}
return content;
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: hPad),
child: FrostedPanel(
radius: 16,
padding: EdgeInsets.symmetric(vertical: hPad),
blurSigma: 15,
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
color: Colors.black.withValues(alpha: 0.3),
child: content,
),
);
}
}
@@ -0,0 +1,102 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/emby_headers_provider.dart';
import '../../../providers/session_provider.dart';
import '../../../shared/mappers/media_image_url.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/utils/media_nav.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
import '../../../shared/widgets/media_poster_card.dart';
import 'section_container.dart';
class SimilarSection extends ConsumerWidget {
final String itemId;
final bool embedded;
final Widget? leading;
const SimilarSection({
super.key,
required this.itemId,
this.embedded = false,
this.leading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
final asyncItems = ref.watch(similarItemsDataProvider(itemId));
return asyncItems.when(
loading: () => const SizedBox.shrink(),
error: (error, stackTrace) => const SizedBox.shrink(),
data: (items) {
if (items.isEmpty) return const SizedBox.shrink();
final sectionPad = SectionContainer.hPadOf(context);
final listPadding = EdgeInsetsDirectional.fromSTEB(
embedded ? 0.0 : sectionPad,
0,
sectionPad,
0,
);
final isAndroid = Platform.isAndroid;
return SectionContainer(
title: '相似推荐',
embedded: embedded,
leadingDivider: embedded,
leading: leading,
child: SizedBox(
height: isAndroid ? 210 : 300,
child: Theme(
data: AppTheme.dark(),
child: HoverScrollableRow(
builder: (_, controller) => ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
padding: listPadding,
itemCount: items.length,
itemBuilder: (_, i) {
final item = items[i];
final imageUrl = EmbyImageUrl.primary(
baseUrl: session.serverUrl,
token: session.token,
item: item,
maxHeight: 480,
);
return Padding(
padding: EdgeInsetsDirectional.only(
end: i < items.length - 1 ? 12 : 0,
),
child: MediaPosterCard(
item: item,
imageUrl: imageUrl,
width: isAndroid ? 105 : 160,
showHoverPlayIcon: false,
enableContextMenu: false,
preloadImageUrl: EmbyImageUrl.detailBanner(
baseUrl: session.serverUrl,
token: session.token,
item: item,
),
imageHeaders: imageHeaders,
onTap: () => goMediaDetail(context, item),
),
);
},
),
),
),
),
);
},
);
}
}
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/contracts/library.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/session_provider.dart';
import '../../../shared/utils/cross_server_search_key.dart';
import 'media_info_section.dart';
import 'multi_source_section.dart';
class SourceSelectionSection extends ConsumerWidget {
final CrossServerSearchReq? req;
final List<EmbyRawMediaSource> localSources;
final int selectedSourceIdx;
final CrossServerSourceCard? selectedCrossServerCard;
final String? currentServerName;
final String? excludedServerIdOverride;
final EmbyRawItem? localMediaInfoItem;
final void Function(EmbyRawMediaSource src, int index) onLocalSourceTap;
final void Function(CrossServerSourceCard card) onCrossServerSourceTap;
final String title;
final bool showLoadingWhenEmpty;
final bool externalLoading;
final String loadingText;
final Widget? leading;
const SourceSelectionSection({
super.key,
required this.req,
required this.localSources,
required this.selectedSourceIdx,
required this.selectedCrossServerCard,
required this.onLocalSourceTap,
required this.onCrossServerSourceTap,
this.currentServerName,
this.excludedServerIdOverride,
this.localMediaInfoItem,
this.title = '多源聚合',
this.showLoadingWhenEmpty = false,
this.externalLoading = false,
this.loadingText = '正在查找可用版本…',
this.leading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final searchReq = req;
final card = selectedCrossServerCard;
final currentLocalSourceId =
card == null && selectedSourceIdx < localSources.length
? localSources[selectedSourceIdx].Id
: null;
final selectedCrossServerKey = card != null
? '${card.serverId}|${card.mediaSourceId}'
: null;
final EmbyRawMediaSource? mediaSource =
card?.mediaSource ??
(selectedSourceIdx < localSources.length
? localSources[selectedSourceIdx]
: null);
final EmbyRawItem? mediaItem = card?.item ?? localMediaInfoItem;
final multiSourceVisibility = searchReq == null
? _MultiSourceVisibility.hidden
: _multiSourceVisibility(ref, searchReq);
final mediaInfo =
mediaSource != null &&
mediaItem != null &&
MediaInfoSection.hasVisibleContent(
item: mediaItem,
selectedSource: mediaSource,
)
? MediaInfoSection(
item: mediaItem,
selectedSource: mediaSource,
embedded: true,
)
: null;
final bool hasContent =
multiSourceVisibility != _MultiSourceVisibility.hidden ||
mediaInfo != null;
if (!hasContent) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) ...[leading!, const SizedBox(height: 16)],
if (searchReq != null)
MultiSourceSection(
req: searchReq,
embedded: true,
localSources: localSources,
currentLocalSourceId: currentLocalSourceId,
currentServerName: currentServerName,
excludedServerIdOverride: excludedServerIdOverride,
selectedCrossServerKey: selectedCrossServerKey,
title: title,
showLoadingWhenEmpty: showLoadingWhenEmpty,
externalLoading: externalLoading,
loadingText: loadingText,
onLocalSourceTap: onLocalSourceTap,
onSourceTap: (serverId, itemId, {mediaSourceId, card}) async {
if (card != null) onCrossServerSourceTap(card);
},
),
if (mediaInfo != null) mediaInfo,
],
);
}
_MultiSourceVisibility _multiSourceVisibility(
WidgetRef ref,
CrossServerSearchReq searchReq,
) {
final session = ref.watch(activeSessionProvider);
if (session == null) return _MultiSourceVisibility.hidden;
final key = (
activeServerId: session.serverId,
anchorType: searchReq.anchorType,
providerIdQuery: encodeProviderIdsForKey(searchReq.providerIds),
seriesProviderIdQuery: encodeProviderIdsForKey(
searchReq.seriesProviderIds ?? const {},
),
parentIndexNumber: searchReq.parentIndexNumber ?? -1,
indexNumber: searchReq.indexNumber ?? -1,
itemName: searchReq.itemName,
excludedServerId: excludedServerIdOverride ?? session.serverId,
);
final asyncResult = ref.watch(crossServerSearchDataProvider(key));
final remoteCards = asyncResult.maybeWhen(
data: (list) => list,
orElse: () => asyncResult.value ?? const <CrossServerSourceCard>[],
);
if (localSources.isNotEmpty || remoteCards.isNotEmpty) {
return _MultiSourceVisibility.content;
}
final loading = externalLoading || asyncResult.isLoading;
if (showLoadingWhenEmpty && loading) return _MultiSourceVisibility.loading;
return _MultiSourceVisibility.hidden;
}
}
enum _MultiSourceVisibility { hidden, loading, content }
@@ -0,0 +1,97 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/di_providers.dart';
import '../../../providers/emby_headers_provider.dart';
import '../../../providers/session_provider.dart';
import '../../../shared/mappers/media_image_url.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/utils/media_nav.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
import '../../../shared/widgets/media_poster_card.dart';
import 'section_container.dart';
class SpecialFeaturesSection extends ConsumerWidget {
final String itemId;
final bool embedded;
final Widget? leading;
const SpecialFeaturesSection({
super.key,
required this.itemId,
this.embedded = false,
this.leading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
final asyncItems = ref.watch(specialFeaturesDataProvider(itemId));
return asyncItems.when(
loading: () => const SizedBox.shrink(),
error: (error, stackTrace) => const SizedBox.shrink(),
data: (items) {
if (items.isEmpty) return const SizedBox.shrink();
final sectionPad = SectionContainer.hPadOf(context);
final listPadding = EdgeInsetsDirectional.fromSTEB(
embedded ? 0.0 : sectionPad,
0,
sectionPad,
0,
);
final isAndroid = Platform.isAndroid;
return SectionContainer(
title: '花絮',
embedded: embedded,
leadingDivider: embedded,
leading: leading,
child: SizedBox(
height: isAndroid ? 210 : 300,
child: Theme(
data: AppTheme.dark(),
child: HoverScrollableRow(
builder: (_, controller) => ListView.builder(
controller: controller,
scrollDirection: Axis.horizontal,
padding: listPadding,
itemCount: items.length,
itemBuilder: (_, i) {
final item = items[i];
final imageUrl = EmbyImageUrl.primary(
baseUrl: session.serverUrl,
token: session.token,
item: item,
maxHeight: 480,
);
return Padding(
padding: EdgeInsetsDirectional.only(
end: i < items.length - 1 ? 12 : 0,
),
child: MediaPosterCard(
item: item,
imageUrl: imageUrl,
width: isAndroid ? 105 : 160,
showHoverPlayIcon: false,
enableContextMenu: false,
imageHeaders: imageHeaders,
onTap: () => goMediaDetail(context, item, ref: ref),
),
);
},
),
),
),
),
);
},
);
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../core/contracts/library.dart';
import '../../../shared/utils/format_utils.dart';
import 'hero_action_buttons.dart';
List<FItem> buildSubtitleMenuButtons({
required List<EmbyRawMediaStream> subtitles,
required int? selectedSubtitleIdx,
required ValueChanged<int?> onSelectSubtitle,
}) {
return [
FItem(
prefix: selectedSubtitleIdx == null
? const Icon(Icons.check, size: 14)
: const SizedBox(width: 14),
title: const Text('关闭字幕'),
selected: selectedSubtitleIdx == null,
onPress: () => onSelectSubtitle(null),
),
...subtitles.asMap().entries.map(
(e) => FItem(
prefix: selectedSubtitleIdx == e.key
? const Icon(Icons.check, size: 14)
: const SizedBox(width: 14),
title: Text(
e.value.DisplayTitle ?? e.value.Language ?? '字幕 ${e.key + 1}',
),
selected: selectedSubtitleIdx == e.key,
onPress: () => onSelectSubtitle(e.key),
),
),
];
}
List<FItem> buildAudioMenuButtons({
required List<EmbyRawMediaStream> audios,
required int selectedAudioIdx,
required ValueChanged<int> onSelectAudio,
}) {
return audios
.asMap()
.entries
.map(
(e) => FItem(
prefix: selectedAudioIdx == e.key
? const Icon(Icons.check, size: 14)
: const SizedBox(width: 14),
title: Text(
e.value.DisplayTitle ?? e.value.Language ?? '音轨 ${e.key + 1}',
),
selected: selectedAudioIdx == e.key,
onPress: () => onSelectAudio(e.key),
),
)
.toList();
}
List<FItem> buildVersionMenuButtons({
required List<EmbyRawMediaSource> sources,
required int selectedSourceIdx,
required ValueChanged<int> onSelectSource,
}) {
return sources.asMap().entries.map((e) {
final src = e.value;
final name = src.Name ?? '版本 ${e.key + 1}';
final size = src.Size;
final sizeStr = size != null && size > 0 ? formatFileSize(size) : null;
return FItem(
prefix: selectedSourceIdx == e.key
? const Icon(Icons.check, size: 14)
: const SizedBox(width: 14),
title: Text(sizeStr != null ? '$name $sizeStr' : name),
selected: selectedSourceIdx == e.key,
onPress: () => onSelectSource(e.key),
);
}).toList();
}
String subtitleSummaryLabel(
List<EmbyRawMediaStream> subtitles,
int? selectedSubtitleIdx,
) => _streamSummaryLabel(
subtitles,
selectedSubtitleIdx,
empty: '关闭',
prefix: '字幕',
);
String audioSummaryLabel(
List<EmbyRawMediaStream> audios,
int selectedAudioIdx,
) => _streamSummaryLabel(audios, selectedAudioIdx, empty: '默认', prefix: '音轨');
String _streamSummaryLabel(
List<EmbyRawMediaStream> streams,
int? idx, {
required String empty,
required String prefix,
}) {
if (idx == null || idx < 0 || idx >= streams.length) return empty;
final s = streams[idx];
return s.Language ?? s.DisplayTitle ?? '$prefix ${idx + 1}';
}
String versionSummaryLabel(
List<EmbyRawMediaSource> sources,
int selectedSourceIdx,
) {
if (selectedSourceIdx < 0 || selectedSourceIdx >= sources.length) {
return '版本';
}
return sources[selectedSourceIdx].Name ?? '版本 ${selectedSourceIdx + 1}';
}
List<Widget> buildStreamSelectorPills({
required List<EmbyRawMediaStream> subtitles,
required List<EmbyRawMediaStream> audios,
required int? selectedSubtitleIdx,
required int selectedAudioIdx,
required ValueChanged<int?> onSelectSubtitle,
required ValueChanged<int> onSelectAudio,
required double height,
}) {
final actions = <Widget>[];
if (subtitles.length > 1) {
actions.add(
HeroPillDropdownButton(
height: height,
icon: Icons.subtitles_outlined,
menuChildren: buildSubtitleMenuButtons(
subtitles: subtitles,
selectedSubtitleIdx: selectedSubtitleIdx,
onSelectSubtitle: onSelectSubtitle,
),
),
);
}
if (audios.length > 1) {
actions.add(
HeroPillDropdownButton(
height: height,
icon: Icons.audiotrack_outlined,
menuChildren: buildAudioMenuButtons(
audios: audios,
selectedAudioIdx: selectedAudioIdx,
onSelectAudio: onSelectAudio,
),
),
);
}
return actions;
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/contracts/tmdb.dart';
import '../../../providers/person_items_provider.dart';
import '../../../shared/mappers/tmdb_image_url.dart';
import '../../../shared/widgets/cast_scroller.dart';
import '../../../shared/widgets/section_header.dart';
class TmdbCastSection extends ConsumerWidget {
final List<TmdbCastMember> cast;
final String imageBaseUrl;
final Widget? leading;
const TmdbCastSection({
super.key,
required this.cast,
required this.imageBaseUrl,
this.leading,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (cast.isEmpty) return const SizedBox.shrink();
final entries = cast.map((member) {
return CastEntry(
name: member.name,
subtitle: member.character,
imageUrl: TmdbImageUrl.profile(imageBaseUrl, member.profilePath),
onTap: () => _openPerson(context, ref, member.name),
);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) leading!,
const Padding(
padding: EdgeInsets.fromLTRB(0, 16, 0, 4),
child: SectionHeader(label: '演职人员'),
),
CastScroller(entries: entries),
],
);
}
Future<void> _openPerson(
BuildContext context,
WidgetRef ref,
String name,
) async {
final messenger = ScaffoldMessenger.maybeOf(context);
String? personId;
try {
personId = await ref.read(embyPersonIdByNameProvider(name).future);
} catch (_) {
personId = null;
}
if (!context.mounted) return;
if (personId != null && personId.isNotEmpty) {
context.push('/person/$personId');
} else {
messenger?.showSnackBar(SnackBar(content: Text('未在媒体库中找到「$name」的作品')));
}
}
}
@@ -0,0 +1,83 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import '../../../core/contracts/tmdb.dart';
import '../../../shared/utils/media_nav.dart';
import '../../../shared/widgets/hover_scrollable_row.dart';
import '../../../shared/widgets/section_header.dart';
import '../../../shared/widgets/tmdb_poster_card.dart';
class TmdbRecommendationSection extends StatelessWidget {
final List<TmdbRecommendation> items;
final String imageBaseUrl;
final String fallbackMediaType;
final Widget? leading;
const TmdbRecommendationSection({
super.key,
required this.items,
required this.imageBaseUrl,
required this.fallbackMediaType,
this.leading,
});
@override
Widget build(BuildContext context) {
if (items.isEmpty) return const SizedBox.shrink();
final list = items.length > 20 ? items.sublist(0, 20) : items;
final isAndroid = Platform.isAndroid;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) leading!,
const Padding(
padding: EdgeInsets.fromLTRB(0, 16, 0, 4),
child: SectionHeader(label: '推荐'),
),
SizedBox(
height: isAndroid ? 210 : 280,
child: HoverScrollableRow(
builder: (_, controller) => ListView.separated(
controller: controller,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: list.length,
separatorBuilder: (_, _) => const SizedBox(width: 12),
itemBuilder: (_, i) {
final item = list[i];
final type = item.mediaType ?? fallbackMediaType;
final tappable = type == 'movie' || type == 'tv';
return TmdbPosterCard(
title: item.title,
posterPath: item.posterPath,
voteAverage: item.voteAverage,
mediaTypeLabel: _typeLabel(item.mediaType),
imageBaseUrl: imageBaseUrl,
width: isAndroid ? 105 : 150,
onTap: tappable
? () => goTmdbDetail(
context,
tmdbId: item.id,
mediaType: type,
seed: item,
)
: null,
);
},
),
),
),
],
);
}
static String? _typeLabel(String? mediaType) => switch (mediaType) {
'tv' => '剧集',
'movie' => '电影',
_ => null,
};
}