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