优化:卡片上实时播放视频预览
This commit is contained in:
parent
a83c76edab
commit
f5bb5ff346
@ -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"
|
||||
|
||||
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 '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<App> 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<App> 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<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
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: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<HomeScreen> {
|
||||
List<TaskItem> _tasks = [];
|
||||
bool _categoriesLoading = true;
|
||||
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
|
||||
void initState() {
|
||||
@ -40,19 +58,27 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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<HomeScreen> {
|
||||
_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
|
||||
void didUpdateWidget(covariant HomeScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isActive && !oldWidget.isActive) {
|
||||
refreshAccount();
|
||||
_scheduleVisibilityRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,7 +189,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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<HomeScreen> {
|
||||
}
|
||||
|
||||
Future<void> _loadCategories() async {
|
||||
print('HomeScreen: _loadCategories called');
|
||||
setState(() => _categoriesLoading = true);
|
||||
await AuthService.loginComplete;
|
||||
if (!mounted) return;
|
||||
@ -137,23 +209,29 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final list = (res.data as List)
|
||||
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
|
||||
.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<HomeScreen> {
|
||||
.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<HomeScreen> {
|
||||
setState(() {
|
||||
_tasks = [];
|
||||
_tasksLoading = false;
|
||||
_userPausedVideoIndices.clear();
|
||||
_visibleVideoIndices = {};
|
||||
_cardVisibleFraction.clear();
|
||||
});
|
||||
} else {
|
||||
_loadTasks(c.id);
|
||||
@ -240,6 +328,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
controller: _scrollController,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
@ -253,20 +342,31 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -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<VideoCard> {
|
||||
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<VideoCard> {
|
||||
_controller = null;
|
||||
}
|
||||
|
||||
void _clearBottomProgress() {
|
||||
if (!_showBottomProgress && _bottomProgressFraction == null) return;
|
||||
_showBottomProgress = false;
|
||||
_bottomProgressFraction = null;
|
||||
}
|
||||
|
||||
Future<void> _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<VideoCard> {
|
||||
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<VideoCard> {
|
||||
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<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(
|
||||
bottom: 16,
|
||||
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
|
||||
flutter_native_splash: ^2.4.7
|
||||
android_id: ^0.5.1
|
||||
visibility_detector: ^0.4.0+2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user