FunyMeeAI/lib/features/home/home_screen.dart

1182 lines
44 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:client_proxy_framework/client_proxy_framework.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:video_player/video_player.dart';
import '../../core/auth/auth_service.dart';
import '../../core/user/user_state.dart';
import '../../design/pencil_theme.dart';
import '../../widgets/pencil_chrome.dart';
import '../generate/generate_screen.dart';
import '../history/history_screen.dart';
import '../profile/profile_screen.dart';
/// 首页横向 [PageView] 的一页:对应某个顶部分类 [tabIndex][item] 为空表示该分类暂无模板占位。
class _FlatHomePage {
const _FlatHomePage({required this.tabIndex, this.item});
final int tabIndex;
final ExtConfigItem? item;
}
/// `bi8Au` FunyMee Home — 底层 `assets/images/home_background.png`,模板图为全屏 [PageView] 叠在其上。
/// - `go_run`[ExtConfigData.showVideoMenu])为 `true` 时:顶栏为**分类** Tab多分类时用**单层** [PageView]
/// 将各分类模板**展平**为连续页,横滑可连贯切换分类(整页动画),避免嵌套横向 PageView 的手势冲突。
/// - 非视频模式:[ExtConfigData.items] 每一项对应一页。
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
/// 展平后 [PageView] 的当前页下标(跨分类连续)。
int _selectedIndex = 0;
final PageController _pageController = PageController();
final ScrollController _categoryTabScrollController = ScrollController();
/// 顶部分类 Tab 每项的 key用于 [Scrollable.ensureVisible] 把选中项滚到中间。
final List<GlobalKey> _categoryTabItemKeys = [];
/// 上次已尝试居中的分类下标,避免重复调度。
int _lastCenteredCategoryTabIndex = -1;
int _lastCategoryTabBarCount = 0;
List<ExtConfigItem> _visibleExtItems(ExtConfigData? ext) =>
ext?.items.where((e) => e.title.trim().isNotEmpty).toList() ?? [];
/// 指定顶部分类下的模板列表(视频模式用 [VideoHomeSnapshot];否则为 ext.items
List<ExtConfigItem> _templateItemsForTab(
ExtConfigData? ext,
VideoHomeSnapshot video,
int tabIndex,
) {
final base = _visibleExtItems(ext);
if (ext?.showVideoMenu != true) return base;
if (video.tabs.isEmpty) return base;
final ti = tabIndex.clamp(0, video.tabs.length - 1);
final tab = video.tabs[ti];
if (tab.isImages) return base;
final id = tab.categoryId!;
return video.networkItemsByCategoryId[id] ?? [];
}
/// 展平为单层 [PageView] 的页序列:多分类时连续滑动即可跨分类;空分类占一页占位。
List<_FlatHomePage> _buildFlatPages(
ExtConfigData? ext,
VideoHomeSnapshot video,
) {
final base = _visibleExtItems(ext);
if (ext?.showVideoMenu != true || video.tabs.isEmpty) {
if (base.isEmpty) return [];
return [for (final item in base) _FlatHomePage(tabIndex: 0, item: item)];
}
final out = <_FlatHomePage>[];
for (var t = 0; t < video.tabs.length; t++) {
final items = _templateItemsForTab(ext, video, t);
if (items.isEmpty) {
out.add(_FlatHomePage(tabIndex: t, item: null));
} else {
for (final item in items) {
out.add(_FlatHomePage(tabIndex: t, item: item));
}
}
}
return out;
}
static int _firstFlatIndexForTab(List<_FlatHomePage> flat, int tabIndex) {
for (var i = 0; i < flat.length; i++) {
if (flat[i].tabIndex == tabIndex) return i;
}
return 0;
}
bool _currentNetworkTabLoading(VideoHomeSnapshot video) {
if (video.tabs.isEmpty) return false;
final ti = VideoHomeRuntime.selectedTabIndex.value
.clamp(0, video.tabs.length - 1);
final tab = video.tabs[ti];
if (tab.isImages) return false;
final id = tab.categoryId!;
return video.loadingCategoryIds.contains(id) &&
!video.networkItemsByCategoryId.containsKey(id);
}
/// `go_run` / `need_wait` → [ExtConfigData.showVideoMenu];仅在为 `true` 时展示顶部分类 Tab 栏。
bool _showTopTabBar(ExtConfigData? ext) => ext?.showVideoMenu == true;
void _ensureCategoryTabKeys(int count) {
while (_categoryTabItemKeys.length < count) {
_categoryTabItemKeys.add(GlobalKey());
}
while (_categoryTabItemKeys.length > count) {
_categoryTabItemKeys.removeLast();
}
}
/// 将指定下标的顶部分类 Tab 尽量滚到横向列表中间(与 PageView 切换分类联动)。
void _scrollCategoryTabToCenter(int index) {
if (index < 0 || index >= _categoryTabItemKeys.length) return;
final ctx = _categoryTabItemKeys[index].currentContext;
if (ctx == null) return;
Scrollable.ensureVisible(
ctx,
alignment: 0.5,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
/// 视频模式顶部分类 Tab与中间区内容独立无任务时也保持显示
Widget _buildCategoryTabRow(VideoHomeSnapshot video) {
final catIdx = video.tabs.isEmpty
? 0
: VideoHomeRuntime.selectedTabIndex.value
.clamp(0, video.tabs.length - 1);
if (video.tabs.length != _lastCategoryTabBarCount) {
_lastCategoryTabBarCount = video.tabs.length;
_lastCenteredCategoryTabIndex = -1;
}
_ensureCategoryTabKeys(video.tabs.length);
if (_lastCenteredCategoryTabIndex != catIdx) {
_lastCenteredCategoryTabIndex = catIdx;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_scrollCategoryTabToCenter(catIdx);
});
}
return Padding(
padding: const EdgeInsets.only(top: 6, bottom: 12),
child: SingleChildScrollView(
controller: _categoryTabScrollController,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < video.tabs.length; i++) ...[
if (i > 0) ...[
Text(
'|',
style: GoogleFonts.inter(
fontSize: 14,
color: PencilTheme.homeTabDivider,
),
),
const SizedBox(width: 14),
],
GestureDetector(
key: _categoryTabItemKeys[i],
behavior: HitTestBehavior.opaque,
onTap: () => _onCategoryTabTap(i),
child: Text(
video.tabs[i].label,
style: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w700,
color: i == catIdx
? PencilTheme.underlineGold
: PencilTheme.homeTextPrimary,
),
),
),
if (i < video.tabs.length - 1) const SizedBox(width: 14),
],
],
),
),
);
}
void _clampFlatPage(int flatLength) {
if (flatLength == 0) return;
if (_selectedIndex >= flatLength) {
final next = flatLength - 1;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() => _selectedIndex = next);
if (_pageController.hasClients) {
_pageController.jumpToPage(next);
}
});
}
}
void _onFlatPageChanged(int i, List<_FlatHomePage> flat) {
if (i < 0 || i >= flat.length) return;
final t = flat[i].tabIndex;
if (VideoHomeRuntime.selectedTabIndex.value != t) {
VideoHomeRuntime.selectedTabIndex.value = t;
unawaited(VideoHomeRuntime.ensureTabItems(t));
}
setState(() => _selectedIndex = i);
}
void _onCategoryTabTap(int tabIndex) {
if (VideoHomeRuntime.selectedTabIndex.value == tabIndex) return;
final ext = ExtConfigRuntime.data.value;
final video = VideoHomeRuntime.snapshot.value;
final flat = _buildFlatPages(ext, video);
if (flat.isEmpty) return;
final idx =
_firstFlatIndexForTab(flat, tabIndex).clamp(0, flat.length - 1);
VideoHomeRuntime.selectedTabIndex.value = tabIndex;
unawaited(VideoHomeRuntime.ensureTabItems(tabIndex));
setState(() => _selectedIndex = idx);
if (_pageController.hasClients) {
_pageController.animateToPage(
idx,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
);
}
}
@override
void initState() {
super.initState();
}
@override
void dispose() {
_pageController.dispose();
_categoryTabScrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: Image.asset(
'assets/images/home_background.png',
fit: BoxFit.cover,
errorBuilder: (_, _, _) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFF2A2318),
Color(0xFF141210),
],
),
),
);
},
),
),
Positioned.fill(
child: ValueListenableBuilder<ExtConfigData?>(
valueListenable: ExtConfigRuntime.data,
builder: (context, ext, _) {
return ValueListenableBuilder<VideoHomeSnapshot>(
valueListenable: VideoHomeRuntime.snapshot,
builder: (context, video, _) {
return ValueListenableBuilder<int>(
valueListenable: VideoHomeRuntime.selectedTabIndex,
builder: (context, _, _) {
return ValueListenableBuilder<bool?>(
valueListenable:
ExtConfigRuntime.commonInfoSucceeded,
builder: (context, ok, _) {
final flat = _buildFlatPages(ext, video);
_clampFlatPage(flat.length);
if (ok == null) {
return const SizedBox.shrink();
}
final videoTabs =
ext?.showVideoMenu == true &&
video.tabs.isNotEmpty;
if (flat.isEmpty) {
if (videoTabs) {
return const ColoredBox(
color: Colors.transparent,
child: SizedBox.expand(),
);
}
return const SizedBox.shrink();
}
return PageView.builder(
controller: _pageController,
itemCount: flat.length,
onPageChanged: (i) {
final e = ExtConfigRuntime.data.value;
final v = VideoHomeRuntime.snapshot.value;
_onFlatPageChanged(i, _buildFlatPages(e, v));
},
itemBuilder: (context, i) {
final fp = flat[i];
if (fp.item == null) {
return const ColoredBox(
color: Colors.transparent,
child: SizedBox.expand(),
);
}
return AnimatedBuilder(
animation: _pageController,
builder: (context, _) {
final page = _pageController.hasClients
? (_pageController.page ??
_selectedIndex.toDouble())
: _selectedIndex.toDouble();
final videoActive = page.round() == i;
return _HomeItemPageContent(
item: fp.item!,
videoActive: videoActive,
);
},
);
},
);
},
);
},
);
},
);
},
),
),
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 34,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 56,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
PencilGlassSquareButton(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const HistoryScreen(),
),
);
},
child: const Icon(Icons.history_rounded,
color: Colors.white, size: 22),
),
Row(
children: [
ValueListenableBuilder<int>(
valueListenable: UserState.credits,
builder: (_, credits, _) {
return PencilGlassCreditsPill(
amountText: credits.toStringAsFixed(2),
);
},
),
const SizedBox(width: 10),
PencilGlassSquareButton(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const ProfileScreen(),
),
);
},
child: const Icon(Icons.settings_rounded,
color: Colors.white, size: 22),
),
],
),
],
),
),
ValueListenableBuilder<bool?>(
valueListenable: ExtConfigRuntime.commonInfoSucceeded,
builder: (context, ok, _) {
if (ok != false) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.orangeAccent.withValues(alpha: 0.6),
),
),
child: Row(
children: [
Icon(Icons.cloud_off_rounded,
color: Colors.orange.shade200, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Config failed to load. Some features may be unavailable. Check the network and restart the app.',
style: GoogleFonts.inter(
fontSize: 12,
height: 1.35,
color: Colors.white.withValues(alpha: 0.92),
),
),
),
],
),
),
);
},
),
Expanded(
child: ValueListenableBuilder<ExtConfigData?>(
valueListenable: ExtConfigRuntime.data,
builder: (context, ext, _) {
return ValueListenableBuilder<VideoHomeSnapshot>(
valueListenable: VideoHomeRuntime.snapshot,
builder: (context, video, _) {
return ValueListenableBuilder<int>(
valueListenable:
VideoHomeRuntime.selectedTabIndex,
builder: (context, _, _) {
return ValueListenableBuilder<bool?>(
valueListenable:
ExtConfigRuntime.commonInfoSucceeded,
builder: (context, ok, _) {
final flat = _buildFlatPages(ext, video);
_clampFlatPage(flat.length);
final showTabs = _showTopTabBar(ext);
final showCategoryTabs =
showTabs && video.tabs.isNotEmpty;
if (ok == null) {
return ValueListenableBuilder<bool>(
valueListenable: AuthService.isLoginComplete,
builder: (context, loginDone, _) {
// [App] startup overlay already shows a spinner; hide
// this duplicate until the overlay is dismissed.
if (!loginDone) {
return const SizedBox.shrink();
}
return Center(
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: PencilTheme.homeTextPrimary,
),
),
const SizedBox(width: 10),
Text(
'Loading config…',
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: PencilTheme.homeTextPrimary,
),
),
],
),
);
},
);
}
if (flat.isEmpty) {
Widget loadingRow(String text) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: PencilTheme.homeTextPrimary,
),
),
const SizedBox(width: 10),
Text(
text,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: PencilTheme.homeTextPrimary,
),
),
],
);
}
if (ok == true &&
ext?.showVideoMenu == true &&
video.loading) {
if (showCategoryTabs) {
return Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
_buildCategoryTabRow(video),
Expanded(
child: IgnorePointer(
child: Center(
child: loadingRow(
'Loading video categories…',
),
),
),
),
],
);
}
return Center(
child: loadingRow(
'Loading video categories…',
),
);
}
if (ok == true &&
ext?.showVideoMenu == true &&
showCategoryTabs &&
_currentNetworkTabLoading(video)) {
return Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
_buildCategoryTabRow(video),
Expanded(
child: IgnorePointer(
child: Center(
child: loadingRow(
'Loading category templates…',
),
),
),
),
],
);
}
if (showCategoryTabs) {
return Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
_buildCategoryTabRow(video),
Expanded(
child: IgnorePointer(
child: Center(
child: Text(
ok == false
? 'No templates (config not ready)'
: 'No templates',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: PencilTheme.homeTextPrimary,
),
),
),
),
),
],
);
}
return Center(
child: Text(
ok == false
? 'No templates (config not ready)'
: 'No templates',
textAlign: TextAlign.center,
style: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: PencilTheme.homeTextPrimary,
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showCategoryTabs)
_buildCategoryTabRow(video),
Expanded(
child: IgnorePointer(
child: SizedBox.expand(),
),
),
],
);
},
);
},
);
},
);
},
),
),
Center(
child: ValueListenableBuilder<ExtConfigData?>(
valueListenable: ExtConfigRuntime.data,
builder: (context, ext, _) {
return ValueListenableBuilder<VideoHomeSnapshot>(
valueListenable: VideoHomeRuntime.snapshot,
builder: (context, video, _) {
return AnimatedBuilder(
animation: _pageController,
builder: (context, _) {
final flat = _buildFlatPages(ext, video);
final safe = flat.isEmpty
? 0
: (_pageController.hasClients
? _pageController.page
?.round() ??
_selectedIndex
: _selectedIndex)
.clamp(0, flat.length - 1);
final template = flat.isEmpty
? null
: flat[safe].item;
final cost = template?.cost ?? 0;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (cost > 0) ...[
Text(
'$cost credits',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: PencilTheme.homeTextPrimary
.withValues(alpha: 0.85),
),
),
const SizedBox(height: 12),
],
PencilCreateNowButton(
onPressed: () {
final t = template;
if (t == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => GenerateScreen(
template: t,
),
),
);
},
),
],
);
},
);
},
);
},
),
),
],
),
),
),
],
),
);
}
}
/// 全屏循环播放 [ExtConfigItem.videoUrl](或 `image` 为视频 URL失败则回退为静态图。
///
/// [isActive] 为 false 时释放 [VideoPlayerController],仅保留封面,避免 PageView 多路解码卡死。
class _HomeItemVideoBackground extends StatefulWidget {
const _HomeItemVideoBackground({
required this.item,
this.isActive = true,
});
final ExtConfigItem item;
/// 当前项是否在 [PageView] 可视页(与 [_pageController.page] 对齐)。
final bool isActive;
@override
State<_HomeItemVideoBackground> createState() =>
_HomeItemVideoBackgroundState();
}
class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
VideoPlayerController? _controller;
bool _failed = false;
/// 含 ExoPlayer 错误416、UnrecognizedInputFormat、坏缓存等时的自动重试次数。
static const int _maxOpenRetries = 6;
int _openRetries = 0;
/// 上一轮已判定磁盘缓存不可播:跳过后续 [getFileFromCache],避免反复读坏文件卡住。
bool _forceNetworkOnly = false;
Timer? _retryTimer;
bool _recovering = false;
/// 封面解码完成监听(先封面后视频,避免长时间空白)。
ImageStream? _coverImageStream;
ImageStreamListener? _coverImageListener;
static final CacheManager _videoCacheManager = DefaultCacheManager();
String get _playUrl {
final v = widget.item.videoUrl?.trim();
if (v != null && v.isNotEmpty) return v;
return widget.item.image.trim();
}
@override
void initState() {
super.initState();
if (widget.isActive) {
_preloadCoverThenStartVideo();
}
}
@override
void didUpdateWidget(_HomeItemVideoBackground oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
if (!widget.isActive) {
_retryTimer?.cancel();
_retryTimer = null;
_recovering = false;
_openRetries = 0;
_removeCoverListener();
_disposePlayback();
if (mounted) setState(() {});
return;
}
_failed = false;
_forceNetworkOnly = false;
_preloadCoverThenStartVideo();
return;
}
final oldUrl = _videoUrlForItem(oldWidget.item);
final newUrl = _videoUrlForItem(widget.item);
if (oldUrl != newUrl) {
_openRetries = 0;
_failed = false;
_recovering = false;
_forceNetworkOnly = false;
_removeCoverListener();
_disposePlayback();
if (widget.isActive) {
_preloadCoverThenStartVideo();
}
}
}
static String _videoUrlForItem(ExtConfigItem item) {
final v = item.videoUrl?.trim();
if (v != null && v.isNotEmpty) return v;
return item.image.trim();
}
/// 与 [_HomeItemPageContent] 一致:主图 + 兜底图。
static String _coverUrlForItem(ExtConfigItem item) {
final u = item.image.trim();
if (u.isNotEmpty) return u;
return item.imageFix?.trim() ?? '';
}
void _removeCoverListener() {
final stream = _coverImageStream;
final listener = _coverImageListener;
if (stream != null && listener != null) {
stream.removeListener(listener);
}
_coverImageStream = null;
_coverImageListener = null;
}
void _preloadCoverThenStartVideo() {
if (!widget.isActive) return;
_removeCoverListener();
final coverUrl = _coverUrlForItem(widget.item);
if (coverUrl.isEmpty) {
unawaited(_startPlaybackAsync());
return;
}
final u = Uri.tryParse(coverUrl);
if (u == null || !(u.isScheme('http') || u.isScheme('https'))) {
unawaited(_startPlaybackAsync());
return;
}
final provider = CachedNetworkImageProvider(coverUrl);
final stream = provider.resolve(const ImageConfiguration());
_coverImageStream = stream;
final listener = ImageStreamListener(
(ImageInfo image, bool synchronousCall) {
_removeCoverListener();
unawaited(_startPlaybackAsync());
},
onError: (Object exception, StackTrace? stackTrace) {
_removeCoverListener();
unawaited(_startPlaybackAsync());
},
);
_coverImageListener = listener;
stream.addListener(listener);
}
/// 先 pause 再 dispose避免 ExoPlayer 已释放后仍收到 [pause]/setPlayWhenReadydead thread
static Future<void> _pauseThenDispose(VideoPlayerController c) async {
try {
await c.pause();
} catch (_) {}
try {
await c.dispose();
} catch (_) {}
}
void _disposePlayback() {
_retryTimer?.cancel();
_retryTimer = null;
final c = _controller;
if (c != null) {
c.removeListener(_onVideoValueChanged);
_controller = null;
unawaited(_pauseThenDispose(c));
}
}
/// 磁盘已有有效缓存则 [VideoPlayerController.file];否则网络流式播放。
/// 仅在 [initialize] + [play] 成功后再 [downloadFile],避免并行下载把 HTML/错包写入缓存导致 UnrecognizedInputFormat。
Future<void> _startPlaybackAsync() async {
if (!widget.isActive) return;
final playUrl = _playUrl;
final uri = Uri.tryParse(playUrl);
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) {
if (mounted) setState(() => _failed = true);
return;
}
_disposePlayback();
VideoPlayerController? controller;
try {
if (!_forceNetworkOnly) {
final cached = await _videoCacheManager.getFileFromCache(playUrl);
if (cached != null &&
cached.validTill.isAfter(DateTime.now()) &&
await cached.file.exists()) {
controller = VideoPlayerController.file(cached.file);
}
}
controller ??= VideoPlayerController.networkUrl(uri);
} catch (_) {
controller = VideoPlayerController.networkUrl(uri);
}
if (!mounted || playUrl != _playUrl || !widget.isActive) {
unawaited(_pauseThenDispose(controller));
return;
}
controller.addListener(_onVideoValueChanged);
_controller = controller;
try {
await controller.initialize().timeout(
const Duration(seconds: 20),
onTimeout: () => throw TimeoutException('video init', const Duration(seconds: 20)),
);
} catch (_) {
if (mounted) _scheduleRecoverFromError();
return;
}
if (!mounted ||
_controller != controller ||
playUrl != _playUrl ||
!widget.isActive) return;
if (controller.value.hasError) {
if (mounted) _scheduleRecoverFromError();
return;
}
_openRetries = 0;
controller.setLooping(true);
try {
await controller.play();
} catch (_) {
if (mounted) _scheduleRecoverFromError();
return;
}
if (!mounted ||
_controller != controller ||
playUrl != _playUrl ||
!widget.isActive) return;
if (controller.value.hasError) {
if (mounted) _scheduleRecoverFromError();
return;
}
_forceNetworkOnly = false;
unawaited(_videoCacheManager.downloadFile(playUrl));
setState(() {});
}
void _onVideoValueChanged() {
final c = _controller;
if (c == null || !mounted || _recovering) return;
if (!c.value.hasError) return;
c.removeListener(_onVideoValueChanged);
_scheduleRecoverFromError();
}
void _scheduleRecoverFromError() {
if (!mounted || _failed || !widget.isActive) return;
if (_recovering) return;
if (_openRetries >= _maxOpenRetries) {
setState(() => _failed = true);
_disposePlayback();
return;
}
_recovering = true;
_openRetries += 1;
final url = _playUrl;
unawaited(_videoCacheManager.removeFile(url));
_forceNetworkOnly = true;
_disposePlayback();
setState(() {});
_retryTimer?.cancel();
_retryTimer = Timer(const Duration(milliseconds: 220), () {
_retryTimer = null;
if (!mounted) return;
_recovering = false;
unawaited(_startPlaybackAsync());
});
}
@override
void dispose() {
_retryTimer?.cancel();
_removeCoverListener();
_disposePlayback();
super.dispose();
}
Widget _coverPlaceholder() {
return ColoredBox(
color: Colors.white.withValues(alpha: 0.12),
child: Center(
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2,
color: PencilTheme.homeTextPrimary.withValues(alpha: 0.7),
),
),
),
);
}
Widget _buildCoverLayer() {
final coverUrl = _coverUrlForItem(widget.item);
if (coverUrl.isEmpty) return _coverPlaceholder();
final u = Uri.tryParse(coverUrl);
if (u == null || !(u.isScheme('http') || u.isScheme('https'))) {
return _coverPlaceholder();
}
return CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
placeholder: (_, _) => _coverPlaceholder(),
errorWidget: (_, _, _) => _coverPlaceholder(),
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
);
}
@override
Widget build(BuildContext context) {
if (!widget.isActive) {
return SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: _buildCoverLayer()),
],
),
);
}
if (_failed) {
return _HomeItemPageContent(item: widget.item, forceImage: true);
}
final c = _controller;
return SizedBox.expand(
child: LayoutBuilder(
builder: (context, constraints) {
final cw = constraints.maxWidth;
final ch = constraints.maxHeight;
if (c != null && c.value.isInitialized) {
final w = c.value.size.width;
final h = c.value.size.height;
if (w > 0 &&
h > 0 &&
cw.isFinite &&
ch.isFinite &&
cw > 0 &&
ch > 0) {
// 视口固定为 cw×chFittedBox.cover 铺满裁切,避免 Transform.scale 只改绘制不改布局导致「画中画」。
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: _buildCoverLayer()),
Positioned.fill(
child: ClipRect(
child: SizedBox(
width: cw,
height: ch,
child: FittedBox(
fit: BoxFit.cover,
alignment: Alignment.center,
clipBehavior: Clip.none,
child: SizedBox(
width: w,
height: h,
child: VideoPlayer(c),
),
),
),
),
),
],
);
}
}
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: _buildCoverLayer()),
],
);
},
),
);
}
}
/// 单个 extConfig item全屏铺满与底层背景同尺寸无圆角。
class _HomeItemPageContent extends StatelessWidget {
const _HomeItemPageContent({
super.key,
required this.item,
this.forceImage = false,
this.videoActive = true,
});
final ExtConfigItem item;
/// 为 true 时跳过视频播放(用于视频解码失败回退为封面图)。
final bool forceImage;
/// 为 false 时不在 [PageView] 当前可视页,视频应释放解码器。
final bool videoActive;
@override
Widget build(BuildContext context) {
if (!forceImage && item.isVideoItem) {
return _HomeItemVideoBackground(
item: item,
isActive: videoActive,
);
}
final imageUrl = item.image.trim();
final fixUrl = item.imageFix?.trim();
if (imageUrl.isNotEmpty) {
return SizedBox.expand(
child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (_, _) => Container(
color: Colors.white.withValues(alpha: 0.12),
alignment: Alignment.center,
child: SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2,
color:
PencilTheme.homeTextPrimary.withValues(alpha: 0.7),
),
),
),
errorWidget: (_, _, _) {
if (fixUrl != null && fixUrl.isNotEmpty) {
return CachedNetworkImage(
imageUrl: fixUrl,
fit: BoxFit.cover,
errorWidget: (_, _, _) => _placeholder(),
);
}
return _placeholder();
},
),
);
}
if (fixUrl != null && fixUrl.isNotEmpty) {
return SizedBox.expand(
child: CachedNetworkImage(
imageUrl: fixUrl,
fit: BoxFit.cover,
errorWidget: (_, _, _) => _placeholder(),
),
);
}
return _placeholder();
}
Widget _placeholder() {
return SizedBox.expand(
child: ColoredBox(
color: Colors.white.withValues(alpha: 0.1),
child: Center(
child: Icon(
Icons.image_outlined,
size: 48,
color: PencilTheme.homeTextPrimary.withValues(alpha: 0.45),
),
),
),
);
}
}