feat(app): 完成 P0 钱包解锁、下载落盘与手机号登录

This commit is contained in:
Zhou Weidong committed 2026-10-19 23:46:47 +08:00
1 parent de941c841c
commit 136ee74618
47 files changed
+3264 -70

No files matched your search

+6
View File
@@ -3,15 +3,21 @@
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 '../../core/data/services/download_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import 'player_logic.dart';
class PlayerBinding {
PlayerLogic create({
required WatchHistoryRepository history,
required PlaybackRepository playback,
WalletController? wallet,
DownloadController? downloads,
}) => PlayerLogic(
history: history,
playback: playback,
pictureInPicture: AndroidPictureInPicture(),
wallet: wallet,
downloads: downloads,
);
}
+62 -3
View File
@@ -14,10 +14,12 @@ 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';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/platform_share_service.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
@@ -37,11 +39,15 @@ class PlayerLogic extends ChangeNotifier {
required this.history,
required this.playback,
required this.pictureInPicture,
this.wallet,
this.downloads,
});
final WatchHistoryRepository history;
final PlaybackRepository playback;
final AndroidPictureInPicture pictureInPicture;
final WalletController? wallet;
final DownloadController? downloads;
late PageController pages;
late PlaybackQueue queue;
@@ -75,6 +81,10 @@ class PlayerLogic extends ChangeNotifier {
bool pipPreparing = false;
final Map<String, int> pickedEpisode = {};
/// 解锁中的集 id 与失败文案;空表示没有进行中的解锁。
String? unlockingEpisodeId;
String? unlockError;
bool Function() isMounted = () => false;
bool homeMode = false;
bool embedded = false;
@@ -194,7 +204,7 @@ class PlayerLogic extends ChangeNotifier {
/// 票据门:起播前先拿票(命中未过期缓存就不会重复请求),
/// 拿不到地址就交给 [blockedTicket] 让 UI 提示解锁/暂无片源。
Future<bool> ensureTicket(int index) async {
Future<bool> ensureTicket(int index, {bool forceRefresh = false}) async {
if (index < 0 || index >= sessionEpisodes.length) return false;
final episode = sessionEpisodes[index];
final request = pageRequest;
@@ -202,7 +212,18 @@ class PlayerLogic extends ChangeNotifier {
ticketError = null;
notify();
try {
final ticket = await playback.ticketFor(episode.id);
final localPath = await downloads?.findLocalPath(episode.id);
if (request != pageRequest) return false;
if (localPath != null) {
blockedTicket = null;
blockedEpisodeId = null;
sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath));
return true;
}
final ticket = await playback.ticketFor(
episode.id,
forceRefresh: forceRefresh,
);
if (request != pageRequest) return false;
blockedTicket = null;
blockedEpisodeId = null;
@@ -231,6 +252,9 @@ class PlayerLogic extends ChangeNotifier {
}
}
String _fileUrl(String path) =>
path.startsWith('file:') ? path : Uri.file(path).toString();
/// 重试当前集的票据(失败态的"重试"按钮)。
Future<void> retryTicket() async {
ticketError = null;
@@ -238,6 +262,36 @@ class PlayerLogic extends ChangeNotifier {
unawaited(selectEpisode(session.currentIndex));
}
/// 金币解锁当前集;成功后强制刷新票据并直接起播。
Future<bool> unlockEpisode(int index) async {
final service = wallet;
if (service == null || index < 0 || index >= sessionEpisodes.length) {
return false;
}
final episode = sessionEpisodes[index];
unlockingEpisodeId = episode.id;
unlockError = null;
notify();
try {
final result = await service.unlock(episode.id);
if (result == null || !result.unlocked) {
unlockError = service.unlockError;
return false;
}
final ready = await ensureTicket(index, forceRefresh: true);
if (ready) {
await session.select(
index,
preloadIndex: nextVisibleIndex(index),
);
}
return ready;
} finally {
unlockingEpisodeId = null;
if (isMounted()) notify();
}
}
void onPageUpdated(PlayerPage previous) {
if (previous.active != active) {
session.setRouteVisible(active && visibleIndices.isNotEmpty);
@@ -562,7 +616,12 @@ class PlayerLogic extends ChangeNotifier {
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(drama: drama, initialIndex: index),
builder: (_) => PlayerPage(
drama: drama,
initialIndex: index,
wallet: wallet,
downloads: downloads,
),
),
);
if (isMounted()) session.setRouteVisible(active);
+74 -1
View File
@@ -10,9 +10,11 @@ import 'package:video_player/video_player.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/interaction_controller.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/platform/haptics.dart';
@@ -48,6 +50,8 @@ class PlayerPage extends StatefulWidget {
this.feedDramas,
this.onDramaChanged,
this.onImmersiveChanged,
this.wallet,
this.downloads,
});
final Drama drama;
@@ -64,6 +68,8 @@ class PlayerPage extends StatefulWidget {
final List<Drama>? feedDramas;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
final WalletController? wallet;
final DownloadController? downloads;
@override
State<PlayerPage> createState() => _PlayerPageState();
@@ -80,6 +86,8 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
logic = PlayerBinding().create(
history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(),
wallet: widget.wallet,
downloads: widget.downloads,
);
logic.attach(widget, isMounted: () => mounted);
WidgetsBinding.instance.addObserver(this);
@@ -285,6 +293,11 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
episode: episode,
accessType: logic.blockedTicket?.accessType,
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
unlocking: logic.unlockingEpisodeId == episode.id,
error: logic.unlockError,
onUnlock: logic.wallet == null
? null
: () => _unlockEpisode(context, index),
),
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
if (selected &&
@@ -484,6 +497,21 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
setState(() => _playbackSpeed = selected);
await logic.setPlaybackSpeed(selected);
}
Future<void> _unlockEpisode(BuildContext context, int index) async {
if (!await ensureSignedIn(context)) return;
if (!context.mounted) return;
final ok = await logic.unlockEpisode(index);
if (!context.mounted || ok) return;
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
errorText(l10n, logic.unlockError, l10n.unlockFailed),
),
),
);
}
}
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
@@ -493,12 +521,18 @@ class _BlockedNotice extends StatelessWidget {
required this.episode,
required this.accessType,
required this.unlockCoin,
required this.unlocking,
required this.error,
required this.onUnlock,
});
final AppLocalizations l10n;
final Episode episode;
final String? accessType;
final int unlockCoin;
final bool unlocking;
final String? error;
final VoidCallback? onUnlock;
@override
Widget build(BuildContext context) {
@@ -521,7 +555,12 @@ class _BlockedNotice extends StatelessWidget {
coinLabel: needsUnlock && unlockCoin > 0
? '$unlockCoin ${l10n.coins}'
: null,
footnote: needsUnlock ? l10n.unlockComingSoon : null,
footnote: needsUnlock && error != null
? errorText(l10n, error, l10n.unlockFailed)
: null,
actionLabel: needsUnlock ? l10n.unlockWithCoins : null,
busy: unlocking,
onAction: needsUnlock ? onUnlock : null,
),
),
);
@@ -535,12 +574,18 @@ class _UnlockCard extends StatelessWidget {
required this.title,
required this.coinLabel,
required this.footnote,
this.actionLabel,
this.busy = false,
this.onAction,
});
final bool locked;
final String title;
final String? coinLabel;
final String? footnote;
final String? actionLabel;
final bool busy;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
@@ -601,6 +646,34 @@ class _UnlockCard extends StatelessWidget {
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
if (actionLabel != null) ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
height: 40,
child: FilledButton(
key: const Key('unlock-with-coins'),
onPressed: busy ? null : onAction,
style: FilledButton.styleFrom(
backgroundColor: AppColors.gold,
foregroundColor: AppColors.onGold,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
actionLabel!,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
],
],
),
),