461 lines
14 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|