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
+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,
);
},
),
),