feat(home): 首页按内容类型分频道 Feed 并支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-21 17:33:52 +08:00
1 parent 4d0be47ee3
commit ae61d9ec4c
22 files changed
+1001 -71

No files matched your search

+87 -5
View File
@@ -100,6 +100,8 @@ class PlayerLogic extends ChangeNotifier {
ValueChanged<Drama>? onDramaChanged;
ValueChanged<bool>? onImmersiveChanged;
bool _disposed = false;
PlayerFactory? _sessionFactory;
PlaybackCoordinator? _sessionCoordinator;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
@@ -109,6 +111,14 @@ class PlayerLogic extends ChangeNotifier {
bool get chromeHidden => immersive;
/// 当前 PageView 对应用户正在观看的短剧 id,尚未起播时为 null。
String? get currentDramaId {
if (!_started || visibleIndices.isEmpty) return null;
final index = session.currentIndex;
if (index < 0 || index >= queue.length) return null;
return queue.entryAt(index).drama.id;
}
Episode displayEpisode(Drama drama, Episode fallback) {
final number = pickedEpisode[drama.id];
if (number == null) return fallback;
@@ -145,6 +155,8 @@ class PlayerLogic extends ChangeNotifier {
required PlaybackCoordinator coordinator,
}) {
_started = true;
_sessionFactory = factory;
_sessionCoordinator = coordinator;
pictureInPicture.setStateListener(onPipChanged);
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
@@ -153,7 +165,53 @@ class PlayerLogic extends ChangeNotifier {
pages = PageController(initialPage: initialIndex);
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
session = PlaybackSession(
session = _createSession(factory, coordinator);
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
/// 原地替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。
void updateFeed(List<Drama> dramas, {String? keepDramaId}) {
final currentId = currentDramaId;
final nextQueue = PlaybackQueue.home(List<Drama>.unmodifiable(dramas));
final nextVisible = List<int>.generate(nextQueue.length, (index) => index);
final nextEpisodes = [for (final entry in nextQueue.entries) entry.episode];
feedDramas = List<Drama>.unmodifiable(dramas);
queue = nextQueue;
visibleIndices = nextVisible;
if (dramas.isNotEmpty) pageDrama = dramas.first;
if (!_started) return;
if (nextQueue.length == 0) {
session.setRouteVisible(false);
notify();
return;
}
final keepIndex = keepDramaId != null && keepDramaId == currentId
? nextQueue.entries.indexWhere((entry) => entry.drama.id == keepDramaId)
: -1;
if (keepIndex >= 0 && session.active != null) {
sessionEpisodes
..clear()
..addAll(nextEpisodes);
session.remapCurrentIndex(
keepIndex,
preloadIndex: nextVisibleIndex(keepIndex),
);
_jumpToFeedPage(keepIndex);
notify();
return;
}
_replaceSession(nextEpisodes, keepIndex >= 0 ? keepIndex : 0);
}
PlaybackSession _createSession(
PlayerFactory factory,
PlaybackCoordinator coordinator,
) {
return PlaybackSession(
episodes: sessionEpisodes,
factory: factory,
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
@@ -162,11 +220,35 @@ class PlayerLogic extends ChangeNotifier {
activate: activatePlayer,
coordinator: coordinator,
);
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
void _replaceSession(List<Episode> nextEpisodes, int targetIndex) {
final factory = _sessionFactory;
final coordinator = _sessionCoordinator;
if (factory == null || coordinator == null) return;
unawaited(saveCurrentProgress());
final previous = session;
sessionEpisodes = List<Episode>.of(nextEpisodes);
trackedPlayer = null;
trackedEpisode = null;
trackedDrama = null;
session = _createSession(factory, coordinator);
session.setRouteVisible(active && visibleIndices.isNotEmpty);
unawaited(previous.close());
_jumpToFeedPage(targetIndex);
unawaited(selectEpisode(targetIndex));
notify();
}
void _jumpToFeedPage(int index) {
if (!_started) return;
if (pages.hasClients) {
pages.jumpToPage(index);
return;
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (pages.hasClients) pages.jumpToPage(index);
});
}
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
+25
View File
@@ -2,6 +2,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -22,6 +23,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import '../home/home_feed_channel.dart';
import 'comments_auth.dart';
import 'player_binding.dart';
import 'player_logic.dart';
@@ -57,10 +59,13 @@ class PlayerPage extends StatefulWidget {
this.onSearch,
this.onMenu,
this.feedDramas,
this.feedChannel,
this.onDramaChanged,
this.onImmersiveChanged,
this.onOpenFullPlayer,
this.onRefreshFeed,
this.onLoadMore,
this.onChannelSelected,
this.wallet,
this.downloads,
});
@@ -77,10 +82,13 @@ class PlayerPage extends StatefulWidget {
final VoidCallback? onSearch;
final VoidCallback? onMenu;
final List<Drama>? feedDramas;
final HomeFeedChannel? feedChannel;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
final Future<bool> Function()? onRefreshFeed;
final Future<void> Function()? onLoadMore;
final ValueChanged<HomeFeedChannel>? onChannelSelected;
final WalletController? wallet;
final DownloadController? downloads;
@@ -123,6 +131,17 @@ class _PlayerPageState extends State<PlayerPage>
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted);
if (widget.homeMode &&
widget.feedDramas != null &&
(!listEquals(oldWidget.feedDramas, widget.feedDramas) ||
oldWidget.feedChannel != widget.feedChannel)) {
logic.updateFeed(
widget.feedDramas!,
keepDramaId: oldWidget.feedChannel == widget.feedChannel
? logic.currentDramaId
: null,
);
}
logic.onPageUpdated(oldWidget);
}
@@ -250,6 +269,10 @@ class _PlayerPageState extends State<PlayerPage>
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
if (position >= indices.length - 3) {
final loadMore = widget.onLoadMore;
if (loadMore != null) unawaited(loadMore());
}
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], interactions),
@@ -268,6 +291,8 @@ class _PlayerPageState extends State<PlayerPage>
bottom: false,
child: HomeFeedHeader(
l10n: l10n,
channel: widget.feedChannel ?? HomeFeedChannel.recommend,
onChannelSelected: widget.onChannelSelected ?? (_) {},
onRefresh: () async {
final ok = await widget.onRefreshFeed?.call() ?? true;
if (context.mounted && !ok) {
+29 -44
View File
@@ -2,6 +2,7 @@
import 'package:flutter/material.dart';
import '../../home/home_feed_channel.dart';
import '../../../l10n/app_localizations.dart';
const double _topIconSize = 36;
@@ -16,12 +17,16 @@ class HomeFeedHeader extends StatefulWidget {
const HomeFeedHeader({
super.key,
required this.l10n,
required this.channel,
required this.onChannelSelected,
required this.onRefresh,
required this.onSearch,
required this.onMore,
});
final AppLocalizations l10n;
final HomeFeedChannel channel;
final ValueChanged<HomeFeedChannel> onChannelSelected;
final Future<bool> Function() onRefresh;
final VoidCallback onSearch;
final VoidCallback onMore;
@@ -31,7 +36,6 @@ class HomeFeedHeader extends StatefulWidget {
}
class _HomeFeedHeaderState extends State<HomeFeedHeader> {
_FeedTab _tab = _FeedTab.recommend;
bool _refreshing = false;
@override
@@ -54,8 +58,12 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < _FeedTab.values.length; index++)
_tabSlot(_FeedTab.values[index], index),
for (
var index = 0;
index < HomeFeedChannel.values.length;
index++
)
_tabSlot(HomeFeedChannel.values[index], index),
],
),
),
@@ -74,15 +82,15 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
);
}
Widget _tabSlot(_FeedTab tab, int index) {
Widget _tabSlot(HomeFeedChannel channel, int index) {
final item = _FeedTabItem(
tab: tab,
label: tab.label(widget.l10n),
selected: _tab == tab,
channel: channel,
label: _label(channel),
selected: widget.channel == channel,
fontSize: _tabFontSize,
underlineWidth: _underlineWidth,
underlineHeight: _underlineHeight,
onTap: () => _selectTab(tab),
onTap: () => widget.onChannelSelected(channel),
);
if (index == 0) return item;
return Padding(
@@ -91,14 +99,6 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
);
}
void _selectTab(_FeedTab tab) {
if (tab.comingSoon) {
_showComingSoon();
return;
}
setState(() => _tab = tab);
}
Future<void> _refresh() async {
if (_refreshing) return;
setState(() => _refreshing = true);
@@ -106,31 +106,10 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
if (mounted) setState(() => _refreshing = false);
}
void _showComingSoon() {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
}
}
/// 顶部频道 tab:漫剧点击提示即将上线。
enum _FeedTab {
anime,
real,
recommend;
bool get comingSoon => this == _FeedTab.anime;
String get keyName => switch (this) {
_FeedTab.anime => 'anime',
_FeedTab.real => 'real',
_FeedTab.recommend => 'recommend',
};
String label(AppLocalizations l10n) => switch (this) {
_FeedTab.anime => l10n.feedTabAnime,
_FeedTab.real => l10n.feedTabReal,
_FeedTab.recommend => l10n.feedTabForYou,
String _label(HomeFeedChannel channel) => switch (channel) {
HomeFeedChannel.anime => widget.l10n.feedTabAnime,
HomeFeedChannel.live => widget.l10n.feedTabReal,
HomeFeedChannel.recommend => widget.l10n.feedTabForYou,
};
}
@@ -223,7 +202,7 @@ class _RefreshButton extends StatelessWidget {
class _FeedTabItem extends StatelessWidget {
const _FeedTabItem({
required this.tab,
required this.channel,
required this.label,
required this.selected,
required this.fontSize,
@@ -232,7 +211,7 @@ class _FeedTabItem extends StatelessWidget {
required this.onTap,
});
final _FeedTab tab;
final HomeFeedChannel channel;
final String label;
final bool selected;
final double fontSize;
@@ -243,7 +222,7 @@ class _FeedTabItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
key: Key('home-feed-tab-${tab.keyName}'),
key: Key('home-feed-tab-${_keyName(channel)}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
@@ -272,4 +251,10 @@ class _FeedTabItem extends StatelessWidget {
),
);
}
String _keyName(HomeFeedChannel channel) => switch (channel) {
HomeFeedChannel.anime => 'anime',
HomeFeedChannel.live => 'real',
HomeFeedChannel.recommend => 'recommend',
};
}