Initial commit
This commit is contained in:
@@ -0,0 +1,853 @@
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user