Files
sm-emby-share/lib/features/player/widgets/panels/episode_panel_body.dart
T
2026-07-14 11:11:36 +08:00

461 lines
14 KiB
Dart

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<EmbyRawItem> 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<EpisodePanelBody> createState() => EpisodePanelBodyState();
}
class EpisodePanelBodyState extends ConsumerState<EpisodePanelBody> {
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<EmbyRawSeason> 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<EmbyRawItem> 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<EmbyRawItem> 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<Color>(
_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,
),
),
],
),
),
],
),
),
);
}
}