优化:卡片上实时播放视频预览

This commit is contained in:
ivan 2026-03-29 17:53:18 +08:00
parent a83c76edab
commit f5bb5ff346
7 changed files with 284 additions and 88 deletions

View File

@ -22,7 +22,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.9.1" apply false 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" include ":app"

View File

@ -2,6 +2,7 @@ import 'package:facebook_app_events/facebook_app_events.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'core/auth/auth_service.dart'; import 'core/auth/auth_service.dart';
import 'core/nav/app_route_observer.dart';
import 'core/config/facebook_config.dart'; import 'core/config/facebook_config.dart';
import 'core/log/app_logger.dart'; import 'core/log/app_logger.dart';
import 'core/theme/app_colors.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/generate_video/generate_video_screen.dart';
import 'features/gallery/models/gallery_task_item.dart'; import 'features/gallery/models/gallery_task_item.dart';
import 'features/generate_video/generation_result_screen.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/home_screen.dart';
import 'features/home/models/task_item.dart'; import 'features/home/models/task_item.dart';
import 'features/profile/profile_screen.dart'; import 'features/profile/profile_screen.dart';
@ -75,6 +77,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
title: 'AI Video App', title: 'AI Video App',
theme: AppTheme.light, theme: AppTheme.light,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
navigatorObservers: [appRouteObserver],
initialRoute: '/', initialRoute: '/',
builder: (context, child) { builder: (context, child) {
return Stack( return Stack(
@ -141,7 +144,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
} }
class _MainScaffold extends StatelessWidget { class _MainScaffold extends StatefulWidget {
const _MainScaffold({ const _MainScaffold({
required this.currentTab, required this.currentTab,
required this.onTabSelected, required this.onTabSelected,
@ -150,20 +153,55 @@ class _MainScaffold extends StatelessWidget {
final NavTab currentTab; final NavTab currentTab;
final ValueChanged<NavTab> onTabSelected; final ValueChanged<NavTab> 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<dynamic>) {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: IndexedStack( body: IndexedStack(
index: currentTab.index, index: widget.currentTab.index,
children: [ children: [
HomeScreen(isActive: currentTab == NavTab.home), HomeScreen(isActive: widget.currentTab == NavTab.home),
GalleryScreen(isActive: currentTab == NavTab.gallery), GalleryScreen(isActive: widget.currentTab == NavTab.gallery),
ProfileScreen(isActive: currentTab == NavTab.profile), ProfileScreen(isActive: widget.currentTab == NavTab.profile),
], ],
), ),
bottomNavigationBar: BottomNavBar( bottomNavigationBar: BottomNavBar(
currentTab: currentTab, currentTab: widget.currentTab,
onTabSelected: onTabSelected, onTabSelected: widget.onTabSelected,
), ),
); );
} }

View File

@ -0,0 +1,5 @@
import 'package:flutter/material.dart';
/// [RouteAware] push
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
RouteObserver<PageRoute<dynamic>>();

View File

@ -0,0 +1,4 @@
import 'package:flutter/foundation.dart';
/// Navigator pop [VisibilityDetector] [_MainScaffoldState.didPopNext]
final ValueNotifier<int> homePlaybackResumeNonce = ValueNotifier<int>(0);

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:visibility_detector/visibility_detector.dart';
import '../../core/api/services/image_api.dart'; import '../../core/api/services/image_api.dart';
import '../../core/auth/auth_service.dart'; import '../../core/auth/auth_service.dart';
@ -10,6 +11,7 @@ import 'models/category_item.dart';
import 'models/ext_config_item.dart'; import 'models/ext_config_item.dart';
import 'models/task_item.dart'; import 'models/task_item.dart';
import 'widgets/home_tab_row.dart'; import 'widgets/home_tab_row.dart';
import 'home_playback_resume.dart';
import 'widgets/video_card.dart'; import 'widgets/video_card.dart';
/// pets id extConfig.items /// pets id extConfig.items
@ -31,7 +33,23 @@ class _HomeScreenState extends State<HomeScreen> {
List<TaskItem> _tasks = []; List<TaskItem> _tasks = [];
bool _categoriesLoading = true; bool _categoriesLoading = true;
bool _tasksLoading = false; bool _tasksLoading = false;
int? _activeCardIndex; /// [VisibilityDetector]
Set<int> _visibleVideoIndices = {};
///
final Set<int> _userPausedVideoIndices = {};
final ScrollController _scrollController = ScrollController();
/// [index] -> visibility [VisibilityInfo.visibleFraction]
final Map<int, double> _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 @override
void initState() { void initState() {
@ -40,19 +58,27 @@ class _HomeScreenState extends State<HomeScreen> {
UserState.extConfigItems.addListener(_onExtConfigChanged); UserState.extConfigItems.addListener(_onExtConfigChanged);
AuthService.isLoginComplete.addListener(_onExtConfigChanged); AuthService.isLoginComplete.addListener(_onExtConfigChanged);
UserState.homeReloadNonce.addListener(_onHomeReloadNonce); UserState.homeReloadNonce.addListener(_onHomeReloadNonce);
homePlaybackResumeNonce.addListener(_onHomePlaybackResumeSignal);
_loadCategories(); _loadCategories();
if (widget.isActive) refreshAccount(); if (widget.isActive) refreshAccount();
} }
@override @override
void dispose() { void dispose() {
homePlaybackResumeNonce.removeListener(_onHomePlaybackResumeSignal);
UserState.needShowVideoMenu.removeListener(_onExtConfigChanged); UserState.needShowVideoMenu.removeListener(_onExtConfigChanged);
UserState.extConfigItems.removeListener(_onExtConfigChanged); UserState.extConfigItems.removeListener(_onExtConfigChanged);
AuthService.isLoginComplete.removeListener(_onExtConfigChanged); AuthService.isLoginComplete.removeListener(_onExtConfigChanged);
UserState.homeReloadNonce.removeListener(_onHomeReloadNonce); UserState.homeReloadNonce.removeListener(_onHomeReloadNonce);
_scrollController.dispose();
super.dispose(); super.dispose();
} }
void _onHomePlaybackResumeSignal() {
if (!mounted || !widget.isActive) return;
_scheduleVisibilityRefresh();
}
void _onExtConfigChanged() { void _onExtConfigChanged() {
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
@ -62,11 +88,55 @@ class _HomeScreenState extends State<HomeScreen> {
_loadCategories(); _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 = <int>{};
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<int> a, Set<int> b) {
if (a.length != b.length) return false;
for (final e in a) {
if (!b.contains(e)) return false;
}
return true;
}
@override @override
void didUpdateWidget(covariant HomeScreen oldWidget) { void didUpdateWidget(covariant HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) { if (widget.isActive && !oldWidget.isActive) {
refreshAccount(); refreshAccount();
_scheduleVisibilityRefresh();
} }
} }
@ -119,7 +189,8 @@ class _HomeScreenState extends State<HomeScreen> {
templateName: e.title, templateName: e.title,
title: e.title, title: e.title,
previewImageUrl: e.image, previewImageUrl: e.image,
previewVideoUrl: null, // detail URL
previewVideoUrl: null, // null
taskType: e.title, taskType: e.title,
ext: e.detail, ext: e.detail,
credits480p: e.cost, credits480p: e.cost,
@ -128,6 +199,7 @@ class _HomeScreenState extends State<HomeScreen> {
} }
Future<void> _loadCategories() async { Future<void> _loadCategories() async {
print('HomeScreen: _loadCategories called');
setState(() => _categoriesLoading = true); setState(() => _categoriesLoading = true);
await AuthService.loginComplete; await AuthService.loginComplete;
if (!mounted) return; if (!mounted) return;
@ -137,23 +209,29 @@ class _HomeScreenState extends State<HomeScreen> {
final list = (res.data as List) final list = (res.data as List)
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>)) .map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
print('HomeScreen: Categories loaded: ${list.length} items');
if (UserState.needShowVideoMenu.value == true) { if (UserState.needShowVideoMenu.value == true) {
list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null)); list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null));
print('HomeScreen: Added pets category');
} }
setState(() { setState(() {
_categories = list; _categories = list;
_selectedCategory = list.isNotEmpty ? list.first : null; _selectedCategory = list.isNotEmpty ? list.first : null;
print('HomeScreen: Selected category: ${_selectedCategory?.name}');
if (_selectedCategory != null) { if (_selectedCategory != null) {
if (_selectedCategory!.id == kExtCategoryId) { if (_selectedCategory!.id == kExtCategoryId) {
_tasks = []; _tasks = [];
_tasksLoading = false; _tasksLoading = false;
print('HomeScreen: Selected pets category, tasks cleared');
} else { } else {
print('HomeScreen: Loading tasks for category: ${_selectedCategory!.id}');
_loadTasks(_selectedCategory!.id); _loadTasks(_selectedCategory!.id);
} }
} }
}); });
} else { } else {
setState(() => _categories = []); setState(() => _categories = []);
print('HomeScreen: Failed to load categories');
} }
setState(() => _categoriesLoading = false); setState(() => _categoriesLoading = false);
} }
@ -169,10 +247,17 @@ class _HomeScreenState extends State<HomeScreen> {
.toList(); .toList();
setState(() { setState(() {
_tasks = list; _tasks = list;
_activeCardIndex = null; _userPausedVideoIndices.clear();
_visibleVideoIndices = {};
_cardVisibleFraction.clear();
}); });
} else { } else {
setState(() => _tasks = []); setState(() {
_tasks = [];
_userPausedVideoIndices.clear();
_visibleVideoIndices = {};
_cardVisibleFraction.clear();
});
} }
setState(() => _tasksLoading = false); setState(() => _tasksLoading = false);
} }
@ -184,6 +269,9 @@ class _HomeScreenState extends State<HomeScreen> {
setState(() { setState(() {
_tasks = []; _tasks = [];
_tasksLoading = false; _tasksLoading = false;
_userPausedVideoIndices.clear();
_visibleVideoIndices = {};
_cardVisibleFraction.clear();
}); });
} else { } else {
_loadTasks(c.id); _loadTasks(c.id);
@ -240,6 +328,7 @@ class _HomeScreenState extends State<HomeScreen> {
AppSpacing.screenPadding, AppSpacing.screenPadding,
AppSpacing.screenPaddingLarge, AppSpacing.screenPaddingLarge,
), ),
controller: _scrollController,
gridDelegate: gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount( const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
@ -253,20 +342,31 @@ class _HomeScreenState extends State<HomeScreen> {
final credits = task.credits480p != null final credits = task.credits480p != null
? task.credits480p.toString() ? task.credits480p.toString()
: '50'; : '50';
return VideoCard( final detectorKey =
imageUrl: 'home_card_${index}_${task.previewVideoUrl ?? ''}_${task.title}';
task.previewImageUrl ?? _placeholderImage, return VisibilityDetector(
videoUrl: task.previewVideoUrl, key: ValueKey(detectorKey),
credits: credits, onVisibilityChanged: (info) =>
isActive: _activeCardIndex == index, _onGridCardVisibilityChanged(index, info),
onPlayRequested: () => child: VideoCard(
setState(() => _activeCardIndex = index), key: ValueKey(detectorKey),
onStopRequested: () => imageUrl: task.previewImageUrl ??
setState(() => _activeCardIndex = null), _placeholderImage,
onGenerateSimilar: () => videoUrl: task.previewVideoUrl,
Navigator.of(context).pushNamed( credits: credits,
'/generate', isActive:
arguments: task, _visibleVideoIndices.contains(index) &&
!_userPausedVideoIndices
.contains(index),
onPlayRequested: () => setState(() =>
_userPausedVideoIndices.remove(index)),
onStopRequested: () => setState(() =>
_userPausedVideoIndices.add(index)),
onGenerateSimilar: () =>
Navigator.of(context).pushNamed(
'/generate',
arguments: task,
),
), ),
); );
}, },

View File

@ -1,13 +1,16 @@
import 'dart:io' show File;
import 'package:flutter/material.dart'; 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:flutter_lucide/flutter_lucide.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
/// Video card for home grid - /// Video card for home grid [isActive] true [videoUrl]
/// ///
class VideoCard extends StatefulWidget { class VideoCard extends StatefulWidget {
const VideoCard({ const VideoCard({
super.key, super.key,
@ -34,6 +37,12 @@ class VideoCard extends StatefulWidget {
class _VideoCardState extends State<VideoCard> { class _VideoCardState extends State<VideoCard> {
VideoPlayerController? _controller; VideoPlayerController? _controller;
/// [ _loadAndPlay ] State
int _loadGen = 0;
///
bool _showBottomProgress = false;
/// 01 null Content-Length
double? _bottomProgressFraction;
@override @override
void initState() { void initState() {
@ -71,67 +80,117 @@ class _VideoCardState extends State<VideoCard> {
_controller = null; _controller = null;
} }
void _clearBottomProgress() {
if (!_showBottomProgress && _bottomProgressFraction == null) return;
_showBottomProgress = false;
_bottomProgressFraction = null;
}
Future<void> _loadAndPlay() async { Future<void> _loadAndPlay() async {
if (widget.videoUrl == null || widget.videoUrl!.isEmpty) { if (widget.videoUrl == null || widget.videoUrl!.isEmpty) {
widget.onStopRequested(); widget.onStopRequested();
return; return;
} }
final gen = ++_loadGen;
if (_controller != null && _controller!.value.isInitialized) { 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; _controller!.value.duration.inMilliseconds > 0;
if (needSeek) { if (needSeek) {
await _controller!.seekTo(Duration.zero); await _controller!.seekTo(Duration.zero);
} }
if (!mounted || gen != _loadGen || !widget.isActive) return;
_controller!.setVolume(0);
_controller!.setLooping(true);
_controller!.removeListener(_onVideoUpdate);
_controller!.addListener(_onVideoUpdate); _controller!.addListener(_onVideoUpdate);
await _controller!.play(); await _controller!.play();
if (mounted) setState(() {}); if (!mounted || gen != _loadGen) return;
if (!widget.isActive) {
_stop();
return;
}
setState(() {});
return; return;
} }
setState(() {}); if (_controller != null) {
_disposeController();
}
if (!widget.isActive) return;
if (mounted) {
setState(() {
_clearBottomProgress();
});
}
try { try {
final file = await DefaultCacheManager().getSingleFile(widget.videoUrl!); String? videoPath;
if (!mounted || !widget.isActive) return; await for (final response in DefaultCacheManager().getFileStream(
_controller = VideoPlayerController.file(file); widget.videoUrl!,
await _controller!.initialize(); withProgress: true,
_controller!.addListener(_onVideoUpdate); )) {
if (mounted && widget.isActive) { if (!mounted || gen != _loadGen || !widget.isActive) {
await _controller!.play(); if (mounted) {
setState(() {}); 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) { } catch (e) {
if (mounted) { if (mounted) {
_disposeController(); _disposeController();
setState(() {}); setState(() {
_clearBottomProgress();
});
widget.onStopRequested(); widget.onStopRequested();
} }
} }
} }
void _onVideoUpdate() { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized; final showVideo = widget.isActive && _controller != null && _controller!.value.isInitialized;
@ -160,7 +219,7 @@ class _VideoCardState extends State<VideoCard> {
if (showVideo) if (showVideo)
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: _onPlayButtonTap, onTap: widget.onGenerateSimilar,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: FittedBox( child: FittedBox(
fit: BoxFit.cover, fit: BoxFit.cover,
@ -179,10 +238,7 @@ class _VideoCardState extends State<VideoCard> {
else else
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: widget.videoUrl != null && onTap: widget.onGenerateSimilar,
widget.videoUrl!.isNotEmpty
? _onPlayButtonTap
: null,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: widget.imageUrl, imageUrl: widget.imageUrl,
@ -232,29 +288,6 @@ class _VideoCardState extends State<VideoCard> {
), ),
), ),
), ),
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( Positioned(
bottom: 16, bottom: 16,
left: 12, left: 12,
@ -296,6 +329,21 @@ class _VideoCardState extends State<VideoCard> {
), ),
), ),
), ),
if (_showBottomProgress)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 2,
child: LinearProgressIndicator(
value: _bottomProgressFraction,
minHeight: 2,
backgroundColor: AppColors.surfaceAlt,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary,
),
),
),
], ],
), ),
), ),

View File

@ -35,6 +35,7 @@ dependencies:
screen_secure: ^1.0.3 screen_secure: ^1.0.3
flutter_native_splash: ^2.4.7 flutter_native_splash: ^2.4.7
android_id: ^0.5.1 android_id: ^0.5.1
visibility_detector: ^0.4.0+2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: