1
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../network/api_client.dart';
|
||||
|
||||
/// 用户操作日志上报器(仅白名单邮箱用户启用)
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// ActionLogger.I.bindUser(userEmail);
|
||||
/// ActionLogger.I.log('route_push', page: '/search/feed', detail: {...});
|
||||
/// ```
|
||||
///
|
||||
/// 设计:
|
||||
/// - 白名单:只有 email == 256kb@gmail.com 时启用,其他账号调用为 no-op
|
||||
/// - 缓冲:本地 Queue 缓冲,达到 BATCH_SIZE 或超过 FLUSH_INTERVAL 自动批量上传
|
||||
/// - 静默失败:网络错误/服务端 403 不重试不上抛,避免影响业务
|
||||
class ActionLogger {
|
||||
ActionLogger._();
|
||||
static final ActionLogger I = ActionLogger._();
|
||||
|
||||
/// 白名单邮箱(与后端 ALLOWED_EMAILS 保持一致)
|
||||
static const String _allowedEmail = '256kb@gmail.com';
|
||||
|
||||
static const int _batchSize = 30;
|
||||
static const Duration _flushInterval = Duration(seconds: 10);
|
||||
static const int _maxQueueLen = 500; // 防止离线积压撑爆内存
|
||||
|
||||
bool _enabled = false;
|
||||
String _userEmail = '';
|
||||
String _deviceInfo = '';
|
||||
String _appVersion = '';
|
||||
|
||||
final Queue<Map<String, dynamic>> _queue = Queue();
|
||||
Timer? _flushTimer;
|
||||
bool _flushing = false;
|
||||
|
||||
bool get enabled => _enabled;
|
||||
|
||||
/// 登录成功后调用,开启上报;非白名单邮箱则关闭
|
||||
void bindUser(String? email) {
|
||||
_userEmail = email ?? '';
|
||||
_enabled = _userEmail.toLowerCase() == _allowedEmail;
|
||||
if (_enabled) {
|
||||
_initMeta();
|
||||
_startFlushTimer();
|
||||
log('log_session_start', page: 'app', detail: {'email': _userEmail});
|
||||
} else {
|
||||
_stopFlushTimer();
|
||||
_queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出/切换账号时调用
|
||||
void unbind() {
|
||||
if (_enabled) {
|
||||
log('log_session_end', page: 'app', detail: {'email': _userEmail});
|
||||
// 立即上传残余
|
||||
unawaited(flush());
|
||||
}
|
||||
_enabled = false;
|
||||
_userEmail = '';
|
||||
_stopFlushTimer();
|
||||
// 不清 queue:flush 已在飞,让它带完最后这批
|
||||
}
|
||||
|
||||
Future<void> _initMeta() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
_appVersion = '${info.version}+${info.buildNumber}';
|
||||
} catch (_) {
|
||||
_appVersion = 'unknown';
|
||||
}
|
||||
_deviceInfo = '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
}
|
||||
|
||||
void _startFlushTimer() {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = Timer.periodic(_flushInterval, (_) => unawaited(flush()));
|
||||
}
|
||||
|
||||
void _stopFlushTimer() {
|
||||
_flushTimer?.cancel();
|
||||
_flushTimer = null;
|
||||
}
|
||||
|
||||
/// 记一条日志。enabled=false 时直接 no-op
|
||||
void log(
|
||||
String action, {
|
||||
String page = '',
|
||||
Map<String, dynamic>? detail,
|
||||
String? detailRaw,
|
||||
}) {
|
||||
if (!_enabled) return;
|
||||
if (_queue.length >= _maxQueueLen) {
|
||||
_queue.removeFirst(); // 丢最老的,保留最新
|
||||
}
|
||||
_queue.add({
|
||||
'action': action,
|
||||
'page': page,
|
||||
'detail': detailRaw ?? (detail != null ? jsonEncode(detail) : ''),
|
||||
'device_info': _deviceInfo,
|
||||
'app_version': _appVersion,
|
||||
'client_ts': DateTime.now().millisecondsSinceEpoch,
|
||||
});
|
||||
if (_queue.length >= _batchSize) {
|
||||
unawaited(flush());
|
||||
}
|
||||
}
|
||||
|
||||
/// 立即上传队列里的所有日志
|
||||
Future<void> flush() async {
|
||||
if (!_enabled || _flushing || _queue.isEmpty) return;
|
||||
_flushing = true;
|
||||
try {
|
||||
final batch = <Map<String, dynamic>>[];
|
||||
while (_queue.isNotEmpty && batch.length < 100) {
|
||||
batch.add(_queue.removeFirst());
|
||||
}
|
||||
await ApiClient.I.post<Map<String, dynamic>>(
|
||||
'/logs/action',
|
||||
data: {'items': batch},
|
||||
);
|
||||
} catch (_) {
|
||||
// 静默失败:上报通道绝不能影响业务
|
||||
} finally {
|
||||
_flushing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
/// 视频封面图的全局缓存管理器:
|
||||
/// - stalePeriod=30 天:30 天内重复访问直接命中缓存,不发请求
|
||||
/// - maxNrOfCacheObjects=100000:取消原默认 200 张的容量限制,足够覆盖整个浏览历史
|
||||
/// 缓存目录由系统管理,卸载/清理 App 数据时一并清除
|
||||
class CoverCacheManager extends CacheManager with ImageCacheManager {
|
||||
CoverCacheManager._()
|
||||
: super(
|
||||
Config(
|
||||
'coverCache',
|
||||
stalePeriod: const Duration(days: 30),
|
||||
maxNrOfCacheObjects: 100000,
|
||||
),
|
||||
);
|
||||
|
||||
static final CoverCacheManager I = CoverCacheManager._();
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class Endpoints {
|
||||
// ---------- 视频 ----------
|
||||
static const feed = '/videos/feed';
|
||||
static const categories = '/videos/categories';
|
||||
static const sources = '/videos/sources';
|
||||
static const uploadToken = '/videos/upload/token';
|
||||
static const uploadFile = '/videos/upload/file';
|
||||
static const publish = '/videos';
|
||||
@@ -42,6 +43,7 @@ class Endpoints {
|
||||
static String videoComments(int videoId) => '/videos/$videoId/comments';
|
||||
static String videoEdit(int videoId) => '/videos/$videoId';
|
||||
static String videoDelete(int videoId) => '/videos/$videoId';
|
||||
static String videoOffline(int videoId) => '/videos/$videoId/offline';
|
||||
static String tagVideos(String tagName) => '/tags/$tagName/videos';
|
||||
|
||||
// ---------- 互动 ----------
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../log/action_logger.dart';
|
||||
import '../media/local_media_server.dart';
|
||||
|
||||
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
||||
@@ -192,6 +193,10 @@ class FeedPlayerPool {
|
||||
/// 只有 stop 能可靠断开下载;位置存到 _resumePosition,恢复时 rebind
|
||||
/// 重新 open + seek 续播(分片走本地代理磁盘缓存,零流量)
|
||||
void suspend() {
|
||||
ActionLogger.I.log('player_suspend', page: 'feed_pool', detail: {
|
||||
'generation': _generation,
|
||||
'currentIndex': _currentIndex,
|
||||
});
|
||||
_suspended = true;
|
||||
// 使在途 bindWindow 过期:其 open 完成后不得再 play
|
||||
_generation++;
|
||||
@@ -213,6 +218,11 @@ class FeedPlayerPool {
|
||||
/// 挂起并 play。_suspended 必须在 await 之后才解除——否则等待期间 completed
|
||||
/// listener 的 auto-loop 会因为看到 _suspended=false 而提前 play
|
||||
Future<void> resume() async {
|
||||
ActionLogger.I.log('player_resume', page: 'feed_pool', detail: {
|
||||
'generation': _generation,
|
||||
'currentIndex': _currentIndex,
|
||||
'suspended': _suspended,
|
||||
});
|
||||
final pending = _pendingBind;
|
||||
if (pending != null) {
|
||||
try {
|
||||
@@ -274,6 +284,11 @@ class FeedPlayerPool {
|
||||
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
|
||||
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
|
||||
void stopAll() {
|
||||
ActionLogger.I.log('player_stopall', page: 'feed_pool', detail: {
|
||||
'generation': _generation,
|
||||
'currentIndex': _currentIndex,
|
||||
'slots': [for (final s in _slots) s.videoId],
|
||||
});
|
||||
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
|
||||
_generation++;
|
||||
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
|
||||
|
||||
@@ -8,6 +8,8 @@ class VideoItem {
|
||||
this.duration = 0,
|
||||
required this.author,
|
||||
this.categoryId = 0,
|
||||
this.sourceId = 0,
|
||||
this.sourceName = '',
|
||||
this.viewCount = 0,
|
||||
this.likeCount = 0,
|
||||
this.commentCount = 0,
|
||||
@@ -19,6 +21,8 @@ class VideoItem {
|
||||
this.status = -1,
|
||||
this.statusText = '',
|
||||
this.rejectReason = '',
|
||||
this.seriesId = 0,
|
||||
this.totalEpisodes = 0,
|
||||
});
|
||||
|
||||
final int videoId;
|
||||
@@ -28,6 +32,11 @@ class VideoItem {
|
||||
final int duration; // 秒
|
||||
final VideoAuthor author;
|
||||
final int categoryId;
|
||||
|
||||
/// 内容来源(麻豆 / 91 ...)
|
||||
final int sourceId;
|
||||
final String sourceName;
|
||||
|
||||
final int viewCount;
|
||||
final int likeCount;
|
||||
final int commentCount;
|
||||
@@ -42,6 +51,12 @@ class VideoItem {
|
||||
final String statusText;
|
||||
final String rejectReason;
|
||||
|
||||
/// 所属系列 id(0 = 非系列视频);系列聚合 Feed 中为首集 video_id
|
||||
final int seriesId;
|
||||
|
||||
/// 系列总集数(系列聚合 Feed 返回,用于卡片「全N集」角标)
|
||||
final int totalEpisodes;
|
||||
|
||||
/// 拷贝更新(点赞/收藏乐观更新用)
|
||||
VideoItem copyWith({
|
||||
bool? isLiked,
|
||||
@@ -58,6 +73,8 @@ class VideoItem {
|
||||
duration: duration,
|
||||
author: author,
|
||||
categoryId: categoryId,
|
||||
sourceId: sourceId,
|
||||
sourceName: sourceName,
|
||||
viewCount: viewCount,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
commentCount: commentCount ?? this.commentCount,
|
||||
@@ -69,6 +86,8 @@ class VideoItem {
|
||||
status: status,
|
||||
statusText: statusText,
|
||||
rejectReason: rejectReason,
|
||||
seriesId: seriesId,
|
||||
totalEpisodes: totalEpisodes,
|
||||
);
|
||||
|
||||
factory VideoItem.fromJson(Map<String, dynamic> json) => VideoItem(
|
||||
@@ -78,6 +97,8 @@ class VideoItem {
|
||||
videoUrl: (json['video_url'] as String?) ?? '',
|
||||
duration: (json['duration'] as num?)?.toInt() ?? 0,
|
||||
categoryId: (json['category_id'] as num?)?.toInt() ?? 0,
|
||||
sourceId: (json['source_id'] as num?)?.toInt() ?? 0,
|
||||
sourceName: (json['source_name'] as String?) ?? '',
|
||||
author: VideoAuthor.fromJson(
|
||||
(json['author'] as Map<String, dynamic>?) ?? const {},
|
||||
),
|
||||
@@ -94,6 +115,23 @@ class VideoItem {
|
||||
status: (json['status'] as num?)?.toInt() ?? -1,
|
||||
statusText: (json['status_text'] as String?) ?? '',
|
||||
rejectReason: (json['reject_reason'] as String?) ?? '',
|
||||
seriesId: (json['series_id'] as num?)?.toInt() ?? 0,
|
||||
totalEpisodes: (json['total_episodes'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 内容来源(片商):麻豆 / 91 ...
|
||||
class VideoSource {
|
||||
VideoSource({required this.sourceId, required this.name, this.code = ''});
|
||||
|
||||
final int sourceId;
|
||||
final String name;
|
||||
final String code;
|
||||
|
||||
factory VideoSource.fromJson(Map<String, dynamic> json) => VideoSource(
|
||||
sourceId: (json['source_id'] as num?)?.toInt() ?? 0,
|
||||
name: (json['name'] as String?) ?? '',
|
||||
code: (json['code'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/log/action_logger.dart';
|
||||
import '../core/network/api_client.dart';
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../core/storage/token_store.dart';
|
||||
@@ -46,6 +47,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
try {
|
||||
final user = await UserService.fetchMe();
|
||||
state = AuthState(user: user, restored: true);
|
||||
ActionLogger.I.bindUser(user.email);
|
||||
} on ApiException catch (e) {
|
||||
// 仅 Token 确认失效(1002)才清理回游客态;
|
||||
// 网络异常等情况保留 Token,下次启动/请求重试恢复
|
||||
@@ -62,9 +64,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
/// 需补拉全量资料,否则积分显示为 0、编辑资料保存会把服务端字段清空。
|
||||
Future<void> onLoginSuccess(UserModel user) async {
|
||||
state = AuthState(user: user, restored: true);
|
||||
ActionLogger.I.bindUser(user.email);
|
||||
try {
|
||||
final full = await UserService.fetchMe();
|
||||
state = state.copyWith(user: full);
|
||||
// 全量资料里的 email 字段更权威(用户可能登录后改过邮箱)
|
||||
ActionLogger.I.bindUser(full.email);
|
||||
} catch (_) {
|
||||
// 补拉失败不影响登录态,保留简要信息
|
||||
}
|
||||
@@ -75,11 +80,13 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
|
||||
/// 退出登录:先通知服务端注销(失败忽略,本地照常清理),再清空登录态
|
||||
Future<void> logout() async {
|
||||
ActionLogger.I.log('logout', page: 'mine');
|
||||
try {
|
||||
await AuthService.logout();
|
||||
} catch (_) {
|
||||
// 服务端已不可达或 Token 已失效:本地清理即可
|
||||
}
|
||||
ActionLogger.I.unbind();
|
||||
await _clearLocal();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@ enum FeedStrategy {
|
||||
latest('latest', '最新'),
|
||||
hot('hot', '热度'),
|
||||
liked('liked', '口碑'),
|
||||
viewed('viewed', '热播');
|
||||
viewed('viewed', '热播'),
|
||||
// 搜索播放专用策略:数据由搜索页注入,不走网络 refresh;
|
||||
// 顶部 Tab 不显示该项,仅作为独立播放器池存在
|
||||
search('search', '搜索');
|
||||
|
||||
const FeedStrategy(this.apiValue, this.label);
|
||||
|
||||
@@ -106,7 +109,10 @@ class FeedState {
|
||||
class FeedNotifier extends StateNotifier<FeedState> {
|
||||
FeedNotifier(this._ref, this.strategy) : super(const FeedState()) {
|
||||
pool = FeedPlayerPool(onWatchComplete: _onWatchComplete)..init();
|
||||
refresh();
|
||||
// search 策略由外部 injectItems 灌入数据,不走网络 refresh
|
||||
if (strategy != FeedStrategy.search) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
final Ref _ref;
|
||||
@@ -123,6 +129,8 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
||||
|
||||
/// 刷新(首屏 / 下拉 / 切策略)
|
||||
Future<void> refresh() async {
|
||||
// search 策略不支持下拉刷新(数据由搜索页注入)
|
||||
if (strategy == FeedStrategy.search) return;
|
||||
state = state.copyWith(isRefreshing: true, loadFailed: false);
|
||||
try {
|
||||
final page = await VideoService.fetchFeed(type: strategy.apiValue);
|
||||
@@ -142,6 +150,8 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
||||
|
||||
/// 加载更多(游标分页)
|
||||
Future<void> loadMore() async {
|
||||
// search 策略暂不支持自动加载更多(如需要可由搜索页同步注入新数据)
|
||||
if (strategy == FeedStrategy.search) return;
|
||||
if (!state.hasMore || state.isLoadingMore) return;
|
||||
state = state.copyWith(isLoadingMore: true);
|
||||
try {
|
||||
@@ -159,6 +169,16 @@ class FeedNotifier extends StateNotifier<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 外部数据注入(仅 search 策略用):把搜索结果整列表灌入并定位到点击的那一条。
|
||||
/// 池子由 SearchFeedScreen 独占,注入后立即绑定播放。
|
||||
Future<void> injectItems(List<VideoItem> items, int initialIndex) async {
|
||||
if (strategy != FeedStrategy.search) return;
|
||||
if (items.isEmpty) return;
|
||||
final index = initialIndex.clamp(0, items.length - 1);
|
||||
state = FeedState(items: items, currentIndex: index);
|
||||
await _bindAndReport(index);
|
||||
}
|
||||
|
||||
/// 从全屏播放器返回后恢复:stopAll 清空了槽位,重新绑定当前页并播放。
|
||||
/// 不上报观看(本次进入 Feed 时已上报过,且 _bindAndReport 有按日去重)。
|
||||
Future<void> rebindCurrent() async {
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../screens/profile/invite_screen.dart';
|
||||
import '../screens/profile/profile_edit_screen.dart';
|
||||
import '../screens/profile/user_profile_screen.dart';
|
||||
import '../screens/profile/video_list_screens.dart';
|
||||
import '../screens/search/search_feed_screen.dart';
|
||||
import '../screens/search/search_screen.dart';
|
||||
import '../screens/search/tag_videos_screen.dart';
|
||||
import '../screens/settings/settings_screen.dart';
|
||||
@@ -28,6 +29,9 @@ import '../screens/upload/upload_screen.dart';
|
||||
import '../screens/wallet/wallet_screen.dart';
|
||||
import '../services/payment_service.dart';
|
||||
|
||||
/// 全局路由观察者:FeedScreen 等需要感知「上层路由 pop 露出本页」的场景使用
|
||||
final routeObserver = RouteObserver<PageRoute<dynamic>>();
|
||||
|
||||
/// 声明式路由(02-技术文档:go_router)
|
||||
/// /home 对游客开放(游客可浏览 Feed,观看超限 2002 时才引导登录);
|
||||
/// 受保护页面(上传/钱包/会员等)在各自页面入口校验登录态并跳 /login。
|
||||
@@ -42,6 +46,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
refreshListenable: refresh,
|
||||
observers: [routeObserver],
|
||||
redirect: (context, state) {
|
||||
final auth = ref.read(authProvider);
|
||||
final loggingIn = state.matchedLocation == '/login' ||
|
||||
@@ -58,6 +63,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(path: '/register', builder: (_, __) => const RegisterScreen()),
|
||||
GoRoute(path: '/home', builder: (_, __) => const HomeShell()),
|
||||
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
||||
// 搜索结果播放(复用 Feed 池的 search 策略,支持上下滑)
|
||||
GoRoute(
|
||||
path: '/search/feed',
|
||||
builder: (_, __) => const SearchFeedScreen(),
|
||||
),
|
||||
// 标签视频页(游客可)
|
||||
GoRoute(
|
||||
path: '/tags/:name',
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/media/cover_cache.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import '../../services/upload_service.dart';
|
||||
import '../../services/video_service.dart';
|
||||
|
||||
@@ -19,13 +21,25 @@ class CategoryScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
List<VideoCategory> _categories = const [];
|
||||
List<VideoSource> _sources = const [];
|
||||
int? _selected; // null = 全部
|
||||
int? _selectedSource; // null = 全部来源
|
||||
List<VideoItem> _items = const [];
|
||||
String? _cursor;
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
bool _failed = false;
|
||||
|
||||
/// 当前选中的分类是否为系列型(如 AI短剧):按「剧」聚合,一部剧一张卡片
|
||||
bool get _seriesMode {
|
||||
final id = _selected;
|
||||
if (id == null) return false;
|
||||
for (final c in _categories) {
|
||||
if (c.categoryId == id) return c.hasSeries;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final _scroll = ScrollController();
|
||||
|
||||
@override
|
||||
@@ -56,13 +70,19 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
UploadService.fetchCategories(),
|
||||
VideoService.fetchFeed(type: 'hot', categoryId: _selected, limit: 10),
|
||||
VideoService.fetchSources(),
|
||||
VideoService.fetchFeed(
|
||||
type: 'hot',
|
||||
categoryId: _selected,
|
||||
sourceId: _selectedSource,
|
||||
limit: 10),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_categories = results[0] as List<VideoCategory>;
|
||||
_items = (results[1] as FeedPage).items;
|
||||
_cursor = (results[1] as FeedPage).nextCursor;
|
||||
_sources = results[1] as List<VideoSource>;
|
||||
_items = (results[2] as FeedPage).items;
|
||||
_cursor = (results[2] as FeedPage).nextCursor;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (_) {
|
||||
@@ -80,6 +100,12 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
await _reload();
|
||||
}
|
||||
|
||||
Future<void> _selectSource(int? id) async {
|
||||
if (id == _selectedSource) return;
|
||||
setState(() => _selectedSource = id);
|
||||
await _reload();
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
@@ -87,7 +113,11 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
});
|
||||
try {
|
||||
final page = await VideoService.fetchFeed(
|
||||
type: 'hot', categoryId: _selected, limit: 10);
|
||||
type: 'hot',
|
||||
categoryId: _selected,
|
||||
sourceId: _selectedSource,
|
||||
group: _seriesMode ? 'series' : null,
|
||||
limit: 10);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = page.items;
|
||||
@@ -108,7 +138,12 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
setState(() => _loadingMore = true);
|
||||
try {
|
||||
final page = await VideoService.fetchFeed(
|
||||
type: 'hot', cursor: _cursor, categoryId: _selected, limit: 10);
|
||||
type: 'hot',
|
||||
cursor: _cursor,
|
||||
categoryId: _selected,
|
||||
sourceId: _selectedSource,
|
||||
group: _seriesMode ? 'series' : null,
|
||||
limit: 10);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_items = [..._items, ...page.items];
|
||||
@@ -157,6 +192,19 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// 来源筛选条(麻豆 / 91 ...,多于一个来源时才显示)
|
||||
if (_sources.length > 1)
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
_sourceChip('全部来源', null),
|
||||
for (final s in _sources) _sourceChip(s.name, s.sourceId),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: _buildBody()),
|
||||
],
|
||||
),
|
||||
@@ -180,6 +228,26 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sourceChip(String label, int? id) {
|
||||
final active = id == _selectedSource;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8, top: 4),
|
||||
child: FilterChip(
|
||||
label: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
selected: active,
|
||||
onSelected: (_) => _selectSource(id),
|
||||
selectedColor: AppColors.primary.withValues(alpha: 0.18),
|
||||
backgroundColor: AppColors.surface,
|
||||
side: BorderSide(
|
||||
color: active ? AppColors.primary : AppColors.textWeak.withValues(alpha: 0.3)),
|
||||
labelStyle: TextStyle(
|
||||
color: active ? AppColors.primary : AppColors.textSecondary,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading && _items.isEmpty) {
|
||||
return const Center(
|
||||
@@ -229,15 +297,21 @@ class _CategoryScreenState extends ConsumerState<CategoryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryCard extends StatelessWidget {
|
||||
class _CategoryCard extends ConsumerWidget {
|
||||
const _CategoryCard({required this.item});
|
||||
|
||||
final VideoItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/search/play', extra: item),
|
||||
// 进入播放页前停掉首页 Feed 的播放器池,返回时再恢复,避免「双重声音」
|
||||
onTap: () {
|
||||
// 挂起首页池(保存断点 + 停所有槽位),恢复由 FeedScreen.didPopNext 统一处理
|
||||
final strategy = ref.read(activeFeedStrategyProvider);
|
||||
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||
context.push('/search/play', extra: item);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
@@ -248,6 +322,7 @@ class _CategoryCard extends StatelessWidget {
|
||||
child: item.coverUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl,
|
||||
cacheManager: CoverCacheManager.I,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const Icon(
|
||||
Icons.play_circle_outline,
|
||||
@@ -282,6 +357,45 @@ class _CategoryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 来源角标(麻豆 / 91 ...)
|
||||
if (item.sourceName.isNotEmpty)
|
||||
Positioned(
|
||||
left: 6,
|
||||
top: 6,
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.sourceName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 系列角标「全N集」:仅系列型分类(AI短剧)聚合卡片显示
|
||||
if (item.totalEpisodes > 0)
|
||||
Positioned(
|
||||
left: 6,
|
||||
bottom: 28,
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.65),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'全${item.totalEpisodes}集',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 标题
|
||||
Positioned(
|
||||
left: 0,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/media/cover_cache.dart';
|
||||
import '../../core/media/download_manager.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/video_item.dart';
|
||||
@@ -107,7 +108,9 @@ class DownloadsScreen extends ConsumerWidget {
|
||||
width: 72, height: 48,
|
||||
child: item.coverUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl, fit: BoxFit.cover)
|
||||
imageUrl: item.coverUrl,
|
||||
cacheManager: CoverCacheManager.I,
|
||||
fit: BoxFit.cover)
|
||||
: Container(
|
||||
color: Colors.grey[800],
|
||||
child: const Icon(Icons.play_circle_outline,
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/log/action_logger.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import '../../router.dart';
|
||||
import 'widgets/comment_sheet.dart';
|
||||
import 'widgets/feed_overlays.dart';
|
||||
import 'widgets/feed_video_page.dart';
|
||||
@@ -23,7 +25,7 @@ class FeedScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
with WidgetsBindingObserver {
|
||||
with WidgetsBindingObserver, RouteAware {
|
||||
FeedStrategy _strategy = FeedStrategy.recommend;
|
||||
final PageController _pageController = PageController();
|
||||
StreamSubscription<FeedEvent>? _eventSub;
|
||||
@@ -46,9 +48,36 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 注册路由感知:当上层路由 pop 露出 FeedScreen 时(didPopNext),
|
||||
// 若首页 Tab 仍选中,恢复播放——这是处理「搜索页/分类页返回后无声」的唯一可靠入口
|
||||
final route = ModalRoute.of(context);
|
||||
if (route is PageRoute) {
|
||||
routeObserver.subscribe(this, route);
|
||||
}
|
||||
}
|
||||
|
||||
/// 上层路由 pop 后本路由重新可见:恢复播放
|
||||
@override
|
||||
void didPopNext() {
|
||||
if (!mounted) return;
|
||||
if (!ref.read(feedTabVisibleProvider)) return;
|
||||
ActionLogger.I.log('feed_did_pop_next', page: '/home', detail: {
|
||||
'strategy': _strategy.name,
|
||||
});
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
unawaited(() async {
|
||||
await notifier.pool.resume();
|
||||
await notifier.rebindCurrent();
|
||||
}());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
routeObserver.unsubscribe(this);
|
||||
_eventSub?.cancel();
|
||||
// 离开 Feed 路由(如跳登录页):彻底停止播放(断音+断缓存下载)。
|
||||
// feedProvider 是全局常驻,不处理则播放器池在后台继续出声
|
||||
@@ -101,7 +130,13 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
.read(feedProvider(_strategy).notifier)
|
||||
.events
|
||||
.listen(_handleEvent);
|
||||
_pageController.jumpToPage(0);
|
||||
// 保留每个策略的播放进度(currentIndex 由 provider 维护):切回时跳到上次位置而非归零
|
||||
final targetIndex = ref.read(feedProvider(_strategy)).currentIndex;
|
||||
if (_pageController.hasClients && targetIndex > 0) {
|
||||
_pageController.jumpToPage(targetIndex);
|
||||
} else if (_pageController.hasClients) {
|
||||
_pageController.jumpToPage(0);
|
||||
}
|
||||
// jumpToPage 后 resume + rebindCurrent:suspend 已 stop 槽位,仅 play
|
||||
// 播不起来,必须 rebind 重新 open(有断点记录则 seek 续播)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -186,7 +221,9 @@ class _FeedScreenState extends ConsumerState<FeedScreen>
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
for (final s in FeedStrategy.values) ...[
|
||||
for (final s in FeedStrategy.values.where(
|
||||
(s) => s != FeedStrategy.search,
|
||||
)) ...[
|
||||
_StrategyTab(
|
||||
label: s.label,
|
||||
selected: s == _strategy,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/media/cover_cache.dart';
|
||||
import '../../../core/network/api_exception.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/comment_item.dart';
|
||||
@@ -423,7 +424,7 @@ class CommentTile extends StatelessWidget {
|
||||
radius: compact ? 12 : 16,
|
||||
backgroundColor: AppColors.background,
|
||||
backgroundImage: comment.avatarUrl.isNotEmpty
|
||||
? CachedNetworkImageProvider(comment.avatarUrl)
|
||||
? CachedNetworkImageProvider(comment.avatarUrl, cacheManager: CoverCacheManager.I)
|
||||
: null,
|
||||
child: comment.avatarUrl.isEmpty
|
||||
? Text(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../core/media/download_manager.dart';
|
||||
import '../../../core/network/api_exception.dart';
|
||||
import '../../../core/player/feed_player_pool.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/video_item.dart';
|
||||
@@ -54,6 +55,15 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
|
||||
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
||||
|
||||
/// 站点管理员邮箱(长按弹窗显示「下架视频」)
|
||||
static const String _kSiteAdminEmail = '256kb@gmail.com';
|
||||
|
||||
bool get _isSiteAdmin {
|
||||
final u = ref.read(authProvider).user;
|
||||
return u != null &&
|
||||
(u.email ?? '').toLowerCase() == _kSiteAdminEmail;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -70,6 +80,8 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
||||
|
||||
String get _seriesTitle => _seriesData?['series']?['title'] as String? ?? '';
|
||||
String get _seriesSourceName =>
|
||||
_seriesData?['series']?['source_name'] as String? ?? '';
|
||||
|
||||
List<dynamic> get _episodes => (_seriesData?['episodes'] as List?) ?? const [];
|
||||
|
||||
@@ -197,6 +209,21 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
},
|
||||
),
|
||||
|
||||
// 下架视频:仅站点管理员可见(按 email 判定)
|
||||
if (_isSiteAdmin)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.remove_circle_outline,
|
||||
color: Colors.redAccent),
|
||||
title: const Text('下架视频',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
subtitle: const Text('从首页/搜索/分类/系列移除该视频'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmOffline();
|
||||
},
|
||||
),
|
||||
|
||||
// 下载(已下载/下载中置灰)
|
||||
Builder(builder: (_) {
|
||||
final dm = ref.read(downloadManagerProvider);
|
||||
@@ -237,6 +264,44 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
await Share.share(text, subject: widget.item.title);
|
||||
}
|
||||
|
||||
// ---------- 下架(管理员) ----------
|
||||
|
||||
Future<void> _confirmOffline() async {
|
||||
final targetId = widget.item.videoId;
|
||||
final title = widget.item.title;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('下架该视频?'),
|
||||
content: Text(
|
||||
'《$title》\n\n下架后将立刻从首页、搜索、分类、系列列表中移除,可在后台恢复。',
|
||||
maxLines: 5, overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||
child: const Text('下架'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
try {
|
||||
await VideoService.offlineVideo(targetId);
|
||||
if (!mounted) return;
|
||||
showTopToast(context, '已下架');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showTopToast(context, e is ApiException ? e.message : '下架失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
void _showSeriesSheet() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -278,10 +343,32 @@ class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'共 ${_episodes.length} 集',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey[600]),
|
||||
Row(
|
||||
children: [
|
||||
if (_seriesSourceName.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
_seriesSourceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
'共 ${_episodes.length} 集',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/media/cover_cache.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../models/video_item.dart';
|
||||
import '../../../providers/auth_provider.dart';
|
||||
@@ -91,7 +92,7 @@ class _AuthorAvatar extends StatelessWidget {
|
||||
radius: 24,
|
||||
backgroundColor: AppColors.surface,
|
||||
backgroundImage: item.author.avatarUrl.isNotEmpty
|
||||
? CachedNetworkImageProvider(item.author.avatarUrl)
|
||||
? CachedNetworkImageProvider(item.author.avatarUrl, cacheManager: CoverCacheManager.I)
|
||||
: null,
|
||||
child: item.author.avatarUrl.isEmpty
|
||||
? Text(
|
||||
|
||||
@@ -32,15 +32,41 @@ class _VideoInfoSectionState extends State<VideoInfoSection> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 昵称
|
||||
Text(
|
||||
'@${item.author.nickname}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
||||
),
|
||||
// 昵称 + 来源徽标
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'@${item.author.nickname}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (item.sourceName.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.sourceName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
static const _rates = [1.0, 1.5, 2.0, 3.0];
|
||||
double _rate = 1.0;
|
||||
|
||||
// 清屏:隐藏互动栏/信息区/顶部栏/底部渐变,仅保留播放画面与进度条。
|
||||
// 恢复只能从长按菜单进入,避免单击误触把界面弹回来
|
||||
bool _cleanMode = false;
|
||||
|
||||
bool get _hasSeries =>
|
||||
_seriesData != null && (_seriesData!['has_series'] as bool? ?? false);
|
||||
|
||||
@@ -77,6 +81,8 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
|
||||
String get _seriesTitle =>
|
||||
_seriesData?['series']?['title'] as String? ?? '';
|
||||
String get _seriesSourceName =>
|
||||
_seriesData?['series']?['source_name'] as String? ?? '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -310,6 +316,15 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 站点管理员邮箱(长按弹窗显示「下架视频」)
|
||||
static const String _kSiteAdminEmail = '256kb@gmail.com';
|
||||
|
||||
bool get _isSiteAdmin {
|
||||
final u = ref.read(authProvider).user;
|
||||
return u != null &&
|
||||
(u.email ?? '').toLowerCase() == _kSiteAdminEmail;
|
||||
}
|
||||
|
||||
// ---------- 长按 ----------
|
||||
|
||||
void _onLongPress() {
|
||||
@@ -403,6 +418,40 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
},
|
||||
),
|
||||
|
||||
// 清屏 / 恢复清屏:隐藏右侧互动栏与底部信息,只留播放画面与进度条。
|
||||
// 恢复只能从长按菜单进入,避免单击误触把界面弹回来
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(_cleanMode
|
||||
? Icons.fullscreen
|
||||
: Icons.fullscreen_exit),
|
||||
title: Text(_cleanMode ? '恢复清屏' : '清屏'),
|
||||
subtitle: Text(_cleanMode
|
||||
? '重新显示点赞、评论、收藏、作者与标题'
|
||||
: '隐藏点赞、评论、收藏、作者与标题'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
setState(() => _cleanMode = !_cleanMode);
|
||||
showTopToast(
|
||||
context, _cleanMode ? '已清屏,长按视频可恢复' : '已恢复显示');
|
||||
},
|
||||
),
|
||||
|
||||
// 下架视频:仅站点管理员可见(按 email 判定)
|
||||
if (_isSiteAdmin)
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.remove_circle_outline,
|
||||
color: Colors.redAccent),
|
||||
title: const Text('下架视频',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
subtitle: const Text('从首页/搜索/分类/系列移除该视频'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_confirmOffline();
|
||||
},
|
||||
),
|
||||
|
||||
// 下载(已下载/下载中置灰;本地已下载视频不再重复下载)
|
||||
Builder(builder: (_) {
|
||||
final dm = ref.read(downloadManagerProvider);
|
||||
@@ -439,8 +488,47 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 进度条 ----------
|
||||
// ---------- 下架(管理员) ----------
|
||||
|
||||
Future<void> _confirmOffline() async {
|
||||
final targetId = _currentItem.videoId;
|
||||
final title = _currentItem.title;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('下架该视频?'),
|
||||
content: Text(
|
||||
'《$title》\n\n下架后将立刻从首页、搜索、分类、系列列表中移除,可在后台恢复。',
|
||||
maxLines: 5, overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
|
||||
child: const Text('下架'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
try {
|
||||
await VideoService.offlineVideo(targetId);
|
||||
if (!mounted) return;
|
||||
showTopToast(context, '已下架');
|
||||
// 播放页停留没意义:返回上一页(触发外层列表刷新由列表自身负责)
|
||||
context.pop();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showTopToast(context, e is ApiException ? e.message : '下架失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 进度条 ----------
|
||||
/// 有效时长:流上报优先,异常(如被当直播 duration=0)回退到 DB 时长
|
||||
Duration get _effectiveDuration => _duration.inMilliseconds > 0
|
||||
? _duration
|
||||
@@ -537,16 +625,20 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// 底部渐变(提升文字可读性)
|
||||
const Positioned(
|
||||
// 底部渐变(提升文字可读性):清屏时一并淡出
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0, height: 200,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black54],
|
||||
child: AnimatedOpacity(
|
||||
opacity: _cleanMode ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black54],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -554,51 +646,59 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
),
|
||||
|
||||
// ---------- 顶部:返回 + 倍速 ----------
|
||||
SafeArea(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const BackButton(color: Colors.white),
|
||||
// 倍速按钮
|
||||
GestureDetector(
|
||||
onTap: _onLongPress,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black45,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_rateLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
_HidableOverlay(
|
||||
hidden: _cleanMode,
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const BackButton(color: Colors.white),
|
||||
// 倍速按钮
|
||||
GestureDetector(
|
||||
onTap: _onLongPress,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black45,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
_rateLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---------- 右侧互动栏 ----------
|
||||
Positioned(
|
||||
right: 8, bottom: 96,
|
||||
child: InteractionBar(
|
||||
item: interactionItem,
|
||||
onLike: (_) => _onLike(),
|
||||
onFavorite: (_) => _onFavorite(),
|
||||
onComment: (_) => _openComments(),
|
||||
child: _HidableOverlay(
|
||||
hidden: _cleanMode,
|
||||
child: InteractionBar(
|
||||
item: interactionItem,
|
||||
onLike: (_) => _onLike(),
|
||||
onFavorite: (_) => _onFavorite(),
|
||||
onComment: (_) => _openComments(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---------- 左下信息区 ----------
|
||||
Positioned(
|
||||
left: 12, right: 88, bottom: 24,
|
||||
child: Column(
|
||||
child: _HidableOverlay(
|
||||
hidden: _cleanMode,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -628,6 +728,24 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (_seriesSourceName.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
_seriesSourceName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 2),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: Colors.white, size: 14),
|
||||
@@ -639,6 +757,7 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
VideoInfoSection(item: _currentItem),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---------- 进度条:抖音风格一条细线 + 时间刻度 ----------
|
||||
@@ -761,10 +880,33 @@ class _SimplePlayerScreenState extends ConsumerState<SimplePlayerScreen> {
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'共 ${_episodes.length} 集',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey[600]),
|
||||
Row(
|
||||
children: [
|
||||
if (_seriesSourceName.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppColors.primary.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
_seriesSourceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
'共 ${_episodes.length} 集',
|
||||
style: TextStyle(
|
||||
fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -992,3 +1134,24 @@ class _ProgressPainter extends CustomPainter {
|
||||
bool shouldRepaint(covariant _ProgressPainter oldDelegate) =>
|
||||
oldDelegate.value != value || oldDelegate.dragging != dragging;
|
||||
}
|
||||
|
||||
/// 可隐藏浮层:淡出 + 禁用点击。隐藏时仍占位但不拦截手势,
|
||||
/// 保证点击/长按能穿透到下层的手势层
|
||||
class _HidableOverlay extends StatelessWidget {
|
||||
const _HidableOverlay({required this.hidden, required this.child});
|
||||
|
||||
final bool hidden;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
ignoring: hidden,
|
||||
child: AnimatedOpacity(
|
||||
opacity: hidden ? 0 : 1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/media/cover_cache.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
@@ -47,7 +48,7 @@ class _MineScreenState extends ConsumerState<MineScreen> {
|
||||
radius: 32,
|
||||
backgroundColor: AppColors.surface,
|
||||
backgroundImage: user.avatarUrl.isNotEmpty
|
||||
? CachedNetworkImageProvider(user.avatarUrl)
|
||||
? CachedNetworkImageProvider(user.avatarUrl, cacheManager: CoverCacheManager.I)
|
||||
: null,
|
||||
child: user.avatarUrl.isEmpty
|
||||
? Text(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../core/media/cover_cache.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
@@ -150,7 +151,7 @@ class _ProfileEditScreenState extends ConsumerState<ProfileEditScreen> {
|
||||
radius: 44,
|
||||
backgroundColor: AppColors.surface,
|
||||
backgroundImage: _avatarUrl.isNotEmpty
|
||||
? CachedNetworkImageProvider(_avatarUrl)
|
||||
? CachedNetworkImageProvider(_avatarUrl, cacheManager: CoverCacheManager.I)
|
||||
: null,
|
||||
child: _avatarUrl.isEmpty
|
||||
? const Icon(Icons.person_outline,
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/media/cover_cache.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
|
||||
/// 他人公开主页(《03-接口文档》4.7/4.8,游客可看):
|
||||
@@ -88,7 +90,7 @@ class _ProfileHeader extends StatelessWidget {
|
||||
radius: 40,
|
||||
backgroundColor: AppColors.surface,
|
||||
backgroundImage: profile.avatarUrl.isNotEmpty
|
||||
? CachedNetworkImageProvider(profile.avatarUrl)
|
||||
? CachedNetworkImageProvider(profile.avatarUrl, cacheManager: CoverCacheManager.I)
|
||||
: null,
|
||||
child: profile.avatarUrl.isEmpty
|
||||
? Text(
|
||||
@@ -155,15 +157,20 @@ class _Stat extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkCard extends StatelessWidget {
|
||||
class _WorkCard extends ConsumerWidget {
|
||||
const _WorkCard({required this.item});
|
||||
|
||||
final VideoItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/search/play', extra: item),
|
||||
// 挂起首页池(保存断点 + 停所有槽位),恢复由 FeedScreen.didPopNext 统一处理
|
||||
onTap: () {
|
||||
final strategy = ref.read(activeFeedStrategyProvider);
|
||||
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||
context.push('/search/play', extra: item);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Stack(
|
||||
@@ -174,6 +181,7 @@ class _WorkCard extends StatelessWidget {
|
||||
child: item.coverUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl,
|
||||
cacheManager: CoverCacheManager.I,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const Icon(
|
||||
Icons.play_circle_outline,
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/media/cover_cache.dart';
|
||||
import '../../core/network/api_exception.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/user_profile.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../services/user_service.dart';
|
||||
import '../../services/video_service.dart';
|
||||
@@ -77,8 +79,11 @@ class _GridCard extends ConsumerWidget {
|
||||
final bool editable;
|
||||
|
||||
/// 我的作品卡片点击:仅已发布可播放,其余状态提示原因
|
||||
void _onTap(BuildContext context) {
|
||||
void _onTap(BuildContext context, WidgetRef ref) {
|
||||
if (item.status == -1 || item.status == 2) {
|
||||
// 挂起首页池(保存断点 + 停所有槽位),恢复由 FeedScreen.didPopNext 统一处理
|
||||
final strategy = ref.read(activeFeedStrategyProvider);
|
||||
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||
context.push('/search/play', extra: item);
|
||||
return;
|
||||
}
|
||||
@@ -158,7 +163,7 @@ class _GridCard extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GestureDetector(
|
||||
onTap: () => _onTap(context),
|
||||
onTap: () => _onTap(context, ref),
|
||||
onLongPress: editable ? () => _showManageSheet(context, ref) : null,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -170,6 +175,7 @@ class _GridCard extends ConsumerWidget {
|
||||
child: item.coverUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl,
|
||||
cacheManager: CoverCacheManager.I,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const Icon(
|
||||
Icons.play_circle_outline,
|
||||
@@ -265,15 +271,20 @@ class HistoryScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryTile extends StatelessWidget {
|
||||
class _HistoryTile extends ConsumerWidget {
|
||||
const _HistoryTile({required this.item});
|
||||
|
||||
final HistoryItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListTile(
|
||||
onTap: () => context.push('/search/play', extra: item.video),
|
||||
// 挂起首页池(保存断点 + 停所有槽位),恢复由 FeedScreen.didPopNext 统一处理
|
||||
onTap: () {
|
||||
final strategy = ref.read(activeFeedStrategyProvider);
|
||||
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||
context.push('/search/play', extra: item.video);
|
||||
},
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
@@ -283,6 +294,7 @@ class _HistoryTile extends StatelessWidget {
|
||||
child: item.video.coverUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.video.coverUrl,
|
||||
cacheManager: CoverCacheManager.I,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) =>
|
||||
const Icon(Icons.play_circle_outline, size: 24),
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/log/action_logger.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import '../../providers/search_provider.dart';
|
||||
import '../../router.dart';
|
||||
import '../feed/widgets/comment_sheet.dart';
|
||||
import '../feed/widgets/feed_overlays.dart';
|
||||
import '../feed/widgets/feed_video_page.dart';
|
||||
|
||||
/// 搜索结果播放页:复用 Feed 播放器池的「第 6 个策略」,
|
||||
/// 支持上下滑切上/下一个视频、双击点赞、长按倍速/分享、评论等完整体验。
|
||||
/// 数据由搜索页 injectItems 注入,不走网络刷新;触底加载更多由搜索 provider 完成后再注入。
|
||||
class SearchFeedScreen extends ConsumerStatefulWidget {
|
||||
const SearchFeedScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SearchFeedScreen> createState() => _SearchFeedScreenState();
|
||||
}
|
||||
|
||||
class _SearchFeedScreenState extends ConsumerState<SearchFeedScreen>
|
||||
with WidgetsBindingObserver, RouteAware {
|
||||
static const _strategy = FeedStrategy.search;
|
||||
final PageController _pageController = PageController();
|
||||
StreamSubscription<FeedEvent>? _eventSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
ActionLogger.I.log('route_enter', page: '/search/feed');
|
||||
_eventSub = ref
|
||||
.read(feedProvider(_strategy).notifier)
|
||||
.events
|
||||
.listen(_handleEvent);
|
||||
// 注入完成后跳到目标 index(数据由搜索页在 push 前 injectItems 灌入)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final idx = ref.read(feedProvider(_strategy)).currentIndex;
|
||||
if (idx > 0 && _pageController.hasClients) {
|
||||
_pageController.jumpToPage(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final route = ModalRoute.of(context);
|
||||
if (route is PageRoute) routeObserver.subscribe(this, route);
|
||||
}
|
||||
|
||||
/// 本页之上又 push 了新路由(评论/分享/登录等):停掉搜索池,避免返回路径复杂时残留声音
|
||||
@override
|
||||
void didPushNext() {
|
||||
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||
}
|
||||
|
||||
/// 上层路由 pop 回本页:恢复播放
|
||||
@override
|
||||
void didPopNext() {
|
||||
if (!mounted) return;
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
unawaited(() async {
|
||||
await notifier.pool.resume();
|
||||
await notifier.rebindCurrent();
|
||||
}());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ActionLogger.I.log('route_exit', page: '/search/feed');
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
routeObserver.unsubscribe(this);
|
||||
_eventSub?.cancel();
|
||||
// 离开搜索播放:直接停掉搜索策略的池(断音 + 断缓存下载)。
|
||||
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 退后台挂起、回前台从断点续播
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
if (state == AppLifecycleState.paused) {
|
||||
notifier.pool.suspend();
|
||||
} else if (state == AppLifecycleState.inactive) {
|
||||
notifier.pool.pauseCurrent();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
// 本页不可见时(压在登录页/评论页下)不恢复播放
|
||||
if (ModalRoute.of(context)?.isCurrent ?? false) {
|
||||
unawaited(() async {
|
||||
await notifier.pool.resume();
|
||||
await notifier.rebindCurrent();
|
||||
}());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEvent(FeedEvent event) {
|
||||
if (!mounted) return;
|
||||
switch (event) {
|
||||
case FeedToastEvent(:final message):
|
||||
showTopToast(context, message);
|
||||
case FeedGuestLimitEvent():
|
||||
showGuestLimitDialog(context);
|
||||
case FeedPointsInsufficientEvent(:final points):
|
||||
showPointsInsufficientDialog(context, points);
|
||||
}
|
||||
}
|
||||
|
||||
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
|
||||
Future<void> _openComments(VideoItem item) async {
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
notifier.pool.pauseCurrent();
|
||||
await showCommentSheet(
|
||||
context,
|
||||
videoId: item.videoId,
|
||||
onCountChanged: (delta) =>
|
||||
notifier.adjustCommentCount(item.videoId, delta),
|
||||
);
|
||||
if (mounted) notifier.pool.playCurrent();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(feedProvider(_strategy));
|
||||
final notifier = ref.read(feedProvider(_strategy).notifier);
|
||||
final keyword = ref.watch(searchProvider).keyword;
|
||||
|
||||
return PopScope(
|
||||
// 系统返回键/手势也要先停搜索池再放行 pop
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) {
|
||||
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
// 内容区
|
||||
if (state.items.isEmpty)
|
||||
const Center(
|
||||
child: Text('暂无视频', style: TextStyle(color: AppColors.textSecondary)),
|
||||
)
|
||||
else
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
// 手指开始拖动即静音,避免划动过程中上一个视频仍在出声
|
||||
if (n is ScrollStartNotification && n.dragDetails != null) {
|
||||
notifier.pool.pauseCurrent();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
scrollDirection: Axis.vertical,
|
||||
itemCount: state.items.length,
|
||||
onPageChanged: notifier.onPageChanged,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.items[index];
|
||||
return FeedVideoPage(
|
||||
key: ValueKey(item.videoId),
|
||||
item: item,
|
||||
slotIndex: notifier.pool.slotOf(index),
|
||||
pool: notifier.pool,
|
||||
isCurrent: index == state.currentIndex,
|
||||
onLike: notifier.toggleLike,
|
||||
onFavorite: notifier.toggleFavorite,
|
||||
onComment: _openComments,
|
||||
onReturnFromPlayer: notifier.rebindCurrent,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 顶部:返回按钮 + 搜索关键词
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios,
|
||||
size: 20, color: Colors.white),
|
||||
onPressed: () {
|
||||
// 先停搜索池再 pop,避免依赖 dispose 时机导致声音残留
|
||||
ref.read(feedProvider(_strategy).notifier).pool.stopAll();
|
||||
context.pop();
|
||||
},
|
||||
),
|
||||
if (keyword.isNotEmpty)
|
||||
Expanded(
|
||||
child: Text(
|
||||
keyword,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Colors.white,
|
||||
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 底部:免费剩余次数提示条(≤3 时显示)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: FreeQuotaBanner(remaining: state.freeRemainingToday),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/media/cover_cache.dart';
|
||||
import '../../core/log/action_logger.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/video_item.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import '../../providers/search_provider.dart';
|
||||
|
||||
/// 搜索页(《01-开发文档》2.5.8):
|
||||
@@ -24,12 +27,22 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ActionLogger.I.log('route_enter', page: '/search');
|
||||
// 打开搜索页挂起首页 Feed:保存当前播放断点 + 停所有槽位。
|
||||
// 用 suspend 而非 stopAll——stopAll 会清掉断点导致返回时从头播,
|
||||
// suspend 会把当前槽位置存到 _resumePosition,rebindCurrent 时经 mpv start 续播
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final strategy = ref.read(activeFeedStrategyProvider);
|
||||
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||
});
|
||||
ref.read(searchProvider.notifier).loadHot();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ActionLogger.I.log('route_exit', page: '/search');
|
||||
// 首页恢复播放改由 FeedScreen 通过 RouteObserver.didPopNext 感知,这里不再处理
|
||||
_inputController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
@@ -283,7 +296,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return VideoGridCard(item: state.items[index]);
|
||||
return VideoGridCard(
|
||||
item: state.items[index],
|
||||
allItems: state.items,
|
||||
initialIndex: index,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -293,15 +310,37 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
}
|
||||
|
||||
/// 视频网格卡片:封面 + 标题 + 作者 + 播放量(搜索结果 / 标签视频页共用)
|
||||
class VideoGridCard extends StatelessWidget {
|
||||
const VideoGridCard({super.key, required this.item});
|
||||
/// 点击行为:把当前列表注入到 feedProvider(FeedStrategy.search),跳 /search/feed
|
||||
/// 进入全屏 Feed 播放,支持上下滑切换上/下一个视频
|
||||
class VideoGridCard extends ConsumerWidget {
|
||||
const VideoGridCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.allItems,
|
||||
required this.initialIndex,
|
||||
});
|
||||
|
||||
final VideoItem item;
|
||||
|
||||
/// 当前列表的所有视频(搜索结果/标签结果)
|
||||
final List<VideoItem> allItems;
|
||||
|
||||
/// 当前点击的视频在列表中的下标
|
||||
final int initialIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/search/play', extra: item),
|
||||
// 宿主页(SearchScreen / TagVideosScreen)已在进入时挂起首页 Feed,
|
||||
// 这里只负责把列表注入 search 策略池并跳转;首页池恢复由宿主页 dispose 统一处理
|
||||
onTap: () async {
|
||||
final searchFeedNotifier =
|
||||
ref.read(feedProvider(FeedStrategy.search).notifier);
|
||||
await searchFeedNotifier.injectItems(allItems, initialIndex);
|
||||
|
||||
if (!context.mounted) return;
|
||||
context.push('/search/feed');
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -315,6 +354,7 @@ class VideoGridCard extends StatelessWidget {
|
||||
child: item.coverUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl,
|
||||
cacheManager: CoverCacheManager.I,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => const Icon(
|
||||
Icons.play_circle_outline,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/feed_provider.dart';
|
||||
import '../../providers/search_provider.dart';
|
||||
import '../../providers/tag_videos_provider.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -23,11 +24,17 @@ class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 进入标签页即挂起首页 Feed(保存断点 + 停所有槽位)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final strategy = ref.read(activeFeedStrategyProvider);
|
||||
ref.read(feedProvider(strategy).notifier).pool.suspend();
|
||||
});
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 首页恢复播放由 FeedScreen.didPopNext 统一处理
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -129,7 +136,11 @@ class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
return VideoGridCard(item: state.items[index]);
|
||||
return VideoGridCard(
|
||||
item: state.items[index],
|
||||
allItems: state.items,
|
||||
initialIndex: index,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -71,14 +71,22 @@ class UploadService {
|
||||
|
||||
/// 视频分类
|
||||
class VideoCategory {
|
||||
VideoCategory({required this.categoryId, required this.name});
|
||||
VideoCategory({
|
||||
required this.categoryId,
|
||||
required this.name,
|
||||
this.hasSeries = false,
|
||||
});
|
||||
|
||||
final int categoryId;
|
||||
final String name;
|
||||
|
||||
/// 系列型分类(如 AI短剧):列表按「剧」聚合展示,一部剧只占一张卡片
|
||||
final bool hasSeries;
|
||||
|
||||
factory VideoCategory.fromJson(Map<String, dynamic> json) => VideoCategory(
|
||||
categoryId: (json['category_id'] as num?)?.toInt() ?? 0,
|
||||
name: (json['name'] as String?) ?? '',
|
||||
hasSeries: (json['has_series'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,14 @@ class VideoService {
|
||||
VideoService._();
|
||||
|
||||
/// 5.1 Feed 流:type=recommend|latest|hot,游标分页
|
||||
/// [group]=series 时(系列型分类)按「剧」聚合,一部剧只返回首集一条
|
||||
static Future<FeedPage> fetchFeed({
|
||||
String type = 'recommend',
|
||||
String? cursor,
|
||||
int limit = 10,
|
||||
int? categoryId,
|
||||
int? sourceId,
|
||||
String? group,
|
||||
}) async {
|
||||
final data = await ApiClient.get<Map<String, dynamic>>(
|
||||
Endpoints.feed,
|
||||
@@ -21,6 +24,8 @@ class VideoService {
|
||||
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
|
||||
'limit': limit,
|
||||
if (categoryId != null) 'category_id': categoryId,
|
||||
if (sourceId != null) 'source_id': sourceId,
|
||||
if (group != null) 'group': group,
|
||||
},
|
||||
);
|
||||
final list = ((data['list'] as List?) ?? const [])
|
||||
@@ -30,6 +35,15 @@ class VideoService {
|
||||
return FeedPage(items: list, nextCursor: data['next_cursor'] as String?);
|
||||
}
|
||||
|
||||
/// 内容来源列表(麻豆 / 91 ...),分类页筛选用
|
||||
static Future<List<VideoSource>> fetchSources() async {
|
||||
final data = await ApiClient.get<Map<String, dynamic>>(Endpoints.sources);
|
||||
return ((data['list'] as List?) ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(VideoSource.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 5.2 视频详情(登录返回续播进度 my_position)
|
||||
static Future<Map<String, dynamic>> fetchDetail(int videoId) =>
|
||||
ApiClient.get<Map<String, dynamic>>(Endpoints.videoDetail(videoId));
|
||||
@@ -93,6 +107,10 @@ class VideoService {
|
||||
static Future<void> deleteVideo(int videoId) =>
|
||||
ApiClient.delete<Map<String, dynamic>>(Endpoints.videoDelete(videoId));
|
||||
|
||||
/// 下架视频(仅站点管理员 256kb@gmail.com,App 长按菜单)
|
||||
static Future<void> offlineVideo(int videoId) =>
|
||||
ApiClient.post<Map<String, dynamic>>(Endpoints.videoOffline(videoId));
|
||||
|
||||
/// 5.9 标签视频列表(游客可):sort=hot(默认)| latest,普通分页
|
||||
static Future<TagVideosPage> fetchTagVideos(
|
||||
String tagName, {
|
||||
|
||||
+3
-1
@@ -2,7 +2,7 @@ name: video_app
|
||||
description: "短视频应用(Flutter,iOS/Android 双端)"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.3.15+31
|
||||
version: 1.3.28+44
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -46,6 +46,8 @@ dependencies:
|
||||
flutter_secure_storage: ^9.2.3
|
||||
# 图片缓存(封面/头像)
|
||||
cached_network_image: ^3.4.1
|
||||
# 自定义缓存策略(封面缓存取消大小限制)
|
||||
flutter_cache_manager: ^3.4.1
|
||||
# 播放器(HLS m3u8)
|
||||
media_kit: ^1.1.11
|
||||
media_kit_video: ^1.2.5
|
||||
|
||||
Reference in New Issue
Block a user