优化:卡片上实时播放视频预览
This commit is contained in:
parent
a83c76edab
commit
f5bb5ff346
@ -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"
|
||||||
|
|||||||
52
lib/app.dart
52
lib/app.dart
@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
lib/core/nav/app_route_observer.dart
Normal file
5
lib/core/nav/app_route_observer.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 用于 [RouteAware](例如首页从 push 的全屏页返回时刷新可见区域)
|
||||||
|
final RouteObserver<PageRoute<dynamic>> appRouteObserver =
|
||||||
|
RouteObserver<PageRoute<dynamic>>();
|
||||||
4
lib/features/home/home_playback_resume.dart
Normal file
4
lib/features/home/home_playback_resume.dart
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 子路由从 Navigator pop 回主导航后,通知首页刷新 [VisibilityDetector](见 [_MainScaffoldState.didPopNext])
|
||||||
|
final ValueNotifier<int> homePlaybackResumeNonce = ValueNotifier<int>(0);
|
||||||
@ -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,21 +342,32 @@ 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(
|
||||||
|
key: ValueKey(detectorKey),
|
||||||
|
onVisibilityChanged: (info) =>
|
||||||
|
_onGridCardVisibilityChanged(index, info),
|
||||||
|
child: VideoCard(
|
||||||
|
key: ValueKey(detectorKey),
|
||||||
|
imageUrl: task.previewImageUrl ??
|
||||||
|
_placeholderImage,
|
||||||
videoUrl: task.previewVideoUrl,
|
videoUrl: task.previewVideoUrl,
|
||||||
credits: credits,
|
credits: credits,
|
||||||
isActive: _activeCardIndex == index,
|
isActive:
|
||||||
onPlayRequested: () =>
|
_visibleVideoIndices.contains(index) &&
|
||||||
setState(() => _activeCardIndex = index),
|
!_userPausedVideoIndices
|
||||||
onStopRequested: () =>
|
.contains(index),
|
||||||
setState(() => _activeCardIndex = null),
|
onPlayRequested: () => setState(() =>
|
||||||
|
_userPausedVideoIndices.remove(index)),
|
||||||
|
onStopRequested: () => setState(() =>
|
||||||
|
_userPausedVideoIndices.add(index)),
|
||||||
onGenerateSimilar: () =>
|
onGenerateSimilar: () =>
|
||||||
Navigator.of(context).pushNamed(
|
Navigator.of(context).pushNamed(
|
||||||
'/generate',
|
'/generate',
|
||||||
arguments: task,
|
arguments: task,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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;
|
||||||
|
/// 0–1 为确定进度;null 为不确定(无 Content-Length 等)
|
||||||
|
double? _bottomProgressFraction;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -71,66 +80,116 @@ 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) {
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user