up
@@ -1,8 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="video_app"
|
||||
android:label="喵舍"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:angle="270"
|
||||
android:startColor="#3A3F4D"
|
||||
android:endColor="#14161B" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -1,3 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.workers.max=2
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.12-all.zip
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#3A3F4D"/>
|
||||
<stop offset="1" stop-color="#14161B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1024" height="1024" fill="url(#bg)"/>
|
||||
<!-- 猫脸(白)+ 双耳 -->
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="512" cy="575" r="270"/>
|
||||
<polygon points="352,205 300,430 470,372"/>
|
||||
<polygon points="672,205 724,430 554,372"/>
|
||||
</g>
|
||||
<!-- 播放键(挖空) -->
|
||||
<polygon points="450,470 450,680 640,575" fill="#15171C"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 635 B |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="108" viewBox="0 0 108 108">
|
||||
<!-- Android 自适应图标前景:透明底,图案居于中心安全区 -->
|
||||
<g transform="translate(54,54) scale(0.097) translate(-512,-525)">
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="512" cy="575" r="270"/>
|
||||
<polygon points="352,205 300,430 470,372"/>
|
||||
<polygon points="672,205 724,430 554,372"/>
|
||||
</g>
|
||||
<polygon points="450,470 450,680 640,575" fill="#15171C"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 491 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 495 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 883 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 883 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Video App</string>
|
||||
<string>喵舍</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import 'core/storage/local_store.dart';
|
||||
import 'core/storage/search_history_store.dart';
|
||||
import 'core/storage/watch_dedup_store.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'router.dart';
|
||||
|
||||
/// App 入口:初始化本地存储 → MaterialApp.router
|
||||
/// App 入口:本地存储 + MediaKit + MaterialApp.router
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// MediaKit(libmpv 底层)全局初始化:每个进程必须且仅调一次
|
||||
MediaKit.ensureInitialized();
|
||||
// 本地存储初始化(设备指纹在 splash 阶段生成)
|
||||
await LocalStore.init();
|
||||
// 搜索历史(本地,不上报服务端)
|
||||
await SearchHistoryStore.init();
|
||||
// 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效)
|
||||
await WatchDedupStore.init();
|
||||
|
||||
runApp(const ProviderScope(child: VideoApp()));
|
||||
}
|
||||
|
||||
@@ -44,13 +44,12 @@ class HistoryItem {
|
||||
final VideoItem video;
|
||||
final String watchedAt;
|
||||
|
||||
/// 计费类型:0 免费额度 / 1 积分 / 2 会员 / 3 重复或本人
|
||||
/// 计费类型:0 免费额度 / 1 积分 / 2 会员 / 3 重复观看或本人作品(不计费)
|
||||
final int costType;
|
||||
|
||||
String get costLabel => switch (costType) {
|
||||
1 => '积分',
|
||||
2 => '会员',
|
||||
3 => '免费',
|
||||
_ => '免费',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/network/api_client.dart';
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../core/storage/token_store.dart';
|
||||
import '../models/user_model.dart';
|
||||
import '../services/auth_service.dart';
|
||||
import '../services/user_service.dart';
|
||||
|
||||
/// 登录态:未登录 / 已登录(含用户信息)
|
||||
@@ -37,25 +39,46 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
try {
|
||||
final user = await UserService.fetchMe();
|
||||
state = AuthState(user: user, restored: true);
|
||||
} on ApiException catch (e) {
|
||||
// 仅 Token 确认失效(1002)才清理回游客态;
|
||||
// 网络异常等情况保留 Token,下次启动/请求重试恢复
|
||||
if (e.isUnauthorized) await TokenStore.clear();
|
||||
state = AuthState(restored: true);
|
||||
} catch (_) {
|
||||
// Token 失效(1002 等):本地清理,回游客态
|
||||
await TokenStore.clear();
|
||||
// 未知异常:保守保留 Token
|
||||
state = AuthState(restored: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录/注册成功:Token 已由 AuthService 写入 TokenStore,此处仅刷新用户
|
||||
/// 登录/注册成功:Token 已由 AuthService 写入 TokenStore,此处刷新用户。
|
||||
/// 登录响应里的 user 是简要信息(无 points/bio/gender),
|
||||
/// 需补拉全量资料,否则积分显示为 0、编辑资料保存会把服务端字段清空。
|
||||
Future<void> onLoginSuccess(UserModel user) async {
|
||||
state = AuthState(user: user, restored: true);
|
||||
try {
|
||||
final full = await UserService.fetchMe();
|
||||
state = state.copyWith(user: full);
|
||||
} catch (_) {
|
||||
// 补拉失败不影响登录态,保留简要信息
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改资料/头像后同步当前用户(不改变登录态与 Token)
|
||||
void updateUser(UserModel user) => state = state.copyWith(user: user);
|
||||
|
||||
/// 退出登录:服务端注销 + 本地清理
|
||||
Future<void> logout() async => _onSessionExpired();
|
||||
/// 退出登录:先通知服务端注销(失败忽略,本地照常清理),再清空登录态
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await AuthService.logout();
|
||||
} catch (_) {
|
||||
// 服务端已不可达或 Token 已失效:本地清理即可
|
||||
}
|
||||
await _clearLocal();
|
||||
}
|
||||
|
||||
Future<void> _onSessionExpired() async {
|
||||
Future<void> _onSessionExpired() async => _clearLocal();
|
||||
|
||||
Future<void> _clearLocal() async {
|
||||
await TokenStore.clear();
|
||||
state = AuthState(restored: true);
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
||||
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
|
||||
void onPageChanged(int index) {
|
||||
state = state.copyWith(currentIndex: index);
|
||||
// fire-and-forget:Future 内部会完整 await bindWindow 再 play,调用方不等
|
||||
_bindAndReport(index);
|
||||
// 接近尾部预取下一页
|
||||
if (index >= state.items.length - 3) loadMore();
|
||||
@@ -160,7 +161,7 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
||||
Future<void> _bindAndReport(int index) async {
|
||||
if (index < 0 || index >= state.items.length) return;
|
||||
final items = state.items;
|
||||
pool.bindWindow(
|
||||
await pool.bindWindow(
|
||||
index,
|
||||
[for (final v in items) (videoId: v.videoId, url: resolvePlayUrl(v))],
|
||||
);
|
||||
|
||||
@@ -89,6 +89,7 @@ class MemberNotifier extends StateNotifier<MemberState> {
|
||||
}
|
||||
}
|
||||
|
||||
final memberProvider = StateNotifierProvider<MemberNotifier, MemberState>(
|
||||
/// autoDispose:进入会员页时创建并加载,离开即销毁;登录态变化后自动取最新
|
||||
final memberProvider = StateNotifierProvider.autoDispose<MemberNotifier, MemberState>(
|
||||
MemberNotifier.new,
|
||||
);
|
||||
|
||||
@@ -101,6 +101,8 @@ class WalletNotifier extends StateNotifier<WalletState> {
|
||||
}
|
||||
}
|
||||
|
||||
final walletProvider = StateNotifierProvider<WalletNotifier, WalletState>(
|
||||
/// autoDispose:进入钱包页时创建并加载,离开即销毁;
|
||||
/// 避免登录态切换(登录/会员变更/充值后)仍展示上一次的旧数据
|
||||
final walletProvider = StateNotifierProvider.autoDispose<WalletNotifier, WalletState>(
|
||||
(_) => WalletNotifier(),
|
||||
);
|
||||
|
||||
@@ -31,11 +31,18 @@ import '../services/payment_service.dart';
|
||||
/// /home 对游客开放(游客可浏览 Feed,观看超限 2002 时才引导登录);
|
||||
/// 受保护页面(上传/钱包/会员等)在各自页面入口校验登录态并跳 /login。
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
// 认证状态变化仅触发 redirect 重估(refreshListenable),
|
||||
// 绝不能 watch 后重建 GoRouter —— 重建会把当前路由重置回启动页,
|
||||
// 启动页再次执行 bootstrap → 状态再变化 → 无限循环(卡死在启动页)。
|
||||
final refresh = ValueNotifier<int>(0);
|
||||
ref.listen(authProvider, (_, __) => refresh.value++);
|
||||
ref.onDispose(refresh.dispose);
|
||||
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
refreshListenable: refresh,
|
||||
redirect: (context, state) {
|
||||
final auth = ref.read(authProvider);
|
||||
final loggingIn = state.matchedLocation == '/login' ||
|
||||
state.matchedLocation == '/register';
|
||||
// 启动恢复未完成:停在 splash
|
||||
@@ -116,18 +123,3 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
/// 兜底页面(迭代期占位)
|
||||
class PlaceholderScreen extends StatelessWidget {
|
||||
const PlaceholderScreen({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(child: Text(title, style: const TextStyle(fontSize: 16))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
|
||||
/// 登录页:骨架期提供手机号+密码登录,验证码/邮箱登录随迭代补充
|
||||
/// 登录页:账号(手机号/邮箱,含@自动识别)+ 密码登录
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@@ -21,10 +22,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
String? _error;
|
||||
|
||||
Future<void> _submit() async {
|
||||
final phone = _phone.text.trim();
|
||||
final account = _phone.text.trim();
|
||||
final password = _password.text;
|
||||
if (phone.isEmpty || password.isEmpty) {
|
||||
setState(() => _error = '请输入手机号和密码');
|
||||
if (account.isEmpty || password.isEmpty) {
|
||||
setState(() => _error = '请输入账号和密码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
@@ -32,15 +33,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
// 含 @ 视为邮箱登录,否则手机号登录(服务端两种都支持密码登录)
|
||||
final isEmail = account.contains('@');
|
||||
final result = await AuthService.login(
|
||||
type: 'phone_password',
|
||||
phone: phone,
|
||||
type: isEmail ? 'email_password' : 'phone_password',
|
||||
phone: isEmail ? null : account,
|
||||
email: isEmail ? account : null,
|
||||
password: password,
|
||||
);
|
||||
await ref.read(authProvider.notifier).onLoginSuccess(result.user);
|
||||
if (mounted) context.go('/home');
|
||||
} catch (e) {
|
||||
setState(() => _error = e.toString().replaceFirst('ApiException(', '').split(', ').last);
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} catch (_) {
|
||||
setState(() => _error = '网络异常,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
@@ -62,8 +68,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(hintText: '手机号'),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(hintText: '手机号 / 邮箱'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/storage/local_store.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
@@ -55,11 +56,17 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await AuthService.sendSmsCode(phone, 'register');
|
||||
final devCode = await AuthService.sendSmsCode(phone, 'register');
|
||||
setState(() {
|
||||
_error = null;
|
||||
_countdown = 60;
|
||||
});
|
||||
if (devCode != null && mounted) {
|
||||
// 短信通道未配置时的开发模式:直接展示验证码(上线配置短信后服务端不再返回)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('开发模式验证码:$devCode(5 分钟内有效)'), duration: const Duration(seconds: 6)),
|
||||
);
|
||||
}
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (_countdown <= 1) {
|
||||
@@ -69,8 +76,10 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
setState(() => _countdown -= 1);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _error = _errText(e));
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} catch (_) {
|
||||
setState(() => _error = '网络异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,16 +114,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
);
|
||||
}
|
||||
context.go('/home');
|
||||
} catch (e) {
|
||||
setState(() => _error = _errText(e));
|
||||
} on ApiException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} catch (_) {
|
||||
setState(() => _error = '网络异常,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _errText(Object e) =>
|
||||
e.toString().replaceFirst('ApiException(', '').split(', ').last.replaceAll(')', '');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../services/upload_service.dart';
|
||||
import '../../services/video_service.dart';
|
||||
|
||||
/// 分类 Tab(首页 Tab1):分类筛选 + 双列视频网格,点击进入播放页。
|
||||
/// 数据源:/videos/categories + /videos/feed(category_id 过滤,游标分页)
|
||||
class CategoryScreen extends ConsumerStatefulWidget {
|
||||
const CategoryScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CategoryScreen> createState() => _CategoryScreenState();
|
||||
}
|
||||
|
||||
class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
List<VideoCategory> _categories = const [];
|
||||
int? _selected; // null = 全部
|
||||
List<VideoItem> _items = const [];
|
||||
String? _cursor;
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
bool _failed = false;
|
||||
|
||||
final _scroll = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrap();
|
||||
_scroll.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.removeListener(_onScroll);
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 400) {
|
||||
_loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_failed = false;
|
||||
});
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
UploadService.fetchCategories(),
|
||||
VideoService.fetchFeed(type: 'hot', categoryId: _selected, limit: 10),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_categories = results[0] as List<VideoCategory>;
|
||||
_items = (results[1] as FeedPage).items;
|
||||
_cursor = (results[1] as FeedPage).nextCursor;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_failed = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectCategory(int? id) async {
|
||||
if (id == _selected) return;
|
||||
setState(() => _selected = id);
|
||||
await _reload();
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_failed = false;
|
||||
});
|
||||
try {
|
||||
final page = await VideoService.fetchFeed(
|
||||
type: 'hot', categoryId: _selected, limit: 10);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = page.items;
|
||||
_cursor = page.nextCursor;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_failed = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_loadingMore || _loading || _failed || _cursor == null) return;
|
||||
setState(() => _loadingMore = true);
|
||||
try {
|
||||
final page = await VideoService.fetchFeed(
|
||||
type: 'hot', cursor: _cursor, categoryId: _selected, limit: 10);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = [..._items, ...page.items];
|
||||
_cursor = page.nextCursor;
|
||||
_loadingMore = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loadingMore = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶栏
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('分类',
|
||||
style:
|
||||
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search,
|
||||
color: AppColors.textSecondary),
|
||||
onPressed: () => context.push('/search'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 分类筛选条
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: _categories.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
_chip('全部', null),
|
||||
for (final c in _categories) _chip(c.name, c.categoryId),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(String label, int? id) {
|
||||
final active = id == _selected;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: active,
|
||||
onSelected: (_) => _selectCategory(id),
|
||||
selectedColor: AppColors.primary,
|
||||
backgroundColor: AppColors.surface,
|
||||
labelStyle: TextStyle(
|
||||
color: active ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading && _items.isEmpty) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary));
|
||||
}
|
||||
if (_failed && _items.isEmpty) {
|
||||
return Center(
|
||||
child: TextButton(
|
||||
onPressed: _selected == null ? _bootstrap : _reload,
|
||||
child: const Text('加载失败,点击重试',
|
||||
style: TextStyle(color: AppColors.primary)),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_items.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('该分类暂无视频',
|
||||
style: TextStyle(color: AppColors.textSecondary)));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _reload,
|
||||
color: AppColors.primary,
|
||||
child: GridView.builder(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 0.72,
|
||||
),
|
||||
itemCount: _items.length + (_cursor != null ? 1 : 0),
|
||||
itemBuilder: (_, index) {
|
||||
if (index >= _items.length) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _CategoryCard(item: _items[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryCard extends StatelessWidget {
|
||||
const _CategoryCard({required this.item});
|
||||
|
||||
final VideoItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/search/play', extra: item),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
color: AppColors.surface,
|
||||
child: item.coverUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: AppColors.textWeak,
|
||||
size: 40),
|
||||
)
|
||||
: const Icon(Icons.play_circle_outline,
|
||||
color: AppColors.textWeak, size: 40),
|
||||
),
|
||||
// 观看数角标
|
||||
Positioned(
|
||||
right: 6,
|
||||
top: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black45,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.play_arrow,
|
||||
color: Colors.white, size: 12),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
_formatCount(item.viewCount),
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 标题
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
color: Colors.black54,
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: Text(
|
||||
item.title.isNotEmpty ? item.title : '未命名视频',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCount(int n) {
|
||||
if (n >= 10000) return '${(n / 10000).toStringAsFixed(1)}w';
|
||||
if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}k';
|
||||
return '$n';
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import 'widgets/feed_overlays.dart';
|
||||
import 'widgets/feed_video_page.dart';
|
||||
|
||||
/// 首页 Feed:全屏竖向 PageView 沉浸式播放(《01-开发文档》5.2.1)
|
||||
/// - 顶部:策略 Tab(推荐/最新/热度)+ 搜索图标(占位)
|
||||
/// - 顶部:策略 Tab(推荐/最新/热度)+ 搜索入口
|
||||
/// - 底部:游客/非会员免费剩余次数提示条
|
||||
/// - 滑出暂停、滑入自动播放并上报观看计费
|
||||
class FeedScreen extends ConsumerStatefulWidget {
|
||||
@@ -144,7 +144,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
},
|
||||
),
|
||||
|
||||
// 顶部:策略 Tab + 搜索(占位)
|
||||
// 顶部:策略 Tab + 搜索入口
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
|
||||
@@ -3,6 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
/// media_kit Video 包装:封面兜底、缓冲转圈、暂停态大播放图标
|
||||
///
|
||||
/// 封面仅在视频尚未渲染出第一帧时显示(player state 仍在初始 idle),
|
||||
/// 一旦 HLS 起播第一帧,封面立即淡出,避免「有声音没画面被封面挡住」。
|
||||
class VideoPlayerView extends StatelessWidget {
|
||||
const VideoPlayerView({
|
||||
super.key,
|
||||
@@ -17,6 +20,7 @@ class VideoPlayerView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = controller.player;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -27,13 +31,24 @@ class VideoPlayerView extends StatelessWidget {
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
|
||||
// 封面兜底:加载/无地址时可见(HLS 起播后被画面覆盖)
|
||||
if (coverUrl.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
// 封面兜底:仅在视频尚未开始渲染时显示
|
||||
StreamBuilder<Duration>(
|
||||
stream: player.stream.position,
|
||||
initialData: player.state.position,
|
||||
builder: (_, snapshot) {
|
||||
final pos = snapshot.data ?? Duration.zero;
|
||||
// position == 0 说明还没渲染出第一帧(含初始加载和播完后)
|
||||
final showCover =
|
||||
coverUrl.isNotEmpty && pos == Duration.zero;
|
||||
return showCover
|
||||
? CachedNetworkImage(
|
||||
imageUrl: coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const SizedBox.shrink(),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
// 暂停态大播放图标
|
||||
if (!playing)
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import '../../screens/category/category_screen.dart';
|
||||
import '../../screens/feed/feed_screen.dart';
|
||||
import '../../screens/notification/notifications_screen.dart';
|
||||
import '../../screens/profile/mine_screen.dart';
|
||||
import '../../screens/upload/upload_screen.dart';
|
||||
|
||||
/// 首页壳:底部 Tab(首页/分类/发布/消息/我的),02-技术文档信息架构
|
||||
/// 首页 Tab 为 Feed 播放页,我的页已接入,其余 Tab 随迭代接入;IndexedStack 保活各 Tab 状态
|
||||
class HomeShell extends StatefulWidget {
|
||||
/// IndexedStack 保活各 Tab 状态(Feed 播放器池 / 分类滚动位置 / 上传流程不被销毁)
|
||||
class HomeShell extends ConsumerStatefulWidget {
|
||||
const HomeShell({super.key});
|
||||
|
||||
@override
|
||||
State<HomeShell> createState() => _HomeShellState();
|
||||
ConsumerState<HomeShell> createState() => _HomeShellState();
|
||||
}
|
||||
|
||||
class _HomeShellState extends State<HomeShell> {
|
||||
class _HomeShellState extends ConsumerState<HomeShell> {
|
||||
int _index = 0;
|
||||
|
||||
/// Feed 三种策略的 provider keys(切 Tab 时用于暂停/恢复播放器池)
|
||||
static const _feedStrategies = FeedStrategy.values;
|
||||
|
||||
void _onTabSelected(int i) {
|
||||
if (i == _index) return;
|
||||
// 切到非首页:暂停所有 Feed 策略的播放器池(IndexedStack 保活,否则声音会继续)
|
||||
if (i != 0) {
|
||||
for (final s in _feedStrategies) {
|
||||
ref.read(feedProvider(s).notifier).pool.pauseCurrent();
|
||||
}
|
||||
}
|
||||
setState(() => _index = i);
|
||||
// 切回首页:恢复当前策略播放(FeedScreen 内部 _strategy 是 recommend)
|
||||
if (i == 0) {
|
||||
ref.read(feedProvider(FeedStrategy.recommend).notifier).pool.playCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -23,9 +46,9 @@ class _HomeShellState extends State<HomeShell> {
|
||||
index: _index,
|
||||
children: const [
|
||||
FeedScreen(),
|
||||
_TabPlaceholder('分类'),
|
||||
_TabPlaceholder('发布'),
|
||||
_TabPlaceholder('消息'),
|
||||
CategoryScreen(),
|
||||
UploadScreen(),
|
||||
NotificationsScreen(),
|
||||
MineScreen(),
|
||||
],
|
||||
),
|
||||
@@ -33,7 +56,7 @@ class _HomeShellState extends State<HomeShell> {
|
||||
backgroundColor: AppColors.surface,
|
||||
indicatorColor: AppColors.primary.withValues(alpha: 0.2),
|
||||
selectedIndex: _index,
|
||||
onDestinationSelected: (i) => setState(() => _index = i),
|
||||
onDestinationSelected: _onTabSelected,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
@@ -61,20 +84,3 @@ class _HomeShellState extends State<HomeShell> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tab 占位(迭代期)
|
||||
class _TabPlaceholder extends StatelessWidget {
|
||||
const _TabPlaceholder(this.title);
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'$title(随迭代接入)',
|
||||
style: const TextStyle(color: AppColors.textSecondary, fontSize: 15),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@ import '../../services/video_service.dart';
|
||||
import '../feed/widgets/feed_overlays.dart';
|
||||
|
||||
/// 单视频播放页(搜索结果/用户主页/作品/收藏/历史共用):
|
||||
/// 与 Feed 一致的观看计费(开始播放即上报,按 video_id+日期本地去重),
|
||||
/// 游客超限 2002 / 积分不足 2001 弹窗逻辑复用 feed_overlays。
|
||||
/// + 系列抽屉(视频属于某系列时,点右上角📚查看同系列所有集数)
|
||||
class SimplePlayerScreen extends StatefulWidget {
|
||||
const SimplePlayerScreen({super.key, required this.item});
|
||||
|
||||
@@ -23,15 +22,27 @@ class SimplePlayerScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
late final Player _player = Player();
|
||||
late final VideoController _controller = VideoController(_player);
|
||||
late Player _player = Player();
|
||||
late VideoController _controller = VideoController(_player);
|
||||
bool _playing = true;
|
||||
Map<String, dynamic>? _seriesData; // null=未加载, {}=无系列
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_player.open(Media(resolvePlayUrl(widget.item)));
|
||||
_open(widget.item);
|
||||
_reportWatch();
|
||||
_loadSeries();
|
||||
}
|
||||
|
||||
void _open(VideoItem item) {
|
||||
_player.open(Media(resolvePlayUrl(item)), play: true);
|
||||
}
|
||||
|
||||
Future<void> _loadSeries() async {
|
||||
final data = await VideoService.fetchVideoSeries(widget.item.videoId);
|
||||
if (!mounted) return;
|
||||
setState(() => _seriesData = data);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -40,7 +51,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 观看上报(计费):本地去重后上报,扣费 Toast / 超限弹窗
|
||||
Future<void> _reportWatch() async {
|
||||
final dedup = WatchDedupStore.I;
|
||||
if (dedup.hasWatched(widget.item.videoId)) return;
|
||||
@@ -72,6 +82,157 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
_playing ? _player.play() : _player.pause();
|
||||
}
|
||||
|
||||
bool get _hasSeries =>
|
||||
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
||||
|
||||
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
|
||||
|
||||
void _showSeriesSheet() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.65,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
builder: (_, controller) => Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 拖动条
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
width: 40, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 系列信息头
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: (_seriesData!['series']['cover_url'] as String?)
|
||||
?.isNotEmpty ==
|
||||
true
|
||||
? Image.network(
|
||||
_seriesData!['series']['cover_url'],
|
||||
width: 64, height: 64, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
const SizedBox(width: 64, height: 64),
|
||||
)
|
||||
: Container(
|
||||
width: 64, height: 64, color: Colors.grey[200]),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_seriesData!['series']['title'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w600),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'共 ${_seriesData!['series']['total_episodes']} 集',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// 集数网格
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: _episodes.length,
|
||||
itemBuilder: (_, i) {
|
||||
final ep = _episodes[i] as Map<String, dynamic>;
|
||||
final isCurrent =
|
||||
ep['video_id'] == widget.item.videoId;
|
||||
return GestureDetector(
|
||||
onTap: () => _switchToEpisode(ep),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent
|
||||
? AppColors.primary.withValues(alpha: 0.15)
|
||||
: Colors.grey[100],
|
||||
border: Border.all(
|
||||
color: isCurrent
|
||||
? AppColors.primary
|
||||
: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
ep['episode_no']?.toString() ?? '${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isCurrent
|
||||
? AppColors.primary
|
||||
: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _switchToEpisode(Map<String, dynamic> ep) {
|
||||
Navigator.of(context).pop(); // 关抽屉
|
||||
final newItem = VideoItem(
|
||||
videoId: ep['video_id'],
|
||||
title: ep['title'] ?? '',
|
||||
coverUrl: ep['cover_url'] ?? '',
|
||||
videoUrl: ep['video_url'] ?? '',
|
||||
duration: (ep['duration'] as num?)?.toInt() ?? 0,
|
||||
author: widget.item.author, // 系列一般作者相同
|
||||
);
|
||||
setState(() {
|
||||
_player.dispose();
|
||||
_player = Player();
|
||||
_controller = VideoController(_player);
|
||||
_open(newItem);
|
||||
// 重置 seriesData 重新加载
|
||||
_seriesData = null;
|
||||
});
|
||||
_reportWatch();
|
||||
_loadSeries();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -81,7 +242,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 播放器
|
||||
Center(
|
||||
child: Video(
|
||||
controller: _controller,
|
||||
@@ -89,7 +249,6 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
// 暂停态大播放图标
|
||||
if (!_playing)
|
||||
const IgnorePointer(
|
||||
child: Center(
|
||||
@@ -100,18 +259,24 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 返回按钮
|
||||
const SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: BackButton(color: Colors.white),
|
||||
// 顶部:返回 + 系列按钮
|
||||
SafeArea(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const BackButton(color: Colors.white),
|
||||
if (_hasSeries)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.menu_book, color: Colors.white),
|
||||
tooltip: '查看系列',
|
||||
onPressed: _showSeriesSheet,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 底部标题/作者
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 24,
|
||||
left: 12, right: 12, bottom: 24,
|
||||
child: IgnorePointer(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -120,17 +285,15 @@ class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
|
||||
Text(
|
||||
'@${widget.item.author.nickname}',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15, fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
widget.item.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
fontSize: 14, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../core/network/api_client.dart';
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../core/network/endpoints.dart';
|
||||
import '../core/storage/token_store.dart';
|
||||
import '../models/user_model.dart';
|
||||
|
||||
/// 认证服务(《03-接口文档》第 3 章)
|
||||
@@ -8,13 +9,22 @@ class AuthService {
|
||||
AuthService._();
|
||||
|
||||
/// 3.1 发送短信验证码 scene: register/login/reset
|
||||
static Future<void> sendSmsCode(String phone, String scene) async {
|
||||
await ApiClient.I.post(Endpoints.smsSend, data: {'phone': phone, 'scene': scene});
|
||||
/// 返回开发模式验证码(短信通道未配置时服务端下发),生产通道下为 null
|
||||
static Future<String?> sendSmsCode(String phone, String scene) async {
|
||||
final data = await ApiClient.post<Map<String, dynamic>>(
|
||||
Endpoints.smsSend,
|
||||
data: {'phone': phone, 'scene': scene},
|
||||
);
|
||||
return data['dev_code'] as String?;
|
||||
}
|
||||
|
||||
/// 3.2 发送邮箱验证码
|
||||
static Future<void> sendEmailCode(String email, String scene) async {
|
||||
await ApiClient.I.post(Endpoints.emailSend, data: {'email': email, 'scene': scene});
|
||||
static Future<String?> sendEmailCode(String email, String scene) async {
|
||||
final data = await ApiClient.post<Map<String, dynamic>>(
|
||||
Endpoints.emailSend,
|
||||
data: {'email': email, 'scene': scene},
|
||||
);
|
||||
return data['dev_code'] as String?;
|
||||
}
|
||||
|
||||
/// 3.3 注册(type: phone/email,可带邀请码),返回 Token 对与用户信息
|
||||
@@ -66,7 +76,13 @@ class AuthService {
|
||||
}
|
||||
|
||||
/// 统一解析 Token 响应:access/refresh 入安全存储,user 转模型
|
||||
static LoginResult _fromTokenData(Map<String, dynamic> data) {
|
||||
static Future<LoginResult> _fromTokenData(Map<String, dynamic> data) async {
|
||||
// 登录/注册响应携带新 token 对,必须先落存储(否则登录态判定与后续请求鉴权全挂)
|
||||
final access = data['access_token'] as String?;
|
||||
final refresh = data['refresh_token'] as String?;
|
||||
if (access != null && access.isNotEmpty && refresh != null && refresh.isNotEmpty) {
|
||||
await TokenStore.save(access: access, refresh: refresh);
|
||||
}
|
||||
final user = UserModel.fromJson((data['user'] as Map<String, dynamic>?) ?? const {});
|
||||
final trial = data['trial_granted'] as Map<String, dynamic>?;
|
||||
return LoginResult(
|
||||
|
||||
@@ -34,6 +34,15 @@ class VideoService {
|
||||
static Future<Map<String, dynamic>> fetchDetail(int videoId) =>
|
||||
ApiClient.get<Map<String, dynamic>>(Endpoints.videoDetail(videoId));
|
||||
|
||||
/// 当前视频所属系列 + 全部集数(播放器抽屉用)
|
||||
static Future<Map<String, dynamic>> fetchVideoSeries(int videoId) async {
|
||||
try {
|
||||
return await ApiClient.get<Map<String, dynamic>>(Endpoints.videoSeries(videoId));
|
||||
} catch (_) {
|
||||
return {'has_series': false};
|
||||
}
|
||||
}
|
||||
|
||||
/// 5.3 观看上报(计费):开始播放时调用,客户端已按 video_id+日期 本地去重
|
||||
static Future<WatchResult> reportWatch(int videoId, {int? position}) async {
|
||||
final data = await ApiClient.post<Map<String, dynamic>>(
|
||||
|
||||
@@ -2,7 +2,7 @@ name: video_app
|
||||
description: "短视频应用(Flutter,iOS/Android 双端)"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
version: 1.1.0+2
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
|
||||