feat(home): 首页按内容类型分频道 Feed 并支持分页
This commit is contained in:
1 parent
4d0be47ee3
commit
ae61d9ec4c
22 files changed
+1001
-71
No files matched your search
@@ -0,0 +1,31 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'drama.dart';
|
||||
|
||||
/// `/app/feed` 的游标分页数据。
|
||||
class FeedPage {
|
||||
const FeedPage({
|
||||
this.dramas = const [],
|
||||
this.nextCursor,
|
||||
this.hasMore = false,
|
||||
});
|
||||
|
||||
final List<Drama> dramas;
|
||||
final String? nextCursor;
|
||||
final bool hasMore;
|
||||
|
||||
factory FeedPage.fromJson(Map<String, dynamic> json) {
|
||||
final rawCursor = json['nextCursor'];
|
||||
final cursor = rawCursor?.toString();
|
||||
return FeedPage(
|
||||
dramas: (json['list'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Drama.fromCardJson)
|
||||
.toList(),
|
||||
nextCursor: cursor == null || cursor.isEmpty || cursor == 'null'
|
||||
? null
|
||||
: cursor,
|
||||
hasMore: json['hasMore'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/feed_page.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/search_result.dart';
|
||||
import 'api_client.dart';
|
||||
@@ -17,6 +18,26 @@ class CatalogApi {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||
}
|
||||
|
||||
/// 内容类型推荐流;types 为空表示推荐全部,cursor 为上一页末条。
|
||||
Future<FeedPage> feed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async {
|
||||
return FeedPage.fromJson(
|
||||
_asMap(
|
||||
await _client.get(
|
||||
'/app/feed',
|
||||
query: {
|
||||
if (types.isNotEmpty) 'types': types.join(','),
|
||||
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
|
||||
'size': '$size',
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
|
||||
Future<SearchResult> search(
|
||||
String keyword, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/feed_page.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
|
||||
@@ -13,6 +14,12 @@ abstract interface class CatalogRepository {
|
||||
|
||||
Future<HomeFeed> fetchDiscoverFeed();
|
||||
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
});
|
||||
|
||||
Future<Drama> fetchDrama(String dramaId);
|
||||
}
|
||||
|
||||
@@ -28,6 +35,12 @@ class HttpCatalogRepository implements CatalogRepository {
|
||||
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) => _api.feed(types: types, cursor: cursor, size: size);
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../data/models/drama.dart';
|
||||
import '../../data/repositories/catalog_repository.dart';
|
||||
import '../../../pages/home/home_feed_channel.dart';
|
||||
|
||||
/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。
|
||||
class HomeFeedController extends ChangeNotifier {
|
||||
HomeFeedController(this._repository)
|
||||
: _states = {
|
||||
for (final channel in HomeFeedChannel.values)
|
||||
channel: _ChannelFeedState(),
|
||||
};
|
||||
|
||||
static const int pageSize = 10;
|
||||
|
||||
final CatalogRepository _repository;
|
||||
final Map<HomeFeedChannel, _ChannelFeedState> _states;
|
||||
HomeFeedChannel _channel = HomeFeedChannel.recommend;
|
||||
String? _lastError;
|
||||
bool _disposed = false;
|
||||
|
||||
HomeFeedChannel get channel => _channel;
|
||||
String? get lastError => _lastError;
|
||||
List<Drama> get dramas => _state(_channel).dramas;
|
||||
String? get cursor => _state(_channel).cursor;
|
||||
bool get hasMore => _state(_channel).hasMore;
|
||||
bool get loaded => _state(_channel).loaded;
|
||||
bool get loadingMore => _state(_channel).loadingMore;
|
||||
bool get loadingFirstPage => !loaded;
|
||||
|
||||
/// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。
|
||||
void seedRecommend(List<Drama> dramas) {
|
||||
final state = _state(HomeFeedChannel.recommend);
|
||||
state
|
||||
..requestSeq = state.requestSeq + 1
|
||||
..dramas = List.unmodifiable(dramas)
|
||||
..cursor = null
|
||||
..hasMore = false
|
||||
..loaded = true
|
||||
..loadingMore = false;
|
||||
_lastError = null;
|
||||
if (_channel == HomeFeedChannel.recommend) _notify();
|
||||
}
|
||||
|
||||
/// 同步切到目标频道;有缓存先渲染,再在后台静默刷新。
|
||||
Future<void> selectChannel(HomeFeedChannel next) async {
|
||||
if (next == _channel) return;
|
||||
_channel = next;
|
||||
_lastError = null;
|
||||
_notify();
|
||||
final state = _state(next);
|
||||
if (state.dramas.isNotEmpty) {
|
||||
unawaited(_refresh(next, keepData: true));
|
||||
} else {
|
||||
await _loadFirst(next);
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新当前频道,失败时保留旧数据。
|
||||
Future<bool> refreshCurrent() => _refresh(_channel, keepData: true);
|
||||
|
||||
/// 加载当前频道下一页并去重追加。
|
||||
Future<void> loadMore() async {
|
||||
final channel = _channel;
|
||||
final state = _state(channel);
|
||||
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
|
||||
final seq = ++state.requestSeq;
|
||||
state.loadingMore = true;
|
||||
_notify();
|
||||
try {
|
||||
final page = await _repository.fetchFeed(
|
||||
types: channel.apiTypes,
|
||||
cursor: state.cursor,
|
||||
size: pageSize,
|
||||
);
|
||||
if (!_accept(channel, seq)) return;
|
||||
final seen = state.dramas.map((drama) => drama.id).toSet();
|
||||
final appended = page.dramas
|
||||
.where((drama) => seen.add(drama.id))
|
||||
.toList();
|
||||
state
|
||||
..dramas = List.unmodifiable([...state.dramas, ...appended])
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true;
|
||||
_lastError = null;
|
||||
} catch (error) {
|
||||
if (_accept(channel, seq)) _lastError = error.toString();
|
||||
} finally {
|
||||
state.loadingMore = false;
|
||||
if (_acceptChannel(channel)) _notify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadFirst(HomeFeedChannel channel) async {
|
||||
final state = _state(channel);
|
||||
state.loaded = false;
|
||||
await _refresh(channel, keepData: state.dramas.isNotEmpty);
|
||||
}
|
||||
|
||||
Future<bool> _refresh(
|
||||
HomeFeedChannel channel, {
|
||||
required bool keepData,
|
||||
}) async {
|
||||
final state = _state(channel);
|
||||
final seq = ++state.requestSeq;
|
||||
_lastError = null;
|
||||
if (_acceptChannel(channel)) _notify();
|
||||
try {
|
||||
final page = await _repository.fetchFeed(
|
||||
types: channel.apiTypes,
|
||||
size: pageSize,
|
||||
);
|
||||
if (!_accept(channel, seq)) return false;
|
||||
state
|
||||
..dramas = List.unmodifiable(page.dramas)
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..loadingMore = false;
|
||||
_lastError = null;
|
||||
_notify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!_accept(channel, seq)) return false;
|
||||
if (!keepData) state.dramas = const [];
|
||||
state.loaded = true;
|
||||
_lastError = error.toString();
|
||||
_notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_ChannelFeedState _state(HomeFeedChannel channel) => _states[channel]!;
|
||||
|
||||
bool _accept(HomeFeedChannel channel, int seq) =>
|
||||
!_disposed && channel == _channel && _state(channel).requestSeq == seq;
|
||||
|
||||
bool _acceptChannel(HomeFeedChannel channel) =>
|
||||
!_disposed && channel == _channel;
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelFeedState {
|
||||
List<Drama> dramas = const [];
|
||||
String? cursor;
|
||||
bool hasMore = true;
|
||||
bool loaded = false;
|
||||
bool loadingMore = false;
|
||||
int requestSeq = 0;
|
||||
}
|
||||
@@ -105,6 +105,27 @@ class PlaybackSession extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
|
||||
void remapCurrentIndex(int index, {int? preloadIndex}) {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
final activeSlot = _active;
|
||||
if (activeSlot == null || activeSlot.index != _currentIndex) {
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
_generation++;
|
||||
activeSlot.index = index;
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
final staleNext = _next;
|
||||
_next = null;
|
||||
_notify();
|
||||
unawaited(_enqueue(() async => _retire(staleNext)));
|
||||
if (_visible && !_overlayOpen) _schedulePreload();
|
||||
}
|
||||
|
||||
Future<void> _enqueue(Future<void> Function() operation) {
|
||||
final task = _operations.then((_) => operation());
|
||||
// A plugin failure must not poison subsequent transitions or cleanup.
|
||||
@@ -363,7 +384,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
|
||||
class _PlayerSlot {
|
||||
_PlayerSlot(this.index, this.player);
|
||||
final int index;
|
||||
int index;
|
||||
final PlayerPort player;
|
||||
late final Future<void> ready;
|
||||
bool prepared = false;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 首页顶栏的三个内容频道。
|
||||
enum HomeFeedChannel {
|
||||
anime('ANIME'),
|
||||
live('LIVE'),
|
||||
recommend(null);
|
||||
|
||||
const HomeFeedChannel(this.apiType);
|
||||
|
||||
final String? apiType;
|
||||
|
||||
List<String> get apiTypes => apiType == null ? const [] : <String>[apiType!];
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/home_feed_controller.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/catalog_retry.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
@@ -24,8 +24,10 @@ import '../discover/discover_types.dart';
|
||||
import '../discover/discover_view.dart';
|
||||
import '../create/create_view.dart';
|
||||
import '../mine/mine_types.dart';
|
||||
import '../player/widgets/home_feed_header.dart';
|
||||
import '../player/player_view.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
import 'home_feed_channel.dart';
|
||||
import 'home_binding.dart';
|
||||
import 'home_logic.dart';
|
||||
|
||||
@@ -43,6 +45,8 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
static const _defaultSplashSize = 384.0;
|
||||
|
||||
bool _resumeRetryInFlight = false;
|
||||
HomeFeedController? _homeFeed;
|
||||
bool _recommendSeeded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -52,6 +56,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_homeFeed?.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
@@ -131,11 +136,19 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
return _HomeTabs(
|
||||
key: ValueKey(catalog.dramas.first.id),
|
||||
dramas: catalog.dramas,
|
||||
onRefreshFeed: catalog.refresh,
|
||||
);
|
||||
final homeFeed = _ensureHomeFeed(catalog);
|
||||
if (!_recommendSeeded) {
|
||||
_recommendSeeded = true;
|
||||
homeFeed.seedRecommend(catalog.dramas);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(homeFeed.refreshCurrent());
|
||||
});
|
||||
}
|
||||
return _HomeTabs(key: const Key('home-tabs'), feedController: homeFeed);
|
||||
}
|
||||
|
||||
HomeFeedController _ensureHomeFeed(CatalogController catalog) {
|
||||
return _homeFeed ??= HomeFeedController(catalog.repository);
|
||||
}
|
||||
|
||||
Widget _startupSplash() {
|
||||
@@ -199,14 +212,9 @@ class HomeFailureView extends StatelessWidget {
|
||||
|
||||
/// 四个根页面共享同一路由并保持各自状态。
|
||||
class _HomeTabs extends StatefulWidget {
|
||||
const _HomeTabs({
|
||||
super.key,
|
||||
required this.dramas,
|
||||
required this.onRefreshFeed,
|
||||
});
|
||||
const _HomeTabs({super.key, required this.feedController});
|
||||
|
||||
final List<Drama> dramas;
|
||||
final Future<bool> Function() onRefreshFeed;
|
||||
final HomeFeedController feedController;
|
||||
|
||||
@override
|
||||
State<_HomeTabs> createState() => _HomeTabsState();
|
||||
@@ -218,15 +226,21 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = HomeBinding().create(widget.dramas);
|
||||
logic = HomeBinding().create(widget.feedController.dramas);
|
||||
widget.feedController.addListener(_onFeedChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.feedController.removeListener(_onFeedChanged);
|
||||
logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFeedChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
@@ -277,16 +291,21 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
|
||||
Widget _home(BuildContext context) {
|
||||
final dramas = widget.feedController.dramas;
|
||||
if (dramas.isEmpty) return _feedChannelPlaceholder(context);
|
||||
return PlayerPage(
|
||||
homeMode: true,
|
||||
embedded: true,
|
||||
active: logic.tab == 0,
|
||||
drama: widget.dramas.first,
|
||||
drama: dramas.first,
|
||||
initialIndex: 0,
|
||||
feedDramas: widget.dramas,
|
||||
feedDramas: dramas,
|
||||
feedChannel: widget.feedController.channel,
|
||||
onOpenFullPlayer: (drama, episode) =>
|
||||
logic.playDrama(context, drama, episodeId: episode.id),
|
||||
onRefreshFeed: widget.onRefreshFeed,
|
||||
onRefreshFeed: widget.feedController.refreshCurrent,
|
||||
onLoadMore: widget.feedController.loadMore,
|
||||
onChannelSelected: _selectFeedChannel,
|
||||
onDramaChanged: logic.onDramaChanged,
|
||||
onImmersiveChanged: logic.onImmersiveChanged,
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
@@ -295,6 +314,65 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _feedChannelPlaceholder(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
HomeFeedHeader(
|
||||
l10n: l10n,
|
||||
channel: widget.feedController.channel,
|
||||
onChannelSelected: _selectFeedChannel,
|
||||
onRefresh: () => _refreshPlaceholder(context),
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
onMore: () {},
|
||||
),
|
||||
Expanded(
|
||||
child: widget.feedController.loadingFirstPage
|
||||
? const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 24,
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('home-feed-channel-loading'),
|
||||
strokeWidth: 2.4,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
)
|
||||
: EmptyState(
|
||||
icon: Icons.movie_outlined,
|
||||
title: l10n.noStories,
|
||||
actionLabel: l10n.retry,
|
||||
onAction: () =>
|
||||
unawaited(widget.feedController.refreshCurrent()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _refreshPlaceholder(BuildContext context) async {
|
||||
final ok = await widget.feedController.refreshCurrent();
|
||||
if (!ok && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)),
|
||||
);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
void _selectFeedChannel(HomeFeedChannel channel) {
|
||||
unawaited(widget.feedController.selectChannel(channel));
|
||||
}
|
||||
|
||||
Widget _discover(BuildContext context) {
|
||||
return DiscoverPage(
|
||||
active: logic.tab == 1,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||
@@ -136,6 +137,13 @@ class ScriptedCatalogRepository implements CatalogRepository {
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => feed;
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async => const FeedPage();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
detailCalls++;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
void main() {
|
||||
String envelope(Object? data) =>
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': data});
|
||||
|
||||
test('should_send_types_and_cursor_when_feed_query_has_filters', () async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope({
|
||||
'list': [
|
||||
{
|
||||
'id': '11',
|
||||
'title': 'Anime',
|
||||
'cover': '/files/anime.png',
|
||||
'preview': {
|
||||
'id': '12',
|
||||
'number': 1,
|
||||
'title': 'Episode 1',
|
||||
'isFree': true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'nextCursor': 10,
|
||||
'hasMore': true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
final api = CatalogApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.feed(
|
||||
types: const ['ANIME', 'LIVE'],
|
||||
cursor: '9',
|
||||
size: 20,
|
||||
);
|
||||
|
||||
expect(adapter.requests.single.uri.path, '/app/feed');
|
||||
expect(adapter.requests.single.queryParameters, {
|
||||
'types': 'ANIME,LIVE',
|
||||
'cursor': '9',
|
||||
'size': '20',
|
||||
});
|
||||
expect(page.dramas.single.id, '11');
|
||||
expect(page.nextCursor, '10');
|
||||
expect(page.hasMore, isTrue);
|
||||
});
|
||||
|
||||
test(
|
||||
'should_omit_empty_types_and_treat_null_cursor_string_as_empty',
|
||||
() async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(
|
||||
envelope({'list': [], 'nextCursor': 'null', 'hasMore': false}),
|
||||
),
|
||||
);
|
||||
final api = CatalogApi(
|
||||
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
|
||||
);
|
||||
|
||||
final page = await api.feed();
|
||||
|
||||
expect(adapter.requests.single.queryParameters, {'size': '10'});
|
||||
expect(page.dramas, isEmpty);
|
||||
expect(page.nextCursor, isNull);
|
||||
expect(page.hasMore, isFalse);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/home_feed_controller.dart';
|
||||
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'should_seed_recommend_without_requesting_when_initial_feed_is_ready',
|
||||
() async {
|
||||
final repository = _ScriptedFeedRepository();
|
||||
final controller = HomeFeedController(repository);
|
||||
|
||||
controller.seedRecommend([_drama('seed')]);
|
||||
|
||||
expect(controller.channel, HomeFeedChannel.recommend);
|
||||
expect(controller.dramas.map((drama) => drama.id), ['seed']);
|
||||
expect(controller.loaded, isTrue);
|
||||
expect(repository.calls, isEmpty);
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_request_anime_live_and_recommend_with_expected_types', () async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('anime')]))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('recommend')]));
|
||||
final controller = HomeFeedController(repository);
|
||||
controller.seedRecommend([_drama('seed')]);
|
||||
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
await controller.selectChannel(HomeFeedChannel.live);
|
||||
await controller.selectChannel(HomeFeedChannel.recommend);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(repository.calls.map((call) => call.types), [
|
||||
['ANIME'],
|
||||
['LIVE'],
|
||||
[],
|
||||
]);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'should_keep_cached_data_while_silently_refreshing_cached_channel',
|
||||
() async {
|
||||
final refresh = Completer<FeedPage>();
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('anime-old')]))
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('live')]))
|
||||
..enqueue(() => refresh.future);
|
||||
final controller = HomeFeedController(repository);
|
||||
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
await controller.selectChannel(HomeFeedChannel.live);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
expect(controller.dramas.single.id, 'anime-old');
|
||||
refresh.complete(FeedPage(dramas: [_drama('anime-new')]));
|
||||
await pumpEventQueue();
|
||||
expect(controller.dramas.single.id, 'anime-new');
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('should_append_unique_items_and_stop_when_has_more_is_false', () async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(
|
||||
() async => FeedPage(
|
||||
dramas: [_drama('a'), _drama('b')],
|
||||
nextCursor: 'next',
|
||||
hasMore: true,
|
||||
),
|
||||
)
|
||||
..enqueue(
|
||||
() async =>
|
||||
FeedPage(dramas: [_drama('b'), _drama('c')], hasMore: false),
|
||||
);
|
||||
final controller = HomeFeedController(repository);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
await controller.loadMore();
|
||||
await controller.loadMore();
|
||||
|
||||
expect(controller.dramas.map((drama) => drama.id), ['a', 'b', 'c']);
|
||||
expect(repository.calls, hasLength(2));
|
||||
expect(repository.calls.last.cursor, 'next');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_ignore_stale_response_when_channels_switch_quickly', () async {
|
||||
final anime = Completer<FeedPage>();
|
||||
final live = Completer<FeedPage>();
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() => anime.future)
|
||||
..enqueue(() => live.future);
|
||||
final controller = HomeFeedController(repository);
|
||||
|
||||
final animeLoad = controller.selectChannel(HomeFeedChannel.anime);
|
||||
final liveLoad = controller.selectChannel(HomeFeedChannel.live);
|
||||
live.complete(FeedPage(dramas: [_drama('live')]));
|
||||
await liveLoad;
|
||||
anime.complete(FeedPage(dramas: [_drama('anime')]));
|
||||
await animeLoad;
|
||||
|
||||
expect(controller.channel, HomeFeedChannel.live);
|
||||
expect(controller.dramas.single.id, 'live');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_keep_old_data_when_refresh_fails', () async {
|
||||
final repository = _ScriptedFeedRepository()
|
||||
..enqueue(() async => FeedPage(dramas: [_drama('old')]))
|
||||
..enqueue(() async => throw StateError('offline'));
|
||||
final controller = HomeFeedController(repository);
|
||||
await controller.selectChannel(HomeFeedChannel.anime);
|
||||
|
||||
final refreshed = await controller.refreshCurrent();
|
||||
|
||||
expect(refreshed, isFalse);
|
||||
expect(controller.dramas.single.id, 'old');
|
||||
expect(controller.lastError, contains('offline'));
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class FeedCall {
|
||||
const FeedCall({
|
||||
required this.types,
|
||||
required this.cursor,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
final List<String> types;
|
||||
final String? cursor;
|
||||
final int size;
|
||||
}
|
||||
|
||||
class _ScriptedFeedRepository implements CatalogRepository {
|
||||
final List<FeedCall> calls = [];
|
||||
final List<Future<FeedPage> Function()> _responses = [];
|
||||
|
||||
void enqueue(Future<FeedPage> Function() response) {
|
||||
_responses.add(response);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) {
|
||||
calls.add(FeedCall(types: types, cursor: cursor, size: size));
|
||||
return _responses.removeAt(0)();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchHomeFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async => _drama(dramaId);
|
||||
}
|
||||
|
||||
Drama _drama(String id) =>
|
||||
Drama(id: id, title: id, synopsis: id, posterUrl: '');
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
|
||||
@@ -59,6 +60,13 @@ class _FakeCatalog implements CatalogRepository {
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async => const FeedPage();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async => _drama;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_emit_channel_callbacks_and_render_selected_channel', (
|
||||
tester,
|
||||
) async {
|
||||
final selected = <HomeFeedChannel>[];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: HomeFeedHeader(
|
||||
l10n: AppLocalizations.of(context)!,
|
||||
channel: HomeFeedChannel.anime,
|
||||
onChannelSelected: selected.add,
|
||||
onRefresh: () async => true,
|
||||
onSearch: () {},
|
||||
onMore: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('home-feed-tab-anime')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-tab-real')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Anime')).style?.fontWeight,
|
||||
FontWeight.w800,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-feed-tab-real')));
|
||||
await tester.tap(find.byKey(const Key('home-feed-tab-recommend')));
|
||||
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
|
||||
|
||||
expect(selected, [
|
||||
HomeFeedChannel.live,
|
||||
HomeFeedChannel.recommend,
|
||||
HomeFeedChannel.anime,
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
@@ -439,6 +440,8 @@ void main() {
|
||||
builder: (context) => Scaffold(
|
||||
body: HomeFeedHeader(
|
||||
l10n: AppLocalizations.of(context)!,
|
||||
channel: HomeFeedChannel.recommend,
|
||||
onChannelSelected: (_) {},
|
||||
onRefresh: () {
|
||||
calls++;
|
||||
return completer.future;
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/config/app_config.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
@@ -158,6 +159,13 @@ class _EmptyCatalogRepository implements CatalogRepository {
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async => const FeedPage();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async => _feed.dramas.first;
|
||||
}
|
||||
@@ -177,6 +185,13 @@ class _PendingCatalogRepository implements CatalogRepository {
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => _feed;
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async => FeedPage(dramas: _feed.dramas);
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async => _feed.dramas.first;
|
||||
}
|
||||
@@ -116,12 +116,18 @@ void main() {
|
||||
expect(player.homeMode, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('should_show_coming_soon_for_anime_tab', (tester) async {
|
||||
testWidgets('should_switch_to_anime_channel', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-feed-tab-anime')));
|
||||
await tester.pump();
|
||||
expect(find.text('Coming soon'), findsOneWidget);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Coming soon'), findsNothing);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('Anime')).style?.fontWeight,
|
||||
FontWeight.w800,
|
||||
);
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_refresh_feed_without_blank_loading', (tester) async {
|
||||
|
||||
@@ -412,6 +412,120 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('updateFeed', () {
|
||||
test('should_append_feed_without_restarting_current_drama', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final feed = [_drama('signal'), _drama('city')];
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(
|
||||
drama: feed.first,
|
||||
initialIndex: 0,
|
||||
homeMode: true,
|
||||
feedDramas: feed,
|
||||
),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
final activeBefore = logic.session.active;
|
||||
|
||||
logic.updateFeed([...feed, _drama('third')], keepDramaId: 'signal');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(logic.currentDramaId, 'signal');
|
||||
expect(logic.visibleIndices, [0, 1, 2]);
|
||||
expect(logic.sessionEpisodes, hasLength(3));
|
||||
expect(logic.session.active, same(activeBefore));
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('should_remap_current_drama_without_restarting_when_order_changes',
|
||||
() async {
|
||||
final factory = _RecordingFactory();
|
||||
final feed = [_drama('signal'), _drama('city')];
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(
|
||||
drama: feed.first,
|
||||
initialIndex: 1,
|
||||
homeMode: true,
|
||||
feedDramas: feed,
|
||||
),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
final activeBefore = logic.session.active;
|
||||
|
||||
logic.updateFeed(
|
||||
[_drama('city'), _drama('signal'), _drama('third')],
|
||||
keepDramaId: 'city',
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(logic.currentDramaId, 'city');
|
||||
expect(logic.session.currentIndex, 0);
|
||||
expect(logic.session.active, same(activeBefore));
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('should_return_to_first_drama_when_feed_channel_changes', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final feed = [_drama('signal'), _drama('city')];
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(
|
||||
drama: feed.first,
|
||||
initialIndex: 0,
|
||||
homeMode: true,
|
||||
feedDramas: feed,
|
||||
),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
logic.updateFeed([_drama('anime-a'), _drama('anime-b')]);
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.queue.entryAt(0).drama.id, 'anime-a');
|
||||
expect(logic.currentDramaId, 'anime-a');
|
||||
expect(logic.session.currentIndex, 0);
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('should_clear_queue_safely_when_feed_becomes_empty', () async {
|
||||
final feed = [_drama('signal')];
|
||||
final logic = _logic(FakeWatchHistory());
|
||||
logic.attach(
|
||||
PlayerPage(
|
||||
drama: feed.first,
|
||||
initialIndex: 0,
|
||||
homeMode: true,
|
||||
feedDramas: feed,
|
||||
),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: _RecordingFactory().create,
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
|
||||
logic.updateFeed(const []);
|
||||
|
||||
expect(logic.queue.length, 0);
|
||||
expect(logic.visibleIndices, isEmpty);
|
||||
expect(logic.currentDramaId, isNull);
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
});
|
||||
|
||||
group('lifecycle', () {
|
||||
test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async {
|
||||
final factory = _RecordingFactory();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/search_result.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
@@ -18,6 +19,23 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository {
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => _feed();
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async {
|
||||
final filtered = types.isEmpty
|
||||
? dramas
|
||||
: dramas.where((drama) => _matchesFeedTypes(drama, types)).toList();
|
||||
return FeedPage(dramas: filtered.take(size).toList());
|
||||
}
|
||||
|
||||
bool _matchesFeedTypes(Drama drama, List<String> types) {
|
||||
final dramaType = drama.id == 'the-signal-demo' ? 'ANIME' : 'LIVE';
|
||||
return types.contains(dramaType);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
return dramas.firstWhere((drama) => drama.id == dramaId);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
@@ -265,6 +266,13 @@ class ScriptedWatchCatalog implements CatalogRepository {
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => const HomeFeed();
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) async => const FeedPage();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
calls.add(dramaId);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
@@ -271,6 +272,13 @@ class _FailingWatchCatalog implements CatalogRepository {
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() => _delegate.fetchDiscoverFeed();
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchFeed({
|
||||
List<String> types = const [],
|
||||
String? cursor,
|
||||
int size = 10,
|
||||
}) => _delegate.fetchFeed(types: types, cursor: cursor, size: size);
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
if (dramaId == 'broken') throw StateError('missing detail');
|
||||
|
||||
Reference in new issue
Block a user