1
This commit is contained in:
@@ -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),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user