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