import 'dart:async'; import 'dart:math' show max; 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:google_fonts/google_fonts.dart'; import 'package:video_player/video_player.dart'; import '../../core/auth/auth_service.dart'; import '../../core/open_purchase_store.dart'; import '../../core/user/user_state.dart'; import '../../core/video_file_cache.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; import '../generate/generate_screen.dart'; /// 首页 Create Now 上方展示的预估积分:**480p 档**(与 [GenerateScreen] 选 480p 时一致)。 int _homeCostDisplay480p(ExtConfigItem? t) { if (t == null) return 0; if (t.cost480p != null && t.cost480p! > 0) return t.cost480p!; int effective720() { if (t.cost720p != null && t.cost720p! > 0) return t.cost720p!; if (t.cost > 0) return t.cost; return 0; } final h = effective720(); if (h <= 0) return 0; return max(1, (h / 2).round()); } /// 首页横向 [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 createState() => _HomeScreenState(); } class _HomeScreenState extends State { /// 展平后 [PageView] 的当前页下标(跨分类连续)。 int _selectedIndex = 0; final PageController _pageController = PageController(); final ScrollController _categoryTabScrollController = ScrollController(); /// 顶部分类 Tab 每项的 key,用于 [Scrollable.ensureVisible] 把选中项滚到中间。 final List _categoryTabItemKeys = []; /// 上次已尝试居中的分类下标,避免重复调度。 int _lastCenteredCategoryTabIndex = -1; int _lastCategoryTabBarCount = 0; List _visibleExtItems(ExtConfigData? ext) => ext?.items.where((e) => e.isUsableOnHome).toList() ?? []; /// 指定顶部分类下的模板列表(视频模式用 [VideoHomeSnapshot];否则为 ext.items)。 List _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( valueListenable: ExtConfigRuntime.data, builder: (context, ext, _) { return ValueListenableBuilder( valueListenable: VideoHomeRuntime.snapshot, builder: (context, video, _) { return ValueListenableBuilder( valueListenable: VideoHomeRuntime.selectedTabIndex, builder: (context, _, _) { return ValueListenableBuilder( 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: EdgeInsets.only( left: 16, right: 16, bottom: 16 + PencilTheme.mainTabBottomChromeReserve(context), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: 56, child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ ValueListenableBuilder( valueListenable: UserState.credits, builder: (_, credits, _) { return PencilGlassCreditsPill( amountText: credits.toStringAsFixed(2), onTap: () => openPurchaseStore(context), ); }, ), ], ), ), ValueListenableBuilder( 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( valueListenable: ExtConfigRuntime.data, builder: (context, ext, _) { return ValueListenableBuilder( valueListenable: VideoHomeRuntime.snapshot, builder: (context, video, _) { return ValueListenableBuilder( valueListenable: VideoHomeRuntime.selectedTabIndex, builder: (context, _, _) { return ValueListenableBuilder( 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( 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( valueListenable: ExtConfigRuntime.data, builder: (context, ext, _) { return ValueListenableBuilder( 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 = _homeCostDisplay480p(template); 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), shadows: PencilTheme.homeCreditsTextShadows, ), ), const SizedBox(height: 12), ], PencilCreateNowButton( onPressed: () { final t = template; if (t == null) return; final need = _homeCostDisplay480p(t); if (need > 0 && UserState.credits.value < need) { openPurchaseStore(context); return; } Navigator.of(context).push( MaterialPageRoute( 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; 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]/setPlayWhenReady(dead thread)。 static Future _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 _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(); final cacheKey = videoCacheKeyForUrl(playUrl); VideoPlayerController? controller; try { if (!_forceNetworkOnly) { final cached = await funymeeVideoCacheManager.getFileFromCache(cacheKey); if (cached != null && cached.validTill.isAfter(DateTime.now()) && await cached.file.exists() && await videoCachedFileLooksPlayable(cached.file)) { controller = VideoPlayerController.file(cached.file); } else if (cached != null && await cached.file.exists()) { await funymeeVideoCacheManager.removeFile(cacheKey); } } 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(funymeeVideoCacheManager.downloadFile(playUrl, key: cacheKey)); 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 cacheKey = videoCacheKeyForUrl(_playUrl); unawaited(funymeeVideoCacheManager.removeFile(cacheKey)); _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×ch;FittedBox.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), ), ), ), ); } }