chore(release): 合并 develop 到 release/v1.0.0
This commit is contained in:
commit
35364c949a
69 files changed
+6115
-2941
No files matched your search
@@ -39,11 +39,11 @@ abstract final class ServerEndpoint {
|
||||
/// In release builds this returns immediately without reading preferences.
|
||||
static Future<void> load({
|
||||
bool debug = kDebugMode,
|
||||
SharedPreferences? preferences,
|
||||
SharedPreferencesAsync? preferences,
|
||||
}) async {
|
||||
if (!debug) return;
|
||||
try {
|
||||
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||
final prefs = preferences ?? SharedPreferencesAsync();
|
||||
final stored = await prefs.getString(preferenceKey);
|
||||
AppConfig.applyRuntimeBaseUrl(
|
||||
resolve(
|
||||
@@ -60,12 +60,12 @@ abstract final class ServerEndpoint {
|
||||
/// Persists a normalized runtime endpoint.
|
||||
static Future<bool> save(
|
||||
String raw, {
|
||||
SharedPreferences? preferences,
|
||||
SharedPreferencesAsync? preferences,
|
||||
}) async {
|
||||
final normalized = normalize(raw);
|
||||
if (normalized == null) return false;
|
||||
try {
|
||||
await (preferences ?? await SharedPreferences.getInstance()).setString(
|
||||
await (preferences ?? SharedPreferencesAsync()).setString(
|
||||
preferenceKey,
|
||||
normalized,
|
||||
);
|
||||
@@ -76,9 +76,9 @@ abstract final class ServerEndpoint {
|
||||
}
|
||||
|
||||
/// Removes the persisted runtime endpoint.
|
||||
static Future<void> clear({SharedPreferences? preferences}) async {
|
||||
static Future<void> clear({SharedPreferencesAsync? preferences}) async {
|
||||
try {
|
||||
await (preferences ?? await SharedPreferences.getInstance()).remove(preferenceKey);
|
||||
await (preferences ?? SharedPreferencesAsync()).remove(preferenceKey);
|
||||
} catch (_) {
|
||||
// Clearing an optional developer override is best effort.
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ class AuthSessionStore {
|
||||
|
||||
Future<String> deviceId() async {
|
||||
if (_cachedDeviceId != null) return _cachedDeviceId!;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = SharedPreferencesAsync();
|
||||
var value = await prefs.getString(_kDeviceId) ?? '';
|
||||
if (value.isEmpty) {
|
||||
final random = Random();
|
||||
@@ -37,7 +37,7 @@ class AuthSessionStore {
|
||||
}
|
||||
|
||||
Future<AuthSession?> read() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = SharedPreferencesAsync();
|
||||
final accessToken = await prefs.getString(_kAccessToken) ?? '';
|
||||
final refreshToken = await prefs.getString(_kRefreshToken) ?? '';
|
||||
if (accessToken.isEmpty || refreshToken.isEmpty) return null;
|
||||
@@ -58,14 +58,14 @@ class AuthSessionStore {
|
||||
}
|
||||
|
||||
Future<void> save(AuthSession session) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = SharedPreferencesAsync();
|
||||
await prefs.setString(_kAccessToken, session.accessToken);
|
||||
await prefs.setString(_kRefreshToken, session.refreshToken);
|
||||
await prefs.setString(_kUser, jsonEncode(session.user.toJson()));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = SharedPreferencesAsync();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
await prefs.remove(_kUser);
|
||||
|
||||
@@ -2,26 +2,31 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Evening palette for the Indonesia launch.
|
||||
/// Obsidian Flare palette from `docs/design/README.md` and screens 01/02.
|
||||
///
|
||||
/// Chili is the action color. Gold marks coins, rewards, and playback
|
||||
/// progress. Cocoa surfaces keep the app in night-market light instead of
|
||||
/// a cold utility screen.
|
||||
/// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page
|
||||
/// code does not need a mass rename; their values now follow the design tokens.
|
||||
abstract final class AppColors {
|
||||
/// Chili fill for buttons, selected chips, and brand marks.
|
||||
static const blue = Color(0xFFC4322A);
|
||||
/// Primary CTA, selected navigation, playback progress.
|
||||
static const blue = Color(0xFFFF5B3D);
|
||||
static const primary = blue;
|
||||
|
||||
/// Pressed chili.
|
||||
static const bluePressed = Color(0xFF8E221C);
|
||||
/// Pressed state for the primary CTA.
|
||||
static const bluePressed = Color(0xFFFF4321);
|
||||
|
||||
/// Coral for text and icons sitting on dark surfaces.
|
||||
static const blueSoft = Color(0xFFFF8A72);
|
||||
/// Secondary accent for tags, locks, and highlighted icons.
|
||||
static const blueSoft = Color(0xFF9278EA);
|
||||
static const secondary = blueSoft;
|
||||
static const lavender = blueSoft;
|
||||
|
||||
/// Coins, rewards, and the playback progress line.
|
||||
static const gold = Color(0xFFF6C453);
|
||||
/// Coins and rewards.
|
||||
static const gold = Color(0xFFFBBF24);
|
||||
|
||||
/// Favorite/like heart without competing with the chili primary action.
|
||||
static const favorite = Color(0xFFD74A7D);
|
||||
/// Success and free-state accents.
|
||||
static const success = Color(0xFF34D399);
|
||||
|
||||
/// Liked hearts use the primary accent.
|
||||
static const favorite = blue;
|
||||
|
||||
/// Text placed on [gold].
|
||||
static const onGold = Color(0xFF2A1810);
|
||||
@@ -30,23 +35,46 @@ abstract final class AppColors {
|
||||
static const onPrimary = Color(0xFFFFFFFF);
|
||||
|
||||
/// Page background.
|
||||
static const ink = Color(0xFF100C0A);
|
||||
static const ink = Color(0xFF0B0C10);
|
||||
|
||||
/// Bottom bar.
|
||||
static const bar = Color(0xFF1A1412);
|
||||
/// Bottom navigation background (rendered at 95% with blur).
|
||||
static const bar = Color(0xFF0D0D11);
|
||||
|
||||
/// Cards lifted off [ink].
|
||||
static const card = Color(0xFF241C19);
|
||||
static const card = Color(0xFF1B1C21);
|
||||
|
||||
/// Chips and nested rows.
|
||||
static const raised = Color(0xFF322824);
|
||||
/// Alternate card surface from the mobile preview.
|
||||
static const cardAlt = Color(0xFF1C1D21);
|
||||
|
||||
/// Primary text on dark surfaces.
|
||||
static const cream = Color(0xFFF8F1EA);
|
||||
/// Floating surfaces, inputs, and nested rows.
|
||||
static const raised = Color(0xFF1C1D21);
|
||||
static const quick = Color(0xFF202126);
|
||||
static const input = Color(0xFF25252A);
|
||||
static const goldCard = Color(0xFF201B1E);
|
||||
|
||||
static const muted = Color(0xFFB7A59C);
|
||||
static const outlineDark = Color(0xFF4A3C36);
|
||||
static const outlineVariantDark = Color(0xFF2A221F);
|
||||
static const outlineLight = Color(0xFF8C7A72);
|
||||
static const outlineVariantLight = Color(0xFFE4D5CC);
|
||||
/// Body text on dark surfaces.
|
||||
static const cream = Color(0xFFE6E1E3);
|
||||
|
||||
/// Titles and high-emphasis text.
|
||||
static const onSurface = Color(0xFFFFFFFF);
|
||||
|
||||
/// Secondary text.
|
||||
static const muted = Color(0xFFD2CDD0);
|
||||
|
||||
/// Weak hints and timestamps.
|
||||
static const subtle = Color(0xFF9A96A3);
|
||||
|
||||
/// Soft orange used for continue labels and secondary times.
|
||||
static const accent = Color(0xFFFFA490);
|
||||
|
||||
/// 8% white border used by cards and inputs.
|
||||
static const border = Color(0x14FFFFFF);
|
||||
|
||||
/// 14% white border used by glass controls.
|
||||
static const glassBorder = Color(0x24FFFFFF);
|
||||
|
||||
static const outlineDark = Color(0xFF2A2C36);
|
||||
static const outlineVariantDark = Color(0xFF242329);
|
||||
static const outlineLight = Color(0xFF8E8E93);
|
||||
static const outlineVariantLight = Color(0xFFE4E4E7);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// K20 cross-page spacing, type, radius, and control sizes.
|
||||
/// Cross-page spacing, type, radius, and control sizes.
|
||||
abstract final class AppDimens {
|
||||
static const double spaceXs = 4;
|
||||
static const double spaceSm = 8;
|
||||
@@ -9,12 +9,17 @@ abstract final class AppDimens {
|
||||
static const double spaceXl = 24;
|
||||
|
||||
static const double pageHorizontal = 16;
|
||||
static const double cardRadius = 16;
|
||||
static const double cardRadius = 12;
|
||||
static const double authCardRadius = 20;
|
||||
static const double cardPadding = 16;
|
||||
static const double innerRadius = 12;
|
||||
static const double innerRadius = 10;
|
||||
static const double innerPadding = 12;
|
||||
static const double pillRadius = 20;
|
||||
static const double glassSmallRadius = 12;
|
||||
static const double glassRadius = 14;
|
||||
static const double glassCardRadius = 14;
|
||||
static const double buttonRadius = 9;
|
||||
static const double coverRadius = 10;
|
||||
|
||||
static const double caption = 12;
|
||||
static const double emptyStateSubtitle = 12.5;
|
||||
@@ -24,16 +29,29 @@ abstract final class AppDimens {
|
||||
static const double sectionTitle = 16;
|
||||
static const double pageTitle = 20;
|
||||
static const double detailHeadline = 24;
|
||||
static const double navLabel = 11;
|
||||
static const double detailHeadlineSmall = 18;
|
||||
static const double previewCardTitle = 13;
|
||||
static const double previewSectionTitle = 14;
|
||||
static const double chipFont = 10;
|
||||
static const double navLabel = 9;
|
||||
|
||||
static const double buttonHeight = 48;
|
||||
static const double primaryButtonHeight = 52;
|
||||
static const double buttonCompact = 32;
|
||||
static const double buttonSmall = 34;
|
||||
static const double buttonMedium = 40;
|
||||
static const double buttonLarge = 44;
|
||||
static const double tapTarget = 44;
|
||||
|
||||
static const double navHeight = 64;
|
||||
static const double navIcon = 22;
|
||||
static const double progressHeight = 4;
|
||||
static const double progressRadius = 4;
|
||||
static const double navHeight = 54;
|
||||
static const double navIcon = 17;
|
||||
static const double progressHeight = 3;
|
||||
static const double progressRadius = 2;
|
||||
static const double progressLineHeight = 3;
|
||||
|
||||
static const double searchHeight = 44;
|
||||
static const double navDot = 3;
|
||||
static const double glassBlur = 24;
|
||||
|
||||
static const double accountCardHeight = 64;
|
||||
static const double accountIcon = 18;
|
||||
|
||||
@@ -6,9 +6,10 @@ import 'package:flutter/services.dart';
|
||||
import 'app_colors.dart';
|
||||
import 'app_dimens.dart';
|
||||
|
||||
/// Light paper and warm-cocoa themes for the Indonesia evening palette.
|
||||
/// Obsidian Flare themes. Dark is the product default; light mirrors the same
|
||||
/// orange primary for system-light devices.
|
||||
abstract final class AppTheme {
|
||||
static const _lightBackground = Color(0xFFFFF6F1);
|
||||
static const _lightBackground = Color(0xFFF7F7F8);
|
||||
static const _darkBackground = AppColors.ink;
|
||||
|
||||
/// Light theme for system light mode.
|
||||
@@ -22,7 +23,7 @@ abstract final class AppTheme {
|
||||
static ThemeData dark() => _build(
|
||||
brightness: Brightness.dark,
|
||||
surface: _darkBackground,
|
||||
accent: AppColors.blueSoft,
|
||||
accent: AppColors.blue,
|
||||
);
|
||||
|
||||
static ThemeData _build({
|
||||
@@ -35,27 +36,27 @@ abstract final class AppTheme {
|
||||
? const ColorScheme.dark(
|
||||
primary: AppColors.blue,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
secondary: AppColors.blueSoft,
|
||||
onSecondary: AppColors.ink,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.onPrimary,
|
||||
primaryContainer: AppColors.bluePressed,
|
||||
onPrimaryContainer: AppColors.onPrimary,
|
||||
secondaryContainer: AppColors.bluePressed,
|
||||
secondaryContainer: AppColors.raised,
|
||||
onSecondaryContainer: AppColors.onPrimary,
|
||||
surface: _darkBackground,
|
||||
onSurface: AppColors.cream,
|
||||
onSurface: AppColors.onSurface,
|
||||
outline: AppColors.outlineDark,
|
||||
outlineVariant: AppColors.outlineVariantDark,
|
||||
surfaceTint: AppColors.blueSoft,
|
||||
surfaceTint: AppColors.blue,
|
||||
inversePrimary: AppColors.blueSoft,
|
||||
)
|
||||
: ColorScheme.light(
|
||||
primary: AppColors.blue,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
secondary: AppColors.blue,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.onPrimary,
|
||||
primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
|
||||
primaryContainer: AppColors.blue.withValues(alpha: 0.14),
|
||||
onPrimaryContainer: AppColors.ink,
|
||||
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22),
|
||||
secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14),
|
||||
onSecondaryContainer: AppColors.ink,
|
||||
surface: _lightBackground,
|
||||
onSurface: AppColors.ink,
|
||||
@@ -64,11 +65,12 @@ abstract final class AppTheme {
|
||||
surfaceTint: AppColors.blue,
|
||||
inversePrimary: AppColors.blueSoft,
|
||||
);
|
||||
return ThemeData(
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: surface,
|
||||
fontFamily: 'PlusJakartaSans',
|
||||
progressIndicatorTheme: ProgressIndicatorThemeData(color: accent),
|
||||
sliderTheme: SliderThemeData(
|
||||
activeTrackColor: accent,
|
||||
@@ -105,7 +107,8 @@ abstract final class AppTheme {
|
||||
appBarTheme: const AppBarTheme(
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
titleTextStyle: TextStyle(
|
||||
color: AppColors.cream,
|
||||
color: AppColors.onSurface,
|
||||
fontFamily: 'Outfit',
|
||||
fontSize: AppDimens.pageTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
@@ -125,8 +128,29 @@ abstract final class AppTheme {
|
||||
contentTextStyle: const TextStyle(color: AppColors.onPrimary),
|
||||
),
|
||||
);
|
||||
final textTheme = base.textTheme
|
||||
.apply(
|
||||
fontFamily: 'PlusJakartaSans',
|
||||
bodyColor: dark ? AppColors.cream : AppColors.ink,
|
||||
displayColor: dark ? AppColors.onSurface : AppColors.ink,
|
||||
)
|
||||
.copyWith(
|
||||
displayLarge: _display(base.textTheme.displayLarge, dark),
|
||||
displayMedium: _display(base.textTheme.displayMedium, dark),
|
||||
displaySmall: _display(base.textTheme.displaySmall, dark),
|
||||
headlineLarge: _display(base.textTheme.headlineLarge, dark),
|
||||
headlineMedium: _display(base.textTheme.headlineMedium, dark),
|
||||
headlineSmall: _display(base.textTheme.headlineSmall, dark),
|
||||
titleLarge: _display(base.textTheme.titleLarge, dark),
|
||||
);
|
||||
return base.copyWith(textTheme: textTheme);
|
||||
}
|
||||
|
||||
static TextStyle? _display(TextStyle? style, bool dark) => style?.copyWith(
|
||||
fontFamily: 'Outfit',
|
||||
color: dark ? AppColors.onSurface : AppColors.ink,
|
||||
);
|
||||
|
||||
static Color _buttonColor(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.pressed) ||
|
||||
states.contains(WidgetState.hovered) ||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
@@ -24,7 +26,7 @@ class AppBottomNavItem {
|
||||
final bool selected;
|
||||
}
|
||||
|
||||
/// Shared bottom navigation used by all root tabs and the player fallback.
|
||||
/// Shared 3-tab navigation used by all root tabs and the player fallback.
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
const AppBottomNav({
|
||||
super.key,
|
||||
@@ -38,20 +40,25 @@ class AppBottomNav extends StatelessWidget {
|
||||
final Key? barKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: const Border(
|
||||
top: BorderSide(color: AppColors.outlineVariantDark),
|
||||
Widget build(BuildContext context) => ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
key: barKey,
|
||||
height: AppDimens.navHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withValues(alpha: 0.95),
|
||||
border: const Border(
|
||||
top: BorderSide(color: AppColors.outlineVariantDark),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [for (final item in items) _NavDestination(item: item)],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [for (final item in items) _NavDestination(item: item)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -71,25 +78,20 @@ class _NavDestination extends StatelessWidget {
|
||||
key: item.tabKey,
|
||||
onTap: item.onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
AnimatedScale(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
curve: Curves.easeOut,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.selected ? AppColors.blue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
),
|
||||
scale: item.selected ? 1 : 0.96,
|
||||
child: Icon(
|
||||
item.selected ? item.selectedIcon : item.icon,
|
||||
size: AppDimens.navIcon,
|
||||
color: item.selected ? AppColors.onPrimary : AppColors.muted,
|
||||
color: item.selected
|
||||
? AppColors.blue
|
||||
: Colors.white.withValues(alpha: 0.45),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@@ -100,7 +102,10 @@ class _NavDestination extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: AppDimens.navLabel,
|
||||
fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: item.selected ? AppColors.blueSoft : AppColors.muted,
|
||||
color: item.selected
|
||||
? AppColors.blue
|
||||
: Colors.white.withValues(alpha: 0.45),
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
/// Dark glass surface used by the home info card and floating controls.
|
||||
class GlassPanel extends StatelessWidget {
|
||||
const GlassPanel({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.radius = AppDimens.glassRadius,
|
||||
this.blur = 10,
|
||||
this.padding,
|
||||
this.borderColor = AppColors.glassBorder,
|
||||
this.gradient,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final double radius;
|
||||
final double blur;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final Color borderColor;
|
||||
final Gradient? gradient;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderRadius = BorderRadius.circular(radius);
|
||||
final surface = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: borderRadius,
|
||||
border: Border.all(color: borderColor),
|
||||
gradient:
|
||||
gradient ??
|
||||
const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0x24FFFFFF), Color(0x0DFFFFFF)],
|
||||
),
|
||||
),
|
||||
child: Padding(padding: padding ?? EdgeInsets.zero, child: child),
|
||||
);
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: surface,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -15,6 +15,14 @@
|
||||
"discoverTab": "Discover",
|
||||
"searchDiscover": "Search dramas, actors, genres...",
|
||||
"featured": "Featured",
|
||||
"recommended": "Recommended",
|
||||
"fruitShortsBrand": "FruitShorts",
|
||||
"authLandingSubtitle": "Sign in to sync progress, favorites and coins",
|
||||
"comingSoon": "Coming soon",
|
||||
"authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.",
|
||||
"authContinueGoogle": "Continue with Google",
|
||||
"authPhoneSignIn": "Phone sign in",
|
||||
"authEmailSignIn": "Email sign in",
|
||||
"suspense": "Suspense",
|
||||
"romance": "Romance",
|
||||
"actionGenre": "Action",
|
||||
@@ -23,6 +31,30 @@
|
||||
"newReleases": "New Releases",
|
||||
"trendingMicroDramas": "Trending Micro-Dramas",
|
||||
"seeAll": "See All",
|
||||
"trendingSubtitle": "Most binge-watched this week",
|
||||
"verticalMiniSeries": "VERTICAL MINI-SERIES",
|
||||
"followSeries": "Follow Series",
|
||||
"seasonOneVerticalCinema": "Season 1 • Vertical Cinema",
|
||||
"seasonOneOriginalRelease": "Season 1 • Original Release",
|
||||
"startWatching": "Start Watching",
|
||||
"showLess": "Less",
|
||||
"views": "views",
|
||||
"episodeOfTotal": "Episode {current} / {total}",
|
||||
"@episodeOfTotal": {
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"clearScreen": "Clear screen",
|
||||
"exitClearScreen": "Exit clear screen",
|
||||
"playbackSpeed": "Playback speed",
|
||||
"minutesAgo": "{count} min ago",
|
||||
"@minutesAgo": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"artworkOnly": "Artwork",
|
||||
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
|
||||
"videoPending": "Video pending",
|
||||
@@ -44,7 +76,7 @@
|
||||
},
|
||||
"resume": "Resume",
|
||||
"history": "History",
|
||||
"watchlist": "Watchlist",
|
||||
"watchlist": "Favorites",
|
||||
"likedTab": "Liked",
|
||||
"all": "All",
|
||||
"inProgress": "In Progress",
|
||||
@@ -113,8 +145,6 @@
|
||||
"reportFailed": "Could not submit report.",
|
||||
"shareFailed": "Could not open the share sheet.",
|
||||
"nowPlaying": "Playing now",
|
||||
"watchFullscreen": "Watch in landscape",
|
||||
"exitFullscreen": "Exit fullscreen",
|
||||
"pictureInPicture": "Picture in picture",
|
||||
"pipUnavailable": "Picture in picture is unavailable on this device",
|
||||
"originalTag": "Original",
|
||||
@@ -243,8 +273,6 @@
|
||||
"myListTab": "My List",
|
||||
"watchFull": "Watch Full",
|
||||
"swipeForNext": "Swipe up for next",
|
||||
"clearScreen": "Clear screen",
|
||||
"exitClearScreen": "Exit clear screen",
|
||||
"logIn": "Log in",
|
||||
"logInHint": "Keep your coins and list",
|
||||
"rewards": "Rewards",
|
||||
|
||||
+33
-5
@@ -12,9 +12,17 @@
|
||||
"openQuickMenu": "Buka menu cepat",
|
||||
"quickAccess": "Akses cepat",
|
||||
"currentSeries": "Serial saat ini",
|
||||
"discoverTab": "Jelajahi",
|
||||
"discoverTab": "Temukan",
|
||||
"searchDiscover": "Cari drama, aktor, genre...",
|
||||
"featured": "Unggulan",
|
||||
"recommended": "Rekomendasi",
|
||||
"fruitShortsBrand": "FruitShorts",
|
||||
"authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin",
|
||||
"comingSoon": "Segera hadir",
|
||||
"authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.",
|
||||
"authContinueGoogle": "Lanjutkan dengan Google",
|
||||
"authPhoneSignIn": "Masuk dengan ponsel",
|
||||
"authEmailSignIn": "Masuk dengan email",
|
||||
"suspense": "Thriller",
|
||||
"romance": "Romantis",
|
||||
"actionGenre": "Aksi",
|
||||
@@ -23,6 +31,30 @@
|
||||
"newReleases": "Rilisan Baru",
|
||||
"trendingMicroDramas": "Drama Mini Populer",
|
||||
"seeAll": "Lihat Semua",
|
||||
"trendingSubtitle": "Paling banyak ditonton minggu ini",
|
||||
"verticalMiniSeries": "SERIAL MINI VERTIKAL",
|
||||
"followSeries": "Ikuti Series",
|
||||
"seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal",
|
||||
"seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal",
|
||||
"startWatching": "Mulai Menonton",
|
||||
"showLess": "Lebih sedikit",
|
||||
"views": "tayangan",
|
||||
"episodeOfTotal": "Episode {current} / {total}",
|
||||
"@episodeOfTotal": {
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"clearScreen": "Bersihkan layar",
|
||||
"exitClearScreen": "Keluar dari layar bersih",
|
||||
"playbackSpeed": "Kecepatan putar",
|
||||
"minutesAgo": "{count} mnt lalu",
|
||||
"@minutesAgo": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"artworkOnly": "Gambar",
|
||||
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
|
||||
"videoPending": "Video belum siap",
|
||||
@@ -113,8 +145,6 @@
|
||||
"reportFailed": "Laporan tidak dapat dikirim.",
|
||||
"shareFailed": "Tidak bisa membuka menu berbagi.",
|
||||
"nowPlaying": "Sedang diputar",
|
||||
"watchFullscreen": "Tonton mode lanskap",
|
||||
"exitFullscreen": "Keluar dari layar penuh",
|
||||
"pictureInPicture": "Gambar dalam gambar",
|
||||
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
|
||||
"originalTag": "Original",
|
||||
@@ -243,8 +273,6 @@
|
||||
"myListTab": "Daftar Saya",
|
||||
"watchFull": "Tonton Penuh",
|
||||
"swipeForNext": "Geser ke atas untuk lanjut",
|
||||
"clearScreen": "Bersihkan layar",
|
||||
"exitClearScreen": "Keluar dari mode bersih",
|
||||
"logIn": "Masuk",
|
||||
"logInHint": "Simpan koin dan daftar kamu",
|
||||
"rewards": "Hadiah",
|
||||
|
||||
+127
-25
@@ -190,6 +190,54 @@ abstract class AppLocalizations {
|
||||
/// **'Featured'**
|
||||
String get featured;
|
||||
|
||||
/// No description provided for @recommended.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recommended'**
|
||||
String get recommended;
|
||||
|
||||
/// No description provided for @fruitShortsBrand.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'FruitShorts'**
|
||||
String get fruitShortsBrand;
|
||||
|
||||
/// No description provided for @authLandingSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign in to sync progress, favorites and coins'**
|
||||
String get authLandingSubtitle;
|
||||
|
||||
/// No description provided for @comingSoon.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Coming soon'**
|
||||
String get comingSoon;
|
||||
|
||||
/// No description provided for @authLegal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'By continuing, you agree to the Terms of Service and Privacy Policy.'**
|
||||
String get authLegal;
|
||||
|
||||
/// No description provided for @authContinueGoogle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue with Google'**
|
||||
String get authContinueGoogle;
|
||||
|
||||
/// No description provided for @authPhoneSignIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Phone sign in'**
|
||||
String get authPhoneSignIn;
|
||||
|
||||
/// No description provided for @authEmailSignIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Email sign in'**
|
||||
String get authEmailSignIn;
|
||||
|
||||
/// No description provided for @suspense.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -238,6 +286,84 @@ abstract class AppLocalizations {
|
||||
/// **'See All'**
|
||||
String get seeAll;
|
||||
|
||||
/// No description provided for @trendingSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Most binge-watched this week'**
|
||||
String get trendingSubtitle;
|
||||
|
||||
/// No description provided for @verticalMiniSeries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'VERTICAL MINI-SERIES'**
|
||||
String get verticalMiniSeries;
|
||||
|
||||
/// No description provided for @followSeries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Follow Series'**
|
||||
String get followSeries;
|
||||
|
||||
/// No description provided for @seasonOneVerticalCinema.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Season 1 • Vertical Cinema'**
|
||||
String get seasonOneVerticalCinema;
|
||||
|
||||
/// No description provided for @seasonOneOriginalRelease.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Season 1 • Original Release'**
|
||||
String get seasonOneOriginalRelease;
|
||||
|
||||
/// No description provided for @startWatching.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start Watching'**
|
||||
String get startWatching;
|
||||
|
||||
/// No description provided for @showLess.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Less'**
|
||||
String get showLess;
|
||||
|
||||
/// No description provided for @views.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'views'**
|
||||
String get views;
|
||||
|
||||
/// No description provided for @episodeOfTotal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Episode {current} / {total}'**
|
||||
String episodeOfTotal(int current, int total);
|
||||
|
||||
/// No description provided for @clearScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear screen'**
|
||||
String get clearScreen;
|
||||
|
||||
/// No description provided for @exitClearScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exit clear screen'**
|
||||
String get exitClearScreen;
|
||||
|
||||
/// No description provided for @playbackSpeed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Playback speed'**
|
||||
String get playbackSpeed;
|
||||
|
||||
/// No description provided for @minutesAgo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count} min ago'**
|
||||
String minutesAgo(int count);
|
||||
|
||||
/// No description provided for @artworkOnly.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -325,7 +451,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @watchlist.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watchlist'**
|
||||
/// **'Favorites'**
|
||||
String get watchlist;
|
||||
|
||||
/// No description provided for @likedTab.
|
||||
@@ -664,18 +790,6 @@ abstract class AppLocalizations {
|
||||
/// **'Playing now'**
|
||||
String get nowPlaying;
|
||||
|
||||
/// No description provided for @watchFullscreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch in landscape'**
|
||||
String get watchFullscreen;
|
||||
|
||||
/// No description provided for @exitFullscreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exit fullscreen'**
|
||||
String get exitFullscreen;
|
||||
|
||||
/// No description provided for @pictureInPicture.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1114,18 +1228,6 @@ abstract class AppLocalizations {
|
||||
/// **'Swipe up for next'**
|
||||
String get swipeForNext;
|
||||
|
||||
/// No description provided for @clearScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear screen'**
|
||||
String get clearScreen;
|
||||
|
||||
/// No description provided for @exitClearScreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Exit clear screen'**
|
||||
String get exitClearScreen;
|
||||
|
||||
/// No description provided for @logIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -54,6 +54,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get featured => 'Featured';
|
||||
|
||||
@override
|
||||
String get recommended => 'Recommended';
|
||||
|
||||
@override
|
||||
String get fruitShortsBrand => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get authLandingSubtitle =>
|
||||
'Sign in to sync progress, favorites and coins';
|
||||
|
||||
@override
|
||||
String get comingSoon => 'Coming soon';
|
||||
|
||||
@override
|
||||
String get authLegal =>
|
||||
'By continuing, you agree to the Terms of Service and Privacy Policy.';
|
||||
|
||||
@override
|
||||
String get authContinueGoogle => 'Continue with Google';
|
||||
|
||||
@override
|
||||
String get authPhoneSignIn => 'Phone sign in';
|
||||
|
||||
@override
|
||||
String get authEmailSignIn => 'Email sign in';
|
||||
|
||||
@override
|
||||
String get suspense => 'Suspense';
|
||||
|
||||
@@ -78,6 +104,49 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => 'See All';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => 'Most binge-watched this week';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => 'VERTICAL MINI-SERIES';
|
||||
|
||||
@override
|
||||
String get followSeries => 'Follow Series';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema';
|
||||
|
||||
@override
|
||||
String get seasonOneOriginalRelease => 'Season 1 • Original Release';
|
||||
|
||||
@override
|
||||
String get startWatching => 'Start Watching';
|
||||
|
||||
@override
|
||||
String get showLess => 'Less';
|
||||
|
||||
@override
|
||||
String get views => 'views';
|
||||
|
||||
@override
|
||||
String episodeOfTotal(int current, int total) {
|
||||
return 'Episode $current / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get clearScreen => 'Clear screen';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => 'Exit clear screen';
|
||||
|
||||
@override
|
||||
String get playbackSpeed => 'Playback speed';
|
||||
|
||||
@override
|
||||
String minutesAgo(int count) {
|
||||
return '$count min ago';
|
||||
}
|
||||
|
||||
@override
|
||||
String get artworkOnly => 'Artwork';
|
||||
|
||||
@@ -125,7 +194,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get history => 'History';
|
||||
|
||||
@override
|
||||
String get watchlist => 'Watchlist';
|
||||
String get watchlist => 'Favorites';
|
||||
|
||||
@override
|
||||
String get likedTab => 'Liked';
|
||||
@@ -300,12 +369,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get nowPlaying => 'Playing now';
|
||||
|
||||
@override
|
||||
String get watchFullscreen => 'Watch in landscape';
|
||||
|
||||
@override
|
||||
String get exitFullscreen => 'Exit fullscreen';
|
||||
|
||||
@override
|
||||
String get pictureInPicture => 'Picture in picture';
|
||||
|
||||
@@ -544,12 +607,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get swipeForNext => 'Swipe up for next';
|
||||
|
||||
@override
|
||||
String get clearScreen => 'Clear screen';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => 'Exit clear screen';
|
||||
|
||||
@override
|
||||
String get logIn => 'Log in';
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get currentSeries => 'Serial saat ini';
|
||||
|
||||
@override
|
||||
String get discoverTab => 'Jelajahi';
|
||||
String get discoverTab => 'Temukan';
|
||||
|
||||
@override
|
||||
String get searchDiscover => 'Cari drama, aktor, genre...';
|
||||
@@ -54,6 +54,32 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get featured => 'Unggulan';
|
||||
|
||||
@override
|
||||
String get recommended => 'Rekomendasi';
|
||||
|
||||
@override
|
||||
String get fruitShortsBrand => 'FruitShorts';
|
||||
|
||||
@override
|
||||
String get authLandingSubtitle =>
|
||||
'Masuk untuk menyinkronkan progres, favorit, dan koin';
|
||||
|
||||
@override
|
||||
String get comingSoon => 'Segera hadir';
|
||||
|
||||
@override
|
||||
String get authLegal =>
|
||||
'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.';
|
||||
|
||||
@override
|
||||
String get authContinueGoogle => 'Lanjutkan dengan Google';
|
||||
|
||||
@override
|
||||
String get authPhoneSignIn => 'Masuk dengan ponsel';
|
||||
|
||||
@override
|
||||
String get authEmailSignIn => 'Masuk dengan email';
|
||||
|
||||
@override
|
||||
String get suspense => 'Thriller';
|
||||
|
||||
@@ -78,6 +104,49 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => 'Lihat Semua';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => 'Paling banyak ditonton minggu ini';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => 'SERIAL MINI VERTIKAL';
|
||||
|
||||
@override
|
||||
String get followSeries => 'Ikuti Series';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal';
|
||||
|
||||
@override
|
||||
String get seasonOneOriginalRelease => 'Musim 1 • Rilis Orisinal';
|
||||
|
||||
@override
|
||||
String get startWatching => 'Mulai Menonton';
|
||||
|
||||
@override
|
||||
String get showLess => 'Lebih sedikit';
|
||||
|
||||
@override
|
||||
String get views => 'tayangan';
|
||||
|
||||
@override
|
||||
String episodeOfTotal(int current, int total) {
|
||||
return 'Episode $current / $total';
|
||||
}
|
||||
|
||||
@override
|
||||
String get clearScreen => 'Bersihkan layar';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => 'Keluar dari layar bersih';
|
||||
|
||||
@override
|
||||
String get playbackSpeed => 'Kecepatan putar';
|
||||
|
||||
@override
|
||||
String minutesAgo(int count) {
|
||||
return '$count mnt lalu';
|
||||
}
|
||||
|
||||
@override
|
||||
String get artworkOnly => 'Gambar';
|
||||
|
||||
@@ -300,12 +369,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get nowPlaying => 'Sedang diputar';
|
||||
|
||||
@override
|
||||
String get watchFullscreen => 'Tonton mode lanskap';
|
||||
|
||||
@override
|
||||
String get exitFullscreen => 'Keluar dari layar penuh';
|
||||
|
||||
@override
|
||||
String get pictureInPicture => 'Gambar dalam gambar';
|
||||
|
||||
@@ -546,12 +609,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get swipeForNext => 'Geser ke atas untuk lanjut';
|
||||
|
||||
@override
|
||||
String get clearScreen => 'Bersihkan layar';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => 'Keluar dari mode bersih';
|
||||
|
||||
@override
|
||||
String get logIn => 'Masuk';
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get currentSeries => '当前短剧';
|
||||
|
||||
@override
|
||||
String get discoverTab => '找剧';
|
||||
String get discoverTab => '发现';
|
||||
|
||||
@override
|
||||
String get searchDiscover => '搜索短剧、演员、类型…';
|
||||
@@ -53,6 +53,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get featured => '精选';
|
||||
|
||||
@override
|
||||
String get recommended => '推荐';
|
||||
|
||||
@override
|
||||
String get fruitShortsBrand => '水果短剧';
|
||||
|
||||
@override
|
||||
String get authLandingSubtitle => '登录后同步进度、收藏与金币权益';
|
||||
|
||||
@override
|
||||
String get comingSoon => '即将开放';
|
||||
|
||||
@override
|
||||
String get authLegal => '继续即代表同意《用户协议》与《隐私政策》';
|
||||
|
||||
@override
|
||||
String get authContinueGoogle => '使用 Google 继续';
|
||||
|
||||
@override
|
||||
String get authPhoneSignIn => '手机号登录';
|
||||
|
||||
@override
|
||||
String get authEmailSignIn => '邮箱登录';
|
||||
|
||||
@override
|
||||
String get suspense => '悬疑';
|
||||
|
||||
@@ -77,6 +101,49 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get seeAll => '查看全部';
|
||||
|
||||
@override
|
||||
String get trendingSubtitle => '本周追看最多';
|
||||
|
||||
@override
|
||||
String get verticalMiniSeries => '竖屏短剧';
|
||||
|
||||
@override
|
||||
String get followSeries => '追剧';
|
||||
|
||||
@override
|
||||
String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院';
|
||||
|
||||
@override
|
||||
String get seasonOneOriginalRelease => '第 1 季 · 原创上线';
|
||||
|
||||
@override
|
||||
String get startWatching => '开始观看';
|
||||
|
||||
@override
|
||||
String get showLess => '收起';
|
||||
|
||||
@override
|
||||
String get views => '次播放';
|
||||
|
||||
@override
|
||||
String episodeOfTotal(int current, int total) {
|
||||
return '第 $current 集 / 共 $total 集';
|
||||
}
|
||||
|
||||
@override
|
||||
String get clearScreen => '清屏';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => '退出清屏';
|
||||
|
||||
@override
|
||||
String get playbackSpeed => '倍速';
|
||||
|
||||
@override
|
||||
String minutesAgo(int count) {
|
||||
return '$count 分钟前';
|
||||
}
|
||||
|
||||
@override
|
||||
String get artworkOnly => '海报展示';
|
||||
|
||||
@@ -122,7 +189,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get history => '历史';
|
||||
|
||||
@override
|
||||
String get watchlist => '追剧';
|
||||
String get watchlist => '收藏';
|
||||
|
||||
@override
|
||||
String get likedTab => '喜欢';
|
||||
@@ -296,12 +363,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get nowPlaying => '正在播放';
|
||||
|
||||
@override
|
||||
String get watchFullscreen => '横屏全屏观看';
|
||||
|
||||
@override
|
||||
String get exitFullscreen => '退出全屏';
|
||||
|
||||
@override
|
||||
String get pictureInPicture => '画中画';
|
||||
|
||||
@@ -535,12 +596,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get swipeForNext => '上滑看下一部';
|
||||
|
||||
@override
|
||||
String get clearScreen => '清屏';
|
||||
|
||||
@override
|
||||
String get exitClearScreen => '退出清屏';
|
||||
|
||||
@override
|
||||
String get logIn => '登录';
|
||||
|
||||
|
||||
+34
-6
@@ -12,9 +12,17 @@
|
||||
"openQuickMenu": "打开快捷菜单",
|
||||
"quickAccess": "快捷入口",
|
||||
"currentSeries": "当前短剧",
|
||||
"discoverTab": "找剧",
|
||||
"discoverTab": "发现",
|
||||
"searchDiscover": "搜索短剧、演员、类型…",
|
||||
"featured": "精选",
|
||||
"recommended": "推荐",
|
||||
"fruitShortsBrand": "水果短剧",
|
||||
"authLandingSubtitle": "登录后同步进度、收藏与金币权益",
|
||||
"comingSoon": "即将开放",
|
||||
"authLegal": "继续即代表同意《用户协议》与《隐私政策》",
|
||||
"authContinueGoogle": "使用 Google 继续",
|
||||
"authPhoneSignIn": "手机号登录",
|
||||
"authEmailSignIn": "邮箱登录",
|
||||
"suspense": "悬疑",
|
||||
"romance": "都市爱情",
|
||||
"actionGenre": "动作",
|
||||
@@ -23,6 +31,30 @@
|
||||
"newReleases": "新剧",
|
||||
"trendingMicroDramas": "热门短剧",
|
||||
"seeAll": "查看全部",
|
||||
"trendingSubtitle": "本周追看最多",
|
||||
"verticalMiniSeries": "竖屏短剧",
|
||||
"followSeries": "追剧",
|
||||
"seasonOneVerticalCinema": "第 1 季 · 竖屏影院",
|
||||
"seasonOneOriginalRelease": "第 1 季 · 原创上线",
|
||||
"startWatching": "开始观看",
|
||||
"showLess": "收起",
|
||||
"views": "次播放",
|
||||
"episodeOfTotal": "第 {current} 集 / 共 {total} 集",
|
||||
"@episodeOfTotal": {
|
||||
"placeholders": {
|
||||
"current": {"type": "int"},
|
||||
"total": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"clearScreen": "清屏",
|
||||
"exitClearScreen": "退出清屏",
|
||||
"playbackSpeed": "倍速",
|
||||
"minutesAgo": "{count} 分钟前",
|
||||
"@minutesAgo": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"artworkOnly": "海报展示",
|
||||
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
|
||||
"videoPending": "视频待提供",
|
||||
@@ -44,7 +76,7 @@
|
||||
},
|
||||
"resume": "继续播放",
|
||||
"history": "历史",
|
||||
"watchlist": "追剧",
|
||||
"watchlist": "收藏",
|
||||
"likedTab": "喜欢",
|
||||
"all": "全部",
|
||||
"inProgress": "未看完",
|
||||
@@ -113,8 +145,6 @@
|
||||
"reportFailed": "举报提交失败,请重试。",
|
||||
"shareFailed": "无法打开系统分享面板。",
|
||||
"nowPlaying": "正在播放",
|
||||
"watchFullscreen": "横屏全屏观看",
|
||||
"exitFullscreen": "退出全屏",
|
||||
"pictureInPicture": "画中画",
|
||||
"pipUnavailable": "此设备无法使用画中画",
|
||||
"originalTag": "原创",
|
||||
@@ -205,8 +235,6 @@
|
||||
"myListTab": "片单",
|
||||
"watchFull": "看全集",
|
||||
"swipeForNext": "上滑看下一部",
|
||||
"clearScreen": "清屏",
|
||||
"exitClearScreen": "退出清屏",
|
||||
"logIn": "登录",
|
||||
"logInHint": "登录后保存金币和片单",
|
||||
"rewards": "奖励",
|
||||
|
||||
@@ -60,6 +60,7 @@ class _AuthPageState extends State<AuthPage> {
|
||||
late final FocusNode _emailFocus;
|
||||
late final FocusNode _passwordFocus;
|
||||
bool _obscurePassword = true;
|
||||
bool _emailEntryOpen = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -243,7 +244,13 @@ class _AuthPageState extends State<AuthPage> {
|
||||
tooltip: MaterialLocalizations.of(
|
||||
context,
|
||||
).backButtonTooltip,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () {
|
||||
if (_emailEntryOpen) {
|
||||
setState(() => _emailEntryOpen = false);
|
||||
} else {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
@@ -272,16 +279,107 @@ class _AuthPageState extends State<AuthPage> {
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
|
||||
SizedBox(height: compact ? _compactBrandGap : 28),
|
||||
_authCard(l10n),
|
||||
],
|
||||
),
|
||||
child: _emailEntryOpen
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
|
||||
SizedBox(height: compact ? _compactBrandGap : 28),
|
||||
_authCard(l10n),
|
||||
],
|
||||
)
|
||||
: _landing(l10n),
|
||||
);
|
||||
|
||||
Widget _landing(AppLocalizations l10n) => Column(
|
||||
key: const Key('auth-landing'),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
key: const Key('auth-brand-logo'),
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.blue, AppColors.secondary],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
l10n.fruitShortsBrand,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.authLandingSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
_AuthEntryButton(
|
||||
key: const Key('auth-entry-google'),
|
||||
label: 'G ${l10n.authContinueGoogle}',
|
||||
background: Colors.white,
|
||||
foreground: const Color(0xFF1A1A1A),
|
||||
leadingColor: const Color(0xFF4285F4),
|
||||
onTap: () => _comingSoon(l10n),
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
_AuthEntryButton(
|
||||
key: const Key('auth-entry-phone'),
|
||||
label: l10n.authPhoneSignIn,
|
||||
icon: Icons.phone_android_rounded,
|
||||
background: AppColors.input,
|
||||
foreground: AppColors.onSurface,
|
||||
onTap: () => _comingSoon(l10n),
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
_AuthEntryButton(
|
||||
key: const Key('auth-entry-email'),
|
||||
label: l10n.authEmailSignIn,
|
||||
icon: Icons.mail_outline_rounded,
|
||||
background: AppColors.input,
|
||||
foreground: AppColors.onSurface,
|
||||
onTap: () => setState(() => _emailEntryOpen = true),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l10n.authLegal,
|
||||
key: const Key('auth-legal'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6B6772),
|
||||
fontSize: 9,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
void _comingSoon(AppLocalizations l10n) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.comingSoon)));
|
||||
}
|
||||
|
||||
Widget _authCard(AppLocalizations l10n) => _AuthCard(
|
||||
state: _AuthCardState(
|
||||
l10n: l10n,
|
||||
@@ -320,3 +418,57 @@ class _AuthPageState extends State<AuthPage> {
|
||||
setState(() => _logic.toggleMode());
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthEntryButton extends StatelessWidget {
|
||||
const _AuthEntryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.onTap,
|
||||
this.icon,
|
||||
this.leadingColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final VoidCallback onTap;
|
||||
final IconData? icon;
|
||||
final Color? leadingColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null)
|
||||
Icon(icon, size: 16, color: leadingColor ?? foreground),
|
||||
if (icon != null) const SizedBox(width: 7),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,5 @@ class DiscoverBinding {
|
||||
DiscoverLogic create({
|
||||
required Drama drama,
|
||||
required WatchHistoryRepository history,
|
||||
}) => DiscoverLogic(
|
||||
drama: drama,
|
||||
history: history,
|
||||
);
|
||||
}) => DiscoverLogic(drama: drama, history: history);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
@@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
final WatchHistoryRepository history;
|
||||
Drama drama;
|
||||
late Future<Duration?> position;
|
||||
WatchProgress? resumeRecord;
|
||||
final TextEditingController search = TextEditingController();
|
||||
final FocusNode searchFocus = FocusNode();
|
||||
final ScrollController scroll = ScrollController();
|
||||
@@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier {
|
||||
.resumePointFor(drama.id)
|
||||
.then((point) => point?.position)
|
||||
.catchError((Object _) => null);
|
||||
refreshResume();
|
||||
}
|
||||
|
||||
Future<void> refreshResume() async {
|
||||
try {
|
||||
final recent = await history.recent(limit: 1);
|
||||
if (recent.isEmpty || recent.first.finished) {
|
||||
resumeRecord = null;
|
||||
} else {
|
||||
resumeRecord = recent.first;
|
||||
}
|
||||
} catch (_) {
|
||||
resumeRecord = null;
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
void onSearchChanged() => notify();
|
||||
|
||||
List<Drama> filtered(List<Drama> dramas) {
|
||||
final query = search.text.trim().toLowerCase();
|
||||
if (query.isEmpty) return dramas;
|
||||
return dramas.where((drama) {
|
||||
final haystack = [
|
||||
drama.title,
|
||||
drama.genre,
|
||||
drama.category,
|
||||
drama.synopsis,
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Drama? resumeDrama(List<Drama> dramas) {
|
||||
final record = resumeRecord;
|
||||
if (record == null) return null;
|
||||
for (final drama in dramas) {
|
||||
if (drama.id == record.dramaId) return drama;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get showSearch => searchOpen || search.text.isNotEmpty;
|
||||
|
||||
void openSearch() {
|
||||
|
||||
@@ -4,10 +4,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/models/watch_progress.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
@@ -18,7 +18,6 @@ import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'discover_binding.dart';
|
||||
import 'discover_logic.dart';
|
||||
import 'widgets/cover_carousel.dart';
|
||||
|
||||
part 'widgets/home_drama_grid.dart';
|
||||
|
||||
@@ -70,7 +69,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
drama: widget.drama,
|
||||
history: widget.history ?? context.read<WatchHistoryRepository>(),
|
||||
);
|
||||
if (widget.focusSearch) logic.searchOpen = true;
|
||||
if (widget.focusSearch) {
|
||||
logic.searchOpen = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.active) logic.searchFocus.requestFocus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -104,14 +108,14 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final categories = context.watch<CategoryController>();
|
||||
final homeBanners = context.watch<CatalogController>().feed.banners;
|
||||
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
|
||||
final visible = logic.filtered(categories.visibleDramas);
|
||||
final content = SafeArea(
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_header(l10n),
|
||||
Expanded(child: _feed(l10n, categories, homeBanners, allDramas)),
|
||||
Expanded(child: _feed(l10n, categories, allDramas, visible)),
|
||||
if (!widget.embedded) _bottomNav(l10n),
|
||||
],
|
||||
),
|
||||
@@ -130,134 +134,216 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
Widget _feed(
|
||||
AppLocalizations l10n,
|
||||
CategoryController categories,
|
||||
List<FeedBanner> banners,
|
||||
List<Drama> allDramas,
|
||||
) => CustomScrollView(
|
||||
controller: logic.scroll,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: _carousel(l10n, banners, allDramas),
|
||||
List<Drama> visible,
|
||||
) => RefreshIndicator(
|
||||
key: const Key('discover-refresh'),
|
||||
color: AppColors.blue,
|
||||
backgroundColor: AppColors.card,
|
||||
onRefresh: () async {
|
||||
await Future.wait([categories.load(), logic.refreshResume()]);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
controller: logic.scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: _search()),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12),
|
||||
child: _categories(l10n, categories),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 14),
|
||||
child: _categories(l10n, categories),
|
||||
),
|
||||
),
|
||||
_HomeDramaGrid(
|
||||
l10n: l10n,
|
||||
categories: categories,
|
||||
onOpen: (drama) => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const _BrandMark(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.appName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||
child: _continueCard(
|
||||
l10n,
|
||||
logic.resumeDrama(allDramas)!,
|
||||
logic.resumeRecord!,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.signedIn) ...[
|
||||
_HeaderPill(
|
||||
icon: Icons.monetization_on_rounded,
|
||||
label: r'$ 0',
|
||||
onTap: widget.onCoins,
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||
child: _quickActions(l10n, categories),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.trendingMicroDramas,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: AppDimens.previewSectionTitle,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
categories.selectCode(CategoryController.allCode),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.accent,
|
||||
minimumSize: const Size(44, 44),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: Text(
|
||||
'${l10n.all} ›',
|
||||
style: const TextStyle(fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_HomeDramaGrid(
|
||||
l10n: l10n,
|
||||
categories: categories,
|
||||
dramas: visible,
|
||||
searching: logic.search.text.trim().isNotEmpty,
|
||||
onOpen: (drama) => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderPill(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
label: l10n.rewards,
|
||||
onTap: widget.onRewards,
|
||||
Text(
|
||||
l10n.discoverTab,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
] else
|
||||
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
|
||||
],
|
||||
const Spacer(),
|
||||
_HeaderIcon(
|
||||
key: const Key('discover-search-button'),
|
||||
icon: Icons.search_rounded,
|
||||
tooltip: l10n.search,
|
||||
onTap: logic.openSearch,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: widget.signedIn ? l10n.myTab : l10n.logIn,
|
||||
child: InkWell(
|
||||
key: const Key('discover-profile'),
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF6A5CFF), AppColors.blue],
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
color: AppColors.onSurface,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carousel(
|
||||
AppLocalizations l10n,
|
||||
List<FeedBanner> banners,
|
||||
List<Drama> allDramas,
|
||||
) {
|
||||
final items = banners
|
||||
.where((banner) => banner.imageUrl.isNotEmpty)
|
||||
.toList();
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return CoverCarousel(
|
||||
active: widget.active,
|
||||
items: [
|
||||
for (final banner in items)
|
||||
CoverItem(
|
||||
title: _bannerTitle(banner, allDramas),
|
||||
subtitle: '',
|
||||
image: banner.imageUrl,
|
||||
onOpen: () => _openBanner(banner, allDramas),
|
||||
Widget _search() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: TextField(
|
||||
key: const Key('discover-search'),
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
onChanged: (_) => logic.onSearchChanged(),
|
||||
onTapOutside: (_) => logic.searchFocus.unfocus(),
|
||||
textInputAction: TextInputAction.search,
|
||||
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: AppColors.input,
|
||||
hintText: AppLocalizations.of(context)!.searchDiscover,
|
||||
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search_rounded,
|
||||
color: AppColors.subtle,
|
||||
size: 17,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderSide: const BorderSide(color: AppColors.blue, width: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return '';
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) return drama.title;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return;
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) {
|
||||
logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _categories(AppLocalizations l10n, CategoryController categories) {
|
||||
final choices = <({String code, String label})>[
|
||||
(code: CategoryController.allCode, label: l10n.all),
|
||||
(code: CategoryController.hotCode, label: l10n.hot),
|
||||
(code: CategoryController.newCode, label: l10n.newest),
|
||||
(code: CategoryController.allCode, label: l10n.recommended),
|
||||
for (final category in categories.categories)
|
||||
(code: 'category-${category.id}', label: category.name),
|
||||
];
|
||||
@@ -265,6 +351,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
key: const Key('home-category-chips'),
|
||||
height: AppDimens.tapTarget,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: choices.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
@@ -281,17 +368,20 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.blue : AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
||||
color: selected ? AppColors.blue : const Color(0xFF29272B),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.blue : Colors.transparent,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
choice.label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.onPrimary : AppColors.cream,
|
||||
fontSize: AppDimens.body,
|
||||
color: selected ? AppColors.onPrimary : AppColors.muted,
|
||||
fontSize: AppDimens.chipFont,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
@@ -305,6 +395,173 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _continueCard(
|
||||
AppLocalizations l10n,
|
||||
Drama drama,
|
||||
WatchProgress record,
|
||||
) {
|
||||
final progress = record.fraction;
|
||||
return Container(
|
||||
key: const Key('discover-continue-watching'),
|
||||
padding: const EdgeInsets.all(9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardAlt,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 52,
|
||||
height: 66,
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.continueWatching,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_resumeShort(drama, record),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress <= 0 ? null : progress,
|
||||
minHeight: 3,
|
||||
backgroundColor: const Color(0xFF474248),
|
||||
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
FilledButton(
|
||||
key: const Key('discover-continue-play'),
|
||||
onPressed: () => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
minimumSize: const Size(56, 28),
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: Text(l10n.resume),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _resumeShort(Drama drama, WatchProgress record) {
|
||||
final label = AppLocalizations.of(context)!;
|
||||
Episode? episode;
|
||||
for (final item in drama.episodes) {
|
||||
if (item.id == record.episodeId) {
|
||||
episode = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (episode == null) return _duration(record.position);
|
||||
return label.episodeOfTotal(episode.number, drama.episodeCount);
|
||||
}
|
||||
|
||||
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('discover-quick-filter'),
|
||||
icon: Icons.tune_rounded,
|
||||
label: l10n.filter,
|
||||
color: AppColors.secondary,
|
||||
onTap: logic.openSearch,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('discover-quick-charts'),
|
||||
icon: Icons.local_fire_department_rounded,
|
||||
label: l10n.topCharts,
|
||||
color: AppColors.blue,
|
||||
onTap: () => categories.selectCode(CategoryController.hotCode),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('discover-quick-new'),
|
||||
icon: Icons.auto_awesome_rounded,
|
||||
label: l10n.newReleases,
|
||||
color: AppColors.gold,
|
||||
onTap: () => categories.selectCode(CategoryController.newCode),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('discover-quick-watchlist'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
label: l10n.watchlist,
|
||||
color: AppColors.success,
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.watchlist,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
@@ -343,72 +600,81 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
}
|
||||
|
||||
const _coin = AppColors.gold;
|
||||
class _HeaderIcon extends StatelessWidget {
|
||||
const _HeaderIcon({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
const _BrandMark();
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2C2D33),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
),
|
||||
icon: Icon(icon, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderPill extends StatelessWidget {
|
||||
const _HeaderPill({
|
||||
class _QuickAction extends StatelessWidget {
|
||||
const _QuickAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.raised,
|
||||
shape: StadiumBorder(
|
||||
side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)),
|
||||
),
|
||||
color: AppColors.quick,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: AppDimens.tapTarget),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: _coin),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: AppDimens.caption,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
child: Container(
|
||||
height: 62,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 16),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.05,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -416,28 +682,8 @@ class _HeaderPill extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginButton extends StatelessWidget {
|
||||
const _LoginButton({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: onTap,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: AppColors.blue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget),
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: AppDimens.body,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(label, maxLines: 1),
|
||||
);
|
||||
}
|
||||
String _duration(Duration value) {
|
||||
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const Duration _autoPlayInterval = Duration(milliseconds: 4500);
|
||||
|
||||
class CoverItem {
|
||||
const CoverItem({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.image,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String image;
|
||||
final VoidCallback onOpen;
|
||||
}
|
||||
|
||||
/// Full-bleed posters that turn as faces of a cube. Titles sit on the image.
|
||||
class CoverCarousel extends StatefulWidget {
|
||||
const CoverCarousel({super.key, required this.items, this.active = true});
|
||||
|
||||
final List<CoverItem> items;
|
||||
final bool active;
|
||||
|
||||
@override
|
||||
State<CoverCarousel> createState() => _CoverCarouselState();
|
||||
}
|
||||
|
||||
class _CoverCarouselState extends State<CoverCarousel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _snap;
|
||||
double _page = 0;
|
||||
double _from = 0;
|
||||
double _to = 0;
|
||||
int? _pointer;
|
||||
double _drag = 0;
|
||||
double _dragPage = 0;
|
||||
Timer? _autoTimer;
|
||||
bool _tickerEnabled = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_snap = AnimationController(vsync: this)..addListener(_onSnap);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoTimer?.cancel();
|
||||
_snap.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final enabled = TickerMode.valuesOf(context).enabled;
|
||||
if (enabled != _tickerEnabled) {
|
||||
_tickerEnabled = enabled;
|
||||
_syncAutoTimer();
|
||||
} else if (_autoTimer == null) {
|
||||
_syncAutoTimer();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant CoverCarousel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_page > _last) _page = _last;
|
||||
if (oldWidget.items.length != widget.items.length ||
|
||||
oldWidget.active != widget.active) {
|
||||
_syncAutoTimer();
|
||||
}
|
||||
}
|
||||
|
||||
double get _last => math.max(0, widget.items.length - 1).toDouble();
|
||||
|
||||
void _onSnap() {
|
||||
if (!mounted) return;
|
||||
final t = Curves.easeOutCubic.transform(_snap.value);
|
||||
setState(() => _page = _from + (_to - _from) * t);
|
||||
}
|
||||
|
||||
void _turnTo(double target) {
|
||||
if (!mounted || widget.items.isEmpty) return;
|
||||
_from = _page;
|
||||
_to = target.clamp(0, _last);
|
||||
_snap.duration = const Duration(milliseconds: 420);
|
||||
_snap.forward(from: 0);
|
||||
}
|
||||
|
||||
void _syncAutoTimer() {
|
||||
_autoTimer?.cancel();
|
||||
_autoTimer = null;
|
||||
if (!mounted ||
|
||||
!widget.active ||
|
||||
!_tickerEnabled ||
|
||||
widget.items.length <= 1) {
|
||||
return;
|
||||
}
|
||||
_autoTimer = Timer.periodic(_autoPlayInterval, (_) {
|
||||
if (!mounted || _pointer != null || !widget.active || !_tickerEnabled) {
|
||||
return;
|
||||
}
|
||||
final current = _page.round().clamp(0, _last.toInt());
|
||||
if (current >= _last.toInt()) {
|
||||
setState(() => _page = 0);
|
||||
} else {
|
||||
_turnTo((current + 1).toDouble());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.items.isEmpty) return const SizedBox.shrink();
|
||||
final current = _page.round().clamp(0, widget.items.length - 1);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final height = math.min(width * 0.52, 210).toDouble();
|
||||
return Semantics(
|
||||
container: true,
|
||||
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _stage(width)),
|
||||
_indicator(l10n, current),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stage(double width) {
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerDown: (event) {
|
||||
_autoTimer?.cancel();
|
||||
_autoTimer = null;
|
||||
_snap.stop();
|
||||
_pointer = event.pointer;
|
||||
_drag = 0;
|
||||
_dragPage = _page;
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_drag += event.delta.dx;
|
||||
setState(() {
|
||||
_page = (_dragPage - _drag / width).clamp(0.0, _last);
|
||||
});
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_pointer = null;
|
||||
_turnTo(_page.roundToDouble());
|
||||
_syncAutoTimer();
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
if (event.pointer != _pointer) return;
|
||||
_pointer = null;
|
||||
_turnTo(_page.roundToDouble());
|
||||
_syncAutoTimer();
|
||||
},
|
||||
child: ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [for (final index in _visible) _face(index, width)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _indicator(AppLocalizations l10n, int current) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 8,
|
||||
child: Semantics(
|
||||
label: l10n.carouselSemantics(current + 1, widget.items.length),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var index = 0; index < widget.items.length; index++)
|
||||
Container(
|
||||
key: Key('carousel-dot-$index'),
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: index == current
|
||||
? AppColors.onPrimary
|
||||
: AppColors.muted.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<int> get _visible {
|
||||
final first = _page.floor();
|
||||
final indexes = <int>[first, first + 1];
|
||||
indexes.retainWhere((index) => index >= 0 && index < widget.items.length);
|
||||
indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs()));
|
||||
return indexes;
|
||||
}
|
||||
|
||||
Widget _face(int index, double width) {
|
||||
final delta = index - _page;
|
||||
if (delta <= -1 || delta >= 1) return const SizedBox.shrink();
|
||||
final item = widget.items[index];
|
||||
final angle = delta * math.pi / 2;
|
||||
// Push the face out to the cube surface, spin the whole cube on its
|
||||
// vertical center, then bring that center back to the banner.
|
||||
final face = Matrix4.identity()
|
||||
..translateByDouble(0, 0, -width / 2, 1)
|
||||
..rotateY(angle)
|
||||
..translateByDouble(0, 0, width / 2, 1);
|
||||
final transform = Matrix4.identity()
|
||||
..setEntry(3, 2, 0.0018)
|
||||
..multiply(face);
|
||||
return Positioned.fill(
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
filterQuality: FilterQuality.medium,
|
||||
transform: transform,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if ((_page - index).abs() < 0.08) item.onOpen();
|
||||
},
|
||||
child: _poster(item),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _poster(CoverItem item) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
item.image,
|
||||
showFallbackIcon: true,
|
||||
fallbackIconKey: const Key('carousel-image-fallback'),
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)],
|
||||
stops: [0, 0.5, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 30,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.15,
|
||||
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xF2FFFFFF),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
const _HomeDramaGrid({
|
||||
required this.l10n,
|
||||
required this.categories,
|
||||
required this.dramas,
|
||||
required this.searching,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final CategoryController categories;
|
||||
final List<Drama> dramas;
|
||||
final bool searching;
|
||||
final ValueChanged<Drama> onOpen;
|
||||
|
||||
@override
|
||||
@@ -33,55 +37,65 @@ class _HomeDramaGrid extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final dramas = categories.visibleDramas;
|
||||
if (dramas.isEmpty) {
|
||||
final missingCategory = categories.selectedSectionMissing;
|
||||
return _fill(
|
||||
EmptyState(
|
||||
key: const Key('home-all-dramas-empty'),
|
||||
icon: missingCategory
|
||||
? Icons.grid_view_rounded
|
||||
: Icons.search_off_rounded,
|
||||
title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults,
|
||||
icon: searching
|
||||
? Icons.search_off_rounded
|
||||
: Icons.movie_filter_outlined,
|
||||
title: searching
|
||||
? l10n.noSearchResults
|
||||
: categories.selectedSectionMissing
|
||||
? l10n.noCategoryDramas
|
||||
: l10n.noSearchResults,
|
||||
subtitle: l10n.emptyHint,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
_dramaGrid(dramas),
|
||||
SliverToBoxAdapter(child: _masonry(dramas)),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dramaGrid(List<Drama> dramas) => SliverPadding(
|
||||
key: const Key('home-all-dramas-section'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal),
|
||||
sliver: SliverGrid(
|
||||
key: const Key('home-all-dramas-grid'),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
mainAxisSpacing: 14,
|
||||
crossAxisSpacing: 10,
|
||||
childAspectRatio: 2 / 3,
|
||||
Widget _masonry(List<Drama> items) {
|
||||
return Padding(
|
||||
key: const Key('home-all-dramas-section'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
key: const Key('home-all-dramas-grid'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _column(items, 0)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _column(items, 1)),
|
||||
],
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _HomeDramaCard(
|
||||
l10n: l10n,
|
||||
drama: dramas[index],
|
||||
onTap: () => onOpen(dramas[index]),
|
||||
),
|
||||
childCount: dramas.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _fill(Widget child) {
|
||||
return SliverFillRemaining(hasScrollBody: false, child: child);
|
||||
);
|
||||
}
|
||||
|
||||
Widget _column(List<Drama> items, int start) {
|
||||
final children = <Widget>[];
|
||||
for (var index = start; index < items.length; index += 2) {
|
||||
children.add(
|
||||
_HomeDramaCard(
|
||||
l10n: l10n,
|
||||
drama: items[index],
|
||||
onTap: () => onOpen(items[index]),
|
||||
),
|
||||
);
|
||||
if (index + 2 < items.length) {
|
||||
children.add(const SizedBox(height: 10));
|
||||
}
|
||||
}
|
||||
return Column(children: children);
|
||||
}
|
||||
|
||||
Widget _fill(Widget child) =>
|
||||
SliverFillRemaining(hasScrollBody: false, child: child);
|
||||
}
|
||||
|
||||
class _HomeDramaCard extends StatelessWidget {
|
||||
@@ -98,96 +112,105 @@ class _HomeDramaCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||
final free =
|
||||
drama.freeEpisodes > 0 ||
|
||||
drama.episodes.any((episode) => episode.isFree);
|
||||
return InkWell(
|
||||
key: Key('home-drama-card-${drama.id}'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
child: AspectRatio(
|
||||
key: Key('home-drama-cover-${drama.id}'),
|
||||
aspectRatio: 2 / 3,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xE6100C0A)],
|
||||
stops: [0.42, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (drama.isHot)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
key: Key('home-drama-cover-${drama.id}'),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
children: [
|
||||
_DiscoverCoverImage(path: drama.posterUrl),
|
||||
if (free)
|
||||
Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
l10n.hot,
|
||||
l10n.free,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: AppDimens.caption,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
key: Key('home-drama-title-${drama.id}'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: AppDimens.body,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
genre.isEmpty ? l10n.videoPending : genre,
|
||||
key: Key('home-drama-genre-${drama.id}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: AppDimens.caption,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
drama.title,
|
||||
key: Key('home-drama-title-${drama.id}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
genre.isEmpty ? l10n.videoPending : genre,
|
||||
key: Key('home-drama-genre-${drama.id}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DiscoverCoverImage extends StatelessWidget {
|
||||
const _DiscoverCoverImage({required this.path});
|
||||
|
||||
final String path;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = AppConfig.mediaUrl(path);
|
||||
if (url.isEmpty) return _placeholder();
|
||||
return Image.network(
|
||||
url,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.fitWidth,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return _placeholder();
|
||||
},
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
return AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: ColoredBox(
|
||||
color: AppColors.card,
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: AppColors.muted,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,30 +6,35 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/data/services/favorite_controller.dart';
|
||||
import '../../core/data/services/interaction_controller.dart';
|
||||
import '../../core/data/services/platform_share_service.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/platform/haptics.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 '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/download_action.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../interaction/widgets/report_dialog.dart';
|
||||
import 'drama_detail_binding.dart';
|
||||
import 'drama_detail_logic.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
|
||||
const String _reportMenuValue = 'report';
|
||||
const String _menuFavorite = 'favorite';
|
||||
const String _menuDownload = 'download';
|
||||
const String _menuShare = 'share';
|
||||
const String _menuReport = 'report';
|
||||
|
||||
class DramaDetailPage extends StatefulWidget {
|
||||
const DramaDetailPage({super.key, required this.drama});
|
||||
|
||||
final Drama drama;
|
||||
|
||||
@override
|
||||
@@ -78,254 +83,430 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (logic.loading) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
||||
),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
if (logic.failed) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, logic.failureMessage, l10n.detailError),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (logic.loading) return _loading(l10n);
|
||||
if (logic.failed) return _failed(l10n);
|
||||
final drama = logic.drama;
|
||||
if (drama.episodes.isEmpty) {
|
||||
// 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(localizedDramaTitle(l10n, drama)),
|
||||
),
|
||||
body: EmptyState(
|
||||
key: const Key('drama-detail-empty'),
|
||||
icon: Icons.video_library_outlined,
|
||||
title: l10n.noEpisodesYet,
|
||||
subtitle: l10n.emptyHint,
|
||||
actionLabel: l10n.retry,
|
||||
onAction: _load,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (drama.episodes.isEmpty) return _empty(l10n, drama);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
body: CustomScrollView(
|
||||
key: const Key('detail-scroll'),
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 300,
|
||||
pinned: true,
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: Colors.white,
|
||||
actions: [
|
||||
_reportAction(context, l10n),
|
||||
_downloadAction(context, l10n),
|
||||
_favoriteAction(context, l10n),
|
||||
],
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(drama.posterUrl),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.center,
|
||||
colors: [Color(0x99000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
SliverToBoxAdapter(child: _hero(context, l10n, drama)),
|
||||
SliverToBoxAdapter(child: _content(context, l10n, drama)),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 28)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _loading(AppLocalizations l10n) => Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
||||
),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
Widget _failed(AppLocalizations l10n) => Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(localizedDramaTitle(l10n, widget.drama)),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
errorText(l10n, logic.failureMessage, l10n.detailError),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: Text(l10n.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold(
|
||||
backgroundColor: AppColors.ink,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.ink,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
title: Text(localizedDramaTitle(l10n, drama)),
|
||||
),
|
||||
body: EmptyState(
|
||||
key: const Key('drama-detail-empty'),
|
||||
icon: Icons.video_library_outlined,
|
||||
title: l10n.noEpisodesYet,
|
||||
subtitle: l10n.emptyHint,
|
||||
actionLabel: l10n.retry,
|
||||
onAction: _load,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) {
|
||||
return SizedBox(
|
||||
key: const Key('detail-hero'),
|
||||
height: 190,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x33000000), Color(0xCC0B0C10)],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Text(
|
||||
l10n.technicalDemoCount(drama.episodes.length),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 1.4,
|
||||
color: AppColors.blueSoft,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
style: const TextStyle(
|
||||
fontSize: AppDimens.detailHeadline,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
localizedDramaSynopsis(l10n, drama),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
logic.openPlayer(context, logic.continueIndex ?? 0),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(
|
||||
logic.continueIndex == null
|
||||
? l10n.watchEpisodeOne
|
||||
: l10n.continueEpisode(logic.continueIndex! + 1),
|
||||
),
|
||||
),
|
||||
if (_continueFraction != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_ResumeBar(
|
||||
fraction: _continueFraction!,
|
||||
label: l10n.resumeProgress(
|
||||
_clock(logic.progress!.position),
|
||||
_clock(logic.progress!.duration),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (logic.progressError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorText(
|
||||
l10n,
|
||||
logic.progressError,
|
||||
l10n.progressError,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(logic.loadContinue()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
Text(
|
||||
l10n.episodes,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontSize: AppDimens.sectionTitle,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
]),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 14,
|
||||
left: 14,
|
||||
child: _RoundAction(
|
||||
key: const Key('detail-back'),
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 40),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: drama.episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = drama.episodes[index];
|
||||
final fraction = logic.progressFractionFor(episode.id);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
child: ListTile(
|
||||
minTileHeight: 76,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.blue.withValues(alpha: 0.18),
|
||||
child: Text('${episode.number}'),
|
||||
),
|
||||
title: Text(l10n.episodeLabel(episode.number)),
|
||||
subtitle: fraction == null
|
||||
? null
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: _ResumeBar(fraction: fraction),
|
||||
),
|
||||
trailing: const Icon(Icons.play_circle_outline_rounded),
|
||||
onTap: () => logic.openPlayer(context, index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
top: MediaQuery.paddingOf(context).top + 14,
|
||||
right: 14,
|
||||
child: _moreMenu(context, l10n),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double? get _continueFraction {
|
||||
final record = logic.progress;
|
||||
if (record == null || !record.hasPosition) return null;
|
||||
if (logic.continueIndex == null) return null;
|
||||
return record.fraction;
|
||||
}
|
||||
|
||||
/// 下载当前续看集或第一集;业务判定完全交给服务端。
|
||||
Widget _downloadAction(BuildContext context, AppLocalizations l10n) {
|
||||
final episodes = logic.drama.episodes;
|
||||
final index = logic.continueIndex ?? 0;
|
||||
final episode = episodes.isEmpty
|
||||
? null
|
||||
: episodes[index.clamp(0, episodes.length - 1)];
|
||||
return IconButton(
|
||||
key: const Key('detail-download'),
|
||||
tooltip: l10n.downloads,
|
||||
icon: const Icon(Icons.download_outlined),
|
||||
onPressed: episode == null
|
||||
? null
|
||||
: () => requestEpisodeDownload(context, episode.id),
|
||||
Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) {
|
||||
final continueIndex = logic.continueIndex;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
key: const Key('detail-episode-count'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.secondary,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
key: const Key('detail-title'),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
localizedDramaSynopsis(l10n, drama),
|
||||
key: const Key('detail-synopsis'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 11,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 40,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('detail-cta'),
|
||||
onPressed: () => logic.openPlayer(context, continueIndex ?? 0),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppDimens.buttonRadius),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 18),
|
||||
label: Text(
|
||||
continueIndex == null
|
||||
? l10n.startWatching
|
||||
: l10n.continueEpisode(continueIndex + 1),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_continueFraction != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_ResumeBar(
|
||||
fraction: _continueFraction!,
|
||||
label: l10n.resumeProgress(
|
||||
_clock(logic.progress!.position),
|
||||
_clock(logic.progress!.duration),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (logic.progressError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorText(l10n, logic.progressError, l10n.progressError),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => unawaited(logic.loadContinue()),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l10n.episodes,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final sixColumnsWidth = (constraints.maxWidth - 5 * 8) / 6;
|
||||
final columns = sixColumnsWidth >= 40 ? 6 : 5;
|
||||
final selectedIndex = continueIndex ?? 0;
|
||||
final selectedNumber = drama.episodes.isEmpty
|
||||
? null
|
||||
: drama.episodes[selectedIndex].number;
|
||||
final ordered = [
|
||||
for (var index = 0; index < drama.episodes.length; index++)
|
||||
(index: index, episode: drama.episodes[index]),
|
||||
]..sort((a, b) => a.episode.number.compareTo(b.episode.number));
|
||||
return GridView.builder(
|
||||
key: const Key('detail-episode-chips'),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: ordered.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 1.35,
|
||||
),
|
||||
itemBuilder: (context, position) {
|
||||
final item = ordered[position];
|
||||
return _episodeChip(
|
||||
context,
|
||||
item.episode,
|
||||
item.index,
|
||||
selectedNumber,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 详情页更多菜单:本批只提供举报短剧。
|
||||
Widget _reportAction(BuildContext context, AppLocalizations l10n) {
|
||||
Widget _episodeChip(
|
||||
BuildContext context,
|
||||
Episode episode,
|
||||
int index,
|
||||
int? selectedNumber,
|
||||
) {
|
||||
final selected = episode.number == selectedNumber;
|
||||
final locked = episode.locked || (!episode.isFree && index > 0);
|
||||
return InkWell(
|
||||
key: Key('detail-episode-chip-${episode.number}'),
|
||||
onTap: () => logic.openPlayer(context, index),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? AppColors.blue
|
||||
: locked
|
||||
? AppColors.secondary.withValues(alpha: 0.16)
|
||||
: const Color(0xFF29272B),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Text(
|
||||
'${episode.number}',
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? AppColors.onPrimary
|
||||
: locked
|
||||
? AppColors.secondary
|
||||
: AppColors.muted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _moreMenu(BuildContext context, AppLocalizations l10n) {
|
||||
final saved = context.watch<FavoriteController>().isSaved(logic.drama.id);
|
||||
return PopupMenuButton<String>(
|
||||
key: const Key('detail-more'),
|
||||
tooltip: l10n.more,
|
||||
onSelected: (_) => unawaited(_reportDrama(l10n)),
|
||||
color: AppColors.card,
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case _menuFavorite:
|
||||
unawaited(_toggleFavorite(l10n));
|
||||
case _menuDownload:
|
||||
_downloadCurrent();
|
||||
case _menuShare:
|
||||
unawaited(_shareDrama(l10n, logic.drama));
|
||||
case _menuReport:
|
||||
unawaited(_reportDrama(l10n));
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)),
|
||||
PopupMenuItem(
|
||||
key: const Key('detail-favorite'),
|
||||
value: _menuFavorite,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
color: saved ? AppColors.blue : AppColors.muted,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(l10n.favorite),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
key: const Key('detail-download'),
|
||||
value: _menuDownload,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.muted,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(l10n.downloads),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
key: const Key('detail-share'),
|
||||
value: _menuShare,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.share_outlined,
|
||||
color: AppColors.muted,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(l10n.share),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
key: const Key('detail-report'),
|
||||
value: _menuReport,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18),
|
||||
const SizedBox(width: 9),
|
||||
Text(l10n.reportDrama),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x80000000),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.more_horiz_rounded,
|
||||
size: 18,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareDrama(AppLocalizations l10n, Drama drama) async {
|
||||
try {
|
||||
await const PlatformShareService().shareDrama(
|
||||
title: localizedDramaTitle(l10n, drama),
|
||||
synopsis: localizedDramaSynopsis(l10n, drama),
|
||||
footer: l10n.shareFooter,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.shareFailed)));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(AppLocalizations l10n) async {
|
||||
if (!await ensureSignedIn(context)) return;
|
||||
if (!mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final ok = await logic.toggleFavorite();
|
||||
if (ok || !mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||
}
|
||||
|
||||
void _downloadCurrent() {
|
||||
final episodes = logic.drama.episodes;
|
||||
if (episodes.isEmpty) return;
|
||||
final index = (logic.continueIndex ?? 0).clamp(0, episodes.length - 1);
|
||||
requestEpisodeDownload(context, episodes[index].id);
|
||||
}
|
||||
|
||||
Future<void> _reportDrama(AppLocalizations l10n) async {
|
||||
if (!await ensureSignedIn(context) || !mounted) return;
|
||||
final draft = await showReportDialog(context, title: l10n.reportDrama);
|
||||
@@ -348,25 +529,11 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。
|
||||
Widget _favoriteAction(BuildContext context, AppLocalizations l10n) {
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final saved = favorites.isSaved(logic.drama.id);
|
||||
return IconButton(
|
||||
tooltip: l10n.favorite,
|
||||
icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border),
|
||||
onPressed: () async {
|
||||
if (!await ensureSignedIn(context)) return;
|
||||
if (!context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final ok = await logic.toggleFavorite();
|
||||
if (ok || !context.mounted) return;
|
||||
// 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚)
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||
},
|
||||
);
|
||||
double? get _continueFraction {
|
||||
final record = logic.progress;
|
||||
if (record == null || !record.hasPosition) return null;
|
||||
if (logic.continueIndex == null) return null;
|
||||
return record.fraction;
|
||||
}
|
||||
|
||||
String _clock(Duration duration) {
|
||||
@@ -378,7 +545,37 @@ class _DramaDetailPageState extends State<DramaDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。
|
||||
class _RoundAction extends StatelessWidget {
|
||||
const _RoundAction({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x80000000),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
tooltip: tooltip.isEmpty ? null : tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(icon, size: 18, color: AppColors.onSurface),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResumeBar extends StatelessWidget {
|
||||
const _ResumeBar({required this.fraction, this.label});
|
||||
|
||||
@@ -391,22 +588,19 @@ class _ResumeBar extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.progressRadius),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: fraction,
|
||||
minHeight: AppDimens.progressHeight,
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft),
|
||||
minHeight: 3,
|
||||
backgroundColor: const Color(0xFF474248),
|
||||
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
|
||||
),
|
||||
),
|
||||
if (label != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 10),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -15,7 +15,7 @@ class HomeLogic extends ChangeNotifier {
|
||||
|
||||
final scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
Drama currentDrama;
|
||||
int tab = 1;
|
||||
int tab = 0;
|
||||
int searchRequest = 0;
|
||||
int sectionRequest = 0;
|
||||
int resumeRequest = 0;
|
||||
@@ -49,21 +49,20 @@ class HomeLogic extends ChangeNotifier {
|
||||
notify();
|
||||
}
|
||||
|
||||
void openMenu() {
|
||||
drawerOpen = true;
|
||||
void setMenuOpen(bool value) {
|
||||
if (drawerOpen == value) return;
|
||||
drawerOpen = value;
|
||||
notify();
|
||||
scaffoldKey.currentState?.openDrawer();
|
||||
}
|
||||
|
||||
void selectFromMenu(int nextTab, {int? mySection}) {
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
drawerOpen = false;
|
||||
select(nextTab, mySection: mySection);
|
||||
}
|
||||
|
||||
Future<void> openCurrentSeries(BuildContext context) async {
|
||||
detailOpen = true;
|
||||
notify();
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
if (!context.mounted) return;
|
||||
try {
|
||||
@@ -102,13 +101,11 @@ class HomeLogic extends ChangeNotifier {
|
||||
|
||||
void openProfile() {
|
||||
profile = true;
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
select(2, mySection: 0);
|
||||
}
|
||||
|
||||
void openLibrary({int section = 1}) {
|
||||
profile = false;
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
select(2, mySection: section);
|
||||
}
|
||||
|
||||
@@ -139,24 +136,14 @@ class HomeLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void onSystemBack() {
|
||||
if (drawerOpen) {
|
||||
scaffoldKey.currentState?.closeDrawer();
|
||||
} else if (videoImmersive) {
|
||||
if (videoImmersive) {
|
||||
backRequest++;
|
||||
notify();
|
||||
} else if (tab != 1) {
|
||||
select(1);
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
void onDrawerChanged(bool opened) {
|
||||
if (drawerOpen == opened) return;
|
||||
drawerOpen = opened;
|
||||
notify();
|
||||
}
|
||||
|
||||
void onDramaChanged(Drama drama) {
|
||||
currentDrama = drama;
|
||||
notify();
|
||||
|
||||
@@ -24,7 +24,7 @@ import '../mine/mine_view.dart';
|
||||
import '../player/player_view.dart';
|
||||
import 'home_binding.dart';
|
||||
import 'home_logic.dart';
|
||||
import 'widgets/home_quick_drawer.dart';
|
||||
import 'widgets/home_menu_sheet.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
@@ -173,21 +173,15 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) return;
|
||||
logic.onSystemBack();
|
||||
if (!logic.videoImmersive && logic.tab == 1) {
|
||||
unawaited(_openMenu(context));
|
||||
} else {
|
||||
logic.onSystemBack();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
key: logic.scaffoldKey,
|
||||
backgroundColor: AppColors.ink,
|
||||
drawer: HomeQuickDrawer(
|
||||
drama: logic.currentDrama,
|
||||
onOpenSeries: () => logic.openCurrentSeries(context),
|
||||
onDiscover: () => logic.selectFromMenu(1),
|
||||
onHistory: () => logic.openLibrary(section: 0),
|
||||
onWatchlist: logic.openLibrary,
|
||||
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
||||
),
|
||||
drawerEnableOpenDragGesture: false,
|
||||
onDrawerChanged: logic.onDrawerChanged,
|
||||
body: IndexedStack(
|
||||
index: logic.tab,
|
||||
children: [
|
||||
@@ -204,7 +198,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
onDiscover: () => logic.select(1),
|
||||
onMy: logic.openProfile,
|
||||
onSearch: () => logic.select(1, search: true),
|
||||
onMenu: logic.openMenu,
|
||||
onMenu: () => _openMenu(context),
|
||||
onImmersiveChanged: logic.onImmersiveChanged,
|
||||
),
|
||||
DiscoverPage(
|
||||
@@ -243,36 +237,28 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
: AppBottomNav(
|
||||
barKey: const Key('home-tabs-bar'),
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-discover'),
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: logic.tab == 1,
|
||||
onTap: () => _selectTab(context, 1),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-home'),
|
||||
icon: Icons.movie_outlined,
|
||||
selectedIcon: Icons.movie_rounded,
|
||||
label: l10n.shortsTab,
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
selectedIcon: Icons.play_circle_fill_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: logic.tab == 0,
|
||||
onTap: () => _selectTab(context, 0),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-my-list'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
selectedIcon: Icons.bookmark_rounded,
|
||||
label: l10n.myListTab,
|
||||
selected: logic.tab == 2 && !logic.profile,
|
||||
onTap: () => _selectTab(context, 2),
|
||||
tabKey: const Key('tab-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
selectedIcon: Icons.explore_rounded,
|
||||
label: l10n.discoverTab,
|
||||
selected: logic.tab == 1,
|
||||
onTap: () => _selectTab(context, 1),
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('tab-my'),
|
||||
icon: Icons.person_outline_rounded,
|
||||
selectedIcon: Icons.person_rounded,
|
||||
icon: Icons.video_library_outlined,
|
||||
selectedIcon: Icons.video_library_rounded,
|
||||
label: l10n.myTab,
|
||||
selected: logic.tab == 2 && logic.profile,
|
||||
selected: logic.tab == 2,
|
||||
onTap: () => _selectTab(context, 2, myProfile: true),
|
||||
),
|
||||
],
|
||||
@@ -282,9 +268,7 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
|
||||
void _selectTab(BuildContext context, int tab, {bool myProfile = false}) {
|
||||
final same = tab == 2
|
||||
? logic.tab == 2 && logic.profile == myProfile
|
||||
: logic.tab == tab;
|
||||
final same = logic.tab == tab;
|
||||
if (!same) {
|
||||
unawaited(context.read<Haptics>().selectionClick());
|
||||
}
|
||||
@@ -294,4 +278,23 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
logic.select(tab);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openMenu(BuildContext context) async {
|
||||
logic.setMenuOpen(true);
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => HomeMenuSheet(
|
||||
drama: logic.currentDrama,
|
||||
onOpenSeries: () => logic.openCurrentSeries(context),
|
||||
onDiscover: () => logic.selectFromMenu(1),
|
||||
onHistory: () => logic.openLibrary(section: 0),
|
||||
onWatchlist: logic.openLibrary,
|
||||
onLiked: () => logic.selectFromMenu(2, mySection: 2),
|
||||
),
|
||||
);
|
||||
logic.setMenuOpen(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/config/locale_controller.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// Compact navigation menu opened from the preview home header.
|
||||
class HomeMenuSheet extends StatelessWidget {
|
||||
const HomeMenuSheet({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.onOpenSeries,
|
||||
required this.onDiscover,
|
||||
required this.onHistory,
|
||||
required this.onWatchlist,
|
||||
required this.onLiked,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final VoidCallback onOpenSeries;
|
||||
final VoidCallback onDiscover;
|
||||
final VoidCallback onHistory;
|
||||
final VoidCallback onWatchlist;
|
||||
final VoidCallback onLiked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final languageCode = Localizations.localeOf(context).languageCode;
|
||||
void closeThen(VoidCallback action) {
|
||||
Navigator.pop(context);
|
||||
action();
|
||||
}
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardAlt,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: RemoteImage(drama.posterUrl, width: 64, height: 86),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 34,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('drawer-open-series'),
|
||||
onPressed: () => closeThen(onOpenSeries),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 11),
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 15,
|
||||
),
|
||||
label: Text(l10n.exploreSeries),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
l10n.quickAccess,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MenuTile(
|
||||
key: const Key('drawer-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
label: l10n.discoverTab,
|
||||
onTap: () => closeThen(onDiscover),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _MenuTile(
|
||||
key: const Key('drawer-history'),
|
||||
icon: Icons.history_rounded,
|
||||
label: l10n.history,
|
||||
onTap: () => closeThen(onHistory),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MenuTile(
|
||||
key: const Key('drawer-watchlist'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
label: l10n.watchlist,
|
||||
onTap: () => closeThen(onWatchlist),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _MenuTile(
|
||||
key: const Key('drawer-liked'),
|
||||
icon: Icons.favorite_border_rounded,
|
||||
label: l10n.likedTab,
|
||||
onTap: () => closeThen(onLiked),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
l10n.appLanguage,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final (code, label) in [
|
||||
('en', l10n.english),
|
||||
('zh', l10n.simplifiedChinese),
|
||||
('id', l10n.bahasaIndonesia),
|
||||
])
|
||||
ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: languageCode == code,
|
||||
onSelected: (_) =>
|
||||
context.read<LocaleController>().select(code),
|
||||
backgroundColor: AppColors.input,
|
||||
selectedColor: AppColors.blue,
|
||||
labelStyle: TextStyle(
|
||||
color: languageCode == code
|
||||
? AppColors.onPrimary
|
||||
: AppColors.muted,
|
||||
fontSize: 10,
|
||||
),
|
||||
side: BorderSide.none,
|
||||
showCheckmark: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuTile extends StatelessWidget {
|
||||
const _MenuTile({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.quick,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 58,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: AppColors.secondary, size: 16),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/config/locale_controller.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
|
||||
class HomeQuickDrawer extends StatelessWidget {
|
||||
const HomeQuickDrawer({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.onOpenSeries,
|
||||
required this.onDiscover,
|
||||
required this.onHistory,
|
||||
required this.onWatchlist,
|
||||
required this.onLiked,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final VoidCallback onOpenSeries;
|
||||
final VoidCallback onDiscover;
|
||||
final VoidCallback onHistory;
|
||||
final VoidCallback onWatchlist;
|
||||
final VoidCallback onLiked;
|
||||
|
||||
static const _panel = AppColors.card;
|
||||
static const _card = AppColors.raised;
|
||||
static const _accent = AppColors.blueSoft;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final languageCode = Localizations.localeOf(context).languageCode;
|
||||
return Drawer(
|
||||
width: (MediaQuery.sizeOf(context).width * 0.83).clamp(0.0, 360.0),
|
||||
backgroundColor: _panel,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.horizontal(right: Radius.circular(20)),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: AppColors.raised,
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
size: 34,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.localViewer,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
l10n.localDemoProfile,
|
||||
style: const TextStyle(color: AppColors.cream),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
_SectionCard(
|
||||
title: l10n.currentSeries,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.innerRadius,
|
||||
),
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
width: 74,
|
||||
height: 100,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
l10n.episodeCount(drama.episodeCount),
|
||||
style: const TextStyle(color: AppColors.cream),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('drawer-open-series'),
|
||||
onPressed: onOpenSeries,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded),
|
||||
label: Text(l10n.exploreSeries),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_SectionCard(
|
||||
title: l10n.quickAccess,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('drawer-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
label: l10n.discoverTab,
|
||||
onTap: onDiscover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('drawer-history'),
|
||||
icon: Icons.history_rounded,
|
||||
label: l10n.history,
|
||||
onTap: onHistory,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('drawer-watchlist'),
|
||||
icon: Icons.bookmark_border_rounded,
|
||||
label: l10n.watchlist,
|
||||
onTap: onWatchlist,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: _QuickAction(
|
||||
key: const Key('drawer-liked'),
|
||||
icon: Icons.favorite_border_rounded,
|
||||
label: l10n.likedTab,
|
||||
onTap: onLiked,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_SectionCard(
|
||||
title: l10n.appLanguage,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final (code, label) in [
|
||||
('en', l10n.english),
|
||||
('zh', l10n.simplifiedChinese),
|
||||
('id', l10n.bahasaIndonesia),
|
||||
])
|
||||
ChoiceChip(
|
||||
label: Text(label, maxLines: 1),
|
||||
selected: languageCode == code,
|
||||
onSelected: (_) =>
|
||||
context.read<LocaleController>().select(code),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
const _SectionCard({required this.title, required this.child});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: HomeQuickDrawer._card,
|
||||
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _QuickAction extends StatelessWidget {
|
||||
const _QuickAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: HomeQuickDrawer._accent, size: 25),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
+719
-635
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
@@ -69,12 +68,11 @@ class PlayerLogic extends ChangeNotifier {
|
||||
List<Episode> sessionEpisodes = const [];
|
||||
int pageRequest = 0;
|
||||
bool muted = false;
|
||||
double playbackSpeed = 1.0;
|
||||
bool stoppedAtFeedEnd = false;
|
||||
VideoPlayerPort? scrubbingPlayer;
|
||||
bool fullscreen = false;
|
||||
bool pip = false;
|
||||
bool pipPreparing = false;
|
||||
bool screenCleared = false;
|
||||
final Map<String, int> pickedEpisode = {};
|
||||
|
||||
bool Function() isMounted = () => false;
|
||||
@@ -98,9 +96,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
|
||||
bool _started = false;
|
||||
|
||||
bool get immersive => fullscreen || pip || pipPreparing;
|
||||
bool get immersive => pip || pipPreparing;
|
||||
|
||||
bool get chromeHidden => immersive || screenCleared;
|
||||
bool get chromeHidden => immersive;
|
||||
|
||||
Episode displayEpisode(Drama drama, Episode fallback) {
|
||||
final number = pickedEpisode[drama.id];
|
||||
@@ -111,13 +109,6 @@ class PlayerLogic extends ChangeNotifier {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
void setScreenCleared(bool value) {
|
||||
if (screenCleared == value) return;
|
||||
screenCleared = value;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
@@ -262,13 +253,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
final request = backRequest;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isMounted() && backRequest == request) {
|
||||
if (fullscreen) {
|
||||
unawaited(setFullscreen(false));
|
||||
} else if (screenCleared) {
|
||||
setScreenCleared(false);
|
||||
} else {
|
||||
onMenu?.call();
|
||||
}
|
||||
onMenu?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -295,12 +280,6 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
pictureInPicture.setStateListener(null);
|
||||
if (fullscreen) {
|
||||
unawaited(
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
||||
);
|
||||
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
|
||||
}
|
||||
unawaited(saveCurrentProgress());
|
||||
if (_started) {
|
||||
session.setRouteVisible(false);
|
||||
@@ -313,32 +292,6 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
|
||||
|
||||
Future<void> setFullscreen(bool value) async {
|
||||
if (fullscreen == value || !isMounted()) return;
|
||||
fullscreen = value;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
try {
|
||||
await SystemChrome.setPreferredOrientations(
|
||||
value
|
||||
? [
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]
|
||||
: [DeviceOrientation.portraitUp],
|
||||
);
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge,
|
||||
);
|
||||
} catch (_) {
|
||||
if (value && isMounted()) {
|
||||
fullscreen = false;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enterPip(BuildContext context) async {
|
||||
if (pip || pipPreparing) return;
|
||||
if (session.active is! VideoPlayerPort) {
|
||||
@@ -347,7 +300,6 @@ class PlayerLogic extends ChangeNotifier {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (fullscreen) await setFullscreen(false);
|
||||
pipPreparing = true;
|
||||
notify();
|
||||
notifyImmersive();
|
||||
@@ -390,6 +342,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
Future<void> preparePlayer(Episode episode, PlayerPort player) async {
|
||||
if (player is! VideoPlayerPort) return;
|
||||
try {
|
||||
await player.controller.setPlaybackSpeed(playbackSpeed);
|
||||
await pendingWrite;
|
||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||
if (resumePoint == null) await loadResumePoint();
|
||||
@@ -409,6 +362,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
final index = session.currentIndex;
|
||||
final drama = queue.entryAt(index).drama;
|
||||
await player.controller.setVolume(muted ? 0 : 1);
|
||||
await player.controller.setPlaybackSpeed(playbackSpeed);
|
||||
if (session.active != player) return;
|
||||
trackedPlayer = player;
|
||||
trackedEpisode = episode;
|
||||
@@ -531,6 +485,18 @@ class PlayerLogic extends ChangeNotifier {
|
||||
: null;
|
||||
}
|
||||
|
||||
bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null;
|
||||
|
||||
Future<void> playNextEpisode() async {
|
||||
final next = nextVisibleIndex(session.currentIndex);
|
||||
if (next == null || !pages.hasClients) return;
|
||||
await pages.animateToPage(
|
||||
visibleIndices.indexOf(next),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
void beginScrub(VideoPlayerPort player) {
|
||||
if (session.active != player) return;
|
||||
scrubbingPlayer = player;
|
||||
@@ -824,6 +790,15 @@ class PlayerLogic extends ChangeNotifier {
|
||||
notify();
|
||||
}
|
||||
|
||||
Future<void> setPlaybackSpeed(double value) async {
|
||||
playbackSpeed = value;
|
||||
final active = session.active;
|
||||
if (active is VideoPlayerPort && active.controller.value.isInitialized) {
|
||||
await active.controller.setPlaybackSpeed(value);
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
Future<void> showComments(BuildContext context, Drama drama) async {
|
||||
session.setOverlayOpen(true);
|
||||
await session.playbackSettled;
|
||||
|
||||
+556
-317
@@ -18,12 +18,15 @@ import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/platform/haptics.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/app_bottom_nav.dart';
|
||||
import '../../core/widgets/auth_gate.dart';
|
||||
import '../../core/widgets/download_action.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'player_binding.dart';
|
||||
import 'player_logic.dart';
|
||||
import 'widgets/home_feed_overlay.dart';
|
||||
import 'widgets/exit_clear_screen_button.dart';
|
||||
import 'widgets/player_center_toggle.dart';
|
||||
import 'widgets/player_featured_comment.dart';
|
||||
import 'widgets/seek_progress_bar.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
@@ -68,6 +71,8 @@ class PlayerPage extends StatefulWidget {
|
||||
|
||||
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
late final PlayerLogic logic;
|
||||
bool _chromeVisible = true;
|
||||
double _playbackSpeed = 1.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -161,7 +166,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
logic.session.preparedPlayerAt(index);
|
||||
final videoPort = port is VideoPlayerPort ? port : null;
|
||||
final ready = videoPort?.controller.value.isInitialized ?? false;
|
||||
final theater = logic.fullscreen || logic.pip || logic.pipPreparing;
|
||||
final theater = logic.pip || logic.pipPreparing;
|
||||
Widget? video;
|
||||
if (ready) {
|
||||
video = GestureDetector(
|
||||
@@ -186,9 +191,10 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
children: [
|
||||
const ColoredBox(color: AppColors.ink),
|
||||
?video,
|
||||
if (!logic.chromeHidden)
|
||||
const IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible))
|
||||
IgnorePointer(
|
||||
key: widget.homeMode ? const Key('home-feed-scrim') : null,
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
@@ -204,6 +210,15 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.homeMode && _chromeVisible && !logic.chromeHidden)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const Key('home-feed-video-toggle'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||
),
|
||||
),
|
||||
// 票据三态之一:起播前正在向后端要签名地址
|
||||
if (selected && logic.ticketLoading)
|
||||
const Center(
|
||||
@@ -300,41 +315,60 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected && ready && !logic.session.userWantsPlay && !logic.pip)
|
||||
const Center(
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
size: 48,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (logic.pip || logic.pipPreparing)
|
||||
const SizedBox.expand()
|
||||
else if (logic.fullscreen)
|
||||
_FullscreenControls(
|
||||
l10n: l10n,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
onClose: () => unawaited(logic.setFullscreen(false)),
|
||||
onPip: logic.pictureInPicture.isPlatformSupported
|
||||
? () => unawaited(logic.enterPip(context))
|
||||
: null,
|
||||
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
)
|
||||
else if (!logic.screenCleared)
|
||||
else if (widget.homeMode)
|
||||
_chromeVisible
|
||||
? HomeFeedOverlay(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
liked: interactions.isLiked(drama.id),
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
if (!context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final result = await interactions.toggleDramaLike(
|
||||
drama.id,
|
||||
);
|
||||
if (!result.isSuccess && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.errorMessage ??
|
||||
AppLocalizations.of(context)!.likeFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSave: () async {
|
||||
final favorites = context.read<FavoriteController>();
|
||||
if (await ensureSignedIn(context)) {
|
||||
if (!context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final ok = await favorites.toggle(drama.id, card: drama);
|
||||
if (ok || !context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.favoriteToggleFailed)),
|
||||
);
|
||||
}
|
||||
},
|
||||
onComments: () => logic.showComments(context, drama),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onClear: () => setState(() => _chromeVisible = false),
|
||||
onSearch:
|
||||
widget.onSearch ?? () => logic.showDiscover(context),
|
||||
onPip: logic.pictureInPicture.isPlatformSupported
|
||||
? () => unawaited(logic.enterPip(context))
|
||||
: null,
|
||||
)
|
||||
: const SizedBox.shrink()
|
||||
else
|
||||
_PlaybackOverlay(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
@@ -346,9 +380,18 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
onBack: widget.embedded
|
||||
? null
|
||||
: () => unawaited(logic.leave(context)),
|
||||
onDiscover: () => logic.showDiscover(context),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onMy: () => logic.showMy(context),
|
||||
onNext: logic.hasNextEpisode
|
||||
? () => unawaited(logic.playNextEpisode())
|
||||
: null,
|
||||
playing: logic.session.userWantsPlay,
|
||||
playbackSpeed: _playbackSpeed,
|
||||
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
|
||||
controlsVisible: _chromeVisible,
|
||||
onClear: () => setState(() => _chromeVisible = false),
|
||||
onExitClear: () => setState(() => _chromeVisible = true),
|
||||
onTogglePlay: () =>
|
||||
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
@@ -382,7 +425,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onComments: () => logic.showComments(context, drama),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onClear: () => logic.setScreenCleared(true),
|
||||
onPip: logic.pictureInPicture.isPlatformSupported
|
||||
? () => unawaited(logic.enterPip(context))
|
||||
: null,
|
||||
@@ -390,81 +432,58 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
onSeek: ready
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
showBottomNav: false,
|
||||
),
|
||||
if (logic.screenCleared)
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4, right: 8),
|
||||
child: IconButton(
|
||||
tooltip: l10n.exitClearScreen,
|
||||
onPressed: () => logic.setScreenCleared(false),
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: const Color(0x59000000),
|
||||
),
|
||||
icon: const Icon(Icons.close_fullscreen_rounded),
|
||||
),
|
||||
if (widget.homeMode && !_chromeVisible)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const Key('home-feed-video-tap'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||
),
|
||||
),
|
||||
if (widget.homeMode && !_chromeVisible)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
left: false,
|
||||
bottom: false,
|
||||
minimum: const EdgeInsets.only(top: 8, right: 12),
|
||||
child: ExitClearScreenButton(
|
||||
key: const Key('home-feed-exit-clear-screen'),
|
||||
tooltip: l10n.exitClearScreen,
|
||||
onPressed: () => setState(() => _chromeVisible = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.homeMode &&
|
||||
!logic.chromeHidden &&
|
||||
selected &&
|
||||
!logic.session.userWantsPlay)
|
||||
Center(
|
||||
child: PlayerCenterToggle(
|
||||
key: const Key('home-feed-center-play'),
|
||||
playing: false,
|
||||
onTap: () =>
|
||||
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FullscreenControls extends StatelessWidget {
|
||||
const _FullscreenControls({
|
||||
required this.l10n,
|
||||
required this.controller,
|
||||
required this.onClose,
|
||||
required this.onPip,
|
||||
required this.onScrubStart,
|
||||
required this.onSeek,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final VideoPlayerController? controller;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback? onPip;
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.exitFullscreen,
|
||||
onPressed: onClose,
|
||||
icon: const Icon(Icons.close_fullscreen_rounded),
|
||||
),
|
||||
const Spacer(),
|
||||
if (onPip != null)
|
||||
IconButton(
|
||||
tooltip: l10n.pictureInPicture,
|
||||
onPressed: onPip,
|
||||
icon: const Icon(Icons.picture_in_picture_alt_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
|
||||
child: SeekProgressBar(
|
||||
valueListenable: controller,
|
||||
semanticsLabel: l10n.seekVideo,
|
||||
onScrubStart: onScrubStart,
|
||||
onSeek: onSeek,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Future<void> _openSpeedSheet(BuildContext context) async {
|
||||
final selected = await showModalBottomSheet<double>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
showDragHandle: true,
|
||||
builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed),
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() => _playbackSpeed = selected);
|
||||
await logic.setPlaybackSpeed(selected);
|
||||
}
|
||||
}
|
||||
|
||||
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
|
||||
@@ -589,7 +608,7 @@ class _UnlockCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaybackOverlay extends StatelessWidget {
|
||||
class _PlaybackOverlay extends StatefulWidget {
|
||||
const _PlaybackOverlay({
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
@@ -597,20 +616,24 @@ class _PlaybackOverlay extends StatelessWidget {
|
||||
required this.controller,
|
||||
required this.liked,
|
||||
required this.saved,
|
||||
required this.playing,
|
||||
required this.playbackSpeed,
|
||||
required this.onSpeedTap,
|
||||
required this.controlsVisible,
|
||||
required this.onTogglePlay,
|
||||
required this.onClear,
|
||||
required this.onExitClear,
|
||||
required this.onBack,
|
||||
required this.onDiscover,
|
||||
required this.onEpisodes,
|
||||
required this.onMy,
|
||||
required this.onComments,
|
||||
required this.onLike,
|
||||
required this.onSave,
|
||||
required this.onDownload,
|
||||
required this.onComments,
|
||||
required this.onShare,
|
||||
required this.onClear,
|
||||
required this.onPip,
|
||||
required this.onScrubStart,
|
||||
required this.onSeek,
|
||||
required this.showBottomNav,
|
||||
this.onNext,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
@@ -619,230 +642,446 @@ class _PlaybackOverlay extends StatelessWidget {
|
||||
final VideoPlayerController? controller;
|
||||
final bool liked;
|
||||
final bool saved;
|
||||
final bool playing;
|
||||
final double playbackSpeed;
|
||||
final VoidCallback onSpeedTap;
|
||||
final bool controlsVisible;
|
||||
final VoidCallback onTogglePlay;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onExitClear;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback onDiscover;
|
||||
final VoidCallback onEpisodes;
|
||||
final VoidCallback onMy;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback onShare;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback? onPip;
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
final bool showBottomNav;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
bottom: showBottomNav,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(child: _info()),
|
||||
_rail(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
0,
|
||||
16,
|
||||
20 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
child: SeekProgressBar(
|
||||
valueListenable: controller,
|
||||
semanticsLabel: l10n.seekVideo,
|
||||
onScrubStart: onScrubStart,
|
||||
onSeek: onSeek,
|
||||
),
|
||||
),
|
||||
if (showBottomNav) _fallbackNav(),
|
||||
],
|
||||
),
|
||||
if (onBack != null)
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 8,
|
||||
child: IconButton(
|
||||
tooltip: l10n.backToSeries,
|
||||
onPressed: onBack,
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: const Color(0x59000000),
|
||||
),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
tooltip: l10n.clearScreen,
|
||||
onPressed: onClear,
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: const Color(0x59000000),
|
||||
),
|
||||
icon: const Icon(Icons.fit_screen_outlined),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _info() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.15,
|
||||
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _rail() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RailAction(
|
||||
key: const Key('rail-like'),
|
||||
icon: liked ? Icons.favorite : Icons.favorite_border,
|
||||
label: l10n.like,
|
||||
selected: liked,
|
||||
onTap: onLike,
|
||||
),
|
||||
_RailAction(
|
||||
key: const Key('rail-save'),
|
||||
icon: saved ? Icons.bookmark : Icons.bookmark_border,
|
||||
label: l10n.save,
|
||||
selected: saved,
|
||||
onTap: onSave,
|
||||
),
|
||||
_RailAction(
|
||||
key: const Key('rail-download'),
|
||||
icon: Icons.download_outlined,
|
||||
label: l10n.downloads,
|
||||
onTap: onDownload,
|
||||
),
|
||||
_RailAction(
|
||||
icon: Icons.grid_view_outlined,
|
||||
label: l10n.episodes,
|
||||
onTap: onEpisodes,
|
||||
),
|
||||
_RailAction(
|
||||
key: const Key('rail-comments'),
|
||||
icon: Icons.chat_bubble_outline,
|
||||
label: l10n.comments,
|
||||
onTap: onComments,
|
||||
),
|
||||
_RailAction(
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.share_outlined,
|
||||
label: l10n.share,
|
||||
onTap: onShare,
|
||||
),
|
||||
if (onPip != null)
|
||||
IconButton(
|
||||
tooltip: l10n.pictureInPicture,
|
||||
onPressed: onPip,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _railTapSize,
|
||||
height: _railTapSize,
|
||||
),
|
||||
color: AppColors.cream,
|
||||
icon: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
size: _railIconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _fallbackNav() => AppBottomNav(
|
||||
items: [
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('player-nav-home'),
|
||||
icon: Icons.play_circle_outline_rounded,
|
||||
selectedIcon: Icons.play_circle_fill_rounded,
|
||||
label: l10n.homeTab,
|
||||
selected: true,
|
||||
onTap: () {},
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('player-nav-discover'),
|
||||
icon: Icons.explore_outlined,
|
||||
selectedIcon: Icons.explore_rounded,
|
||||
label: l10n.discoverTab,
|
||||
onTap: onDiscover,
|
||||
),
|
||||
AppBottomNavItem(
|
||||
tabKey: const Key('player-nav-my'),
|
||||
icon: Icons.video_library_outlined,
|
||||
selectedIcon: Icons.video_library_rounded,
|
||||
label: l10n.myTab,
|
||||
onTap: onMy,
|
||||
),
|
||||
],
|
||||
);
|
||||
State<_PlaybackOverlay> createState() => _PlaybackOverlayState();
|
||||
}
|
||||
|
||||
const double _railIconSize = 28;
|
||||
const double _railIconWeight = 300;
|
||||
const double _railTapSize = 44;
|
||||
class _PlaybackOverlayState extends State<_PlaybackOverlay> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
key: const Key('player-video-tap'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTogglePlay,
|
||||
),
|
||||
),
|
||||
if (widget.controlsVisible)
|
||||
Column(
|
||||
key: const Key('player-controls-visible'),
|
||||
children: [
|
||||
_topBar(context),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
key: const Key('player-controls-bottom'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 14, 12),
|
||||
child: Column(
|
||||
children: [
|
||||
SeekProgressBar(
|
||||
key: const Key('player-progress'),
|
||||
valueListenable: widget.controller,
|
||||
semanticsLabel: widget.l10n.seekVideo,
|
||||
showTimes: true,
|
||||
onScrubStart: widget.onScrubStart,
|
||||
onSeek: widget.onSeek,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _PlayerAction(
|
||||
key: const Key('player-episodes'),
|
||||
icon: Icons.view_list_rounded,
|
||||
label: widget.l10n.episodes,
|
||||
onTap: widget.onEpisodes,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _PlayerAction(
|
||||
key: const Key('player-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: widget.l10n.comments,
|
||||
onTap: widget.onComments,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
child: FilledButton(
|
||||
key: const Key('player-next'),
|
||||
onPressed: widget.onNext,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
disabledBackgroundColor: AppColors.raised,
|
||||
disabledForegroundColor: AppColors.subtle,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.l10n.nextEpisode,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(
|
||||
Icons.skip_next_rounded,
|
||||
size: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PlayerFeaturedComment(
|
||||
drama: widget.drama,
|
||||
onViewAll: widget.onComments,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!widget.controlsVisible)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 12,
|
||||
child: ExitClearScreenButton(
|
||||
key: const Key('player-exit-clear-screen'),
|
||||
tooltip: widget.l10n.exitClearScreen,
|
||||
onPressed: widget.onExitClear,
|
||||
),
|
||||
),
|
||||
if (!widget.playing)
|
||||
Center(
|
||||
child: PlayerCenterToggle(
|
||||
key: const Key('player-play-toggle'),
|
||||
playing: false,
|
||||
onTap: widget.onTogglePlay,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RailAction extends StatelessWidget {
|
||||
const _RailAction({
|
||||
Widget _topBar(BuildContext context) {
|
||||
return SizedBox(
|
||||
key: const Key('player-controls-top'),
|
||||
height: 46,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.onBack != null)
|
||||
_TopAction(
|
||||
key: const Key('player-back'),
|
||||
tooltip: widget.l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
onPressed: widget.onBack!,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 36),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${localizedDramaTitle(widget.l10n, widget.drama)} · '
|
||||
'${widget.l10n.episodeLabel(widget.episode.number)}',
|
||||
key: const Key('player-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_TopAction(
|
||||
key: const Key('player-clear-screen'),
|
||||
tooltip: widget.l10n.clearScreen,
|
||||
icon: Icons.fullscreen_rounded,
|
||||
onPressed: widget.onClear,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_TopAction(
|
||||
key: const Key('player-more'),
|
||||
tooltip: widget.l10n.more,
|
||||
icon: Icons.more_horiz_rounded,
|
||||
onPressed: () => _openMore(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openMore(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: const Key('player-more-speed'),
|
||||
leading: const Icon(
|
||||
Icons.speed_rounded,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(widget.l10n.playbackSpeed),
|
||||
trailing: Text(
|
||||
_speedLabel(widget.playbackSpeed),
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onSpeedTap();
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-download'),
|
||||
leading: const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(widget.l10n.downloads),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
widget.onDownload();
|
||||
},
|
||||
),
|
||||
if (widget.onPip != null)
|
||||
ListTile(
|
||||
key: const Key('player-more-pip'),
|
||||
leading: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(widget.l10n.pictureInPicture),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
widget.onPip!();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-like'),
|
||||
leading: Icon(
|
||||
widget.liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
color: widget.liked ? AppColors.blue : AppColors.muted,
|
||||
),
|
||||
title: Text(widget.l10n.like),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
widget.onLike();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-favorite'),
|
||||
leading: Icon(
|
||||
widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
color: widget.saved ? AppColors.blue : AppColors.muted,
|
||||
),
|
||||
title: Text(widget.l10n.favorite),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
widget.onSave();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('player-more-share'),
|
||||
leading: const Icon(Icons.share_outlined, color: AppColors.muted),
|
||||
title: Text(widget.l10n.share),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
widget.onShare();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const List<double> _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
String _speedLabel(double value) => '${value.toString()}x';
|
||||
|
||||
class _PlaybackSpeedSheet extends StatelessWidget {
|
||||
const _PlaybackSpeedSheet({required this.selected});
|
||||
|
||||
final double selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
key: const Key('player-speed-sheet'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 2, 20, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
l10n.playbackSpeed,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final value in _speedOptions)
|
||||
ListTile(
|
||||
key: Key('player-speed-${value.toString()}'),
|
||||
dense: true,
|
||||
tileColor: value == selected
|
||||
? AppColors.blue.withValues(alpha: 0.12)
|
||||
: null,
|
||||
title: Text(
|
||||
_speedLabel(value),
|
||||
style: TextStyle(
|
||||
color: value == selected ? AppColors.blue : AppColors.cream,
|
||||
fontSize: 13,
|
||||
fontWeight: value == selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
trailing: value == selected
|
||||
? const Icon(
|
||||
Icons.check_rounded,
|
||||
color: AppColors.blue,
|
||||
size: 18,
|
||||
)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopAction extends StatelessWidget {
|
||||
const _TopAction({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2C2D33),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
),
|
||||
icon: Icon(icon, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerAction extends StatelessWidget {
|
||||
const _PlayerAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => IconButton(
|
||||
tooltip: label,
|
||||
onPressed: onTap,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: _railTapSize,
|
||||
height: _railTapSize,
|
||||
),
|
||||
color: selected ? AppColors.blueSoft : AppColors.cream,
|
||||
icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0),
|
||||
);
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF2A2A30),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: AppColors.secondary),
|
||||
const SizedBox(width: 3),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Shared floating action used to leave clear-screen mode.
|
||||
class ExitClearScreenButton extends StatelessWidget {
|
||||
const ExitClearScreenButton({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: const Color(0x80000000),
|
||||
foregroundColor: Colors.white,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
icon: const Icon(Icons.fullscreen_exit_rounded, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/glass_panel.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// Home feed layer from `docs/移动端设计预览.html` screen 01.
|
||||
class HomeFeedOverlay extends StatelessWidget {
|
||||
const HomeFeedOverlay({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
required this.episode,
|
||||
required this.liked,
|
||||
required this.saved,
|
||||
required this.onLike,
|
||||
required this.onSave,
|
||||
required this.onComments,
|
||||
required this.onShare,
|
||||
required this.onEpisodes,
|
||||
required this.onDownload,
|
||||
required this.onClear,
|
||||
required this.onSearch,
|
||||
this.onPip,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Drama drama;
|
||||
final Episode episode;
|
||||
final bool liked;
|
||||
final bool saved;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback onShare;
|
||||
final VoidCallback onEpisodes;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback? onPip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_header(context),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: const Alignment(1.0, 0.45),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 12, 16),
|
||||
child: _rail(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
child: _infoCard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 10, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
key: const Key('home-feed-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
),
|
||||
_RoundIcon(
|
||||
key: const Key('home-feed-search'),
|
||||
tooltip: l10n.search,
|
||||
icon: Icons.search_rounded,
|
||||
onPressed: onSearch,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_RoundIcon(
|
||||
key: const Key('home-feed-clear-screen'),
|
||||
tooltip: l10n.clearScreen,
|
||||
icon: Icons.fullscreen_rounded,
|
||||
onPressed: onClear,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_RoundIcon(
|
||||
key: const Key('home-feed-more'),
|
||||
tooltip: l10n.more,
|
||||
icon: Icons.menu_rounded,
|
||||
onPressed: () => _openMore(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rail() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RailItem(
|
||||
key: const Key('rail-like'),
|
||||
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
label: l10n.like,
|
||||
selected: liked,
|
||||
onTap: onLike,
|
||||
),
|
||||
_RailItem(
|
||||
key: const Key('rail-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.comments,
|
||||
onTap: onComments,
|
||||
),
|
||||
_RailItem(
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.ios_share_rounded,
|
||||
label: l10n.share,
|
||||
onTap: onShare,
|
||||
),
|
||||
_RailItem(
|
||||
key: const Key('rail-save'),
|
||||
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
label: l10n.favorite,
|
||||
selected: saved,
|
||||
onTap: onSave,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard() {
|
||||
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||
return GlassPanel(
|
||||
key: const Key('home-feed-info-card'),
|
||||
radius: 14,
|
||||
blur: 6,
|
||||
padding: const EdgeInsets.all(12),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xBF0F0F14), Color(0xA60F0F14)],
|
||||
),
|
||||
borderColor: Colors.white.withValues(alpha: 0.06),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (genre.isNotEmpty)
|
||||
_tag(
|
||||
genre,
|
||||
background: AppColors.blue,
|
||||
foreground: AppColors.onPrimary,
|
||||
),
|
||||
if (drama.category.isNotEmpty && drama.category != genre)
|
||||
_tag(
|
||||
drama.category,
|
||||
background: const Color(0xFF333333),
|
||||
foreground: const Color(0xFFDDDDDD),
|
||||
),
|
||||
Text(
|
||||
l10n.episodeOfTotal(episode.number, drama.episodeCount),
|
||||
key: const Key('home-feed-episode'),
|
||||
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
episode.title.trim().isEmpty
|
||||
? localizedDramaTitle(l10n, drama)
|
||||
: episode.title,
|
||||
key: const Key('home-feed-episode-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('home-feed-follow-series'),
|
||||
onPressed: onSave,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.buttonRadius,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 16),
|
||||
label: Text(
|
||||
saved ? l10n.followingTab : l10n.followSeries,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: FilledButton(
|
||||
key: const Key('home-feed-episodes'),
|
||||
onPressed: onEpisodes,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF2A2A30),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.buttonRadius,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
l10n.episodes,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tag(
|
||||
String label, {
|
||||
required Color background,
|
||||
required Color foreground,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openMore(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: const Key('home-feed-download'),
|
||||
leading: const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.downloads),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onDownload();
|
||||
},
|
||||
),
|
||||
if (onPip != null)
|
||||
ListTile(
|
||||
key: const Key('home-feed-pip'),
|
||||
leading: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.pictureInPicture),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onPip!();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoundIcon extends StatelessWidget {
|
||||
const _RoundIcon({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: const Color(0xCC2C2D33),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
),
|
||||
icon: Icon(icon, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RailItem extends StatelessWidget {
|
||||
const _RailItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: selected ? AppColors.blue : AppColors.onSurface,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 9,
|
||||
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
/// Shared play/pause control used by the home feed and full-screen player.
|
||||
class PlayerCenterToggle extends StatelessWidget {
|
||||
const PlayerCenterToggle({
|
||||
super.key,
|
||||
required this.playing,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool playing;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: playing ? 'Pause' : 'Play',
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x80000000),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
playing ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/comment.dart';
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/repositories/comment_repository.dart';
|
||||
import '../../../core/data/repositories/interaction_repository.dart';
|
||||
import '../../../core/data/services/comment_controller.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
class PlayerFeaturedComment extends StatefulWidget {
|
||||
const PlayerFeaturedComment({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.onViewAll,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final VoidCallback onViewAll;
|
||||
|
||||
@override
|
||||
State<PlayerFeaturedComment> createState() => _PlayerFeaturedCommentState();
|
||||
}
|
||||
|
||||
class _PlayerFeaturedCommentState extends State<PlayerFeaturedComment> {
|
||||
late final CommentController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = CommentController(
|
||||
comments: context.read<CommentRepository>(),
|
||||
interactions: context.read<InteractionRepository>(),
|
||||
dramaId: widget.drama.id,
|
||||
);
|
||||
unawaited(_controller.load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) {
|
||||
if (_controller.comments.isEmpty) return const SizedBox.shrink();
|
||||
return _card(context, _controller.comments.first);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _card(BuildContext context, DramaComment comment) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final time = _time(l10n, comment.createdAt);
|
||||
return InkWell(
|
||||
key: const Key('player-featured-comment'),
|
||||
onTap: widget.onViewAll,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.secondary.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Text(
|
||||
_initial(comment.nickname),
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
comment.nickname.isEmpty
|
||||
? l10n.localViewer
|
||||
: comment.nickname,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (time != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'· $time',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF777777),
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
comment.content,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFDDDDDD),
|
||||
fontSize: 10,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _time(AppLocalizations l10n, DateTime? createdAt) {
|
||||
if (createdAt == null) return null;
|
||||
final diff = DateTime.now().difference(createdAt);
|
||||
if (diff.inMinutes < 1) return null;
|
||||
return l10n.minutesAgo(diff.inMinutes);
|
||||
}
|
||||
|
||||
String _initial(String nickname) {
|
||||
final trimmed = nickname.trim();
|
||||
return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.showTimes = true,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
final bool showTimes;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -116,17 +118,27 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: scrubbing ? 48 : 44,
|
||||
height: widget.compact
|
||||
? (scrubbing ? 32 : 20)
|
||||
: (scrubbing ? 48 : 44),
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: scrubbing ? 4 : 3,
|
||||
activeTrackColor: AppColors.gold,
|
||||
inactiveTrackColor: Colors.white30,
|
||||
thumbColor: Colors.white,
|
||||
trackHeight: widget.compact
|
||||
? (scrubbing ? 3 : 1.5)
|
||||
: (scrubbing ? 4 : 3),
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.compact
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: const Color(0xFF474248),
|
||||
thumbColor: widget.compact ? AppColors.blue : Colors.white,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
||||
enabledThumbRadius: widget.compact
|
||||
? (scrubbing ? 6 : 4)
|
||||
: (scrubbing ? 8 : 6),
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
|
||||
Reference in new issue
Block a user