diff --git a/android/settings.gradle b/android/settings.gradle index 312d5d4..4cc65ef 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -22,7 +22,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.9.1" apply false - id "org.jetbrains.kotlin.android" version "1.9.24" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" diff --git a/lib/app.dart b/lib/app.dart index 1b10cc1..6921620 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,7 @@ import 'package:facebook_app_events/facebook_app_events.dart'; import 'package:flutter/material.dart'; import 'core/auth/auth_service.dart'; +import 'core/nav/app_route_observer.dart'; import 'core/config/facebook_config.dart'; import 'core/log/app_logger.dart'; import 'core/theme/app_colors.dart'; @@ -12,6 +13,7 @@ import 'features/generate_video/generate_progress_screen.dart'; import 'features/generate_video/generate_video_screen.dart'; import 'features/gallery/models/gallery_task_item.dart'; import 'features/generate_video/generation_result_screen.dart'; +import 'features/home/home_playback_resume.dart'; import 'features/home/home_screen.dart'; import 'features/home/models/task_item.dart'; import 'features/profile/profile_screen.dart'; @@ -75,6 +77,7 @@ class _AppState extends State with WidgetsBindingObserver { title: 'AI Video App', theme: AppTheme.light, debugShowCheckedModeBanner: false, + navigatorObservers: [appRouteObserver], initialRoute: '/', builder: (context, child) { return Stack( @@ -141,7 +144,7 @@ class _AppState extends State with WidgetsBindingObserver { } } -class _MainScaffold extends StatelessWidget { +class _MainScaffold extends StatefulWidget { const _MainScaffold({ required this.currentTab, required this.onTabSelected, @@ -150,20 +153,55 @@ class _MainScaffold extends StatelessWidget { final NavTab currentTab; final ValueChanged onTabSelected; + @override + State<_MainScaffold> createState() => _MainScaffoldState(); +} + +class _MainScaffoldState extends State<_MainScaffold> with RouteAware { + bool _routeObserverSubscribed = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_routeObserverSubscribed) { + final route = ModalRoute.of(context); + if (route is PageRoute) { + appRouteObserver.subscribe(this, route); + _routeObserverSubscribed = true; + } + } + } + + @override + void dispose() { + if (_routeObserverSubscribed) { + appRouteObserver.unsubscribe(this); + } + super.dispose(); + } + + /// 挂在 `/` 的 [PageRoute] 上:任意子页面 pop 后底层重新露出时触发(比 [HomeScreen] 内订阅更可靠) + @override + void didPopNext() { + if (widget.currentTab == NavTab.home) { + homePlaybackResumeNonce.value++; + } + } + @override Widget build(BuildContext context) { return Scaffold( body: IndexedStack( - index: currentTab.index, + index: widget.currentTab.index, children: [ - HomeScreen(isActive: currentTab == NavTab.home), - GalleryScreen(isActive: currentTab == NavTab.gallery), - ProfileScreen(isActive: currentTab == NavTab.profile), + HomeScreen(isActive: widget.currentTab == NavTab.home), + GalleryScreen(isActive: widget.currentTab == NavTab.gallery), + ProfileScreen(isActive: widget.currentTab == NavTab.profile), ], ), bottomNavigationBar: BottomNavBar( - currentTab: currentTab, - onTabSelected: onTabSelected, + currentTab: widget.currentTab, + onTabSelected: widget.onTabSelected, ), ); } diff --git a/lib/core/nav/app_route_observer.dart b/lib/core/nav/app_route_observer.dart new file mode 100644 index 0000000..24fd46b --- /dev/null +++ b/lib/core/nav/app_route_observer.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +/// 用于 [RouteAware](例如首页从 push 的全屏页返回时刷新可见区域) +final RouteObserver> appRouteObserver = + RouteObserver>(); diff --git a/lib/features/home/home_playback_resume.dart b/lib/features/home/home_playback_resume.dart new file mode 100644 index 0000000..4f6fa3d --- /dev/null +++ b/lib/features/home/home_playback_resume.dart @@ -0,0 +1,4 @@ +import 'package:flutter/foundation.dart'; + +/// 子路由从 Navigator pop 回主导航后,通知首页刷新 [VisibilityDetector](见 [_MainScaffoldState.didPopNext]) +final ValueNotifier homePlaybackResumeNonce = ValueNotifier(0); diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 57bfd9b..44f1adf 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:visibility_detector/visibility_detector.dart'; import '../../core/api/services/image_api.dart'; import '../../core/auth/auth_service.dart'; @@ -10,6 +11,7 @@ import 'models/category_item.dart'; import 'models/ext_config_item.dart'; import 'models/task_item.dart'; import 'widgets/home_tab_row.dart'; +import 'home_playback_resume.dart'; import 'widgets/video_card.dart'; /// 固定「pets」分类 id,用于展示 extConfig.items @@ -31,7 +33,23 @@ class _HomeScreenState extends State { List _tasks = []; bool _categoriesLoading = true; bool _tasksLoading = false; - int? _activeCardIndex; + /// 当前在屏幕上有足够可见比例、且带有预览视频的格子(由 [VisibilityDetector] 实测,允许多个) + Set _visibleVideoIndices = {}; + /// 用户在本轮可视周期内点过「停止」的索引;滑出视口后清除,再次进入可自动播放 + final Set _userPausedVideoIndices = {}; + final ScrollController _scrollController = ScrollController(); + /// [index] -> 最近一次 visibility 回调的 [VisibilityInfo.visibleFraction] + final Map _cardVisibleFraction = {}; + bool _visibilityReconcileScheduled = false; + + /// IndexedStack 切回首页或 [homePlaybackResumeNonce] 递增后,立即让 [VisibilityDetector] 重新计算 + void _scheduleVisibilityRefresh() { + if (!widget.isActive) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !widget.isActive) return; + VisibilityDetectorController.instance.notifyNow(); + }); + } @override void initState() { @@ -40,19 +58,27 @@ class _HomeScreenState extends State { UserState.extConfigItems.addListener(_onExtConfigChanged); AuthService.isLoginComplete.addListener(_onExtConfigChanged); UserState.homeReloadNonce.addListener(_onHomeReloadNonce); + homePlaybackResumeNonce.addListener(_onHomePlaybackResumeSignal); _loadCategories(); if (widget.isActive) refreshAccount(); } @override void dispose() { + homePlaybackResumeNonce.removeListener(_onHomePlaybackResumeSignal); UserState.needShowVideoMenu.removeListener(_onExtConfigChanged); UserState.extConfigItems.removeListener(_onExtConfigChanged); AuthService.isLoginComplete.removeListener(_onExtConfigChanged); UserState.homeReloadNonce.removeListener(_onHomeReloadNonce); + _scrollController.dispose(); super.dispose(); } + void _onHomePlaybackResumeSignal() { + if (!mounted || !widget.isActive) return; + _scheduleVisibilityRefresh(); + } + void _onExtConfigChanged() { if (mounted) setState(() {}); } @@ -62,11 +88,55 @@ class _HomeScreenState extends State { _loadCategories(); } + static const double _videoVisibilityThreshold = 0.15; + + void _onGridCardVisibilityChanged(int index, VisibilityInfo info) { + if (!mounted) return; + if (info.visibleFraction >= _videoVisibilityThreshold) { + _cardVisibleFraction[index] = info.visibleFraction; + } else { + _cardVisibleFraction.remove(index); + } + if (_visibilityReconcileScheduled) return; + _visibilityReconcileScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _visibilityReconcileScheduled = false; + if (mounted) _reconcileVisibleVideoIndicesFromDetector(); + }); + } + + void _reconcileVisibleVideoIndicesFromDetector() { + final tasks = _displayTasks; + final next = {}; + for (final e in _cardVisibleFraction.entries) { + final i = e.key; + if (i < 0 || i >= tasks.length) continue; + if (e.value < _videoVisibilityThreshold) continue; + final url = tasks[i].previewVideoUrl; + if (url != null && url.isNotEmpty) { + next.add(i); + } + } + _userPausedVideoIndices.removeWhere((i) => !next.contains(i)); + if (!_setsEqual(next, _visibleVideoIndices)) { + setState(() => _visibleVideoIndices = next); + } + } + + bool _setsEqual(Set a, Set b) { + if (a.length != b.length) return false; + for (final e in a) { + if (!b.contains(e)) return false; + } + return true; + } + @override void didUpdateWidget(covariant HomeScreen oldWidget) { super.didUpdateWidget(oldWidget); if (widget.isActive && !oldWidget.isActive) { refreshAccount(); + _scheduleVisibilityRefresh(); } } @@ -119,7 +189,8 @@ class _HomeScreenState extends State { templateName: e.title, title: e.title, previewImageUrl: e.image, - previewVideoUrl: null, + // 尝试从 detail 字段中提取视频 URL(如果有) + previewVideoUrl: null, // 暂时保持为 null,需要根据实际数据结构调整 taskType: e.title, ext: e.detail, credits480p: e.cost, @@ -128,6 +199,7 @@ class _HomeScreenState extends State { } Future _loadCategories() async { + print('HomeScreen: _loadCategories called'); setState(() => _categoriesLoading = true); await AuthService.loginComplete; if (!mounted) return; @@ -137,23 +209,29 @@ class _HomeScreenState extends State { final list = (res.data as List) .map((e) => CategoryItem.fromJson(e as Map)) .toList(); + print('HomeScreen: Categories loaded: ${list.length} items'); if (UserState.needShowVideoMenu.value == true) { list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null)); + print('HomeScreen: Added pets category'); } setState(() { _categories = list; _selectedCategory = list.isNotEmpty ? list.first : null; + print('HomeScreen: Selected category: ${_selectedCategory?.name}'); if (_selectedCategory != null) { if (_selectedCategory!.id == kExtCategoryId) { _tasks = []; _tasksLoading = false; + print('HomeScreen: Selected pets category, tasks cleared'); } else { + print('HomeScreen: Loading tasks for category: ${_selectedCategory!.id}'); _loadTasks(_selectedCategory!.id); } } }); } else { setState(() => _categories = []); + print('HomeScreen: Failed to load categories'); } setState(() => _categoriesLoading = false); } @@ -169,10 +247,17 @@ class _HomeScreenState extends State { .toList(); setState(() { _tasks = list; - _activeCardIndex = null; + _userPausedVideoIndices.clear(); + _visibleVideoIndices = {}; + _cardVisibleFraction.clear(); }); } else { - setState(() => _tasks = []); + setState(() { + _tasks = []; + _userPausedVideoIndices.clear(); + _visibleVideoIndices = {}; + _cardVisibleFraction.clear(); + }); } setState(() => _tasksLoading = false); } @@ -184,6 +269,9 @@ class _HomeScreenState extends State { setState(() { _tasks = []; _tasksLoading = false; + _userPausedVideoIndices.clear(); + _visibleVideoIndices = {}; + _cardVisibleFraction.clear(); }); } else { _loadTasks(c.id); @@ -240,6 +328,7 @@ class _HomeScreenState extends State { AppSpacing.screenPadding, AppSpacing.screenPaddingLarge, ), + controller: _scrollController, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, @@ -253,20 +342,31 @@ class _HomeScreenState extends State { final credits = task.credits480p != null ? task.credits480p.toString() : '50'; - return VideoCard( - imageUrl: - task.previewImageUrl ?? _placeholderImage, - videoUrl: task.previewVideoUrl, - credits: credits, - isActive: _activeCardIndex == index, - onPlayRequested: () => - setState(() => _activeCardIndex = index), - onStopRequested: () => - setState(() => _activeCardIndex = null), - onGenerateSimilar: () => - Navigator.of(context).pushNamed( - '/generate', - arguments: task, + final detectorKey = + 'home_card_${index}_${task.previewVideoUrl ?? ''}_${task.title}'; + return VisibilityDetector( + key: ValueKey(detectorKey), + onVisibilityChanged: (info) => + _onGridCardVisibilityChanged(index, info), + child: VideoCard( + key: ValueKey(detectorKey), + imageUrl: task.previewImageUrl ?? + _placeholderImage, + videoUrl: task.previewVideoUrl, + credits: credits, + isActive: + _visibleVideoIndices.contains(index) && + !_userPausedVideoIndices + .contains(index), + onPlayRequested: () => setState(() => + _userPausedVideoIndices.remove(index)), + onStopRequested: () => setState(() => + _userPausedVideoIndices.add(index)), + onGenerateSimilar: () => + Navigator.of(context).pushNamed( + '/generate', + arguments: task, + ), ), ); }, diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index 5a2a700..435f52e 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -1,13 +1,16 @@ +import 'dart:io' show File; + import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart' + show DefaultCacheManager, DownloadProgress, FileInfo; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; -/// Video card for home grid - 点击播放按钮可在卡片上播放视频 -/// 同时只能一个卡片处于播放状态 +/// Video card for home grid:在 [isActive] 为 true 时自动静音循环播放 [videoUrl]; +/// 父组件按视口控制多个卡片可同时处于播放状态。 class VideoCard extends StatefulWidget { const VideoCard({ super.key, @@ -34,6 +37,12 @@ class VideoCard extends StatefulWidget { class _VideoCardState extends State { VideoPlayerController? _controller; + /// 递增以作废过期的异步 [ _loadAndPlay ],避免多路初始化互相抢同一个 State + int _loadGen = 0; + /// 从网络拉取视频时显示底部细进度条(缓存命中则不会出现) + bool _showBottomProgress = false; + /// 0–1 为确定进度;null 为不确定(无 Content-Length 等) + double? _bottomProgressFraction; @override void initState() { @@ -71,67 +80,117 @@ class _VideoCardState extends State { _controller = null; } + void _clearBottomProgress() { + if (!_showBottomProgress && _bottomProgressFraction == null) return; + _showBottomProgress = false; + _bottomProgressFraction = null; + } + Future _loadAndPlay() async { if (widget.videoUrl == null || widget.videoUrl!.isEmpty) { widget.onStopRequested(); return; } + final gen = ++_loadGen; + if (_controller != null && _controller!.value.isInitialized) { - final needSeek = _controller!.value.position >= _controller!.value.duration && + if (!widget.isActive) return; + final needSeek = _controller!.value.position >= + _controller!.value.duration && _controller!.value.duration.inMilliseconds > 0; if (needSeek) { await _controller!.seekTo(Duration.zero); } + if (!mounted || gen != _loadGen || !widget.isActive) return; + _controller!.setVolume(0); + _controller!.setLooping(true); + _controller!.removeListener(_onVideoUpdate); _controller!.addListener(_onVideoUpdate); await _controller!.play(); - if (mounted) setState(() {}); + if (!mounted || gen != _loadGen) return; + if (!widget.isActive) { + _stop(); + return; + } + setState(() {}); return; } - setState(() {}); + if (_controller != null) { + _disposeController(); + } + if (!widget.isActive) return; + + if (mounted) { + setState(() { + _clearBottomProgress(); + }); + } try { - final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!); - if (!mounted || !widget.isActive) return; - _controller = VideoPlayerController.file(file); - await _controller!.initialize(); - _controller!.addListener(_onVideoUpdate); - if (mounted && widget.isActive) { - await _controller!.play(); - setState(() {}); + String? videoPath; + await for (final response in DefaultCacheManager().getFileStream( + widget.videoUrl!, + withProgress: true, + )) { + if (!mounted || gen != _loadGen || !widget.isActive) { + if (mounted) { + setState(_clearBottomProgress); + } + return; + } + if (response is DownloadProgress) { + setState(() { + _showBottomProgress = true; + _bottomProgressFraction = response.progress; + }); + } else if (response is FileInfo) { + videoPath = response.file.path; + } } + if (!mounted || gen != _loadGen || !widget.isActive) { + if (mounted) setState(_clearBottomProgress); + return; + } + if (videoPath == null || videoPath.isEmpty) { + throw StateError('Video file stream ended without FileInfo'); + } + setState(_clearBottomProgress); + + _controller = VideoPlayerController.file( + File(videoPath), + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), + ); + await _controller!.initialize(); + if (!mounted || gen != _loadGen || !widget.isActive) { + _disposeController(); + return; + } + _controller!.setVolume(0); + _controller!.setLooping(true); + _controller!.addListener(_onVideoUpdate); + await _controller!.play(); + if (!mounted || gen != _loadGen || !widget.isActive) { + _disposeController(); + return; + } + setState(() {}); } catch (e) { if (mounted) { _disposeController(); - setState(() {}); + setState(() { + _clearBottomProgress(); + }); widget.onStopRequested(); } } } void _onVideoUpdate() { - if (_controller != null && - _controller!.value.position >= _controller!.value.duration && - _controller!.value.duration.inMilliseconds > 0) { - _controller!.removeListener(_onVideoUpdate); - _controller!.seekTo(Duration.zero); - if (mounted) widget.onStopRequested(); - } + // 循环播放,不需要停止 } - void _onPlayButtonTap() { - if (widget.isActive) { - widget.onStopRequested(); - _stop(); - } else { - widget.onPlayRequested(); - } - } - - bool get _isPlaying => - widget.isActive && _controller != null && _controller!.value.isPlaying; - @override Widget build(BuildContext context) { final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized; @@ -160,7 +219,7 @@ class _VideoCardState extends State { if (showVideo) Positioned.fill( child: GestureDetector( - onTap: _onPlayButtonTap, + onTap: widget.onGenerateSimilar, behavior: HitTestBehavior.opaque, child: FittedBox( fit: BoxFit.cover, @@ -179,10 +238,7 @@ class _VideoCardState extends State { else Positioned.fill( child: GestureDetector( - onTap: widget.videoUrl != null && - widget.videoUrl!.isNotEmpty - ? _onPlayButtonTap - : null, + onTap: widget.onGenerateSimilar, behavior: HitTestBehavior.opaque, child: CachedNetworkImage( imageUrl: widget.imageUrl, @@ -232,29 +288,6 @@ class _VideoCardState extends State { ), ), ), - if (_isPlaying) - Positioned( - top: 8, - left: 8, - child: GestureDetector( - onTap: () { - widget.onStopRequested(); - _stop(); - }, - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: AppColors.overlayDark, - borderRadius: BorderRadius.circular(20), - ), - child: const Icon( - LucideIcons.x, - size: 16, - color: AppColors.surface, - ), - ), - ), - ), Positioned( bottom: 16, left: 12, @@ -296,6 +329,21 @@ class _VideoCardState extends State { ), ), ), + if (_showBottomProgress) + Positioned( + left: 0, + right: 0, + bottom: 0, + height: 2, + child: LinearProgressIndicator( + value: _bottomProgressFraction, + minHeight: 2, + backgroundColor: AppColors.surfaceAlt, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index a45231c..3c9c195 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: screen_secure: ^1.0.3 flutter_native_splash: ^2.4.7 android_id: ^0.5.1 + visibility_detector: ^0.4.0+2 dev_dependencies: flutter_test: