Files

854 lines
28 KiB
Dart
Raw Permalink Normal View History

2026-07-14 11:11:36 +08:00
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),
);
},
);
},
),
),
],
),
);
}
}