576 lines
17 KiB
Dart
576 lines
17 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/home_feed_controller.dart';
|
|
import '../../core/data/services/playlist_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 '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';
|
|
|
|
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;
|
|
final PageController _pageController = PageController();
|
|
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: RecommendTopNav(
|
|
selected: _tab,
|
|
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,
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
return ColoredBox(
|
|
color: AppColors.mediaBlack,
|
|
child: EmptyState(
|
|
key: const Key('recommend-feed-empty'),
|
|
icon: Icons.movie_outlined,
|
|
title: l10n.noStories,
|
|
),
|
|
);
|
|
}
|
|
|
|
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++;
|
|
});
|
|
if (_pageController.hasClients) {
|
|
unawaited(
|
|
_pageController.animateToPage(
|
|
tab.index,
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOut,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _onPageChanged(int index) {
|
|
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>(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
(String, String) _sectionCopy(
|
|
AppLocalizations l10n,
|
|
RecommendSectionKind section,
|
|
) {
|
|
return switch (section) {
|
|
RecommendSectionKind.hits => (
|
|
l10n.weeklyHitsTitle,
|
|
l10n.weeklyHitsSubtitle,
|
|
),
|
|
RecommendSectionKind.newReleases => (
|
|
l10n.weeklyNewTitle,
|
|
l10n.weeklyNewSubtitle,
|
|
),
|
|
RecommendSectionKind.costume => (
|
|
l10n.costumeAwardsTitle,
|
|
l10n.costumeAwardsSubtitle,
|
|
),
|
|
};
|
|
}
|
|
|
|
Future<void> _openDrama(Drama drama, {String? episodeId}) async {
|
|
await playDramaFromCatalog(context, drama, episodeId: episodeId);
|
|
}
|
|
}
|
|
|
|
class _RecommendFeedSkeleton extends StatelessWidget {
|
|
const _RecommendFeedSkeleton();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const ColoredBox(
|
|
key: Key('recommend-feed-skeleton'),
|
|
color: AppColors.mediaBlack,
|
|
child: Stack(
|
|
children: [
|
|
Center(
|
|
child: SizedBox(
|
|
width: 132,
|
|
height: 176,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.skeletonMedia,
|
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 14,
|
|
right: 72,
|
|
bottom: 42,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_SkeletonLine(width: 190, height: 18),
|
|
SizedBox(height: 10),
|
|
_SkeletonLine(width: 140, height: 12),
|
|
SizedBox(height: 8),
|
|
_SkeletonLine(width: 220, height: 10),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SkeletonLine extends StatelessWidget {
|
|
const _SkeletonLine({required this.width, required this.height});
|
|
|
|
final double width;
|
|
final double height;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: width,
|
|
height: height,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.skeletonMedia,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KeepAliveTab extends StatefulWidget {
|
|
const _KeepAliveTab({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
State<_KeepAliveTab> createState() => _KeepAliveTabState();
|
|
}
|
|
|
|
class _KeepAliveTabState extends State<_KeepAliveTab>
|
|
with AutomaticKeepAliveClientMixin<_KeepAliveTab> {
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
return widget.child;
|
|
}
|
|
}
|