up
This commit is contained in:
@@ -3,8 +3,8 @@ class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
/// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号)
|
||||
static const String appVersion = '1.0.0';
|
||||
static const int appVersionCode = 1;
|
||||
static const String appVersion = '1.1.0';
|
||||
static const int appVersionCode = 2;
|
||||
|
||||
/// 平台标识(运行时按 iOS/Android 判定,见 platformName)
|
||||
static const String platformNameIOS = 'ios';
|
||||
|
||||
@@ -43,28 +43,53 @@ class ApiClient {
|
||||
}
|
||||
|
||||
/// ---------- 便捷请求方法:直接返回解包后的 data ----------
|
||||
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理
|
||||
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理;
|
||||
/// 这里兜底把 DioException 转成 ApiException(否则 UI 只能收到未知类型异常,
|
||||
/// 2002 游客超限 / 2001 积分不足等业务弹窗全部失效)
|
||||
|
||||
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;
|
||||
try {
|
||||
final resp = await _dio.get<dynamic>(path, queryParameters: queryParameters);
|
||||
return resp.data as T;
|
||||
} on DioException catch (e) {
|
||||
throw _unwrap(e);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
final resp = await _dio.post<dynamic>(path,
|
||||
data: data, onSendProgress: onSendProgress);
|
||||
return resp.data as T;
|
||||
} on DioException catch (e) {
|
||||
throw _unwrap(e);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<T> put<T>(String path, {Object? data}) async {
|
||||
final resp = await _dio.put<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
try {
|
||||
final resp = await _dio.put<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
} on DioException catch (e) {
|
||||
throw _unwrap(e);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<T> delete<T>(String path, {Object? data}) async {
|
||||
final resp = await _dio.delete<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
try {
|
||||
final resp = await _dio.delete<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
} on DioException catch (e) {
|
||||
throw _unwrap(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// DioException → ApiException:保留拦截器已包装的业务错误(含 data)
|
||||
static ApiException _unwrap(DioException e) {
|
||||
final err = e.error;
|
||||
if (err is ApiException) return err;
|
||||
return ApiException(-1, '网络异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +111,37 @@ class _CommonHeadersInterceptor extends Interceptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 递归把 data 中所有 `*_url` / `*_Url` 结尾、且以 `/` 开头(非 `//`)的相对路径
|
||||
/// 拼上 [ApiDomain.current]。只补一次,幂等。
|
||||
Object? _resolveRelativeUrls(Object? node, String baseUrl) {
|
||||
if (node is Map<String, dynamic>) {
|
||||
for (final entry in node.entries) {
|
||||
final v = entry.value;
|
||||
if (v is String &&
|
||||
v.isNotEmpty &&
|
||||
v.startsWith('/') &&
|
||||
!v.startsWith('//') &&
|
||||
(entry.key.endsWith('_url') ||
|
||||
entry.key.endsWith('_Url') ||
|
||||
entry.key.endsWith('_URL') ||
|
||||
entry.key == 'url' ||
|
||||
entry.key == 'src')) {
|
||||
node[entry.key] = baseUrl + v;
|
||||
} else {
|
||||
node[entry.key] = _resolveRelativeUrls(v, baseUrl);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
if (node is List) {
|
||||
for (var i = 0; i < node.length; i++) {
|
||||
node[i] = _resolveRelativeUrls(node[i], baseUrl);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/// 统一响应解包:{ code, message, data } → code=0 返回 data,非 0 抛 ApiException
|
||||
class _UnwrapInterceptor extends Interceptor {
|
||||
@override
|
||||
@@ -95,7 +151,7 @@ class _UnwrapInterceptor extends Interceptor {
|
||||
if (body is Map && body.containsKey('code')) {
|
||||
final code = body['code'] as int;
|
||||
if (code == 0) {
|
||||
response.data = body['data'];
|
||||
response.data = _resolveRelativeUrls(body['data'], ApiDomain.current);
|
||||
return handler.next(response);
|
||||
}
|
||||
return handler.reject(DioException(
|
||||
@@ -110,12 +166,27 @@ class _UnwrapInterceptor extends Interceptor {
|
||||
),
|
||||
));
|
||||
}
|
||||
// 非统一包裹响应(如 FastAPI 直接返回的 {"detail": ...} 4xx):
|
||||
// 不能当作成功把 detail 透传给业务层,统一转 ApiException
|
||||
final status = response.statusCode ?? 200;
|
||||
if (status >= 400) {
|
||||
final detail = body is Map ? (body['detail']?.toString() ?? '') : '';
|
||||
return handler.reject(DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
error: ApiException(-1, detail.isNotEmpty ? detail : '请求失败(HTTP $status)'),
|
||||
));
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,失败则回调登出
|
||||
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,确认会话失效才登出
|
||||
class _ErrorInterceptor extends Interceptor {
|
||||
/// 刷新单飞锁:并发 401 只触发一次刷新,避免 refresh token 轮换互踩
|
||||
/// (多请求同时携带旧 refresh 请求,后到者必被服务端拒绝 → 误登出)
|
||||
static Future<String?>? _refreshing;
|
||||
|
||||
@override
|
||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// 网络层失败(域名不可达等):计数并按需重新发现域名
|
||||
@@ -132,7 +203,16 @@ class _ErrorInterceptor extends Interceptor {
|
||||
if (status == 401 &&
|
||||
err.requestOptions.extra['retried'] != true &&
|
||||
TokenStore.refreshToken != null) {
|
||||
final newToken = await _refreshToken();
|
||||
String? newToken;
|
||||
try {
|
||||
newToken = await _refreshToken();
|
||||
} on ApiException catch (refreshErr) {
|
||||
// 刷新请求本身网络失败:不清登录态,透传错误
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: refreshErr,
|
||||
));
|
||||
}
|
||||
if (newToken != null) {
|
||||
try {
|
||||
final opts = err.requestOptions;
|
||||
@@ -140,8 +220,27 @@ class _ErrorInterceptor extends Interceptor {
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final resp = await ApiClient._dio.fetch<dynamic>(opts);
|
||||
return handler.resolve(resp);
|
||||
} on DioException catch (retryErr) {
|
||||
final retryApi = retryErr.error;
|
||||
final retryStatus = retryErr.response?.statusCode;
|
||||
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
|
||||
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
|
||||
final stillAuthFailed = retryStatus == 401 ||
|
||||
(retryApi is ApiException && retryApi.isUnauthorized);
|
||||
if (!stillAuthFailed) {
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: retryApi is ApiException
|
||||
? retryApi
|
||||
: ApiException(-1, '网络异常,请稍后重试'),
|
||||
));
|
||||
}
|
||||
} catch (_) {
|
||||
// 重试仍失败 → 走下方登出
|
||||
// 非 Dio 失败:视为网络异常,不清登录态
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ApiException(-1, '网络异常,请稍后重试'),
|
||||
));
|
||||
}
|
||||
}
|
||||
await TokenStore.clear();
|
||||
@@ -150,37 +249,46 @@ class _ErrorInterceptor extends Interceptor {
|
||||
|
||||
final message = apiError is ApiException ? apiError.message : '网络异常,请稍后重试';
|
||||
final code = apiError is ApiException ? apiError.code : -1;
|
||||
final data = apiError is ApiException ? apiError.data : null;
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ApiException(code, message),
|
||||
error: ApiException(code, message, data: data),
|
||||
));
|
||||
}
|
||||
|
||||
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归
|
||||
Future<String?> _refreshToken() async {
|
||||
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归。
|
||||
/// - 服务端明确拒绝(业务失败)→ 返回 null,调用方登出
|
||||
/// - 网络层失败 → 抛异常,调用方透传错误、不清登录态
|
||||
Future<String?> _refreshToken() {
|
||||
// 单飞:进行中的刷新直接共享其结果
|
||||
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
|
||||
}
|
||||
|
||||
Future<String?> _doRefresh() async {
|
||||
final refresh = TokenStore.refreshToken;
|
||||
if (refresh == null) return null;
|
||||
Response<Map<String, dynamic>> resp;
|
||||
try {
|
||||
final bare = Dio(BaseOptions(
|
||||
baseUrl: ApiDomain.current,
|
||||
validateStatus: (s) => s != null && s < 500,
|
||||
));
|
||||
final resp = await bare.post<Map<String, dynamic>>(
|
||||
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 (_) {
|
||||
// 刷新失败视为会话过期
|
||||
throw ApiException(-1, '网络异常,请稍后重试');
|
||||
}
|
||||
return null;
|
||||
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;
|
||||
}
|
||||
return null; // 服务端明确拒绝刷新 → 会话确实过期
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ class ApiDomain {
|
||||
final resp = await _doh.get<dynamic>(
|
||||
server,
|
||||
queryParameters: {'name': AppConfig.dnsTxtHost, 'type': 'TXT'},
|
||||
// 腾讯 DoH (119.29.29.29/dns-query) 必须显式声明 JSON 才返回 JSON
|
||||
options: Options(headers: {'Accept': 'application/dns-json'}),
|
||||
);
|
||||
final body = resp.data;
|
||||
final answers = body is Map ? body['Answer'] as List? : null;
|
||||
|
||||
@@ -35,6 +35,8 @@ class Endpoints {
|
||||
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 videoSeries(int videoId) => '/videos/$videoId/series';
|
||||
static String seriesDetail(int seriesId) => '/videos/series/$seriesId';
|
||||
static String videoLike(int videoId) => '/videos/$videoId/like';
|
||||
static String videoFavorite(int videoId) => '/videos/$videoId/favorite';
|
||||
static String videoComments(int videoId) => '/videos/$videoId/comments';
|
||||
|
||||
@@ -65,9 +65,9 @@ class FeedPlayerPool {
|
||||
bool get isCurrentPlaying => currentSlot.player.state.playing;
|
||||
|
||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||
void bindWindow(int index, List<({int videoId, String url})> items) {
|
||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
||||
_currentIndex = index;
|
||||
// 1. 窗口内槽位按需加载
|
||||
// 1. 窗口内槽位按需加载(open 完才 play,libmpv 不接受未 open 的 play)
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
@@ -77,7 +77,7 @@ class FeedPlayerPool {
|
||||
slot.videoId = item.videoId;
|
||||
slot.completeNotified = false;
|
||||
if (item.url.isNotEmpty) {
|
||||
slot.player.open(Media(item.url), play: false);
|
||||
await slot.player.open(Media(item.url), play: false);
|
||||
}
|
||||
}
|
||||
// 2. 槽位状态:当前播、相邻预缓冲(保持 pause)、出窗复位
|
||||
@@ -120,10 +120,18 @@ class FeedPlayerPool {
|
||||
}));
|
||||
_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);
|
||||
// 每视频每次绑定只触发一次 complete 上报(≥95% 或播完都算)
|
||||
if (slot.videoId >= 0 && !slot.completeNotified) {
|
||||
slot.completeNotified = true;
|
||||
if (slot == currentSlot) {
|
||||
onWatchComplete?.call(slot.videoId, lastPosition.inSeconds);
|
||||
}
|
||||
}
|
||||
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
|
||||
if (slot == currentSlot) {
|
||||
slot.player.seek(Duration.zero);
|
||||
slot.player.play();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user