This commit is contained in:
lz_db
2026-10-17 19:04:05 +08:00
parent 28b11b4365
commit 1253c159f5
43 changed files with 2652 additions and 114 deletions
+25 -3
View File
@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// 正式签名配置:从 android/key.properties 读取(该文件与 keystore 均已 gitignore,不入库)
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.jsnbhh.video_app"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +40,23 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// key.properties 存在时用正式签名;否则回退 debug(保证 CI/新环境能编译)
signingConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
+304
View File
@@ -0,0 +1,304 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import '../../models/video_item.dart';
import 'local_media_server.dart';
/// 已下载视频元数据(持久化在 downloads/<videoId>/meta.json)
class DownloadItem {
DownloadItem({
required this.videoId,
required this.title,
required this.coverUrl,
required this.duration,
required this.sizeBytes,
required this.mp4,
required this.createdAt,
});
final int videoId;
final String title;
final String coverUrl;
final int duration; // 秒
final int sizeBytes;
final bool mp4;
final int createdAt; // 毫秒时间戳
/// 本地播放地址(经 LocalMediaServer /local 路由,支持 Range)
String get playUrl =>
LocalMediaServer.I.localPlayUrl(videoId, mp4: mp4);
Map<String, dynamic> toJson() => {
'video_id': videoId,
'title': title,
'cover_url': coverUrl,
'duration': duration,
'size_bytes': sizeBytes,
'mp4': mp4,
'created_at': createdAt,
};
factory DownloadItem.fromJson(Map<String, dynamic> j) => DownloadItem(
videoId: j['video_id'] as int,
title: (j['title'] as String?) ?? '',
coverUrl: (j['cover_url'] as String?) ?? '',
duration: (j['duration'] as num?)?.toInt() ?? 0,
sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0,
mp4: j['mp4'] as bool? ?? false,
createdAt: (j['created_at'] as num?)?.toInt() ?? 0,
);
}
/// 下载任务状态机:value 为 0..1 进度;done/error 用独立集合
class DownloadsState {
const DownloadsState({
this.progress = const {},
this.errors = const {},
this.items = const [],
});
final Map<int, double> progress; // 下载中
final Map<int, String> errors; // 失败信息
final List<DownloadItem> items; // 已完成(按下载时间倒序)
DownloadsState copyWith({
Map<int, double>? progress,
Map<int, String>? errors,
List<DownloadItem>? items,
}) =>
DownloadsState(
progress: progress ?? this.progress,
errors: errors ?? this.errors,
items: items ?? this.items,
);
}
class DownloadManager extends StateNotifier<DownloadsState> {
DownloadManager() : super(const DownloadsState());
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(minutes: 10),
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
},
));
final Map<int, CancelToken> _cancel = {};
bool isDownloaded(int videoId) =>
state.items.any((e) => e.videoId == videoId);
bool isDownloading(int videoId) => state.progress.containsKey(videoId);
/// 启动时扫描下载目录恢复列表
Future<void> init() async {
final dir = LocalMediaServer.I.downloadsDir;
final items = <DownloadItem>[];
await for (final e in dir.list()) {
if (e is! Directory) continue;
final meta = File(p.join(e.path, 'meta.json'));
if (!await meta.exists()) continue;
try {
items.add(DownloadItem.fromJson(
jsonDecode(await meta.readAsString()) as Map<String, dynamic>));
} catch (_) {}
}
items.sort((a, b) => b.createdAt.compareTo(a.createdAt));
state = state.copyWith(items: items);
}
/// 开始下载(已在下载/已下载则忽略)
Future<void> start(VideoItem item) async {
if (isDownloading(item.videoId) || isDownloaded(item.videoId)) return;
final dir = Directory(p.join(
LocalMediaServer.I.downloadsDir.path, '${item.videoId}'));
await dir.create(recursive: true);
final cancel = CancelToken();
_cancel[item.videoId] = cancel;
_setProgress(item.videoId, 0);
try {
final isMp4 = item.videoUrl.contains('.mp4') &&
!item.videoUrl.contains('m3u8');
int size;
if (isMp4) {
size = await _downloadMp4(item.videoUrl, dir, item.videoId, cancel);
} else {
size = await _downloadHls(item.videoUrl, dir, item.videoId, cancel);
}
final meta = DownloadItem(
videoId: item.videoId,
title: item.title,
coverUrl: item.coverUrl,
duration: item.duration,
sizeBytes: size,
mp4: isMp4,
createdAt: DateTime.now().millisecondsSinceEpoch,
);
await File(p.join(dir.path, 'meta.json'))
.writeAsString(jsonEncode(meta.toJson()));
final progress = Map<int, double>.from(state.progress)
..remove(item.videoId);
state = state.copyWith(
progress: progress,
items: [meta, ...state.items],
);
} catch (e) {
// 失败/取消:清掉半成品目录
try {
await dir.delete(recursive: true);
} catch (_) {}
final progress = Map<int, double>.from(state.progress)
..remove(item.videoId);
if (e is DioException && CancelToken.isCancel(e)) {
state = state.copyWith(progress: progress);
} else {
final errors = Map<int, String>.from(state.errors)
..[item.videoId] = '下载失败,请重试';
state = state.copyWith(progress: progress, errors: errors);
}
rethrow;
} finally {
_cancel.remove(item.videoId);
}
}
void cancel(int videoId) => _cancel[videoId]?.cancel();
Future<void> delete(int videoId) async {
_cancel[videoId]?.cancel();
final dir = Directory(
p.join(LocalMediaServer.I.downloadsDir.path, '$videoId'));
try {
await dir.delete(recursive: true);
} catch (_) {}
state = state.copyWith(
items: state.items.where((e) => e.videoId != videoId).toList(),
);
}
// ---------- MP4 ----------
Future<int> _downloadMp4(
String url, Directory dir, int videoId, CancelToken cancel) async {
final file = File(p.join(dir.path, 'video.mp4'));
await _dio.download(
url,
file.path,
cancelToken: cancel,
onReceiveProgress: (r, t) {
if (t > 0) _setProgress(videoId, r / t);
},
);
return file.length();
}
// ---------- HLS ----------
/// 拉取 m3u8 → 下载 key + 全部 ts → 改写为本地相对引用
Future<int> _downloadHls(
String url, Directory dir, int videoId, CancelToken cancel) async {
var playlist = (await _dio.get<String>(url, cancelToken: cancel)).data ?? '';
var base = Uri.parse(url);
// master playlist(多变体):取第一个变体再拉一次
if (playlist.contains('#EXT-X-STREAM-INF')) {
final variant = const LineSplitter()
.convert(playlist)
.firstWhere((l) => l.trim().isNotEmpty && !l.startsWith('#'),
orElse: () => '');
if (variant.isEmpty) throw Exception('空变体列表');
final variantUrl = base.resolve(variant.trim()).toString();
playlist =
(await _dio.get<String>(variantUrl, cancelToken: cancel)).data ?? '';
base = Uri.parse(variantUrl);
}
final lines = const LineSplitter().convert(playlist);
final segUrls = <Uri>[
for (final l in lines)
if (l.trim().isNotEmpty && !l.trim().startsWith('#'))
base.resolve(l.trim()),
];
if (segUrls.isEmpty) throw Exception('无分片');
var total = 0;
var done = 0;
// key(可能多个,逐标签改写时按出现顺序对应)
final keyUris = <String>[];
for (final l in lines) {
for (final m in RegExp(r'URI="([^"]+)"').allMatches(l)) {
final abs = base.resolve(m.group(1)!).toString();
if (!keyUris.contains(abs)) keyUris.add(abs);
}
}
final keyNames = <String, String>{};
for (var i = 0; i < keyUris.length; i++) {
final name = i == 0 ? 'key.bin' : 'key_$i.bin';
final bytes = (await _dio.get<List<int>>(keyUris[i],
cancelToken: cancel,
options: Options(responseType: ResponseType.bytes)))
.data;
if (bytes == null || bytes.isEmpty) throw Exception('密钥下载失败');
await File(p.join(dir.path, name)).writeAsBytes(bytes);
keyNames[keyUris[i]] = name;
total += bytes.length;
}
// ts 分片:4 并发
const concurrency = 4;
for (var i = 0; i < segUrls.length; i += concurrency) {
final batch = segUrls.sublist(
i, (i + concurrency).clamp(0, segUrls.length));
final results = await Future.wait(batch.map((u) => _dio.get<List<int>>(
u.toString(),
cancelToken: cancel,
options: Options(responseType: ResponseType.bytes))));
for (var j = 0; j < batch.length; j++) {
final bytes = results[j].data;
if (bytes == null || bytes.isEmpty) throw Exception('分片下载失败');
await File(p.join(dir.path, 'seg_${i + j}.ts')).writeAsBytes(bytes);
total += bytes.length;
done++;
}
_setProgress(videoId, done / segUrls.length);
}
// 改写 playlist 为本地相对引用
var segIndex = 0;
final out = StringBuffer();
for (final raw in lines) {
final line = raw.trim();
if (line.isEmpty) continue;
if (line.startsWith('#')) {
out.writeln(line.replaceAllMapped(RegExp(r'URI="([^"]+)"'), (m) {
final abs = base.resolve(m.group(1)!).toString();
return 'URI="${keyNames[abs] ?? m.group(1)!}"';
}));
} else {
out.writeln('seg_${segIndex++}.ts');
}
}
await File(p.join(dir.path, 'index.m3u8')).writeAsString(out.toString());
return total;
}
void _setProgress(int videoId, double value) {
final progress = Map<int, double>.from(state.progress);
progress[videoId] = value;
state = state.copyWith(progress: progress);
}
}
final downloadManagerProvider =
StateNotifierProvider<DownloadManager, DownloadsState>(
(ref) => DownloadManager());
+303
View File
@@ -0,0 +1,303 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
/// 本地媒体服务(127.0.0.1 环回 HTTP):
///
/// 1. HLS 分片缓存(1G LRU):播放器请求经 /hls 改写 m3u8 内的 ts/key 绝对
/// 地址到 /seg,/seg 命中磁盘缓存直接返回,未命中回源并落盘 —— 回看不下载。
/// 注意:ts 链接带时效签名,缓存键取「去 query 的路径 sha1」,m3u8 每次回源。
/// 2. 已下载视频离线播放:/local/<videoId>/<file> 直接读下载目录(支持 Range)。
///
/// 只有 m3u8 走代理;mp4 直连(库里仅个位数 mp4,不做缓存)。
class LocalMediaServer {
LocalMediaServer._();
static final LocalMediaServer I = LocalMediaServer._();
static const int _cacheLimit = 1024 * 1024 * 1024; // 1G
static const int _evictTo = 900 * 1024 * 1024; // 清理到 900M(滞后防抖)
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(minutes: 5),
headers: {
'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36',
},
));
bool _started = false;
late int _port;
late Directory cacheDir; // <temp>/hls_cache(系统可清理,适合缓存语义)
late Directory downloadsDir; // <appSupport>/downloads(持久)
int _cacheBytes = 0;
int _writesSinceEvictCheck = 0;
/// 同一分片并发回源去重(playlist 内可能重复引用 / 多槽位同时 open)
final Map<String, Future<List<int>>> _inflight = {};
Future<void> init() async {
if (_started) return;
final tmp = await getTemporaryDirectory();
cacheDir = Directory(p.join(tmp.path, 'hls_cache'));
await cacheDir.create(recursive: true);
final support = await getApplicationSupportDirectory();
downloadsDir = Directory(p.join(support.path, 'downloads'));
await downloadsDir.create(recursive: true);
_cacheBytes = await _dirSize(cacheDir);
final router = Router()
..get('/hls', _handlePlaylist)
..get('/seg', _handleSegment)
..get('/local/<vid>/<name>', _handleLocal);
final server = await shelf_io.serve(
const shelf.Pipeline().addHandler(router.call),
InternetAddress.loopbackIPv4,
0,
);
_port = server.port;
_started = true;
}
/// 播放地址改写:网络 m3u8 → 本地缓存代理;其余(本地地址/mp4/空)原样返回
String wrap(String url) {
if (!_started || url.isEmpty) return url;
final u = Uri.tryParse(url);
if (u == null || (u.scheme != 'http' && u.scheme != 'https')) return url;
if (u.host == '127.0.0.1' || u.host == 'localhost') return url;
if (url.contains('m3u8')) {
return 'http://127.0.0.1:$_port/hls?src=${Uri.encodeComponent(url)}';
}
return url;
}
/// 已下载视频的本地播放地址
String localPlayUrl(int videoId, {bool mp4 = false}) =>
'http://127.0.0.1:$_port/local/$videoId/${mp4 ? 'video.mp4' : 'index.m3u8'}';
// ---------- /hls:回源拉 m3u8,改写分片地址到 /seg ----------
Future<shelf.Response> _handlePlaylist(shelf.Request req) async {
final src = req.url.queryParameters['src'];
if (src == null || src.isEmpty) {
return shelf.Response(400, body: 'missing src');
}
try {
final resp = await _dio.get<String>(src);
final text = resp.data ?? '';
if (!text.trimLeft().startsWith('#EXTM3U')) {
return shelf.Response(502, body: 'bad playlist');
}
return _bytesResponse(
req,
utf8.encode(_rewritePlaylist(text, Uri.parse(src))),
'application/vnd.apple.mpegurl',
noCache: true,
);
} catch (e) {
return shelf.Response(502, body: 'upstream error: $e');
}
}
String _segProxyUrl(Uri absolute) =>
'http://127.0.0.1:$_port/seg?u=${Uri.encodeComponent(absolute.toString())}';
String _rewritePlaylist(String text, Uri base) {
final out = StringBuffer();
for (final rawLine in const LineSplitter().convert(text)) {
final line = rawLine.trim();
if (line.isEmpty) continue;
if (line.startsWith('#')) {
// 改写标签内的 URI="..."(EXT-X-KEY / EXT-X-MAP 等)
out.writeln(line.replaceAllMapped(
RegExp(r'URI="([^"]+)"'),
(m) => 'URI="${_segProxyUrl(base.resolve(m.group(1)!))}"',
));
} else {
out.writeln(_segProxyUrl(base.resolve(line)));
}
}
return out.toString();
}
// ---------- /seg:分片缓存(键 = 去签名路径 sha1)----------
/// 统一字节响应:始终声明 Accept-Ranges,带 Range 头则返回 206 切片。
/// (ffmpeg/mpv 依据 Accept-Ranges 判断流可拖动,否则按直播处理:
/// duration=0 → 进度条不走、无法 seek)
shelf.Response _bytesResponse(
shelf.Request req, List<int> bytes, String contentType,
{bool noCache = false}) {
final headers = <String, String>{
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
if (noCache) 'Cache-Control': 'no-cache' else 'Cache-Control': 'max-age=31536000',
};
final range = req.headers['range'];
if (range != null && range.startsWith('bytes=')) {
final m = RegExp(r'bytes=(\d+)-(\d*)').firstMatch(range);
if (m != null) {
final start = int.parse(m.group(1)!);
if (start < bytes.length) {
final endStr = m.group(2) ?? '';
final end = endStr.isEmpty
? bytes.length - 1
: int.parse(endStr).clamp(0, bytes.length - 1);
return shelf.Response(206,
body: bytes.sublist(start, end + 1),
headers: {
...headers,
'Content-Range': 'bytes $start-$end/${bytes.length}',
});
}
}
}
return shelf.Response.ok(bytes, headers: headers);
}
String _cacheKeyFor(Uri u) {
final stripped = Uri(scheme: u.scheme, host: u.host, port: u.port, path: u.path);
return sha1.convert(utf8.encode(stripped.toString())).toString();
}
Future<shelf.Response> _handleSegment(shelf.Request req) async {
final raw = req.url.queryParameters['u'];
if (raw == null || raw.isEmpty) return shelf.Response(400, body: 'missing u');
final u = Uri.tryParse(raw);
if (u == null) return shelf.Response(400, body: 'bad u');
final file = File(p.join(cacheDir.path, _cacheKeyFor(u)));
if (await file.exists()) {
// 触活:LRU 以 mtime 排序
await file.setLastModified(DateTime.now());
return _bytesResponse(req, await file.readAsBytes(), _contentType(u.path));
}
try {
final bytes = await (_inflight[raw] ??= _fetch(u));
return _bytesResponse(req, bytes, _contentType(u.path));
} catch (e) {
return shelf.Response(502, body: 'fetch error: $e');
} finally {
unawaited(Future(() async {
await Future<void>.delayed(const Duration(seconds: 30));
_inflight.remove(raw);
}));
}
}
Future<List<int>> _fetch(Uri u) async {
final resp = await _dio.get<List<int>>(
u.toString(),
options: Options(responseType: ResponseType.bytes),
);
final bytes = resp.data ?? const <int>[];
if (bytes.isEmpty) throw Exception('empty body');
final file = File(p.join(cacheDir.path, _cacheKeyFor(u)));
// tmp 名唯一化:不同签名的同一分片并发回源时避免写同一个临时文件
final tmp = File(
'${file.path}.${DateTime.now().microsecondsSinceEpoch}.tmp');
await tmp.writeAsBytes(bytes, flush: true);
await tmp.rename(file.path);
_cacheBytes += bytes.length;
_maybeEvict();
return bytes;
}
/// LRU 清理:超 1G 时按 mtime 从旧到新删到 900M(每 20 次写入最多全扫一次)
void _maybeEvict() {
if (_cacheBytes <= _cacheLimit) return;
if (++_writesSinceEvictCheck < 20) return;
_writesSinceEvictCheck = 0;
unawaited(Future(() async {
final entries = <File>[];
await for (final e in cacheDir.list()) {
if (e is File) entries.add(e);
}
entries.sort((a, b) =>
a.lastModifiedSync().compareTo(b.lastModifiedSync()));
for (final f in entries) {
if (_cacheBytes <= _evictTo) break;
try {
final len = await f.length();
await f.delete();
_cacheBytes -= len;
} catch (_) {}
}
}));
}
// ---------- /local:已下载内容(Range 支持 mp4 拖动)----------
Future<shelf.Response> _handleLocal(
shelf.Request req, String vid, String name) async {
// 防路径穿越
if (name.contains('/') || name.contains('..') || vid.contains('..')) {
return shelf.Response(400, body: 'bad path');
}
final file = File(p.join(downloadsDir.path, vid, name));
if (!await file.exists()) return shelf.Response.notFound('gone');
final total = await file.length();
final range = req.headers['range'];
if (range != null && range.startsWith('bytes=')) {
final m = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(range);
if (m != null) {
final start = int.tryParse(m.group(1) ?? '') ?? 0;
final endStr = m.group(2) ?? '';
final end = endStr.isEmpty
? total - 1
: (int.tryParse(endStr) ?? total - 1).clamp(0, total - 1);
final raf = await file.open();
try {
await raf.setPosition(start);
final bytes = await raf.read(end - start + 1);
return shelf.Response(206,
body: bytes,
headers: {
'Content-Type': _contentType(name),
'Content-Range': 'bytes $start-$end/$total',
'Accept-Ranges': 'bytes',
'Content-Length': '${bytes.length}',
});
} finally {
await raf.close();
}
}
}
return shelf.Response.ok(await file.readAsBytes(), headers: {
'Content-Type': _contentType(name),
'Accept-Ranges': 'bytes',
});
}
String _contentType(String path) {
if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
if (path.endsWith('.ts')) return 'video/mp2t';
if (path.endsWith('.mp4')) return 'video/mp4';
return 'application/octet-stream';
}
Future<int> _dirSize(Directory dir) async {
var total = 0;
await for (final e in dir.list()) {
if (e is File) {
try {
total += await e.length();
} catch (_) {}
}
}
return total;
}
}
+40 -37
View File
@@ -199,49 +199,52 @@ class _ErrorInterceptor extends Interceptor {
final apiError = err.error;
final status = err.response?.statusCode;
// 401:静默刷新重试一次
// 401:静默刷新重试一次;无法刷新(无 refresh / 刷新被拒 / 重试仍 401)
// 统一按会话失效处理,否则 APP 会卡在"本地有 user 但 access 已过期"的
// 僵尸登录态:我的页正常显示资料卡,点会员/钱包等全部 401 加载失败
if (status == 401 &&
err.requestOptions.extra['retried'] != true &&
TokenStore.refreshToken != null) {
String? newToken;
try {
newToken = await _refreshToken();
} on ApiException catch (refreshErr) {
// 刷新请求本身网络失败:不清登录态,透传错误
return handler.next(DioException(
requestOptions: err.requestOptions,
error: refreshErr,
));
}
if (newToken != null) {
err.requestOptions.extra['retried'] != true) {
if (TokenStore.refreshToken != null) {
String? newToken;
try {
final opts = err.requestOptions;
opts.extra['retried'] = true;
opts.headers['Authorization'] = 'Bearer $newToken';
final resp = await ApiClient._dio.fetch<dynamic>(opts);
return handler.resolve(resp);
} 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 失败:视为网络异常,不清登录态
newToken = await _refreshToken();
} on ApiException catch (refreshErr) {
// 刷新请求本身网络失败:不清登录态,透传错误
return handler.next(DioException(
requestOptions: err.requestOptions,
error: ApiException(-1, '网络异常,请稍后重试'),
error: refreshErr,
));
}
if (newToken != null) {
try {
final opts = err.requestOptions;
opts.extra['retried'] = true;
opts.headers['Authorization'] = 'Bearer $newToken';
final resp = await ApiClient._dio.fetch<dynamic>(opts);
return handler.resolve(resp);
} 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();
ApiClient.onSessionExpired?.call();
+86 -10
View File
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../media/local_media_server.dart';
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
class PlayerSlot {
PlayerSlot(this.player);
@@ -44,6 +46,20 @@ class FeedPlayerPool {
int _currentIndex = 0;
int get currentIndex => _currentIndex;
/// 代际计数器:每次 bindWindow 自增,await 恢复后校验,过期调用直接退出。
/// 快速连划时多个 bindWindow 并发 await open,无互斥会导致旧调用恢复后
/// 误 play 装有旧媒体的槽位(幽灵声音来源)。
int _generation = 0;
/// 是否挂起(切出首页 Tab / App 退后台):挂起期间 bindWindow 的 open
/// 完成后不允许自动 play —— 否则「先暂停、open 后完成又 play」的竞态
/// 会让视频在后台继续出声(切到"我的"听到同一视频声音的根因)
bool _suspended = false;
/// 最近一次 bindWindow(resume 时需等在途 open 完成再 play,避免 play 被
/// open(play:false) 内部的 pause 覆盖导致恢复后不播)
Future<void>? _pendingBind;
/// 初始化(创建 3 槽位并订阅各自进度流)
void init() {
for (var i = 0; i < slotCount; i++) {
@@ -65,26 +81,40 @@ class FeedPlayerPool {
bool get isCurrentPlaying => currentSlot.player.state.playing;
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
///
/// 窗口策略 [index, index+1]:只保留当前页(播放)和下一页(预缓冲)。
/// 上一页槽位 stop 清空:一是满足"划走即停止缓存下载"(pause 不停止缓冲,
/// stop 才断开),二是 stop 后槽内无媒体,即使误 play 也不会出声(防幽灵)。
Future<void> bindWindow(int index, List<({int videoId, String url})> items) {
final fut = _bindWindow(index, items);
_pendingBind = fut;
return fut;
}
Future<void> _bindWindow(
int index, List<({int videoId, String url})> items) async {
final gen = ++_generation;
// 1. 先切换当前页指针(currentSlot / isCurrentPlaying / 完成回调都依赖它)
_currentIndex = index;
// 窗口内应保留的槽位集合 [index-1, index, index+1]
// 窗口内应保留的槽位集合 [index, index+1]
final activeSet = <int>{};
for (var offset = -1; offset <= 1; offset++) {
for (var offset = 0; offset <= 1; offset++) {
final i = index + offset;
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
}
// 2. 立即静音(同步执行,不等后续网络 open):
// - 出窗槽位:stop(断音 + 清缓存),并复位
// - 窗口内槽位:全部 pause(含刚划走的上一页,防止切页期间串音)
// - 出窗槽位:stop(断音 + 断缓存 + 清媒体),并复位
// - 窗口内非当前槽位:pause(下一页预缓冲不自动播)
// 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声
for (var s = 0; s < slotCount; s++) {
final slot = _slots[s];
if (slot.videoId < 0) continue;
if (activeSet.contains(s)) {
try { slot.player.pause(); } catch (_) {}
if (s != slotOf(index)) {
try { slot.player.pause(); } catch (_) {}
}
} else {
unawaited(slot.player.stop());
slot.videoId = -1;
@@ -93,7 +123,7 @@ class FeedPlayerPool {
}
// 3. 窗口内槽位按需加载(open 完才 play)
for (var offset = -1; offset <= 1; offset++) {
for (var offset = 0; offset <= 1; offset++) {
final i = index + offset;
if (i < 0 || i >= items.length) continue;
final slot = _slots[slotOf(i)];
@@ -102,14 +132,44 @@ class FeedPlayerPool {
slot.videoId = item.videoId;
slot.completeNotified = false;
if (item.url.isNotEmpty) {
await slot.player.open(Media(item.url), play: false);
// 经本地代理:HLS 分片 1G LRU 缓存,回看命中缓存不再下载
await slot.player.open(Media(LocalMediaServer.I.wrap(item.url)), play: false);
// await 期间用户可能又翻页:本次绑定已过期,退出(最新绑定会接管)
if (gen != _generation) return;
}
}
// 4. 当前槽:播放
// 4. 当前槽:播放(播前再校验代际与挂起态,防过期调用/后台挂起时唤醒媒体)
if (gen != _generation || _suspended) return;
currentSlot.player.play();
}
/// 挂起(切出首页底部 Tab / App 退后台 / 切走当前策略):
/// 立即暂停当前槽,并阻止在途 bindWindow 的 open 完成后自动播放
void suspend() {
_suspended = true;
try {
currentSlot.player.pause();
} catch (_) {}
}
/// 恢复(切回首页 / App 回前台 / 策略切回):解除挂起;若 bind 仍在 open,
/// 等其完成后再 play,避免 play 被 open(play:false) 的内部 pause 覆盖
Future<void> resume() async {
_suspended = false;
final pending = _pendingBind;
if (pending != null) {
try {
await pending;
} catch (_) {}
}
if (!_suspended) {
try {
currentSlot.player.play();
} catch (_) {}
}
}
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
void _hookSlot(PlayerSlot slot) {
var lastPosition = Duration.zero;
@@ -135,7 +195,7 @@ class FeedPlayerPool {
}
}
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
if (slot == currentSlot) {
if (slot == currentSlot && !_suspended) {
slot.player.seek(Duration.zero);
slot.player.play();
}
@@ -155,6 +215,22 @@ class FeedPlayerPool {
}
}
/// 全部停止并清空(进入全屏播放器前调用):停音 + 断缓存 + 清媒体。
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
void stopAll() {
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
_generation++;
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
_suspended = false;
for (final slot in _slots) {
if (slot.videoId < 0) continue;
unawaited(slot.player.stop());
slot.videoId = -1;
slot.completeNotified = false;
}
}
/// 释放全部资源(Feed 页 dispose 时调用)
Future<void> dispose() async {
for (final sub in _subs) {
+27 -1
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'core/media/download_manager.dart';
import 'core/media/local_media_server.dart';
import 'core/storage/local_store.dart';
import 'core/storage/search_history_store.dart';
import 'core/storage/watch_dedup_store.dart';
@@ -22,8 +24,32 @@ Future<void> main() async {
await SearchHistoryStore.init();
// 观看上报去重(持久化;不初始化则观看计费/游客免费额度链路失效)
await WatchDedupStore.init();
// 本地媒体服务:HLS 1G 分片缓存(回看免下载)+ 已下载视频离线播放
await LocalMediaServer.I.init();
runApp(const ProviderScope(child: VideoApp()));
runApp(ProviderScope(
overrides: const [],
child: const _AppInitializer(),
));
}
/// 初始化需要 Provider 环境的单例(下载管理器扫描本地已下载列表)
class _AppInitializer extends ConsumerStatefulWidget {
const _AppInitializer();
@override
ConsumerState<_AppInitializer> createState() => _AppInitializerState();
}
class _AppInitializerState extends ConsumerState<_AppInitializer> {
@override
void initState() {
super.initState();
ref.read(downloadManagerProvider.notifier).init();
}
@override
Widget build(BuildContext context) => const VideoApp();
}
class VideoApp extends ConsumerWidget {
+7
View File
@@ -36,6 +36,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = state.copyWith(restored: true);
return;
}
// 数据完整性防线:有 access 无 refresh 是不完整登录态(2h 后必然 401
// 且无法静默刷新),直接清理回游客态引导重新登录,避免僵尸登录态
if (TokenStore.refreshToken == null || TokenStore.refreshToken!.isEmpty) {
await TokenStore.clear();
state = state.copyWith(restored: true);
return;
}
try {
final user = await UserService.fetchMe();
state = AuthState(user: user, restored: true);
+23 -1
View File
@@ -14,7 +14,9 @@ import 'auth_provider.dart';
enum FeedStrategy {
recommend('recommend', '推荐'),
latest('latest', '最新'),
hot('hot', '热度');
hot('hot', '热度'),
liked('liked', '口碑'),
viewed('viewed', '热播');
const FeedStrategy(this.apiValue, this.label);
@@ -22,6 +24,15 @@ enum FeedStrategy {
final String label;
}
/// Feed 顶部当前选中的策略:HomeShell 切底部 Tab 回来时,
/// 只恢复该策略对应的播放器池(其余池保持挂起,防止幽灵声)
final activeFeedStrategyProvider =
StateProvider<FeedStrategy>((_) => FeedStrategy.recommend);
/// 首页底部 Tab 当前是否可见:App 回前台时仅在可见状态下恢复播放
/// (否则在"我的"等页面退后台再回来会误恢复声音)
final feedTabVisibleProvider = StateProvider<bool>((_) => true);
/// Feed 页 UI 事件(Notifier 不持有 BuildContext,通过事件流驱动弹窗/Toast)
sealed class FeedEvent {
const FeedEvent();
@@ -148,6 +159,17 @@ class FeedNotifier extends StateNotifier<FeedState> {
}
}
/// 从全屏播放器返回后恢复:stopAll 清空了槽位,重新绑定当前页并播放。
/// 不上报观看(本次进入 Feed 时已上报过,且 _bindAndReport 有按日去重)。
Future<void> rebindCurrent() async {
final index = state.currentIndex;
if (index < 0 || index >= state.items.length) return;
await pool.bindWindow(
index,
[for (final v in state.items) (videoId: v.videoId, url: resolvePlayUrl(v))],
);
}
/// 翻页:滑出即暂停、滑入自动播放并上报观看计费(《01-开发文档》5.3)
void onPageChanged(int index) {
// 立即静音全部非目标槽位,防止翻页动画期间多个视频同时出声
+6
View File
@@ -8,6 +8,7 @@ import '../screens/assets/assets_screen.dart';
import '../screens/assets/withdraw_screen.dart';
import '../screens/auth/login_screen.dart';
import '../screens/auth/register_screen.dart';
import '../screens/downloads/downloads_screen.dart';
import '../screens/home/home_shell.dart';
import '../screens/membership/membership_screen.dart';
import '../screens/notification/notifications_screen.dart';
@@ -94,6 +95,11 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (_, __) => VideoGridScreen.favorites(),
),
GoRoute(path: '/mine/history', builder: (_, __) => const HistoryScreen()),
// 已下载(离线可看)
GoRoute(
path: '/mine/downloads',
builder: (_, __) => const DownloadsScreen(),
),
// 编辑资料 / 头像(页面内兜底未登录引导)
GoRoute(
path: '/profile/edit',
@@ -0,0 +1,135 @@
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/media/download_manager.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
/// 已下载视频列表(我的 → 已下载):
/// 下载中任务(进度/取消)+ 已完成列表(点击播放 / 左滑或长按删除)
class DownloadsScreen extends ConsumerWidget {
const DownloadsScreen({super.key});
static String _fmtSize(int bytes) {
if (bytes >= 1 << 30) return '${(bytes / (1 << 30)).toStringAsFixed(2)} GB';
if (bytes >= 1 << 20) return '${(bytes / (1 << 20)).toStringAsFixed(1)} MB';
return '${(bytes / 1024).toStringAsFixed(0)} KB';
}
static String _fmtDuration(int seconds) {
final m = seconds ~/ 60;
final s = seconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}
void _play(BuildContext context, DownloadItem item) {
context.push('/player',
extra: VideoItem(
videoId: item.videoId,
title: item.title,
coverUrl: item.coverUrl,
videoUrl: item.playUrl,
duration: item.duration,
author: VideoAuthor(userId: 0),
));
}
Future<void> _confirmDelete(
BuildContext context, WidgetRef ref, DownloadItem item) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除下载'),
content: Text('删除「${item.title}」?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('删除', style: TextStyle(color: Colors.red))),
],
),
);
if (ok == true) {
ref.read(downloadManagerProvider.notifier).delete(item.videoId);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(downloadManagerProvider);
final downloading = state.progress.entries.toList();
return Scaffold(
appBar: AppBar(title: const Text('已下载')),
body: ListView(
children: [
// 下载中
for (final e in downloading)
ListTile(
leading: const SizedBox(
width: 56, height: 56,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
title: Text('视频 #${e.key}',
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: LinearProgressIndicator(value: e.value),
),
trailing: Text('${(e.value * 100).toStringAsFixed(0)}%'),
onLongPress: () =>
ref.read(downloadManagerProvider.notifier).cancel(e.key),
),
if (downloading.isNotEmpty) const Divider(),
// 已完成
if (state.items.isEmpty && downloading.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 120),
child: Center(
child: Text('暂无下载\n长按视频可下载',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textWeak)),
),
),
for (final item in state.items)
ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 72, height: 48,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl, fit: BoxFit.cover)
: Container(
color: Colors.grey[800],
child: const Icon(Icons.play_circle_outline,
color: Colors.white54),
),
),
),
title: Text(item.title.isEmpty ? '视频 #${item.videoId}' : item.title,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
'${_fmtDuration(item.duration)} · ${_fmtSize(item.sizeBytes)}',
style: const TextStyle(fontSize: 12),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline,
color: AppColors.textWeak),
onPressed: () => _confirmDelete(context, ref, item),
),
onTap: () => _play(context, item),
),
],
),
);
}
}
+30 -5
View File
@@ -32,30 +32,46 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// 登记当前策略,HomeShell 切回首页时只恢复这个策略的播放器池
ref.read(activeFeedStrategyProvider.notifier).state = _strategy;
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
_eventSub = ref
.read(feedProvider(_strategy).notifier)
.events
.listen(_handleEvent);
// 从登录页等返回时恢复播放:provider 全局常驻保留了列表,
// 但槽位已在 dispose 时 stopAll,需重新绑定(冷启动列表为空时为 no-op)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ref.read(feedProvider(_strategy).notifier).rebindCurrent();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_eventSub?.cancel();
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
_pageController.dispose();
super.dispose();
}
/// App 退后台暂停、回前台恢复播放
/// App 退后台挂起、回前台恢复播放(suspend/resume 防在途 open 竞态)
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final notifier = ref.read(feedProvider(_strategy).notifier);
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
notifier.pool.pauseCurrent();
notifier.pool.suspend();
} else if (state == AppLifecycleState.resumed) {
notifier.pool.playCurrent();
// 仅在首页 Tab 可见时恢复(在"我的"等页面回前台不播)
if (ref.read(feedTabVisibleProvider)) {
unawaited(() async {
await notifier.pool.resume();
await notifier.rebindCurrent();
}());
}
}
}
@@ -73,15 +89,23 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
void _switchStrategy(FeedStrategy next) {
if (next == _strategy) return;
// 暂停旧策略播放,切换后新策略独立维护状态与播放器池
ref.read(feedProvider(_strategy).notifier).pool.pauseCurrent();
// 挂起旧策略池(防在途 open 完成后自动出声)
ref.read(feedProvider(_strategy).notifier).pool.suspend();
setState(() => _strategy = next);
ref.read(activeFeedStrategyProvider.notifier).state = next;
_eventSub?.cancel();
_eventSub = ref
.read(feedProvider(_strategy).notifier)
.events
.listen(_handleEvent);
_pageController.jumpToPage(0);
// jumpToPage 触发新策略 bindWindow 后再 resume:等其在途 open 完成再播放,
// 同时解除新策略池可能残留的挂起态
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(ref.read(feedProvider(_strategy).notifier).pool.resume());
}
});
}
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
@@ -142,6 +166,7 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
onLike: notifier.toggleLike,
onFavorite: notifier.toggleFavorite,
onComment: _openComments,
onReturnFromPlayer: notifier.rebindCurrent,
);
},
),
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/media/download_manager.dart';
import '../../../core/player/feed_player_pool.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
@@ -25,6 +26,7 @@ class FeedVideoPage extends ConsumerStatefulWidget {
required this.onLike,
this.onFavorite,
this.onComment,
this.onReturnFromPlayer,
});
final VideoItem item;
@@ -35,6 +37,9 @@ class FeedVideoPage extends ConsumerStatefulWidget {
final void Function(int videoId)? onFavorite;
final void Function(VideoItem item)? onComment;
/// 从全屏播放器返回后的恢复回调(重新绑定 Feed 当前页)
final VoidCallback? onReturnFromPlayer;
@override
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
}
@@ -89,7 +94,10 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => Padding(
builder: (sheetContext) => Theme(
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
data: ThemeData.light(),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -165,8 +173,38 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
_share();
},
),
// 下载(已下载/下载中置灰)
Builder(builder: (_) {
final dm = ref.read(downloadManagerProvider);
final downloaded = dm.items
.any((e) => e.videoId == widget.item.videoId);
final downloading =
dm.progress.containsKey(widget.item.videoId);
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(downloaded
? Icons.check_circle_outline
: Icons.download_outlined),
title: Text(downloaded
? '已下载'
: (downloading ? '下载中…' : '下载')),
enabled: !downloaded && !downloading,
onTap: () {
Navigator.of(sheetContext).pop();
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
ref
.read(downloadManagerProvider.notifier)
.start(widget.item)
.catchError((_) {
if (mounted) showTopToast(context, '下载失败,请重试');
});
},
);
}),
],
),
),
),
);
}
@@ -185,7 +223,9 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => DraggableScrollableSheet(
builder: (_) => Theme(
data: ThemeData.light(),
child: DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
@@ -273,6 +313,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
),
],
),
),
),
);
}
@@ -287,10 +328,13 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
duration: (ep['duration'] as num?)?.toInt() ?? 0,
author: widget.item.author,
);
// 先暂停 Feed 页播放器,再打开 SimplePlayerScreen
widget.pool.pauseExcept(slotIndex: -1);
// 彻底停止 Feed 层播放器:stopAll 断音 + 断缓存下载(pause 不停止缓冲)
widget.pool.stopAll();
// ignore: use_build_context_synchronously
context.push('/player', extra: newItem);
context.push('/player', extra: newItem).then((_) {
// 从播放器返回:恢复 Feed 当前页(槽位已清空,需重新绑定)
if (mounted) widget.onReturnFromPlayer?.call();
});
}
@override
@@ -327,6 +371,7 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
slotIndex: widget.slotIndex,
coverUrl: widget.item.coverUrl,
isCurrent: widget.isCurrent,
durationSeconds: widget.item.duration,
),
// 双击爱心动画
@@ -18,6 +18,13 @@ class VideoInfoSection extends StatefulWidget {
class _VideoInfoSectionState extends State<VideoInfoSection> {
bool _expanded = false;
static String _fmtSeconds(int seconds) {
final h = seconds ~/ 3600;
final m = ((seconds % 3600) ~/ 60).toString().padLeft(2, '0');
final s = (seconds % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
@override
Widget build(BuildContext context) {
final item = widget.item;
@@ -37,7 +44,7 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
),
const SizedBox(height: 8),
// 标题 + #标签(点击标签跳转标签视频页)
// 标题 + 时长 + #标签(点击标签跳转标签视频页)
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
@@ -52,6 +59,25 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
// 标题后显示视频时长
if (item.duration > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.white24, width: 0.5),
),
child: Text(
_fmtSeconds(item.duration),
style: const TextStyle(
color: Colors.white70,
fontSize: 11,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
),
for (final tag in item.tags)
GestureDetector(
onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'),
@@ -14,6 +14,7 @@ class VideoPlayerView extends StatefulWidget {
required this.slotIndex,
required this.coverUrl,
required this.isCurrent,
this.durationSeconds = 0,
});
final FeedPlayerPool pool;
@@ -23,6 +24,10 @@ class VideoPlayerView extends StatefulWidget {
/// 该页是否为当前页(暂停图标只在当前页显示,避免划走后旧页残留大图标)
final bool isCurrent;
/// DB 里的视频时长(秒):流 duration 异常(如被当直播流报 0)时兜底,
/// 保证进度条/拖动可用
final int durationSeconds;
@override
State<VideoPlayerView> createState() => _VideoPlayerViewState();
}
@@ -45,9 +50,15 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
});
}
/// 有效时长:流上报优先,异常(duration=0)回退 DB 时长
int get _totalMs {
final streamMs = _duration.inMilliseconds;
return streamMs > 0 ? streamMs : widget.durationSeconds * 1000;
}
double get _value {
if (_seeking) return _seekValue;
final total = _duration.inMilliseconds;
final total = _totalMs;
if (total <= 0) return 0;
final cur = _position.inMilliseconds.clamp(0, total);
return cur / total;
@@ -66,7 +77,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
void _onSeekEnd(double v) {
setState(() => _seeking = false);
final totalMs = _duration.inMilliseconds;
final totalMs = _totalMs;
if (totalMs > 0) {
_slot.player.seek(Duration(milliseconds: (v * totalMs).round()));
}
@@ -120,7 +131,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
),
),
// 抖音风格细进度条(贴底,常驻)
// 抖音风格细进度条(贴底,常驻;拖动时显示目标时间气泡)
Positioned(
left: 0, right: 0, bottom: 0,
child: _ThinProgressBar(
@@ -128,6 +139,7 @@ class _VideoPlayerViewState extends State<VideoPlayerView> {
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
totalMs: _totalMs,
),
),
],
@@ -141,16 +153,29 @@ class _ThinProgressBar extends StatefulWidget {
required this.onChanged,
required this.onChangeStart,
required this.onChangeEnd,
this.totalMs = 0,
});
final double value;
final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd;
/// 视频总时长(毫秒):拖动气泡显示目标位置用时/分/秒
final int totalMs;
@override
State<_ThinProgressBar> createState() => _ThinProgressBarState();
}
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
String _seekBubbleText(double value, int totalMs) {
final d = Duration(milliseconds: (value * totalMs).round());
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
}
class _ThinProgressBarState extends State<_ThinProgressBar> {
bool _dragging = false;
double _dragValue = 0;
@@ -187,15 +212,44 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
setState(() => _dragging = false);
widget.onChangeEnd(_dragValue.clamp(0.0, 1.0));
},
child: Container(
child: SizedBox(
height: 32, // 触摸热区 32px(视觉 2px,好点)
alignment: Alignment.bottomCenter,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
size: Size(w, _dragging ? 20 : 4),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
Align(
alignment: Alignment.bottomCenter,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
size: Size(w, _dragging ? 20 : 4),
),
),
// 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
if (_dragging && widget.totalMs > 0)
Positioned(
bottom: 14,
left: (_dragValue * w - 38)
.clamp(0.0, (w - 76).clamp(0.0, w)),
child: Container(
width: 76,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_seekBubbleText(_dragValue, widget.totalMs),
style: const TextStyle(
color: Colors.white, fontSize: 11.5),
),
),
),
],
),
),
);
+14 -4
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -26,16 +28,24 @@ class _HomeShellState extends ConsumerState<HomeShell> {
void _onTabSelected(int i) {
if (i == _index) return;
// 切到非首页:暂停所有 Feed 策略的播放器池(IndexedStack 保活,否则声音会继续)
// 切到非首页:挂起所有 Feed 策略的播放器池(IndexedStack 保活,
// suspend 会同时阻止在途 open 完成后自动播放,杜绝后台幽灵声)
if (i != 0) {
for (final s in _feedStrategies) {
ref.read(feedProvider(s).notifier).pool.pauseCurrent();
ref.read(feedProvider(s).notifier).pool.suspend();
}
}
ref.read(feedTabVisibleProvider.notifier).state = i == 0;
setState(() => _index = i);
// 切回首页:恢复当前策略播放(FeedScreen 内部 _strategy 是 recommend)
// 切回首页:只恢复当前选中策略的池(其余保持挂起)
if (i == 0) {
ref.read(feedProvider(FeedStrategy.recommend).notifier).pool.playCurrent();
final active = ref.read(activeFeedStrategyProvider);
final notifier = ref.read(feedProvider(active).notifier);
unawaited(() async {
await notifier.pool.resume();
// 兜底:挂起期间首屏 bind 可能尚未发生,补一次当前页绑定播放
await notifier.rebindCurrent();
}());
}
}
+150 -24
View File
@@ -8,6 +8,8 @@ import 'package:media_kit_video/media_kit_video.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/config/test_stream.dart';
import '../../core/media/download_manager.dart';
import '../../core/media/local_media_server.dart';
import '../../core/network/api_exception.dart';
import '../../core/storage/watch_dedup_store.dart';
import '../../core/theme/app_theme.dart';
@@ -100,7 +102,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
}
void _open(VideoItem item) {
_player.open(Media(resolvePlayUrl(item)), play: true);
// 本地已下载地址(127.0.0.1)原样播放;网络 m3u8 经本地代理走 1G 分片缓存
_player.open(Media(LocalMediaServer.I.wrap(resolvePlayUrl(item))),
play: true);
}
void _toggle() {
@@ -317,7 +321,10 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => Padding(
builder: (sheetContext) => Theme(
// 弹窗是白底,APP 主题为暗色(默认白字),需显式套用亮色主题
data: ThemeData.light(),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -395,21 +402,64 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
_share();
},
),
// 下载(已下载/下载中置灰;本地已下载视频不再重复下载)
Builder(builder: (_) {
final dm = ref.read(downloadManagerProvider);
final downloaded =
dm.items.any((e) => e.videoId == _currentItem.videoId);
final downloading =
dm.progress.containsKey(_currentItem.videoId);
final isLocal = _currentItem.videoUrl.contains('127.0.0.1');
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(downloaded || isLocal
? Icons.check_circle_outline
: Icons.download_outlined),
title: Text(downloaded || isLocal
? '已下载'
: (downloading ? '下载中…' : '下载')),
enabled: !downloaded && !downloading && !isLocal,
onTap: () {
Navigator.of(sheetContext).pop();
showTopToast(context, '开始下载,可在「我的 → 已下载」查看');
ref
.read(downloadManagerProvider.notifier)
.start(_currentItem)
.catchError((_) {
if (mounted) showTopToast(context, '下载失败,请重试');
});
},
);
}),
],
),
),
),
);
}
// ---------- 进度条 ----------
/// 有效时长:流上报优先,异常(如被当直播 duration=0)回退到 DB 时长
Duration get _effectiveDuration => _duration.inMilliseconds > 0
? _duration
: Duration(seconds: _currentItem.duration);
double get _sliderValue {
if (_seeking) return _seekValue;
if (_duration.inMilliseconds <= 0) return 0;
return (_position.inMilliseconds / _duration.inMilliseconds)
if (_effectiveDuration.inMilliseconds <= 0) return 0;
return (_position.inMilliseconds / _effectiveDuration.inMilliseconds)
.clamp(0.0, 1.0);
}
static String _fmtDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
void _onSeekStart(double v) {
setState(() {
_seeking = true;
@@ -423,9 +473,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
void _onSeekEnd(double v) {
setState(() => _seeking = false);
if (_duration.inMilliseconds > 0) {
if (_effectiveDuration.inMilliseconds > 0) {
final target = Duration(
milliseconds: (v * _duration.inMilliseconds).round(),
milliseconds: (v * _effectiveDuration.inMilliseconds).round(),
);
_player.seek(target);
}
@@ -591,14 +641,45 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
),
),
// ---------- 进度条:抖音风格一条细线 ----------
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
Positioned(
left: 0, right: 0, bottom: 0,
child: _ThinProgressBar(
value: _sliderValue,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 时间刻度:左当前(拖动时为目标位置),右总时长
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_fmtDuration(_seeking
? Duration(
milliseconds: (_seekValue *
_effectiveDuration.inMilliseconds)
.round())
: _position),
style: const TextStyle(
color: Colors.white70, fontSize: 11),
),
Text(
_fmtDuration(_effectiveDuration),
style: const TextStyle(
color: Colors.white70, fontSize: 11),
),
],
),
),
_ThinProgressBar(
value: _sliderValue,
onChanged: _onSeek,
onChangeStart: _onSeekStart,
onChangeEnd: _onSeekEnd,
totalMs: _effectiveDuration.inMilliseconds,
),
],
),
),
],
@@ -648,7 +729,9 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (_) => DraggableScrollableSheet(
builder: (_) => Theme(
data: ThemeData.light(),
child: DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
@@ -744,6 +827,7 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
),
],
),
),
),
);
}
@@ -757,6 +841,7 @@ class _ThinProgressBar extends StatefulWidget {
required this.onChanged,
required this.onChangeStart,
required this.onChangeEnd,
this.totalMs = 0,
});
final double value;
@@ -764,10 +849,22 @@ class _ThinProgressBar extends StatefulWidget {
final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd;
/// 视频总时长(毫秒):拖动时气泡显示目标位置用时/分/秒
final int totalMs;
@override
State<_ThinProgressBar> createState() => _ThinProgressBarState();
}
/// 拖动气泡时间:>1 小时显示 x时xx分xx秒,否则 xx分xx秒
String _seekBubbleText(double value, int totalMs) {
final d = Duration(milliseconds: (value * totalMs).round());
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h时$m分$s秒' : '$m分$s秒';
}
class _ThinProgressBarState extends State<_ThinProgressBar> {
bool _dragging = false;
double _dragValue = 0;
@@ -805,19 +902,48 @@ class _ThinProgressBarState extends State<_ThinProgressBar> {
_start(d.localPosition, w);
},
onTapUp: (_) => _end(w),
child: Container(
child: SizedBox(
height: 32, // 触摸热区 32px(比视觉线条大,提升交互)
alignment: Alignment.bottomCenter,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: _dragging ? 4 : 2,
width: double.infinity,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.bottomCenter,
children: [
Align(
alignment: Alignment.bottomCenter,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: _dragging ? 4 : 2,
width: double.infinity,
child: CustomPaint(
painter: _ProgressPainter(
value: _dragging ? _dragValue : widget.value,
dragging: _dragging,
),
),
),
),
),
// 拖动中的时间气泡(跟随 thumb,两端自动夹在屏内)
if (_dragging && widget.totalMs > 0)
Positioned(
bottom: 14,
left: (_dragValue * w - 38)
.clamp(0.0, (w - 76).clamp(0.0, w)),
child: Container(
width: 76,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_seekBubbleText(_dragValue, widget.totalMs),
style: const TextStyle(
color: Colors.white, fontSize: 11.5),
),
),
),
],
),
),
);
+5
View File
@@ -142,6 +142,11 @@ class _MineScreenState extends ConsumerState<MineScreen> {
label: '观看历史',
onTap: () => context.push('/mine/history'),
),
_Entry(
icon: Icons.download_outlined,
label: '已下载',
onTap: () => context.push('/mine/downloads'),
),
_Entry(
icon: Icons.account_balance_wallet_outlined,
label: '钱包',
+6 -1
View File
@@ -2,7 +2,7 @@ name: video_app
description: "短视频应用(Flutter,iOS/Android 双端)"
publish_to: 'none'
version: 1.2.0+13
version: 1.3.7+23
environment:
sdk: ^3.9.2
@@ -59,6 +59,11 @@ dependencies:
image_picker: ^1.1.2
# 外部浏览器/商店跳转(强制更新下载页)
url_launcher: ^6.3.1
# 本地媒体服务:HLS 分片缓存(1G LRU)+ 已下载视频离线播放
shelf: ^1.4.2
shelf_router: ^1.1.4
path_provider: ^2.1.5
path: ^1.9.0
tobias: ^5.3.4
fluwx: ^5.7.7