chore(release): 合并 develop 到 release/v1.0.0

This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-19 21:39:21 +08:00
commit 35364c949a
69 files changed
+6115 -2941

No files matched your search

+6 -6
View File
@@ -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);
+55 -27
View File
@@ -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);
}
+26 -8
View File
@@ -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;
+37 -13
View File
@@ -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) ||
+30 -25
View File
@@ -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,
),
),
],
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+70 -13
View File
@@ -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';
+70 -13
View File
@@ -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';
+69 -14
View File
@@ -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
View File
@@ -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": "奖励",
+161 -9
View File
@@ -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,
),
),
),
],
),
),
),
);
}
}
+1 -4
View File
@@ -8,8 +8,5 @@ class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
required WatchHistoryRepository history,
}) => DiscoverLogic(
drama: drama,
history: history,
);
}) => DiscoverLogic(drama: drama, history: history);
}
+40
View File
@@ -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() {
+433 -187
View File
@@ -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)],
),
),
],
),
),
],
);
}
}
+130 -107
View File
@@ -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,
),
),
),
+447 -253
View File
@@ -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),
),
],
],
+6 -19
View File
@@ -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();
+39 -36
View File
@@ -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);
}
}
+246
View File
@@ -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),
),
],
),
),
),
);
}
File diff suppressed because it is too large. Load diff
+27 -52
View File
@@ -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
View File
@@ -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,