merge: 将远程 main 同步到 develop
No files matched your search
@@ -36,6 +36,9 @@ class MainActivity : FlutterActivity() {
|
||||
private var imeInset = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
splashScreen.setOnExitAnimationListener { screen -> screen.remove() }
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_screen" />
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -1,4 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_screen" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,6 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_screen" />
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -1,6 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_screen" />
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -1,6 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
<item>
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@drawable/splash_screen" />
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -8,6 +8,8 @@
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#000000</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_brand</item>
|
||||
<item name="android:windowSplashScreenAnimationDuration">0</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#000000</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_brand</item>
|
||||
<item name="android:windowSplashScreenAnimationDuration">0</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
|
||||
@@ -2,6 +2,8 @@ import Flutter
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey"
|
||||
|
||||
private final class ThumbnailResponseGate {
|
||||
private let lock = NSLock()
|
||||
private var completed = false
|
||||
@@ -29,7 +31,7 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor(red: 16.0 / 255.0, green: 12.0 / 255.0, blue: 10.0 / 255.0, alpha: 1)
|
||||
view.backgroundColor = .black
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +106,7 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
|
||||
let options: [String: Any]? = headers.isEmpty
|
||||
? nil
|
||||
: [AVURLAssetHTTPHeaderFieldsKey: headers]
|
||||
: [thumbnailHTTPHeaderFieldsKey: headers]
|
||||
let asset = AVURLAsset(url: assetUrl, options: options)
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
|
||||
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 24 KiB |
@@ -16,18 +16,13 @@
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"/>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color key="backgroundColor" red="0" green="0" blue="0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
|
||||
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
@@ -38,7 +33,6 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="LaunchBackground" width="1" height="1"/>
|
||||
<image name="LaunchImage" width="390" height="844"/>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -48,7 +48,7 @@
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>192.168.31.187</key>
|
||||
<key>10.43.47.1</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
|
||||
@@ -14,7 +14,7 @@ class AppConfig {
|
||||
/// 开发调试默认服务地址,当前指向本机局域网 IP。
|
||||
///
|
||||
/// 换网络时只改这一行,地址不要带结尾斜杠。
|
||||
static const String devHostBaseUrl = 'http://192.168.31.187:8081';
|
||||
static const String devHostBaseUrl = 'http://10.43.47.1:8081';
|
||||
|
||||
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
|
||||
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
|
||||
|
||||
@@ -122,6 +122,8 @@ abstract interface class SpriteThumbnailAxisSource implements Listenable {
|
||||
SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position);
|
||||
|
||||
bool hasAxis(String? thumbnailsUrl);
|
||||
|
||||
Future<void> prepareAt(String? thumbnailsUrl, Duration position);
|
||||
}
|
||||
|
||||
/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。
|
||||
@@ -153,6 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
|
||||
List<String> get spriteCacheKeys => _spriteCache.keys.toList();
|
||||
|
||||
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
|
||||
@override
|
||||
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
|
||||
final url = _normalizeUrl(thumbnailsUrl);
|
||||
if (_disposed || url == null) return;
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'package:flutter/material.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
const double _regularIconSize = 48;
|
||||
const double _compactIconSize = 32;
|
||||
const double _regularIconSize = 72;
|
||||
const double _compactIconSize = 44;
|
||||
const double _maxContentWidth = 260;
|
||||
const double _regularHorizontal = 24;
|
||||
const double _regularVertical = 28;
|
||||
@@ -78,7 +78,7 @@ class EmptyState extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 空状态只留一枚图标,不加水印底和描边。
|
||||
/// 空列表只放一枚大图标,不画线框,也不铺底色。
|
||||
class _EmptyMark extends StatelessWidget {
|
||||
const _EmptyMark({required this.icon, required this.compact});
|
||||
|
||||
@@ -91,7 +91,7 @@ class _EmptyMark extends StatelessWidget {
|
||||
icon,
|
||||
key: const Key('empty-state-icon'),
|
||||
size: compact ? _compactIconSize : _regularIconSize,
|
||||
color: AppColors.blueSoft,
|
||||
color: AppColors.subtle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,34 +31,45 @@ class RemoteImage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = AppConfig.mediaUrl(path);
|
||||
if (url.isEmpty) return _fallback();
|
||||
if (url.isEmpty) return _fallback(brand: showFallbackIcon);
|
||||
return Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
errorBuilder: (_, _, _) => _fallback(),
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return _fallback();
|
||||
return _fallback(brand: showFallbackIcon);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _fallback() {
|
||||
Widget _fallback({required bool brand}) {
|
||||
final mark = brand
|
||||
? LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: 120.0;
|
||||
return Center(
|
||||
child: Image.asset(
|
||||
'assets/icon/splash_logo.png',
|
||||
key: fallbackIconKey,
|
||||
width: width * 0.72,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
final fallback = Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: fallbackColor,
|
||||
color: brand ? AppColors.mediaBlack : fallbackColor,
|
||||
alignment: Alignment.center,
|
||||
child: showFallbackIcon
|
||||
? Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
key: fallbackIconKey,
|
||||
color: AppColors.muted,
|
||||
size: 32,
|
||||
)
|
||||
: null,
|
||||
child: mark,
|
||||
);
|
||||
final ratio = fallbackAspectRatio;
|
||||
if (ratio == null) return fallback;
|
||||
|
||||
@@ -669,7 +669,7 @@
|
||||
"homeGridClearFilter": "Hapus filter",
|
||||
"recommendTopTab": "Rekomendasi",
|
||||
"playlistTopTab": "Daftar Putar",
|
||||
"allDramasTopTab": "Semua Drama",
|
||||
"allDramasTopTab": "Semua",
|
||||
"weeklyHitsTitle": "Hits Minggu Ini",
|
||||
"weeklyHitsSubtitle": "Drama terpopuler minggu ini, tonton sekaligus!",
|
||||
"weeklyNewTitle": "Drama Baru Minggu Ini",
|
||||
|
||||
@@ -1610,7 +1610,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get playlistTopTab => 'Daftar Putar';
|
||||
|
||||
@override
|
||||
String get allDramasTopTab => 'Semua Drama';
|
||||
String get allDramasTopTab => 'Semua';
|
||||
|
||||
@override
|
||||
String get weeklyHitsTitle => 'Hits Minggu Ini';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
@@ -9,10 +11,10 @@ import 'core/config/server_endpoint.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Future.wait([
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
||||
ServerEndpoint.load(),
|
||||
]);
|
||||
await ServerEndpoint.load();
|
||||
AppConfig.ready = AppConfig.probeAndApplyBaseUrl();
|
||||
runApp(const LumaDramaApp());
|
||||
unawaited(
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
||||
);
|
||||
}
|
||||
@@ -40,9 +40,9 @@ const double _buttonRadius = 25;
|
||||
const double _errorOpacity = 0.10;
|
||||
const double _errorBorderOpacity = 0.28;
|
||||
const double _enterSlideDistance = 16;
|
||||
const double _topContentPadding = 64;
|
||||
const double _topContentPadding = 56;
|
||||
const double _bottomContentPadding = 28;
|
||||
const double _compactTopContentPadding = 48;
|
||||
const double _compactTopContentPadding = 40;
|
||||
const double _compactHeightBreakpoint = 720;
|
||||
const double _compactBrandGap = 16;
|
||||
const double _glowSize = 280;
|
||||
@@ -159,6 +159,19 @@ class _AuthPageState extends State<AuthPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点在当前邮箱或密码框之外时取消焦点。
|
||||
void _releaseFieldFocus(PointerDownEvent event) {
|
||||
final focus = FocusManager.instance.primaryFocus;
|
||||
if (focus != _emailFocus && focus != _passwordFocus) return;
|
||||
final renderObject = focus?.context?.findRenderObject();
|
||||
if (renderObject is! RenderBox || !renderObject.hasSize) {
|
||||
focus?.unfocus();
|
||||
return;
|
||||
}
|
||||
final bounds = renderObject.localToGlobal(Offset.zero) & renderObject.size;
|
||||
if (!bounds.contains(event.position)) focus?.unfocus();
|
||||
}
|
||||
|
||||
void _scheduleRevealFocusedField() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _revealFocusedField();
|
||||
@@ -325,26 +338,31 @@ class _AuthPageState extends State<AuthPage> {
|
||||
effectiveKeyboardInset(context, _keyboardInset);
|
||||
final available = constraints.maxHeight - top - bottom;
|
||||
final minHeight = available > 0 ? available : 0.0;
|
||||
return AutofillGroup(
|
||||
child: SingleChildScrollView(
|
||||
key: const Key('auth-scroll'),
|
||||
controller: _scrollController,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppDimens.pageHorizontal,
|
||||
top,
|
||||
AppDimens.pageHorizontal,
|
||||
bottom,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: _contentMaxWidth,
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: _releaseFieldFocus,
|
||||
child: AutofillGroup(
|
||||
child: SingleChildScrollView(
|
||||
key: const Key('auth-scroll'),
|
||||
controller: _scrollController,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppDimens.pageHorizontal,
|
||||
top,
|
||||
AppDimens.pageHorizontal,
|
||||
bottom,
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: _contentMaxWidth,
|
||||
),
|
||||
child: _content(l10n, compact: compact),
|
||||
),
|
||||
child: _content(l10n, compact: compact),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -410,7 +428,7 @@ class _AuthPageState extends State<AuthPage> {
|
||||
|
||||
Widget _landing(AppLocalizations l10n) => Column(
|
||||
key: const Key('auth-landing'),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
|
||||
@@ -35,9 +35,6 @@ class _SearchPageState extends State<SearchPage> {
|
||||
super.initState();
|
||||
_logic = DiscoverBinding().create(search: context.read<SearchRepository>());
|
||||
_logic.addListener(_rebuild);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _logic.searchFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void _rebuild() {
|
||||
@@ -59,21 +56,27 @@ class _SearchPageState extends State<SearchPage> {
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Scaffold(
|
||||
key: const Key('search-page'),
|
||||
backgroundColor: AppColors.appBackground,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_header(l10n),
|
||||
Expanded(
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('search-refresh'),
|
||||
onRefresh: _logic.refreshSearch,
|
||||
child: _body(l10n),
|
||||
child: MediaQuery.removeViewInsets(
|
||||
context: context,
|
||||
removeBottom: true,
|
||||
child: Scaffold(
|
||||
key: const Key('search-page'),
|
||||
backgroundColor: AppColors.appBackground,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_header(l10n),
|
||||
Expanded(
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('search-refresh'),
|
||||
onRefresh: _logic.refreshSearch,
|
||||
child: _body(l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -157,6 +160,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
scrollPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
if (_logic.searchLoading)
|
||||
|
||||
@@ -30,9 +30,6 @@ import 'home_logic.dart';
|
||||
import 'widgets/home_feed_grid.dart';
|
||||
import 'widgets/home_grid_header.dart';
|
||||
|
||||
const Duration _startupSplashMinimumDuration = Duration(milliseconds: 400);
|
||||
const Duration _startupSplashFadeDuration = Duration(milliseconds: 200);
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@@ -44,31 +41,15 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
bool _resumeRetryInFlight = false;
|
||||
HomeFeedController? _homeFeed;
|
||||
bool _recommendSeeded = false;
|
||||
Timer? _startupSplashMinimumTimer;
|
||||
bool _catalogSettled = false;
|
||||
bool _startupSplashVisible = true;
|
||||
bool _startupSplashFadingOut = false;
|
||||
bool _startupSplashMinimumElapsed = false;
|
||||
bool _startupSplashDismissing = false;
|
||||
bool _startupSplashSettledTriggered = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_startupSplashMinimumTimer = Timer(_startupSplashMinimumDuration, () {
|
||||
if (!mounted) return;
|
||||
_startupSplashMinimumElapsed = true;
|
||||
unawaited(_dismissStartupSplashIfReady());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_startupSplashMinimumTimer?.cancel();
|
||||
_homeFeed?.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
@@ -102,43 +83,12 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final catalog = context.watch<CatalogController>();
|
||||
_catalogSettled = catalog.settled;
|
||||
if (_startupSplashVisible &&
|
||||
_catalogSettled &&
|
||||
!_startupSplashSettledTriggered) {
|
||||
_startupSplashSettledTriggered = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_dismissStartupSplashIfReady());
|
||||
});
|
||||
}
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildHome(context, catalog),
|
||||
if (_startupSplashVisible)
|
||||
_StartupSplashOverlay(fadingOut: _startupSplashFadingOut),
|
||||
],
|
||||
),
|
||||
child: _buildHome(context, catalog),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _dismissStartupSplashIfReady() async {
|
||||
if (!mounted ||
|
||||
!_startupSplashVisible ||
|
||||
_startupSplashDismissing ||
|
||||
!_startupSplashMinimumElapsed ||
|
||||
!_catalogSettled) {
|
||||
return;
|
||||
}
|
||||
_startupSplashDismissing = true;
|
||||
setState(() => _startupSplashFadingOut = true);
|
||||
await Future<void>.delayed(_startupSplashFadeDuration);
|
||||
if (!mounted) return;
|
||||
setState(() => _startupSplashVisible = false);
|
||||
}
|
||||
|
||||
Widget _buildHome(BuildContext context, CatalogController catalog) {
|
||||
final homeFeed = _ensureHomeFeed(catalog);
|
||||
if (!_recommendSeeded && catalog.dramas.isNotEmpty) {
|
||||
@@ -162,35 +112,6 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupSplashOverlay extends StatelessWidget {
|
||||
const _StartupSplashOverlay({required this.fadingOut});
|
||||
|
||||
final bool fadingOut;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
key: const Key('startup-splash'),
|
||||
opacity: fadingOut ? 0 : 1,
|
||||
duration: _startupSplashFadeDuration,
|
||||
curve: Curves.easeOut,
|
||||
child: ColoredBox(
|
||||
color: AppColors.appBackground,
|
||||
child: Center(
|
||||
child: Image.asset(
|
||||
'assets/icon/splash_logo.png',
|
||||
key: const Key('startup-splash-logo'),
|
||||
width: 240,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 四个根页面共享同一路由并保持各自状态。
|
||||
class _HomeTabs extends StatefulWidget {
|
||||
const _HomeTabs({super.key, required this.feedController});
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../../core/data/services/home_feed_controller.dart';
|
||||
import '../../../core/data/services/wallet_controller.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/app_pull_to_refresh.dart';
|
||||
import '../../../core/widgets/drama_grid_skeleton.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
@@ -81,17 +82,16 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
final auth = context.watch<AuthController>();
|
||||
final wallet = context.watch<WalletController>();
|
||||
final shortcuts = _categoryShortcuts(categories, l10n);
|
||||
final tags = _availableTags(l10n);
|
||||
final activeTag =
|
||||
tags.any((tag) => tag.id == _selectedTags[_selectedPrimary])
|
||||
? _selectedTags[_selectedPrimary]
|
||||
: null;
|
||||
final walletBadge = auth.isSignedIn && wallet.snapshot != null
|
||||
? wallet.balance
|
||||
: null;
|
||||
return ColoredBox(
|
||||
final ranking = _selectedPrimary == HomePrimaryTab.ranking;
|
||||
return DecoratedBox(
|
||||
key: const Key('home-grid'),
|
||||
color: AppColors.appBackground,
|
||||
decoration: BoxDecoration(
|
||||
color: ranking ? null : AppColors.appBackground,
|
||||
gradient: ranking ? AppTheme.pageGradient : null,
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
@@ -100,12 +100,8 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
HomeGridHeader(
|
||||
callbacks: widget.callbacks,
|
||||
categories: shortcuts,
|
||||
tags: tags,
|
||||
selectedPrimary: _selectedPrimary,
|
||||
selectedTag: activeTag,
|
||||
onPrimarySelected: _selectPrimary,
|
||||
onTagSelected: (tag) =>
|
||||
setState(() => _selectedTags[_selectedPrimary] = tag),
|
||||
walletBadge: walletBadge,
|
||||
),
|
||||
Expanded(
|
||||
@@ -322,17 +318,6 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<HomeGridTagOption> _availableTags(AppLocalizations l10n) {
|
||||
// UI 先完整展示筛选维度;后续接口补齐题材字段时只替换数据来源。
|
||||
return [
|
||||
HomeGridTagOption(id: 'hot', label: l10n.homeTagHot),
|
||||
HomeGridTagOption(id: 'uplifting', label: l10n.homeTagUplifting),
|
||||
HomeGridTagOption(id: 'sweet', label: l10n.homeTagSweet),
|
||||
HomeGridTagOption(id: 'faceSlap', label: l10n.homeTagFaceSlap),
|
||||
HomeGridTagOption(id: 'angst', label: l10n.homeTagAngst),
|
||||
];
|
||||
}
|
||||
|
||||
List<Drama> _dramasFor(
|
||||
HomePrimaryTab tab,
|
||||
String? tag,
|
||||
|
||||
@@ -28,13 +28,6 @@ class HomeGridCategoryShortcut {
|
||||
final HomePrimaryTab tab;
|
||||
}
|
||||
|
||||
class HomeGridTagOption {
|
||||
const HomeGridTagOption({required this.id, required this.label});
|
||||
|
||||
final String id;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class HomeGridCallbacks {
|
||||
const HomeGridCallbacks({
|
||||
required this.onSearch,
|
||||
@@ -54,21 +47,15 @@ class HomeGridHeader extends StatelessWidget {
|
||||
super.key,
|
||||
required this.callbacks,
|
||||
required this.categories,
|
||||
required this.tags,
|
||||
required this.selectedPrimary,
|
||||
required this.selectedTag,
|
||||
required this.onPrimarySelected,
|
||||
required this.onTagSelected,
|
||||
this.walletBadge,
|
||||
});
|
||||
|
||||
final HomeGridCallbacks callbacks;
|
||||
final List<HomeGridCategoryShortcut> categories;
|
||||
final List<HomeGridTagOption> tags;
|
||||
final HomePrimaryTab selectedPrimary;
|
||||
final String? selectedTag;
|
||||
final ValueChanged<HomePrimaryTab> onPrimarySelected;
|
||||
final ValueChanged<String?> onTagSelected;
|
||||
final int? walletBadge;
|
||||
|
||||
@override
|
||||
@@ -81,10 +68,7 @@ class HomeGridHeader extends StatelessWidget {
|
||||
children: [
|
||||
_searchRow(l10n),
|
||||
_primaryRow(l10n),
|
||||
if (selectedPrimary == HomePrimaryTab.featured)
|
||||
_tagRow(l10n)
|
||||
else
|
||||
const SizedBox(height: AppDimens.homeSecondaryGridGap),
|
||||
const SizedBox(height: AppDimens.homeSecondaryGridGap),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -323,119 +307,4 @@ class HomeGridHeader extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagRow(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppDimens.feedPageHorizontal,
|
||||
AppDimens.homePrimarySecondaryGap,
|
||||
AppDimens.feedPageHorizontal,
|
||||
AppDimens.homeSecondaryGridGap,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
key: const Key('home-secondary-tags'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
for (final tag in tags) ...[
|
||||
_tagChip(tag),
|
||||
const SizedBox(width: AppDimens.homeSecondaryTagGap),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDimens.homeSecondaryFilterGap),
|
||||
Tooltip(
|
||||
message: l10n.filter,
|
||||
child: SizedBox.square(
|
||||
dimension: AppDimens.homeSecondaryFilterVisualSize,
|
||||
child: Material(
|
||||
color: AppColors.transparent,
|
||||
child: InkWell(
|
||||
key: const Key('home-grid-filter'),
|
||||
onTap: callbacks.onOpenFilter,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
key: const Key('home-secondary-filter-visual'),
|
||||
dimension: AppDimens.homeSecondaryFilterVisualSize,
|
||||
child: const Center(
|
||||
child: CustomPaint(
|
||||
key: Key('home-secondary-filter-icon'),
|
||||
size: Size.square(AppDimens.homeSecondaryFilterIcon),
|
||||
painter: _ThreeLineFilterPainter(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagChip(HomeGridTagOption tag) {
|
||||
final selected = selectedTag == tag.id;
|
||||
final label = Text(
|
||||
tag.label,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.navSelected : AppColors.textTertiary,
|
||||
fontSize: AppDimens.homeSecondaryTagFont,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
);
|
||||
return InkWell(
|
||||
key: Key('home-tag-${tag.id}'),
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
onTap: () => onTagSelected(selected ? null : tag.id),
|
||||
child: AnimatedContainer(
|
||||
key: Key('home-tag-animated-${tag.id}'),
|
||||
duration: AppDimens.homeSecondaryTagAnimationDuration,
|
||||
curve: Curves.easeOut,
|
||||
height: AppDimens.homeSecondaryTagHeight,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.homeSecondaryTagHorizontalPadding,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.contentSurface : AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.homeSecondaryTagRadius),
|
||||
),
|
||||
child: label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThreeLineFilterPainter extends CustomPainter {
|
||||
const _ThreeLineFilterPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = AppColors.textFilterIcon
|
||||
..strokeWidth = AppDimens.homeSecondaryFilterStroke
|
||||
..strokeCap = StrokeCap.butt;
|
||||
for (final y in [
|
||||
size.height * 0.25,
|
||||
size.height * 0.5,
|
||||
size.height * 0.75,
|
||||
]) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ThreeLineFilterPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import 'player_logic.dart';
|
||||
import 'widgets/home_feed_header.dart';
|
||||
import 'widgets/home_feed_overlay.dart';
|
||||
import 'widgets/exit_clear_screen_button.dart';
|
||||
import 'widgets/feed_pull_refresh.dart';
|
||||
import 'widgets/player_center_toggle.dart';
|
||||
import 'widgets/player_more_sheet.dart';
|
||||
import 'widgets/player_overlay.dart';
|
||||
@@ -49,6 +50,7 @@ part 'player_view_actions.dart';
|
||||
const double _boostPlaybackSpeed = 2.0;
|
||||
const double _playerLoadingSize = 24;
|
||||
const double _playerLoadingStrokeWidth = 2;
|
||||
const double _feedRefreshTopGap = 56;
|
||||
|
||||
class PlayerPage extends StatefulWidget {
|
||||
const PlayerPage({
|
||||
@@ -305,6 +307,16 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
unawaited(logic.setPlaybackSpeed(speed));
|
||||
}
|
||||
|
||||
Future<void> _refreshHomeFeed() async {
|
||||
final refresh = widget.onRefreshFeed;
|
||||
if (refresh == null) return;
|
||||
final ok = await refresh();
|
||||
if (!mounted || ok) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)),
|
||||
);
|
||||
}
|
||||
|
||||
void _setScrubbing(bool value) {
|
||||
if (!mounted) return;
|
||||
if (_scrubbing == value) return;
|
||||
@@ -404,26 +416,37 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final interactions = context.watch<InteractionController>();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final indices = logic.visibleIndices;
|
||||
final pages = logic.pages;
|
||||
final content = AnimatedBuilder(
|
||||
animation: logic.session,
|
||||
builder: (context, _) => PageView.builder(
|
||||
controller: pages,
|
||||
scrollDirection: Axis.vertical,
|
||||
allowImplicitScrolling: true,
|
||||
itemCount: indices.length,
|
||||
onPageChanged: (position) {
|
||||
final index = indices[position];
|
||||
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||
unawaited(logic.selectEpisode(index));
|
||||
if (position >= indices.length - 3) {
|
||||
final loadMore = widget.onLoadMore;
|
||||
if (loadMore != null) unawaited(loadMore());
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, position) =>
|
||||
_buildEpisode(context, indices[position], interactions),
|
||||
),
|
||||
builder: (context, _) {
|
||||
final pages = PageView.builder(
|
||||
controller: logic.pages,
|
||||
scrollDirection: Axis.vertical,
|
||||
allowImplicitScrolling: true,
|
||||
physics: widget.onRefreshFeed == null
|
||||
? null
|
||||
: const FeedRefreshPhysics(),
|
||||
itemCount: indices.length,
|
||||
onPageChanged: (position) {
|
||||
final index = indices[position];
|
||||
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||
unawaited(logic.selectEpisode(index));
|
||||
if (position >= indices.length - 3) {
|
||||
final loadMore = widget.onLoadMore;
|
||||
if (loadMore != null) unawaited(loadMore());
|
||||
}
|
||||
},
|
||||
itemBuilder: (context, position) =>
|
||||
_buildEpisode(context, indices[position], interactions),
|
||||
);
|
||||
if (widget.onRefreshFeed == null) return pages;
|
||||
return FeedPullRefresh(
|
||||
pageController: logic.pages,
|
||||
topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap,
|
||||
onRefresh: _refreshHomeFeed,
|
||||
child: pages,
|
||||
);
|
||||
},
|
||||
);
|
||||
final body = Stack(
|
||||
fit: StackFit.expand,
|
||||
@@ -504,9 +527,17 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
|
||||
? logic.sessionEpisodes[index]
|
||||
: entry.episode;
|
||||
final episode = widget.homeMode
|
||||
final displayed = widget.homeMode
|
||||
? logic.displayEpisode(drama, sessionEpisode)
|
||||
: sessionEpisode;
|
||||
final episode =
|
||||
(displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) &&
|
||||
sessionEpisode.thumbnailsUrl != null
|
||||
? displayed.withVideoUrl(
|
||||
displayed.videoUrl,
|
||||
thumbnailsUrl: sessionEpisode.thumbnailsUrl,
|
||||
)
|
||||
: displayed;
|
||||
final selected = index == logic.session.currentIndex;
|
||||
// Selection changes before the prepared slot is promoted. Use its controller
|
||||
// throughout the handoff instead of briefly falling back to a placeholder.
|
||||
@@ -702,6 +733,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
thumbnailService: _thumbnailService,
|
||||
spriteAxisService: _spriteAxisService,
|
||||
),
|
||||
scrubbing: _scrubbing,
|
||||
actions: PlayerOverlayActions(
|
||||
onBack: widget.embedded
|
||||
? null
|
||||
@@ -719,7 +751,13 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onSave: () => unawaited(_toggleSave(context, drama)),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onMore: () => _openMore(context, episode),
|
||||
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||
onScrubStart: ready
|
||||
? () {
|
||||
_setScrubbing(true);
|
||||
logic.beginScrub(videoPort!);
|
||||
}
|
||||
: null,
|
||||
onScrubEnd: () => _setScrubbing(false),
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
|
||||
const double _maxPull = 96;
|
||||
const double _refreshThreshold = 56;
|
||||
const double _firstPageEpsilon = 0.01;
|
||||
|
||||
/// 第一条已经到顶时,向下的拖动不再翻页。
|
||||
class FeedRefreshPhysics extends PageScrollPhysics {
|
||||
const FeedRefreshPhysics({super.parent});
|
||||
|
||||
@override
|
||||
FeedRefreshPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return FeedRefreshPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
@override
|
||||
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||
if (position.pixels <= position.minScrollExtent && offset > 0) {
|
||||
return 0;
|
||||
}
|
||||
return super.applyPhysicsToUserOffset(position, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
Simulation? createBallisticSimulation(
|
||||
ScrollMetrics position,
|
||||
double velocity,
|
||||
) {
|
||||
if (position.pixels <= position.minScrollExtent && velocity < 0) {
|
||||
return super.createBallisticSimulation(position, 0);
|
||||
}
|
||||
return super.createBallisticSimulation(position, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
/// 竖滑到第一条后继续下拉时刷新,其它页仍交给翻页。
|
||||
class FeedPullRefresh extends StatefulWidget {
|
||||
const FeedPullRefresh({
|
||||
super.key,
|
||||
required this.pageController,
|
||||
required this.onRefresh,
|
||||
required this.child,
|
||||
this.topInset = 0,
|
||||
});
|
||||
|
||||
final PageController pageController;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Widget child;
|
||||
final double topInset;
|
||||
|
||||
@override
|
||||
State<FeedPullRefresh> createState() => _FeedPullRefreshState();
|
||||
}
|
||||
|
||||
class _FeedPullRefreshState extends State<FeedPullRefresh> {
|
||||
Offset? _origin;
|
||||
double _pull = 0;
|
||||
bool _tracking = false;
|
||||
bool _refreshing = false;
|
||||
|
||||
bool _atFirstPage() {
|
||||
final controller = widget.pageController;
|
||||
if (!controller.hasClients) return false;
|
||||
return (controller.page ?? 0) <= _firstPageEpsilon;
|
||||
}
|
||||
|
||||
void _setPull(double value) {
|
||||
final next = value.clamp(0.0, _maxPull);
|
||||
if (next == _pull) return;
|
||||
setState(() => _pull = next);
|
||||
}
|
||||
|
||||
void _onPointerDown(PointerDownEvent event) {
|
||||
if (_refreshing || !_atFirstPage()) {
|
||||
_tracking = false;
|
||||
return;
|
||||
}
|
||||
_origin = event.position;
|
||||
_tracking = true;
|
||||
}
|
||||
|
||||
void _onPointerMove(PointerMoveEvent event) {
|
||||
final origin = _origin;
|
||||
if (!_tracking || origin == null || _refreshing) return;
|
||||
final delta = event.position - origin;
|
||||
if (delta.dx.abs() > delta.dy.abs() && delta.dy < _refreshThreshold) {
|
||||
_tracking = false;
|
||||
_setPull(0);
|
||||
return;
|
||||
}
|
||||
if (delta.dy > 0) _holdFirstPage();
|
||||
_setPull(delta.dy);
|
||||
}
|
||||
|
||||
void _holdFirstPage() {
|
||||
final controller = widget.pageController;
|
||||
if (!controller.hasClients) return;
|
||||
final position = controller.position;
|
||||
if (position.pixels <= position.minScrollExtent) return;
|
||||
controller.jumpTo(position.minScrollExtent);
|
||||
}
|
||||
|
||||
void _onPointerEnd() {
|
||||
final shouldRefresh = _tracking && _pull >= _refreshThreshold;
|
||||
_origin = null;
|
||||
_tracking = false;
|
||||
if (shouldRefresh) {
|
||||
_refresh();
|
||||
return;
|
||||
}
|
||||
_setPull(0);
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (_refreshing) return;
|
||||
_refreshing = true;
|
||||
_setPull(_refreshThreshold);
|
||||
try {
|
||||
await widget.onRefresh();
|
||||
} finally {
|
||||
_refreshing = false;
|
||||
if (mounted) _setPull(0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Listener(
|
||||
onPointerDown: _onPointerDown,
|
||||
onPointerMove: _onPointerMove,
|
||||
onPointerUp: (_) => _onPointerEnd(),
|
||||
onPointerCancel: (_) => _onPointerEnd(),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
widget.child,
|
||||
if (_pull > 0)
|
||||
Positioned(
|
||||
top: widget.topInset,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Container(
|
||||
key: const Key('pull-refresh-indicator'),
|
||||
width: AppDimens.refreshIndicatorSize,
|
||||
height: AppDimens.refreshIndicatorSize,
|
||||
padding: const EdgeInsets.all(
|
||||
(AppDimens.refreshIndicatorSize -
|
||||
AppDimens.refreshSpinnerSize) /
|
||||
2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.refreshIndicatorSurface,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.refreshIndicatorRadius,
|
||||
),
|
||||
),
|
||||
child: const SizedBox.square(
|
||||
dimension: AppDimens.refreshSpinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: AppDimens.refreshSpinnerStroke,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import 'home_feed_recommend_panel.dart';
|
||||
import 'loading_progress_track.dart';
|
||||
import 'player_double_tap_hearts.dart';
|
||||
import 'seek_progress_bar.dart';
|
||||
import 'scrub_preview_card.dart';
|
||||
|
||||
part 'home_feed_overlay_parts.dart';
|
||||
|
||||
@@ -207,6 +208,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
height: _progressHeight,
|
||||
hitHeight: _progressHitHeight,
|
||||
cancelScrubRequest: widget.cancelScrubRequest,
|
||||
thumbnailService: widget.thumbnailService,
|
||||
spriteAxisService: widget.spriteAxisService,
|
||||
thumbnailsUrl: widget.episode.thumbnailsUrl,
|
||||
episodeId: widget.episode.id,
|
||||
posterUrl: widget.drama.posterUrl,
|
||||
onScrubStart: _startScrub,
|
||||
onScrubEnd: _endScrub,
|
||||
onSeek: widget.onSeek,
|
||||
|
||||
@@ -19,6 +19,11 @@ class _FeedProgressBar extends StatefulWidget {
|
||||
required this.height,
|
||||
required this.hitHeight,
|
||||
this.cancelScrubRequest = 0,
|
||||
this.thumbnailService,
|
||||
this.spriteAxisService,
|
||||
this.thumbnailsUrl,
|
||||
this.episodeId,
|
||||
this.posterUrl = '',
|
||||
this.onScrubStart,
|
||||
this.onScrubEnd,
|
||||
this.onSeek,
|
||||
@@ -29,6 +34,11 @@ class _FeedProgressBar extends StatefulWidget {
|
||||
final double height;
|
||||
final double hitHeight;
|
||||
final int cancelScrubRequest;
|
||||
final PlaybackThumbnailService? thumbnailService;
|
||||
final SpriteThumbnailAxisSource? spriteAxisService;
|
||||
final String? thumbnailsUrl;
|
||||
final String? episodeId;
|
||||
final String posterUrl;
|
||||
final VoidCallback? onScrubStart;
|
||||
final VoidCallback? onScrubEnd;
|
||||
final ValueChanged<Duration>? onSeek;
|
||||
@@ -142,7 +152,26 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
track,
|
||||
if (showPreview)
|
||||
if (showPreview &&
|
||||
widget.thumbnailService != null &&
|
||||
widget.episodeId != null)
|
||||
Positioned(
|
||||
left: scrubPreviewLeft(
|
||||
trackWidth: width,
|
||||
fraction: _preview!,
|
||||
),
|
||||
bottom: _lineCenterFromBottom + 16,
|
||||
child: ScrubPreviewCard(
|
||||
service: widget.thumbnailService!,
|
||||
spriteAxisService: widget.spriteAxisService,
|
||||
thumbnailsUrl: widget.thumbnailsUrl,
|
||||
episodeId: widget.episodeId!,
|
||||
posterUrl: widget.posterUrl,
|
||||
position: previewPosition,
|
||||
duration: video!.duration,
|
||||
),
|
||||
)
|
||||
else if (showPreview)
|
||||
Positioned(
|
||||
left: _timeBubbleLeft(width, _preview!),
|
||||
bottom: _lineCenterFromBottom + 16,
|
||||
|
||||
@@ -11,7 +11,6 @@ import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../home/widgets/home_drama_classification.dart';
|
||||
|
||||
const double _headlineFont = 26;
|
||||
const double _seriesFont = 19;
|
||||
const double _tagFont = 13;
|
||||
const double _synopsisFont = 13;
|
||||
@@ -49,34 +48,12 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
|
||||
final episodeTitle = widget.episode.title.trim();
|
||||
final headline = _headline(l10n, dramaTitle, episodeTitle);
|
||||
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
||||
final genres = _genreValues();
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
key: const Key('home-feed-title-open'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
child: Text(
|
||||
headline,
|
||||
key: const Key('home-feed-title'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _headlineFont,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.2,
|
||||
letterSpacing: 0,
|
||||
shadows: _textShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
key: const Key('home-feed-series-name'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -119,23 +96,6 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
String _headline(
|
||||
AppLocalizations l10n,
|
||||
String dramaTitle,
|
||||
String episodeTitle,
|
||||
) {
|
||||
if (episodeTitle.isEmpty) return dramaTitle;
|
||||
final normalized = episodeTitle.toLowerCase().replaceAll(' ', '');
|
||||
final localizedEpisode = l10n
|
||||
.episodeLabel(widget.episode.number)
|
||||
.toLowerCase()
|
||||
.replaceAll(' ', '');
|
||||
final episodeOnly = RegExp(r'^(第?\d+集|episode\d+)$').hasMatch(normalized);
|
||||
return normalized == localizedEpisode || episodeOnly
|
||||
? dramaTitle
|
||||
: episodeTitle;
|
||||
}
|
||||
|
||||
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
|
||||
return Row(
|
||||
key: const Key('home-feed-tags'),
|
||||
@@ -213,21 +173,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
);
|
||||
}
|
||||
if (_synopsisExpanded) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
synopsis,
|
||||
key: const Key('home-feed-synopsis'),
|
||||
style: style,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
_synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.showLess,
|
||||
),
|
||||
],
|
||||
);
|
||||
return _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n);
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -255,6 +201,30 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _expandedSynopsis(
|
||||
double maxWidth,
|
||||
String synopsis,
|
||||
TextStyle style,
|
||||
AppLocalizations l10n,
|
||||
) {
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(synopsis, key: const Key('home-feed-synopsis'), style: style),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _synopsisToggle(
|
||||
key: const Key('home-feed-synopsis-more'),
|
||||
label: l10n.showLess,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsisToggle({required Key key, required String label}) {
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/services/catalog_controller.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
@@ -17,6 +22,7 @@ const double _topButtonSize = 36;
|
||||
const double _topBarHeight = 58;
|
||||
const double _boostHintIconSize = 16;
|
||||
const double _boostHintFontSize = 13;
|
||||
const double _synopsisRailInset = 54;
|
||||
const List<Shadow> _playerTextShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
|
||||
];
|
||||
@@ -29,6 +35,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
required this.data,
|
||||
required this.actions,
|
||||
required this.controlsVisible,
|
||||
required this.scrubbing,
|
||||
});
|
||||
|
||||
/// 展示数据。
|
||||
@@ -40,6 +47,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
/// 控制层是否可见。
|
||||
final bool controlsVisible;
|
||||
|
||||
/// 正在拖动进度条。拖动期间清屏,只留进度条。
|
||||
final bool scrubbing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
@@ -61,7 +71,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
_top(context),
|
||||
_bottom(context),
|
||||
if (controlsVisible) _rail(context),
|
||||
if (!data.playing)
|
||||
if (!data.playing && !scrubbing)
|
||||
Center(
|
||||
child: PlayerCenterToggle(
|
||||
key: const Key('player-play-toggle'),
|
||||
@@ -79,49 +89,55 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
key: const Key('player-controls-top'),
|
||||
height: _topBarHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (actions.onBack != null)
|
||||
_TopButton(
|
||||
key: const Key('player-back'),
|
||||
tooltip: data.l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onPressed: actions.onBack!,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: _topButtonSize),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.l10n.episodeLabel(data.episode.number),
|
||||
key: const Key('player-episode-heading'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
shadows: _playerTextShadows,
|
||||
child: IgnorePointer(
|
||||
ignoring: scrubbing,
|
||||
child: Opacity(
|
||||
opacity: scrubbing ? 0 : 1,
|
||||
child: Container(
|
||||
key: const Key('player-controls-top'),
|
||||
height: _topBarHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (actions.onBack != null)
|
||||
_TopButton(
|
||||
key: const Key('player-back'),
|
||||
tooltip: data.l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onPressed: actions.onBack!,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: _topButtonSize),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.l10n.episodeLabel(data.episode.number),
|
||||
key: const Key('player-episode-heading'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_SpeedEntry(
|
||||
key: const Key('player-speed-entry'),
|
||||
speed: data.playbackSpeed,
|
||||
onTap: actions.onSpeed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_TopButton(
|
||||
key: const Key('player-more'),
|
||||
tooltip: data.l10n.more,
|
||||
icon: Icons.more_vert_rounded,
|
||||
onPressed: actions.onMore,
|
||||
),
|
||||
],
|
||||
),
|
||||
_SpeedEntry(
|
||||
key: const Key('player-speed-entry'),
|
||||
speed: data.playbackSpeed,
|
||||
onTap: actions.onSpeed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_TopButton(
|
||||
key: const Key('player-more'),
|
||||
tooltip: data.l10n.more,
|
||||
icon: Icons.more_vert_rounded,
|
||||
onPressed: actions.onMore,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -143,43 +159,49 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
return Positioned(
|
||||
right: AppDimens.spaceMd,
|
||||
top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43),
|
||||
child: InteractionRail(
|
||||
key: const Key('player-interaction-rail'),
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-save'),
|
||||
asset: 'assets/icon/rail/rail_save.png',
|
||||
label: data.l10n.favorite,
|
||||
count: data.favoriteCount,
|
||||
countKey: const Key('player-rail-save-count'),
|
||||
selected: data.saved,
|
||||
onTap: actions.onSave,
|
||||
child: IgnorePointer(
|
||||
ignoring: scrubbing,
|
||||
child: Opacity(
|
||||
opacity: scrubbing ? 0 : 1,
|
||||
child: InteractionRail(
|
||||
key: const Key('player-interaction-rail'),
|
||||
items: [
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-save'),
|
||||
asset: 'assets/icon/rail/rail_save.png',
|
||||
label: data.l10n.favorite,
|
||||
count: data.favoriteCount,
|
||||
countKey: const Key('player-rail-save-count'),
|
||||
selected: data.saved,
|
||||
onTap: actions.onSave,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-comments'),
|
||||
asset: 'assets/icon/rail/rail_comment.png',
|
||||
label: data.l10n.comments,
|
||||
count: data.commentCount,
|
||||
countKey: const Key('player-rail-comments-count'),
|
||||
onTap: actions.onComments,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-like'),
|
||||
asset: 'assets/icon/rail/rail_like.png',
|
||||
label: data.l10n.like,
|
||||
count: data.likeCount,
|
||||
countKey: const Key('player-rail-like-count'),
|
||||
selected: data.liked,
|
||||
selectedColor: AppColors.doubleTapHeartRed,
|
||||
onTap: actions.onLike,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-share'),
|
||||
asset: 'assets/icon/rail/rail_share.png',
|
||||
label: data.l10n.share,
|
||||
onTap: actions.onShare,
|
||||
),
|
||||
],
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-comments'),
|
||||
asset: 'assets/icon/rail/rail_comment.png',
|
||||
label: data.l10n.comments,
|
||||
count: data.commentCount,
|
||||
countKey: const Key('player-rail-comments-count'),
|
||||
onTap: actions.onComments,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-like'),
|
||||
asset: 'assets/icon/rail/rail_like.png',
|
||||
label: data.l10n.like,
|
||||
count: data.likeCount,
|
||||
countKey: const Key('player-rail-like-count'),
|
||||
selected: data.liked,
|
||||
selectedColor: AppColors.doubleTapHeartRed,
|
||||
onTap: actions.onLike,
|
||||
),
|
||||
InteractionRailItem(
|
||||
key: const Key('player-rail-share'),
|
||||
asset: 'assets/icon/rail/rail_share.png',
|
||||
label: data.l10n.share,
|
||||
onTap: actions.onShare,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -192,11 +214,17 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (controlsVisible) _bottomInfo(context),
|
||||
Offstage(
|
||||
offstage: !controlsVisible || scrubbing,
|
||||
child: _bottomInfo(context),
|
||||
),
|
||||
if (data.boosting) _boostHint(),
|
||||
_bottomProgress(),
|
||||
// 加速态只保留进度条与提示,便于全屏快进时看清画面。
|
||||
if (!data.boosting) _bottomActions(),
|
||||
// 加速或拖动进度时只留进度条,避免挡住画面。
|
||||
Offstage(
|
||||
offstage: data.boosting || scrubbing,
|
||||
child: _bottomActions(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -243,12 +271,16 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
children: [
|
||||
_detailRow(series),
|
||||
const SizedBox(height: 4),
|
||||
_EpisodeSynopsis(
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
authorNote: data.authorNote,
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: _synopsisRailInset),
|
||||
child: _EpisodeSynopsis(
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
drama: data.drama,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
authorNote: data.authorNote,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -273,6 +305,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
posterUrl: data.drama.posterUrl,
|
||||
inactiveTrackColor: AppColors.inactiveTrack,
|
||||
onScrubStart: actions.onScrubStart,
|
||||
onScrubEnd: actions.onScrubEnd,
|
||||
onSeek: actions.onSeek,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
@@ -40,8 +41,8 @@ class PlayerOverlayData {
|
||||
/// 当前剧集。
|
||||
final Episode episode;
|
||||
|
||||
/// 当前播放器。
|
||||
final VideoPlayerController? controller;
|
||||
/// 当前播放器进度。
|
||||
final ValueListenable<VideoPlayerValue>? controller;
|
||||
|
||||
/// 是否已点赞。
|
||||
final bool liked;
|
||||
@@ -98,6 +99,7 @@ class PlayerOverlayActions {
|
||||
required this.onShare,
|
||||
required this.onMore,
|
||||
required this.onScrubStart,
|
||||
required this.onScrubEnd,
|
||||
required this.onSeek,
|
||||
required this.onLongPressCenter,
|
||||
required this.onBoostStart,
|
||||
@@ -143,6 +145,9 @@ class PlayerOverlayActions {
|
||||
/// 开始拖动进度。
|
||||
final VoidCallback? onScrubStart;
|
||||
|
||||
/// 结束拖动进度。
|
||||
final VoidCallback? onScrubEnd;
|
||||
|
||||
/// 提交进度跳转。
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
|
||||
|
||||
@@ -109,12 +109,14 @@ class _EpisodeSynopsis extends StatefulWidget {
|
||||
const _EpisodeSynopsis({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
required this.episodeNumber,
|
||||
required this.synopsis,
|
||||
required this.authorNote,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Drama drama;
|
||||
final int episodeNumber;
|
||||
final String synopsis;
|
||||
final String authorNote;
|
||||
@@ -125,6 +127,9 @@ class _EpisodeSynopsis extends StatefulWidget {
|
||||
|
||||
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
bool _expanded = false;
|
||||
String? _fullSynopsis;
|
||||
int _detailRequest = 0;
|
||||
bool _detailRequested = false;
|
||||
|
||||
/// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。
|
||||
static const TextStyle _lineStyle = TextStyle(
|
||||
@@ -134,53 +139,114 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
height: 1.35,
|
||||
shadows: _playerTextShadows,
|
||||
);
|
||||
static const TextStyle _linkStyle = TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
shadows: _playerTextShadows,
|
||||
);
|
||||
static const int _collapsedMaxLines = 2;
|
||||
static const double _expandGap = 6;
|
||||
static const double _expandTrailing = 28;
|
||||
static const double _separatorGap = 6;
|
||||
|
||||
String get _synopsis => _fullSynopsis ?? widget.synopsis;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_detailRequested || widget.drama.detailLoaded) return;
|
||||
_detailRequested = true;
|
||||
unawaited(_loadFullSynopsis());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _EpisodeSynopsis oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.drama.id == widget.drama.id &&
|
||||
oldWidget.synopsis == widget.synopsis) {
|
||||
return;
|
||||
}
|
||||
_fullSynopsis = null;
|
||||
_expanded = false;
|
||||
_detailRequested = !widget.drama.detailLoaded;
|
||||
if (_detailRequested) unawaited(_loadFullSynopsis());
|
||||
}
|
||||
|
||||
Future<void> _loadFullSynopsis() async {
|
||||
if (widget.drama.detailLoaded) return;
|
||||
final request = ++_detailRequest;
|
||||
final CatalogController catalog;
|
||||
try {
|
||||
catalog = context.read<CatalogController>();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final detail = await catalog.requireDetail(widget.drama);
|
||||
if (!mounted || request != _detailRequest) return;
|
||||
final full = localizedDramaSynopsis(widget.l10n, detail);
|
||||
if (full == widget.synopsis) return;
|
||||
setState(() => _fullSynopsis = full);
|
||||
} catch (_) {
|
||||
// 详情失败时继续用列表里的简介。
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => _block(constraints.maxWidth),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _block(double maxWidth) {
|
||||
final label = widget.l10n.episodeLabel(widget.episodeNumber);
|
||||
if (_expanded) return _expandedBlock(label, maxWidth);
|
||||
return _collapsedBlock(label, maxWidth);
|
||||
}
|
||||
|
||||
Widget _collapsedBlock(String label, double maxWidth) {
|
||||
final link = widget.l10n.expandSynopsis;
|
||||
final body = _collapsedBody(label, maxWidth, link);
|
||||
final text = _synopsisText(label, body, link: null, expanded: false);
|
||||
if (body == _synopsis) return text;
|
||||
final caret = _caret(label, body, maxWidth);
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
widget.l10n.episodeLabel(widget.episodeNumber),
|
||||
key: const Key('player-episode-label'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _lineStyle,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(
|
||||
'|',
|
||||
maxLines: 1,
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.synopsis,
|
||||
key: const Key('player-synopsis'),
|
||||
maxLines: _expanded ? null : 1,
|
||||
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
|
||||
style: _lineStyle,
|
||||
),
|
||||
),
|
||||
if (!_expanded) _toggle(widget.l10n.expandSynopsis),
|
||||
],
|
||||
text,
|
||||
Positioned(
|
||||
left: caret.dx,
|
||||
top: caret.dy,
|
||||
child: Padding(
|
||||
key: const Key('player-synopsis-gap'),
|
||||
padding: const EdgeInsets.only(left: _expandGap),
|
||||
child: _toggle(link),
|
||||
),
|
||||
),
|
||||
if (_expanded) ...[
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _expandedBlock(String label, double maxWidth) {
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_synopsisText(label, _synopsis, link: null, expanded: true),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _toggle(widget.l10n.showLess),
|
||||
),
|
||||
if (widget.authorNote.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.authorNote,
|
||||
key: const Key('player-author-note'),
|
||||
maxLines: 2,
|
||||
maxLines: _collapsedMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.playerMuted,
|
||||
@@ -189,31 +255,181 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
),
|
||||
),
|
||||
],
|
||||
_toggle(widget.l10n.showLess),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsisText(
|
||||
String label,
|
||||
String body, {
|
||||
required String? link,
|
||||
required bool expanded,
|
||||
}) {
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
style: _lineStyle,
|
||||
children: _spans(label, body, link),
|
||||
),
|
||||
key: const Key('player-synopsis'),
|
||||
style: _lineStyle,
|
||||
maxLines: expanded ? null : _collapsedMaxLines,
|
||||
overflow: expanded ? TextOverflow.clip : TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
List<InlineSpan> _spans(String label, String body, String? link) {
|
||||
return [
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: Text(
|
||||
label,
|
||||
key: const Key('player-episode-label'),
|
||||
style: _lineStyle,
|
||||
),
|
||||
),
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _separatorGap),
|
||||
child: Text(
|
||||
'|',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(text: body, style: _lineStyle),
|
||||
if (link != null)
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: Padding(
|
||||
key: const Key('player-synopsis-gap'),
|
||||
padding: const EdgeInsets.only(left: _expandGap),
|
||||
child: _toggle(link),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String _collapsedBody(String label, double maxWidth, String link) {
|
||||
if (_fits(label, _synopsis, maxWidth, reserve: 0)) {
|
||||
return _synopsis;
|
||||
}
|
||||
final reserve =
|
||||
_textWidth(link, _linkStyle) + _expandGap + _expandTrailing;
|
||||
var low = 0;
|
||||
var high = _synopsis.length;
|
||||
var best = '…';
|
||||
while (low <= high) {
|
||||
final mid = (low + high) >> 1;
|
||||
final slice = '${_synopsis.substring(0, mid)}…';
|
||||
if (_fits(label, slice, maxWidth, reserve: reserve)) {
|
||||
best = slice;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
bool _fits(
|
||||
String label,
|
||||
String body,
|
||||
double maxWidth, {
|
||||
required double reserve,
|
||||
}) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(text: label, style: _lineStyle),
|
||||
const WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: SizedBox(width: _separatorGap),
|
||||
),
|
||||
TextSpan(
|
||||
text: '|',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: SizedBox(width: _separatorGap),
|
||||
),
|
||||
TextSpan(text: body, style: _lineStyle),
|
||||
if (reserve > 0)
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
child: SizedBox(width: reserve),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: _collapsedMaxLines,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
painter.setPlaceholderDimensions(_slots(reserve));
|
||||
painter.layout(maxWidth: maxWidth);
|
||||
return !painter.didExceedMaxLines;
|
||||
}
|
||||
|
||||
List<PlaceholderDimensions> _slots(double reserve) {
|
||||
return [
|
||||
const PlaceholderDimensions(
|
||||
size: Size(_separatorGap, 1),
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
),
|
||||
const PlaceholderDimensions(
|
||||
size: Size(_separatorGap, 1),
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
),
|
||||
if (reserve > 0)
|
||||
PlaceholderDimensions(
|
||||
size: Size(reserve, _lineStyle.fontSize ?? 12),
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Offset _caret(String label, String body, double maxWidth) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(children: _spans(label, body, null)),
|
||||
maxLines: _collapsedMaxLines,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
painter.setPlaceholderDimensions(_slots(0));
|
||||
painter.layout(maxWidth: maxWidth);
|
||||
return painter.getOffsetForCaret(
|
||||
TextPosition(offset: painter.plainText.length),
|
||||
Rect.zero,
|
||||
);
|
||||
}
|
||||
double _textWidth(String text, TextStyle style) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
return painter.width;
|
||||
}
|
||||
|
||||
Widget _toggle(String label) {
|
||||
return InkWell(
|
||||
key: const Key('player-synopsis-toggle'),
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(label, maxLines: 1, style: _linkStyle),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ class PlayerBlockedNotice extends StatelessWidget {
|
||||
|
||||
Widget _coinLabel(AppLocalizations l10n) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.goldCard,
|
||||
color: AppColors.homeCrownGold,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Padding(
|
||||
|
||||
@@ -11,8 +11,9 @@ import '../../../core/data/services/sprite_thumbnail_axis.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
|
||||
const double scrubPreviewWidth = 120;
|
||||
const double scrubPreviewWidth = 96;
|
||||
const double scrubPreviewSafeMargin = 12;
|
||||
const double _portraitFrameAspect = 9 / 16;
|
||||
|
||||
/// 把预览卡片限制在进度条可用宽度内。
|
||||
double scrubPreviewLeft({
|
||||
@@ -64,6 +65,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
|
||||
_prepareSprite();
|
||||
_syncFallbackSource();
|
||||
}
|
||||
|
||||
@@ -74,6 +76,10 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged);
|
||||
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
|
||||
}
|
||||
if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl ||
|
||||
oldWidget.position != widget.position) {
|
||||
_prepareSprite();
|
||||
}
|
||||
if (oldWidget.service != widget.service ||
|
||||
oldWidget.episodeId != widget.episodeId ||
|
||||
oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
|
||||
@@ -97,6 +103,12 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _prepareSprite() {
|
||||
final service = widget.spriteAxisService;
|
||||
if (service == null) return;
|
||||
unawaited(service.prepareAt(widget.thumbnailsUrl, widget.position));
|
||||
}
|
||||
|
||||
void _handleSpriteAxisChanged() {
|
||||
if (!mounted) return;
|
||||
if (_spriteFrame != null) {
|
||||
@@ -190,7 +202,8 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
),
|
||||
),
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
key: const Key('scrub-preview-frame-box'),
|
||||
aspectRatio: _frameAspect(spriteFrame),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
@@ -229,6 +242,14 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
||||
}
|
||||
}
|
||||
|
||||
double _frameAspect(SpriteThumbnailFrame? frame) {
|
||||
final rect = frame?.sourceRect;
|
||||
if (rect == null || rect.width <= 0 || rect.height <= 0) {
|
||||
return _portraitFrameAspect;
|
||||
}
|
||||
return rect.width / rect.height;
|
||||
}
|
||||
|
||||
/// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。
|
||||
class SpriteCropPreview extends StatelessWidget {
|
||||
const SpriteCropPreview({
|
||||
|
||||
@@ -170,11 +170,28 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
setState(() => _buffering = buffering);
|
||||
}
|
||||
|
||||
void _endPointerDrag() {
|
||||
if (!_dragging) {
|
||||
_pointerMoved = false;
|
||||
_pointerStart = null;
|
||||
_dragStartFraction = null;
|
||||
return;
|
||||
}
|
||||
final duration = widget.valueListenable?.value.duration ?? Duration.zero;
|
||||
final fraction = _previewFraction.value ?? _dragStartFraction ?? 0;
|
||||
unawaited(_finish(fraction, duration));
|
||||
}
|
||||
|
||||
Future<void> _finish(double fraction, Duration duration) async {
|
||||
final startedAt = _dragStartFraction;
|
||||
_dragStartFraction = null;
|
||||
if (widget.locked || !_dragging || startedAt == null) return;
|
||||
setState(() => _dragging = false);
|
||||
_dragging = false;
|
||||
if (!mounted) {
|
||||
widget.onScrubEnd?.call();
|
||||
return;
|
||||
}
|
||||
setState(() {});
|
||||
_pointerMoved = false;
|
||||
_pointerStart = null;
|
||||
widget.thumbnailService?.cancelPending();
|
||||
@@ -287,20 +304,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
_pointerMoved = true;
|
||||
}
|
||||
},
|
||||
onPointerUp: (_) {
|
||||
if (!_dragging) {
|
||||
_pointerMoved = false;
|
||||
_pointerStart = null;
|
||||
_dragStartFraction = null;
|
||||
}
|
||||
},
|
||||
onPointerCancel: (_) {
|
||||
if (!_dragging) {
|
||||
_pointerMoved = false;
|
||||
_pointerStart = null;
|
||||
_dragStartFraction = null;
|
||||
}
|
||||
},
|
||||
onPointerUp: (_) => _endPointerDrag(),
|
||||
onPointerCancel: (_) => _endPointerDrag(),
|
||||
child: Slider(
|
||||
value: fraction,
|
||||
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
|
||||
|
||||
@@ -10,7 +10,6 @@ import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/formatting/count_format.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_pull_to_refresh.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/paged_local_list.dart';
|
||||
@@ -20,7 +19,12 @@ import '../../l10n/app_localizations.dart';
|
||||
import '../player/open_player.dart';
|
||||
|
||||
const int _topCount = 50;
|
||||
const double _loadMoreThreshold = 200, _championLift = 12;
|
||||
const double _loadMoreThreshold = 200;
|
||||
const double _championLift = 12;
|
||||
const double _podiumGap = 8;
|
||||
const double _podiumHorizontal = 8;
|
||||
const double _championScale = 1.18;
|
||||
const double _coverAspect = 146 / 110;
|
||||
|
||||
/// 剧场榜单页:前三名领奖台,第 4 名起为单列排行行。
|
||||
class RankingPage extends StatefulWidget {
|
||||
@@ -117,7 +121,7 @@ class _RankingPageState extends State<RankingPage> {
|
||||
if (podium.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.feedPageHorizontal,
|
||||
horizontal: _podiumHorizontal,
|
||||
),
|
||||
child: _Podium(podium: podium, l10n: l10n),
|
||||
),
|
||||
@@ -156,11 +160,7 @@ class _RankingPageState extends State<RankingPage> {
|
||||
),
|
||||
);
|
||||
if (widget.embedded) {
|
||||
return DecoratedBox(
|
||||
key: const Key('ranking-page'),
|
||||
decoration: const BoxDecoration(gradient: AppTheme.pageGradient),
|
||||
child: SafeArea(bottom: false, child: content),
|
||||
);
|
||||
return KeyedSubtree(key: const Key('ranking-page'), child: content);
|
||||
}
|
||||
return PlainPageScaffold(
|
||||
pageKey: const Key('ranking-page'),
|
||||
@@ -233,33 +233,55 @@ class _Podium extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: AppDimens.rankingPodiumWidth,
|
||||
child: podium.length > 1
|
||||
? _PodiumCard(rank: 2, drama: podium[1], l10n: l10n)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: AppDimens.rankingPodiumChampionWidth,
|
||||
child: podium.isNotEmpty
|
||||
? Transform.translate(
|
||||
offset: const Offset(0, -_championLift),
|
||||
child: _PodiumCard(rank: 1, drama: podium.first, l10n: l10n),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: AppDimens.rankingPodiumWidth,
|
||||
child: podium.length > 2
|
||||
? _PodiumCard(rank: 3, drama: podium[2], l10n: l10n)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final available = constraints.maxWidth - _podiumGap * 2;
|
||||
final side = available / (2 + _championScale);
|
||||
final champion = side * _championScale;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: side,
|
||||
child: podium.length > 1
|
||||
? _PodiumCard(
|
||||
rank: 2,
|
||||
drama: podium[1],
|
||||
l10n: l10n,
|
||||
coverWidth: side,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(width: _podiumGap),
|
||||
SizedBox(
|
||||
width: champion,
|
||||
child: podium.isNotEmpty
|
||||
? Transform.translate(
|
||||
offset: const Offset(0, -_championLift),
|
||||
child: _PodiumCard(
|
||||
rank: 1,
|
||||
drama: podium.first,
|
||||
l10n: l10n,
|
||||
coverWidth: champion,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
const SizedBox(width: _podiumGap),
|
||||
SizedBox(
|
||||
width: side,
|
||||
child: podium.length > 2
|
||||
? _PodiumCard(
|
||||
rank: 3,
|
||||
drama: podium[2],
|
||||
l10n: l10n,
|
||||
coverWidth: side,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -269,19 +291,15 @@ class _PodiumCard extends StatelessWidget {
|
||||
required this.rank,
|
||||
required this.drama,
|
||||
required this.l10n,
|
||||
required this.coverWidth,
|
||||
});
|
||||
|
||||
final int rank;
|
||||
final Drama drama;
|
||||
final AppLocalizations l10n;
|
||||
final double coverWidth;
|
||||
|
||||
double get _coverWidth => rank == 1
|
||||
? AppDimens.rankingPodiumChampionWidth
|
||||
: AppDimens.rankingPodiumWidth;
|
||||
|
||||
double get _coverHeight => rank == 1
|
||||
? AppDimens.rankingPodiumChampionHeight
|
||||
: AppDimens.rankingPodiumHeight;
|
||||
double get _coverHeight => coverWidth * _coverAspect;
|
||||
|
||||
Color get _badgeColor => switch (rank) {
|
||||
1 => AppColors.homeCrownGold,
|
||||
@@ -303,7 +321,7 @@ class _PodiumCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(AppDimens.rankingPodiumRadius),
|
||||
child: SizedBox(
|
||||
key: Key('ranking-cover-$rank'),
|
||||
width: _coverWidth,
|
||||
width: coverWidth,
|
||||
height: _coverHeight,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../player/open_player.dart';
|
||||
import '../player/player_view.dart';
|
||||
import 'recommend_types.dart';
|
||||
import 'widgets/all_dramas_filter_grid.dart';
|
||||
import 'widgets/playlist_detail_page.dart';
|
||||
import 'widgets/recommend_sections_view.dart';
|
||||
import 'widgets/recommend_top_nav.dart';
|
||||
|
||||
@@ -243,7 +244,41 @@ class _RecommendPageState extends State<RecommendPage> {
|
||||
}
|
||||
|
||||
void _handleMore(RecommendSectionKind section) {
|
||||
_selectTab(RecommendInnerTab.allDramas);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final copy = _sectionCopy(l10n, section);
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlaylistDetailPage(
|
||||
title: copy.$1,
|
||||
subtitle: copy.$2,
|
||||
dramas: recommendSectionDramas(
|
||||
kind: section,
|
||||
source: widget.feedController.dramas,
|
||||
categories: context.read<CategoryController>(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
(String, String) _sectionCopy(
|
||||
AppLocalizations l10n,
|
||||
RecommendSectionKind section,
|
||||
) {
|
||||
return switch (section) {
|
||||
RecommendSectionKind.hits => (
|
||||
l10n.weeklyHitsTitle,
|
||||
l10n.weeklyHitsSubtitle,
|
||||
),
|
||||
RecommendSectionKind.newReleases => (
|
||||
l10n.weeklyNewTitle,
|
||||
l10n.weeklyNewSubtitle,
|
||||
),
|
||||
RecommendSectionKind.costume => (
|
||||
l10n.costumeAwardsTitle,
|
||||
l10n.costumeAwardsSubtitle,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _openDrama(Drama drama, {String? episodeId}) async {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../home/widgets/home_feed_card.dart';
|
||||
import '../../player/open_player.dart';
|
||||
|
||||
const double _collageHeight = 188;
|
||||
const int _collageCoverCount = 5;
|
||||
const double _pageHorizontal = 14;
|
||||
const double _gridGap = 10;
|
||||
const double _sprocketBand = 22;
|
||||
const Color _filmBody = Color(0xFF161311);
|
||||
const Color _filmHole = Color(0xFFE6D3BE);
|
||||
|
||||
/// 剧单「更多」:顶部拼图、与剧单一致的标题描述,以及两列卡片。
|
||||
class PlaylistDetailPage extends StatelessWidget {
|
||||
const PlaylistDetailPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.dramas,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final List<Drama> dramas;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final itemWidth = (width - _pageHorizontal * 2 - _gridGap) / 2;
|
||||
final itemExtent =
|
||||
itemWidth / AppDimens.cardCoverAspectRatio + AppDimens.homeCardBodyExtent;
|
||||
return Scaffold(
|
||||
key: const Key('playlist-detail-page'),
|
||||
backgroundColor: AppColors.ink,
|
||||
body: DecoratedBox(
|
||||
decoration: const BoxDecoration(gradient: AppTheme.pageGradient),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
IconButton(
|
||||
key: const Key('playlist-detail-back'),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
icon: const Icon(Icons.chevron_left_rounded),
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
Expanded(child: _body(itemExtent)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(double itemExtent) {
|
||||
return CustomScrollView(
|
||||
key: const Key('playlist-detail-scroll'),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_pageHorizontal,
|
||||
0,
|
||||
_pageHorizontal,
|
||||
12,
|
||||
),
|
||||
sliver: SliverToBoxAdapter(child: _PlaylistCollage(dramas: dramas)),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _pageHorizontal),
|
||||
sliver: SliverToBoxAdapter(child: _heading()),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_pageHorizontal,
|
||||
0,
|
||||
_pageHorizontal,
|
||||
24,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
key: const Key('playlist-detail-grid'),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: _gridGap,
|
||||
mainAxisSpacing: 16,
|
||||
mainAxisExtent: itemExtent,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => HomeFeedCard(
|
||||
drama: dramas[index],
|
||||
onTap: () => playDramaFromCatalog(context, dramas[index]),
|
||||
),
|
||||
childCount: dramas.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _heading() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
key: const Key('playlist-detail-title'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: AppDimens.recommendSectionTitle,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
key: const Key('playlist-detail-subtitle'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: AppDimens.recommendSectionSubtitle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaylistCollage extends StatelessWidget {
|
||||
const _PlaylistCollage({required this.dramas});
|
||||
|
||||
final List<Drama> dramas;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final covers = dramas.take(_collageCoverCount).toList(growable: false);
|
||||
return ClipRRect(
|
||||
key: const Key('playlist-detail-collage'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: _collageHeight,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: _filmBody),
|
||||
const CustomPaint(painter: _FilmSprocketPainter()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: _sprocketBand,
|
||||
horizontal: 8,
|
||||
),
|
||||
child: covers.isEmpty
|
||||
? const SizedBox.expand()
|
||||
: Row(
|
||||
children: [
|
||||
for (var index = 0; index < covers.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 4),
|
||||
Expanded(child: _frame(covers[index])),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _frame(Drama drama) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
fallbackColor: _filmBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilmSprocketPainter extends CustomPainter {
|
||||
const _FilmSprocketPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = _filmHole;
|
||||
const holeWidth = 10.0;
|
||||
const holeHeight = 7.0;
|
||||
const gap = 8.0;
|
||||
const inset = 6.0;
|
||||
final step = holeWidth + gap;
|
||||
final count = ((size.width - inset * 2 + gap) / step).floor();
|
||||
if (count <= 0) return;
|
||||
final used = count * step - gap;
|
||||
var x = (size.width - used) / 2;
|
||||
for (var index = 0; index < count; index++) {
|
||||
_hole(canvas, paint, x, 7, holeWidth, holeHeight);
|
||||
_hole(
|
||||
canvas,
|
||||
paint,
|
||||
x,
|
||||
size.height - 7 - holeHeight,
|
||||
holeWidth,
|
||||
holeHeight,
|
||||
);
|
||||
x += step;
|
||||
}
|
||||
}
|
||||
|
||||
void _hole(
|
||||
Canvas canvas,
|
||||
Paint paint,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
) {
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(x, y, width, height),
|
||||
const Radius.circular(1.5),
|
||||
),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _FilmSprocketPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import '../../home/widgets/home_drama_classification.dart';
|
||||
import 'recommend_section_card.dart';
|
||||
|
||||
const List<String> _costumeNames = ['古装', 'costume', 'kostum'];
|
||||
const int _sectionPreviewCount = 10;
|
||||
|
||||
enum RecommendSectionKind { hits, newReleases, costume }
|
||||
|
||||
@@ -35,17 +36,14 @@ class RecommendSectionsView extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final source = feedController.dramas;
|
||||
final hits = [...source]
|
||||
..sort((a, b) => b.viewCount.compareTo(a.viewCount));
|
||||
final newest = [...source]
|
||||
..sort((a, b) {
|
||||
final left = publishedAtForDrama(a);
|
||||
final right = publishedAtForDrama(b);
|
||||
if (left == null && right == null) return b.id.compareTo(a.id);
|
||||
if (left == null) return 1;
|
||||
if (right == null) return -1;
|
||||
return right.compareTo(left);
|
||||
});
|
||||
final hits = recommendSectionDramas(
|
||||
kind: RecommendSectionKind.hits,
|
||||
source: source,
|
||||
);
|
||||
final newest = recommendSectionDramas(
|
||||
kind: RecommendSectionKind.newReleases,
|
||||
source: source,
|
||||
);
|
||||
return AppPullToRefresh(
|
||||
key: const Key('recommend-sections-refresh'),
|
||||
onRefresh: () async {
|
||||
@@ -62,7 +60,7 @@ class RecommendSectionsView extends StatelessWidget {
|
||||
key: 'hits',
|
||||
title: l10n.weeklyHitsTitle,
|
||||
subtitle: l10n.weeklyHitsSubtitle,
|
||||
dramas: hits.take(10).toList(),
|
||||
dramas: hits.take(_sectionPreviewCount).toList(),
|
||||
onMore: () => onMore(RecommendSectionKind.hits),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXl),
|
||||
@@ -72,7 +70,7 @@ class RecommendSectionsView extends StatelessWidget {
|
||||
key: 'new',
|
||||
title: l10n.weeklyNewTitle,
|
||||
subtitle: l10n.weeklyNewSubtitle,
|
||||
dramas: newest.take(10).toList(),
|
||||
dramas: newest.take(_sectionPreviewCount).toList(),
|
||||
onMore: () => onMore(RecommendSectionKind.newReleases),
|
||||
),
|
||||
const SizedBox(height: AppDimens.spaceXl),
|
||||
@@ -82,7 +80,11 @@ class RecommendSectionsView extends StatelessWidget {
|
||||
key: 'costume',
|
||||
title: l10n.costumeAwardsTitle,
|
||||
subtitle: l10n.costumeAwardsSubtitle,
|
||||
dramas: _costumeDramas(source),
|
||||
dramas: recommendSectionDramas(
|
||||
kind: RecommendSectionKind.costume,
|
||||
source: source,
|
||||
categories: categories,
|
||||
).take(_sectionPreviewCount).toList(),
|
||||
onMore: () => onMore(RecommendSectionKind.costume),
|
||||
),
|
||||
],
|
||||
@@ -90,33 +92,6 @@ class RecommendSectionsView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<Drama> _costumeDramas(List<Drama> source) {
|
||||
final category = _costumeCategory();
|
||||
if (category != null) {
|
||||
final fromCategory = categories.dramasOf(category.id);
|
||||
if (fromCategory.isNotEmpty) return fromCategory.take(10).toList();
|
||||
}
|
||||
return source
|
||||
.where(
|
||||
(drama) =>
|
||||
_containsCostume(drama.category) || _containsCostume(drama.genre),
|
||||
)
|
||||
.take(10)
|
||||
.toList();
|
||||
}
|
||||
|
||||
ContentCategory? _costumeCategory() {
|
||||
for (final category in categories.categories) {
|
||||
if (_containsCostume(category.name)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _containsCostume(String value) {
|
||||
final normalized = value.toLowerCase();
|
||||
return _costumeNames.any(normalized.contains);
|
||||
}
|
||||
|
||||
Widget _section(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n, {
|
||||
@@ -208,3 +183,54 @@ class RecommendSectionsView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 剧单分区的完整片单,横滑预览和更多页共用同一排序。
|
||||
List<Drama> recommendSectionDramas({
|
||||
required RecommendSectionKind kind,
|
||||
required List<Drama> source,
|
||||
CategoryController? categories,
|
||||
}) {
|
||||
switch (kind) {
|
||||
case RecommendSectionKind.hits:
|
||||
return [...source]..sort((a, b) => b.viewCount.compareTo(a.viewCount));
|
||||
case RecommendSectionKind.newReleases:
|
||||
return [...source]..sort(_newestFirst);
|
||||
case RecommendSectionKind.costume:
|
||||
return _costumeDramas(source, categories);
|
||||
}
|
||||
}
|
||||
|
||||
int _newestFirst(Drama leftDrama, Drama rightDrama) {
|
||||
final left = publishedAtForDrama(leftDrama);
|
||||
final right = publishedAtForDrama(rightDrama);
|
||||
if (left == null && right == null) return rightDrama.id.compareTo(leftDrama.id);
|
||||
if (left == null) return 1;
|
||||
if (right == null) return -1;
|
||||
return right.compareTo(left);
|
||||
}
|
||||
|
||||
List<Drama> _costumeDramas(List<Drama> source, CategoryController? categories) {
|
||||
final category = _costumeCategory(categories);
|
||||
if (category != null) {
|
||||
final fromCategory = categories!.dramasOf(category.id);
|
||||
if (fromCategory.isNotEmpty) return fromCategory;
|
||||
}
|
||||
return source.where(_isCostumeDrama).toList(growable: false);
|
||||
}
|
||||
|
||||
ContentCategory? _costumeCategory(CategoryController? categories) {
|
||||
if (categories == null) return null;
|
||||
for (final category in categories.categories) {
|
||||
if (_containsCostume(category.name)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isCostumeDrama(Drama drama) {
|
||||
return _containsCostume(drama.category) || _containsCostume(drama.genre);
|
||||
}
|
||||
|
||||
bool _containsCostume(String value) {
|
||||
final normalized = value.toLowerCase();
|
||||
return _costumeNames.any(normalized.contains);
|
||||
}
|
||||
@@ -52,6 +52,10 @@ void main() {
|
||||
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
|
||||
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
|
||||
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(find.byKey(const Key('auth-landing'))).dy,
|
||||
lessThan(80),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('auth-entry-phone')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -63,7 +63,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('should_use_the_configured_local_address_for_device_default', () {
|
||||
expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081');
|
||||
expect(AppConfig.devHostBaseUrl, 'http://10.43.47.1:8081');
|
||||
});
|
||||
|
||||
test('should_skip_probe_when_compiled_override_is_set', () async {
|
||||
|
||||
@@ -113,8 +113,10 @@ void main() {
|
||||
final top3 = tester.getRect(find.byKey(const Key('ranking-cover-3')));
|
||||
expect(top1.width, greaterThan(top2.width));
|
||||
expect(top3.width, closeTo(top2.width, 0.5));
|
||||
expect(top2.left, closeTo(14, 0.5));
|
||||
expect(top3.right, closeTo(376, 0.5));
|
||||
expect(top2.left, closeTo(8, 0.5));
|
||||
expect(top3.right, closeTo(382, 0.5));
|
||||
expect(top1.left - top2.right, lessThanOrEqualTo(10));
|
||||
expect(top2.width, greaterThan(100));
|
||||
expect(top2.left, lessThan(top1.left));
|
||||
expect(top3.left, greaterThan(top1.right));
|
||||
expect(top1.bottom, closeTo(top2.bottom - 12, 0.5));
|
||||
|
||||
@@ -23,17 +23,16 @@ void main() {
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('empty-state-art')), findsNothing);
|
||||
expect(find.byKey(const Key('empty-state-art-inner')), findsNothing);
|
||||
expect(find.byKey(const Key('empty-state-icon')), findsOneWidget);
|
||||
expect(find.text('No stories'), findsOneWidget);
|
||||
expect(find.text('New content will appear here.'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('empty-state-icon'))),
|
||||
const Size(48, 48),
|
||||
const Size(72, 72),
|
||||
);
|
||||
expect(
|
||||
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
|
||||
AppColors.blueSoft,
|
||||
AppColors.subtle,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
@@ -84,7 +83,7 @@ void main() {
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('empty-state-icon'))),
|
||||
const Size(32, 32),
|
||||
const Size(44, 44),
|
||||
);
|
||||
expect(find.byType(DecoratedBox), findsNothing);
|
||||
expect(
|
||||
@@ -111,7 +110,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
|
||||
AppColors.blueSoft,
|
||||
AppColors.subtle,
|
||||
);
|
||||
|
||||
await _pump(
|
||||
@@ -121,7 +120,7 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
tester.widget<Icon>(find.byKey(const Key('empty-state-icon'))).color,
|
||||
AppColors.blueSoft,
|
||||
AppColors.subtle,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/pages/home/widgets/home_feed_card.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
@@ -55,18 +56,7 @@ void main() {
|
||||
.map((key) => tester.getCenter(find.byKey(key)).dx)
|
||||
.toList();
|
||||
expect(sectionCenters, orderedEquals(sectionCenters.toList()..sort()));
|
||||
|
||||
final tagKeys = [
|
||||
const Key('home-tag-hot'),
|
||||
const Key('home-tag-uplifting'),
|
||||
const Key('home-tag-sweet'),
|
||||
const Key('home-tag-faceSlap'),
|
||||
const Key('home-tag-angst'),
|
||||
];
|
||||
final tagCenters = tagKeys
|
||||
.map((key) => tester.getCenter(find.byKey(key)).dx)
|
||||
.toList();
|
||||
expect(tagCenters, orderedEquals(tagCenters.toList()..sort()));
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsNothing);
|
||||
expect(find.text('Featured'), findsOneWidget);
|
||||
expect(find.text('New'), findsOneWidget);
|
||||
expect(find.text('Categories'), findsOneWidget);
|
||||
@@ -117,6 +107,11 @@ void main() {
|
||||
expect(find.byKey(const Key('ranking-page')), findsOneWidget);
|
||||
expect(find.byKey(const Key('ranking-hero')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-grid-cards')), findsNothing);
|
||||
final home = tester.widget<DecoratedBox>(find.byKey(const Key('home-grid')));
|
||||
expect(
|
||||
(home.decoration as BoxDecoration).gradient,
|
||||
AppTheme.pageGradient,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-section-categories')));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -158,7 +153,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('home-section-featured')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsNothing);
|
||||
|
||||
final afterScrollable = find
|
||||
.descendant(
|
||||
@@ -176,7 +171,7 @@ void main() {
|
||||
final repository = _PagedCatalogRepository();
|
||||
await _pumpApp(tester, repository);
|
||||
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsNothing);
|
||||
await tester.drag(
|
||||
find.byKey(const Key('home-primary-page-view')),
|
||||
const Offset(-320, 0),
|
||||
@@ -195,7 +190,7 @@ void main() {
|
||||
const Offset(320, 0),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_load_next_page_without_expanding_page_size', (
|
||||
|
||||
@@ -93,44 +93,8 @@ void main() {
|
||||
expect(find.byKey(const Key('home-section-ranking')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-section-category-古装')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-section-category-社群')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-grid-filter')), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-grid-filter'))),
|
||||
const Size.square(AppDimens.homeSecondaryFilterVisualSize),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-secondary-filter-visual'))),
|
||||
const Size.square(AppDimens.homeSecondaryFilterVisualSize),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(find.byKey(const Key('home-grid-filter'))).dy,
|
||||
closeTo(tester.getCenter(find.byKey(const Key('home-tag-hot'))).dy, 0.1),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(find.byKey(const Key('home-grid-filter'))).right,
|
||||
closeTo(
|
||||
tester.getRect(find.byKey(const Key('home-grid'))).right -
|
||||
AppDimens.feedPageHorizontal,
|
||||
0.1,
|
||||
),
|
||||
);
|
||||
final filterIcon = tester.widget<CustomPaint>(
|
||||
find.byKey(const Key('home-secondary-filter-icon')),
|
||||
);
|
||||
expect(
|
||||
filterIcon.size,
|
||||
const Size.square(AppDimens.homeSecondaryFilterIcon),
|
||||
);
|
||||
final filterMaterial = tester.widget<Material>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byKey(const Key('home-grid-filter')),
|
||||
matching: find.byType(Material),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(filterMaterial.color, AppColors.transparent);
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsNothing);
|
||||
expect(find.byKey(const Key('home-grid-filter')), findsNothing);
|
||||
final searchBox = find.descendant(
|
||||
of: find.byKey(const Key('home-search-entry')),
|
||||
matching: find.byType(Container),
|
||||
@@ -169,28 +133,6 @@ void main() {
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
final secondaryTag = tester.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('home-tag-hot')),
|
||||
matching: find.text('Hot'),
|
||||
),
|
||||
);
|
||||
expect(secondaryTag.style?.fontSize, AppDimens.homeSecondaryTagFont);
|
||||
expect(secondaryTag.style?.fontWeight, FontWeight.w600);
|
||||
expect(secondaryTag.style?.color, AppColors.textTertiary);
|
||||
final unselectedTagChip = tester.widget<AnimatedContainer>(
|
||||
find.byKey(const Key('home-tag-animated-hot')),
|
||||
);
|
||||
expect(
|
||||
(unselectedTagChip.decoration! as BoxDecoration).color,
|
||||
AppColors.transparent,
|
||||
);
|
||||
expect(
|
||||
unselectedTagChip.padding,
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: AppDimens.homeSecondaryTagHorizontalPadding,
|
||||
),
|
||||
);
|
||||
final primaryStart = tester.getTopLeft(find.text('Featured'));
|
||||
final pageStart = tester.getTopLeft(searchBox);
|
||||
expect(primaryStart.dx, pageStart.dx);
|
||||
@@ -198,41 +140,6 @@ void main() {
|
||||
tester.getTopLeft(find.text('New')).dx -
|
||||
tester.getBottomRight(find.text('Featured')).dx;
|
||||
expect(primaryGap, closeTo(AppDimens.homePrimaryCategoryGap, 0.1));
|
||||
final tagStart = tester.getTopLeft(find.byKey(const Key('home-tag-hot')));
|
||||
expect(tagStart.dx, pageStart.dx);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-tag-hot'))).height,
|
||||
AppDimens.homeSecondaryTagHeight,
|
||||
);
|
||||
final tagGap =
|
||||
tester.getTopLeft(find.byKey(const Key('home-tag-uplifting'))).dx -
|
||||
tester.getBottomRight(find.byKey(const Key('home-tag-hot'))).dx;
|
||||
expect(tagGap, closeTo(AppDimens.homeSecondaryTagGap, 0.1));
|
||||
await tester.tap(find.byKey(const Key('home-tag-hot')));
|
||||
await tester.pump();
|
||||
final selectedTagChip = tester.widget<AnimatedContainer>(
|
||||
find.byKey(const Key('home-tag-animated-hot')),
|
||||
);
|
||||
expect(
|
||||
(selectedTagChip.decoration! as BoxDecoration).color,
|
||||
AppColors.contentSurface,
|
||||
);
|
||||
expect(
|
||||
(selectedTagChip.decoration! as BoxDecoration).borderRadius,
|
||||
BorderRadius.circular(AppDimens.homeSecondaryTagRadius),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-tag-hot'))).height,
|
||||
AppDimens.homeSecondaryTagHeight,
|
||||
);
|
||||
final selectedTag = tester.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('home-tag-hot')),
|
||||
matching: find.text('Hot'),
|
||||
),
|
||||
);
|
||||
expect(selectedTag.style?.color, AppColors.navSelected);
|
||||
expect(selectedTag.style?.fontWeight, FontWeight.w600);
|
||||
expect(find.byType(PageView), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
@@ -351,13 +258,11 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('home-feed-title'))).data,
|
||||
'Feed Drama',
|
||||
);
|
||||
final seriesTitle = tester.widget<Text>(
|
||||
find.byKey(const Key('home-feed-series-title')),
|
||||
);
|
||||
expect(seriesTitle.data, 'Feed Drama');
|
||||
expect(find.text('Episode 2'), findsNothing);
|
||||
expect(seriesTitle.style?.fontSize, 19);
|
||||
expect(seriesTitle.style?.fontWeight, FontWeight.w600);
|
||||
expect(seriesTitle.style?.color, AppColors.textPrimary);
|
||||
@@ -726,7 +631,7 @@ void main() {
|
||||
await gesture.moveBy(const Offset(120, 0));
|
||||
await tester.pump();
|
||||
expect(scrubVisibility.last, isTrue);
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-series-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('rail-save')), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
@@ -824,7 +729,7 @@ void main() {
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(scrubVisibility.last, isFalse);
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-series-title')), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<AnimatedOpacity>(
|
||||
|
||||
@@ -36,7 +36,7 @@ void main() {
|
||||
AppConfig.ready = Future<void>.value();
|
||||
});
|
||||
|
||||
testWidgets('should_show_brand_splash_and_grid_skeleton_while_loading', (
|
||||
testWidgets('should_show_grid_skeleton_without_an_in_app_splash', (
|
||||
tester,
|
||||
) async {
|
||||
final repository = _PendingCatalogRepository();
|
||||
@@ -47,8 +47,7 @@ void main() {
|
||||
expect(find.byKey(const Key('home-grid-header')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-grid-skeleton')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget);
|
||||
expect(find.byKey(const Key('startup-splash')), findsOneWidget);
|
||||
expect(find.byKey(const Key('startup-splash-logo')), findsOneWidget);
|
||||
expect(find.byKey(const Key('startup-splash')), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
|
||||
repository.complete(_feed);
|
||||
|
||||
@@ -294,7 +294,7 @@ void main() {
|
||||
expect(find.textContaining('/ 00:12'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('scrub-preview-card'))).width,
|
||||
120,
|
||||
96,
|
||||
);
|
||||
|
||||
await gesture.up();
|
||||
|
||||
@@ -570,14 +570,11 @@ void main() {
|
||||
32,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('player-synopsis-toggle')));
|
||||
await tester.pumpAndSettle();
|
||||
final expandedBottom = tester.getRect(
|
||||
final bottomPanel = tester.getRect(
|
||||
find.byKey(const Key('player-controls-bottom')),
|
||||
);
|
||||
// K29.9 展开态展示简介全文,高度会增长,但仍需留在画面下半区。
|
||||
expect(expandedBottom.height, lessThanOrEqualTo(330));
|
||||
expect(expandedBottom.top, greaterThanOrEqualTo(screenHeight * 0.5));
|
||||
expect(bottomPanel.height, lessThanOrEqualTo(330));
|
||||
expect(bottomPanel.top, greaterThanOrEqualTo(screenHeight * 0.5));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -753,8 +750,33 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
await _openSeriesPlayer(tester);
|
||||
final longCopy = Drama(
|
||||
id: 'long-copy-demo',
|
||||
title: 'Long Title',
|
||||
synopsis:
|
||||
'A very long synopsis that keeps describing the story far beyond '
|
||||
'the two lines available under the title in the fullscreen player.',
|
||||
posterUrl: '/files/long-copy-demo.png',
|
||||
genre: 'Drama',
|
||||
detailLoaded: true,
|
||||
episodes: const [
|
||||
Episode(
|
||||
id: 'long-copy-demo-1',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
isFree: true,
|
||||
videoUrl: 'https://example.test/long-demo-1.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
await _pumpApp(
|
||||
tester,
|
||||
catalog: FakeCatalogRepository(dramas: [longCopy]),
|
||||
);
|
||||
await _openSeriesPlayer(
|
||||
tester,
|
||||
cardKey: const Key('home-feed-card-long-copy-demo'),
|
||||
);
|
||||
|
||||
final toggle = find.byKey(const Key('player-synopsis-toggle'));
|
||||
final synopsis = find.byKey(const Key('player-synopsis'));
|
||||
@@ -762,10 +784,26 @@ void main() {
|
||||
find.descendant(of: toggle, matching: find.text('More')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, 1);
|
||||
expect(
|
||||
tester.getRect(toggle).left - tester.getRect(synopsis).right,
|
||||
lessThanOrEqualTo(4),
|
||||
tester
|
||||
.widget<Text>(
|
||||
find.descendant(of: toggle, matching: find.text('More')),
|
||||
)
|
||||
.style
|
||||
?.color,
|
||||
AppColors.textPrimary,
|
||||
);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, 2);
|
||||
expect(
|
||||
tester.widget<Padding>(find.byKey(const Key('player-synopsis-gap'))).padding,
|
||||
const EdgeInsets.only(left: 6),
|
||||
);
|
||||
final synopsisRect = tester.getRect(synopsis);
|
||||
final toggleRect = tester.getRect(toggle);
|
||||
expect(toggleRect.left, greaterThan(synopsisRect.left));
|
||||
expect(
|
||||
toggleRect.center.dy,
|
||||
greaterThan(synopsisRect.top + synopsisRect.height * 0.45),
|
||||
);
|
||||
|
||||
await tester.tap(toggle);
|
||||
@@ -774,7 +812,12 @@ void main() {
|
||||
find.descendant(of: toggle, matching: find.text('Less')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, isNull);
|
||||
expect(tester.widget<Text>(find.byKey(const Key('player-synopsis'))).maxLines, isNull);
|
||||
final lessRect = tester.getRect(toggle);
|
||||
final textRect = tester.getRect(find.byKey(const Key('player-synopsis')));
|
||||
final block = tester.getRect(find.byKey(const Key('player-episode-synopsis')));
|
||||
expect(lessRect.top, greaterThanOrEqualTo(textRect.bottom - 1));
|
||||
expect(block.right - lessRect.right, lessThanOrEqualTo(2));
|
||||
|
||||
await tester.tap(toggle);
|
||||
await tester.pumpAndSettle();
|
||||
@@ -782,7 +825,7 @@ void main() {
|
||||
find.descendant(of: toggle, matching: find.text('More')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, 1);
|
||||
expect(tester.widget<Text>(synopsis).maxLines, 2);
|
||||
});
|
||||
|
||||
testWidgets('should_share_one_muted_style_across_label_and_synopsis', (
|
||||
@@ -860,7 +903,11 @@ void main() {
|
||||
final synopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('player-synopsis')),
|
||||
);
|
||||
expect(synopsis.maxLines, 1);
|
||||
expect(
|
||||
tester.getRect(find.byKey(const Key('player-synopsis'))).right,
|
||||
lessThanOrEqualTo(320 - 14 - 54),
|
||||
);
|
||||
expect(synopsis.maxLines, 2);
|
||||
expect(synopsis.overflow, TextOverflow.ellipsis);
|
||||
expect(
|
||||
tester.getRect(find.byKey(const Key('player-episodes-pill'))).right,
|
||||
|
||||
@@ -140,6 +140,9 @@ class _FakeAxisSource extends ChangeNotifier
|
||||
Duration position,
|
||||
) => frame;
|
||||
|
||||
@override
|
||||
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {}
|
||||
|
||||
@override
|
||||
bool hasAxis(String? thumbnailsUrl) => axisReady;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/player_overlay.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/player_overlay_models.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
@@ -151,6 +153,56 @@ void main() {
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('full player drag clears chrome and release restores it', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpFullPlayerOverlay(tester);
|
||||
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('player-progress'))),
|
||||
);
|
||||
await gesture.moveBy(const Offset(80, 0));
|
||||
await tester.pump();
|
||||
|
||||
expect(_opacity(tester, const Key('player-controls-top')), 0);
|
||||
expect(_opacity(tester, const Key('player-interaction-rail')), 0);
|
||||
expect(_offstage(tester, const Key('player-controls-bottom')), isTrue);
|
||||
expect(_offstage(tester, const Key('player-bottom-actions')), isTrue);
|
||||
expect(find.byKey(const Key('player-progress')), findsOneWidget);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_opacity(tester, const Key('player-controls-top')), 1);
|
||||
expect(_opacity(tester, const Key('player-interaction-rail')), 1);
|
||||
expect(_offstage(tester, const Key('player-bottom-actions')), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
double _opacity(WidgetTester tester, Key key) {
|
||||
return tester
|
||||
.widget<Opacity>(
|
||||
find
|
||||
.ancestor(of: find.byKey(key), matching: find.byType(Opacity))
|
||||
.first,
|
||||
)
|
||||
.opacity;
|
||||
}
|
||||
|
||||
bool _offstage(WidgetTester tester, Key key) {
|
||||
return tester
|
||||
.widget<Offstage>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byKey(key, skipOffstage: false),
|
||||
matching: find.byType(Offstage),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.offstage;
|
||||
}
|
||||
|
||||
Future<_HarnessState> _pumpHarness(WidgetTester tester) async {
|
||||
@@ -245,6 +297,96 @@ Future<ValueNotifier<VideoPlayerValue>> _pumpFeedOverlay(
|
||||
return controller;
|
||||
}
|
||||
|
||||
class _FullPlayerHarness extends StatefulWidget {
|
||||
const _FullPlayerHarness({required this.controller});
|
||||
|
||||
final ValueNotifier<VideoPlayerValue> controller;
|
||||
|
||||
@override
|
||||
State<_FullPlayerHarness> createState() => _FullPlayerHarnessState();
|
||||
}
|
||||
|
||||
class _FullPlayerHarnessState extends State<_FullPlayerHarness> {
|
||||
bool _scrubbing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return PlayerPlaybackOverlay(
|
||||
scrubbing: _scrubbing,
|
||||
controlsVisible: true,
|
||||
data: PlayerOverlayData(
|
||||
l10n: l10n,
|
||||
drama: const Drama(
|
||||
id: 'full-drama',
|
||||
title: 'Full Drama',
|
||||
synopsis: 'A short synopsis for the full player.',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 8,
|
||||
detailLoaded: true,
|
||||
),
|
||||
episode: const Episode(
|
||||
id: 'full-episode',
|
||||
number: 2,
|
||||
title: 'Episode 2',
|
||||
isFree: true,
|
||||
),
|
||||
controller: widget.controller,
|
||||
locked: false,
|
||||
liked: false,
|
||||
likeCount: 1,
|
||||
commentCount: 1,
|
||||
favoriteCount: 1,
|
||||
saved: false,
|
||||
playing: true,
|
||||
playbackSpeed: 1,
|
||||
totalEpisodes: 8,
|
||||
boosting: false,
|
||||
),
|
||||
actions: PlayerOverlayActions(
|
||||
onBack: () {},
|
||||
onTogglePlay: () {},
|
||||
onClear: () {},
|
||||
onExitClear: () {},
|
||||
onDetail: () {},
|
||||
onSpeed: () {},
|
||||
onEpisodes: () {},
|
||||
onComments: () {},
|
||||
onLike: () {},
|
||||
onSave: () {},
|
||||
onShare: () {},
|
||||
onMore: () {},
|
||||
onScrubStart: () => setState(() => _scrubbing = true),
|
||||
onScrubEnd: () => setState(() => _scrubbing = false),
|
||||
onSeek: (_) async {},
|
||||
onLongPressCenter: () {},
|
||||
onBoostStart: () {},
|
||||
onBoostEnd: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pumpFullPlayerOverlay(WidgetTester tester) async {
|
||||
final controller = ValueNotifier<VideoPlayerValue>(
|
||||
const VideoPlayerValue(
|
||||
duration: Duration(minutes: 2),
|
||||
position: Duration(seconds: 20),
|
||||
isInitialized: true,
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Scaffold(body: _FullPlayerHarness(controller: controller)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
class _Harness extends StatefulWidget {
|
||||
const _Harness({super.key, required this.controller});
|
||||
|
||||
|
||||
@@ -250,6 +250,45 @@ void main() {
|
||||
expect(tester.getTopLeft(tagsFinder).dx, tester.getTopLeft(coverFinder).dx);
|
||||
});
|
||||
|
||||
testWidgets('should_open_playlist_detail_with_collage_and_two_columns', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpRecommend(tester);
|
||||
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.byKey(const Key('recommend-more-hits')));
|
||||
await tester.tap(find.byKey(const Key('recommend-more-hits')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('playlist-detail-page')), findsOneWidget);
|
||||
expect(find.byKey(const Key('playlist-detail-collage')), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('playlist-detail-title'))).data,
|
||||
"This Week's Hits",
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(find.byKey(const Key('playlist-detail-subtitle')))
|
||||
.data,
|
||||
'Binge the hottest stories of the week!',
|
||||
);
|
||||
final grid = tester.widget<SliverGrid>(
|
||||
find.byKey(const Key('playlist-detail-grid')),
|
||||
);
|
||||
final delegate =
|
||||
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||
expect(delegate.crossAxisCount, 2);
|
||||
final first = tester.getRect(
|
||||
find.byKey(const Key('home-feed-card-the-signal-demo')),
|
||||
);
|
||||
final second = tester.getRect(
|
||||
find.byKey(const Key('home-feed-card-city-lights-demo')),
|
||||
);
|
||||
expect(second.top, closeTo(first.top, 1));
|
||||
expect(second.left, greaterThan(first.right));
|
||||
while (tester.takeException() != null) {}
|
||||
});
|
||||
|
||||
testWidgets('should_switch_inner_tabs_without_losing_grid_filter_state', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -521,7 +560,7 @@ void main() {
|
||||
) async {
|
||||
await _pumpRecommend(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-feed-title-open')));
|
||||
await tester.tap(find.byKey(const Key('home-feed-series-name')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<PlayerPage>(find.byType(PlayerPage).last).homeMode,
|
||||
@@ -540,6 +579,34 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_pull_refresh_recommend_feed_from_the_first_video', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpRecommend(tester);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('recommend-feed-player'))),
|
||||
);
|
||||
await gesture.moveBy(const Offset(0, 80));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('pull-refresh-indicator')), findsOneWidget);
|
||||
final feedScroll = tester.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('recommend-feed-player')),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(feedScroll.position.pixels, 0);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
expect(feedScroll.position.pixels, 0);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
while (tester.takeException() != null) {}
|
||||
});
|
||||
|
||||
testWidgets('should_open_player_and_search_from_recommend_page', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -204,7 +204,7 @@ void main() {
|
||||
expect(find.byKey(const Key('home-grid')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-grid-cards')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-search-entry')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-secondary-tags')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
|
||||