import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/contracts/library.dart'; import '../../../../providers/di_providers.dart'; import '../../../../providers/tmdb_settings_provider.dart'; import '../../../../shared/utils/emby_ticks.dart'; import '../../../../shared/utils/format_utils.dart'; import '../../../../shared/utils/tmdb_episode_helpers.dart'; import '../../../../shared/utils/tmdb_key_utils.dart'; import '../../../../shared/widgets/app_inline_alert.dart'; import '../../../../shared/widgets/app_loading_ring.dart'; import '../../../../shared/widgets/episode_thumb.dart'; import '../../../../shared/widgets/season_selector.dart'; import '../player_panel_shell.dart'; const Color _kActiveColor = Color(0xFF4FA8FF); class EpisodePanelBody extends ConsumerStatefulWidget { final String seriesId; final String? initialSeasonId; final String currentEpisodeId; final ValueChanged onEpisodeSelected; final String serverId; final String baseUrl; final String token; const EpisodePanelBody({ super.key, required this.seriesId, required this.currentEpisodeId, required this.onEpisodeSelected, required this.serverId, required this.baseUrl, required this.token, this.initialSeasonId, }); @override ConsumerState createState() => EpisodePanelBodyState(); } class EpisodePanelBodyState extends ConsumerState { String? _selectedSeasonId; bool _loadStarted = false; @override void initState() { super.initState(); } void ensureLoaded() { if (_loadStarted) return; _loadStarted = true; if (mounted) setState(() {}); } @override Widget build(BuildContext context) { if (!_loadStarted) { return const SizedBox.shrink(); } final serverId = widget.serverId; if (serverId.isEmpty) return const SizedBox.shrink(); final seasonsAsync = ref.watch( seriesSeasonsDataProvider(( serverId: serverId, seriesId: widget.seriesId, )), ); return seasonsAsync.when( loading: _loading, error: (_, _) => _error(), data: (seasons) { if (seasons.isEmpty) return _empty('暂无可用季'); final effectiveSeasonId = _resolveSeasonId(seasons); final effectiveSeason = seasons.firstWhere( (s) => s.Id == effectiveSeasonId, orElse: () => seasons.first, ); return Column( mainAxisSize: MainAxisSize.min, children: [ if (seasons.length > 1) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), child: Align( alignment: Alignment.centerLeft, child: SeasonSelector( seasons: seasons, selectedId: effectiveSeasonId, onSelect: (seasonId) { setState(() => _selectedSeasonId = seasonId); }, ), ), ), const Divider(color: kPanelDivider, height: 1), ], Flexible( child: _EpisodeList( seriesId: widget.seriesId, seasonId: effectiveSeasonId, seasonNumber: seasonNumberFrom(effectiveSeason), currentEpisodeId: widget.currentEpisodeId, serverId: widget.serverId, baseUrl: widget.baseUrl, token: widget.token, onSelected: widget.onEpisodeSelected, ), ), ], ); }, ); } String _resolveSeasonId(List seasons) { final selected = _selectedSeasonId; if (selected != null && seasons.any((s) => s.Id == selected)) { return selected; } final initial = widget.initialSeasonId; if (initial != null && seasons.any((s) => s.Id == initial)) { return initial; } return seasons.first.Id; } Widget _loading() => const Padding( padding: EdgeInsets.symmetric(vertical: 32), child: Center(child: AppLoadingRing(size: 22, color: Colors.white)), ); Widget _empty(String text) => Padding( padding: const EdgeInsets.symmetric(vertical: 32), child: Center( child: Text( text, style: const TextStyle(color: Colors.white54, fontSize: 13), ), ), ); Widget _error() => const Padding( padding: EdgeInsets.symmetric(vertical: 32), child: AppInlineAlert(message: '选集加载失败'), ); } class _EpisodeList extends ConsumerStatefulWidget { final String seriesId; final String seasonId; final int? seasonNumber; final String? currentEpisodeId; final String serverId; final String baseUrl; final String token; final ValueChanged onSelected; const _EpisodeList({ required this.seriesId, required this.seasonId, required this.seasonNumber, required this.currentEpisodeId, required this.serverId, required this.baseUrl, required this.token, required this.onSelected, }); @override ConsumerState<_EpisodeList> createState() => _EpisodeListState(); } class _EpisodeListState extends ConsumerState<_EpisodeList> { final ScrollController _scrollController = ScrollController(); bool _hasScrolledToActive = false; static const _itemHeight = 76.0; @override void didUpdateWidget(covariant _EpisodeList oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.seasonId != widget.seasonId || oldWidget.currentEpisodeId != widget.currentEpisodeId) { _hasScrolledToActive = false; } } @override void dispose() { _scrollController.dispose(); super.dispose(); } void _scrollToActive(List episodes) { if (_hasScrolledToActive || widget.currentEpisodeId == null) return; final idx = episodes.indexWhere((e) => e.Id == widget.currentEpisodeId); if (idx < 0) return; _hasScrolledToActive = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scrollController.hasClients) return; final viewport = _scrollController.position.viewportDimension; final target = idx * _itemHeight - (viewport - _itemHeight) / 2; final clamped = target.clamp( 0.0, _scrollController.position.maxScrollExtent, ); _scrollController.jumpTo(clamped); }); } @override Widget build(BuildContext context) { final serverId = widget.serverId; final episodesAsync = ref.watch( seriesEpisodesDataProvider(( serverId: serverId, seriesId: widget.seriesId, seasonId: widget.seasonId, )), ); final tmdbSettings = ref.watch(tmdbSettingsProvider).value; final canRequest = tmdbSettings?.canRequest ?? false; String tmdbSeriesId = ''; if (canRequest) { final seriesItem = ref .watch( mediaDetailDataProvider(( serverId: serverId, itemId: widget.seriesId, )), ) .value ?.base; if (seriesItem != null) tmdbSeriesId = tmdbIdFromItem(seriesItem); } final canUseTmdbSeason = canRequest && tmdbSeriesId.isNotEmpty && widget.seasonNumber != null; final tmdbSeason = canUseTmdbSeason ? ref .watch( tmdbSeasonDetailProvider(( tmdbId: tmdbSeriesId, seasonNumber: widget.seasonNumber!, )), ) .value : null; final tmdbStillUrls = tmdbStillUrlsByEpisode( tmdbSeason, tmdbSettings?.effectiveImageBaseUrl, ); return episodesAsync.when( loading: () => const Padding( padding: EdgeInsets.symmetric(vertical: 32), child: Center(child: AppLoadingRing(size: 22, color: Colors.white)), ), error: (_, _) => const Padding( padding: EdgeInsets.symmetric(vertical: 32), child: AppInlineAlert(message: '选集加载失败'), ), data: (episodes) { if (episodes.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 32), child: Center( child: Text( '暂无分集', style: TextStyle(color: Colors.white54, fontSize: 13), ), ), ); } _scrollToActive(episodes); return ListView.separated( controller: _scrollController, shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 4), itemCount: episodes.length, separatorBuilder: (_, _) => const Divider( color: kPanelDivider, height: 1, indent: 12, endIndent: 12, ), itemBuilder: (_, i) { final ep = episodes[i]; final tmdbStillUrl = ep.IndexNumber == null ? null : tmdbStillUrls[ep.IndexNumber]; return _EpisodeTile( episode: ep, seriesId: widget.seriesId, tmdbStillUrl: tmdbStillUrl, isActive: ep.Id == widget.currentEpisodeId, baseUrl: widget.baseUrl, token: widget.token, onTap: () => widget.onSelected(ep), ); }, ); }, ); } } class _EpisodeTile extends StatelessWidget { final EmbyRawItem episode; final String seriesId; final String? tmdbStillUrl; final bool isActive; final String baseUrl; final String token; final VoidCallback onTap; const _EpisodeTile({ required this.episode, required this.seriesId, required this.tmdbStillUrl, required this.isActive, required this.baseUrl, required this.token, required this.onTap, }); @override Widget build(BuildContext context) { final ep = formatEpisodeNumber(episode.IndexNumber) ?? ''; final runtime = episode.RunTimeTicks != null ? '${(episode.RunTimeTicks! / kTicksPerMinute).round()} 分钟' : ''; final progress = playbackProgress(episode); return InkWell( onTap: isActive ? null : onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(4), child: Container( width: 96, height: 54, color: Colors.white12, child: Stack( fit: StackFit.expand, children: [ EpisodeThumb( episode: episode, seriesId: seriesId, baseUrl: baseUrl, token: token, tmdbStillUrl: tmdbStillUrl, maxWidth: 240, ), if (isActive) Container( color: Colors.black54, alignment: Alignment.center, child: const Icon( Icons.play_arrow, color: Colors.white, size: 28, ), ), if (progress > 0) Positioned( left: 0, right: 0, bottom: 0, child: LinearProgressIndicator( value: progress, minHeight: 2, backgroundColor: Colors.white24, valueColor: const AlwaysStoppedAnimation( _kActiveColor, ), ), ), ], ), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ if (ep.isNotEmpty) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( color: isActive ? _kActiveColor : Colors.white12, borderRadius: BorderRadius.circular(3), ), child: Text( ep, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 6), ], Expanded( child: Text( episode.Name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: isActive ? _kActiveColor : Colors.white, fontSize: 13, fontWeight: FontWeight.w500, ), ), ), ], ), const SizedBox(height: 4), if (runtime.isNotEmpty) Text( runtime, style: const TextStyle( color: Colors.white54, fontSize: 11, ), ), ], ), ), ], ), ), ); } }