Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,105 @@
import 'package:flutter/widgets.dart';
import '../../../core/contracts/tmdb.dart';
import '../../../shared/mappers/tmdb_image_selector.dart';
import '../../../shared/mappers/tmdb_image_url.dart';
import '../../../shared/utils/format_utils.dart';
import '../utils/detail_helpers.dart';
import '../model/detail_hero_model.dart';
import '../model/detail_image_model.dart';
import '../model/detail_section_model.dart';
import '../model/detail_view_state.dart';
import '../widgets/tmdb_cast_section.dart';
import '../widgets/tmdb_recommendation_section.dart';
DetailViewState buildTmdbDetailViewState({
required String mediaType,
required TmdbEnrichedDetail? enriched,
required TmdbRecommendation? seed,
required String imageBaseUrl,
required String language,
required Widget? heroActions,
required List<DetailSection> hitStateSections,
required bool showSpinner,
}) {
final detail = enriched?.detail;
final title = (detail?.title.isNotEmpty ?? false)
? detail!.title
: (seed?.title ?? '');
final posterPath = detail?.posterPath ?? seed?.posterPath;
final backdropPath = detail?.backdropPath ?? seed?.backdropPath;
final voteAverage = detail?.voteAverage ?? seed?.voteAverage;
final overview = (detail?.overview?.trim().isNotEmpty ?? false)
? detail!.overview!.trim()
: (seed?.overview?.trim() ?? '');
final tagline = detail?.tagline?.trim();
final metaParts = <String>[];
final year = yearOf(detail?.releaseDate);
if (year != null) metaParts.add(year);
final runtime = formatRuntimeMinutes(detail?.runtime);
if (runtime != null) metaParts.add(runtime);
final genreNames = [
for (final g in detail?.genres ?? const <TmdbGenre>[])
if (g.name.isNotEmpty) g.name,
];
final cast = enriched?.credits?.cast ?? const <TmdbCastMember>[];
final recommendations =
enriched?.recommendations?.results ?? const <TmdbRecommendation>[];
final posterUrl = TmdbImageUrl.poster(imageBaseUrl, posterPath);
final backdropUrl = TmdbImageUrl.backdrop(imageBaseUrl, backdropPath);
final logoUrl = TmdbImageSelector.logoUrl(
enriched?.images,
imageBaseUrl,
language: language,
);
final fallbackUrls = TmdbImageSelector.buildFallbackUrls(
primaryUrl: backdropUrl,
candidates: [posterUrl],
);
final sections = <DetailSection>[
...hitStateSections,
if (cast.isNotEmpty)
DetailSection(
id: 'tmdb_cast',
child: TmdbCastSection(cast: cast, imageBaseUrl: imageBaseUrl),
),
if (recommendations.isNotEmpty)
DetailSection(
id: 'tmdb_recommendations',
child: TmdbRecommendationSection(
items: recommendations,
imageBaseUrl: imageBaseUrl,
fallbackMediaType: mediaType,
),
),
];
return DetailViewState(
image: DetailImageModel(
backdropUrl: backdropUrl,
fallbackUrls: fallbackUrls,
),
navTitle: title,
hero: DetailHeroModel(
title: title,
logoUrl: logoUrl,
posterUrl: posterUrl,
imageHeaders: null,
rating: voteAverage,
metaParts: metaParts,
genres: genreNames,
tagline: tagline,
overview: overview,
actions: heroActions,
),
sections: sections,
showLoadingBelowHero: showSpinner,
);
}
@@ -0,0 +1,108 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../shared/widgets/smart_image.dart';
class AndroidDetailBanner extends StatefulWidget {
const AndroidDetailBanner({
super.key,
required this.scrollProgress,
required this.backdropUrl,
required this.fallbackUrls,
this.embyBaseUrl = '',
this.imageHeaders,
});
final ValueListenable<double> scrollProgress;
final String backdropUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
@override
State<AndroidDetailBanner> createState() => _AndroidDetailBannerState();
}
class _AndroidDetailBannerState extends State<AndroidDetailBanner> {
bool _allImagesFailed = false;
@override
void didUpdateWidget(covariant AndroidDetailBanner oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.backdropUrl != widget.backdropUrl ||
!listEquals(oldWidget.fallbackUrls, widget.fallbackUrls)) {
_allImagesFailed = false;
}
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
if (!_allImagesFailed)
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.68, 1.0],
colors: [Colors.black, Colors.black, Colors.transparent],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: SmartImage(
url: widget.backdropUrl,
fallbackUrls: widget.fallbackUrls,
fit: BoxFit.cover,
alignment: Alignment.center,
borderRadius: 0,
placeholder: const SizedBox.expand(),
onError: () {
if (mounted && !_allImagesFailed) {
setState(() => _allImagesFailed = true);
}
},
resolveHttpHeaders: (url) =>
widget.embyBaseUrl.isNotEmpty &&
url.startsWith(widget.embyBaseUrl)
? widget.imageHeaders
: null,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 120,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.38),
Colors.transparent,
],
),
),
),
),
),
Positioned.fill(
child: ValueListenableBuilder<double>(
valueListenable: widget.scrollProgress,
builder: (context, rawProgress, child) {
final progress = rawProgress.clamp(0.0, 1.0);
return IgnorePointer(
child: ColoredBox(
color: Colors.black.withValues(alpha: progress * 0.5),
),
);
},
),
),
],
);
}
}
@@ -0,0 +1,16 @@
import 'dart:ui' show Color;
class AndroidDetailColors {
AndroidDetailColors._();
static const background = Color(0xFF1A1A2E);
static const navBarBg = Color(0xFF141428);
static const accent = Color(0xFFE5A00D);
static const cardBg = Color(0x0AFFFFFF);
static const genrePillBg = Color(0x14FFFFFF);
static const genrePillBorder = Color(0x14FFFFFF);
static const divider = Color(0x1AFFFFFF);
static const starEmpty = Color(0x4DFFFFFF);
static const mediaTagBg = Color(0x1AFFFFFF);
static const mediaTagBorder = Color(0x1AFFFFFF);
}
@@ -0,0 +1,27 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
const double androidDetailDesktopTitleBarHeight = 38.0;
const double androidDetailNavigationContentHeight = 30.0;
double computeAndroidDetailNavigationHeight(BuildContext context) {
final topInset = MediaQuery.paddingOf(context).top;
return math.max(topInset, androidDetailDesktopTitleBarHeight) +
androidDetailNavigationContentHeight;
}
double computeAndroidDetailBannerHeight(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final bodyHeight = mediaQuery.size.height - mediaQuery.padding.top;
final preferredHeight = (bodyHeight * 0.46).clamp(300.0, 390.0);
final landscapeSafeHeight = bodyHeight * 0.55;
return math.min(preferredHeight, landscapeSafeHeight);
}
double computeAndroidDetailCollapseExtent(BuildContext context) {
final bannerHeight = computeAndroidDetailBannerHeight(context);
final navigationHeight = computeAndroidDetailNavigationHeight(context);
return math.max(1.0, bannerHeight - navigationHeight);
}
@@ -0,0 +1,232 @@
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import '../../../shared/widgets/smart_image.dart';
import '../widgets/detail_nav_bar.dart';
import 'android_detail_banner.dart';
import 'android_detail_colors.dart';
import 'android_detail_geometry.dart';
class AndroidDetailLayout extends StatelessWidget {
const AndroidDetailLayout({
super.key,
required this.scrollController,
required this.scrollProgress,
this.backdropUrl,
this.fallbackUrls = const [],
this.embyBaseUrl = '',
this.imageHeaders,
this.backgroundColor,
this.navigationColor,
required this.title,
required this.onBack,
required this.heroInfo,
this.heroActions,
this.overview,
this.belowOverview,
this.sections = const [],
});
final ScrollController scrollController;
final ValueNotifier<double> scrollProgress;
final String? backdropUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
final Color? backgroundColor;
final Color? navigationColor;
final String title;
final VoidCallback onBack;
final Widget heroInfo;
final Widget? heroActions;
final Widget? overview;
final Widget? belowOverview;
final List<Widget> sections;
@override
Widget build(BuildContext context) {
final topInset = MediaQuery.paddingOf(context).top;
final bannerHeight = computeAndroidDetailBannerHeight(context);
final backdrop =
backdropUrl ?? (fallbackUrls.isNotEmpty ? fallbackUrls.first : null);
final effectiveFallbackUrls = backdropUrl != null
? fallbackUrls
: fallbackUrls.skip(1).toList(growable: false);
final targetBackgroundColor =
backgroundColor ?? AndroidDetailColors.background;
final animationDuration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 350);
return TweenAnimationBuilder<Color?>(
tween: ColorTween(
begin: AndroidDetailColors.background,
end: targetBackgroundColor,
),
duration: animationDuration,
curve: Curves.easeOutCubic,
builder: (context, animatedBackgroundColor, child) {
final effectiveBackgroundColor =
animatedBackgroundColor ?? targetBackgroundColor;
return Scaffold(
backgroundColor: effectiveBackgroundColor,
body: Stack(
children: [
Positioned.fill(
child: _AndroidDetailBlurredBackdrop(
imageUrl: backdrop,
fallbackUrls: effectiveFallbackUrls,
embyBaseUrl: embyBaseUrl,
imageHeaders: imageHeaders,
),
),
CustomScrollView(
controller: scrollController,
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
),
slivers: [
if (backdrop != null)
SliverAppBar(
automaticallyImplyLeading: false,
primary: false,
pinned: false,
stretch: true,
toolbarHeight: 0,
collapsedHeight: 0,
expandedHeight: bannerHeight,
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
stretchModes: const [StretchMode.zoomBackground],
background: AndroidDetailBanner(
scrollProgress: scrollProgress,
backdropUrl: backdrop,
fallbackUrls: effectiveFallbackUrls,
embyBaseUrl: embyBaseUrl,
imageHeaders: imageHeaders,
),
),
)
else
SliverToBoxAdapter(child: SizedBox(height: topInset + 72)),
SliverToBoxAdapter(child: heroInfo),
if (heroActions != null)
SliverToBoxAdapter(child: heroActions!),
if (overview != null) SliverToBoxAdapter(child: overview!),
if (belowOverview != null)
SliverToBoxAdapter(child: belowOverview!),
SliverList.list(
children: [
...sections,
SizedBox(
height: 32 + MediaQuery.paddingOf(context).bottom,
),
],
),
],
),
DetailNavBar(
scrollProgress: scrollProgress,
title: title,
onBack: onBack,
chromeColor: navigationColor ?? AndroidDetailColors.navBarBg,
),
],
),
);
},
);
}
}
class _AndroidDetailBlurredBackdrop extends StatelessWidget {
const _AndroidDetailBlurredBackdrop({
required this.imageUrl,
required this.fallbackUrls,
required this.embyBaseUrl,
required this.imageHeaders,
});
final String? imageUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
static const double _blurSigma = 32;
static const LinearGradient _scrim = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.4, 1.0],
colors: [Color(0x59000000), Color(0x8C000000), Color(0xD9000000)],
);
@override
Widget build(BuildContext context) {
final url = imageUrl;
final Widget child;
if (url == null) {
child = const SizedBox.expand(key: ValueKey<String>('__no_backdrop__'));
} else {
child = RepaintBoundary(
key: ValueKey<String>(url),
child: Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _blurSigma,
sigmaY: _blurSigma,
tileMode: TileMode.clamp,
),
child: SmartImage(
url: url,
fallbackUrls: fallbackUrls,
fit: BoxFit.cover,
borderRadius: 0,
memCacheWidth: 400,
fallbackIcon: null,
placeholder: const SizedBox.expand(),
resolveHttpHeaders: (candidateUrl) =>
embyBaseUrl.isNotEmpty &&
candidateUrl.startsWith(embyBaseUrl)
? imageHeaders
: null,
),
),
const DecoratedBox(decoration: BoxDecoration(gradient: _scrim)),
],
),
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeOut,
layoutBuilder: (currentChild, previousChildren) => Stack(
fit: StackFit.expand,
children: [...previousChildren, if (currentChild != null) currentChild],
),
child: child,
);
}
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../widgets/section_container.dart';
class ExternalLinksSection extends StatelessWidget {
final String? imdbId;
final int? tmdbId;
final String? tmdbMediaType;
final int? traktId;
final bool embedded;
final Widget? leading;
const ExternalLinksSection({
super.key,
this.imdbId,
this.tmdbId,
this.tmdbMediaType,
this.traktId,
this.embedded = true,
this.leading,
});
bool get _hasAnyLink => imdbId != null || tmdbId != null || traktId != null;
@override
Widget build(BuildContext context) {
if (!_hasAnyLink) return const SizedBox.shrink();
return SectionContainer(
title: '外部链接',
embedded: embedded,
leading: leading,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (imdbId != null)
_LinkPill(
label: 'IMDb',
dotColor: const Color(0xFFF5C518),
onTap: () =>
launchUrl(Uri.parse('https://www.imdb.com/title/$imdbId')),
),
if (tmdbId != null)
_LinkPill(
label: 'TMDB',
dotColor: const Color(0xFF01B4E4),
onTap: () => launchUrl(
Uri.parse(
'https://www.themoviedb.org/${tmdbMediaType ?? 'movie'}/$tmdbId',
),
),
),
if (traktId != null)
_LinkPill(
label: 'Trakt',
dotColor: const Color(0xFFED1C24),
onTap: () => launchUrl(
Uri.parse(
'https://trakt.tv/${tmdbMediaType == 'tv' ? 'shows' : 'movies'}/$traktId',
),
),
),
],
),
);
}
}
class _LinkPill extends StatelessWidget {
final String label;
final Color dotColor;
final VoidCallback onTap;
const _LinkPill({
required this.label,
required this.dotColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white.withValues(alpha: 0.06)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.65),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'android_detail_colors.dart';
import '../../../shared/widgets/app_loading_ring.dart';
class AndroidHeroActions extends StatelessWidget {
final String playLabel;
final String? playTrailingLabel;
final double? progressPercent;
final bool isLoading;
final String loadingLabel;
final bool canPlay;
final VoidCallback? onPlay;
final bool showReplay;
final VoidCallback? onReplay;
final bool isPlayed;
final VoidCallback onTogglePlayed;
final bool isFavorite;
final VoidCallback onToggleFavorite;
const AndroidHeroActions({
super.key,
required this.playLabel,
this.playTrailingLabel,
this.progressPercent,
this.isLoading = false,
this.loadingLabel = '启动中',
this.canPlay = true,
this.onPlay,
this.showReplay = false,
this.onReplay,
required this.isPlayed,
required this.onTogglePlayed,
required this.isFavorite,
required this.onToggleFavorite,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
if (canPlay)
Expanded(
child: _GoldPlayButton(
label: playLabel,
trailingLabel: playTrailingLabel,
progressPercent: progressPercent,
isLoading: isLoading,
loadingLabel: loadingLabel,
onPressed: onPlay,
),
),
if (canPlay) const SizedBox(width: 8),
if (!canPlay) const Spacer(),
_SquareButton(
icon: isPlayed ? Icons.check_circle : Icons.check_circle_outline,
active: isPlayed,
onTap: onTogglePlayed,
),
const SizedBox(width: 8),
_SquareButton(
icon: isFavorite ? Icons.favorite : Icons.favorite_border,
active: isFavorite,
onTap: onToggleFavorite,
),
if (showReplay) ...[
const SizedBox(width: 8),
_SquareButton(icon: Icons.replay, active: false, onTap: onReplay),
],
],
),
);
}
}
class _GoldPlayButton extends StatelessWidget {
final String label;
final String? trailingLabel;
final bool isLoading;
final String loadingLabel;
final double? progressPercent;
final VoidCallback? onPressed;
const _GoldPlayButton({
required this.label,
this.trailingLabel,
this.isLoading = false,
this.loadingLabel = '启动中',
this.progressPercent,
this.onPressed,
});
@override
Widget build(BuildContext context) {
final progress = progressPercent;
final normalizedProgress = progress != null && progress > 0
? progress.clamp(0.0, 100.0) / 100.0
: null;
final enabled = !isLoading && onPressed != null;
final radius = BorderRadius.circular(10);
final backgroundColor = enabled
? AndroidDetailColors.accent
: Colors.white.withValues(alpha: 0.12);
final foregroundColor = enabled
? Colors.black
: Colors.white.withValues(alpha: 0.38);
return SizedBox(
height: 48,
child: Material(
color: backgroundColor,
borderRadius: radius,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: enabled ? onPressed : null,
child: Stack(
fit: StackFit.expand,
children: [
if (normalizedProgress != null)
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: normalizedProgress,
child: ColoredBox(
color: AndroidDetailColors.accent.withValues(alpha: 0.25),
),
),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
AppLoadingRing(size: 16, color: foregroundColor)
else
Icon(Icons.play_arrow, color: foregroundColor, size: 20),
const SizedBox(width: 6),
Text(
isLoading
? loadingLabel
: trailingLabel != null
? '$label $trailingLabel'
: label,
style: TextStyle(
color: foregroundColor,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
),
),
),
);
}
}
class _SquareButton extends StatelessWidget {
final IconData icon;
final bool active;
final VoidCallback? onTap;
const _SquareButton({required this.icon, required this.active, this.onTap});
@override
Widget build(BuildContext context) {
final color = active
? AndroidDetailColors.accent
: Colors.white.withValues(alpha: 0.8);
return SizedBox(
width: 48,
height: 48,
child: Material(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
),
child: Center(child: Icon(icon, size: 20, color: color)),
),
),
),
);
}
}
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import '../../../shared/widgets/smart_image.dart';
import 'android_detail_colors.dart';
class AndroidHeroInfo extends StatelessWidget {
final String? posterUrl;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
final String title;
final double? rating;
final List<String> metaParts;
final List<String> genres;
final List<String> mediaInfoTags;
const AndroidHeroInfo({
super.key,
this.posterUrl,
this.embyBaseUrl = '',
this.imageHeaders,
required this.title,
this.rating,
this.metaParts = const [],
this.genres = const [],
this.mediaInfoTags = const [],
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
boxShadow: const [
BoxShadow(
color: Color(0x80000000),
blurRadius: 20,
offset: Offset(0, 10),
),
],
),
child: SizedBox(
width: 120,
height: 180,
child: SmartImage(
url: posterUrl,
fit: BoxFit.cover,
borderRadius: 10,
placeholder: Container(
decoration: BoxDecoration(
color: const Color(0xFF2A2A4A),
borderRadius: BorderRadius.circular(10),
),
),
resolveHttpHeaders: (url) =>
embyBaseUrl.isNotEmpty && url.startsWith(embyBaseUrl)
? imageHeaders
: null,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
height: 1.2,
shadows: [
Shadow(
color: Color(0xCC000000),
blurRadius: 12,
offset: Offset(0, 2),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (metaParts.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
metaParts.join(' · '),
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.55),
shadows: const [
Shadow(
color: Color(0xCC000000),
blurRadius: 12,
offset: Offset(0, 2),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (rating != null && rating! > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: _StarRating(rating: rating!),
),
if (genres.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: genres
.map((g) => _GenrePill(label: g))
.toList(),
),
),
if (mediaInfoTags.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: mediaInfoTags
.map((t) => _MediaInfoPill(label: t))
.toList(),
),
),
],
),
),
],
),
);
}
}
class _StarRating extends StatelessWidget {
final double rating;
const _StarRating({required this.rating});
@override
Widget build(BuildContext context) {
final stars = rating / 2.0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < 5; i++)
Icon(
Icons.star_rounded,
size: 16,
color: i < stars.round()
? AndroidDetailColors.accent
: AndroidDetailColors.starEmpty,
),
const SizedBox(width: 6),
Text(
rating.toStringAsFixed(1),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.8),
shadows: const [
Shadow(
color: Color(0xCC000000),
blurRadius: 12,
offset: Offset(0, 2),
),
],
),
),
],
);
}
}
class _GenrePill extends StatelessWidget {
final String label;
const _GenrePill({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AndroidDetailColors.genrePillBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AndroidDetailColors.genrePillBorder),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.65),
),
),
);
}
}
class _MediaInfoPill extends StatelessWidget {
final String label;
const _MediaInfoPill({required this.label});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AndroidDetailColors.mediaTagBg,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AndroidDetailColors.mediaTagBorder),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.7),
),
),
);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../../../core/contracts/library.dart';
class AndroidMediaInfoSection extends StatelessWidget {
final EmbyRawMediaSource? source;
final List<String> studioNames;
final List<String> directors;
final Widget? leading;
const AndroidMediaInfoSection({
super.key,
this.source,
this.studioNames = const [],
this.directors = const [],
this.leading,
});
@override
Widget build(BuildContext context) {
final rows = _buildRows();
if (rows.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leading != null) leading!,
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rows,
),
),
],
);
}
List<Widget> _buildRows() {
final src = source;
final streams = src?.MediaStreams ?? [];
final rows = <Widget>[];
final subCount = streams.where((s) => s.Type == 'Subtitle').length;
if (subCount > 0) {
_addRow(rows, '字幕', '$subCount 条字幕轨');
}
if (directors.isNotEmpty) {
_addRow(rows, '导演', directors.join(' · '));
}
if (studioNames.isNotEmpty) {
_addRow(rows, '工作室', studioNames.join(' · '));
}
return rows;
}
void _addRow(List<Widget> rows, String label, String value) {
if (rows.isNotEmpty) {
rows.add(Divider(color: Colors.white.withValues(alpha: 0.04), height: 1));
}
rows.add(_InfoRow(label: label, value: value));
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
SizedBox(
width: 56,
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.55),
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.75),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import '../../../shared/widgets/app_dialog.dart';
import 'android_detail_colors.dart';
class AndroidOverviewSection extends StatelessWidget {
final String overview;
const AndroidOverviewSection({super.key, required this.overview});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: LayoutBuilder(
builder: (context, constraints) {
final textStyle = TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.6),
height: 1.65,
);
final textSpan = TextSpan(text: overview, style: textStyle);
final tp = TextPainter(
text: textSpan,
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
final hasOverflow = tp.didExceedMaxLines;
return GestureDetector(
onTap: hasOverflow ? () => _showOverviewDialog(context) : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
overview,
style: textStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (hasOverflow) ...[
const SizedBox(height: 6),
const Text(
'阅读全部',
style: TextStyle(
fontSize: 13,
color: AndroidDetailColors.accent,
),
),
],
],
),
);
},
),
);
}
void _showOverviewDialog(BuildContext context) {
showAppRawDialog<void>(
context,
builder: (context) => ColoredBox(
color: const Color(0xFF1E1E2E),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'简介',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Text(
overview,
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.75),
height: 1.7,
),
),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import 'android_detail_colors.dart';
class AndroidSectionDivider extends StatelessWidget {
const AndroidSectionDivider({super.key});
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: FDivider(style: .delta(color: AndroidDetailColors.divider)),
);
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../core/contracts/library.dart';
import '../../../shared/widgets/auto_dismiss_menu.dart';
import '../widgets/stream_selector_actions.dart';
const _dropdownMenuMaxHeight = 320.0;
class AndroidStreamSelectorRow extends StatelessWidget {
final List<EmbyRawMediaSource> sources;
final int selectedSourceIdx;
final ValueChanged<int>? onSelectSourceIndex;
final List<EmbyRawMediaStream> subtitles;
final List<EmbyRawMediaStream> audios;
final int? selectedSubtitleIdx;
final int selectedAudioIdx;
final ValueChanged<int?> onSelectSubtitleIndex;
final ValueChanged<int> onSelectAudioIndex;
const AndroidStreamSelectorRow({
super.key,
this.sources = const [],
this.selectedSourceIdx = 0,
this.onSelectSourceIndex,
required this.subtitles,
required this.audios,
required this.selectedSubtitleIdx,
required this.selectedAudioIdx,
required this.onSelectSubtitleIndex,
required this.onSelectAudioIndex,
});
@override
Widget build(BuildContext context) {
final buttons = <Widget>[];
final onSelectSource = onSelectSourceIndex;
if (sources.length > 1 && onSelectSource != null) {
buttons.add(
_SelectorButton(
icon: Icons.video_file_outlined,
label: versionSummaryLabel(sources, selectedSourceIdx),
menuChildrenBuilder: () => buildVersionMenuButtons(
sources: sources,
selectedSourceIdx: selectedSourceIdx,
onSelectSource: onSelectSource,
),
),
);
}
if (subtitles.length > 1) {
buttons.add(
_SelectorButton(
icon: Icons.subtitles_outlined,
label: subtitleSummaryLabel(subtitles, selectedSubtitleIdx),
menuChildrenBuilder: () => buildSubtitleMenuButtons(
subtitles: subtitles,
selectedSubtitleIdx: selectedSubtitleIdx,
onSelectSubtitle: onSelectSubtitleIndex,
),
),
);
}
if (audios.length > 1) {
buttons.add(
_SelectorButton(
icon: Icons.audiotrack_outlined,
label: audioSummaryLabel(audios, selectedAudioIdx),
menuChildrenBuilder: () => buildAudioMenuButtons(
audios: audios,
selectedAudioIdx: selectedAudioIdx,
onSelectAudio: onSelectAudioIndex,
),
),
);
}
if (buttons.isEmpty) return const SizedBox.shrink();
return SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: buttons.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, index) => buttons[index],
),
);
}
}
class _SelectorButton extends StatelessWidget {
final IconData icon;
final String label;
final List<FItem> Function() menuChildrenBuilder;
const _SelectorButton({
required this.icon,
required this.label,
required this.menuChildrenBuilder,
});
@override
Widget build(BuildContext context) {
return FPopoverMenu(
menuBuilder: autoDismissMenuBuilder,
menuAnchor: Alignment.topCenter,
childAnchor: Alignment.bottomCenter,
maxHeight: _dropdownMenuMaxHeight,
menu: [FItemGroup(children: menuChildrenBuilder())],
builder: (context, controller, child) => GestureDetector(
onTap: controller.toggle,
child: child,
),
child: Material(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: Colors.white.withValues(alpha: 0.8),
),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.85),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 2),
Icon(
Icons.arrow_drop_down,
size: 18,
color: Colors.white.withValues(alpha: 0.6),
),
],
),
),
),
),
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import '../../core/contracts/tmdb.dart';
class TmdbDetailEntry {
final String mediaType;
final String tmdbId;
final TmdbRecommendation? seed;
const TmdbDetailEntry({
required this.mediaType,
required this.tmdbId,
this.seed,
});
}
@@ -0,0 +1,470 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/contracts/auth.dart';
import '../../core/contracts/library.dart';
import '../../providers/detail_palette_provider.dart';
import '../../providers/di_providers.dart';
import '../../providers/session_provider.dart';
import '../../providers/tmdb_settings_provider.dart';
import '../../providers/version_priority_provider.dart';
import '../../shared/mappers/media_image_url.dart';
import '../../shared/mappers/tmdb_image_selector.dart';
import '../../shared/utils/format_utils.dart';
import '../../shared/utils/user_error_formatter.dart';
import '../../shared/widgets/app_error_state.dart';
import '../../shared/widgets/app_loading_ring.dart';
import 'android/android_detail_layout.dart';
import 'android/android_detail_colors.dart';
import 'android/android_external_links_section.dart';
import 'android/android_hero_actions.dart';
import 'android/android_hero_info.dart';
import 'android/android_overview_section.dart';
import 'android/android_section_divider.dart';
import 'android/android_stream_selector_row.dart';
import 'emby_detail_helpers.dart';
import 'emby_detail_sections_builder.dart';
import 'model/stream_selection.dart';
class EmbyDetailBodyAndroid extends ConsumerWidget {
const EmbyDetailBodyAndroid({
super.key,
required this.snap,
required this.detailData,
required this.seedItem,
required this.selectedItem,
required this.tmdbSeriesItem,
required this.detailServerId,
required this.initialSeasonId,
required this.pendingMediaSourceId,
required this.scrollController,
required this.scrollProgress,
required this.imageHeaders,
required this.session,
required this.priorities,
required this.selectedSourceIdx,
required this.selectedSubtitleIdx,
required this.selectedAudioIdx,
required this.selectedCrossServerCard,
required this.isPlayed,
required this.isFavorite,
required this.isLaunchingPlayer,
required this.onBack,
required this.onRetry,
required this.onTogglePlayed,
required this.onToggleFavorite,
required this.onSelectSourceIndex,
required this.onSelectSubtitleIndex,
required this.onSelectAudioIndex,
required this.onEpisodeTap,
required this.onSelectCrossServerSource,
required this.onApplyPendingMediaSource,
required this.onClearPendingMediaSource,
required this.onLaunchPlayer,
});
final AsyncSnapshot<MediaDetailRes> snap;
final MediaDetailRes? detailData;
final EmbyRawItem? seedItem;
final EmbyRawItem? selectedItem;
final EmbyRawItem? tmdbSeriesItem;
final String? detailServerId;
final String? initialSeasonId;
final String? pendingMediaSourceId;
final ScrollController scrollController;
final ValueNotifier<double> scrollProgress;
final Map<String, String>? imageHeaders;
final AuthedSession session;
final List<VersionPriority> priorities;
final int selectedSourceIdx;
final int? selectedSubtitleIdx;
final int selectedAudioIdx;
final CrossServerSourceCard? selectedCrossServerCard;
final bool isPlayed;
final bool isFavorite;
final bool isLaunchingPlayer;
final VoidCallback onBack;
final VoidCallback onRetry;
final VoidCallback onTogglePlayed;
final VoidCallback onToggleFavorite;
final ValueChanged<int> onSelectSourceIndex;
final ValueChanged<int?> onSelectSubtitleIndex;
final ValueChanged<int> onSelectAudioIndex;
final ValueChanged<EmbyRawItem> onEpisodeTap;
final ValueChanged<CrossServerSourceCard> onSelectCrossServerSource;
final ValueChanged<int> onApplyPendingMediaSource;
final VoidCallback onClearPendingMediaSource;
final Future<void> Function(
EmbyRawItem displayItem,
List<EmbyRawMediaSource> sources, {
bool fromStart,
int? overrideSubtitleIdx,
String? backdropUrl,
})
onLaunchPlayer;
@override
Widget build(BuildContext context, WidgetRef ref) {
if (snap.connectionState != ConnectionState.done &&
detailData == null &&
seedItem == null) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
),
body: const Center(child: AppLoadingRing()),
);
}
final data = detailData ?? snap.data;
if (snap.hasError && data == null) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
),
body: AppErrorState(
message: formatUserError(snap.error, fallback: '详情加载失败'),
onRetry: onRetry,
),
);
}
final baseItem = data?.base ?? seedItem;
if (baseItem == null) return const Center(child: Text('未找到数据'));
final isSeedOnly = data == null;
final displayItem = selectedItem ?? baseItem;
final tmdbMediaRef = tmdbMediaRefFor(baseItem, tmdbSeriesItem);
final tmdbSettings = tmdbMediaRef != null
? ref.watch(tmdbSettingsProvider).value
: null;
final tmdbImageSet =
tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false)
? ref.watch(
tmdbImagesProvider((
tmdbId: tmdbMediaRef.id,
mediaType: tmdbMediaRef.mediaType,
)),
)
: null;
final tmdbImageBaseUrl = tmdbSettings?.effectiveImageBaseUrl;
final tmdbDetail =
tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false)
? ref.watch(
tmdbMediaDetailProvider((
tmdbId: tmdbMediaRef.id,
mediaType: tmdbMediaRef.mediaType,
)),
)
: null;
final tmdbBackdropUrl = TmdbImageSelector.backdropUrl(
tmdbImageSet,
tmdbImageBaseUrl,
);
final tmdbPosterUrl = TmdbImageSelector.posterUrl(
tmdbImageSet,
tmdbImageBaseUrl,
);
final embyBackdropUrl = EmbyImageUrl.detailBanner(
baseUrl: session.serverUrl,
token: session.token,
item: baseItem,
);
final String? embyPosterUrl;
if (baseItem.Type == 'Episode' &&
(baseItem.SeriesId?.isNotEmpty ?? false)) {
embyPosterUrl = EmbyImageUrl.primaryById(
baseUrl: session.serverUrl,
token: session.token,
itemId: baseItem.SeriesId!,
maxHeight: 720,
);
} else {
embyPosterUrl = EmbyImageUrl.primary(
baseUrl: session.serverUrl,
token: session.token,
item: baseItem,
maxHeight: 720,
);
}
final backdropUrl = tmdbBackdropUrl ?? embyBackdropUrl;
final posterUrl = tmdbPosterUrl ?? embyPosterUrl;
final backgroundFallbackUrls = TmdbImageSelector.buildFallbackUrls(
primaryUrl: backdropUrl,
candidates: [tmdbPosterUrl, embyBackdropUrl, embyPosterUrl],
);
final paletteImageUrl = posterUrl ?? backdropUrl;
final paletteImageHeaders = paletteImageUrl.startsWith(session.serverUrl)
? imageHeaders
: null;
final extractedBackgroundColor = ref
.watch(
detailPaletteProvider(
DetailPaletteRequest(
url: paletteImageUrl,
headers: paletteImageHeaders,
),
),
)
.value;
final detailBackgroundColor =
extractedBackgroundColor ?? AndroidDetailColors.background;
final canPlay = displayItem.Type != 'Series';
final rawSources = mediaSourcesOfItem(displayItem);
final sortedSources = sortMediaSources(rawSources, priorities);
final pendingId = pendingMediaSourceId;
if (pendingId != null && sortedSources.isNotEmpty) {
final idx = resolveDefaultSourceIndex(
sortedSources,
pendingId,
fallback: -1,
);
if (idx >= 0 && idx != selectedSourceIdx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onApplyPendingMediaSource(idx);
});
} else {
onClearPendingMediaSource();
}
}
final selectedSrc = resolveActiveMediaSource(
selectedCrossServerCard,
sortedSources,
selectedSourceIdx,
);
final int? effectiveSubtitleIdx = selectedSubtitleIdx;
final streamSelection = resolveStreamSelection(
selectedSrc,
selectedSubtitleIdx,
selectedAudioIdx,
);
final String? seriesId;
if (baseItem.Type == 'Series') {
seriesId = baseItem.Id;
} else if (baseItem.Type == 'Episode' &&
(baseItem.SeriesId?.isNotEmpty ?? false)) {
seriesId = baseItem.SeriesId;
} else {
seriesId = null;
}
final similarItemId =
baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)
? baseItem.SeriesId!
: baseItem.Id;
final frozenOverview = baseItem.Overview;
final embyOverview = (frozenOverview ?? displayItem.Overview)?.trim();
final overviewFallback =
(tmdbDetail?.overview ??
tmdbSeriesItem?.Overview ??
(baseItem.Type == 'Series' ? baseItem.Overview : null))
?.trim();
final overview = embyOverview != null && embyOverview.isNotEmpty
? embyOverview
: overviewFallback;
final metaParts = buildDetailMetaParts(displayItem, source: selectedSrc);
final genres = extractGenres(baseItem);
final progressPercent = detailProgressPercent(displayItem);
final hasProgress = progressPercent != null && progressPercent > 0;
final pos = displayItem.UserData?.PlaybackPositionTicks;
final positionClock = pos != null && pos > 0
? formatTicksAsClock(pos)
: null;
final studiosRaw = baseItem.extra['Studios'];
final studioNames = studiosRaw is List
? studiosRaw
.whereType<Map>()
.map((s) => s['Name']?.toString() ?? '')
.where((n) => n.isNotEmpty)
.toList()
: <String>[];
final peopleRaw = baseItem.extra['People'];
final directors = peopleRaw is List
? peopleRaw
.whereType<Map>()
.where((p) => p['Type'] == 'Director')
.map((p) => p['Name']?.toString() ?? '')
.where((n) => n.isNotEmpty)
.toList()
: <String>[];
final mediaInfoTags = _buildMediaInfoTags(selectedSrc);
final sections = buildEmbyDetailSections(
ref: ref,
baseItem: baseItem,
displayItem: displayItem,
seriesId: seriesId,
similarItemId: similarItemId,
tmdbMediaRef: tmdbMediaRef,
detailServerId: detailServerId,
initialSeasonId: initialSeasonId,
tmdbSeriesItem: tmdbSeriesItem,
sortedSources: sortedSources,
selectedSourceIdx: selectedSourceIdx,
selectedCrossServerCard: selectedCrossServerCard,
currentServerName: ref.read(activeSessionProvider)?.serverName,
onEpisodeTap: onEpisodeTap,
onSelectSourceIndex: onSelectSourceIndex,
onSelectCrossServerSource: onSelectCrossServerSource,
activeSource: selectedSrc,
studioNames: studioNames,
directors: directors,
sectionLeading: const AndroidSectionDivider(),
);
if (isSeedOnly) sections.removeRange(1, sections.length);
final providerIds = providerIdsOfItem(baseItem);
final imdbId =
providerIds['Imdb'] ?? providerIds['IMDB'] ?? providerIds['imdb'];
final tmdbIdRaw =
providerIds['Tmdb'] ?? providerIds['TMDB'] ?? providerIds['tmdb'];
final tmdbIdInt = tmdbIdRaw != null ? int.tryParse(tmdbIdRaw) : null;
final traktRaw =
providerIds['TraktTv'] ?? providerIds['Trakt'] ?? providerIds['trakt'];
final traktIdInt = traktRaw != null ? int.tryParse(traktRaw) : null;
final sectionWidgets = <Widget>[
for (final section in sections) section.child,
];
sectionWidgets.add(
ExternalLinksSection(
imdbId: imdbId,
tmdbId: tmdbIdInt,
tmdbMediaType: tmdbMediaRef?.mediaType,
traktId: traktIdInt,
leading: const AndroidSectionDivider(),
),
);
return AndroidDetailLayout(
scrollController: scrollController,
scrollProgress: scrollProgress,
backdropUrl: backdropUrl,
fallbackUrls: backgroundFallbackUrls,
embyBaseUrl: session.serverUrl,
imageHeaders: imageHeaders,
backgroundColor: detailBackgroundColor,
navigationColor: createDetailNavigationColor(detailBackgroundColor),
title: detailNavTitle(displayItem, tmdbSeriesItem),
onBack: onBack,
heroInfo: AndroidHeroInfo(
posterUrl: posterUrl,
embyBaseUrl: session.serverUrl,
imageHeaders: imageHeaders,
title: displayItem.Name,
rating: displayItem.CommunityRating,
metaParts: metaParts,
genres: genres,
mediaInfoTags: mediaInfoTags,
),
heroActions: isSeedOnly
? null
: Column(
mainAxisSize: MainAxisSize.min,
children: [
AndroidHeroActions(
playLabel: hasProgress ? '继续播放' : '播放',
playTrailingLabel: positionClock,
progressPercent: progressPercent,
isLoading: isLaunchingPlayer,
canPlay: canPlay,
onPlay: canPlay
? () => unawaited(
onLaunchPlayer(
displayItem,
sortedSources,
overrideSubtitleIdx: effectiveSubtitleIdx,
backdropUrl: backdropUrl,
),
)
: null,
showReplay: hasProgress && canPlay,
onReplay: canPlay
? () => unawaited(
onLaunchPlayer(
displayItem,
sortedSources,
fromStart: true,
overrideSubtitleIdx: effectiveSubtitleIdx,
backdropUrl: backdropUrl,
),
)
: null,
isPlayed: isPlayed,
onTogglePlayed: onTogglePlayed,
isFavorite: isFavorite,
onToggleFavorite: onToggleFavorite,
),
if (canPlay)
AndroidStreamSelectorRow(
sources: sortedSources,
selectedSourceIdx: selectedSourceIdx,
onSelectSourceIndex: onSelectSourceIndex,
subtitles: streamSelection.subtitles,
audios: streamSelection.audios,
selectedSubtitleIdx: selectedSubtitleIdx,
selectedAudioIdx: selectedAudioIdx,
onSelectSubtitleIndex: onSelectSubtitleIndex,
onSelectAudioIndex: onSelectAudioIndex,
),
],
),
overview: overview != null && overview.isNotEmpty
? AndroidOverviewSection(overview: overview)
: null,
belowOverview: null,
sections: sectionWidgets,
);
}
static List<String> _buildMediaInfoTags(EmbyRawMediaSource? source) {
if (source == null) return const [];
final tags = <String>[];
final streams = source.MediaStreams ?? [];
final video = streams.where((s) => s.Type == 'Video').firstOrNull;
if (video != null) {
final codec = video.Codec?.toUpperCase();
if (codec != null && codec.isNotEmpty) tags.add(codec);
final range = video.extra['VideoRange']?.toString();
if (range != null && range.isNotEmpty && range != 'SDR') {
tags.add(range);
}
}
final audio = streams.where((s) => s.Type == 'Audio').firstOrNull;
if (audio != null) {
final codec = audio.Codec?.toUpperCase();
if (codec != null && codec.isNotEmpty) tags.add(codec);
}
if (source.Size != null && source.Size! > 0) {
tags.add(formatFileSize(source.Size!));
}
return tags;
}
}
@@ -0,0 +1,371 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/contracts/auth.dart';
import '../../core/contracts/library.dart';
import '../../providers/di_providers.dart';
import '../../providers/session_provider.dart';
import '../../providers/tmdb_settings_provider.dart';
import '../../providers/version_priority_provider.dart';
import '../../shared/constants/breakpoints.dart';
import '../../shared/mappers/media_image_url.dart';
import '../../shared/mappers/tmdb_image_selector.dart';
import '../../shared/utils/user_error_formatter.dart';
import '../../shared/widgets/app_error_state.dart';
import '../../shared/widgets/app_loading_ring.dart';
import 'emby_detail_helpers.dart';
import 'emby_detail_hero_builder.dart';
import 'emby_detail_sections_builder.dart';
import 'model/detail_hero_model.dart';
import 'model/detail_image_model.dart';
import 'model/detail_view_state.dart';
import 'model/stream_selection.dart';
import 'widgets/detail_immersive_scaffold.dart';
import 'widgets/detail_nav_bar.dart';
import 'widgets/detail_view_renderer.dart';
Widget buildEmbyDetailBody({
required BuildContext context,
required WidgetRef ref,
required AsyncSnapshot<MediaDetailRes> snap,
required MediaDetailRes? detailData,
required EmbyRawItem? seedItem,
required EmbyRawItem? selectedItem,
required EmbyRawItem? tmdbSeriesItem,
required String? detailServerId,
required String? initialSeasonId,
required String? pendingMediaSourceId,
required ScrollController scrollController,
required ValueNotifier<double> scrollProgress,
required Map<String, String>? imageHeaders,
required AuthedSession session,
required List<VersionPriority> priorities,
required int selectedSourceIdx,
required int? selectedSubtitleIdx,
required int selectedAudioIdx,
required CrossServerSourceCard? selectedCrossServerCard,
required bool isPlayed,
required bool isFavorite,
required bool isLaunchingPlayer,
required VoidCallback onBack,
required VoidCallback onRetry,
required VoidCallback onTogglePlayed,
required VoidCallback onToggleFavorite,
required ValueChanged<int> onSelectSourceIndex,
required ValueChanged<int?> onSelectSubtitleIndex,
required ValueChanged<int> onSelectAudioIndex,
required ValueChanged<EmbyRawItem> onEpisodeTap,
required ValueChanged<CrossServerSourceCard> onSelectCrossServerSource,
required ValueChanged<int> onApplyPendingMediaSource,
required VoidCallback onClearPendingMediaSource,
required Future<void> Function(
EmbyRawItem displayItem,
List<EmbyRawMediaSource> sources, {
bool fromStart,
int? overrideSubtitleIdx,
String? backdropUrl,
})
onLaunchPlayer,
}) {
if (snap.connectionState != ConnectionState.done &&
detailData == null &&
seedItem == null) {
return Stack(
children: [
const Center(child: AppLoadingRing()),
Positioned(
top: 0,
left: 0,
right: 0,
child: DetailNavBar(
scrollProgress: scrollProgress,
title: '',
onBack: onBack,
),
),
],
);
}
final data = detailData ?? snap.data;
if (snap.hasError && data == null) {
return Stack(
children: [
AppErrorState(
message: formatUserError(snap.error, fallback: '详情加载失败'),
onRetry: onRetry,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: DetailNavBar(
scrollProgress: scrollProgress,
title: '',
onBack: onBack,
),
),
],
);
}
final baseItem = data?.base ?? seedItem;
if (baseItem == null) return const Center(child: Text('未找到数据'));
final isSeedOnly = data == null;
final displayItem = selectedItem ?? baseItem;
final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
final tmdbMediaRef = tmdbMediaRefFor(baseItem, tmdbSeriesItem);
final tmdbSettings = tmdbMediaRef != null
? ref.watch(tmdbSettingsProvider).value
: null;
final tmdbImageSet =
tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false)
? ref.watch(
tmdbImagesProvider((
tmdbId: tmdbMediaRef.id,
mediaType: tmdbMediaRef.mediaType,
)),
)
: null;
final tmdbImageBaseUrl = tmdbSettings?.effectiveImageBaseUrl;
final tmdbDetail = tmdbMediaRef != null && (tmdbSettings?.canRequest ?? false)
? ref.watch(
tmdbMediaDetailProvider((
tmdbId: tmdbMediaRef.id,
mediaType: tmdbMediaRef.mediaType,
)),
)
: null;
final tmdbBackdropUrl = TmdbImageSelector.backdropUrl(
tmdbImageSet,
tmdbImageBaseUrl,
);
final tmdbPosterUrl = TmdbImageSelector.posterUrl(
tmdbImageSet,
tmdbImageBaseUrl,
);
final embyBackdropUrl = EmbyImageUrl.detailBanner(
baseUrl: session.serverUrl,
token: session.token,
item: baseItem,
);
final String? embyPosterUrl;
if (baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)) {
embyPosterUrl = EmbyImageUrl.primaryById(
baseUrl: session.serverUrl,
token: session.token,
itemId: baseItem.SeriesId!,
maxHeight: 720,
);
} else {
embyPosterUrl = EmbyImageUrl.primary(
baseUrl: session.serverUrl,
token: session.token,
item: baseItem,
maxHeight: 720,
);
}
final backdropUrl = tmdbBackdropUrl ?? embyBackdropUrl;
final backgroundFallbackUrls = TmdbImageSelector.buildFallbackUrls(
primaryUrl: backdropUrl,
candidates: [tmdbPosterUrl, embyBackdropUrl, embyPosterUrl],
);
final imageModel = DetailImageModel(
backdropUrl: backdropUrl,
fallbackUrls: backgroundFallbackUrls,
embyBaseUrl: session.serverUrl,
imageHeaders: imageHeaders,
);
final isEpisode = baseItem.Type == 'Episode';
final logoItemId = isEpisode && (baseItem.SeriesId?.isNotEmpty ?? false)
? baseItem.SeriesId!
: baseItem.Id;
final logoTag = isEpisode ? null : baseItem.ImageTags?['Logo'];
final logoUrl = !isEpisode && logoTag == null
? null
: EmbyImageUrl.logo(
baseUrl: session.serverUrl,
token: session.token,
itemId: logoItemId,
tag: logoTag,
);
final canPlay = displayItem.Type != 'Series';
final rawSources = mediaSourcesOfItem(displayItem);
final sortedSources = sortMediaSources(rawSources, priorities);
final pendingId = pendingMediaSourceId;
if (pendingId != null && sortedSources.isNotEmpty) {
final idx = resolveDefaultSourceIndex(
sortedSources,
pendingId,
fallback: -1,
);
if (idx >= 0 && idx != selectedSourceIdx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
onApplyPendingMediaSource(idx);
});
} else {
onClearPendingMediaSource();
}
}
final selectedSrc = resolveActiveMediaSource(
selectedCrossServerCard,
sortedSources,
selectedSourceIdx,
);
final streamSelection = resolveStreamSelection(
selectedSrc,
selectedSubtitleIdx,
selectedAudioIdx,
);
final subtitles = streamSelection.subtitles;
final audios = streamSelection.audios;
final int? effectiveSubtitleIdx = selectedSubtitleIdx;
final String? seriesId;
if (baseItem.Type == 'Series') {
seriesId = baseItem.Id;
} else if (baseItem.Type == 'Episode' &&
(baseItem.SeriesId?.isNotEmpty ?? false)) {
seriesId = baseItem.SeriesId;
} else {
seriesId = null;
}
final similarItemId =
baseItem.Type == 'Episode' && (baseItem.SeriesId?.isNotEmpty ?? false)
? baseItem.SeriesId!
: baseItem.Id;
final frozenOverview = baseItem.Overview;
final embyOverview = (frozenOverview ?? displayItem.Overview)?.trim();
final overviewFallback =
(tmdbDetail?.overview ??
tmdbSeriesItem?.Overview ??
(baseItem.Type == 'Series' ? baseItem.Overview : null))
?.trim();
final overview = embyOverview != null && embyOverview.isNotEmpty
? embyOverview
: overviewFallback;
final heroActions = isSeedOnly
? null
: buildEmbyHeroActions(
displayItem: displayItem,
canPlay: canPlay,
sortedSources: sortedSources,
subtitles: subtitles,
audios: audios,
effectiveSubtitleIdx: effectiveSubtitleIdx,
compact: compact,
selectedSourceIdx: selectedSourceIdx,
selectedSubtitleIdx: selectedSubtitleIdx,
selectedAudioIdx: selectedAudioIdx,
isPlayed: isPlayed,
isFavorite: isFavorite,
isLaunchingPlayer: isLaunchingPlayer,
onSelectSource: onSelectSourceIndex,
onSelectSubtitle: onSelectSubtitleIndex,
onSelectAudio: onSelectAudioIndex,
onTogglePlayed: onTogglePlayed,
onToggleFavorite: onToggleFavorite,
onReplay: () => unawaited(
onLaunchPlayer(
displayItem,
sortedSources,
fromStart: true,
overrideSubtitleIdx: effectiveSubtitleIdx,
backdropUrl: backdropUrl,
),
),
onPlay: () => unawaited(
onLaunchPlayer(
displayItem,
sortedSources,
overrideSubtitleIdx: effectiveSubtitleIdx,
backdropUrl: backdropUrl,
),
),
);
final sections = buildEmbyDetailSections(
ref: ref,
baseItem: baseItem,
displayItem: displayItem,
seriesId: seriesId,
similarItemId: similarItemId,
tmdbMediaRef: tmdbMediaRef,
detailServerId: detailServerId,
initialSeasonId: initialSeasonId,
tmdbSeriesItem: tmdbSeriesItem,
sortedSources: sortedSources,
selectedSourceIdx: selectedSourceIdx,
selectedCrossServerCard: selectedCrossServerCard,
currentServerName: ref.read(activeSessionProvider)?.serverName,
onEpisodeTap: onEpisodeTap,
onSelectSourceIndex: onSelectSourceIndex,
onSelectCrossServerSource: onSelectCrossServerSource,
);
if (isSeedOnly) {
sections.removeRange(1, sections.length);
}
final viewState = DetailViewState(
image: imageModel,
navTitle: detailNavTitle(displayItem, tmdbSeriesItem),
hero: DetailHeroModel(
title: displayItem.Name,
logoUrl: logoUrl,
posterUrl: embyPosterUrl,
imageHeaders: imageHeaders,
rating: displayItem.CommunityRating,
metaParts: buildDetailMetaParts(displayItem, source: selectedSrc),
genres: extractGenres(baseItem),
tagline: tmdbDetail?.tagline,
overview: overview,
actions: heroActions,
),
sections: sections,
showLoadingBelowHero: isSeedOnly,
);
return Stack(
children: [
DetailImmersiveScaffold(
scrollController: scrollController,
scrollProgress: scrollProgress,
backdropUrl: viewState.image.backdropUrl,
fallbackUrls: viewState.image.fallbackUrls,
embyBaseUrl: viewState.image.embyBaseUrl,
imageHeaders: viewState.image.imageHeaders,
compact: compact,
horizontalPadding: compact ? 16 : 32,
child: DetailViewRenderer(state: viewState),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: DetailNavBar(
scrollProgress: scrollProgress,
title: viewState.navTitle,
onBack: onBack,
),
),
],
);
}
@@ -0,0 +1,164 @@
import '../../core/contracts/library.dart';
import '../../shared/utils/cross_server_search_key.dart';
import '../../shared/utils/emby_ticks.dart';
import '../../shared/utils/format_utils.dart';
import '../../shared/utils/tmdb_key_utils.dart';
class TmdbMediaRef {
final String id;
final String mediaType;
const TmdbMediaRef({required this.id, required this.mediaType});
}
List<String> buildDetailMetaParts(
EmbyRawItem item, {
EmbyRawMediaSource? source,
}) {
final parts = <String>[];
final seriesName = item.SeriesName;
if (seriesName != null && seriesName.isNotEmpty) {
parts.add(seriesName);
}
if (item.ProductionYear != null) {
parts.add('${item.ProductionYear}');
}
final episode = episodeLabel(item);
if (episode != null) {
parts.add(episode);
}
final runtime = item.RunTimeTicks;
if (runtime != null && runtime > 0) {
final label = formatRuntimeMinutes(runtime ~/ kTicksPerMinute);
if (label != null) parts.add(label);
}
if (source != null) {
final videoStream = findVideoStream(source);
if (videoStream != null) {
final h = videoStream.extra['Height'] as int? ?? source.Height;
final range = videoStream.extra['VideoRange'] as String?;
if (h != null) {
final label = heightToLabel(h);
parts.add(range != null && range.isNotEmpty ? '$label $range' : label);
}
}
final size = source.Size;
if (size != null && size > 0) parts.add(formatFileSize(size));
}
return parts;
}
EmbyRawMediaStream? findVideoStream(EmbyRawMediaSource source) {
final streams = source.MediaStreams;
if (streams == null) return null;
for (final s in streams) {
if (s.Type == 'Video') return s;
}
return null;
}
String heightToLabel(int h) {
if (h >= 2160) return '4K';
if (h >= 1440) return '2K';
if (h >= 1080) return '1080P';
if (h >= 720) return '720P';
return '${h}P';
}
String? episodeLabel(EmbyRawItem item) {
final seasonName = item.SeasonName;
final index = item.IndexNumber;
if (seasonName == null && index == null) return null;
if (seasonName != null && seasonName.isNotEmpty && index != null) {
return '$seasonName ${formatEpisodeNumber(index)}';
}
if (seasonName != null && seasonName.isNotEmpty) return seasonName;
return formatEpisodeNumber(index);
}
String detailNavTitle(EmbyRawItem item, EmbyRawItem? seriesItem) {
if (item.Type != 'Episode') return item.Name;
final seriesName = item.SeriesName;
if (seriesName != null && seriesName.isNotEmpty) return seriesName;
final loadedSeriesName = seriesItem?.Name;
if (loadedSeriesName != null && loadedSeriesName.isNotEmpty) {
return loadedSeriesName;
}
return item.Name;
}
String? seriesIdForEpisode(EmbyRawItem item, String routeItemId) {
if (item.Type != 'Episode') return null;
final seriesId = item.SeriesId;
if (seriesId != null && seriesId.isNotEmpty) return seriesId;
if (routeItemId.isNotEmpty && routeItemId != item.Id) return routeItemId;
return null;
}
Map<String, String> lowercaseProviderIds(Map<String, String> ids) {
return {
for (final e in ids.entries)
if (e.key.isNotEmpty && e.value.isNotEmpty) e.key.toLowerCase(): e.value,
};
}
List<String> extractGenres(EmbyRawItem item) {
final genresRaw = item.extra['Genres'];
if (genresRaw is List) {
return genresRaw.whereType<String>().where((g) => g.isNotEmpty).toList();
}
return const [];
}
double? detailProgressPercent(EmbyRawItem item) {
final percent = playbackProgress(item) * 100;
return percent < 1 ? null : percent;
}
String tmdbIdFromDetailItem(EmbyRawItem item) => tmdbIdFromItem(item);
TmdbMediaRef? tmdbMediaRefFor(EmbyRawItem item, EmbyRawItem? tmdbSeriesItem) {
final String mediaType;
final String tmdbId;
switch (item.Type) {
case 'Movie':
mediaType = 'movie';
tmdbId = tmdbIdFromDetailItem(item);
case 'Series':
mediaType = 'tv';
tmdbId = tmdbIdFromDetailItem(item);
case 'Episode':
final seriesItem = tmdbSeriesItem;
if (seriesItem == null) return null;
mediaType = 'tv';
tmdbId = tmdbIdFromDetailItem(seriesItem);
default:
return null;
}
if (tmdbId.isEmpty) return null;
return TmdbMediaRef(id: tmdbId, mediaType: mediaType);
}
CrossServerSearchReq? buildCrossServerReqForDetail(
EmbyRawItem displayItem,
EmbyRawItem? seriesItem,
) {
final providerIds = providerIdsOfItem(displayItem);
if (displayItem.Type == 'Movie') {
if (providerIds.isEmpty && displayItem.Name.isEmpty) return null;
return CrossServerSearchReq(
anchorType: 'Movie',
itemName: displayItem.Name,
providerIds: providerIds,
);
}
if (displayItem.Type == 'Episode') {
return buildEpisodeCrossServerReq(
episode: displayItem,
seriesItem: seriesItem,
);
}
return null;
}
@@ -0,0 +1,78 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/contracts/library.dart';
import '../../shared/utils/format_utils.dart';
import 'emby_detail_helpers.dart';
import 'widgets/hero_action_buttons.dart';
import 'widgets/stream_selector_actions.dart';
Widget buildEmbyHeroActions({
required EmbyRawItem displayItem,
required bool canPlay,
required List<EmbyRawMediaSource> sortedSources,
required List<EmbyRawMediaStream> subtitles,
required List<EmbyRawMediaStream> audios,
required int? effectiveSubtitleIdx,
required bool compact,
required int selectedSourceIdx,
required int? selectedSubtitleIdx,
required int selectedAudioIdx,
required bool isPlayed,
required bool isFavorite,
required bool isLaunchingPlayer,
required ValueChanged<int> onSelectSource,
required ValueChanged<int?> onSelectSubtitle,
required ValueChanged<int> onSelectAudio,
required VoidCallback onTogglePlayed,
required VoidCallback onToggleFavorite,
required VoidCallback onPlay,
required VoidCallback onReplay,
}) {
final btnH = compact ? 44.0 : 52.0;
final progressPercent = detailProgressPercent(displayItem);
final hasProgress = progressPercent != null && progressPercent > 0;
final pos = displayItem.UserData?.PlaybackPositionTicks;
final positionClock = pos != null && pos > 0 ? formatTicksAsClock(pos) : null;
final extraActions = buildStreamSelectorPills(
subtitles: subtitles,
audios: audios,
selectedSubtitleIdx: selectedSubtitleIdx,
selectedAudioIdx: selectedAudioIdx,
onSelectSubtitle: onSelectSubtitle,
onSelectAudio: onSelectAudio,
height: btnH,
);
if (sortedSources.length > 1) {
extraActions.add(
HeroPillDropdownButton(
height: btnH,
icon: Icons.video_file_outlined,
menuChildren: buildVersionMenuButtons(
sources: sortedSources,
selectedSourceIdx: selectedSourceIdx,
onSelectSource: onSelectSource,
),
),
);
}
return DetailHeroActions(
showReplay: hasProgress && canPlay,
onReplay: canPlay ? () => unawaited(Future<void>.sync(onReplay)) : null,
isPlayed: isPlayed,
onTogglePlayed: () => unawaited(Future<void>.sync(onTogglePlayed)),
isFavorite: isFavorite,
onToggleFavorite: () => unawaited(Future<void>.sync(onToggleFavorite)),
extraIconActions: extraActions,
onPlay: canPlay ? () => unawaited(Future<void>.sync(onPlay)) : null,
playLabel: hasProgress ? '继续播放' : '播放',
progressPercent: progressPercent,
trailingLabel: positionClock,
isPlayLoading: isLaunchingPlayer,
compact: compact,
);
}
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/contracts/library.dart';
import '../../providers/di_providers.dart';
import 'android/android_media_info_section.dart';
import 'widgets/additional_parts_section.dart';
import 'widgets/emby_cast_section.dart';
import 'widgets/episode_list_section.dart';
import 'widgets/similar_section.dart';
import 'widgets/special_features_section.dart';
import 'emby_detail_helpers.dart';
import 'model/detail_section_model.dart';
import 'model/episode_auto_select_args.dart';
import 'widgets/source_selection_section.dart';
List<DetailSection> buildEmbyDetailSections({
required WidgetRef ref,
required EmbyRawItem baseItem,
required EmbyRawItem displayItem,
required String? seriesId,
required String similarItemId,
required TmdbMediaRef? tmdbMediaRef,
required String? detailServerId,
required String? initialSeasonId,
required EmbyRawItem? tmdbSeriesItem,
required List<EmbyRawMediaSource> sortedSources,
required int selectedSourceIdx,
required CrossServerSourceCard? selectedCrossServerCard,
required String? currentServerName,
required ValueChanged<EmbyRawItem> onEpisodeTap,
required ValueChanged<int> onSelectSourceIndex,
required ValueChanged<CrossServerSourceCard> onSelectCrossServerSource,
EmbyRawMediaSource? activeSource,
List<String> studioNames = const [],
List<String> directors = const [],
Widget? sectionLeading,
}) {
return <DetailSection>[
if (seriesId != null)
DetailSection(
id: 'emby_episodes',
child: _buildEpisodesSection(
ref: ref,
baseItem: baseItem,
displayItem: displayItem,
seriesId: seriesId,
tmdbMediaRef: tmdbMediaRef,
detailServerId: detailServerId,
initialSeasonId: initialSeasonId,
tmdbSeriesItem: tmdbSeriesItem,
onEpisodeTap: onEpisodeTap,
leading: sectionLeading,
),
),
if (displayItem.Type == 'Movie' || displayItem.Type == 'Episode')
DetailSection(
id: 'emby_source',
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: _buildSourceSection(
baseItem: baseItem,
displayItem: displayItem,
tmdbSeriesItem: tmdbSeriesItem,
sortedSources: sortedSources,
selectedSourceIdx: selectedSourceIdx,
selectedCrossServerCard: selectedCrossServerCard,
currentServerName: currentServerName,
onSelectSourceIndex: onSelectSourceIndex,
onSelectCrossServerSource: onSelectCrossServerSource,
leading: sectionLeading,
),
),
),
DetailSection(
id: 'emby_cast',
child: EmbyCastSection(
item: baseItem,
embedded: true,
leading: sectionLeading,
),
),
DetailSection(
id: 'emby_media_detail',
child: AndroidMediaInfoSection(
source: activeSource,
studioNames: studioNames,
directors: directors,
leading: sectionLeading,
),
),
if (baseItem.Type != 'Series')
DetailSection(
id: 'emby_additional_parts',
child: AdditionalPartsSection(
itemId: displayItem.Id,
embedded: true,
leading: sectionLeading,
),
),
if (baseItem.Type != 'Series')
DetailSection(
id: 'emby_special_features',
child: SpecialFeaturesSection(
itemId: displayItem.Id,
embedded: true,
leading: sectionLeading,
),
),
DetailSection(
id: 'emby_similar',
child: SimilarSection(
itemId: similarItemId,
embedded: true,
leading: sectionLeading,
),
),
];
}
Widget _buildEpisodesSection({
required WidgetRef ref,
required EmbyRawItem baseItem,
required EmbyRawItem displayItem,
required String seriesId,
required TmdbMediaRef? tmdbMediaRef,
required String? detailServerId,
required String? initialSeasonId,
required EmbyRawItem? tmdbSeriesItem,
required ValueChanged<EmbyRawItem> onEpisodeTap,
Widget? leading,
}) {
final showItem = baseItem.Type == 'Series' ? baseItem : tmdbSeriesItem;
final resumeAsync = baseItem.Type == 'Series' && detailServerId != null
? ref.watch(
seriesResumeTargetProvider((
serverId: detailServerId,
seriesId: seriesId,
)),
)
: null;
final autoSelect = EpisodeAutoSelectArgs.fromResume(resumeAsync);
return EpisodeListSection(
seriesId: seriesId,
tmdbSeriesId: tmdbMediaRef != null && tmdbMediaRef.mediaType == 'tv'
? tmdbMediaRef.id
: null,
currentEpisodeId: displayItem.Type == 'Episode' ? displayItem.Id : null,
currentEpisodeSeasonId: displayItem.Type == 'Episode'
? displayItem.SeasonId
: null,
currentEpisodeSeasonNumber: displayItem.Type == 'Episode'
? (displayItem.extra['ParentIndexNumber'] as num?)?.toInt()
: null,
currentEpisodeIndexNumber: displayItem.Type == 'Episode'
? displayItem.IndexNumber
: null,
initialSeasonId: initialSeasonId,
autoSelectFirstEpisode: autoSelect.autoSelectFirstEpisode,
autoSelectEpisodeId: autoSelect.autoSelectEpisodeId,
autoSelectSeasonId: autoSelect.autoSelectSeasonId,
onEpisodeTap: onEpisodeTap,
embedded: true,
showProviderIds: showItem != null
? lowercaseProviderIds(providerIdsOfItem(showItem))
: null,
showTitle: showItem?.Name,
showYear: showItem?.ProductionYear,
leading: leading,
);
}
Widget _buildSourceSection({
required EmbyRawItem baseItem,
required EmbyRawItem displayItem,
required EmbyRawItem? tmdbSeriesItem,
required List<EmbyRawMediaSource> sortedSources,
required int selectedSourceIdx,
required CrossServerSourceCard? selectedCrossServerCard,
required String? currentServerName,
required ValueChanged<int> onSelectSourceIndex,
required ValueChanged<CrossServerSourceCard> onSelectCrossServerSource,
Widget? leading,
}) {
final seriesItemForReq = baseItem.Type == 'Series'
? baseItem
: tmdbSeriesItem;
final req = buildCrossServerReqForDetail(displayItem, seriesItemForReq);
return SourceSelectionSection(
req: req,
localSources: sortedSources,
selectedSourceIdx: selectedSourceIdx,
selectedCrossServerCard: selectedCrossServerCard,
currentServerName: currentServerName,
localMediaInfoItem: displayItem,
showLoadingWhenEmpty: true,
loadingText: '正在查找可用版本…',
onLocalSourceTap: (src, index) => onSelectSourceIndex(index),
onCrossServerSourceTap: onSelectCrossServerSource,
leading: leading,
);
}
+806
View File
@@ -0,0 +1,806 @@
import 'dart:async';
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/last_played_version_store.dart';
import '../../providers/playback_info_prefetch_provider.dart';
import '../../providers/playback_active_provider.dart';
import '../../providers/session_provider.dart';
import '../../providers/version_priority_provider.dart';
import '../../shared/theme/app_theme.dart';
import '../../shared/utils/cross_server_search_key.dart';
import '../../shared/utils/app_logger.dart';
import '../../shared/widgets/app_snack_bar.dart';
import '../player/player_launcher.dart';
import 'emby_detail_body_android.dart';
import 'emby_detail_body_builder.dart';
import 'emby_detail_helpers.dart';
import 'android/android_detail_geometry.dart';
import 'model/selected_episode_merge.dart';
import 'model/stream_selection.dart';
import 'widgets/detail_scroll_progress.dart';
class EmbyDetailView extends ConsumerStatefulWidget {
final String itemId;
final String? initialSeriesId;
final String? initialSeasonId;
final EmbyRawItem? seedItem;
const EmbyDetailView({
super.key,
required this.itemId,
this.initialSeriesId,
this.initialSeasonId,
this.seedItem,
});
@override
ConsumerState<EmbyDetailView> createState() => _EmbyDetailViewState();
}
class _EmbyDetailViewState extends ConsumerState<EmbyDetailView> {
static const _tag = 'EmbyDetailView';
Future<MediaDetailRes>? _detailFuture;
MediaDetailRes? _data;
EmbyRawItem? _selectedItem;
EmbyRawItem? _tmdbSeriesItem;
bool _isFavorite = false;
bool _isPlayed = false;
int _selectedSourceIdx = 0;
int? _selectedSubtitleIdx;
int _selectedAudioIdx = 0;
bool _isLaunchingPlayer = false;
String? _detailServerId;
String? _originServerId;
bool _serverReloadScheduled = false;
String? _overrideItemId;
String? _pendingMediaSourceId;
bool _isRestoringOriginServer = false;
CrossServerSourceCard? _selectedCrossServerCard;
bool _crossServerPlaybackInFlight = false;
bool _backInFlight = false;
final _scrollController = ScrollController();
final _scrollProgress = ValueNotifier<double>(0.0);
late final DetailPageActivity _detailActivity;
bool _didEnterDetailActivity = false;
String get _detailItemId {
if (_overrideItemId != null && _overrideItemId!.isNotEmpty) {
return _overrideItemId!;
}
return widget.itemId;
}
@override
void initState() {
super.initState();
_detailActivity = ref.read(detailPageActivityProvider.notifier);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_didEnterDetailActivity = true;
_detailActivity.enter();
});
_originServerId = ref.read(activeSessionProvider)?.serverId;
_scrollController.addListener(_onScroll);
_reload();
}
@override
void dispose() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_didEnterDetailActivity) _detailActivity.leave();
});
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_scrollProgress.dispose();
super.dispose();
}
void _onScroll() {
_scrollProgress.value = computeDetailScrollProgress(
context,
_scrollController.offset,
collapseExtent: Platform.isAndroid
? computeAndroidDetailCollapseExtent(context)
: null,
);
}
@override
void didUpdateWidget(covariant EmbyDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.itemId != widget.itemId ||
oldWidget.initialSeriesId != widget.initialSeriesId ||
oldWidget.initialSeasonId != widget.initialSeasonId) {
_overrideItemId = null;
_reload();
}
}
void _reload({bool invalidateCache = false}) {
_detailServerId = ref.read(activeSessionProvider)?.serverId;
_serverReloadScheduled = false;
_resetScrollState();
if (invalidateCache) {
final serverId = _detailServerId ?? '';
ref.invalidate(
mediaDetailDataProvider((serverId: serverId, itemId: _detailItemId)),
);
}
setState(() {
_data = null;
_selectedItem = null;
_tmdbSeriesItem = null;
_selectedSourceIdx = 0;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_selectedCrossServerCard = null;
_isLaunchingPlayer = false;
_detailFuture = _loadDetail();
});
}
void _resetScrollState() {
_scrollProgress.value = 0.0;
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
}
Future<MediaDetailRes> _loadDetail() async {
final capturedServerId = _detailServerId ?? '';
final initialSeriesId = widget.initialSeriesId;
if (initialSeriesId != null &&
initialSeriesId.isNotEmpty &&
capturedServerId.isNotEmpty) {
unawaited(
ref.read(
mediaDetailDataProvider((
serverId: capturedServerId,
itemId: initialSeriesId,
)).future,
),
);
}
final res = await ref.read(
mediaDetailDataProvider((
serverId: capturedServerId,
itemId: _detailItemId,
)).future,
);
if (!mounted) return res;
AppLogger.debug(
_tag,
'_loadDetail entry itemId=$_detailItemId '
'detailServerId=$capturedServerId '
'activeServerId=${ref.read(activeSessionProvider)?.serverId} '
'base.Id=${res.base.Id} type=${res.base.Type} '
'seriesId=${res.base.SeriesId} seasonId=${res.base.SeasonId} '
'index=${res.base.IndexNumber} '
'sources=${mediaSourcesOfItem(res.base).length}',
);
setState(() {
_data = res;
_selectedItem = res.base;
_isFavorite = res.base.UserData?.IsFavorite ?? false;
_isPlayed = res.base.UserData?.Played ?? false;
});
unawaited(_loadRememberedVersion(res.base));
final seriesId = seriesIdForEpisode(res.base, widget.itemId);
if (seriesId != null) {
unawaited(_loadSeriesInfo(seriesId, capturedServerId, res.base));
} else {
_firePrewarm(res.base);
}
_prefetchPlaybackInfoFor(res.base);
return res;
}
Future<void> _loadSeriesInfo(
String seriesId,
String serverId,
EmbyRawItem episode,
) async {
try {
final seriesRes = await ref.read(
mediaDetailDataProvider((serverId: serverId, itemId: seriesId)).future,
);
if (!mounted) return;
setState(() {
_tmdbSeriesItem = seriesRes.base;
});
_firePrewarm(seriesRes.base);
} catch (_) {
_firePrewarm(episode);
}
}
void _firePrewarm(EmbyRawItem refItem) {
if (refItem.Type != 'Series' && refItem.Type != 'Episode') return;
final pids = providerIdsOfItem(refItem);
if (pids.isEmpty) return;
final activeServerId = ref.read(activeSessionProvider)?.serverId ?? '';
if (activeServerId.isEmpty) return;
unawaited(
ref.read(
seriesPrewarmProvider((
activeServerId: activeServerId,
seriesProviderIdQuery: encodeProviderIdsForKey(pids),
)).future,
),
);
}
void _syncDetailWithActiveServer(String serverId) {
final loadedServerId = _detailServerId;
if (loadedServerId == null) {
_detailServerId = serverId;
return;
}
if (_crossServerPlaybackInFlight) return;
if (loadedServerId == serverId || _serverReloadScheduled) return;
_serverReloadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_serverReloadScheduled = false;
final currentServerId = ref.read(activeSessionProvider)?.serverId;
if (currentServerId == null || _detailServerId == currentServerId) {
return;
}
_reload(invalidateCache: true);
});
}
void _selectCrossServerSource(CrossServerSourceCard card) {
setState(() {
_selectedCrossServerCard = card;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
});
_prefetchPlaybackInfoForCrossServer(card);
}
void _onEpisodeTap(EmbyRawItem episode) {
AppLogger.debug(
_tag,
'_onEpisodeTap id=${episode.Id} name=${episode.Name} '
'type=${episode.Type} seriesId=${episode.SeriesId} '
'seasonId=${episode.SeasonId} index=${episode.IndexNumber} '
'positionTicks=${episode.UserData?.PlaybackPositionTicks} '
'sources=${mediaSourcesOfItem(episode).length}',
);
setState(() {
_selectedItem = episode;
_isFavorite = episode.UserData?.IsFavorite ?? false;
_isPlayed = episode.UserData?.Played ?? false;
_selectedSourceIdx = 0;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_selectedCrossServerCard = null;
});
unawaited(_loadSelectedEpisodeDetail(episode.Id));
}
Future<void> _loadSelectedEpisodeDetail(String episodeId) async {
final serverId = _detailServerId ?? '';
if (serverId.isEmpty || episodeId.isEmpty) return;
try {
final res = await ref.read(
mediaDetailDataProvider((serverId: serverId, itemId: episodeId)).future,
);
final listItem = _selectedItem;
if (!mounted || listItem?.Id != episodeId) return;
final merged = listItem != null
? mergeSelectedEpisodeDetail(detail: res.base, listItem: listItem)
: res.base;
final mergedSources = mediaSourcesOfItem(merged);
AppLogger.debug(
_tag,
'_loadSelectedEpisodeDetail episodeId=$episodeId '
'listItem.Id=${listItem?.Id} detail.base.Id=${res.base.Id} '
'merged.Id=${merged.Id} mergedSources=${mergedSources.length} '
'firstSourceId=${mergedSources.isNotEmpty ? mergedSources.first.Id : null}',
);
setState(() {
_selectedItem = merged;
_isFavorite = merged.UserData?.IsFavorite ?? false;
_isPlayed = merged.UserData?.Played ?? false;
});
unawaited(_loadRememberedVersion(merged));
_prefetchPlaybackInfoFor(merged);
} catch (_) {}
}
void _selectSourceIndex(int index) {
setState(() {
_selectedSourceIdx = index;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_selectedCrossServerCard = null;
_pendingMediaSourceId = null;
});
final item = _selectedItem;
if (item != null) _prefetchPlaybackInfoFor(item);
}
void _selectSubtitleIndex(int? index) {
setState(() => _selectedSubtitleIdx = index);
}
void _selectAudioIndex(int index) {
setState(() => _selectedAudioIdx = index);
}
void _applyPendingMediaSource(int index) {
if (!mounted) return;
setState(() {
_selectedSourceIdx = index;
_pendingMediaSourceId = null;
});
final item = _selectedItem;
if (item != null) _prefetchPlaybackInfoFor(item);
}
void _clearPendingMediaSource() {
_pendingMediaSourceId = null;
}
int _prefetchStartTimeTicksForCrossServer(CrossServerSourceCard card) {
final selectedTicks = _selectedItem?.UserData?.PlaybackPositionTicks;
if (selectedTicks != null && selectedTicks > 0) return selectedTicks;
return 0;
}
void _prefetchPlaybackInfoFor(EmbyRawItem item) {
final serverId = _detailServerId ?? '';
if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return;
final key = _playbackInfoPrefetchKeyFor(
item,
mediaSourceId: _currentLocalMediaSourceIdFor(item),
fromStart: false,
);
AppLogger.debug(
_tag,
'playbackInfo prefetch request local '
'${describePlaybackInfoPrefetchKey(key)}',
);
unawaited(ref.read(playbackInfoPrefetchProvider(key).future));
}
void _prefetchPlaybackInfoForCrossServer(CrossServerSourceCard card) {
if (card.serverId.isEmpty ||
card.landingItemId.isEmpty ||
card.item.Type == 'Series') {
return;
}
final key = (
serverId: card.serverId,
itemId: card.landingItemId,
mediaSourceId: card.mediaSourceId.isEmpty ? null : card.mediaSourceId,
startTimeTicks: _prefetchStartTimeTicksForCrossServer(card),
);
AppLogger.debug(
_tag,
'playbackInfo prefetch request crossServer '
'${describePlaybackInfoPrefetchKey(key)}',
);
unawaited(ref.read(playbackInfoPrefetchProvider(key).future));
}
PlaybackInfoPrefetchKey _playbackInfoPrefetchKeyFor(
EmbyRawItem item, {
String? mediaSourceId,
required bool fromStart,
}) {
return (
serverId: _detailServerId ?? '',
itemId: item.Id,
mediaSourceId: mediaSourceId,
startTimeTicks: fromStart
? 0
: (item.UserData?.PlaybackPositionTicks ?? 0),
);
}
String? _currentLocalMediaSourceIdFor(EmbyRawItem item) {
if (_selectedCrossServerCard != null) return null;
final priorities =
ref.read(versionPriorityProvider).value?.activePriorities ?? [];
final sources = sortMediaSources(mediaSourcesOfItem(item), priorities);
if (_selectedSourceIdx < 0 || _selectedSourceIdx >= sources.length) {
return null;
}
return sources[_selectedSourceIdx].Id;
}
Future<void> _loadRememberedVersion(EmbyRawItem item) async {
final serverId = _detailServerId ?? '';
if (serverId.isEmpty || item.Id.isEmpty || item.Type == 'Series') return;
try {
final remembered = await ref
.read(lastPlayedVersionStoreProvider)
.get(serverId: serverId, itemId: item.Id);
if (!mounted || remembered == null) return;
if (_selectedItem?.Id != item.Id) return;
if (_selectedSourceIdx != 0) return;
setState(() => _pendingMediaSourceId = remembered);
} catch (_) {}
}
void _refreshSelectedItemState(EmbyRawItem item) {
setState(() {
_selectedItem = item;
_isFavorite = item.UserData?.IsFavorite ?? false;
_isPlayed = item.UserData?.Played ?? false;
});
}
Future<void> _toggleFavorite() async {
final item = _selectedItem;
if (item == null) return;
final next = !_isFavorite;
final uc = await ref.read(setFavoriteUseCaseProvider.future);
final res = await uc.execute(
FavoriteSetReq(itemId: item.Id, isFavorite: next),
);
if (!mounted) return;
setState(() => _isFavorite = res.isFavorite);
final serverId = _detailServerId;
if (serverId != null && serverId.isNotEmpty) {
ref.invalidate(
mediaDetailDataProvider((serverId: serverId, itemId: item.Id)),
);
}
}
Future<void> _togglePlayed() async {
final item = _selectedItem;
if (item == null) return;
final next = !_isPlayed;
final uc = await ref.read(setPlayedUseCaseProvider.future);
final res = await uc.execute(PlayedSetReq(itemId: item.Id, played: next));
if (!mounted) return;
setState(() => _isPlayed = res.played);
unawaited(_syncTraktPlayedChange(item, res.played));
final serverId = _detailServerId;
if (serverId != null && serverId.isNotEmpty) {
ref.invalidate(
mediaDetailDataProvider((serverId: serverId, itemId: item.Id)),
);
}
}
Future<void> _syncTraktPlayedChange(EmbyRawItem item, bool played) async {
try {
final service = await ref.read(traktSyncServiceProvider.future);
await service.syncEmbyPlayedChange(
item: item,
played: played,
seriesItem: _tmdbSeriesItem,
);
} catch (_) {}
}
Future<void> _goBack() async {
if (_backInFlight || _isRestoringOriginServer) return;
_backInFlight = true;
final currentServerId = ref.read(activeSessionProvider)?.serverId;
if (_originServerId != null && currentServerId != _originServerId) {
setState(() => _isRestoringOriginServer = true);
try {
await ref
.read(sessionProvider.notifier)
.switchSession(_originServerId!);
} catch (_) {
if (!mounted) return;
setState(() => _isRestoringOriginServer = false);
_backInFlight = false;
showAppSnackBar(context, '恢复原服务器失败', tone: AppSnackTone.error);
return;
}
}
if (!mounted) return;
if (_isRestoringOriginServer) {
setState(() => _isRestoringOriginServer = false);
}
if (context.canPop()) {
context.pop();
} else {
context.go('/');
}
}
Future<void> _launchPlayer(
EmbyRawItem displayItem,
List<EmbyRawMediaSource> sources, {
bool fromStart = false,
int? overrideSubtitleIdx,
String? backdropUrl,
}) async {
if (_isLaunchingPlayer) return;
setState(() => _isLaunchingPlayer = true);
try {
final crossServerCard = _selectedCrossServerCard;
final selectedSrc = _selectedSourceIdx < sources.length
? sources[_selectedSourceIdx]
: null;
final subIdx = overrideSubtitleIdx ?? _selectedSubtitleIdx;
final audioIdx = _selectedAudioIdx;
final streamSelection = resolveStreamSelection(
selectedSrc,
subIdx,
audioIdx,
);
final subStreamIndex = streamSelection.preferredSubtitleStreamIndex;
final audioStreamIndex = streamSelection.preferredAudioStreamIndex;
final crossServerMediaSourceId =
crossServerCard?.mediaSourceId.isEmpty ?? true
? null
: crossServerCard?.mediaSourceId;
final playedItemId = crossServerCard?.landingItemId ?? displayItem.Id;
final playServerId = crossServerCard?.serverId ?? _detailServerId;
final playMediaSourceId = crossServerCard != null
? crossServerMediaSourceId
: selectedSrc?.Id;
final playStartTicks = crossServerCard != null
? _prefetchStartTimeTicksForCrossServer(crossServerCard)
: (displayItem.UserData?.PlaybackPositionTicks ?? 0);
final effectiveFromStart = crossServerCard != null
? (fromStart || playStartTicks <= 0)
: fromStart;
AppLogger.debug(
_tag,
'_launchPlayer displayItem.Id=$playedItemId name=${displayItem.Name} '
'type=${displayItem.Type} selectedSourceIdx=$_selectedSourceIdx '
'serverId=$playServerId crossServer=${crossServerCard != null} '
'selectedSrc.Id=${selectedSrc?.Id} sources=${sources.length} '
'subIdx=$subIdx audioIdx=$audioIdx '
'subStreamIndex=$subStreamIndex audioStreamIndex=$audioStreamIndex '
'fromStart=$effectiveFromStart '
'positionTicks=$playStartTicks',
);
Future<PlayerExitResult?> doLaunch() => PlayerLauncher.launch(
context: context,
itemId: playedItemId,
serverId: playServerId?.isEmpty ?? true ? null : playServerId,
mediaSourceId: playMediaSourceId,
preferredSubtitleStreamIndex: crossServerCard != null
? -1
: subStreamIndex,
preferredAudioStreamIndex: crossServerCard != null
? null
: audioStreamIndex,
startPositionTicks: effectiveFromStart ? null : playStartTicks,
fromStart: effectiveFromStart,
backdropUrl: backdropUrl,
);
final PlayerExitResult? exitResult;
if (crossServerCard != null) {
exitResult = await _withCrossServerSession(
crossServerCard,
action: doLaunch,
);
} else {
exitResult = await doLaunch();
}
final positionTicks = exitResult?.positionTicks;
if (mounted && positionTicks != null && positionTicks > 0) {
final item = _selectedItem;
if (item != null && item.Id == exitResult?.itemId) {
final userData = (item.UserData ?? const EmbyRawUserData()).copyWith(
PlaybackPositionTicks: positionTicks,
);
_refreshSelectedItemState(item.copyWith(UserData: userData));
final exitSourceId = exitResult?.mediaSourceId;
if (exitSourceId != null && exitSourceId.isNotEmpty) {
final idx = sources.indexWhere((s) => s.Id == exitSourceId);
if (idx != -1 && idx != _selectedSourceIdx) {
_selectSourceIndex(idx);
}
}
}
}
unawaited(
_refreshSelectedItemProgress(
displayItem.Id,
optimisticTicks: positionTicks,
),
);
} finally {
if (mounted && _isLaunchingPlayer) {
setState(() => _isLaunchingPlayer = false);
}
}
}
Future<T> _withCrossServerSession<T>(
CrossServerSourceCard card, {
required Future<T> Function() action,
}) async {
_crossServerPlaybackInFlight = true;
final detailServerId = _detailServerId;
try {
if (ref.read(activeSessionProvider)?.serverId != card.serverId) {
await ref.read(sessionProvider.notifier).switchSession(card.serverId);
}
return await action();
} finally {
if (mounted &&
detailServerId != null &&
detailServerId.isNotEmpty &&
ref.read(activeSessionProvider)?.serverId != detailServerId) {
try {
await ref
.read(sessionProvider.notifier)
.switchSession(detailServerId);
} catch (_) {}
}
if (mounted) _crossServerPlaybackInFlight = false;
}
}
Future<void> _refreshSelectedItemProgress(
String itemId, {
int? optimisticTicks,
}) async {
if (!mounted) return;
try {
final capturedServerId = _detailServerId ?? '';
final uc = await ref.read(mediaDetailUseCaseProvider.future);
if (!mounted) return;
final res = await uc.execute(MediaDetailReq(itemId: itemId));
final prev = _selectedItem;
if (!mounted || prev?.Id != itemId) return;
var merged = prev != null
? mergeSelectedEpisodeDetail(detail: res.base, listItem: prev)
: res.base;
if (optimisticTicks != null &&
optimisticTicks > 0 &&
merged.UserData?.Played != true) {
final serverTicks = merged.UserData?.PlaybackPositionTicks ?? 0;
if (optimisticTicks > serverTicks) {
merged = merged.copyWith(
UserData: (merged.UserData ?? const EmbyRawUserData()).copyWith(
PlaybackPositionTicks: optimisticTicks,
),
);
}
}
_refreshSelectedItemState(merged);
if (capturedServerId.isNotEmpty) {
ref.invalidate(
mediaDetailDataProvider((serverId: capturedServerId, itemId: itemId)),
);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final session = ref.watch(activeSessionProvider);
if (session == null) return const SizedBox.shrink();
_syncDetailWithActiveServer(session.serverId);
final imageHeaders = ref.watch(embyImageHeadersProvider).value;
final priorities =
ref.watch(versionPriorityProvider).value?.activePriorities ?? [];
Widget buildBody(AsyncSnapshot<MediaDetailRes> snap) {
if (Platform.isAndroid) {
return EmbyDetailBodyAndroid(
snap: snap,
detailData: _data,
seedItem: widget.seedItem,
selectedItem: _selectedItem,
tmdbSeriesItem: _tmdbSeriesItem,
detailServerId: _detailServerId,
initialSeasonId: widget.initialSeasonId,
pendingMediaSourceId: _pendingMediaSourceId,
scrollController: _scrollController,
scrollProgress: _scrollProgress,
imageHeaders: imageHeaders,
session: session,
priorities: priorities,
selectedSourceIdx: _selectedSourceIdx,
selectedSubtitleIdx: _selectedSubtitleIdx,
selectedAudioIdx: _selectedAudioIdx,
selectedCrossServerCard: _selectedCrossServerCard,
isPlayed: _isPlayed,
isFavorite: _isFavorite,
isLaunchingPlayer: _isLaunchingPlayer,
onBack: () => unawaited(_goBack()),
onRetry: () => _reload(invalidateCache: true),
onTogglePlayed: () => unawaited(_togglePlayed()),
onToggleFavorite: () => unawaited(_toggleFavorite()),
onSelectSourceIndex: _selectSourceIndex,
onSelectSubtitleIndex: _selectSubtitleIndex,
onSelectAudioIndex: _selectAudioIndex,
onEpisodeTap: _onEpisodeTap,
onSelectCrossServerSource: _selectCrossServerSource,
onApplyPendingMediaSource: _applyPendingMediaSource,
onClearPendingMediaSource: _clearPendingMediaSource,
onLaunchPlayer: _launchPlayer,
);
}
return buildEmbyDetailBody(
context: context,
ref: ref,
snap: snap,
detailData: _data,
seedItem: widget.seedItem,
selectedItem: _selectedItem,
tmdbSeriesItem: _tmdbSeriesItem,
detailServerId: _detailServerId,
initialSeasonId: widget.initialSeasonId,
pendingMediaSourceId: _pendingMediaSourceId,
scrollController: _scrollController,
scrollProgress: _scrollProgress,
imageHeaders: imageHeaders,
session: session,
priorities: priorities,
selectedSourceIdx: _selectedSourceIdx,
selectedSubtitleIdx: _selectedSubtitleIdx,
selectedAudioIdx: _selectedAudioIdx,
selectedCrossServerCard: _selectedCrossServerCard,
isPlayed: _isPlayed,
isFavorite: _isFavorite,
isLaunchingPlayer: _isLaunchingPlayer,
onBack: () => unawaited(_goBack()),
onRetry: () => _reload(invalidateCache: true),
onTogglePlayed: () => unawaited(_togglePlayed()),
onToggleFavorite: () => unawaited(_toggleFavorite()),
onSelectSourceIndex: _selectSourceIndex,
onSelectSubtitleIndex: _selectSubtitleIndex,
onSelectAudioIndex: _selectAudioIndex,
onEpisodeTap: _onEpisodeTap,
onSelectCrossServerSource: _selectCrossServerSource,
onApplyPendingMediaSource: _applyPendingMediaSource,
onClearPendingMediaSource: _clearPendingMediaSource,
onLaunchPlayer: _launchPlayer,
);
}
final content = FutureBuilder<MediaDetailRes>(
future: _detailFuture,
builder: (_, snap) => buildBody(snap),
);
if (Platform.isAndroid) {
return Theme(data: AppTheme.dark(), child: content);
}
return Theme(
data: AppTheme.dark(),
child: Scaffold(backgroundColor: Colors.black, body: content),
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/widgets.dart';
class DetailHeroModel {
final String title;
final String? logoUrl;
final String? posterUrl;
final double? rating;
final List<String> metaParts;
final List<String> genres;
final String? tagline;
final String? overview;
final Widget? actions;
final Map<String, String>? imageHeaders;
const DetailHeroModel({
required this.title,
this.logoUrl,
this.posterUrl,
this.rating,
this.metaParts = const <String>[],
this.genres = const <String>[],
this.tagline,
this.overview,
this.actions,
this.imageHeaders,
});
}
@@ -0,0 +1,15 @@
class DetailImageModel {
final String? backdropUrl;
final List<String> fallbackUrls;
final String embyBaseUrl;
final Map<String, String>? imageHeaders;
const DetailImageModel({
this.backdropUrl,
this.fallbackUrls = const <String>[],
this.embyBaseUrl = '',
this.imageHeaders,
});
}
@@ -0,0 +1,9 @@
import 'package:flutter/widgets.dart';
class DetailSection {
final String id;
final Widget child;
const DetailSection({required this.id, required this.child});
}
@@ -0,0 +1,29 @@
import 'detail_hero_model.dart';
import 'detail_image_model.dart';
import 'detail_section_model.dart';
class DetailViewState {
final DetailImageModel image;
final String navTitle;
final DetailHeroModel hero;
final List<DetailSection> sections;
final bool showLoadingBelowHero;
const DetailViewState({
required this.image,
required this.navTitle,
required this.hero,
this.sections = const <DetailSection>[],
this.showLoadingBelowHero = false,
});
}
@@ -0,0 +1,34 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/domain/usecases/series_resume_target.dart';
class EpisodeAutoSelectArgs {
final bool autoSelectFirstEpisode;
final String? autoSelectEpisodeId;
final String? autoSelectSeasonId;
const EpisodeAutoSelectArgs({
required this.autoSelectFirstEpisode,
required this.autoSelectEpisodeId,
required this.autoSelectSeasonId,
});
factory EpisodeAutoSelectArgs.fromResume(
AsyncValue<SeriesResumeTarget?>? resumeAsync,
) {
final loading = resumeAsync?.isLoading ?? false;
final target = resumeAsync?.value;
return EpisodeAutoSelectArgs(
autoSelectFirstEpisode: !loading,
autoSelectEpisodeId: target?.episodeId,
autoSelectSeasonId: target?.seasonId,
);
}
}
@@ -0,0 +1,38 @@
import '../../../core/contracts/library.dart';
EmbyRawItem mergeSelectedEpisodeDetail({
required EmbyRawItem detail,
required EmbyRawItem listItem,
}) {
final mergedRunTime = detail.RunTimeTicks ?? listItem.RunTimeTicks;
final mergedUserData = _mergeUserData(detail.UserData, listItem.UserData);
if (mergedRunTime == detail.RunTimeTicks &&
identical(mergedUserData, detail.UserData)) {
return detail;
}
return detail.copyWith(RunTimeTicks: mergedRunTime, UserData: mergedUserData);
}
EmbyRawUserData? _mergeUserData(
EmbyRawUserData? detail,
EmbyRawUserData? listItem,
) {
if (listItem == null) return detail;
if (detail == null) return listItem;
if (detail.PlaybackPositionTicks == null &&
listItem.PlaybackPositionTicks != null) {
return detail.copyWith(
PlaybackPositionTicks: listItem.PlaybackPositionTicks,
);
}
return detail;
}
EmbyRawItem applyPlaybackReturnProgress(EmbyRawItem item, int? positionTicks) {
if (positionTicks == null || positionTicks <= 0) return item;
final userData = (item.UserData ?? const EmbyRawUserData()).copyWith(
PlaybackPositionTicks: positionTicks,
);
return item.copyWith(UserData: userData);
}
@@ -0,0 +1,82 @@
import '../../../core/contracts/library.dart';
import '../../../core/infra/emby_api/playback_resolver.dart';
class StreamSelection {
final List<EmbyRawMediaStream> subtitles;
final List<EmbyRawMediaStream> audios;
final int preferredSubtitleStreamIndex;
final int? preferredAudioStreamIndex;
const StreamSelection({
required this.subtitles,
required this.audios,
required this.preferredSubtitleStreamIndex,
required this.preferredAudioStreamIndex,
});
}
EmbyRawMediaSource? resolveActiveMediaSource(
CrossServerSourceCard? selectedCrossServerCard,
List<EmbyRawMediaSource> localSources,
int selectedSourceIdx,
) {
return selectedCrossServerCard?.mediaSource ??
(selectedSourceIdx < localSources.length
? localSources[selectedSourceIdx]
: null);
}
StreamSelection resolveStreamSelection(
EmbyRawMediaSource? source,
int? selectedSubtitleIdx,
int selectedAudioIdx,
) {
final subtitles = (source?.MediaStreams ?? [])
.where((s) => s.Type == 'Subtitle')
.where((s) {
final isExternal =
(s.IsExternal ?? false) ||
s.DeliveryMethod == 'External' ||
(s.DeliveryUrl != null && s.DeliveryUrl!.isNotEmpty);
if (!isExternal) return true;
return PlaybackResolver.isTextSubtitleCodec(s.Codec);
})
.toList();
final audios = (source?.MediaStreams ?? [])
.where((s) => s.Type == 'Audio')
.toList();
final preferredSubtitleStreamIndex =
selectedSubtitleIdx != null && selectedSubtitleIdx < subtitles.length
? subtitles[selectedSubtitleIdx].Index ?? -1
: -1;
final preferredAudioStreamIndex = selectedAudioIdx < audios.length
? audios[selectedAudioIdx].Index
: null;
return StreamSelection(
subtitles: subtitles,
audios: audios,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
);
}
int resolveDefaultSourceIndex(
List<EmbyRawMediaSource> sortedSources,
String? rememberedMediaSourceId, {
int fallback = 0,
}) {
if (rememberedMediaSourceId == null) return fallback;
final idx = sortedSources.indexWhere((s) => s.Id == rememberedMediaSourceId);
return idx >= 0 ? idx : fallback;
}
@@ -0,0 +1,239 @@
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 '../../core/contracts/tmdb.dart';
import '../../providers/detail_palette_provider.dart';
import '../../shared/mappers/tmdb_image_selector.dart';
import '../../shared/mappers/tmdb_image_url.dart';
import '../../shared/utils/format_utils.dart';
import '../../shared/utils/tmdb_cta_model.dart';
import 'utils/detail_helpers.dart';
import 'android/android_detail_layout.dart';
import 'android/android_detail_colors.dart';
import 'android/android_external_links_section.dart';
import 'android/android_hero_actions.dart';
import 'android/android_hero_info.dart';
import 'android/android_overview_section.dart';
import 'android/android_section_divider.dart';
import 'android/android_stream_selector_row.dart';
import 'detail_entry.dart';
import 'model/detail_section_model.dart';
import 'widgets/tmdb_cast_section.dart';
import 'widgets/tmdb_recommendation_section.dart';
class TmdbDetailBodyAndroid extends ConsumerWidget {
const TmdbDetailBodyAndroid({
super.key,
required this.entry,
required this.enriched,
required this.imageBaseUrl,
required this.language,
required this.showSpinner,
required this.scrollController,
required this.scrollProgress,
required this.primaryHit,
required this.availabilityResolving,
required this.isFavorite,
required this.isPlayed,
required this.subtitles,
required this.audios,
required this.selectedSubtitleIdx,
required this.selectedAudioIdx,
required this.onPlay,
required this.onReplay,
required this.onToggleFavorite,
required this.onTogglePlayed,
required this.onSelectSubtitleIndex,
required this.onSelectAudioIndex,
required this.hitCount,
required this.hitStateSections,
});
final TmdbDetailEntry entry;
final TmdbEnrichedDetail? enriched;
final String imageBaseUrl;
final String language;
final bool showSpinner;
final ScrollController scrollController;
final ValueNotifier<double> scrollProgress;
final TmdbLibraryHit? primaryHit;
final bool availabilityResolving;
final bool isFavorite;
final bool isPlayed;
final List<EmbyRawMediaStream> subtitles;
final List<EmbyRawMediaStream> audios;
final int? selectedSubtitleIdx;
final int selectedAudioIdx;
final VoidCallback onPlay;
final VoidCallback onReplay;
final VoidCallback onToggleFavorite;
final VoidCallback onTogglePlayed;
final ValueChanged<int?> onSelectSubtitleIndex;
final ValueChanged<int> onSelectAudioIndex;
final int hitCount;
final List<DetailSection> hitStateSections;
@override
Widget build(BuildContext context, WidgetRef ref) {
final mediaType = entry.mediaType;
final detail = enriched?.detail;
final title = (detail?.title.isNotEmpty ?? false)
? detail!.title
: (entry.seed?.title ?? '');
final posterPath = detail?.posterPath ?? entry.seed?.posterPath;
final backdropPath = detail?.backdropPath ?? entry.seed?.backdropPath;
final posterUrl = TmdbImageUrl.poster(imageBaseUrl, posterPath);
final backdropUrl = TmdbImageUrl.backdrop(imageBaseUrl, backdropPath);
final fallbackUrls = TmdbImageSelector.buildFallbackUrls(
primaryUrl: backdropUrl,
candidates: [posterUrl],
);
final paletteImageUrl = posterUrl ?? backdropUrl;
final extractedBackgroundColor = paletteImageUrl == null
? null
: ref
.watch(
detailPaletteProvider(
DetailPaletteRequest(url: paletteImageUrl),
),
)
.value;
final detailBackgroundColor =
extractedBackgroundColor ?? AndroidDetailColors.background;
final metaParts = <String>[];
final year = yearOf(detail?.releaseDate);
if (year != null) metaParts.add(year);
final runtime = formatRuntimeMinutes(detail?.runtime);
if (runtime != null) metaParts.add(runtime);
final genreNames = [
for (final g in detail?.genres ?? const <TmdbGenre>[])
if (g.name.isNotEmpty) g.name,
];
final overview = (detail?.overview?.trim().isNotEmpty ?? false)
? detail!.overview!.trim()
: (entry.seed?.overview?.trim() ?? '');
final voteAverage = detail?.voteAverage ?? entry.seed?.voteAverage;
String? playLabel;
bool showReplay = false;
double? progressPercent;
String? positionClock;
bool canPlay = false;
if (!availabilityResolving) {
final ctaLabel = buildTmdbCtaLabel(
mediaType: mediaType,
primaryHit: primaryHit,
hitCount: hitCount,
);
if (ctaLabel != null && primaryHit != null) {
canPlay = true;
playLabel = ctaLabel;
final pos = primaryHit!.playbackPositionTicks ?? 0;
final rt = primaryHit!.runTimeTicks ?? 0;
final hasProgress = pos > 0;
progressPercent = (hasProgress && rt > 0)
? (pos / rt * 100).clamp(0.0, 100.0)
: null;
positionClock = hasProgress ? formatTicksAsClock(pos) : null;
showReplay = hasProgress;
}
}
final tmdbIdInt = detail?.id;
final imdbIdStr = enriched?.imdbId;
final cast = enriched?.credits?.cast ?? const <TmdbCastMember>[];
final recommendations =
enriched?.recommendations?.results ?? const <TmdbRecommendation>[];
final sectionWidgets = <Widget>[
for (final section in hitStateSections)
if (section.id != 'tmdb_movie_divider' &&
section.id != 'tmdb_tv_divider')
section.child,
];
sectionWidgets.add(
TmdbCastSection(
cast: cast,
imageBaseUrl: imageBaseUrl,
leading: const AndroidSectionDivider(),
),
);
sectionWidgets.add(
TmdbRecommendationSection(
items: recommendations,
imageBaseUrl: imageBaseUrl,
fallbackMediaType: mediaType,
leading: const AndroidSectionDivider(),
),
);
sectionWidgets.add(
ExternalLinksSection(
imdbId: imdbIdStr,
tmdbId: tmdbIdInt,
tmdbMediaType: mediaType,
leading: const AndroidSectionDivider(),
),
);
return AndroidDetailLayout(
scrollController: scrollController,
scrollProgress: scrollProgress,
backdropUrl: backdropUrl,
fallbackUrls: fallbackUrls,
backgroundColor: detailBackgroundColor,
navigationColor: createDetailNavigationColor(detailBackgroundColor),
title: title,
onBack: () => context.pop(),
heroInfo: AndroidHeroInfo(
posterUrl: posterUrl,
title: title,
rating: voteAverage,
metaParts: metaParts,
genres: genreNames,
),
heroActions: Column(
mainAxisSize: MainAxisSize.min,
children: [
AndroidHeroActions(
playLabel: playLabel ?? '播放',
playTrailingLabel: positionClock,
progressPercent: progressPercent,
isLoading: availabilityResolving,
loadingLabel: '检测中',
canPlay: canPlay || availabilityResolving,
onPlay: canPlay ? onPlay : null,
showReplay: showReplay,
onReplay: onReplay,
isPlayed: isPlayed,
onTogglePlayed: onTogglePlayed,
isFavorite: isFavorite,
onToggleFavorite: onToggleFavorite,
),
if (canPlay)
AndroidStreamSelectorRow(
subtitles: subtitles,
audios: audios,
selectedSubtitleIdx: selectedSubtitleIdx,
selectedAudioIdx: selectedAudioIdx,
onSelectSubtitleIndex: onSelectSubtitleIndex,
onSelectAudioIndex: onSelectAudioIndex,
),
],
),
overview: overview.isNotEmpty
? AndroidOverviewSection(overview: overview)
: null,
sections: sectionWidgets,
);
}
}
+904
View File
@@ -0,0 +1,904 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:forui/forui.dart';
import 'package:go_router/go_router.dart';
import '../../core/contracts/library.dart';
import '../../core/contracts/tmdb.dart';
import '../../providers/di_providers.dart';
import '../../providers/last_played_version_store.dart';
import '../../providers/session_provider.dart';
import '../../providers/tmdb_settings_provider.dart';
import '../../providers/version_priority_provider.dart';
import '../../shared/constants/breakpoints.dart';
import '../../shared/mappers/tmdb_image_url.dart';
import '../../shared/theme/app_theme.dart';
import '../../shared/utils/cross_server_search_key.dart';
import '../../shared/utils/format_utils.dart';
import '../../shared/utils/media_nav.dart';
import '../player/player_launcher.dart';
import '../../shared/utils/tmdb_cta_model.dart';
import '../../shared/utils/tmdb_primary_hit.dart';
import '../../shared/widgets/app_error_state.dart';
import '../../shared/widgets/app_skeleton.dart';
import '../../shared/widgets/empty_state.dart';
import 'widgets/additional_parts_section.dart';
import 'widgets/episode_list_section.dart';
import 'adapter/tmdb_detail_view_builder.dart';
import 'android/android_detail_geometry.dart';
import 'detail_entry.dart';
import 'model/detail_section_model.dart';
import 'model/episode_auto_select_args.dart';
import 'model/stream_selection.dart';
import 'tmdb_detail_body_android.dart';
import 'widgets/detail_immersive_scaffold.dart';
import 'widgets/detail_nav_bar.dart';
import 'widgets/detail_scroll_progress.dart';
import 'widgets/detail_view_renderer.dart';
import 'widgets/hero_action_buttons.dart';
import 'widgets/source_selection_section.dart';
import 'widgets/stream_selector_actions.dart';
const Color _kBg = Color(0xFF08090c);
const double _kHPad = 32.0;
class TmdbDetailView extends ConsumerStatefulWidget {
final TmdbDetailEntry entry;
const TmdbDetailView({super.key, required this.entry});
@override
ConsumerState<TmdbDetailView> createState() => _TmdbDetailViewState();
}
class _TmdbDetailViewState extends ConsumerState<TmdbDetailView> {
static const _sectionDivider = FDivider(
style: .delta(
color: Color(0x1AFFFFFF),
padding: .value(EdgeInsets.only(top: 24)),
),
);
final _scroll = ScrollController();
final _scrollProgress = ValueNotifier<double>(0);
bool? _favoriteOverride;
bool? _playedOverride;
String? _overrideHitKey;
int _selectedSourceIdx = 0;
CrossServerSourceCard? _selectedCrossServerCard;
EmbyRawItem? _selectedTvEpisode;
int? _selectedTvEpisodeSeasonNumber;
String? _selectedTvEpisodeHitKey;
int? _selectedSubtitleIdx;
int _selectedAudioIdx = 0;
String? _pendingMediaSourceId;
String? _versionMemoryQueriedKey;
String? _playbackOriginServerId;
int? _positionTicksOverride;
String? _positionOverrideItemKey;
int? _positionOverrideFor(String? serverId, String? itemId) {
if (_positionTicksOverride == null) return null;
if (_positionOverrideItemKey != '$serverId|$itemId') return null;
return _positionTicksOverride;
}
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
}
void _onScroll() {
_scrollProgress.value = computeDetailScrollProgress(
context,
_scroll.offset,
collapseExtent: Platform.isAndroid
? computeAndroidDetailCollapseExtent(context)
: null,
);
}
@override
void didUpdateWidget(covariant TmdbDetailView oldWidget) {
super.didUpdateWidget(oldWidget);
final old = oldWidget.entry;
final next = widget.entry;
if (old.mediaType != next.mediaType || old.tmdbId != next.tmdbId) {
setState(() {
_favoriteOverride = null;
_playedOverride = null;
_overrideHitKey = null;
_selectedSourceIdx = 0;
_selectedCrossServerCard = null;
_selectedTvEpisode = null;
_selectedTvEpisodeSeasonNumber = null;
_selectedTvEpisodeHitKey = null;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_pendingMediaSourceId = null;
_versionMemoryQueriedKey = null;
_positionTicksOverride = null;
_positionOverrideItemKey = null;
});
_scrollProgress.value = 0;
if (_scroll.hasClients) _scroll.jumpTo(0);
}
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
_scrollProgress.dispose();
super.dispose();
}
void _selectLocalSource(int index) {
setState(() {
_selectedSourceIdx = index;
_selectedCrossServerCard = null;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_pendingMediaSourceId = null;
});
}
void _selectCrossServerSource(CrossServerSourceCard card) {
setState(() {
_selectedCrossServerCard = card;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
_pendingMediaSourceId = null;
});
}
void _selectTvEpisode(EmbyRawItem episode, int? seasonNumber, String hitKey) {
setState(() {
_selectedTvEpisode = episode;
_selectedTvEpisodeSeasonNumber = seasonNumber;
_selectedTvEpisodeHitKey = hitKey;
_selectedSourceIdx = 0;
_selectedCrossServerCard = null;
_selectedSubtitleIdx = null;
_selectedAudioIdx = 0;
});
}
Future<void> _playRestoringActiveServer({
required String serverId,
required String itemId,
String? mediaSourceId,
int preferredSubtitleStreamIndex = -1,
int? preferredAudioStreamIndex,
bool fromStart = false,
int? startPositionTicks,
String? backdropUrl,
}) async {
final originServerId = ref.read(activeSessionProvider)?.serverId;
setState(() => _playbackOriginServerId = originServerId);
PlayerExitResult? exitResult;
try {
exitResult = await playFromTmdbOnServer(
context,
ref,
serverId: serverId,
itemId: itemId,
mediaSourceId: mediaSourceId,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
startPositionTicks: startPositionTicks,
fromStart: fromStart,
backdropUrl: backdropUrl,
);
} finally {
if (mounted) {
if (originServerId != null &&
originServerId.isNotEmpty &&
ref.read(activeSessionProvider)?.serverId != originServerId) {
try {
await ref
.read(sessionProvider.notifier)
.switchSession(originServerId);
} catch (_) {}
}
if (mounted) setState(() => _playbackOriginServerId = null);
}
}
final positionTicks = exitResult?.positionTicks;
if (!mounted || positionTicks == null || positionTicks <= 0) return;
final exitServerId = exitResult!.serverId;
final exitItemId = exitResult.itemId;
setState(() {
_positionTicksOverride = positionTicks;
_positionOverrideItemKey = '$exitServerId|$exitItemId';
final ep = _selectedTvEpisode;
if (ep != null && ep.Id == exitItemId) {
_selectedTvEpisode = ep.copyWith(
UserData: (ep.UserData ?? const EmbyRawUserData()).copyWith(
PlaybackPositionTicks: positionTicks,
),
);
}
});
}
void _selectSubtitleIndex(int? index) {
setState(() => _selectedSubtitleIdx = index);
}
void _selectAudioIndex(int index) {
setState(() => _selectedAudioIdx = index);
}
static String _hitKeyOf(TmdbLibraryHit hit) =>
'${hit.serverId}|${hit.itemId}';
Future<void> _toggleTmdbFavorite(TmdbLibraryHit hit, bool current) async {
final next = !current;
setState(() {
_favoriteOverride = next;
_overrideHitKey = _hitKeyOf(hit);
});
try {
final uc = await ref.read(setFavoriteUseCaseProvider.future);
final res = await uc.executeForServer(
serverId: hit.serverId,
input: FavoriteSetReq(itemId: hit.itemId, isFavorite: next),
);
if (!mounted) return;
setState(() => _favoriteOverride = res.isFavorite);
ref.invalidate(
tmdbServerScopedDetailProvider((
serverId: hit.serverId,
itemId: hit.itemId,
)),
);
} catch (_) {
if (!mounted) return;
setState(() => _favoriteOverride = current);
}
}
Future<void> _toggleTmdbPlayed(TmdbLibraryHit hit, bool current) async {
final next = !current;
setState(() {
_playedOverride = next;
_overrideHitKey = _hitKeyOf(hit);
});
try {
final uc = await ref.read(setPlayedUseCaseProvider.future);
final res = await uc.executeForServer(
serverId: hit.serverId,
input: PlayedSetReq(itemId: hit.itemId, played: next),
);
if (!mounted) return;
setState(() => _playedOverride = res.played);
ref.invalidate(
tmdbServerScopedDetailProvider((
serverId: hit.serverId,
itemId: hit.itemId,
)),
);
} catch (_) {
if (!mounted) return;
setState(() => _playedOverride = current);
}
}
@override
Widget build(BuildContext context) {
return _buildTmdbDetail(widget.entry);
}
Widget _buildTmdbDetail(TmdbDetailEntry entry) {
final key = (tmdbId: entry.tmdbId, mediaType: entry.mediaType);
final settings = ref.watch(tmdbSettingsProvider).value;
final imageBaseUrl = settings?.effectiveImageBaseUrl ?? '';
final language = settings?.language ?? 'zh-CN';
final asyncDetail = ref.watch(tmdbEnrichedDetailProvider(key));
if (Platform.isAndroid) {
return Theme(
data: AppTheme.dark(),
child: asyncDetail.when(
loading: () => _buildTmdbContent(
entry: entry,
enriched: null,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: true,
),
error: (_, _) => Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: AppErrorState(
title: '加载失败',
message: '无法获取该内容的 TMDB 详情',
onRetry: () => ref.invalidate(tmdbEnrichedDetailProvider(key)),
),
),
data: (enriched) {
if (enriched == null) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
),
body: const Center(
child: EmptyState(
icon: Icons.movie_filter_outlined,
title: '暂无详情',
message: '请检查 TMDB 配置后重试',
),
),
);
}
return _buildTmdbContent(
entry: entry,
enriched: enriched,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: false,
);
},
),
);
}
final detail = asyncDetail.value?.detail;
final title = (detail?.title.isNotEmpty ?? false)
? detail!.title
: (entry.seed?.title ?? '');
return Theme(
data: AppTheme.dark(),
child: Scaffold(
backgroundColor: _kBg,
body: Stack(
children: [
Positioned.fill(
child: asyncDetail.when(
loading: () => _buildTmdbContent(
entry: entry,
enriched: null,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: true,
),
error: (_, _) => AppErrorState(
title: '加载失败',
message: '无法获取该内容的 TMDB 详情',
onRetry: () =>
ref.invalidate(tmdbEnrichedDetailProvider(key)),
),
data: (enriched) {
if (enriched == null) {
return const Center(
child: EmptyState(
icon: Icons.movie_filter_outlined,
title: '暂无详情',
message: '请检查 TMDB 配置后重试',
),
);
}
return _buildTmdbContent(
entry: entry,
enriched: enriched,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: false,
);
},
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: DetailNavBar(
scrollProgress: _scrollProgress,
title: title,
onBack: () => context.pop(),
),
),
],
),
),
);
}
Widget _buildTmdbContent({
required TmdbDetailEntry entry,
required TmdbEnrichedDetail? enriched,
required String imageBaseUrl,
required String language,
required bool showSpinner,
}) {
final mediaType = entry.mediaType;
final tmdbId = entry.tmdbId;
final detail = enriched?.detail;
final title = (detail?.title.isNotEmpty ?? false)
? detail!.title
: (entry.seed?.title ?? '');
final imdbIdForLookup = mediaType == 'movie' ? enriched?.imdbId : null;
final detailBackdropUrl = TmdbImageUrl.backdrop(
imageBaseUrl,
detail?.backdropPath ?? entry.seed?.backdropPath,
);
final availabilityTmdbAsync = ref.watch(
tmdbLibraryAvailabilityProvider((
mediaType: mediaType,
tmdbId: tmdbId,
imdbId: '',
)),
);
final tmdbResultEmpty = availabilityTmdbAsync.value?.isEmpty == true;
final needsImdbFallback = imdbIdForLookup != null && tmdbResultEmpty;
final availabilityImdbAsync = needsImdbFallback
? ref.watch(
tmdbLibraryAvailabilityProvider((
mediaType: mediaType,
tmdbId: tmdbId,
imdbId: imdbIdForLookup,
)),
)
: null;
final availabilityAsync = availabilityImdbAsync ?? availabilityTmdbAsync;
final hits = availabilityAsync.value ?? const <TmdbLibraryHit>[];
final availabilityResolving =
(availabilityTmdbAsync.isLoading &&
availabilityTmdbAsync.value == null) ||
(availabilityImdbAsync?.isLoading == true &&
availabilityImdbAsync?.value == null) ||
(mediaType == 'movie' && enriched == null && tmdbResultEmpty);
final liveActiveServerId = ref.watch(activeSessionProvider)?.serverId;
final activeServerId = _playbackOriginServerId ?? liveActiveServerId;
final rawPrimaryHit = selectPrimaryTmdbHit(
hits,
activeServerId: activeServerId,
);
final tvRecentHit = (rawPrimaryHit != null && mediaType == 'tv')
? ref
.watch(
tmdbTvRecentHitProvider((
mediaType: mediaType,
tmdbId: tmdbId,
imdbId: imdbIdForLookup ?? '',
)),
)
.value
: null;
final primaryHit = tvRecentHit ?? rawPrimaryHit;
AsyncValue<MediaDetailRes>? scopedDetailAsync;
EmbyRawItem? serverScopedItem;
if (primaryHit != null) {
final scopedKey = (
serverId: primaryHit.serverId,
itemId: primaryHit.itemId,
);
scopedDetailAsync = ref.watch(tmdbServerScopedDetailProvider(scopedKey));
serverScopedItem = scopedDetailAsync?.value?.base;
}
if (_overrideHitKey != null &&
(primaryHit == null || _overrideHitKey != _hitKeyOf(primaryHit))) {
_favoriteOverride = null;
_playedOverride = null;
_overrideHitKey = null;
}
final isFavorite =
_favoriteOverride ?? (serverScopedItem?.UserData?.IsFavorite ?? false);
final isPlayed =
_playedOverride ?? (serverScopedItem?.UserData?.Played ?? false);
final isMovieHit = primaryHit != null && mediaType == 'movie';
final isTvHit = primaryHit != null && mediaType == 'tv';
final primaryHitKey = primaryHit == null ? null : _hitKeyOf(primaryHit);
final selectedTvEpisode =
isTvHit && _selectedTvEpisodeHitKey == primaryHitKey
? _selectedTvEpisode
: null;
final selectedTvEpisodeSeasonNumber = selectedTvEpisode == null
? null
: _selectedTvEpisodeSeasonNumber;
final tvAnchorForResume = isTvHit ? primaryHit : null;
final tvResumeAsync = tvAnchorForResume == null
? null
: ref.watch(
seriesResumeTargetProvider((
serverId: tvAnchorForResume.serverId,
seriesId: tvAnchorForResume.itemId,
)),
);
final tvAutoSelect = EpisodeAutoSelectArgs.fromResume(tvResumeAsync);
final priorities =
ref.watch(versionPriorityProvider).value?.activePriorities ??
const <VersionPriority>[];
final localSourceItem = isMovieHit ? serverScopedItem : selectedTvEpisode;
final sortedSources = localSourceItem != null
? sortMediaSources(mediaSourcesOfItem(localSourceItem), priorities)
: const <EmbyRawMediaSource>[];
final memServerId = primaryHit?.serverId;
final memItemId = localSourceItem?.Id;
if (memServerId != null &&
memServerId.isNotEmpty &&
memItemId != null &&
memItemId.isNotEmpty) {
final memKey = '$memServerId:$memItemId';
if (memKey != _versionMemoryQueriedKey) {
_versionMemoryQueriedKey = memKey;
final store = ref.read(lastPlayedVersionStoreProvider);
unawaited(
store
.get(serverId: memServerId, itemId: memItemId)
.then((id) {
if (!mounted || id == null) return;
if (_versionMemoryQueriedKey != memKey) return;
if (_selectedSourceIdx != 0) return;
setState(() => _pendingMediaSourceId = id);
})
.onError((e, s) {}),
);
}
}
final pendingId = _pendingMediaSourceId;
if (pendingId != null && sortedSources.isNotEmpty) {
final idx = resolveDefaultSourceIndex(
sortedSources,
pendingId,
fallback: -1,
);
if (idx >= 0 && idx != _selectedSourceIdx) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_selectedSourceIdx = idx;
_pendingMediaSourceId = null;
});
});
} else {
_pendingMediaSourceId = null;
}
}
final effectiveCrossServerCard = isTvHit && selectedTvEpisode == null
? null
: _selectedCrossServerCard;
final localSourceId =
effectiveCrossServerCard == null &&
_selectedSourceIdx < sortedSources.length
? sortedSources[_selectedSourceIdx].Id
: null;
final selectedMediaSource = resolveActiveMediaSource(
effectiveCrossServerCard,
sortedSources,
_selectedSourceIdx,
);
final streamSelection = resolveStreamSelection(
selectedMediaSource,
_selectedSubtitleIdx,
_selectedAudioIdx,
);
final subtitles = streamSelection.subtitles;
final audios = streamSelection.audios;
final preferredSubtitleStreamIndex =
streamSelection.preferredSubtitleStreamIndex;
final preferredAudioStreamIndex = streamSelection.preferredAudioStreamIndex;
void play(bool fromStart) {
final card = effectiveCrossServerCard;
if (card != null) {
final pos =
selectedTvEpisode?.UserData?.PlaybackPositionTicks ??
_positionOverrideFor(card.serverId, card.landingItemId) ??
_positionOverrideFor(primaryHit?.serverId, primaryHit?.itemId) ??
primaryHit?.playbackPositionTicks;
final resumeTicks = (!fromStart && pos != null && pos > 0) ? pos : null;
unawaited(
_playRestoringActiveServer(
serverId: card.serverId,
itemId: card.landingItemId,
mediaSourceId: card.mediaSourceId,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
startPositionTicks: resumeTicks,
fromStart: resumeTicks == null,
backdropUrl: detailBackdropUrl,
),
);
return;
}
if (primaryHit == null) return;
final localItemId = selectedTvEpisode?.Id ?? primaryHit.itemId;
final localResume = fromStart
? null
: _positionOverrideFor(primaryHit.serverId, localItemId);
unawaited(
_playRestoringActiveServer(
serverId: primaryHit.serverId,
itemId: localItemId,
mediaSourceId: localSourceId,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
preferredAudioStreamIndex: preferredAudioStreamIndex,
startPositionTicks: localResume,
fromStart: fromStart,
backdropUrl: detailBackdropUrl,
),
);
}
final imdb = imdbIdForLookup?.trim() ?? '';
final crossServerReq = CrossServerSearchReq(
anchorType: 'Movie',
itemName: title,
providerIds: {'Tmdb': tmdbId, if (imdb.isNotEmpty) 'Imdb': imdb},
);
final tvEpisodeReq = selectedTvEpisode == null || serverScopedItem == null
? null
: buildEpisodeCrossServerReq(
episode: selectedTvEpisode,
seriesItem: serverScopedItem,
seasonNumberOverride: selectedTvEpisodeSeasonNumber,
);
final hitStateSections = <DetailSection>[
if (isMovieHit) ...[
DetailSection(
id: 'tmdb_movie_source',
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
child: SourceSelectionSection(
req: crossServerReq,
localSources: sortedSources,
selectedSourceIdx: _selectedSourceIdx,
selectedCrossServerCard: effectiveCrossServerCard,
currentServerName: primaryHit.serverName,
excludedServerIdOverride: primaryHit.serverId,
localMediaInfoItem: localSourceItem,
showLoadingWhenEmpty: true,
externalLoading: scopedDetailAsync?.isLoading ?? false,
loadingText: '正在查找可用版本…',
onLocalSourceTap: (src, index) => _selectLocalSource(index),
onCrossServerSourceTap: _selectCrossServerSource,
),
),
),
DetailSection(
id: 'tmdb_movie_additional_parts',
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
child: AdditionalPartsSection(
itemId: primaryHit.itemId,
embedded: true,
serverId: primaryHit.serverId,
onItemTap: (item) => unawaited(
_playRestoringActiveServer(
serverId: primaryHit.serverId,
itemId: item.Id,
backdropUrl: detailBackdropUrl,
),
),
),
),
),
const DetailSection(id: 'tmdb_movie_divider', child: _sectionDivider),
],
if (isTvHit) ...[
DetailSection(
id: 'tmdb_tv_episodes',
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
child: EpisodeListSection(
seriesId: primaryHit.itemId,
tmdbSeriesId: tmdbId,
serverId: primaryHit.serverId,
embedded: true,
autoSelectFirstEpisode: tvAutoSelect.autoSelectFirstEpisode,
autoSelectEpisodeId: tvAutoSelect.autoSelectEpisodeId,
autoSelectSeasonId: tvAutoSelect.autoSelectSeasonId,
currentEpisodeId: selectedTvEpisode?.Id,
onEpisodeTap: (episode) =>
_selectTvEpisode(episode, null, primaryHitKey!),
onEpisodeTapWithSeason: (episode, seasonNumber) =>
_selectTvEpisode(episode, seasonNumber, primaryHitKey!),
),
),
),
if (tvEpisodeReq != null)
DetailSection(
id: 'tmdb_tv_source',
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 24, 0, 0),
child: SourceSelectionSection(
req: tvEpisodeReq,
localSources: sortedSources,
selectedSourceIdx: _selectedSourceIdx,
selectedCrossServerCard: effectiveCrossServerCard,
currentServerName: primaryHit.serverName,
excludedServerIdOverride: primaryHit.serverId,
localMediaInfoItem: localSourceItem,
showLoadingWhenEmpty: true,
loadingText: '正在查找该集的可用版本…',
onLocalSourceTap: (src, index) => _selectLocalSource(index),
onCrossServerSourceTap: _selectCrossServerSource,
),
),
),
const DetailSection(id: 'tmdb_tv_divider', child: _sectionDivider),
],
];
final heroActions = _buildHeroActions(
mediaType: mediaType,
primaryHit: primaryHit,
hitCount: hits.length,
availabilityResolving: availabilityResolving,
isFavorite: isFavorite,
isPlayed: isPlayed,
subtitles: subtitles,
audios: audios,
onPlay: () => play(false),
onReplay: () => play(true),
);
final viewState = buildTmdbDetailViewState(
mediaType: mediaType,
enriched: enriched,
seed: entry.seed,
imageBaseUrl: imageBaseUrl,
language: language,
heroActions: heroActions,
hitStateSections: hitStateSections,
showSpinner: showSpinner,
);
if (Platform.isAndroid) {
return TmdbDetailBodyAndroid(
entry: entry,
enriched: enriched,
imageBaseUrl: imageBaseUrl,
language: language,
showSpinner: showSpinner,
scrollController: _scroll,
scrollProgress: _scrollProgress,
primaryHit: primaryHit,
availabilityResolving: availabilityResolving,
isFavorite: isFavorite,
isPlayed: isPlayed,
subtitles: subtitles,
audios: audios,
selectedSubtitleIdx: _selectedSubtitleIdx,
selectedAudioIdx: _selectedAudioIdx,
onPlay: () => play(false),
onReplay: () => play(true),
onToggleFavorite: primaryHit != null
? () => unawaited(_toggleTmdbFavorite(primaryHit, isFavorite))
: () {},
onTogglePlayed: primaryHit != null
? () => unawaited(_toggleTmdbPlayed(primaryHit, isPlayed))
: () {},
onSelectSubtitleIndex: _selectSubtitleIndex,
onSelectAudioIndex: _selectAudioIndex,
hitCount: hits.length,
hitStateSections: hitStateSections,
);
}
final isCompact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
return DetailImmersiveScaffold(
scrollController: _scroll,
scrollProgress: _scrollProgress,
backdropUrl: viewState.image.backdropUrl,
fallbackUrls: viewState.image.fallbackUrls,
embyBaseUrl: viewState.image.embyBaseUrl,
imageHeaders: viewState.image.imageHeaders,
compact: isCompact,
horizontalPadding: isCompact ? 16 : _kHPad,
child: DetailViewRenderer(state: viewState),
);
}
Widget? _buildHeroActions({
required String mediaType,
required TmdbLibraryHit? primaryHit,
required int hitCount,
required bool availabilityResolving,
required bool isFavorite,
required bool isPlayed,
required List<EmbyRawMediaStream> subtitles,
required List<EmbyRawMediaStream> audios,
required VoidCallback onPlay,
required VoidCallback onReplay,
}) {
final compact = MediaQuery.sizeOf(context).width < Breakpoints.detail;
final btnH = compact ? 44.0 : 52.0;
if (availabilityResolving) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: AppSkeleton(height: btnH, radius: 12),
);
}
final ctaLabel = buildTmdbCtaLabel(
mediaType: mediaType,
primaryHit: primaryHit,
hitCount: hitCount,
);
final hit = primaryHit;
if (ctaLabel == null || hit == null) return null;
final pos =
_positionOverrideFor(hit.serverId, hit.itemId) ??
hit.playbackPositionTicks ??
0;
final runtime = hit.runTimeTicks ?? 0;
final hasProgress = pos > 0;
final progressPercent = (hasProgress && runtime > 0)
? (pos / runtime * 100).clamp(0.0, 100.0)
: null;
final positionClock = hasProgress ? formatTicksAsClock(pos) : null;
final extraActions = buildStreamSelectorPills(
subtitles: subtitles,
audios: audios,
selectedSubtitleIdx: _selectedSubtitleIdx,
selectedAudioIdx: _selectedAudioIdx,
onSelectSubtitle: _selectSubtitleIndex,
onSelectAudio: _selectAudioIndex,
height: btnH,
);
return DetailHeroActions(
showReplay: hasProgress,
onReplay: onReplay,
isPlayed: isPlayed,
onTogglePlayed: () => _toggleTmdbPlayed(hit, isPlayed),
isFavorite: isFavorite,
onToggleFavorite: () => _toggleTmdbFavorite(hit, isFavorite),
extraIconActions: extraActions,
onPlay: onPlay,
playLabel: ctaLabel,
progressPercent: progressPercent,
trailingLabel: positionClock,
compact: compact,
);
}
}
@@ -0,0 +1,5 @@
String? yearOf(String? date) {
if (date == null || date.length < 4) return null;
return date.substring(0, 4);
}
@@ -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,
};
}