This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
+3 -2
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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
+17
View File
@@ -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

+11
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 495 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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';
+137 -29
View File
@@ -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; // 服务端明确拒绝刷新 → 会话确实过期
}
}
+2
View File
@@ -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;
+2
View File
@@ -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';
+15 -7
View File
@@ -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();
}
}));
}
+7 -1
View File
@@ -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()));
}
+1 -2
View File
@@ -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 => '免费',
_ => '免费',
};
+29 -6
View File
@@ -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);
}
+2 -1
View File
@@ -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))],
);
+2 -1
View File
@@ -89,6 +89,7 @@ class MemberNotifier extends StateNotifier<MemberState> {
}
}
final memberProvider = StateNotifierProvider<MemberNotifier, MemberState>(
/// autoDispose:进入会员页时创建并加载,离开即销毁;登录态变化后自动取最新
final memberProvider = StateNotifierProvider.autoDispose<MemberNotifier, MemberState>(
MemberNotifier.new,
);
+3 -1
View File
@@ -101,6 +101,8 @@ class WalletNotifier extends StateNotifier<WalletState> {
}
}
final walletProvider = StateNotifierProvider<WalletNotifier, WalletState>(
/// autoDispose:进入钱包页时创建并加载,离开即销毁;
/// 避免登录态切换(登录/会员变更/充值后)仍展示上一次的旧数据
final walletProvider = StateNotifierProvider.autoDispose<WalletNotifier, WalletState>(
(_) => WalletNotifier(),
);
+8 -16
View File
@@ -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))),
);
}
}
+16 -10
View File
@@ -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(
+16 -8
View File
@@ -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';
}
}
+2 -2
View File
@@ -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)
+31 -25
View File
@@ -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),
),
);
}
}
+184 -21
View File
@@ -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),
),
],
),
+21 -5
View File
@@ -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(
+9
View File
@@ -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>>(
+132 -132
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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