From 77d5748dbb8a6137ac51d636e0a87c04107be294 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 17:53:27 +0800 Subject: [PATCH] =?UTF-8?q?chore(client):=20=E5=BD=92=E6=A1=A3=E5=8E=9F?= =?UTF-8?q?=E5=9E=8B=E9=98=B6=E6=AE=B5=E6=9C=AA=E6=8F=90=E4=BA=A4=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=EF=BC=88binding/logic/view=20=E5=88=86=E5=B1=82?= =?UTF-8?q?=E4=B8=8E=E5=90=84=E9=A1=B5=E8=BF=AD=E4=BB=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:客户端原型在 14d7bd3 之后又做了一轮(页面分层、发现页、分类滑动、播放器控件等), 一直留在工作区没入库。下一批账号链路要改其中的 app.dart / home_view / player_view / mine_view, 先归档这批既存改动,本批的实际 diff 才说得清边界。 只归档,不改行为:当前工作区 flutter analyze 0 问题、flutter test 148 全过。 --- README.md | 35 +- android/app/src/debug/AndroidManifest.xml | 2 + android/app/src/main/AndroidManifest.xml | 1 + android/app/src/profile/AndroidManifest.xml | 2 + ios/Runner.xcodeproj/project.pbxproj | 6 +- ios/Runner/Info.plist | 7 + lib/core/data/models/drama.dart | 108 ++- .../data/repositories/catalog_repository.dart | 126 +-- .../watch_history_repository.dart | 13 + .../data/services/catalog_controller.dart | 56 +- lib/core/data/services/playback_session.dart | 14 +- lib/core/data/services/video_player_port.dart | 25 +- lib/pages/discover/discover_logic.dart | 24 +- lib/pages/discover/discover_view.dart | 826 ++++++++---------- .../drama_detail/drama_detail_logic.dart | 33 +- lib/pages/drama_detail/drama_detail_view.dart | 50 +- lib/pages/home/home_feed_sections.dart | 30 - .../home/widgets/category_swipe_region.dart | 35 - lib/pages/home/widgets/home_feed_header.dart | 170 ---- lib/pages/home/widgets/home_quick_drawer.dart | 19 +- lib/pages/mine/mine_logic.dart | 6 +- lib/pages/player/player_logic.dart | 259 ++++-- .../player/widgets/seek_progress_bar.dart | 42 +- pubspec.lock | 24 + pubspec.yaml | 11 +- test/catalog/catalog_controller_test.dart | 138 ++- test/catalog/demo_media_test.dart | 26 - test/catalog/home_feed_category_test.dart | 22 - test/playback/playback_preload_test.dart | 2 +- test/playback/playback_queue_test.dart | 90 +- test/playback/playback_session_test.dart | 43 +- test/playback/seek_progress_bar_test.dart | 7 +- test/playback/watch_history_test.dart | 76 ++ 33 files changed, 1264 insertions(+), 1064 deletions(-) delete mode 100644 lib/pages/home/home_feed_sections.dart delete mode 100644 lib/pages/home/widgets/category_swipe_region.dart delete mode 100644 lib/pages/home/widgets/home_feed_header.dart delete mode 100644 test/catalog/demo_media_test.dart delete mode 100644 test/catalog/home_feed_category_test.dart diff --git a/README.md b/README.md index c2bc86e..8ee766b 100644 --- a/README.md +++ b/README.md @@ -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://:8081/files/**`,本地不再打包演示素材 +(原先 `assets/media` 里的演示视频有 316MB,已从打包清单移除)。 + ## 当前说明 -现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。 +收藏、点赞、评论与观看进度仍保存在本机(后端互动接口尚未开发); +金币与会员相关界面仍是占位数据。 diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..cbb4f77 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,6 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 674882b..6b90a62 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6833b0c..b8a8db0 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 724c986..d52254b 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -26,6 +26,13 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + 开发阶段需要访问同一局域网内的短剧内容服务。 UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 8a90835..a137f78 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -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 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 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 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 + ? [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 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(Episode.fromJson) + .toList(), + ); + } } diff --git a/lib/core/data/repositories/catalog_repository.dart b/lib/core/data/repositories/catalog_repository.dart index e5ee180..8762895 100644 --- a/lib/core/data/repositories/catalog_repository.dart +++ b/lib/core/data/repositories/catalog_repository.dart @@ -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> fetchDramas(); + Future fetchHomeFeed(); + + Future fetchDiscoverFeed(); + + Future fetchDrama(String dramaId); + + Future resolvePlay(String episodeId); } -class DemoCatalogRepository implements CatalogRepository { +class HttpCatalogRepository implements CatalogRepository { + HttpCatalogRepository(this._api); + + final CatalogApi _api; + @override - Future> 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 fetchHomeFeed() => _api.homeFeed(); + + @override + Future fetchDiscoverFeed() => _api.discoverFeed(); + + @override + Future fetchDrama(String dramaId) => _api.dramaDetail(dramaId); + + @override + Future resolvePlay(String episodeId) => + _api.playTicket(episodeId); } diff --git a/lib/core/data/repositories/watch_history_repository.dart b/lib/core/data/repositories/watch_history_repository.dart index 746500c..da15c63 100644 --- a/lib/core/data/repositories/watch_history_repository.dart +++ b/lib/core/data/repositories/watch_history_repository.dart @@ -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 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; diff --git a/lib/core/data/services/catalog_controller.dart b/lib/core/data/services/catalog_controller.dart index c2e1b70..00e446c 100644 --- a/lib/core/data/services/catalog_controller.dart +++ b/lib/core/data/services/catalog_controller.dart @@ -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 dramas = const []; + HomeFeed feed = const HomeFeed(); bool loading = false; String? error; + List get dramas => feed.dramas; + List get banners => feed.banners; + List get sections => feed.sections; + Future 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 requireDetail(Drama drama) async { + if (drama.detailLoaded) return drama; + final detail = await repository.fetchDrama(drama.id); + _replace(detail); + return detail; + } + + /// 播放前解析票据:返回带播放地址的剧集,锁定集保持无地址。 + Future 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(); + } } diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index bc2d864..b1f3210 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -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 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, diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index b334db5..4256398 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -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, - }) : 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, + ) { + final options = VideoPlayerOptions(mixWithOthers: false); + // 片源统一由后端下发(详情里的可播放地址 / 播放票据) + return VideoPlayerController.networkUrl( + resolveUri(episode), + closedCaptionFile: closedCaptionFile, + videoPlayerOptions: options, + ); + } final VideoPlayerController controller; Future? _initialization; diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index dce193f..0536385 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -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); diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index c60f3f6..9027291 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -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? onAction; final List? dramas; final ValueChanged? onDramaSelected; @@ -48,6 +61,7 @@ class _DiscoverPageState extends State { drama: widget.drama, history: widget.history, ); + if (widget.focusSearch) logic.searchOpen = true; } @override @@ -80,50 +94,64 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final cards = _cardsFor(l10n, logic.category, logic.search.text); + final catalog = context.watch(); + 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 { ); } - 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( - 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 banners, + List 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 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 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 dramas, + List 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 [], + }; 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), + ); + } +} diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index e6d8dfb..7f613d1 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -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 load(Future 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 loadContinue() async { try { final id = await history.lastEpisodeId(demoProfileId, drama.id); diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 264febd..1dcc719 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -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 { void initState() { super.initState(); logic = DramaDetailBinding().create(drama: widget.drama); - logic.loadContinue(); + _load(); + } + + void _load() { + unawaited( + logic.load( + () => context.read().requireDetail(widget.drama), + ), + ); } @override @@ -40,6 +53,34 @@ class _DramaDetailPageState extends State { 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 { 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 { 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 { 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), ), diff --git a/lib/pages/home/home_feed_sections.dart b/lib/pages/home/home_feed_sections.dart deleted file mode 100644 index 74559dc..0000000 --- a/lib/pages/home/home_feed_sections.dart +++ /dev/null @@ -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 dramas; - final Set followedDramaIds; - - List 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]]; -} diff --git a/lib/pages/home/widgets/category_swipe_region.dart b/lib/pages/home/widgets/category_swipe_region.dart deleted file mode 100644 index c641cc1..0000000 --- a/lib/pages/home/widgets/category_swipe_region.dart +++ /dev/null @@ -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 onShift; - final Widget child; - - @override - State createState() => _CategorySwipeRegionState(); -} - -class _CategorySwipeRegionState extends State { - 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, - ); -} diff --git a/lib/pages/home/widgets/home_feed_header.dart b/lib/pages/home/widgets/home_feed_header.dart deleted file mode 100644 index 2998d41..0000000 --- a/lib/pages/home/widgets/home_feed_header.dart +++ /dev/null @@ -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 onCategorySelected; - final ValueChanged 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, - ), - ), - ], - ), - ), - ], - ); -} diff --git a/lib/pages/home/widgets/home_quick_drawer.dart b/lib/pages/home/widgets/home_quick_drawer.dart index 2a60d68..1d12caf 100644 --- a/lib/pages/home/widgets/home_quick_drawer.dart +++ b/lib/pages/home/widgets/home_quick_drawer.dart @@ -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, - ), - ), ], ), ), diff --git a/lib/pages/mine/mine_logic.dart b/lib/pages/mine/mine_logic.dart index 749d1c3..bc699a3 100644 --- a/lib/pages/mine/mine_logic.dart +++ b/lib/pages/mine/mine_logic.dart @@ -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) { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index b5085e8..5fee99d 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -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 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 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.generate(queue.length, (index) => index); + visibleIndices = List.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 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 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 seekTo(BuildContext context, VideoPlayerPort player, Duration position) async { + Future 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 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( + builder: (_) => PlayerPage(drama: drama, initialIndex: index), + ), + ); + if (isMounted()) session.setRouteVisible(active); + } + Future 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( 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 _withFullEpisodes(BuildContext context, Drama drama) async { + if (drama.detailLoaded) return drama; + final CatalogController catalog; + try { + catalog = context.read(); + } 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 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 showDiscover(BuildContext context, {bool focusSearch = false}) async { + Future 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); } } - } diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index d5080b6..f2b23b9 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -22,7 +22,6 @@ class SeekProgressBar extends StatefulWidget { this.onScrubStart, this.onSeek, this.showTimes = true, - this.compactIdle = false, }); final ValueListenable? valueListenable; @@ -30,7 +29,6 @@ class SeekProgressBar extends StatefulWidget { final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; final bool showTimes; - final bool compactIdle; @override State createState() => _SeekProgressBarState(); @@ -99,20 +97,32 @@ class _SeekProgressBarState extends State { 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 { ), ), ), - 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), - ), - ], - ), ], ); } diff --git a/pubspec.lock b/pubspec.lock index ee2e934..63efda0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 99de653..50740f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/catalog/catalog_controller_test.dart b/test/catalog/catalog_controller_test.dart index dc4ab8f..599e64e 100644 --- a/test/catalog/catalog_controller_test.dart +++ b/test/catalog/catalog_controller_test.dart @@ -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 items = const []; + HomeFeed feed = const HomeFeed(); + int detailCalls = 0; + final Map tickets = {}; @override - Future> fetchDramas() async { + Future fetchHomeFeed() async { if (fail) throw StateError('offline'); - return items; + return feed; + } + + @override + Future fetchDiscoverFeed() async => feed; + + @override + Future fetchDrama(String dramaId) async { + detailCalls++; + return _detail(dramaId); + } + + @override + Future resolvePlay(String episodeId) async { + return tickets[episodeId] ?? + PlayTicket( + episodeId: episodeId, + accessible: false, + accessType: PlayTicket.noMedia, + ); } } diff --git a/test/catalog/demo_media_test.dart b/test/catalog/demo_media_test.dart deleted file mode 100644 index b823934..0000000 --- a/test/catalog/demo_media_test.dart +++ /dev/null @@ -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)); - }); -} diff --git a/test/catalog/home_feed_category_test.dart b/test/catalog/home_feed_category_test.dart deleted file mode 100644 index f10457a..0000000 --- a/test/catalog/home_feed_category_test.dart +++ /dev/null @@ -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', - ); - }, - ); -} diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 569f263..4dc671b 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -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, ), ); diff --git a/test/playback/playback_queue_test.dart b/test/playback/playback_queue_test.dart index 461451d..179f4a2 100644 --- a/test/playback/playback_queue_test.dart +++ b/test/playback/playback_queue_test.dart @@ -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', + ), + ], +); diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index 4dcc547..99e17ab 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -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.delayed(Duration.zero); + expect(session.userWantsPlay, isFalse); + + await session.select(1); + + expect(session.userWantsPlay, isTrue); + await Future.delayed(Duration.zero); + final playing = session.active; + expect(playing, isA()); + 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.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( diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index f13a976..01f1114 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -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); }); } diff --git a/test/playback/watch_history_test.dart b/test/playback/watch_history_test.dart index fbd5ffa..8e3c081 100644 --- a/test/playback/watch_history_test.dart +++ b/test/playback/watch_history_test.dart @@ -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); + }); }