chore(client): 归档原型阶段未提交改动(binding/logic/view 分层与各页迭代)
为什么:客户端原型在 14d7bd3 之后又做了一轮(页面分层、发现页、分类滑动、播放器控件等),
一直留在工作区没入库。下一批账号链路要改其中的 app.dart / home_view / player_view / mine_view,
先归档这批既存改动,本批的实际 diff 才说得清边界。
只归档,不改行为:当前工作区 flutter analyze 0 问题、flutter test 148 全过。
This commit is contained in:
1 parent
14d7bd39c7
commit
77d5748dbb
33 files changed
+1264
-1064
No files matched your search
@@ -12,6 +12,39 @@
|
||||
|
||||
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
|
||||
|
||||
## 对接后端
|
||||
|
||||
目录数据来自短剧服务的客户端接口(`FruitServer` 的 `reelshort-app-api`,默认 `http://127.0.0.1:8081`):
|
||||
|
||||
- `GET /app/home/feed`、`GET /app/discover/feed`:轮播 + 分区,一次请求渲染整页。
|
||||
每张卡片带 `preview`(首个可播免费集),首页竖屏播放可以直接起播;
|
||||
没有片源的剧不下发 `preview`,也不会出现在首页的滑动队列里
|
||||
- `GET /app/dramas/{id}`:详情(含剧集列表;可播放的剧集直接带播放地址)
|
||||
- `GET /app/episodes/{id}/play`:播放票据,返回可访问性与地址
|
||||
|
||||
启动后端后直接 `flutter run` 或 Xcode 里 Run 即可,接口地址按运行环境自动选择
|
||||
(见 `lib/core/config/app_config.dart`):
|
||||
|
||||
| 运行环境 | 默认地址 |
|
||||
| --- | --- |
|
||||
| iOS 模拟器 / 桌面端 | `http://127.0.0.1:8081` |
|
||||
| iPhone 真机 | [`devHostBaseUrl`],即电脑当前的局域网地址(`ipconfig getifaddr en0`) |
|
||||
| Android 模拟器 | 需手动指定 `--dart-define=API_BASE_URL=http://10.0.2.2:8081` |
|
||||
|
||||
任何环境都可以用 `--dart-define=API_BASE_URL=http://x.x.x.x:8081` 覆盖默认值。
|
||||
电脑换网络后局域网地址会变,改 `devHostBaseUrl` 即可;也可以换成 Bonjour 名字
|
||||
(`http://$(scutil --get LocalHostName).local:8081`),换网络也不用改。
|
||||
|
||||
真机调试还需注意:手机和电脑在同一个 Wi-Fi;首次启动时 iPhone 会弹「本地网络」权限,
|
||||
必须允许;iOS 的明文 HTTP 与局域网访问已在 `ios/Runner/Info.plist` 里放行
|
||||
(`NSAllowsLocalNetworking`、`NSLocalNetworkUsageDescription`),改动这些配置后
|
||||
要重新构建安装,热重载不生效;如果仍连不上,到「系统设置 → 网络 → 防火墙 → 选项」
|
||||
把 `java` 设为允许传入连接。
|
||||
|
||||
封面与视频都走 `http://<host>:8081/files/**`,本地不再打包演示素材
|
||||
(原先 `assets/media` 里的演示视频有 316MB,已从打包清单移除)。
|
||||
|
||||
## 当前说明
|
||||
|
||||
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
|
||||
收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发);
|
||||
金币与会员相关界面仍是占位数据。
|
||||
@@ -4,4 +4,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
@@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="Nontonki"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -4,4 +4,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
@@ -360,7 +360,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -487,7 +487,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -538,7 +538,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -1,19 +1,51 @@
|
||||
enum DramaFormat { animated, liveAction }
|
||||
|
||||
/// 短剧与剧集,字段与后端 `/app/**` 的下发结构对应。
|
||||
///
|
||||
/// 封面与播放地址都是后端返回的相对路径(`/files/...`),
|
||||
/// 渲染前用 `AppConfig.mediaUrl` 补上服务地址。
|
||||
class Episode {
|
||||
const Episode({
|
||||
required this.id,
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.assetPath,
|
||||
required this.isFree,
|
||||
this.videoUrl = '',
|
||||
this.durationSec = 0,
|
||||
this.locked = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int number;
|
||||
final String title;
|
||||
final String assetPath;
|
||||
final bool isFree;
|
||||
|
||||
/// 播放地址:详情接口不下发,由 `/app/episodes/{id}/play` 返回后写入。
|
||||
final String videoUrl;
|
||||
final int durationSec;
|
||||
|
||||
/// 后端判定需要解锁;解锁能力(金币/VIP)在 M3 接入。
|
||||
final bool locked;
|
||||
|
||||
Episode withVideoUrl(String url) => Episode(
|
||||
id: id,
|
||||
number: number,
|
||||
title: title,
|
||||
isFree: isFree,
|
||||
videoUrl: url,
|
||||
durationSec: durationSec,
|
||||
locked: locked,
|
||||
);
|
||||
|
||||
factory Episode.fromJson(Map<String, dynamic> json) {
|
||||
return Episode(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
number: (json['number'] as num?)?.toInt() ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
isFree: json['isFree'] == true,
|
||||
durationSec: (json['durationSec'] as num?)?.toInt() ?? 0,
|
||||
locked: json['locked'] == true,
|
||||
videoUrl: json['playUrl'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Drama {
|
||||
@@ -21,15 +53,73 @@ class Drama {
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.synopsis,
|
||||
required this.posterAsset,
|
||||
required this.episodes,
|
||||
this.format = DramaFormat.liveAction,
|
||||
required this.posterUrl,
|
||||
this.episodes = const [],
|
||||
this.category = '',
|
||||
this.genre = '',
|
||||
this.totalEpisodes = 0,
|
||||
this.freeEpisodes = 0,
|
||||
this.isHot = false,
|
||||
this.viewCount = 0,
|
||||
this.detailLoaded = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String synopsis;
|
||||
final String posterAsset;
|
||||
final String posterUrl;
|
||||
final List<Episode> episodes;
|
||||
final DramaFormat format;
|
||||
final String category;
|
||||
final String genre;
|
||||
final int totalEpisodes;
|
||||
final int freeEpisodes;
|
||||
final bool isHot;
|
||||
final int viewCount;
|
||||
|
||||
/// 详情接口是否已经取过完整剧集列表。
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时再进详情。
|
||||
final bool detailLoaded;
|
||||
|
||||
/// 卡片只带一集预览,集数以后端给的总集数为准。
|
||||
int get episodeCount => totalEpisodes > 0 ? totalEpisodes : episodes.length;
|
||||
|
||||
/// 列表卡片:带一集可播预览(首页直接起播),完整剧集进详情再取。
|
||||
factory Drama.fromCardJson(Map<String, dynamic> json) {
|
||||
final preview = json['preview'];
|
||||
return Drama(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
episodes: preview is Map<String, dynamic>
|
||||
? [Episode.fromJson(preview)]
|
||||
: const [],
|
||||
category: json['category'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
viewCount: (json['viewCount'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情:带剧集列表。
|
||||
factory Drama.fromDetailJson(Map<String, dynamic> json) {
|
||||
return Drama(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
title: json['title'] as String? ?? '',
|
||||
synopsis: json['synopsis'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
category: json['category'] as String? ?? '',
|
||||
genre: json['genre'] as String? ?? '',
|
||||
totalEpisodes: (json['totalEpisodes'] as num?)?.toInt() ?? 0,
|
||||
freeEpisodes: (json['freeEpisodes'] as num?)?.toInt() ?? 0,
|
||||
isHot: json['isHot'] == true,
|
||||
viewCount: (json['viewCount'] as num?)?.toInt() ?? 0,
|
||||
detailLoaded: true,
|
||||
episodes: (json['episodes'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Episode.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +1,36 @@
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../models/play_ticket.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
|
||||
/// 目录数据来源:首页/发现页聚合、详情、播放票据。
|
||||
///
|
||||
/// 首页卡片不带剧集,进详情或播放时才按需取,保证列表接口足够轻。
|
||||
abstract interface class CatalogRepository {
|
||||
Future<List<Drama>> fetchDramas();
|
||||
Future<HomeFeed> fetchHomeFeed();
|
||||
|
||||
Future<HomeFeed> fetchDiscoverFeed();
|
||||
|
||||
Future<Drama> fetchDrama(String dramaId);
|
||||
|
||||
Future<PlayTicket> resolvePlay(String episodeId);
|
||||
}
|
||||
|
||||
class DemoCatalogRepository implements CatalogRepository {
|
||||
class HttpCatalogRepository implements CatalogRepository {
|
||||
HttpCatalogRepository(this._api);
|
||||
|
||||
final CatalogApi _api;
|
||||
|
||||
@override
|
||||
Future<List<Drama>> fetchDramas() async => const [
|
||||
Drama(
|
||||
id: 'the-signal-demo',
|
||||
title: 'The Signal',
|
||||
synopsis:
|
||||
'A mock series for testing vertical episode playback with one reused local clip.',
|
||||
posterAsset: 'assets/images/stitch_signal_poster_clean.png',
|
||||
format: DramaFormat.animated,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'signal-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'signal-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'signal-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
Drama(
|
||||
id: 'city-lights-demo',
|
||||
title: 'City Lights & Velvet Nights',
|
||||
synopsis:
|
||||
'A mock romance series using the same local video for navigation testing.',
|
||||
posterAsset: 'assets/images/stitch_city_poster_clean.png',
|
||||
format: DramaFormat.liveAction,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'city-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'city-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'city-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
Drama(
|
||||
id: 'vengeance-demo',
|
||||
title: 'Vengeance Rising',
|
||||
synopsis:
|
||||
'A mock action series using the same local video for navigation testing.',
|
||||
posterAsset: 'assets/images/stitch_vengeance_poster_clean.png',
|
||||
format: DramaFormat.liveAction,
|
||||
episodes: [
|
||||
Episode(
|
||||
id: 'vengeance-01',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'vengeance-02',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
Episode(
|
||||
id: 'vengeance-03',
|
||||
number: 3,
|
||||
title: 'Episode 3',
|
||||
assetPath: 'assets/media/demo_clip.mp4',
|
||||
isFree: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
Future<HomeFeed> fetchHomeFeed() => _api.homeFeed();
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
|
||||
|
||||
@override
|
||||
Future<PlayTicket> resolvePlay(String episodeId) =>
|
||||
_api.playTicket(episodeId);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
|
||||
const demoProfileId = 'demo-local-v1';
|
||||
|
||||
abstract interface class WatchHistoryRepository {
|
||||
@@ -60,6 +62,17 @@ class SharedPreferencesWatchHistoryRepository
|
||||
}
|
||||
}
|
||||
|
||||
/// 续看位置:列表卡片没有剧集列表,取不到首集时退回最近观看的那一集。
|
||||
extension WatchHistoryResume on WatchHistoryRepository {
|
||||
Future<Duration?> resumePositionFor(String profileId, Drama drama) async {
|
||||
final episodeId = drama.episodes.isNotEmpty
|
||||
? drama.episodes.first.id
|
||||
: await lastEpisodeId(profileId, drama.id);
|
||||
if (episodeId == null) return null;
|
||||
return positionFor(profileId, drama.id, episodeId);
|
||||
}
|
||||
}
|
||||
|
||||
Duration clampResumePosition(Duration saved, Duration duration) {
|
||||
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
|
||||
if (saved <= Duration.zero) return Duration.zero;
|
||||
|
||||
@@ -1,26 +1,72 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../repositories/catalog_repository.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/home_feed.dart';
|
||||
import '../repositories/catalog_repository.dart';
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 目录状态:首页/发现页聚合数据 + 详情按需加载。
|
||||
///
|
||||
/// 列表卡片不含剧集,只有进详情或播放时才取一次详情,
|
||||
/// 这样首页接口能保持很小的响应体。
|
||||
class CatalogController extends ChangeNotifier {
|
||||
CatalogController(this.repository);
|
||||
|
||||
final CatalogRepository repository;
|
||||
|
||||
List<Drama> dramas = const [];
|
||||
HomeFeed feed = const HomeFeed();
|
||||
bool loading = false;
|
||||
String? error;
|
||||
|
||||
List<Drama> get dramas => feed.dramas;
|
||||
List<FeedBanner> get banners => feed.banners;
|
||||
List<FeedSection> get sections => feed.sections;
|
||||
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
dramas = await repository.fetchDramas();
|
||||
feed = await repository.fetchHomeFeed();
|
||||
} on ApiException catch (exception) {
|
||||
error = exception.message;
|
||||
} catch (_) {
|
||||
error = 'The catalog could not be loaded.';
|
||||
error = 'catalogError';
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要完整剧集时补一次详情;已加载过则直接返回,避免重复请求。
|
||||
Future<Drama> requireDetail(Drama drama) async {
|
||||
if (drama.detailLoaded) return drama;
|
||||
final detail = await repository.fetchDrama(drama.id);
|
||||
_replace(detail);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/// 播放前解析票据:返回带播放地址的剧集,锁定集保持无地址。
|
||||
Future<Episode> resolvePlay(Episode episode) async {
|
||||
if (episode.videoUrl.isNotEmpty) return episode;
|
||||
final ticket = await repository.resolvePlay(episode.id);
|
||||
return ticket.accessible ? episode.withVideoUrl(ticket.playUrl) : episode;
|
||||
}
|
||||
|
||||
void _replace(Drama detail) {
|
||||
final sections = feed.sections
|
||||
.map(
|
||||
(section) => FeedSection(
|
||||
code: section.code,
|
||||
title: section.title,
|
||||
dramas: [
|
||||
for (final drama in section.dramas)
|
||||
if (drama.id == detail.id) detail else drama,
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
feed = HomeFeed(banners: feed.banners, sections: sections);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,9 @@ class PlaybackSession extends ChangeNotifier {
|
||||
if (!slot.retiring && !_closed) slot.prepared = true;
|
||||
} catch (error) {
|
||||
slot.failure = error;
|
||||
if (kDebugMode) {
|
||||
debugPrint('片源准备失败 ${episodes[index].videoUrl}: $error');
|
||||
}
|
||||
}
|
||||
}();
|
||||
return slot;
|
||||
@@ -149,14 +152,23 @@ class PlaybackSession extends ChangeNotifier {
|
||||
/// use exactly the same mechanism without warming an unrelated episode.
|
||||
Future<void> select(int index, {int? preloadIndex}) async {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
// 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。
|
||||
// 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。
|
||||
if (!_selected || index != _currentIndex) {
|
||||
_userWantsPlay = true;
|
||||
}
|
||||
final request = ++_generation;
|
||||
_selected = true;
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
_error = null;
|
||||
if (!_visible) {
|
||||
_loading = false;
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
_loading = preparedPlayerAt(index) == null && !isLocked;
|
||||
_notify();
|
||||
if (!_visible) return;
|
||||
final acquired = await _coordinator._acquire(
|
||||
this,
|
||||
() => _visible && request == _generation,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../models/player_port.dart';
|
||||
|
||||
@@ -7,11 +8,25 @@ class VideoPlayerPort implements PlayerPort {
|
||||
VideoPlayerPort(
|
||||
Episode episode, {
|
||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||
}) : controller = VideoPlayerController.asset(
|
||||
episode.assetPath,
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
|
||||
);
|
||||
}) : controller = _open(episode, closedCaptionFile);
|
||||
|
||||
/// 后端下发的是相对路径(`/files/...`),交给播放器前补上服务地址。
|
||||
/// 否则 Uri 没有 scheme/host,AVPlayer 和 ExoPlayer 都加载不了。
|
||||
static Uri resolveUri(Episode episode) =>
|
||||
Uri.parse(AppConfig.mediaUrl(episode.videoUrl));
|
||||
|
||||
static VideoPlayerController _open(
|
||||
Episode episode,
|
||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||
) {
|
||||
final options = VideoPlayerOptions(mixWithOthers: false);
|
||||
// 片源统一由后端下发(详情里的可播放地址 / 播放票据)
|
||||
return VideoPlayerController.networkUrl(
|
||||
resolveUri(episode),
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
}
|
||||
|
||||
final VideoPlayerController controller;
|
||||
Future<void>? _initialization;
|
||||
|
||||
@@ -18,6 +18,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
final FocusNode searchFocus = FocusNode();
|
||||
final ScrollController scroll = ScrollController();
|
||||
int category = 0;
|
||||
bool searchOpen = false;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -25,11 +26,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.positionFor(
|
||||
demoProfileId,
|
||||
drama.id,
|
||||
drama.episodes.first.id,
|
||||
);
|
||||
position = history.resumePositionFor(demoProfileId, drama);
|
||||
}
|
||||
|
||||
void selectCategory(int index) {
|
||||
@@ -39,6 +36,14 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
|
||||
void onSearchChanged() => notify();
|
||||
|
||||
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
||||
|
||||
void openSearch() {
|
||||
searchOpen = true;
|
||||
notify();
|
||||
searchFocus.requestFocus();
|
||||
}
|
||||
|
||||
void syncDrama({
|
||||
required bool active,
|
||||
required bool wasActive,
|
||||
@@ -54,6 +59,8 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
notify();
|
||||
}
|
||||
if (active && searchRequest != previousSearchRequest) {
|
||||
searchOpen = true;
|
||||
notify();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isActive()) searchFocus.requestFocus();
|
||||
});
|
||||
@@ -113,8 +120,9 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
void showFilters(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final labels = [
|
||||
l10n.featured,
|
||||
l10n.suspense,
|
||||
l10n.forYou,
|
||||
l10n.hot,
|
||||
l10n.newest,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
@@ -131,7 +139,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
ListTile(
|
||||
title: Text(labels[index]),
|
||||
trailing: index == category
|
||||
? const Icon(Icons.check_rounded, color: Color(0xFFFF5B3D))
|
||||
? const Icon(Icons.check_rounded, color: Color(0xFF2F7BFF))
|
||||
: null,
|
||||
onTap: () {
|
||||
selectCategory(index);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'discover_binding.dart';
|
||||
import 'discover_logic.dart';
|
||||
import 'widgets/cover_carousel.dart';
|
||||
|
||||
class DiscoverPage extends StatefulWidget {
|
||||
const DiscoverPage({
|
||||
@@ -18,6 +23,10 @@ class DiscoverPage extends StatefulWidget {
|
||||
this.embedded = false,
|
||||
this.active = true,
|
||||
this.searchRequest = 0,
|
||||
this.signedIn = false,
|
||||
this.onSignIn,
|
||||
this.onCoins,
|
||||
this.onRewards,
|
||||
this.onAction,
|
||||
this.dramas,
|
||||
this.onDramaSelected,
|
||||
@@ -30,6 +39,10 @@ class DiscoverPage extends StatefulWidget {
|
||||
final bool embedded;
|
||||
final bool active;
|
||||
final int searchRequest;
|
||||
final bool signedIn;
|
||||
final VoidCallback? onSignIn;
|
||||
final VoidCallback? onCoins;
|
||||
final VoidCallback? onRewards;
|
||||
final ValueChanged<DiscoverPageAction>? onAction;
|
||||
final List<Drama>? dramas;
|
||||
final ValueChanged<Drama>? onDramaSelected;
|
||||
@@ -48,6 +61,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
drama: widget.drama,
|
||||
history: widget.history,
|
||||
);
|
||||
if (widget.focusSearch) logic.searchOpen = true;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -80,50 +94,64 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cards = _cardsFor(l10n, logic.category, logic.search.text);
|
||||
final catalog = context.watch<CatalogController>();
|
||||
final allDramas = widget.dramas ?? [logic.drama];
|
||||
final cards = _cardsFor(
|
||||
allDramas,
|
||||
catalog.sections,
|
||||
logic.category,
|
||||
'',
|
||||
);
|
||||
final content = SafeArea(
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(l10n),
|
||||
_header(l10n),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: logic.scroll,
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
_searchBar(l10n),
|
||||
const SizedBox(height: 12),
|
||||
_categories(l10n),
|
||||
const SizedBox(height: 15),
|
||||
_continueCard(l10n),
|
||||
const SizedBox(height: 15),
|
||||
_quickActions(l10n),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(l10n),
|
||||
const SizedBox(height: 13),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
l10n.noSearchResults,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: cards.length,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 14,
|
||||
childAspectRatio: 0.59,
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_posterCard(l10n, cards[index]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: _carousel(l10n, catalog.banners, allDramas),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_categories(l10n),
|
||||
const SizedBox(height: 18),
|
||||
_continueHeader(l10n),
|
||||
const SizedBox(height: 12),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
l10n.noSearchResults,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 196,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final card in cards) ...[
|
||||
_continueTile(l10n, card),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -142,412 +170,233 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar(AppLocalizations l10n) => SizedBox(
|
||||
height: 51,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 29,
|
||||
height: 29,
|
||||
decoration: BoxDecoration(
|
||||
color: _orange,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
l10n.discoverTab,
|
||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.search,
|
||||
onPressed: () => logic.searchFocus.requestFocus(),
|
||||
icon: const Icon(Icons.search_rounded, size: 22),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.my,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
child: const CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: Color(0xFF34353B),
|
||||
child: Icon(Icons.person_rounded, size: 19),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _searchBar(AppLocalizations l10n) => Container(
|
||||
height: 43,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF25252A),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 11),
|
||||
const Icon(Icons.search_rounded, color: Color(0xFFF2EEEF), size: 20),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
autofocus: widget.focusSearch,
|
||||
onChanged: (_) => logic.onSearchChanged(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchDiscover,
|
||||
hintStyle: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.filter,
|
||||
onPressed: () => logic.showFilters(context),
|
||||
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _categories(AppLocalizations l10n) {
|
||||
final labels = [
|
||||
l10n.featured,
|
||||
l10n.suspense,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
return SizedBox(
|
||||
height: 30,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: labels.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 7),
|
||||
itemBuilder: (context, index) => ChoiceChip(
|
||||
label: Text(labels[index]),
|
||||
selected: logic.category == index,
|
||||
onSelected: (_) => logic.selectCategory(index),
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
backgroundColor: const Color(0xFF29272B),
|
||||
selectedColor: _orange,
|
||||
side: BorderSide.none,
|
||||
labelStyle: TextStyle(
|
||||
color: logic.category == index ? Colors.white : const Color(0xFFDDD5D7),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _continueCard(AppLocalizations l10n) => FutureBuilder<Duration?>(
|
||||
future: logic.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
final total = widget.duration > Duration.zero
|
||||
? widget.duration
|
||||
: const Duration(seconds: 12);
|
||||
final progress = (position.inMilliseconds / total.inMilliseconds).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1C1D21),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: SizedBox(
|
||||
width: 55,
|
||||
height: 69,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(widget.drama.posterAsset, fit: BoxFit.cover),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 12,
|
||||
child: ColoredBox(color: Color(0xFF1C1D21)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.continueWatchingHeading,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFA490),
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
localizedDramaTitle(l10n, widget.drama),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.episodeProgress(1, widget.drama.episodes.length),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 3,
|
||||
backgroundColor: const Color(0xFF474248),
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
FilledButton.icon(
|
||||
onPressed: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.resume,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFFBD5C),
|
||||
foregroundColor: const Color(0xFF2A160B),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
minimumSize: const Size(0, 32),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 14),
|
||||
label: Text(l10n.resume),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _quickActions(AppLocalizations l10n) {
|
||||
final actions = [
|
||||
(Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)),
|
||||
(Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)),
|
||||
(
|
||||
Icons.auto_awesome_rounded,
|
||||
l10n.newReleases,
|
||||
() => logic.catalogInfo(context),
|
||||
),
|
||||
(
|
||||
Icons.bookmark_outline_rounded,
|
||||
l10n.watchlist,
|
||||
() {
|
||||
logic.act(
|
||||
context,
|
||||
DiscoverPageAction.watchlist,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
const _BrandMark(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: actions[index].$3,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 77,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF202126),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: const Color(0xFF333036),
|
||||
child: Icon(
|
||||
actions[index].$1,
|
||||
size: 19,
|
||||
color: index == 1
|
||||
? const Color(0xFFFFC15E)
|
||||
: const Color(0xFFFFAB96),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
actions[index].$2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
l10n.appName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index < actions.length - 1) const SizedBox(width: 7),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.signedIn) ...[
|
||||
_HeaderPill(
|
||||
icon: Icons.monetization_on_rounded,
|
||||
label: r'$ 0',
|
||||
onTap: widget.onCoins,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderPill(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
label: l10n.rewards,
|
||||
onTap: widget.onRewards,
|
||||
),
|
||||
] else
|
||||
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carousel(
|
||||
AppLocalizations l10n,
|
||||
List<FeedBanner> banners,
|
||||
List<Drama> allDramas,
|
||||
) {
|
||||
final items = banners.where((banner) => banner.imageUrl.isNotEmpty).toList();
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return CoverCarousel(
|
||||
items: [
|
||||
for (final banner in items)
|
||||
CoverItem(
|
||||
title: _bannerTitle(banner, allDramas),
|
||||
subtitle: '',
|
||||
image: banner.imageUrl,
|
||||
onOpen: () => _openBanner(banner, allDramas),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(AppLocalizations l10n) => Row(
|
||||
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return '';
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) return drama.title;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return;
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) {
|
||||
logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _continueHeader(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
Container(width: 3, height: 20, color: _orange),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.trendingMicroDramas,
|
||||
l10n.continueWatching,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => logic.selectCategory(0),
|
||||
child: Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(color: Color(0xFFFFB3A1)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
|
||||
final playable =
|
||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null;
|
||||
return InkWell(
|
||||
onTap: () => logic.openCard(
|
||||
context,
|
||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]),
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(card.image, fit: BoxFit.cover),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 37,
|
||||
child: ColoredBox(color: Color(0xFF101116)),
|
||||
Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) {
|
||||
final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]);
|
||||
return SizedBox(
|
||||
width: 132,
|
||||
child: InkWell(
|
||||
onTap: () => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(card.image),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xE6000000)],
|
||||
stops: [0.45, 1],
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: playable ? _orange : const Color(0xDD37343B),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
playable ? l10n.free : l10n.artworkOnly,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (drama == null)
|
||||
const ColoredBox(
|
||||
color: Color(0x99000000),
|
||||
child: Center(
|
||||
child: Icon(Icons.image_outlined, color: Colors.white),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: Text(
|
||||
playable ? l10n.freeTestMedia : l10n.videoPending,
|
||||
),
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
card.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFC789),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
drama == null ? l10n.videoPending : card.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xE6FFFFFF),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _categories(AppLocalizations l10n) {
|
||||
final labels = [
|
||||
l10n.forYou,
|
||||
l10n.hot,
|
||||
l10n.newest,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: labels.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final selected = logic.category == index;
|
||||
return GestureDetector(
|
||||
onTap: () => logic.selectCategory(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.blue : const Color(0xFF1C1E24),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
labels[index],
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : AppColors.muted,
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
card.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
card.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -637,60 +486,131 @@ class _ShowcaseCard {
|
||||
required this.title,
|
||||
required this.genre,
|
||||
required this.image,
|
||||
required this.category,
|
||||
this.dramaId,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String genre;
|
||||
final String image;
|
||||
final int category;
|
||||
final String? dramaId;
|
||||
}
|
||||
|
||||
/// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。
|
||||
List<_ShowcaseCard> _cardsFor(
|
||||
AppLocalizations l10n,
|
||||
List<Drama> dramas,
|
||||
List<FeedSection> sections,
|
||||
int category,
|
||||
String query,
|
||||
) {
|
||||
final cards = [
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaSignalTitle,
|
||||
genre: l10n.genreSuspenseMystery,
|
||||
image: 'assets/images/stitch_signal_poster_clean.png',
|
||||
category: 1,
|
||||
dramaId: 'the-signal-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaCityTitle,
|
||||
genre: l10n.genreUrbanRomance,
|
||||
image: 'assets/images/stitch_city_poster_clean.png',
|
||||
category: 2,
|
||||
dramaId: 'city-lights-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaVengeanceTitle,
|
||||
genre: l10n.genreActionThriller,
|
||||
image: 'assets/images/stitch_vengeance_poster_clean.png',
|
||||
category: 3,
|
||||
dramaId: 'vengeance-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaEchoesTitle,
|
||||
genre: l10n.genreDramaMystery,
|
||||
image: 'assets/images/stitch_rain_scene.jpg',
|
||||
category: 1,
|
||||
),
|
||||
];
|
||||
final source = switch (category) {
|
||||
0 => dramas,
|
||||
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
|
||||
};
|
||||
final normalized = query.trim().toLowerCase();
|
||||
return cards.where((card) {
|
||||
final categoryMatches = category == 0 || card.category == category;
|
||||
final queryMatches =
|
||||
normalized.isEmpty ||
|
||||
card.title.toLowerCase().contains(normalized) ||
|
||||
card.genre.toLowerCase().contains(normalized);
|
||||
return categoryMatches && queryMatches;
|
||||
}).toList();
|
||||
return [
|
||||
for (final drama in source)
|
||||
if (normalized.isEmpty ||
|
||||
drama.title.toLowerCase().contains(normalized) ||
|
||||
drama.genre.toLowerCase().contains(normalized) ||
|
||||
drama.category.toLowerCase().contains(normalized))
|
||||
_ShowcaseCard(
|
||||
title: drama.title,
|
||||
genre: drama.genre.isEmpty ? drama.category : drama.genre,
|
||||
image: drama.posterUrl,
|
||||
dramaId: drama.id,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const _orange = Color(0xFFFF5B3D);
|
||||
const _orange = AppColors.blue;
|
||||
const _coin = Color(0xFFF5C451);
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
const _BrandMark();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderPill extends StatelessWidget {
|
||||
const _HeaderPill({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF1C1E24),
|
||||
shape: const StadiumBorder(side: BorderSide(color: Color(0x66F5C451))),
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: _coin),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginButton extends StatelessWidget {
|
||||
const _LoginButton({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: onTap,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: AppColors.blue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: Text(label, maxLines: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../player/player_view.dart';
|
||||
|
||||
class DramaDetailLogic extends ChangeNotifier {
|
||||
DramaDetailLogic({required this.drama, required this.history});
|
||||
|
||||
final Drama drama;
|
||||
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
|
||||
Drama drama;
|
||||
final WatchHistoryRepository history;
|
||||
bool loading = false;
|
||||
bool failed = false;
|
||||
|
||||
/// 接口给出的失败原因;为空时界面回退到本地文案。
|
||||
String? failureMessage;
|
||||
int? continueIndex;
|
||||
bool _disposed = false;
|
||||
|
||||
@@ -16,6 +23,30 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
/// 列表卡片只带一集可播预览,进详情时补一次完整剧集列表。
|
||||
Future<void> load(Future<Drama> Function() fetch) async {
|
||||
if (drama.detailLoaded) {
|
||||
await loadContinue();
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
failed = false;
|
||||
failureMessage = null;
|
||||
notify();
|
||||
try {
|
||||
drama = await fetch();
|
||||
await loadContinue();
|
||||
} on ApiException catch (exception) {
|
||||
failed = true;
|
||||
failureMessage = exception.message;
|
||||
} catch (_) {
|
||||
failed = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadContinue() async {
|
||||
try {
|
||||
final id = await history.lastEpisodeId(demoProfileId, drama.id);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'drama_detail_binding.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
@@ -21,7 +26,15 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = DramaDetailBinding().create(drama: widget.drama);
|
||||
logic.loadContinue();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
unawaited(
|
||||
logic.load(
|
||||
() => context.read<CatalogController>().requireDetail(widget.drama),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -40,6 +53,34 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (logic.loading) {
|
||||
return const Scaffold(
|
||||
backgroundColor: Color(0xFF111111),
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (logic.failed) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
logic.failureMessage ?? l10n.detailError,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final drama = logic.drama;
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
@@ -50,7 +91,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
|
||||
background: RemoteImage(drama.posterUrl),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
@@ -61,7 +102,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
l10n.technicalDemoCount(drama.episodes.length),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 1.4,
|
||||
color: const Color(0xFF9278EA),
|
||||
color: const Color(0xFF2F7BFF),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -112,12 +153,11 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
minTileHeight: 76,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(
|
||||
0xFFFF6B45,
|
||||
0xFF2F7BFF,
|
||||
).withValues(alpha: 0.18),
|
||||
child: Text('${episode.number}'),
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: Text(l10n.freeTestMedia),
|
||||
trailing: const Icon(Icons.play_circle_outline_rounded),
|
||||
onTap: () => logic.openPlayer(context, index),
|
||||
),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import '../../core/data/models/drama.dart';
|
||||
|
||||
enum HomeFeedCategory { following, animated, liveAction, recommended }
|
||||
|
||||
/// Maps catalog entries into home feeds without owning playback or UI state.
|
||||
class HomeFeedSections {
|
||||
HomeFeedSections(this.dramas, {this.followedDramaIds = const {}});
|
||||
|
||||
final List<Drama> dramas;
|
||||
final Set<String> followedDramaIds;
|
||||
|
||||
List<int> indicesFor(HomeFeedCategory category) => [
|
||||
for (var index = 0; index < dramas.length; index++)
|
||||
if (dramas[index].episodes.isNotEmpty &&
|
||||
switch (category) {
|
||||
HomeFeedCategory.following => followedDramaIds.contains(
|
||||
dramas[index].id,
|
||||
),
|
||||
HomeFeedCategory.animated =>
|
||||
dramas[index].format == DramaFormat.animated,
|
||||
HomeFeedCategory.liveAction =>
|
||||
dramas[index].format == DramaFormat.liveAction,
|
||||
HomeFeedCategory.recommended => true,
|
||||
})
|
||||
index,
|
||||
];
|
||||
|
||||
Drama dramaAt(HomeFeedCategory category, int categoryIndex) =>
|
||||
dramas[indicesFor(category)[categoryIndex]];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Converts horizontal drags into a previous/next category request.
|
||||
/// Placement decides where the gesture is active; the seek bar sits outside it.
|
||||
class CategorySwipeRegion extends StatefulWidget {
|
||||
const CategorySwipeRegion({
|
||||
super.key,
|
||||
required this.onShift,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final ValueChanged<int> onShift;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<CategorySwipeRegion> createState() => _CategorySwipeRegionState();
|
||||
}
|
||||
|
||||
class _CategorySwipeRegionState extends State<CategorySwipeRegion> {
|
||||
double _distance = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (_) => _distance = 0,
|
||||
onHorizontalDragUpdate: (details) => _distance += details.delta.dx,
|
||||
onHorizontalDragEnd: (details) {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
if (_distance.abs() < 44 && velocity.abs() < 300) return;
|
||||
final direction = _distance.abs() >= 44 ? _distance : velocity;
|
||||
widget.onShift(direction < 0 ? 1 : -1);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../home_feed_sections.dart';
|
||||
import 'category_swipe_region.dart';
|
||||
|
||||
/// Reused by playable and empty home feeds.
|
||||
class HomeFeedHeader extends StatelessWidget {
|
||||
const HomeFeedHeader({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.category,
|
||||
required this.onCategorySelected,
|
||||
required this.onCategoryShift,
|
||||
required this.onSearch,
|
||||
required this.onMy,
|
||||
this.onMenu,
|
||||
this.onMute,
|
||||
this.muted = false,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final HomeFeedCategory category;
|
||||
final ValueChanged<HomeFeedCategory> onCategorySelected;
|
||||
final ValueChanged<int> onCategoryShift;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback onMy;
|
||||
final VoidCallback? onMenu;
|
||||
final VoidCallback? onMute;
|
||||
final bool muted;
|
||||
|
||||
String _label(HomeFeedCategory category) => switch (category) {
|
||||
HomeFeedCategory.following => l10n.followingTab,
|
||||
HomeFeedCategory.animated => l10n.animatedTab,
|
||||
HomeFeedCategory.liveAction => l10n.liveActionTab,
|
||||
HomeFeedCategory.recommended => l10n.recommendedTab,
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IconTheme(
|
||||
data: const IconThemeData(color: Colors.white),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: const TextStyle(color: Colors.white),
|
||||
child: _header(context),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _header(BuildContext context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (onMenu != null)
|
||||
IconButton(
|
||||
key: const Key('home-open-menu'),
|
||||
tooltip: l10n.openQuickMenu,
|
||||
onPressed: onMenu,
|
||||
icon: const Icon(Icons.menu_rounded),
|
||||
),
|
||||
Container(
|
||||
width: 29,
|
||||
height: 29,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF5C37),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.homeTab,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.search,
|
||||
onPressed: onSearch,
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.myTab,
|
||||
onPressed: onMy,
|
||||
icon: const CircleAvatar(
|
||||
radius: 15,
|
||||
backgroundColor: Color(0xFF3C3A3A),
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
color: Colors.white,
|
||||
size: 19,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CategorySwipeRegion(
|
||||
key: const Key('home-category-swipe'),
|
||||
onShift: onCategoryShift,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in HomeFeedCategory.values)
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
key: Key('home-category-${item.name}'),
|
||||
onTap: () => onCategorySelected(item),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_label(item),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: category == item
|
||||
? const Color(0xFFFFC2B0)
|
||||
: const Color(0xFFF7F4F2),
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 8, color: Colors.black),
|
||||
],
|
||||
fontSize: 12,
|
||||
fontWeight: category == item
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 2,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: category == item
|
||||
? const Color(0xFFFF5C37)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onMute != null)
|
||||
IconButton(
|
||||
tooltip: muted ? l10n.unmute : l10n.mute,
|
||||
onPressed: onMute,
|
||||
icon: Icon(
|
||||
muted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
|
||||
size: 21,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../../../core/config/locale_controller.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
|
||||
@@ -27,7 +28,7 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
|
||||
static const _panel = Color(0xFF222225);
|
||||
static const _card = Color(0xFF303033);
|
||||
static const _orange = Color(0xFFFF5C37);
|
||||
static const _orange = Color(0xFF2F7BFF);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -88,11 +89,10 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.asset(
|
||||
drama.posterAsset,
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
width: 74,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -111,18 +111,9 @@ class HomeQuickDrawer extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodes.length),
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
style: const TextStyle(color: Color(0xFFE7E2E0)),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
l10n.freeTestMedia,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFB99E),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -29,11 +29,7 @@ class MineLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.positionFor(
|
||||
demoProfileId,
|
||||
drama.id,
|
||||
drama.episodes.first.id,
|
||||
);
|
||||
position = history.resumePositionFor(demoProfileId, drama);
|
||||
}
|
||||
|
||||
void selectSection(int index) {
|
||||
|
||||
@@ -2,11 +2,13 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/player_port.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/android_picture_in_picture.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/platform_share_service.dart';
|
||||
import '../../core/data/services/playback_queue.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
@@ -16,7 +18,6 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../discover/discover_logic.dart';
|
||||
import '../discover/discover_view.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../home/home_feed_sections.dart';
|
||||
import '../mine/mine_logic.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
import 'player_view.dart';
|
||||
@@ -30,10 +31,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
late PageController pages;
|
||||
late PlaybackQueue queue;
|
||||
late HomeFeedSections homeSections;
|
||||
late List<int> visibleIndices;
|
||||
HomeFeedCategory category = HomeFeedCategory.recommended;
|
||||
int categoryRequest = 0;
|
||||
late PlaybackSession session;
|
||||
VideoPlayerPort? trackedPlayer;
|
||||
Episode? trackedEpisode;
|
||||
@@ -47,6 +45,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
bool fullscreen = false;
|
||||
bool pip = false;
|
||||
bool pipPreparing = false;
|
||||
bool screenCleared = false;
|
||||
final Map<String, int> pickedEpisode = {};
|
||||
|
||||
bool Function() isMounted = () => false;
|
||||
bool homeMode = false;
|
||||
@@ -67,6 +67,24 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
bool get immersive => fullscreen || pip || pipPreparing;
|
||||
|
||||
bool get chromeHidden => immersive || screenCleared;
|
||||
|
||||
Episode displayEpisode(Drama drama, Episode fallback) {
|
||||
final number = pickedEpisode[drama.id];
|
||||
if (number == null) return fallback;
|
||||
for (final episode in drama.episodes) {
|
||||
if (episode.number == number) return episode;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
void setScreenCleared(bool value) {
|
||||
if (screenCleared == value) return;
|
||||
screenCleared = value;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
@@ -97,17 +115,13 @@ class PlayerLogic extends ChangeNotifier {
|
||||
queue = feedDramas == null
|
||||
? PlaybackQueue.series(pageDrama)
|
||||
: PlaybackQueue.home(feedDramas!);
|
||||
homeSections = HomeFeedSections([
|
||||
for (final entry in queue.entries) entry.drama,
|
||||
]);
|
||||
visibleIndices = homeMode
|
||||
? homeSections.indicesFor(category)
|
||||
: List<int>.generate(queue.length, (index) => index);
|
||||
visibleIndices = List<int>.generate(queue.length, (index) => index);
|
||||
pages = PageController(initialPage: initialIndex);
|
||||
session = PlaybackSession(
|
||||
episodes: [for (final entry in queue.entries) entry.episode],
|
||||
factory: factory,
|
||||
canPlay: (episode) => episode.isFree,
|
||||
// 后端只在可播放时才下发地址(免费集或已解锁),拿不到地址即视为锁定
|
||||
canPlay: (episode) => episode.videoUrl.isNotEmpty,
|
||||
prepare: preparePlayer,
|
||||
activate: activatePlayer,
|
||||
coordinator: coordinator,
|
||||
@@ -135,8 +149,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (isMounted() && backRequest == request) {
|
||||
if (fullscreen) {
|
||||
unawaited(setFullscreen(false));
|
||||
} else if (screenCleared) {
|
||||
setScreenCleared(false);
|
||||
} else {
|
||||
shiftCategory(-1);
|
||||
onMenu?.call();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -170,7 +186,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void notifyImmersive() => onImmersiveChanged?.call(immersive);
|
||||
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
|
||||
|
||||
Future<void> setFullscreen(bool value) async {
|
||||
if (fullscreen == value || !isMounted()) return;
|
||||
@@ -192,7 +208,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
} catch (_) {
|
||||
if (value && isMounted()) {
|
||||
fullscreen = false;
|
||||
notify();
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
}
|
||||
@@ -226,7 +242,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setAppResumed(true);
|
||||
} else {
|
||||
pipPreparing = false;
|
||||
notify();
|
||||
notify();
|
||||
notifyImmersive();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
|
||||
@@ -327,7 +343,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
} catch (_) {
|
||||
// A category change can detach the old page controller mid-animation.
|
||||
// The page can leave the tree before the animation finishes.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,49 +387,6 @@ class PlayerLogic extends ChangeNotifier {
|
||||
: null;
|
||||
}
|
||||
|
||||
void shiftCategory(int offset) {
|
||||
if (!homeMode) return;
|
||||
final next = category.index + offset;
|
||||
if (next < 0) {
|
||||
onMenu?.call();
|
||||
return;
|
||||
}
|
||||
if (next >= HomeFeedCategory.values.length) return;
|
||||
unawaited(selectCategory(HomeFeedCategory.values[next]));
|
||||
}
|
||||
|
||||
Future<void> selectCategory(HomeFeedCategory next) async {
|
||||
if (!homeMode || next == category) return;
|
||||
final request = ++categoryRequest;
|
||||
++pageRequest;
|
||||
session.setRouteVisible(false);
|
||||
cancelScrub();
|
||||
await saveCurrentProgress();
|
||||
if (!isMounted() || request != categoryRequest) return;
|
||||
|
||||
final oldPages = pages;
|
||||
final indices = homeSections.indicesFor(next);
|
||||
category = next;
|
||||
visibleIndices = indices;
|
||||
pages = PageController();
|
||||
notify();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => oldPages.dispose());
|
||||
|
||||
if (indices.isEmpty) return;
|
||||
onDramaChanged?.call(queue.entryAt(indices.first).drama);
|
||||
await session.select(
|
||||
indices.first,
|
||||
preloadIndex: nextVisibleIndex(indices.first),
|
||||
);
|
||||
if (isMounted() && request == categoryRequest) {
|
||||
if (stoppedAtFeedEnd) {
|
||||
stoppedAtFeedEnd = false;
|
||||
session.setUserWantsPlay(true);
|
||||
}
|
||||
session.setRouteVisible(active);
|
||||
}
|
||||
}
|
||||
|
||||
void beginScrub(VideoPlayerPort player) {
|
||||
if (session.active != player) return;
|
||||
scrubbingPlayer = player;
|
||||
@@ -426,7 +399,11 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
|
||||
Future<void> seekTo(BuildContext context, VideoPlayerPort player, Duration position) async {
|
||||
Future<void> seekTo(
|
||||
BuildContext context,
|
||||
VideoPlayerPort player,
|
||||
Duration position,
|
||||
) async {
|
||||
if (scrubbingPlayer != player || session.active != player) return;
|
||||
try {
|
||||
await session.playbackSettled;
|
||||
@@ -464,12 +441,31 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (isMounted()) session.setRouteVisible(active);
|
||||
}
|
||||
|
||||
Future<void> openSeriesAt(
|
||||
BuildContext context,
|
||||
Drama drama,
|
||||
int index,
|
||||
) async {
|
||||
session.setRouteVisible(false);
|
||||
await session.playbackSettled;
|
||||
await saveCurrentProgress();
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlayerPage(drama: drama, initialIndex: index),
|
||||
),
|
||||
);
|
||||
if (isMounted()) session.setRouteVisible(active);
|
||||
}
|
||||
|
||||
Future<void> showEpisodes(BuildContext context) async {
|
||||
if (feedDramas != null) {
|
||||
await openDetail(context);
|
||||
return;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final entry = queue.entryAt(session.currentIndex);
|
||||
final drama = await _withFullEpisodes(context, entry.drama);
|
||||
if (!context.mounted) return;
|
||||
final homeFeed = feedDramas != null;
|
||||
final episodes = drama.episodes;
|
||||
final currentNumber = entry.episode.number;
|
||||
session.setOverlayOpen(true);
|
||||
await session.playbackSettled;
|
||||
if (!context.mounted) {
|
||||
@@ -479,49 +475,57 @@ class PlayerLogic extends ChangeNotifier {
|
||||
try {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF202024),
|
||||
backgroundColor: const Color(0xFF16171C),
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.6,
|
||||
height: MediaQuery.sizeOf(sheetContext).height * 0.46,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
|
||||
child: Text(
|
||||
l10n.episodes,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFB7B3B8),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: queue.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = queue.entryAt(index).episode;
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: const Color(0xFF3A3030),
|
||||
child: Text('${episode.number}'),
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 6,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 1.45,
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: Text(l10n.freeTestMedia),
|
||||
trailing: const Icon(Icons.play_arrow_rounded),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
unawaited(
|
||||
pages.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (_, index) => _episodeCell(
|
||||
context,
|
||||
sheetContext,
|
||||
drama,
|
||||
episodes[index],
|
||||
index,
|
||||
homeFeed,
|
||||
currentNumber,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -534,6 +538,68 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
|
||||
/// 取不到(或页面外没有目录来源)就退回卡片本身。
|
||||
Future<Drama> _withFullEpisodes(BuildContext context, Drama drama) async {
|
||||
if (drama.detailLoaded) return drama;
|
||||
final CatalogController catalog;
|
||||
try {
|
||||
catalog = context.read<CatalogController>();
|
||||
} catch (_) {
|
||||
return drama;
|
||||
}
|
||||
try {
|
||||
return await catalog.requireDetail(drama);
|
||||
} catch (_) {
|
||||
return drama;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _episodeCell(
|
||||
BuildContext pageContext,
|
||||
BuildContext sheetContext,
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
int index,
|
||||
bool homeFeed,
|
||||
int currentNumber,
|
||||
) {
|
||||
final selected = episode.number == currentNumber;
|
||||
return Material(
|
||||
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFF2A2C33),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (homeFeed) {
|
||||
if (episode.number == currentNumber) return;
|
||||
unawaited(openSeriesAt(pageContext, drama, index));
|
||||
return;
|
||||
}
|
||||
if (index == session.currentIndex) return;
|
||||
unawaited(
|
||||
pages.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${episode.number}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showMy(BuildContext context, {int initialSection = 0}) async {
|
||||
if (embedded) {
|
||||
onMy?.call();
|
||||
@@ -574,7 +640,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await showDiscover(context, focusSearch: true);
|
||||
}
|
||||
|
||||
Future<void> showDiscover(BuildContext context, {bool focusSearch = false}) async {
|
||||
Future<void> showDiscover(
|
||||
BuildContext context, {
|
||||
bool focusSearch = false,
|
||||
}) async {
|
||||
if (embedded) {
|
||||
onDiscover?.call();
|
||||
return;
|
||||
@@ -601,7 +670,8 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await openDetail(context);
|
||||
} else if (action == DiscoverPageAction.my ||
|
||||
action == DiscoverPageAction.watchlist) {
|
||||
await showMy(context,
|
||||
await showMy(
|
||||
context,
|
||||
initialSection: action == DiscoverPageAction.watchlist ? 1 : 0,
|
||||
);
|
||||
} else {
|
||||
@@ -661,5 +731,4 @@ class PlayerLogic extends ChangeNotifier {
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,7 +22,6 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.showTimes = true,
|
||||
this.compactIdle = false,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -30,7 +29,6 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
final bool showTimes;
|
||||
final bool compactIdle;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -99,20 +97,32 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.showTimes && scrubbing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
'${_format(previewPosition)} / ${_format(video.duration)}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: widget.compactIdle && !scrubbing ? 20 : 36,
|
||||
height: scrubbing ? 48 : 44,
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: widget.compactIdle && !scrubbing ? 2 : 3,
|
||||
activeTrackColor: const Color(0xFFFF5C37),
|
||||
trackHeight: scrubbing ? 4 : 3,
|
||||
activeTrackColor: const Color(0xFF2F7BFF),
|
||||
inactiveTrackColor: Colors.white30,
|
||||
thumbColor: Colors.white,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6,
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11,
|
||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
@@ -134,20 +144,6 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showTimes && (!widget.compactIdle || scrubbing))
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_format(previewPosition),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
Text(
|
||||
_format(video.duration),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,22 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.15.7"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -418,6 +434,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+4
-7
@@ -34,6 +34,8 @@ dependencies:
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
# 只用官方 http:纯 Dart、体积小;不引 dio/代码生成/图片缓存,控制包体积
|
||||
http: ^1.2.2
|
||||
provider: ^6.1.5+1
|
||||
video_player: ^2.14.0
|
||||
shared_preferences: ^2.5.5
|
||||
@@ -59,13 +61,8 @@ dev_dependencies:
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
generate: true
|
||||
assets:
|
||||
- assets/media/demo_clip.mp4
|
||||
- assets/images/demo_poster.jpg
|
||||
- assets/images/stitch_signal_poster_clean.png
|
||||
- assets/images/stitch_city_poster_clean.png
|
||||
- assets/images/stitch_vengeance_poster_clean.png
|
||||
- assets/images/stitch_rain_scene.jpg
|
||||
# 封面与视频全部由后端下发(/files/**),本地不再打包演示素材:
|
||||
# 原先 assets/media 下的演示视频有 316MB,会把安装包撑到无法接受的大小。
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
|
||||
@@ -1,39 +1,131 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/models/home_feed.dart';
|
||||
import 'package:nontonki/core/data/models/play_ticket.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:nontonki/core/data/services/catalog_controller.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'empty catalog is distinct from a failed load, and retry recovers',
|
||||
() async {
|
||||
final repo = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repo);
|
||||
await controller.load();
|
||||
expect(controller.dramas, isEmpty);
|
||||
expect(controller.error, isNull);
|
||||
test('空目录与加载失败可区分,重试后恢复', () async {
|
||||
final repository = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repository);
|
||||
|
||||
repo.fail = true;
|
||||
await controller.load();
|
||||
expect(controller.error, isNotNull);
|
||||
await controller.load();
|
||||
expect(controller.dramas, isEmpty);
|
||||
expect(controller.error, isNull);
|
||||
|
||||
repo.fail = false;
|
||||
repo.items = await DemoCatalogRepository().fetchDramas();
|
||||
await controller.load();
|
||||
expect(controller.error, isNull);
|
||||
expect(controller.dramas, hasLength(3));
|
||||
expect(controller.dramas.first.episodes.length, 3);
|
||||
},
|
||||
);
|
||||
repository.fail = true;
|
||||
await controller.load();
|
||||
expect(controller.error, isNotNull);
|
||||
|
||||
repository.fail = false;
|
||||
repository.feed = HomeFeed(
|
||||
sections: [
|
||||
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
|
||||
],
|
||||
);
|
||||
await controller.load();
|
||||
expect(controller.error, isNull);
|
||||
expect(controller.dramas, hasLength(1));
|
||||
expect(controller.sections.single.title, '热门推荐');
|
||||
});
|
||||
|
||||
test('详情按需加载:同一部短剧只请求一次', () async {
|
||||
final repository = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repository);
|
||||
repository.feed = HomeFeed(
|
||||
sections: [
|
||||
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
|
||||
],
|
||||
);
|
||||
await controller.load();
|
||||
|
||||
final card = controller.dramas.single;
|
||||
expect(card.episodes, isEmpty);
|
||||
|
||||
final detail = await controller.requireDetail(card);
|
||||
expect(detail.episodes, hasLength(2));
|
||||
expect(repository.detailCalls, 1);
|
||||
|
||||
// 第二次已经是详情,直接复用,不再请求
|
||||
await controller.requireDetail(detail);
|
||||
expect(repository.detailCalls, 1);
|
||||
expect(controller.dramas.single.episodes, hasLength(2));
|
||||
});
|
||||
|
||||
test('播放票据:可播放写入地址,锁定集保持无地址', () async {
|
||||
final repository = ScriptedCatalogRepository();
|
||||
final controller = CatalogController(repository);
|
||||
repository.tickets['10'] = const PlayTicket(
|
||||
episodeId: '10',
|
||||
accessible: true,
|
||||
accessType: PlayTicket.free,
|
||||
playUrl: '/files/episode/10.mp4',
|
||||
);
|
||||
|
||||
const free = Episode(id: '10', number: 1, title: '免费集', isFree: true);
|
||||
final resolved = await controller.resolvePlay(free);
|
||||
expect(resolved.videoUrl, '/files/episode/10.mp4');
|
||||
|
||||
const locked = Episode(
|
||||
id: '11',
|
||||
number: 2,
|
||||
title: '锁定集',
|
||||
isFree: false,
|
||||
locked: true,
|
||||
);
|
||||
final lockedResolved = await controller.resolvePlay(locked);
|
||||
expect(lockedResolved.videoUrl, isEmpty);
|
||||
});
|
||||
}
|
||||
|
||||
Drama _card(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: '简介 $id',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
);
|
||||
|
||||
Drama _detail(String id) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: '简介 $id',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
detailLoaded: true,
|
||||
episodes: const [
|
||||
Episode(id: '1', number: 1, title: '第一集', isFree: true),
|
||||
Episode(id: '2', number: 2, title: '第二集', isFree: false),
|
||||
],
|
||||
);
|
||||
|
||||
class ScriptedCatalogRepository implements CatalogRepository {
|
||||
bool fail = false;
|
||||
List<Drama> items = const [];
|
||||
HomeFeed feed = const HomeFeed();
|
||||
int detailCalls = 0;
|
||||
final Map<String, PlayTicket> tickets = {};
|
||||
|
||||
@override
|
||||
Future<List<Drama>> fetchDramas() async {
|
||||
Future<HomeFeed> fetchHomeFeed() async {
|
||||
if (fail) throw StateError('offline');
|
||||
return items;
|
||||
return feed;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HomeFeed> fetchDiscoverFeed() async => feed;
|
||||
|
||||
@override
|
||||
Future<Drama> fetchDrama(String dramaId) async {
|
||||
detailCalls++;
|
||||
return _detail(dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PlayTicket> resolvePlay(String episodeId) async {
|
||||
return tickets[episodeId] ??
|
||||
PlayTicket(
|
||||
episodeId: episodeId,
|
||||
accessible: false,
|
||||
accessType: PlayTicket.noMedia,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('all mock entries reuse one bundled local demo video', () async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
expect(dramas, hasLength(3));
|
||||
expect(dramas.every((drama) => drama.episodes.length == 3), isTrue);
|
||||
final paths = {
|
||||
for (final drama in dramas)
|
||||
for (final episode in drama.episodes) episode.assetPath,
|
||||
};
|
||||
expect(paths, {'assets/media/demo_clip.mp4'});
|
||||
expect(
|
||||
dramas.every(
|
||||
(drama) => drama.episodes.every((episode) => episode.isFree),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
final bytes = await rootBundle.load(paths.single);
|
||||
expect(bytes.lengthInBytes, greaterThan(1000000));
|
||||
});
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/pages/home/home_feed_sections.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'home categories filter demo dramas without changing their media',
|
||||
() async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
final sections = HomeFeedSections(dramas);
|
||||
|
||||
expect(sections.indicesFor(HomeFeedCategory.recommended), [0, 1, 2]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.animated), [0]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.liveAction), [1, 2]);
|
||||
expect(sections.indicesFor(HomeFeedCategory.following), isEmpty);
|
||||
expect(
|
||||
sections.dramaAt(HomeFeedCategory.liveAction, 0).title,
|
||||
'City Lights & Velvet Nights',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ void main() {
|
||||
id: 'ep-$i',
|
||||
number: i + 1,
|
||||
title: 'Episode $i',
|
||||
assetPath: 'test-$i.mp4',
|
||||
videoUrl: 'https://example.test/$i.mp4',
|
||||
isFree: i < 4,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,26 +1,76 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/services/playback_queue.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'home feed advances between dramas, series player between episodes',
|
||||
() async {
|
||||
final dramas = await DemoCatalogRepository().fetchDramas();
|
||||
final home = PlaybackQueue.home(dramas);
|
||||
expect(home.length, 3);
|
||||
expect(home.entryAt(0).drama.id, dramas[0].id);
|
||||
expect(home.entryAt(1).drama.id, dramas[1].id);
|
||||
expect(home.entryAt(1).episode.number, 1);
|
||||
expect(home.nextIndex(0), 1);
|
||||
expect(home.nextIndex(2), isNull);
|
||||
test('home feed advances between dramas, series player between episodes', () {
|
||||
final dramas = [_drama('a', 3), _drama('b', 2), _drama('c', 4)];
|
||||
|
||||
final series = PlaybackQueue.series(dramas[0]);
|
||||
expect(series.length, 3);
|
||||
expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id);
|
||||
expect(series.entryAt(1).episode.number, 2);
|
||||
expect(series.nextIndex(0), 1);
|
||||
expect(series.nextIndex(2), isNull);
|
||||
},
|
||||
);
|
||||
final home = PlaybackQueue.home(dramas);
|
||||
expect(home.length, 3);
|
||||
expect(home.entryAt(0).drama.id, 'a');
|
||||
expect(home.entryAt(1).drama.id, 'b');
|
||||
expect(home.entryAt(1).episode.number, 1);
|
||||
expect(home.nextIndex(0), 1);
|
||||
expect(home.nextIndex(2), isNull);
|
||||
|
||||
final series = PlaybackQueue.series(dramas[0]);
|
||||
expect(series.length, 3);
|
||||
expect(series.entryAt(0).drama.id, series.entryAt(1).drama.id);
|
||||
expect(series.entryAt(1).episode.number, 2);
|
||||
expect(series.nextIndex(0), 1);
|
||||
expect(series.nextIndex(2), isNull);
|
||||
});
|
||||
|
||||
test('dramas without episodes are left out of the home queue', () {
|
||||
final home = PlaybackQueue.home([
|
||||
_drama('a', 2),
|
||||
Drama(
|
||||
id: 'empty',
|
||||
title: '没有剧集',
|
||||
synopsis: '',
|
||||
posterUrl: '/files/empty.jpg',
|
||||
),
|
||||
]);
|
||||
expect(home.length, 1);
|
||||
expect(home.entryAt(0).drama.id, 'a');
|
||||
});
|
||||
|
||||
test('a list card with a preview episode plays on the home feed', () {
|
||||
final home = PlaybackQueue.home([
|
||||
Drama.fromCardJson(const {
|
||||
'id': 5,
|
||||
'title': '闪婚后',
|
||||
'cover': '/files/cover.jpg',
|
||||
'totalEpisodes': 77,
|
||||
'preview': {
|
||||
'id': 9,
|
||||
'number': 1,
|
||||
'title': '第 1 集',
|
||||
'isFree': true,
|
||||
'playUrl': '/files/episode/9.mp4',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(home.length, 1);
|
||||
expect(home.entryAt(0).episode.videoUrl, '/files/episode/9.mp4');
|
||||
});
|
||||
}
|
||||
|
||||
Drama _drama(String id, int episodeCount) => Drama(
|
||||
id: id,
|
||||
title: 'Drama $id',
|
||||
synopsis: '简介 $id',
|
||||
posterUrl: '/files/$id.jpg',
|
||||
episodes: [
|
||||
for (var index = 1; index <= episodeCount; index++)
|
||||
Episode(
|
||||
id: '$id-$index',
|
||||
number: index,
|
||||
title: '第 $index 集',
|
||||
isFree: true,
|
||||
videoUrl: 'https://example.test/$id-$index.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -12,7 +12,7 @@ void main() {
|
||||
id: 'ep-${i + 1}',
|
||||
number: i + 1,
|
||||
title: 'Episode ${i + 1}',
|
||||
assetPath: 'assets/media/episode_0${i + 1}.mp4',
|
||||
videoUrl: 'https://example.test/episode_0${i + 1}.mp4',
|
||||
isFree: i < 2,
|
||||
),
|
||||
);
|
||||
@@ -99,6 +99,47 @@ void main() {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('pausing then swiping to the next episode resumes playback', () async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (episode) => episode.isFree,
|
||||
);
|
||||
await session.select(0);
|
||||
session.setUserWantsPlay(false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(session.userWantsPlay, isFalse);
|
||||
|
||||
await session.select(1);
|
||||
|
||||
expect(session.userWantsPlay, isTrue);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final playing = session.active;
|
||||
expect(playing, isA<FakePlayer>());
|
||||
expect((playing! as FakePlayer).playCalls, greaterThan(0));
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('re-selecting the same episode keeps the user pause', () async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
episodes: episodes,
|
||||
factory: factory.create,
|
||||
canPlay: (episode) => episode.isFree,
|
||||
);
|
||||
await session.select(0);
|
||||
session.setUserWantsPlay(false);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
// 页面重新可见等场景会重复选中同一集,这时不能把暂停改成播放。
|
||||
await session.select(0);
|
||||
|
||||
expect(session.userWantsPlay, isFalse);
|
||||
expect(session.canAutoAdvance, isFalse);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('automatic advance respects visibility and playback intent', () async {
|
||||
final factory = FakeFactory();
|
||||
final session = PlaybackSession(
|
||||
|
||||
@@ -78,21 +78,20 @@ void main() {
|
||||
body: SeekProgressBar(
|
||||
valueListenable: video,
|
||||
semanticsLabel: 'Seek video',
|
||||
compactIdle: true,
|
||||
onSeek: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('00:03'), findsNothing);
|
||||
expect(find.textContaining('00:12'), findsNothing);
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Slider)),
|
||||
);
|
||||
await gesture.moveBy(const Offset(40, 0));
|
||||
await tester.pump();
|
||||
expect(find.text('00:12'), findsOneWidget);
|
||||
expect(find.textContaining('/ 00:12'), findsOneWidget);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('00:12'), findsNothing);
|
||||
expect(find.textContaining('00:12'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nontonki/core/data/models/drama.dart';
|
||||
import 'package:nontonki/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
@@ -51,4 +52,79 @@ void main() {
|
||||
expect(await history.lastEpisodeId('profile-a', 'drama-a'), 'episode-2');
|
||||
expect(await history.lastEpisodeId('profile-a', 'drama-b'), isNull);
|
||||
});
|
||||
|
||||
test('resume prefers the first loaded episode', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final history = SharedPreferencesWatchHistoryRepository();
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-1',
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-2',
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
final drama = Drama(
|
||||
id: 'drama-a',
|
||||
title: 'title',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
episodes: const [
|
||||
Episode(id: 'episode-1', number: 1, title: 'E1', isFree: true),
|
||||
Episode(id: 'episode-2', number: 2, title: 'E2', isFree: true),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
await history.resumePositionFor('profile-a', drama),
|
||||
const Duration(seconds: 3),
|
||||
);
|
||||
});
|
||||
|
||||
test('resume falls back to the last watched episode for a list card', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final history = SharedPreferencesWatchHistoryRepository();
|
||||
await history.save(
|
||||
'profile-a',
|
||||
'drama-a',
|
||||
'episode-2',
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
const card = Drama(
|
||||
id: 'drama-a',
|
||||
title: 'title',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 12,
|
||||
);
|
||||
|
||||
expect(
|
||||
await history.resumePositionFor('profile-a', card),
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
});
|
||||
|
||||
test('a list card with no history has no resume position', () async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final history = SharedPreferencesWatchHistoryRepository();
|
||||
|
||||
const card = Drama(
|
||||
id: 'drama-b',
|
||||
title: 'title',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 12,
|
||||
);
|
||||
|
||||
expect(await history.resumePositionFor('profile-a', card), isNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user