This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
+134
View File
@@ -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;
}
}
}
+18
View File
@@ -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._();
}
+2
View File
@@ -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';
// ---------- 互动 ----------
+15
View File
@@ -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 重新播放(如从登录页返回),解除挂起态
+38
View File
@@ -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?) ?? '',
);
}
+7
View File
@@ -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();
}
+22 -2
View File
@@ -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 {
+10
View File
@@ -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',
+122 -8
View File
@@ -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,
+40 -3
View File
@@ -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),
+206 -43
View File
@@ -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,
),
);
}
}
+2 -1
View File
@@ -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),
),
],
),
),
);
}
}
+45 -5
View File
@@ -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,
+12 -1
View File
@@ -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,
);
},
),
),
+9 -1
View File
@@ -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,
);
}
+18
View File
@@ -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, {