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? 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 createState() => _EpisodeListSectionState(); } class _EpisodeListSectionState extends ConsumerState { 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? 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 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 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 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 episodes, String? activeEpisodeId, Map tmdbStillUrls, ) { showAdaptiveModal( 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( 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 episodes; final String seriesId; final String? activeEpisodeId; final Map 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), ); }, ); }, ), ), ], ), ); } }