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