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

371 lines
10 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/services/category_controller.dart';
import '../../core/data/services/home_feed_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.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,
required this.active,
required this.onSearch,
});
final HomeFeedController feedController;
final bool active;
final VoidCallback onSearch;
@override
State<RecommendPage> createState() => _RecommendPageState();
}
class _RecommendPageState extends State<RecommendPage> {
RecommendInnerTab _tab = RecommendInnerTab.feed;
final PageController _pageController = PageController();
int _scrubCancelRequest = 0;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext 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,
onOpenDrama: _openDrama,
),
),
),
],
),
),
),
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],
),
),
),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: RecommendTopNav(
selected: _tab,
onVideo: _tab == RecommendInnerTab.feed,
onSelected: _selectTab,
onSearch: _handleSearch,
),
),
),
],
),
);
}
Widget _recommendFeed(BuildContext context) {
final dramas = widget.feedController.dramas;
if (dramas.isEmpty) {
final l10n = AppLocalizations.of(context)!;
if (widget.feedController.loadingFirstPage) {
return const _RecommendFeedSkeleton();
}
return ColoredBox(
color: AppColors.mediaBlack,
child: EmptyState(
key: const Key('recommend-feed-empty'),
icon: Icons.movie_outlined,
title: l10n.noStories,
actionLabel: l10n.retry,
onAction: () => unawaited(widget.feedController.refreshCurrent()),
),
);
}
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,
scrubCancelRequest: _scrubCancelRequest,
);
}
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.mineGradientTop,
AppColors.mineGradientMiddle,
AppColors.mineGradientBottom,
],
stops: [0, 0.4, 0.75],
),
),
child: Stack(
fit: StackFit.expand,
children: [
const DecoratedBox(
key: Key('recommend-playlist-glow'),
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(0, -0.95),
radius: 0.9,
colors: [
AppColors.recommendPlaylistGlow,
AppColors.transparent,
],
),
),
),
_withTopNavSpace(
RecommendSectionsView(
feedController: widget.feedController,
categories: categories,
onOpenDrama: _openDrama,
onMore: _handleMore,
),
),
],
),
);
}
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,
),
);
}
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.skeletonSurface,
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.skeletonSurface,
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;
}
}