feat(client): 对接详情/播放/进度续看/收藏(K1 看剧闭环)+ 三态 UI
后端接口从 6 个扩到 13 个,全部走真数据:
- 新增 api:playback_api(/app/episodes/{id}/play)、watch_api(/app/watch/*)、
library_api(/app/favorites*);票据从 CatalogApi 搬到 PlaybackApi
- 新增 repository:PlaybackRepository(含票据 TTL 缓存,默认 10 分钟,时钟可注入)、
FavoriteRepository、WatchHistoryRepository 换成服务端模型(每剧一条续看记录,删掉 demoProfileId)
- PlayerLogic:起播前取票(拿不到给三态 UI,失败可重试);accessType 驱动锁定文案,
不在前端猜;签名地址原样交给播放器(只补 host,不动 path/query);
进度上报节流 5s → 10s,切集/退出/切后台各补一次,播完带 finished
- 续看定位:服务端记录里的那一集才 seek(其它集从头播),续看位置进详情/播放页时读一次
- FavoriteController:收藏以服务端为准,乐观更新 + 失败回滚;退出登录清空
- UI 三态:详情页(加载/失败可重试/空剧集 + 续看进度条 + 收藏按钮)、
播放页(票据加载中/失败可重试/后端判定不可播)、我的页(续看失败可重试、收藏三态、最近观看)
- l10n 新增 8 个键(en/zh/id 三份):网络/进度/收藏失败、锁定提示、续看文案等
测试:flutter test 188 全过(存量 27 个测试文件未跑红);新增票据过期重取、进度节流、
续看定位、收藏回滚、签名 query 原样保留、真实后端端到端联调(live_watch_flow)
This commit is contained in:
1 parent
94ff9f1177
commit
d7eff4ca59
48 files changed
+2411
-377
No files matched your search
@@ -5,9 +5,9 @@ import 'discover_logic.dart';
|
||||
class DiscoverBinding {
|
||||
DiscoverLogic create({
|
||||
required Drama drama,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
}) => DiscoverLogic(
|
||||
drama: drama,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,11 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.resumePositionFor(demoProfileId, drama);
|
||||
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
|
||||
position = history
|
||||
.resumePointFor(drama.id)
|
||||
.then((point) => point?.position)
|
||||
.catchError((Object _) => null);
|
||||
}
|
||||
|
||||
void selectCategory(int index) {
|
||||
|
||||
@@ -59,7 +59,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
super.initState();
|
||||
logic = DiscoverBinding().create(
|
||||
drama: widget.drama,
|
||||
history: widget.history,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
if (widget.focusSearch) logic.searchOpen = true;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
|
||||
class DramaDetailBinding {
|
||||
DramaDetailLogic create({
|
||||
required Drama drama,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
required PlaybackRepository playback,
|
||||
required FavoriteController favorites,
|
||||
}) => DramaDetailLogic(
|
||||
drama: drama,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
playback: playback,
|
||||
favorites: favorites,
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,35 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../player/player_view.dart';
|
||||
|
||||
class DramaDetailLogic extends ChangeNotifier {
|
||||
DramaDetailLogic({required this.drama, required this.history});
|
||||
DramaDetailLogic({
|
||||
required this.drama,
|
||||
required this.history,
|
||||
required this.playback,
|
||||
required this.favorites,
|
||||
});
|
||||
|
||||
/// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。
|
||||
Drama drama;
|
||||
final WatchHistoryRepository history;
|
||||
final PlaybackRepository playback;
|
||||
final FavoriteController favorites;
|
||||
bool loading = false;
|
||||
bool failed = false;
|
||||
|
||||
/// 接口给出的失败原因;为空时界面回退到本地文案。
|
||||
String? failureMessage;
|
||||
int? continueIndex;
|
||||
WatchProgress? progress;
|
||||
String? progressError;
|
||||
bool _disposed = false;
|
||||
|
||||
void notify() {
|
||||
@@ -39,6 +52,10 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
} on ApiException catch (exception) {
|
||||
failed = true;
|
||||
failureMessage = exception.message;
|
||||
} on DioException {
|
||||
// 断网/超时:交给 l10n 文案,不把 HTTP 细节丢给用户
|
||||
failed = true;
|
||||
failureMessage = 'networkError';
|
||||
} catch (_) {
|
||||
failed = true;
|
||||
} finally {
|
||||
@@ -49,18 +66,39 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
|
||||
Future<void> loadContinue() async {
|
||||
try {
|
||||
final id = await history.lastEpisodeId(demoProfileId, drama.id);
|
||||
if (id == null || _disposed) return;
|
||||
final index = drama.episodes.indexWhere((episode) => episode.id == id);
|
||||
if (index >= 0 && drama.episodes[index].isFree) {
|
||||
continueIndex = index;
|
||||
notify();
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep the first episode available if preferences cannot be read.
|
||||
final record = await history.progressFor(drama.id);
|
||||
if (_disposed) return;
|
||||
progress = record;
|
||||
progressError = null;
|
||||
continueIndex = record == null
|
||||
? null
|
||||
: _indexOf(record.episodeId) ?? continueIndex;
|
||||
notify();
|
||||
} catch (exception) {
|
||||
if (_disposed) return;
|
||||
// 拿不到续看位置不影响看第一集,但要让用户知道(可重试)
|
||||
progressError = exception is ApiException
|
||||
? exception.message
|
||||
: 'progressError';
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
int? _indexOf(String episodeId) {
|
||||
final index = drama.episodes.indexWhere(
|
||||
(episode) => episode.id == episodeId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
|
||||
/// 该集的续看进度(0~1),用于剧集行上的进度条;没有记录返回 null。
|
||||
double? progressFractionFor(String episodeId) {
|
||||
final record = progress;
|
||||
if (record == null || record.episodeId != episodeId) return null;
|
||||
if (!record.hasPosition) return null;
|
||||
return record.fraction;
|
||||
}
|
||||
|
||||
Future<void> openPlayer(BuildContext context, int index) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -70,6 +108,9 @@ class DramaDetailLogic extends ChangeNotifier {
|
||||
await loadContinue();
|
||||
}
|
||||
|
||||
/// 收藏切换:乐观更新失败时会回滚,由调用方给出提示。
|
||||
Future<bool> toggleFavorite() => favorites.toggle(drama.id, card: drama);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -5,11 +5,16 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'drama_detail_binding.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
|
||||
class DramaDetailPage extends StatefulWidget {
|
||||
const DramaDetailPage({super.key, required this.drama});
|
||||
@@ -25,7 +30,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = DramaDetailBinding().create(drama: widget.drama);
|
||||
logic = DramaDetailBinding().create(
|
||||
drama: widget.drama,
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
playback: context.read<PlaybackRepository>(),
|
||||
favorites: context.read<FavoriteController>(),
|
||||
);
|
||||
_load();
|
||||
}
|
||||
|
||||
@@ -69,7 +79,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
logic.failureMessage ?? l10n.detailError,
|
||||
errorText(l10n, logic.failureMessage, l10n.detailError),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
@@ -82,6 +92,40 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
);
|
||||
}
|
||||
final drama = logic.drama;
|
||||
if (drama.episodes.isEmpty) {
|
||||
// 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(localizedDramaTitle(l10n, drama)),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_filter_outlined,
|
||||
size: 48,
|
||||
color: Colors.white38,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.noEpisodesYet,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -90,6 +134,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
pinned: true,
|
||||
backgroundColor: const Color(0xFF111111),
|
||||
foregroundColor: Colors.white,
|
||||
actions: [_favoriteAction(context, l10n)],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: RemoteImage(drama.posterUrl),
|
||||
),
|
||||
@@ -121,15 +166,54 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(
|
||||
logic.continueIndex == null
|
||||
? l10n.watchEpisodeOne
|
||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(
|
||||
logic.continueIndex == null
|
||||
? l10n.watchEpisodeOne
|
||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
||||
),
|
||||
),
|
||||
if (_continueFraction != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_ResumeBar(
|
||||
fraction: _continueFraction!,
|
||||
label: l10n.resumeProgress(
|
||||
_clock(logic.progress!.position),
|
||||
_clock(logic.progress!.duration),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (logic.progressError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorText(
|
||||
l10n,
|
||||
logic.progressError,
|
||||
l10n.progressError,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(logic.loadContinue()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Text(
|
||||
@@ -147,6 +231,7 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
itemCount: drama.episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = drama.episodes[index];
|
||||
final fraction = logic.progressFractionFor(episode.id);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
@@ -158,6 +243,12 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
child: Text('${episode.number}'),
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: fraction == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: _ResumeBar(fraction: fraction),
|
||||
),
|
||||
trailing: const Icon(Icons.play_circle_outline_rounded),
|
||||
onTap: () => logic.openPlayer(context, index),
|
||||
),
|
||||
@@ -169,4 +260,71 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double? get _continueFraction {
|
||||
final record = logic.progress;
|
||||
if (record == null || !record.hasPosition) return null;
|
||||
if (logic.continueIndex == null) return null;
|
||||
return record.fraction;
|
||||
}
|
||||
|
||||
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
|
||||
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final saved = favorites.isSaved(logic.drama.id);
|
||||
return IconButton(
|
||||
tooltip: l10n.favorite,
|
||||
icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border),
|
||||
onPressed: () async {
|
||||
if (!await ensureSignedIn(context)) return;
|
||||
final ok = await logic.toggleFavorite();
|
||||
if (ok || !context.mounted) return;
|
||||
// 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚)
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _clock(Duration duration) {
|
||||
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return duration.inHours > 0
|
||||
? '${duration.inHours}:$minutes:$seconds'
|
||||
: '$minutes:$seconds';
|
||||
}
|
||||
}
|
||||
|
||||
/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。
|
||||
class _ResumeBar extends StatelessWidget {
|
||||
const _ResumeBar({required this.fraction, this.label});
|
||||
|
||||
final double fraction;
|
||||
final String? label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: fraction,
|
||||
minHeight: 3,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation(Color(0xFF2F7BFF)),
|
||||
),
|
||||
),
|
||||
if (label != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label!,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,10 @@ class MineBinding {
|
||||
MineLogic create({
|
||||
required Drama drama,
|
||||
required int initialSection,
|
||||
WatchHistoryRepository? history,
|
||||
required WatchHistoryRepository history,
|
||||
}) => MineLogic(
|
||||
drama: drama,
|
||||
initialSection: initialSection,
|
||||
history: history ?? SharedPreferencesWatchHistoryRepository(),
|
||||
history: history,
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/locale_controller.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -20,6 +21,11 @@ class MineLogic extends ChangeNotifier {
|
||||
final WatchHistoryRepository history;
|
||||
Drama drama;
|
||||
late Future<Duration?> position;
|
||||
List<WatchProgress> recent = const [];
|
||||
bool recentLoading = false;
|
||||
String? recentError;
|
||||
/// 续看位置读取失败:与"没有记录"区分开,失败要能重试(不能假装成从 0 开始)
|
||||
bool positionFailed = false;
|
||||
int section;
|
||||
int filter = 0;
|
||||
bool _disposed = false;
|
||||
@@ -29,7 +35,33 @@ class MineLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void refreshPosition() {
|
||||
position = history.resumePositionFor(demoProfileId, drama);
|
||||
position = history
|
||||
.resumePointFor(drama.id)
|
||||
.then((point) {
|
||||
positionFailed = false;
|
||||
return point?.position;
|
||||
})
|
||||
.catchError((Object _) {
|
||||
positionFailed = true;
|
||||
notify();
|
||||
return null;
|
||||
});
|
||||
refreshRecent();
|
||||
}
|
||||
|
||||
/// 最近观看:服务端只给 dramaId/位置,剧名封面用已加载的目录卡片按 id 映射。
|
||||
Future<void> refreshRecent() async {
|
||||
recentLoading = true;
|
||||
recentError = null;
|
||||
notify();
|
||||
try {
|
||||
recent = await history.recent(limit: 20);
|
||||
} catch (_) {
|
||||
recentError = 'progressError';
|
||||
} finally {
|
||||
recentLoading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
void selectSection(int index) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -6,6 +8,8 @@ import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/auth_controller.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
@@ -61,7 +65,7 @@ class _MyPageState extends State<MyPage> {
|
||||
logic = MineBinding().create(
|
||||
drama: widget.drama,
|
||||
initialSection: widget.initialSection,
|
||||
history: widget.history,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,11 +442,14 @@ class _MyPageState extends State<MyPage> {
|
||||
(logic.filter == 1 && fraction < 0.99) ||
|
||||
(logic.filter == 2 && fraction >= 0.99);
|
||||
final engagement = context.watch<EngagementController>();
|
||||
final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) {
|
||||
return logic.section == 1
|
||||
? engagement.isSaved(drama.id)
|
||||
: engagement.isLiked(drama.id);
|
||||
}).toList();
|
||||
// 收藏以服务端为准(收藏列表接口返回卡片结构);点赞仍是本地状态(K3 接互动)。
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final isWatchlist = logic.section == 1;
|
||||
final markedDramas = isWatchlist
|
||||
? favorites.cards
|
||||
: (widget.dramas ?? [widget.drama])
|
||||
.where((drama) => engagement.isLiked(drama.id))
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -452,7 +459,11 @@ class _MyPageState extends State<MyPage> {
|
||||
children: [
|
||||
for (var index = 0; index < tabs.length; index++) ...[
|
||||
InkWell(
|
||||
onTap: () => logic.selectSection(index),
|
||||
onTap: () {
|
||||
logic.selectSection(index);
|
||||
// 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐)
|
||||
if (index == 1) unawaited(favorites.load());
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -577,6 +588,34 @@ class _MyPageState extends State<MyPage> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (isWatchlist && favorites.loading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 22),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (isWatchlist && favorites.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, favorites.error, l10n.favoritesError),
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(favorites.load()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (logic.section != 0 && markedDramas.isNotEmpty)
|
||||
for (final drama in markedDramas) ...[
|
||||
_markedDramaCard(drama, l10n),
|
||||
@@ -590,10 +629,90 @@ class _MyPageState extends State<MyPage> {
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
// 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧;
|
||||
// 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。
|
||||
if (logic.section == 0) ...[
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l10n.history,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
if (logic.positionFailed)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.progressError,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: logic.refreshPosition,
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (logic.recentLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (logic.recentError != null)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorText(l10n, logic.recentError, l10n.progressError),
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(logic.refreshRecent()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (_recentDramas.isEmpty)
|
||||
Text(
|
||||
l10n.noStories,
|
||||
style: const TextStyle(color: Color(0xFFB7B3B8)),
|
||||
)
|
||||
else
|
||||
for (final drama in _recentDramas) ...[
|
||||
_markedDramaCard(drama, l10n),
|
||||
const SizedBox(height: 9),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。
|
||||
List<Drama> get _recentDramas {
|
||||
final pool = <Drama>[...?widget.dramas, widget.drama];
|
||||
final result = <Drama>[];
|
||||
for (final item in logic.recent) {
|
||||
for (final drama in pool) {
|
||||
if (drama.id == item.dramaId &&
|
||||
!result.any((picked) => picked.id == drama.id)) {
|
||||
result.add(drama);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell(
|
||||
onTap: () {
|
||||
if (widget.onDramaSelected != null) {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/services/android_picture_in_picture.dart';
|
||||
import 'player_logic.dart';
|
||||
|
||||
class PlayerBinding {
|
||||
PlayerLogic create() => PlayerLogic(
|
||||
history: SharedPreferencesWatchHistoryRepository(),
|
||||
PlayerLogic create({
|
||||
required WatchHistoryRepository history,
|
||||
required PlaybackRepository playback,
|
||||
}) => PlayerLogic(
|
||||
history: history,
|
||||
playback: playback,
|
||||
pictureInPicture: AndroidPictureInPicture(),
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/play_ticket.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/models/player_port.dart';
|
||||
import '../../core/data/remote/api_client.dart';
|
||||
import '../../core/data/repositories/playback_repository.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';
|
||||
@@ -24,9 +28,14 @@ import 'player_view.dart';
|
||||
import 'widgets/drama_comments_sheet.dart';
|
||||
|
||||
class PlayerLogic extends ChangeNotifier {
|
||||
PlayerLogic({required this.history, required this.pictureInPicture});
|
||||
PlayerLogic({
|
||||
required this.history,
|
||||
required this.playback,
|
||||
required this.pictureInPicture,
|
||||
});
|
||||
|
||||
final WatchHistoryRepository history;
|
||||
final PlaybackRepository playback;
|
||||
final AndroidPictureInPicture pictureInPicture;
|
||||
|
||||
late PageController pages;
|
||||
@@ -37,7 +46,18 @@ class PlayerLogic extends ChangeNotifier {
|
||||
Episode? trackedEpisode;
|
||||
Drama? trackedDrama;
|
||||
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
/// 进度上报节流:契约建议每 10 秒一次
|
||||
Duration progressInterval = const Duration(seconds: 10);
|
||||
Future<void> pendingWrite = Future<void>.value();
|
||||
/// 服务端续看点:进入播放页时定位到"那一集的哪一秒"
|
||||
ResumePoint? resumePoint;
|
||||
bool resumeLoaded = false;
|
||||
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
|
||||
bool ticketLoading = false;
|
||||
String? ticketError;
|
||||
PlayTicket? blockedTicket;
|
||||
String? blockedEpisodeId;
|
||||
List<Episode> sessionEpisodes = const [];
|
||||
int pageRequest = 0;
|
||||
bool muted = false;
|
||||
bool stoppedAtFeedEnd = false;
|
||||
@@ -64,6 +84,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
ValueChanged<Drama>? onDramaChanged;
|
||||
ValueChanged<bool>? onImmersiveChanged;
|
||||
bool _disposed = false;
|
||||
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
|
||||
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
|
||||
bool _started = false;
|
||||
|
||||
bool get immersive => fullscreen || pip || pipPreparing;
|
||||
|
||||
@@ -111,16 +134,19 @@ class PlayerLogic extends ChangeNotifier {
|
||||
required PlayerFactory factory,
|
||||
required PlaybackCoordinator coordinator,
|
||||
}) {
|
||||
_started = true;
|
||||
pictureInPicture.setStateListener(onPipChanged);
|
||||
queue = feedDramas == null
|
||||
? PlaybackQueue.series(pageDrama)
|
||||
: PlaybackQueue.home(feedDramas!);
|
||||
visibleIndices = List<int>.generate(queue.length, (index) => index);
|
||||
pages = PageController(initialPage: initialIndex);
|
||||
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
|
||||
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
|
||||
session = PlaybackSession(
|
||||
episodes: [for (final entry in queue.entries) entry.episode],
|
||||
episodes: sessionEpisodes,
|
||||
factory: factory,
|
||||
// 后端只在可播放时才下发地址(免费集或已解锁),拿不到地址即视为锁定
|
||||
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
|
||||
canPlay: (episode) => episode.videoUrl.isNotEmpty,
|
||||
prepare: preparePlayer,
|
||||
activate: activatePlayer,
|
||||
@@ -129,9 +155,88 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (!active || visibleIndices.isEmpty) {
|
||||
session.setRouteVisible(false);
|
||||
}
|
||||
unawaited(loadResumePoint());
|
||||
unawaited(selectEpisode(initialIndex));
|
||||
}
|
||||
|
||||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||||
Future<void> loadResumePoint() async {
|
||||
final dramaId = queue.entryAt(initialIndex).drama.id;
|
||||
try {
|
||||
resumePoint = await history.resumePointFor(dramaId);
|
||||
} catch (_) {
|
||||
// 拿不到续看位置不影响播放:从当前集开头看
|
||||
resumePoint = null;
|
||||
} finally {
|
||||
resumeLoaded = true;
|
||||
if (isMounted()) notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
|
||||
/// 该集是否该定位到服务端记录的位置:只有"记录里的那一集"才定位,其它集从头播。
|
||||
Duration? resumePositionFor(String episodeId) {
|
||||
final point = resumePoint;
|
||||
if (point == null || point.episodeId != episodeId) return null;
|
||||
if (point.position <= Duration.zero) return null;
|
||||
return point.position;
|
||||
}
|
||||
|
||||
/// 进度上报节流:距上次上报不足 [progressInterval] 就不报。
|
||||
///
|
||||
/// 切集/退出/切后台走 [saveCurrentProgress],那条路径**不受节流限制**。
|
||||
bool shouldReportProgress(DateTime now) {
|
||||
if (now.difference(lastProgressWrite) < progressInterval) return false;
|
||||
lastProgressWrite = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
|
||||
/// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。
|
||||
Future<bool> ensureTicket(int index) async {
|
||||
if (index < 0 || index >= sessionEpisodes.length) return false;
|
||||
final episode = sessionEpisodes[index];
|
||||
final request = pageRequest;
|
||||
ticketLoading = true;
|
||||
ticketError = null;
|
||||
notify();
|
||||
try {
|
||||
final ticket = await playback.ticketFor(episode.id);
|
||||
if (request != pageRequest) return false;
|
||||
blockedTicket = null;
|
||||
blockedEpisodeId = null;
|
||||
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
||||
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
||||
sessionEpisodes[index] = episode.withVideoUrl(ticket.playUrl);
|
||||
return true;
|
||||
}
|
||||
blockedTicket = ticket;
|
||||
blockedEpisodeId = episode.id;
|
||||
return false;
|
||||
} catch (exception) {
|
||||
if (request != pageRequest) return false;
|
||||
// 详情里已有可用地址(免费集)时降级继续,避免票据服务抖动就完全看不了
|
||||
if (episode.videoUrl.isNotEmpty) {
|
||||
ticketError = null;
|
||||
return true;
|
||||
}
|
||||
ticketError = exception is ApiException
|
||||
? exception.message
|
||||
: 'ticketError';
|
||||
return false;
|
||||
} finally {
|
||||
ticketLoading = false;
|
||||
if (isMounted()) notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试当前集的票据(失败态的"重试"按钮)。
|
||||
Future<void> retryTicket() async {
|
||||
ticketError = null;
|
||||
notify();
|
||||
unawaited(selectEpisode(session.currentIndex));
|
||||
}
|
||||
|
||||
void onPageUpdated(PlayerPage previous) {
|
||||
if (previous.active != active) {
|
||||
session.setRouteVisible(active && visibleIndices.isNotEmpty);
|
||||
@@ -161,6 +266,14 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
void onLifecycle(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) unawaited(saveCurrentProgress());
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// 签名地址有有效期:回前台时若票据已过期,重新取一张再起播,
|
||||
// 否则会撞上 403(契约 §1.4)
|
||||
final episode = trackedEpisode;
|
||||
if (episode != null && !playback.isFresh(episode.id)) {
|
||||
unawaited(selectEpisode(session.currentIndex));
|
||||
}
|
||||
}
|
||||
session.setAppResumed(
|
||||
state == AppLifecycleState.resumed || pip || pipPreparing,
|
||||
);
|
||||
@@ -179,10 +292,12 @@ class PlayerLogic extends ChangeNotifier {
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
}
|
||||
unawaited(saveCurrentProgress());
|
||||
session.setRouteVisible(false);
|
||||
cancelScrub();
|
||||
unawaited(session.close());
|
||||
pages.dispose();
|
||||
if (_started) {
|
||||
session.setRouteVisible(false);
|
||||
cancelScrub();
|
||||
unawaited(session.close());
|
||||
pages.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -264,23 +379,18 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
|
||||
if (player is! VideoPlayerPort) return;
|
||||
final drama = queue.entries
|
||||
.firstWhere((e) => e.episode.id == episode.id)
|
||||
.drama;
|
||||
try {
|
||||
await pendingWrite;
|
||||
final saved = await history.positionFor(
|
||||
demoProfileId,
|
||||
drama.id,
|
||||
episode.id,
|
||||
);
|
||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||
if (resumePoint == null) await loadResumePoint();
|
||||
final saved = resumePositionFor(episode.id);
|
||||
if (saved != null) {
|
||||
await player.controller.seekTo(
|
||||
clampResumePosition(saved, player.controller.value.duration),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// A local preference failure does not prevent playback.
|
||||
// 续看位置取不到不影响播放:从头播。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +413,13 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (player.controller.value.isCompleted && !completionProgressSaved) {
|
||||
completionProgressSaved = true;
|
||||
unawaited(
|
||||
queueProgress(drama, episode, player.controller.value.duration),
|
||||
queueProgress(
|
||||
drama,
|
||||
episode,
|
||||
player.controller.value.duration,
|
||||
duration: player.controller.value.duration,
|
||||
finished: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!completionHandled &&
|
||||
@@ -313,11 +429,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
completionHandled = true;
|
||||
unawaited(advanceAfterEnd(player, index));
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (now.difference(lastProgressWrite) < const Duration(seconds: 5)) {
|
||||
return;
|
||||
}
|
||||
lastProgressWrite = now;
|
||||
if (!shouldReportProgress(DateTime.now())) return;
|
||||
unawaited(
|
||||
queueProgress(drama, episode, player.controller.value.position),
|
||||
);
|
||||
@@ -347,13 +459,26 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> queueProgress(Drama drama, Episode episode, Duration position) {
|
||||
/// 进度上报:节流由调用方控制(10 秒),切集/退出/切后台都会补一次。
|
||||
Future<void> queueProgress(
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
Duration position, {
|
||||
Duration? duration,
|
||||
bool finished = false,
|
||||
}) {
|
||||
pendingWrite = pendingWrite
|
||||
.then(
|
||||
(_) => history.save(demoProfileId, drama.id, episode.id, position),
|
||||
(_) => history.save(
|
||||
episodeId: episode.id,
|
||||
position: position,
|
||||
duration: duration,
|
||||
finished: finished,
|
||||
),
|
||||
)
|
||||
.catchError((Object _) {
|
||||
// Playback continues if local history cannot be stored.
|
||||
// 进度上报失败不影响播放(下次节流点会再报一次)。
|
||||
return null;
|
||||
});
|
||||
return pendingWrite;
|
||||
}
|
||||
@@ -368,7 +493,12 @@ class PlayerLogic extends ChangeNotifier {
|
||||
!player.controller.value.isInitialized) {
|
||||
return pendingWrite;
|
||||
}
|
||||
return queueProgress(drama, episode, player.controller.value.position);
|
||||
return queueProgress(
|
||||
drama,
|
||||
episode,
|
||||
player.controller.value.position,
|
||||
duration: player.controller.value.duration,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> selectEpisode(int index) async {
|
||||
@@ -377,6 +507,10 @@ class PlayerLogic extends ChangeNotifier {
|
||||
// Capture the old position synchronously, persist without blocking a swipe.
|
||||
unawaited(saveCurrentProgress());
|
||||
if (request != pageRequest || !isMounted()) return;
|
||||
// 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试)
|
||||
final ready = await ensureTicket(index);
|
||||
if (request != pageRequest || !isMounted()) return;
|
||||
if (!ready && ticketError != null) return;
|
||||
await session.select(index, preloadIndex: nextVisibleIndex(index));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,20 @@ import 'package:provider/provider.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/play_ticket.dart';
|
||||
import '../../core/data/services/engagement_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
import '../../core/data/services/video_player_port.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'player_binding.dart';
|
||||
import 'player_logic.dart';
|
||||
import 'widgets/seek_progress_bar.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
|
||||
class PlayerPage extends StatefulWidget {
|
||||
const PlayerPage({
|
||||
@@ -60,7 +65,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
logic = PlayerBinding().create();
|
||||
logic = PlayerBinding().create(
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
playback: context.read<PlaybackRepository>(),
|
||||
);
|
||||
logic.attach(widget, isMounted: () => mounted);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
logic.start(
|
||||
@@ -189,6 +197,39 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 票据三态之一:起播前正在向后端要签名地址
|
||||
if (selected && logic.ticketLoading)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)),
|
||||
),
|
||||
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
|
||||
if (selected && !logic.ticketLoading && logic.ticketError != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
color: Colors.white70,
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
errorText(l10n, logic.ticketError, l10n.playbackError),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () => unawaited(logic.retryTicket()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected && logic.session.loading)
|
||||
const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)),
|
||||
@@ -215,9 +256,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 锁定集与还没上传片源的剧集都会走到这里,之前是一片纯黑,看不出原因。
|
||||
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
|
||||
if (selected && !ready && logic.blockedEpisodeId == episode.id)
|
||||
_BlockedNotice(
|
||||
l10n: l10n,
|
||||
episode: episode,
|
||||
accessType: logic.blockedTicket?.accessType,
|
||||
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
|
||||
),
|
||||
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
|
||||
if (selected &&
|
||||
!ready &&
|
||||
logic.blockedEpisodeId != episode.id &&
|
||||
logic.session.error == null &&
|
||||
logic.session.isLocked)
|
||||
Center(
|
||||
@@ -284,7 +334,8 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
liked: engagement.isLiked(drama.id),
|
||||
saved: engagement.isSaved(drama.id),
|
||||
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onBack: widget.embedded
|
||||
? null
|
||||
: () => unawaited(logic.leave(context)),
|
||||
@@ -298,8 +349,13 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
}
|
||||
},
|
||||
onSave: () async {
|
||||
final favorites = context.read<FavoriteController>();
|
||||
if (await ensureSignedIn(context)) {
|
||||
unawaited(engagement.toggleSaved(drama.id));
|
||||
final ok = await favorites.toggle(drama.id, card: drama);
|
||||
if (ok || !context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.favoriteToggleFailed)),
|
||||
);
|
||||
}
|
||||
},
|
||||
onComments: () => logic.showComments(context, drama),
|
||||
@@ -389,6 +445,68 @@ class _FullscreenControls extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
|
||||
class _BlockedNotice extends StatelessWidget {
|
||||
const _BlockedNotice({
|
||||
required this.l10n,
|
||||
required this.episode,
|
||||
required this.accessType,
|
||||
required this.unlockCoin,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Episode episode;
|
||||
final String? accessType;
|
||||
final int unlockCoin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final needsUnlock =
|
||||
accessType == PlayTicket.coinRequired ||
|
||||
accessType == PlayTicket.vipRequired;
|
||||
final title = needsUnlock
|
||||
? l10n.lockedTitle(episode.number)
|
||||
: accessType == PlayTicket.noMedia
|
||||
? l10n.noMediaNotice
|
||||
: episode.locked
|
||||
? l10n.lockedTitle(episode.number)
|
||||
: l10n.noMediaNotice;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
needsUnlock
|
||||
? Icons.lock_outline_rounded
|
||||
: Icons.movie_filter_outlined,
|
||||
color: Colors.white70,
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
if (needsUnlock) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
unlockCoin > 0
|
||||
? '${l10n.unlockComingSoon} · $unlockCoin ${l10n.coins}'
|
||||
: l10n.unlockComingSoon,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaybackOverlay extends StatelessWidget {
|
||||
const _PlaybackOverlay({
|
||||
required this.l10n,
|
||||
|
||||
Reference in new issue
Block a user