1182 lines
44 KiB
Dart
1182 lines
44 KiB
Dart
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]/setPlayWhenReady(dead 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×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),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|