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:
Vicky committed 2026-10-15 17:53:27 +08:00
1 parent 14d7bd39c7
commit 77d5748dbb
33 files changed
+1264 -1064

No files matched your search

+99 -9
View File
@@ -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;
+51 -5
View File
@@ -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();
}
}
+13 -1
View File
@@ -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,
+20 -5
View File
@@ -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;
+16 -8
View File
@@ -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);
+373 -453
View File
@@ -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),
);
}
}
+32 -1
View File
@@ -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);
+45 -5
View File
@@ -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),
),
-30
View File
@@ -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,
),
),
],
),
),
],
);
}
+5 -14
View File
@@ -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,
),
),
],
),
),
+1 -5
View File
@@ -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) {
+164 -95
View File
@@ -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);
}
}
}
+19 -23
View File
@@ -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),
),
],
),
],
);
}