add
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
/// 全局配置常量(域名发现、公共请求头、版本号,对应《01-开发文档》5.5/5.6/5.7)
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
/// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号)
|
||||
static const String appVersion = '1.0.0';
|
||||
static const int appVersionCode = 1;
|
||||
|
||||
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
|
||||
static const String platformNameIOS = 'ios';
|
||||
static const String platformNameAndroid = 'android';
|
||||
|
||||
/// 域名发现:DNS TXT 记录域名与兜底域名后缀
|
||||
static const String dnsTxtHost = 'txt.256kb.top';
|
||||
static const String fallbackDomainSuffix = '_jsnbhh.vip';
|
||||
|
||||
/// DoH 服务(阿里 / 腾讯),TXT 解析走 JSON API
|
||||
static const List<String> dohServers = [
|
||||
'https://223.5.5.5/resolve',
|
||||
'https://119.29.29.29/dns-query',
|
||||
];
|
||||
|
||||
/// 缓存域名连续失败达到该阈值后,重新走完整发现流程
|
||||
static const int domainFailureThreshold = 3;
|
||||
|
||||
/// 开发调试:--dart-define=API_BASE=http://localhost:8000 可直接指定本地服务,
|
||||
/// 跳过域名发现(生产构建不传即走 TXT/兜底逻辑)
|
||||
static const String devApiBase = String.fromEnvironment('API_BASE');
|
||||
|
||||
/// 微信支付 iOS 回跳 Universal Link:
|
||||
/// --dart-define=WECHAT_UNIVERSAL_LINK=https://app.256kb.top/wechat
|
||||
/// 须与 pubspec.yaml 中 fluwx.ios.universal_link 保持一致;未传时 iOS 端无法回跳
|
||||
static const String wechatUniversalLink =
|
||||
String.fromEnvironment('WECHAT_UNIVERSAL_LINK');
|
||||
|
||||
/// 请求超时
|
||||
static const Duration connectTimeout = Duration(seconds: 10);
|
||||
static const Duration receiveTimeout = Duration(seconds: 20);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import '../../models/video_item.dart';
|
||||
|
||||
/// 开发期播放地址钩子:
|
||||
/// 服务端 /hls 转码产物未就绪前,用 --dart-define=TEST_VIDEO_URL=xxx 注入公网
|
||||
/// 测试 m3u8,把 video_url 为空或相对路径的条目替换为测试流以验证播放器。
|
||||
/// 生产构建不传该 define,零开销;服务端 /hls 就绪后此钩子可保留作降级兜底。
|
||||
const String _testStreamUrl = String.fromEnvironment('TEST_VIDEO_URL');
|
||||
|
||||
/// 解析实际播放地址。
|
||||
/// - 注入了测试流且原始地址不可用(空/非 http 绝对地址)→ 返回测试流
|
||||
/// - 否则返回原始 video_url
|
||||
///
|
||||
/// [testUrl] 可选参数便于单元测试(fromEnvironment 在测试中恒为空)。
|
||||
String resolvePlayUrl(VideoItem item, {String? testUrl}) {
|
||||
final injected = testUrl ?? _testStreamUrl;
|
||||
final raw = item.videoUrl.trim();
|
||||
final playable = raw.startsWith('http://') || raw.startsWith('https://');
|
||||
if (!playable && injected.isNotEmpty) return injected;
|
||||
return raw;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'api_domain.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'endpoints.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/local_store.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
/// 统一网络客户端:
|
||||
/// - Base URL 来自域名发现(ApiDomain),不硬编码任何 API 域名
|
||||
/// - 公共请求头:X-Device-ID / X-App-Version / X-Platform / X-Request-ID
|
||||
/// - 登录态:Authorization: Bearer `access_token`(TokenStore 内存缓存)
|
||||
/// - 统一响应解包:code=0 → data;非 0 → ApiException;HTTP 401 → 静默刷新重试一次或登出
|
||||
/// - 缓存域名连续失败达阈值 → 重新域名发现并切换 Base URL
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
|
||||
static late Dio _dio;
|
||||
static Dio get I => _dio;
|
||||
|
||||
/// 401 且无法刷新时的回调(由 AuthProvider 注册:清登录态并跳登录页)
|
||||
static void Function()? onSessionExpired;
|
||||
|
||||
/// 初始化(splash 阶段调用一次)
|
||||
static Future<void> init() async {
|
||||
final base = await ApiDomain.resolve();
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: base,
|
||||
connectTimeout: AppConfig.connectTimeout,
|
||||
receiveTimeout: AppConfig.receiveTimeout,
|
||||
// 业务错误统一 HTTP 200 + code,服务端 4xx/5xx 也走统一处理
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
));
|
||||
_dio.interceptors.addAll([
|
||||
_CommonHeadersInterceptor(),
|
||||
_UnwrapInterceptor(),
|
||||
_ErrorInterceptor(),
|
||||
]);
|
||||
}
|
||||
|
||||
/// ---------- 便捷请求方法:直接返回解包后的 data ----------
|
||||
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理
|
||||
|
||||
static Future<T> get<T>(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
final resp = await _dio.get<dynamic>(path, queryParameters: queryParameters);
|
||||
return resp.data as T;
|
||||
}
|
||||
|
||||
static Future<T> post<T>(String path,
|
||||
{Object? data, ProgressCallback? onSendProgress}) async {
|
||||
final resp = await _dio.post<dynamic>(path,
|
||||
data: data, onSendProgress: onSendProgress);
|
||||
return resp.data as T;
|
||||
}
|
||||
|
||||
static Future<T> put<T>(String path, {Object? data}) async {
|
||||
final resp = await _dio.put<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
}
|
||||
|
||||
static Future<T> delete<T>(String path, {Object? data}) async {
|
||||
final resp = await _dio.delete<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
/// 公共请求头注入
|
||||
class _CommonHeadersInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
options.headers['X-Device-ID'] = LocalStore.I.deviceId;
|
||||
options.headers['X-App-Version'] = AppConfig.appVersion;
|
||||
options.headers['X-Platform'] = Platform.isIOS
|
||||
? AppConfig.platformNameIOS
|
||||
: AppConfig.platformNameAndroid;
|
||||
options.headers['X-Request-ID'] = const Uuid().v4();
|
||||
final token = TokenStore.accessToken;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一响应解包:{ code, message, data } → code=0 返回 data,非 0 抛 ApiException
|
||||
class _UnwrapInterceptor extends Interceptor {
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
ApiDomain.markSuccess();
|
||||
final body = response.data;
|
||||
if (body is Map && body.containsKey('code')) {
|
||||
final code = body['code'] as int;
|
||||
if (code == 0) {
|
||||
response.data = body['data'];
|
||||
return handler.next(response);
|
||||
}
|
||||
return handler.reject(DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
error: ApiException(
|
||||
code,
|
||||
(body['message'] as String?) ?? '请求失败',
|
||||
data: body['data'] is Map<String, dynamic>
|
||||
? body['data'] as Map<String, dynamic>
|
||||
: null,
|
||||
),
|
||||
));
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,失败则回调登出
|
||||
class _ErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// 网络层失败(域名不可达等):计数并按需重新发现域名
|
||||
if (err.type == DioExceptionType.connectionError ||
|
||||
err.type == DioExceptionType.connectionTimeout) {
|
||||
await ApiDomain.rediscoverIfBroken();
|
||||
ApiClient._dio.options.baseUrl = ApiDomain.current;
|
||||
}
|
||||
|
||||
final apiError = err.error;
|
||||
final status = err.response?.statusCode;
|
||||
|
||||
// 401:静默刷新重试一次
|
||||
if (status == 401 &&
|
||||
err.requestOptions.extra['retried'] != true &&
|
||||
TokenStore.refreshToken != null) {
|
||||
final newToken = await _refreshToken();
|
||||
if (newToken != null) {
|
||||
try {
|
||||
final opts = err.requestOptions;
|
||||
opts.extra['retried'] = true;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final resp = await ApiClient._dio.fetch<dynamic>(opts);
|
||||
return handler.resolve(resp);
|
||||
} catch (_) {
|
||||
// 重试仍失败 → 走下方登出
|
||||
}
|
||||
}
|
||||
await TokenStore.clear();
|
||||
ApiClient.onSessionExpired?.call();
|
||||
}
|
||||
|
||||
final message = apiError is ApiException ? apiError.message : '网络异常,请稍后重试';
|
||||
final code = apiError is ApiException ? apiError.code : -1;
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ApiException(code, message),
|
||||
));
|
||||
}
|
||||
|
||||
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归
|
||||
Future<String?> _refreshToken() async {
|
||||
final refresh = TokenStore.refreshToken;
|
||||
if (refresh == null) return null;
|
||||
try {
|
||||
final bare = Dio(BaseOptions(
|
||||
baseUrl: ApiDomain.current,
|
||||
validateStatus: (s) => s != null && s < 500,
|
||||
));
|
||||
final resp = await bare.post<Map<String, dynamic>>(
|
||||
Endpoints.tokenRefresh,
|
||||
data: {'refresh_token': refresh},
|
||||
);
|
||||
final body = resp.data;
|
||||
if (body != null && body['code'] == 0) {
|
||||
final data = body['data'] as Map<String, dynamic>;
|
||||
await TokenStore.save(
|
||||
access: data['access_token'] as String,
|
||||
refresh: data['refresh_token'] as String,
|
||||
);
|
||||
return TokenStore.accessToken;
|
||||
}
|
||||
} catch (_) {
|
||||
// 刷新失败视为会话过期
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/local_store.dart';
|
||||
|
||||
/// API 域名发现(《01-开发文档》5.5):
|
||||
/// 1. 优先使用 --dart-define=API_BASE 调试覆盖
|
||||
/// 2. 本地缓存域名
|
||||
/// 3. DoH 解析 txt.256kb.top 的 TXT 记录,内容即真实 API 域名
|
||||
/// 4. 兜底:md5(yyyyMM) + "_jsnbhh.vip"
|
||||
/// 结果统一拼 https:// 作为 Base URL 并写入缓存。
|
||||
class ApiDomain {
|
||||
ApiDomain._();
|
||||
|
||||
static final Dio _doh = Dio(BaseOptions(
|
||||
connectTimeout: AppConfig.connectTimeout,
|
||||
receiveTimeout: AppConfig.connectTimeout,
|
||||
));
|
||||
|
||||
static int _failCount = 0;
|
||||
|
||||
/// 当前生效的 Base URL(含 https:// 前缀,无尾斜杠)
|
||||
static String? _current;
|
||||
static String get current => _current ?? '';
|
||||
|
||||
/// 启动时解析域名:按优先级逐级回退
|
||||
static Future<String> resolve() async {
|
||||
// 0. 调试覆盖
|
||||
if (AppConfig.devApiBase.isNotEmpty) {
|
||||
_current = _normalize(AppConfig.devApiBase);
|
||||
return _current!;
|
||||
}
|
||||
// 1. 本地缓存
|
||||
final cached = LocalStore.I.cachedBaseUrl;
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_current = cached;
|
||||
return cached;
|
||||
}
|
||||
// 2/3. TXT → 兜底域名
|
||||
_current = await _discover();
|
||||
return _current!;
|
||||
}
|
||||
|
||||
/// 缓存域名连续失败达到阈值时重新发现(由 ApiClient 调用)
|
||||
static Future<String> rediscoverIfBroken() async {
|
||||
_failCount++;
|
||||
if (_failCount < AppConfig.domainFailureThreshold) return current;
|
||||
_failCount = 0;
|
||||
final next = await _discover();
|
||||
if (next != current) {
|
||||
_current = next;
|
||||
await LocalStore.I.saveBaseUrl(next);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/// 请求成功时清零失败计数
|
||||
static void markSuccess() => _failCount = 0;
|
||||
|
||||
/// 完整发现流程:TXT → 兜底
|
||||
static Future<String> _discover() async {
|
||||
final host = await _resolveTxt();
|
||||
if (host != null && host.isNotEmpty) {
|
||||
final base = _normalize(host);
|
||||
await LocalStore.I.saveBaseUrl(base);
|
||||
return base;
|
||||
}
|
||||
final base = _normalize(_fallbackDomain());
|
||||
await LocalStore.I.saveBaseUrl(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
/// DoH 解析 TXT:优先阿里 223.5.5.5,失败切腾讯 119.29.29.29
|
||||
static Future<String?> _resolveTxt() async {
|
||||
for (final server in AppConfig.dohServers) {
|
||||
try {
|
||||
final resp = await _doh.get<dynamic>(
|
||||
server,
|
||||
queryParameters: {'name': AppConfig.dnsTxtHost, 'type': 'TXT'},
|
||||
);
|
||||
final body = resp.data;
|
||||
final answers = body is Map ? body['Answer'] as List? : null;
|
||||
if (answers == null || answers.isEmpty) continue;
|
||||
// TXT 记录值形如 "\"api.xxx.com\"",去掉引号
|
||||
final raw = (answers.first['data'] as String? ?? '').replaceAll('"', '').trim();
|
||||
if (raw.isNotEmpty) return raw;
|
||||
} catch (_) {
|
||||
continue; // 单个 DoH 失败不影响整体回退
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 兜底域名:md5(yyyyMM)(32 位小写)+ 后缀
|
||||
static String _fallbackDomain() {
|
||||
final now = DateTime.now();
|
||||
final ym = '${now.year}${now.month.toString().padLeft(2, '0')}';
|
||||
final digest = md5.convert(utf8.encode(ym)).toString();
|
||||
return '$digest${AppConfig.fallbackDomainSuffix}';
|
||||
}
|
||||
|
||||
/// 归一化为 Base URL:补 https://,去尾斜杠
|
||||
static String _normalize(String hostOrUrl) {
|
||||
var s = hostOrUrl.trim();
|
||||
if (!s.startsWith('http://') && !s.startsWith('https://')) {
|
||||
s = 'https://$s';
|
||||
}
|
||||
return s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
||||
}
|
||||
|
||||
/// 统一业务错误转 ApiException(域名探测等场景复用)
|
||||
static ApiException wrap(Object error) {
|
||||
if (error is ApiException) return error;
|
||||
return ApiException(-1, '网络异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// 业务异常:对应统一响应结构 { code, message, data }
|
||||
/// code=0 为成功;非 0 抛出该异常由 UI 层捕获提示(错误码表见《03-接口文档》1.3)
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.code, this.message, {this.data});
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
/// 错误响应携带的 data(如 2001 积分不足时含当前 points、2002 含 need_login)
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
/// 未登录或 Token 无效/过期
|
||||
bool get isUnauthorized => code == 1002;
|
||||
|
||||
/// 游客今日免费次数已用完(需登录引导)
|
||||
bool get isGuestLimit => code == 2002;
|
||||
|
||||
/// 积分不足(需引导充值/开通会员)
|
||||
bool get isPointsInsufficient => code == 2001;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code, $message)';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/// 接口路径常量(《03-接口文档》第 2~10 章)
|
||||
/// 统一响应已由 ApiClient 解包,各 Service 只关心 data。
|
||||
class Endpoints {
|
||||
Endpoints._();
|
||||
|
||||
// ---------- 认证 ----------
|
||||
static const smsSend = '/auth/sms/send';
|
||||
static const emailSend = '/auth/email/send';
|
||||
static const register = '/auth/register';
|
||||
static const login = '/auth/login';
|
||||
static const tokenRefresh = '/auth/token/refresh';
|
||||
static const logout = '/auth/logout';
|
||||
static const passwordReset = '/auth/password/reset';
|
||||
|
||||
// ---------- 用户 ----------
|
||||
static const me = '/users/me';
|
||||
static const meAvatar = '/users/me/avatar';
|
||||
static const meWallet = '/users/me/wallet';
|
||||
static const mePointsRecords = '/users/me/points/records';
|
||||
static const meDeactivate = '/users/me/deactivate';
|
||||
static String userDetail(int userId) => '/users/$userId';
|
||||
static String userVideos(int userId) => '/users/$userId/videos';
|
||||
static const meVideos = '/users/me/videos';
|
||||
static const meFavorites = '/users/me/favorites';
|
||||
static const meHistory = '/users/me/history';
|
||||
|
||||
// ---------- 视频 ----------
|
||||
static const feed = '/videos/feed';
|
||||
static const categories = '/videos/categories';
|
||||
static const uploadToken = '/videos/upload/token';
|
||||
static const uploadFile = '/videos/upload/file';
|
||||
static const publish = '/videos';
|
||||
static const search = '/videos/search';
|
||||
static const hotSearch = '/search/hot';
|
||||
static String videoDetail(int videoId) => '/videos/$videoId';
|
||||
static String videoWatch(int videoId) => '/videos/$videoId/watch';
|
||||
static String videoWatchComplete(int videoId) => '/videos/$videoId/watch/complete';
|
||||
static String videoLike(int videoId) => '/videos/$videoId/like';
|
||||
static String videoFavorite(int videoId) => '/videos/$videoId/favorite';
|
||||
static String videoComments(int videoId) => '/videos/$videoId/comments';
|
||||
static String videoEdit(int videoId) => '/videos/$videoId';
|
||||
static String videoDelete(int videoId) => '/videos/$videoId';
|
||||
static String tagVideos(String tagName) => '/tags/$tagName/videos';
|
||||
|
||||
// ---------- 互动 ----------
|
||||
static String commentDelete(int commentId) => '/comments/$commentId';
|
||||
|
||||
// ---------- 会员 ----------
|
||||
static const membershipPlans = '/membership/plans';
|
||||
static const membershipMe = '/membership/me';
|
||||
static const membershipRedeem = '/membership/redeem';
|
||||
|
||||
// ---------- 支付 ----------
|
||||
static const payPackages = '/payments/packages';
|
||||
static const recharge = '/payments/recharge';
|
||||
static String orderDetail(String orderNo) => '/payments/orders/$orderNo';
|
||||
static String orderCancel(String orderNo) => '/payments/orders/$orderNo/cancel';
|
||||
|
||||
// ---------- 激励 / 推广归因 ----------
|
||||
static const referralMe = '/referral/me';
|
||||
static const referralBind = '/referral/bind';
|
||||
static const inviteMatch = '/invite/match';
|
||||
static const assetsMe = '/assets/me';
|
||||
static const assetsRecords = '/assets/records';
|
||||
static const assetsWithdraw = '/assets/withdraw';
|
||||
static const withdrawRecords = '/assets/withdraw/records';
|
||||
|
||||
// ---------- 通用 ----------
|
||||
static const appVersion = '/app/version';
|
||||
static const notifications = '/notifications';
|
||||
static const notificationsUnread = '/notifications/unread_count';
|
||||
static const notificationsRead = '/notifications/read';
|
||||
static const report = '/reports';
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
||||
class PlayerSlot {
|
||||
PlayerSlot(this.player);
|
||||
|
||||
final Player player;
|
||||
late final VideoController controller = VideoController(player);
|
||||
|
||||
/// 当前槽位绑定的 videoId(-1 表示空槽)
|
||||
int videoId = -1;
|
||||
|
||||
/// 是否已回调过 complete(每视频每次绑定只触发一次)
|
||||
bool completeNotified = false;
|
||||
}
|
||||
|
||||
/// Feed 播放器池:3 槽位循环复用,防止 PageView 无限列表下 OOM。
|
||||
///
|
||||
/// 策略:
|
||||
/// - 窗口 [current-1, current, current+1] 映射到 index % 3 槽位
|
||||
/// - 当前槽 play,相邻槽仅 open 预缓冲,出窗槽 stop 复位
|
||||
/// - 每个槽位独立订阅进度流:position ≥ duration×95% 或 completed → 回调一次 complete
|
||||
///
|
||||
/// 构造接受 [playerFactory] 便于测试注入 fake(本机无播放器环境)。
|
||||
class FeedPlayerPool {
|
||||
FeedPlayerPool({
|
||||
Player Function()? playerFactory,
|
||||
this.onWatchComplete,
|
||||
}) : _playerFactory = playerFactory;
|
||||
|
||||
final Player Function()? _playerFactory;
|
||||
|
||||
/// 完整观看回调:(videoId, watchDuration秒),由上层做去重与上报
|
||||
final void Function(int videoId, int watchDurationSeconds)? onWatchComplete;
|
||||
|
||||
static const int slotCount = 3;
|
||||
|
||||
final List<PlayerSlot> _slots = [];
|
||||
final List<StreamSubscription> _subs = [];
|
||||
|
||||
int _currentIndex = 0;
|
||||
int get currentIndex => _currentIndex;
|
||||
|
||||
/// 初始化(创建 3 槽位并订阅各自进度流)
|
||||
void init() {
|
||||
for (var i = 0; i < slotCount; i++) {
|
||||
final player = _playerFactory?.call() ?? Player();
|
||||
final slot = PlayerSlot(player);
|
||||
_slots.add(slot);
|
||||
_hookSlot(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽位号(PageView 页用 index % 3 取对应 controller 渲染)
|
||||
int slotOf(int index) => index % slotCount;
|
||||
|
||||
PlayerSlot slotAt(int slotIndex) => _slots[slotIndex];
|
||||
VideoController controllerAt(int slotIndex) => _slots[slotIndex].controller;
|
||||
|
||||
PlayerSlot get currentSlot => _slots[slotOf(_currentIndex)];
|
||||
|
||||
bool get isCurrentPlaying => currentSlot.player.state.playing;
|
||||
|
||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||
void bindWindow(int index, List<({int videoId, String url})> items) {
|
||||
_currentIndex = index;
|
||||
// 1. 窗口内槽位按需加载
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
final slot = _slots[slotOf(i)];
|
||||
final item = items[i];
|
||||
if (slot.videoId == item.videoId) continue; // 已加载,避免重复 open
|
||||
slot.videoId = item.videoId;
|
||||
slot.completeNotified = false;
|
||||
if (item.url.isNotEmpty) {
|
||||
slot.player.open(Media(item.url), play: false);
|
||||
}
|
||||
}
|
||||
// 2. 槽位状态:当前播、相邻预缓冲(保持 pause)、出窗复位
|
||||
for (var s = 0; s < slotCount; s++) {
|
||||
final slot = _slots[s];
|
||||
if (s == slotOf(index)) continue;
|
||||
if (slot.videoId == -1) continue;
|
||||
if (_inWindow(slot.videoId, index, items)) {
|
||||
slot.player.pause(); // 窗口内非当前:预缓冲不播放
|
||||
} else {
|
||||
slot.player.stop();
|
||||
slot.videoId = -1; // 出窗复位
|
||||
}
|
||||
}
|
||||
currentSlot.player.play();
|
||||
}
|
||||
|
||||
bool _inWindow(int videoId, int index, List<({int videoId, String url})> items) {
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i >= 0 && i < items.length && items[i].videoId == videoId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
|
||||
void _hookSlot(PlayerSlot slot) {
|
||||
var lastPosition = Duration.zero;
|
||||
_subs.add(slot.player.stream.position.listen((pos) {
|
||||
lastPosition = pos;
|
||||
if (slot.videoId < 0 || slot.completeNotified) return;
|
||||
// 仅当前槽才上报(预缓冲槽不应触发)
|
||||
if (slot != currentSlot) return;
|
||||
final dur = slot.player.state.duration;
|
||||
if (dur.inMilliseconds > 0 &&
|
||||
pos.inMilliseconds >= dur.inMilliseconds * 0.95) {
|
||||
slot.completeNotified = true;
|
||||
onWatchComplete?.call(slot.videoId, pos.inSeconds);
|
||||
}
|
||||
}));
|
||||
_subs.add(slot.player.stream.completed.listen((done) {
|
||||
if (!done) return;
|
||||
if (slot.videoId < 0 || slot.completeNotified) return;
|
||||
if (slot != currentSlot) return;
|
||||
slot.completeNotified = true;
|
||||
onWatchComplete?.call(slot.videoId, lastPosition.inSeconds);
|
||||
}));
|
||||
}
|
||||
|
||||
/// 暂停/继续/切换当前槽(手势与生命周期用)
|
||||
void pauseCurrent() => currentSlot.player.pause();
|
||||
void playCurrent() => currentSlot.player.play();
|
||||
void toggleCurrent() => currentSlot.player.playOrPause();
|
||||
|
||||
/// 释放全部资源(Feed 页 dispose 时调用)
|
||||
Future<void> dispose() async {
|
||||
for (final sub in _subs) {
|
||||
await sub.cancel();
|
||||
}
|
||||
_subs.clear();
|
||||
for (final slot in _slots) {
|
||||
await slot.player.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地轻量配置存储(SharedPreferences)
|
||||
/// device_id 为游客计费与风控的设备指纹:
|
||||
/// 生产要求 Android 存 Keystore 加密的 SharedPreferences、iOS 存 Keychain(卸载重装保留),
|
||||
/// 骨架阶段先持久化到 SharedPreferences,接入原生通道时替换此处实现即可。
|
||||
class LocalStore {
|
||||
LocalStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _keyDeviceId = 'device_id';
|
||||
static const String _keyCachedDomain = 'cached_domain';
|
||||
static const String _keyPendingInviteCode = 'pending_invite_code';
|
||||
static const String _keyInviteMatchReported = 'invite_match_reported';
|
||||
|
||||
static LocalStore? _instance;
|
||||
|
||||
/// 初始化(main 中 await 完成后再 runApp)
|
||||
static Future<LocalStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = LocalStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
static LocalStore get I => _instance!;
|
||||
|
||||
/// 读取设备 ID;不存在时生成 UUID v4 并持久化
|
||||
String ensureDeviceId(String Function() uuidV4) {
|
||||
final existing = _prefs.getString(_keyDeviceId);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
final id = uuidV4();
|
||||
_prefs.setString(_keyDeviceId, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
String get deviceId => _prefs.getString(_keyDeviceId) ?? '';
|
||||
|
||||
/// 待绑定邀请码(首启口令解析/模糊归因命中后写入,注册成功或手填后清除)
|
||||
String? get pendingInviteCode => _prefs.getString(_keyPendingInviteCode);
|
||||
|
||||
Future<void> savePendingInviteCode(String code) =>
|
||||
_prefs.setString(_keyPendingInviteCode, code);
|
||||
|
||||
Future<void> clearPendingInviteCode() => _prefs.remove(_keyPendingInviteCode);
|
||||
|
||||
/// 模糊归因是否已上报过(每安装仅一次,避免重复请求)
|
||||
bool get inviteMatchReported => _prefs.getBool(_keyInviteMatchReported) ?? false;
|
||||
|
||||
Future<void> markInviteMatchReported() =>
|
||||
_prefs.setBool(_keyInviteMatchReported, true);
|
||||
|
||||
/// API 域名缓存(含 https:// 前缀的 Base URL)
|
||||
String? get cachedBaseUrl => _prefs.getString(_keyCachedDomain);
|
||||
|
||||
Future<void> saveBaseUrl(String baseUrl) =>
|
||||
_prefs.setString(_keyCachedDomain, baseUrl);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 搜索历史本地存储(《01-开发文档》2.5.8:App 端本地存储最近 10 条,不上报服务端)
|
||||
/// 最新搜索排最前,重复关键词去重置顶,超出 10 条截断。
|
||||
class SearchHistoryStore {
|
||||
SearchHistoryStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _key = 'search_history';
|
||||
static const int maxCount = 10;
|
||||
|
||||
static SearchHistoryStore? _instance;
|
||||
|
||||
/// 初始化(main 阶段调用)
|
||||
static Future<SearchHistoryStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = SearchHistoryStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 测试注入
|
||||
static void initForTest(SharedPreferences prefs) {
|
||||
_instance = SearchHistoryStore._(prefs);
|
||||
}
|
||||
|
||||
static SearchHistoryStore get I => _instance!;
|
||||
|
||||
/// 历史列表(最新在前)
|
||||
List<String> get history => _prefs.getStringList(_key) ?? const [];
|
||||
|
||||
/// 记录一次搜索:去重置顶,保留最近 [maxCount] 条
|
||||
Future<void> add(String keyword) async {
|
||||
final word = keyword.trim();
|
||||
if (word.isEmpty) return;
|
||||
final list = [...history]..remove(word);
|
||||
list.insert(0, word);
|
||||
if (list.length > maxCount) list.length = maxCount;
|
||||
await _prefs.setStringList(_key, list);
|
||||
}
|
||||
|
||||
/// 删除单条
|
||||
Future<void> remove(String keyword) async {
|
||||
final list = [...history]..remove(keyword);
|
||||
await _prefs.setStringList(_key, list);
|
||||
}
|
||||
|
||||
/// 清空
|
||||
Future<void> clear() => _prefs.remove(_key);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Token 安全存储(02-技术文档:flutter_secure_storage)
|
||||
/// 仅存 access / refresh,业务代码不落明文日志。
|
||||
class TokenStore {
|
||||
TokenStore._();
|
||||
|
||||
static const _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
static const String _keyAccess = 'access_token';
|
||||
static const String _keyRefresh = 'refresh_token';
|
||||
|
||||
/// 内存缓存:Dio 拦截器同步读取,避免每请求异步取 IO
|
||||
static String? accessToken;
|
||||
static String? refreshToken;
|
||||
|
||||
static Future<void> save({
|
||||
required String access,
|
||||
required String refresh,
|
||||
}) async {
|
||||
accessToken = access;
|
||||
refreshToken = refresh;
|
||||
await _storage.write(key: _keyAccess, value: access);
|
||||
await _storage.write(key: _keyRefresh, value: refresh);
|
||||
}
|
||||
|
||||
/// 启动时恢复登录态
|
||||
static Future<void> restore() async {
|
||||
accessToken = await _storage.read(key: _keyAccess);
|
||||
refreshToken = await _storage.read(key: _keyRefresh);
|
||||
}
|
||||
|
||||
/// 登出 / Token 失效:清空内存与本地
|
||||
static Future<void> clear() async {
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
await _storage.delete(key: _keyAccess);
|
||||
await _storage.delete(key: _keyRefresh);
|
||||
}
|
||||
|
||||
static bool get hasToken => accessToken != null && accessToken!.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 观看上报去重存储(《03-接口文档》5.3:客户端本地按 video_id+日期 去重,
|
||||
/// 同日同视频只上报一次)。游客免费额度按设备计费,杀进程重进若重复上报会
|
||||
/// 白扣游客当日次数,因此必须持久化而非仅内存。
|
||||
///
|
||||
/// key 按日分组:`watch_yyyyMMdd` / `complete_yyyyMMdd`,值为当日已上报的
|
||||
/// video_id 列表;每次访问时惰性清理非今日 key。
|
||||
class WatchDedupStore {
|
||||
WatchDedupStore._(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const String _watchPrefix = 'watch_';
|
||||
static const String _completePrefix = 'complete_';
|
||||
|
||||
static WatchDedupStore? _instance;
|
||||
|
||||
/// 初始化(splash 阶段调用)
|
||||
static Future<WatchDedupStore> init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_instance = WatchDedupStore._(prefs);
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// 测试注入
|
||||
static void initForTest(SharedPreferences prefs) {
|
||||
_instance = WatchDedupStore._(prefs);
|
||||
}
|
||||
|
||||
static WatchDedupStore get I => _instance!;
|
||||
|
||||
String get _today {
|
||||
final now = DateTime.now();
|
||||
return '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 是否已上报过 watch
|
||||
bool hasWatched(int videoId) => _contains(_watchPrefix, videoId);
|
||||
|
||||
/// 标记已上报 watch
|
||||
Future<void> markWatched(int videoId) => _mark(_watchPrefix, videoId);
|
||||
|
||||
/// 是否已上报过 complete
|
||||
bool hasCompleted(int videoId) => _contains(_completePrefix, videoId);
|
||||
|
||||
/// 标记已上报 complete
|
||||
Future<void> markCompleted(int videoId) => _mark(_completePrefix, videoId);
|
||||
|
||||
bool _contains(String prefix, int videoId) {
|
||||
_cleanup(prefix);
|
||||
final list = _prefs.getStringList('$prefix$_today') ?? const [];
|
||||
return list.contains(videoId.toString());
|
||||
}
|
||||
|
||||
Future<void> _mark(String prefix, int videoId) async {
|
||||
_cleanup(prefix);
|
||||
final key = '$prefix$_today';
|
||||
final list = _prefs.getStringList(key) ?? <String>[];
|
||||
final id = videoId.toString();
|
||||
if (!list.contains(id)) {
|
||||
list.add(id);
|
||||
await _prefs.setStringList(key, list);
|
||||
}
|
||||
}
|
||||
|
||||
/// 惰性清理:删除该前缀下所有非今日的 key
|
||||
void _cleanup(String prefix) {
|
||||
final todayKey = '$prefix$_today';
|
||||
final stale = _prefs
|
||||
.getKeys()
|
||||
.where((k) => k.startsWith(prefix) && k != todayKey)
|
||||
.toList();
|
||||
for (final k in stale) {
|
||||
_prefs.remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// UI 样式规范(《01-开发文档》5.4):深色沉浸 + 品牌红/青 + 会员金
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
static const Color background = Color(0xFF121212);
|
||||
static const Color surface = Color(0xFF1E1E1E);
|
||||
static const Color primary = Color(0xFFFE2C55); // 品牌红
|
||||
static const Color secondary = Color(0xFF25F4EE); // 辅助青
|
||||
static const Color textPrimary = Color(0xFFFFFFFF);
|
||||
static const Color textSecondary = Color(0xFFB0B0B0);
|
||||
static const Color textWeak = Color(0xFF6E6E6E);
|
||||
static const Color gold = Color(0xFFF7D794);
|
||||
static const Color goldDeep = Color(0xFFD4A017);
|
||||
|
||||
/// 会员金色渐变
|
||||
static const LinearGradient goldGradient = LinearGradient(
|
||||
colors: [gold, goldDeep],
|
||||
);
|
||||
}
|
||||
|
||||
/// 全局深色主题
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static ThemeData get dark => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
scaffoldBackgroundColor: AppColors.background,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: AppColors.primary,
|
||||
secondary: AppColors.secondary,
|
||||
surface: AppColors.surface,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.background,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
hintStyle: const TextStyle(color: AppColors.textWeak),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
dividerColor: AppColors.textWeak.withValues(alpha: 0.2),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../../models/video_item.dart';
|
||||
import '../config/test_stream.dart';
|
||||
|
||||
/// 分享文案:「标题」#标签 #标签 播放链接(链接不可用时省略)
|
||||
/// 一期走系统分享面板;分享落地页/海报属二期规划(《01-开发文档》2.10)
|
||||
String buildShareText(VideoItem item, {String? testUrl}) {
|
||||
final buffer = StringBuffer();
|
||||
buffer.write(item.title.isNotEmpty ? '「${item.title}」' : '精彩视频');
|
||||
for (final tag in item.tags) {
|
||||
buffer.write(' #$tag');
|
||||
}
|
||||
final url = resolvePlayUrl(item, testUrl: testUrl);
|
||||
// 仅 http(s) 绝对链接可分享(相对路径对外不可达)
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
buffer.write('\n$url');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
Reference in New Issue
Block a user