feat(app): 完成 P0 钱包解锁、下载落盘与手机号登录
This commit is contained in:
1 parent
de941c841c
commit
136ee74618
47 files changed
+3264
-70
No files matched your search
@@ -0,0 +1,107 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'json_numbers.dart';
|
||||
|
||||
/// 金币钱包与流水:对应 `GET /app/wallet` 的 `wallet` / `ledger`。
|
||||
class CoinWallet {
|
||||
const CoinWallet({
|
||||
required this.coinBalance,
|
||||
required this.totalRechargedCoin,
|
||||
required this.totalConsumedCoin,
|
||||
required this.totalRefundedCoin,
|
||||
});
|
||||
|
||||
final int coinBalance;
|
||||
final int totalRechargedCoin;
|
||||
final int totalConsumedCoin;
|
||||
final int totalRefundedCoin;
|
||||
|
||||
factory CoinWallet.fromJson(Map<String, dynamic> json) {
|
||||
return CoinWallet(
|
||||
coinBalance: jsonToInt(json['coinBalance']),
|
||||
totalRechargedCoin: jsonToInt(json['totalRechargedCoin']),
|
||||
totalConsumedCoin: jsonToInt(json['totalConsumedCoin']),
|
||||
totalRefundedCoin: jsonToInt(json['totalRefundedCoin']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一笔金币变动;[changeAmount] 正数入账、负数出账。
|
||||
class CoinLedgerEntry {
|
||||
const CoinLedgerEntry({
|
||||
required this.id,
|
||||
required this.changeAmount,
|
||||
required this.balanceAfter,
|
||||
required this.bizType,
|
||||
required this.remark,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int changeAmount;
|
||||
final int balanceAfter;
|
||||
final String bizType;
|
||||
final String remark;
|
||||
final DateTime? createdAt;
|
||||
|
||||
factory CoinLedgerEntry.fromJson(Map<String, dynamic> json) {
|
||||
return CoinLedgerEntry(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
changeAmount: jsonToInt(json['changeAmount']),
|
||||
balanceAfter: jsonToInt(json['balanceAfter']),
|
||||
bizType: json['bizType'] as String? ?? '',
|
||||
remark: json['remark'] as String? ?? '',
|
||||
createdAt: DateTime.tryParse(json['createdAt'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包页一次渲染所需的余额与最近流水。
|
||||
class WalletSnapshot {
|
||||
const WalletSnapshot({required this.wallet, this.ledger = const []});
|
||||
|
||||
final CoinWallet wallet;
|
||||
final List<CoinLedgerEntry> ledger;
|
||||
|
||||
factory WalletSnapshot.fromJson(Map<String, dynamic> json) {
|
||||
final wallet = json['wallet'];
|
||||
return WalletSnapshot(
|
||||
wallet: wallet is Map<String, dynamic>
|
||||
? CoinWallet.fromJson(wallet)
|
||||
: const CoinWallet(
|
||||
coinBalance: 0,
|
||||
totalRechargedCoin: 0,
|
||||
totalConsumedCoin: 0,
|
||||
totalRefundedCoin: 0,
|
||||
),
|
||||
ledger: (json['ledger'] as List? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(CoinLedgerEntry.fromJson)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 金币解锁结果:`POST /app/episodes/{id}/unlock`。
|
||||
class UnlockResult {
|
||||
const UnlockResult({
|
||||
required this.unlocked,
|
||||
required this.accessType,
|
||||
required this.coinCost,
|
||||
required this.coinBalance,
|
||||
});
|
||||
|
||||
final bool unlocked;
|
||||
final String accessType;
|
||||
final int coinCost;
|
||||
final int coinBalance;
|
||||
|
||||
factory UnlockResult.fromJson(Map<String, dynamic> json) {
|
||||
return UnlockResult(
|
||||
unlocked: json['unlocked'] == true,
|
||||
accessType: json['accessType'] as String? ?? '',
|
||||
coinCost: jsonToInt(json['coinCost']),
|
||||
coinBalance: jsonToInt(json['coinBalance']),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,67 @@ class AuthApi {
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 发送短信验证码;[scene] 为 REGISTER / LOGIN / RESET_PWD / BIND。
|
||||
Future<void> sendOtp({
|
||||
required String target,
|
||||
required String scene,
|
||||
required String deviceId,
|
||||
}) async {
|
||||
await _client.post(
|
||||
'/app/auth/otp',
|
||||
body: {
|
||||
'scene': scene,
|
||||
'targetType': 'PHONE',
|
||||
'target': target,
|
||||
'deviceId': deviceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 手机号 + 验证码登录。
|
||||
Future<AuthSession> loginWithPhone({
|
||||
required String target,
|
||||
required String code,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/auth/login',
|
||||
body: {
|
||||
'targetType': 'PHONE',
|
||||
'target': target,
|
||||
'code': code,
|
||||
'deviceId': deviceId,
|
||||
'platform': platform,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
);
|
||||
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
/// 手机号 + 验证码注册。
|
||||
Future<AuthSession> registerWithPhone({
|
||||
required String target,
|
||||
required String code,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/auth/register',
|
||||
body: {
|
||||
'targetType': 'PHONE',
|
||||
'target': target,
|
||||
'code': code,
|
||||
'deviceId': deviceId,
|
||||
'platform': platform,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
);
|
||||
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
Future<AuthSession> register({
|
||||
required String email,
|
||||
required String password,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import 'api_client.dart';
|
||||
|
||||
/// 金币与解锁接口,对应契约 §1.8。
|
||||
class CommerceApi {
|
||||
const CommerceApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<WalletSnapshot> wallet() async {
|
||||
final data = await _client.get('/app/wallet');
|
||||
if (data is Map<String, dynamic>) return WalletSnapshot.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
|
||||
Future<UnlockResult> unlock(String episodeId) async {
|
||||
final data = await _client.post('/app/episodes/$episodeId/unlock');
|
||||
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../remote/commerce_api.dart';
|
||||
|
||||
/// 钱包与金币解锁的数据来源。
|
||||
abstract interface class CommerceRepository {
|
||||
Future<WalletSnapshot> fetchWallet();
|
||||
|
||||
Future<UnlockResult> unlock(String episodeId);
|
||||
}
|
||||
|
||||
class HttpCommerceRepository implements CommerceRepository {
|
||||
const HttpCommerceRepository(this._api);
|
||||
|
||||
final CommerceApi _api;
|
||||
|
||||
@override
|
||||
Future<WalletSnapshot> fetchWallet() => _api.wallet();
|
||||
|
||||
@override
|
||||
Future<UnlockResult> unlock(String episodeId) => _api.unlock(episodeId);
|
||||
}
|
||||
@@ -57,6 +57,43 @@ class AuthController extends ChangeNotifier {
|
||||
await _apply(session);
|
||||
}
|
||||
|
||||
Future<void> sendOtp({
|
||||
required String target,
|
||||
String scene = 'LOGIN',
|
||||
}) async {
|
||||
await _api.sendOtp(
|
||||
target: target,
|
||||
scene: scene,
|
||||
deviceId: await _store.deviceId(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> signInWithPhone({
|
||||
required String target,
|
||||
required String code,
|
||||
}) async {
|
||||
final session = await _api.loginWithPhone(
|
||||
target: target,
|
||||
code: code,
|
||||
deviceId: await _store.deviceId(),
|
||||
platform: _platform,
|
||||
);
|
||||
await _apply(session);
|
||||
}
|
||||
|
||||
Future<void> registerWithPhone({
|
||||
required String target,
|
||||
required String code,
|
||||
}) async {
|
||||
final session = await _api.registerWithPhone(
|
||||
target: target,
|
||||
code: code,
|
||||
deviceId: await _store.deviceId(),
|
||||
platform: _platform,
|
||||
);
|
||||
await _apply(session);
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
final refreshToken = _session?.refreshToken ?? '';
|
||||
_session = null;
|
||||
|
||||
@@ -14,6 +14,11 @@ import '../models/auth_session.dart';
|
||||
/// 设备号是后端注册风控与后续"下载设备绑定"的依据,所以**首次生成后就固定下来**;
|
||||
/// 不引 UUID 库(保持极简依赖),用「时间戳 + 随机数」拼一个够用的 id。
|
||||
class AuthSessionStore {
|
||||
AuthSessionStore({SharedPreferencesAsync? preferences})
|
||||
: _preferences = preferences ?? SharedPreferencesAsync();
|
||||
|
||||
final SharedPreferencesAsync _preferences;
|
||||
|
||||
static const _kAccessToken = 'auth.accessToken';
|
||||
static const _kRefreshToken = 'auth.refreshToken';
|
||||
static const _kUser = 'auth.user';
|
||||
@@ -23,25 +28,23 @@ class AuthSessionStore {
|
||||
|
||||
Future<String> deviceId() async {
|
||||
if (_cachedDeviceId != null) return _cachedDeviceId!;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var value = await prefs.getString(_kDeviceId) ?? '';
|
||||
var value = await _preferences.getString(_kDeviceId) ?? '';
|
||||
if (value.isEmpty) {
|
||||
final random = Random();
|
||||
value =
|
||||
'd-${DateTime.now().millisecondsSinceEpoch.toRadixString(36)}-'
|
||||
'${random.nextInt(1 << 32).toRadixString(36)}';
|
||||
await prefs.setString(_kDeviceId, value);
|
||||
await _preferences.setString(_kDeviceId, value);
|
||||
}
|
||||
_cachedDeviceId = value;
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<AuthSession?> read() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final accessToken = await prefs.getString(_kAccessToken) ?? '';
|
||||
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
|
||||
final accessToken = await _preferences.getString(_kAccessToken) ?? '';
|
||||
final refreshToken = await _preferences.getString(_kRefreshToken) ?? '';
|
||||
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
|
||||
final rawUser = await prefs.getString(_kUser);
|
||||
final rawUser = await _preferences.getString(_kUser);
|
||||
Map<String, dynamic> user = const {};
|
||||
if (rawUser != null && rawUser.isNotEmpty) {
|
||||
try {
|
||||
@@ -58,17 +61,15 @@ class AuthSessionStore {
|
||||
}
|
||||
|
||||
Future<void> save(AuthSession session) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kAccessToken, session.accessToken);
|
||||
await prefs.setString(_kRefreshToken, session.refreshToken);
|
||||
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
|
||||
await _preferences.setString(_kAccessToken, session.accessToken);
|
||||
await _preferences.setString(_kRefreshToken, session.refreshToken);
|
||||
await _preferences.setString(_kUser, jsonEncode(session.user.toJson()));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
await prefs.remove(_kUser);
|
||||
await _preferences.remove(_kAccessToken);
|
||||
await _preferences.remove(_kRefreshToken);
|
||||
await _preferences.remove(_kUser);
|
||||
// 设备号不清:它代表"这台设备",与登录态无关
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/download_license.dart';
|
||||
import '../models/download_policy.dart';
|
||||
import '../../config/app_config.dart';
|
||||
import '../remote/api_exception.dart';
|
||||
import '../repositories/download_repository.dart';
|
||||
import 'download_storage.dart';
|
||||
|
||||
/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。
|
||||
class DownloadActionResult {
|
||||
@@ -18,9 +22,15 @@ class DownloadActionResult {
|
||||
|
||||
/// 下载授权与策略状态。
|
||||
class DownloadController extends ChangeNotifier {
|
||||
DownloadController(this._repository);
|
||||
DownloadController(this._repository, {DownloadStorage? storage})
|
||||
: _storage = storage ?? FileDownloadStorage();
|
||||
|
||||
final DownloadRepository _repository;
|
||||
final DownloadStorage _storage;
|
||||
final Map<String, String> _localPaths = {};
|
||||
final Set<String> _downloading = {};
|
||||
final Map<String, double> _progress = {};
|
||||
final Map<String, DateTime> _lastReport = {};
|
||||
|
||||
DownloadPolicy? policy;
|
||||
List<DownloadLicense> licenses = const [];
|
||||
@@ -30,6 +40,18 @@ class DownloadController extends ChangeNotifier {
|
||||
String? actionError;
|
||||
bool _disposed = false;
|
||||
|
||||
bool isDownloaded(String episodeId) => _localPaths.containsKey(episodeId);
|
||||
|
||||
bool isDownloading(String episodeId) => _downloading.contains(episodeId);
|
||||
|
||||
double progressFor(String episodeId) => _progress[episodeId] ?? 0;
|
||||
|
||||
String? localPathFor(String episodeId) => _localPaths[episodeId];
|
||||
|
||||
/// 播放器使用:只读已同步的本地缓存;下载页加载后会把磁盘文件补进来。
|
||||
Future<String?> findLocalPath(String episodeId) async =>
|
||||
_localPaths[episodeId];
|
||||
|
||||
/// 同时拉取策略与当前设备授权列表。
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
@@ -42,6 +64,7 @@ class DownloadController extends ChangeNotifier {
|
||||
]);
|
||||
policy = results[0] as DownloadPolicy;
|
||||
licenses = results[1] as List<DownloadLicense>;
|
||||
await _syncLocalFiles();
|
||||
} catch (_) {
|
||||
error = 'downloadsError';
|
||||
} finally {
|
||||
@@ -50,6 +73,22 @@ class DownloadController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncLocalFiles() async => restore();
|
||||
|
||||
/// 冷启动时恢复已落盘文件,播放器只依赖这份内存索引。
|
||||
Future<void> restore() async {
|
||||
try {
|
||||
final paths = await _storage.restore();
|
||||
_localPaths.addAll(paths);
|
||||
for (final id in paths.keys) {
|
||||
_progress[id] = 1;
|
||||
}
|
||||
_notify();
|
||||
} catch (_) {
|
||||
// 本地目录不可用时保持未下载状态。
|
||||
}
|
||||
}
|
||||
|
||||
/// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。
|
||||
Future<DownloadActionResult> grant(
|
||||
String episodeId, {
|
||||
@@ -76,6 +115,93 @@ class DownloadController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载并落盘:授权已在 [grant] 完成,播放地址缺失时向服务端刷新一次。
|
||||
Future<DownloadActionResult> startDownload(
|
||||
String episodeId, {
|
||||
DownloadLicense? license,
|
||||
}) async {
|
||||
if (_downloading.contains(episodeId)) return const DownloadActionResult(ok: true);
|
||||
final source = license ?? _licenseFor(episodeId);
|
||||
if (source == null) return const DownloadActionResult(ok: false);
|
||||
_beginDownload(episodeId);
|
||||
try {
|
||||
final resolved = await _resolvePlayUrl(source);
|
||||
final media = await _storage.download(
|
||||
episodeId: episodeId,
|
||||
url: resolved,
|
||||
onProgress: (received, total) => _onProgress(episodeId, received, total),
|
||||
);
|
||||
_localPaths[episodeId] = media.filePath;
|
||||
_progress[episodeId] = 1;
|
||||
unawaited(_report(episodeId, 'COMPLETED', 100, media.bytes, media.bytes));
|
||||
return DownloadActionResult(ok: true, license: source);
|
||||
} catch (_) {
|
||||
unawaited(_report(episodeId, 'FAILED', 0, null, null, 'downloadFailed'));
|
||||
actionError = 'downloadFailed';
|
||||
return const DownloadActionResult(ok: false);
|
||||
} finally {
|
||||
_downloading.remove(episodeId);
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
DownloadLicense? _licenseFor(String episodeId) {
|
||||
for (final item in licenses) {
|
||||
if (item.episodeId == episodeId) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _beginDownload(String episodeId) {
|
||||
_downloading.add(episodeId);
|
||||
_progress[episodeId] = 0;
|
||||
actionError = null;
|
||||
unawaited(_report(episodeId, 'DOWNLOADING', 0, 0, 0));
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<String> _resolvePlayUrl(DownloadLicense license) async {
|
||||
final current = license.playUrl.isEmpty
|
||||
? await _repository.refreshUrl(license.episodeId)
|
||||
: license;
|
||||
return AppConfig.mediaUrl(current.playUrl);
|
||||
}
|
||||
|
||||
void _onProgress(String episodeId, int received, int total) {
|
||||
final fraction = total > 0 ? received / total : 0.0;
|
||||
_progress[episodeId] = fraction.clamp(0.0, 1.0);
|
||||
final now = DateTime.now();
|
||||
final last = _lastReport[episodeId];
|
||||
if (last == null || now.difference(last) >= const Duration(seconds: 2)) {
|
||||
_lastReport[episodeId] = now;
|
||||
final percent = (_progress[episodeId]! * 100).round();
|
||||
unawaited(_report(episodeId, 'DOWNLOADING', percent, received, total));
|
||||
}
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> _report(
|
||||
String episodeId,
|
||||
String status,
|
||||
int percent,
|
||||
int? received,
|
||||
int? total, [
|
||||
String? error,
|
||||
]) async {
|
||||
try {
|
||||
await _repository.reportProgress(
|
||||
episodeId,
|
||||
status: status,
|
||||
progressPercent: percent,
|
||||
downloadedBytes: received,
|
||||
totalBytes: total,
|
||||
errorMsg: error,
|
||||
);
|
||||
} catch (_) {
|
||||
// 进度上报失败不阻断本地播放,下次打开下载页会重新同步。
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新设置,先本地展示,失败恢复服务端旧值。
|
||||
Future<bool> updateSettings({bool? wifiOnly, String? maxQuality}) async {
|
||||
final previous = policy;
|
||||
@@ -109,6 +235,9 @@ class DownloadController extends ChangeNotifier {
|
||||
_notify();
|
||||
try {
|
||||
await _repository.remove(episodeId);
|
||||
await _storage.delete(episodeId);
|
||||
_localPaths.remove(episodeId);
|
||||
_progress.remove(episodeId);
|
||||
return true;
|
||||
} catch (_) {
|
||||
final restored = [...licenses];
|
||||
@@ -134,6 +263,9 @@ class DownloadController extends ChangeNotifier {
|
||||
_notify();
|
||||
try {
|
||||
await _repository.clear();
|
||||
await _storage.clear();
|
||||
_localPaths.clear();
|
||||
_progress.clear();
|
||||
return true;
|
||||
} catch (_) {
|
||||
licenses = previous;
|
||||
@@ -153,6 +285,10 @@ class DownloadController extends ChangeNotifier {
|
||||
clearing = false;
|
||||
error = null;
|
||||
actionError = null;
|
||||
_localPaths.clear();
|
||||
_downloading.clear();
|
||||
_progress.clear();
|
||||
_lastReport.clear();
|
||||
_notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// 下载进度回调:[received] 与 [total] 均为字节数,未知总长时 [total] 为 0。
|
||||
typedef DownloadProgress = void Function(int received, int total);
|
||||
|
||||
/// 已落盘的媒体文件。
|
||||
class DownloadedMedia {
|
||||
const DownloadedMedia({
|
||||
required this.episodeId,
|
||||
required this.filePath,
|
||||
required this.bytes,
|
||||
});
|
||||
|
||||
final String episodeId;
|
||||
final String filePath;
|
||||
final int bytes;
|
||||
}
|
||||
|
||||
/// 本地媒体存储边界;页面与控制器不直接操作文件系统。
|
||||
abstract interface class DownloadStorage {
|
||||
Future<Map<String, String>> restore();
|
||||
|
||||
Future<String?> localPathFor(String episodeId);
|
||||
|
||||
Future<DownloadedMedia> download({
|
||||
required String episodeId,
|
||||
required String url,
|
||||
DownloadProgress? onProgress,
|
||||
});
|
||||
|
||||
Future<void> delete(String episodeId);
|
||||
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
/// 将分集落盘到应用文档目录;临时文件完成后原子改名,避免半文件被播放。
|
||||
class FileDownloadStorage implements DownloadStorage {
|
||||
FileDownloadStorage({
|
||||
Future<Directory> Function()? directoryProvider,
|
||||
HttpClient Function()? clientFactory,
|
||||
}) : _directoryProvider = directoryProvider ?? getApplicationDocumentsDirectory,
|
||||
_clientFactory = clientFactory ?? HttpClient.new;
|
||||
|
||||
final Future<Directory> Function() _directoryProvider;
|
||||
final HttpClient Function() _clientFactory;
|
||||
|
||||
static const String _folder = 'downloads';
|
||||
static const String _partSuffix = '.part';
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> restore() async {
|
||||
try {
|
||||
final directory = await _downloadDirectory();
|
||||
final files = <String, String>{};
|
||||
await for (final entity in directory.list()) {
|
||||
if (entity is! File) continue;
|
||||
final name = entity.uri.pathSegments.last;
|
||||
if (name.endsWith(_partSuffix)) continue;
|
||||
final dot = name.lastIndexOf('.');
|
||||
final id = dot > 0 ? name.substring(0, dot) : name;
|
||||
files[id] = entity.path;
|
||||
}
|
||||
return files;
|
||||
} catch (_) {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> localPathFor(String episodeId) async {
|
||||
try {
|
||||
final file = await _findFile(episodeId, includePartial: false);
|
||||
return file?.path;
|
||||
} catch (_) {
|
||||
// 本地目录不可用时视为未下载;真正下载会单独返回错误。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DownloadedMedia> download({
|
||||
required String episodeId,
|
||||
required String url,
|
||||
DownloadProgress? onProgress,
|
||||
}) async {
|
||||
final existing = await _findFile(episodeId, includePartial: false);
|
||||
if (existing != null) {
|
||||
final length = await existing.length();
|
||||
onProgress?.call(length, length);
|
||||
return DownloadedMedia(
|
||||
episodeId: episodeId,
|
||||
filePath: existing.path,
|
||||
bytes: length,
|
||||
);
|
||||
}
|
||||
final directory = await _downloadDirectory();
|
||||
final target = File('${directory.path}/${_safe(episodeId)}${_extension(url)}');
|
||||
final partial = File('${target.path}$_partSuffix');
|
||||
final client = _clientFactory();
|
||||
var received = 0;
|
||||
IOSink? sink;
|
||||
try {
|
||||
final request = await client.getUrl(Uri.parse(url));
|
||||
final response = await request.close();
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
throw HttpException('HTTP ${response.statusCode}', uri: Uri.parse(url));
|
||||
}
|
||||
sink = partial.openWrite();
|
||||
await for (final chunk in response) {
|
||||
sink.add(chunk);
|
||||
received += chunk.length;
|
||||
final total = response.contentLength > 0 ? response.contentLength : 0;
|
||||
onProgress?.call(received, total);
|
||||
}
|
||||
await sink.close();
|
||||
sink = null;
|
||||
if (await target.exists()) await target.delete();
|
||||
final file = await partial.rename(target.path);
|
||||
final bytes = await file.length();
|
||||
onProgress?.call(bytes, bytes);
|
||||
return DownloadedMedia(
|
||||
episodeId: episodeId,
|
||||
filePath: file.path,
|
||||
bytes: bytes,
|
||||
);
|
||||
} catch (_) {
|
||||
await sink?.close();
|
||||
if (await partial.exists()) await partial.delete();
|
||||
rethrow;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String episodeId) async {
|
||||
final file = await _findFile(episodeId, includePartial: true);
|
||||
if (file != null && await file.exists()) await file.delete();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final directory = await _downloadDirectory();
|
||||
await for (final entity in directory.list()) {
|
||||
if (entity is File) await entity.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> _findFile(
|
||||
String episodeId, {
|
||||
required bool includePartial,
|
||||
}) async {
|
||||
final directory = await _downloadDirectory();
|
||||
final prefix = _safe(episodeId);
|
||||
await for (final entity in directory.list()) {
|
||||
if (entity is! File) continue;
|
||||
final name = entity.uri.pathSegments.last;
|
||||
if (!name.startsWith(prefix)) continue;
|
||||
final isPartial = name.endsWith(_partSuffix);
|
||||
if (includePartial || !isPartial) return entity;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Directory> _downloadDirectory() async {
|
||||
final base = await _directoryProvider();
|
||||
return Directory('${base.path}/$_folder').create(recursive: true);
|
||||
}
|
||||
|
||||
String _safe(String episodeId) =>
|
||||
episodeId.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
|
||||
|
||||
String _extension(String url) {
|
||||
final path = Uri.tryParse(url)?.path.toLowerCase() ?? '';
|
||||
if (path.endsWith('.m3u8')) return '.m3u8';
|
||||
return '.mp4';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
@@ -21,10 +23,18 @@ class VideoPlayerPort implements PlayerPort {
|
||||
Episode episode,
|
||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||
) {
|
||||
final options = VideoPlayerOptions(mixWithOthers: false);
|
||||
// 片源统一由后端下发(详情里的可播放地址 / 播放票据)
|
||||
final uri = resolveUri(episode);
|
||||
final options = VideoPlayerOptions(mixWithOthers: false);
|
||||
if (uri.scheme == 'file') {
|
||||
return VideoPlayerController.file(
|
||||
File.fromUri(uri),
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
}
|
||||
return VideoPlayerController.networkUrl(
|
||||
resolveUri(episode),
|
||||
uri,
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: options,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/wallet.dart';
|
||||
import '../remote/api_exception.dart';
|
||||
import '../repositories/commerce_repository.dart';
|
||||
|
||||
/// 钱包状态:余额、最近流水与金币解锁。
|
||||
///
|
||||
/// 余额是解锁后 UI 的唯一来源;接口失败保留后端 message,页面原样展示。
|
||||
class WalletController extends ChangeNotifier {
|
||||
WalletController(this._repository);
|
||||
|
||||
final CommerceRepository _repository;
|
||||
|
||||
WalletSnapshot? snapshot;
|
||||
bool loading = false;
|
||||
bool unlocking = false;
|
||||
String? error;
|
||||
String? unlockError;
|
||||
String? unlockingEpisodeId;
|
||||
bool _disposed = false;
|
||||
|
||||
int get balance => snapshot?.wallet.coinBalance ?? 0;
|
||||
List<CoinLedgerEntry> get ledger => snapshot?.ledger ?? const [];
|
||||
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
_notify();
|
||||
try {
|
||||
snapshot = await _repository.fetchWallet();
|
||||
} on ApiException catch (exception) {
|
||||
error = exception.message;
|
||||
} catch (_) {
|
||||
error = 'walletLoadFailed';
|
||||
} finally {
|
||||
loading = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 金币解锁;成功后就地更新余额并返回后端结果。
|
||||
Future<UnlockResult?> unlock(String episodeId) async {
|
||||
if (unlocking) return null;
|
||||
unlocking = true;
|
||||
unlockingEpisodeId = episodeId;
|
||||
unlockError = null;
|
||||
_notify();
|
||||
try {
|
||||
final result = await _repository.unlock(episodeId);
|
||||
final current = snapshot;
|
||||
snapshot = WalletSnapshot(
|
||||
wallet: CoinWallet(
|
||||
coinBalance: result.coinBalance,
|
||||
totalRechargedCoin: current?.wallet.totalRechargedCoin ?? 0,
|
||||
totalConsumedCoin: current?.wallet.totalConsumedCoin ?? 0,
|
||||
totalRefundedCoin: current?.wallet.totalRefundedCoin ?? 0,
|
||||
),
|
||||
ledger: current?.ledger ?? const [],
|
||||
);
|
||||
return result;
|
||||
} on ApiException catch (exception) {
|
||||
unlockError = exception.message;
|
||||
return null;
|
||||
} catch (_) {
|
||||
unlockError = 'unlockFailed';
|
||||
return null;
|
||||
} finally {
|
||||
unlocking = false;
|
||||
unlockingEpisodeId = null;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
snapshot = null;
|
||||
loading = false;
|
||||
unlocking = false;
|
||||
error = null;
|
||||
unlockError = null;
|
||||
unlockingEpisodeId = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user