Files
ReelShort/lib/pages/recommend/recommend_page.dart
T

494 lines
15 KiB
Dart

// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/remote/catalog_api.dart';
import '../../core/data/models/playlist_detail.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/playlist_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../home/home_feed_channel.dart';
import '../player/open_player.dart';
import '../player/player_view.dart';
import 'home_tab_motion_controller.dart';
import 'recommend_types.dart';
import 'widgets/all_dramas_filter_grid.dart';
import 'widgets/playlist_detail_page.dart';
import 'widgets/recommend_sections_view.dart';
import 'widgets/recommend_top_nav.dart';
part 'recommend_page_parts.dart';
class RecommendPage extends StatefulWidget {
const RecommendPage({
super.key,
required this.feedController,
this.catalogApi,
required this.active,
required this.onSearch,
this.onOpenWallet,
this.videoOnly = false,
this.onImmersiveChanged,
});
final HomeFeedController feedController;
final CatalogApi? catalogApi;
final bool active;
final VoidCallback onSearch;
/// 顶部金色礼物盒(钱包入口)。
final VoidCallback? onOpenWallet;
/// 画中画时只留视频,不画顶栏。
final bool videoOnly;
final ValueChanged<bool>? onImmersiveChanged;
@override
State<RecommendPage> createState() => _RecommendPageState();
}
class _RecommendPageState extends State<RecommendPage>
with AutomaticKeepAliveClientMixin<RecommendPage> {
static const Duration _recommendRetryDelay = Duration(milliseconds: 1500);
static const int _maxRecommendRetries = 3;
RecommendInnerTab _tab = RecommendInnerTab.feed;
late final _pageController = HomeTabMotionController(
onSettled: _onPageChanged,
);
int _scrubCancelRequest = 0;
bool _recommendDataRefreshScheduled = false;
bool _recommendRefreshInFlight = false;
int _recommendRetryAttempts = 0;
Timer? _recommendRetryTimer;
HomeFeedChannel? _observedFeedChannel;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_observedFeedChannel = widget.feedController.channel;
widget.feedController.addListener(_handleFeedControllerChanged);
if (widget.active) _scheduleRecommendData();
}
@override
void didUpdateWidget(RecommendPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.feedController != widget.feedController) {
oldWidget.feedController.removeListener(_handleFeedControllerChanged);
_cancelRecommendRetries();
_observedFeedChannel = widget.feedController.channel;
widget.feedController.addListener(_handleFeedControllerChanged);
}
if (oldWidget.active && !widget.active) {
_cancelRecommendRetries();
}
if (_requiresRecommendDataRefresh(oldWidget)) {
_scheduleRecommendData();
}
}
bool _requiresRecommendDataRefresh(RecommendPage oldWidget) {
if (!widget.active) return false;
return !oldWidget.active ||
oldWidget.feedController != widget.feedController ||
oldWidget.catalogApi != widget.catalogApi;
}
void _handleFeedControllerChanged() {
final channel = widget.feedController.channel;
if (channel != _observedFeedChannel) {
_observedFeedChannel = channel;
_cancelRecommendRetries();
if (widget.active) _scheduleRecommendData();
return;
}
_handleRecommendFeedState();
}
void _handleRecommendFeedState() {
if (!mounted || !widget.active) return;
final feed = widget.feedController;
if (feed.dramas.isNotEmpty) {
_cancelRecommendRetries();
return;
}
if (feed.loaded &&
feed.lastError == null &&
!feed.isLoading &&
!_recommendRefreshInFlight) {
_cancelRecommendRetries();
return;
}
if (feed.loaded && feed.lastError != null && !_recommendRefreshInFlight) {
_scheduleRecommendRetry();
}
}
void _scheduleRecommendData() {
if (_recommendDataRefreshScheduled) return;
_recommendDataRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_recommendDataRefreshScheduled = false;
if (!mounted || !widget.active) return;
final feed = widget.feedController;
if (feed.dramas.isEmpty || feed.lastError != null) {
_recommendRetryAttempts = 0;
if (!feed.isLoading && !_recommendRefreshInFlight) {
if (feed.loaded) {
_scheduleRecommendRetry();
} else {
unawaited(_refreshRecommendData());
}
}
}
final playlists = _readPlaylists();
if (playlists != null && !playlists.loaded && !playlists.loading) {
unawaited(playlists.load());
}
});
}
void _scheduleRecommendRetry() {
if (!mounted ||
!widget.active ||
_recommendRefreshInFlight ||
_recommendRetryTimer != null ||
_recommendRetryAttempts >= _maxRecommendRetries) {
return;
}
setState(() {
_recommendRetryTimer = Timer(_recommendRetryDelay, () {
_recommendRetryTimer = null;
if (!mounted || !widget.active) return;
_recommendRetryAttempts += 1;
unawaited(_refreshRecommendData());
});
});
}
Future<void> _refreshRecommendData() async {
if (!mounted || !widget.active || _recommendRefreshInFlight) return;
setState(() => _recommendRefreshInFlight = true);
final succeeded = await widget.feedController.refreshCurrent();
if (!mounted) return;
_recommendRefreshInFlight = false;
if (!widget.active) return;
final feed = widget.feedController;
if (succeeded || (feed.loaded && feed.lastError == null)) {
_cancelRecommendRetries();
setState(() {});
return;
}
setState(() {});
_scheduleRecommendRetry();
}
void _cancelRecommendRetries() {
_recommendRetryTimer?.cancel();
_recommendRetryTimer = null;
_recommendRetryAttempts = 0;
}
@override
void dispose() {
widget.feedController.removeListener(_handleFeedControllerChanged);
_cancelRecommendRetries();
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
final categories = context.read<CategoryController>();
return ColoredBox(
key: const Key('recommend-page'),
color: AppColors.appBackground,
child: Stack(
children: [
Positioned.fill(
child: ListenableBuilder(
listenable: Listenable.merge([widget.feedController, categories]),
builder: (context, _) => PageView(
key: const Key('recommend-tab-page-view'),
controller: _pageController,
scrollDirection: Axis.horizontal,
onPageChanged: _onPageChanged,
children: [
_KeepAliveTab(
key: const PageStorageKey('recommend-tab-feed'),
child: _recommendFeed(context),
),
_KeepAliveTab(
key: const PageStorageKey('recommend-tab-playlists'),
child: _recommendPlaylists(categories),
),
_KeepAliveTab(
key: const PageStorageKey('recommend-tab-all'),
child: _withTopNavSpace(
AllDramasFilterGrid(
feedController: widget.feedController,
catalogApi: widget.catalogApi,
onOpenDrama: _openDrama,
),
),
),
],
),
),
),
if (!widget.videoOnly)
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: SizedBox(
height: 72,
child: DecoratedBox(
key: const Key('recommend-top-gradient'),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.mediaBlack, AppColors.transparent],
),
),
),
),
),
),
if (!widget.videoOnly)
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: ListenableBuilder(
listenable: _pageController,
builder: (context, _) => RecommendTopNav(
selected: _tab,
pagePosition: _pageController.pagePosition,
onVideo: _tab == RecommendInnerTab.feed,
onSelected: _selectTab,
onSearch: _handleSearch,
onGift: widget.onOpenWallet,
),
),
),
),
],
),
);
}
Widget _recommendFeed(BuildContext context) {
final feed = widget.feedController;
final dramas = widget.feedController.dramas;
if (dramas.isNotEmpty) {
return PlayerPage(
key: const Key('recommend-feed-player'),
homeMode: true,
embedded: true,
active: widget.active && _tab == RecommendInnerTab.feed,
showHomeFeedHeader: false,
drama: dramas.first,
initialIndex: 0,
feedDramas: dramas,
feedChannel: widget.feedController.channel,
// 内嵌流也是真实播放页:不传钱包/下载,锁定集点「解锁」会静默无响应,
// 进全屏后也拿不到这两个依赖。
wallet: context.read<WalletController>(),
downloads: context.read<DownloadController>(),
onOpenFullPlayer: (drama, episode) =>
_openDrama(drama, episodeId: episode.id),
onRefreshFeed: widget.feedController.refreshCurrent,
onLoadMore: widget.feedController.loadMore,
onImmersiveChanged: widget.onImmersiveChanged,
scrubCancelRequest: _scrubCancelRequest,
);
}
if (!feed.loaded ||
feed.isLoading ||
_recommendRefreshInFlight ||
_recommendRetryTimer != null) {
return const _RecommendFeedSkeleton();
}
final l10n = AppLocalizations.of(context)!;
if (feed.lastError != null) {
return ColoredBox(
color: AppColors.mediaBlack,
child: EmptyState(
key: const Key('recommend-feed-empty'),
icon: Icons.wifi_off_rounded,
title: l10n.networkError,
actionLabel: l10n.retry,
actionKey: const Key('recommend-feed-retry'),
onAction: _retryRecommendData,
),
);
}
return ColoredBox(
color: AppColors.mediaBlack,
child: EmptyState(
key: const Key('recommend-feed-empty'),
icon: Icons.movie_outlined,
title: l10n.noStories,
),
);
}
void _retryRecommendData() {
_cancelRecommendRetries();
unawaited(_refreshRecommendData());
}
PlaylistController? _watchPlaylists(BuildContext context) {
try {
return context.watch<PlaylistController>();
} on ProviderNotFoundException {
return null;
}
}
PlaylistController? _readPlaylists() {
try {
return context.read<PlaylistController>();
} on ProviderNotFoundException {
return null;
}
}
Widget _recommendPlaylists(CategoryController categories) {
return DecoratedBox(
key: const Key('recommend-playlist-surface'),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
child: _withTopNavSpace(
RecommendSectionsView(
feedController: widget.feedController,
categories: categories,
onOpenDrama: _openDrama,
onMore: _handleMore,
playlists: _watchPlaylists(context),
onOpenPlaylist: _openPlaylist,
),
),
);
}
void _selectTab(RecommendInnerTab tab) {
setState(() {
_tab = tab;
_scrubCancelRequest++;
});
unawaited(
_pageController.selectPage(
tab.index,
reduceMotion: MediaQuery.disableAnimationsOf(context),
),
);
}
void _onPageChanged(int index) {
if (!_pageController.acceptsSelection(index)) return;
final next = RecommendInnerTab.values[index];
if (next == _tab) return;
setState(() {
_tab = next;
_scrubCancelRequest++;
});
}
void _handleSearch() {
setState(() => _scrubCancelRequest++);
widget.onSearch();
}
Widget _withTopNavSpace(Widget child) {
return SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.only(top: RecommendTopNav.height),
child: child,
),
);
}
Future<void> _openPlaylist(PlaylistDetail playlist) async {
final controller = _readPlaylists();
const pageSize = CatalogApi.playlistDramaPageSize;
final fresh = controller == null
? playlist
: await controller.detail(playlist.id, pageSize: pageSize);
if (!mounted) return;
final shown = fresh.dramas.isEmpty && playlist.dramas.isNotEmpty
? playlist
: fresh;
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => PlaylistDetailPage(
title: shown.name,
subtitle: shown.description,
source: shown,
dramas: shown.dramas,
dramaCount: shown.dramaCount,
loadPage: controller == null
? null
: (pageIndex) => controller.detail(
playlist.id,
pageIndex: pageIndex,
pageSize: pageSize,
),
),
),
);
}
void _handleMore(RecommendSectionKind section) {
final l10n = AppLocalizations.of(context)!;
final copy = _sectionCopy(l10n, section);
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => PlaylistDetailPage(
title: copy.$1,
subtitle: copy.$2,
dramas: recommendSectionDramas(
kind: section,
source: widget.feedController.dramas,
categories: context.read<CategoryController>(),
),
),
),
);
}
Future<void> _openDrama(Drama drama, {String? episodeId}) async {
await playDramaFromCatalog(context, drama, episodeId: episodeId);
}
}