merge: K25 客户端详情页与播放器

This commit is contained in:
Zhou Weidong committed 2026-10-19 15:17:18 +08:00
commit 04fddce1d1
15 files changed
+2382 -243

No files matched your search

+7
View File
@@ -27,6 +27,13 @@
"verticalMiniSeries": "VERTICAL MINI-SERIES",
"followSeries": "Follow Series",
"seasonOneVerticalCinema": "Season 1 • Vertical Cinema",
"seasonOneOriginalRelease": "Season 1 • Original Release",
"startWatching": "Start Watching",
"autoNextOn": "Auto-Next On",
"viewAll": "View all",
"showLess": "Less",
"views": "views",
"enterFullscreen": "Fullscreen",
"artworkOnly": "Artwork",
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
"videoPending": "Video pending",
+7
View File
@@ -27,6 +27,13 @@
"verticalMiniSeries": "SERIAL MINI VERTIKAL",
"followSeries": "Ikuti Series",
"seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal",
"seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal",
"startWatching": "Mulai Menonton",
"autoNextOn": "Lanjut Otomatis",
"viewAll": "Lihat semua",
"showLess": "Lebih sedikit",
"views": "tayangan",
"enterFullscreen": "Layar penuh",
"artworkOnly": "Gambar",
"artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.",
"videoPending": "Video belum siap",
+42
View File
@@ -262,6 +262,48 @@ abstract class AppLocalizations {
/// **'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 @autoNextOn.
///
/// In en, this message translates to:
/// **'Auto-Next On'**
String get autoNextOn;
/// No description provided for @viewAll.
///
/// In en, this message translates to:
/// **'View all'**
String get viewAll;
/// 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 @enterFullscreen.
///
/// In en, this message translates to:
/// **'Fullscreen'**
String get enterFullscreen;
/// No description provided for @artworkOnly.
///
/// In en, this message translates to:
+21
View File
@@ -90,6 +90,27 @@ class AppLocalizationsEn extends AppLocalizations {
@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 autoNextOn => 'Auto-Next On';
@override
String get viewAll => 'View all';
@override
String get showLess => 'Less';
@override
String get views => 'views';
@override
String get enterFullscreen => 'Fullscreen';
@override
String get artworkOnly => 'Artwork';
+21
View File
@@ -90,6 +90,27 @@ class AppLocalizationsId extends AppLocalizations {
@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 autoNextOn => 'Lanjut Otomatis';
@override
String get viewAll => 'Lihat semua';
@override
String get showLess => 'Lebih sedikit';
@override
String get views => 'tayangan';
@override
String get enterFullscreen => 'Layar penuh';
@override
String get artworkOnly => 'Gambar';
+21
View File
@@ -89,6 +89,27 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院';
@override
String get seasonOneOriginalRelease => '第 1 季 · 原创上线';
@override
String get startWatching => '开始观看';
@override
String get autoNextOn => '自动连播';
@override
String get viewAll => '查看全部';
@override
String get showLess => '收起';
@override
String get views => '次播放';
@override
String get enterFullscreen => '全屏';
@override
String get artworkOnly => '海报展示';
+7
View File
@@ -27,6 +27,13 @@
"verticalMiniSeries": "竖屏短剧",
"followSeries": "追剧",
"seasonOneVerticalCinema": "第 1 季 · 竖屏影院",
"seasonOneOriginalRelease": "第 1 季 · 原创上线",
"startWatching": "开始观看",
"autoNextOn": "自动连播",
"viewAll": "查看全部",
"showLess": "收起",
"views": "次播放",
"enterFullscreen": "全屏",
"artworkOnly": "海报展示",
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
"videoPending": "视频待提供",
File diff suppressed because it is too large. Load diff
+12
View File
@@ -531,6 +531,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;
+620
View File
@@ -21,10 +21,14 @@ 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 '../../core/widgets/glass_panel.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/card_seek_bar.dart';
import 'widgets/home_feed_overlay.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';
@@ -114,6 +118,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
Widget _buildPlayer(BuildContext context) {
final interactions = context.watch<InteractionController>();
if (!widget.homeMode && !logic.fullscreen) {
return _buildCardPlayer(context, interactions);
}
final indices = logic.visibleIndices;
final pages = logic.pages;
final content = AnimatedBuilder(
@@ -143,6 +150,533 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
);
}
Widget _buildCardPlayer(
BuildContext context,
InteractionController interactions,
) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.dark(),
child: Scaffold(
backgroundColor: AppColors.ink,
body: SafeArea(
child: AnimatedBuilder(
animation: logic.session,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
final index = logic.session.currentIndex;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = logic.displayEpisode(drama, entry.episode);
final port =
(index == logic.session.currentIndex
? logic.session.active
: null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final ready =
videoPort?.controller.value.isInitialized ?? false;
return Column(
children: [
_cardTopBar(context, l10n, drama, episode),
Expanded(
child: SingleChildScrollView(
key: const Key('card-player-scroll'),
padding: const EdgeInsets.fromLTRB(16, 6, 16, 20),
child: Column(
children: [
AspectRatio(
key: const Key('card-player-video-frame'),
aspectRatio: 9 / 10,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AnimatedBuilder(
animation: logic.session,
builder: (context, _) => PageView.builder(
controller: logic.pages,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
itemCount: logic.visibleIndices.length,
onPageChanged: (position) {
final next =
logic.visibleIndices[position];
widget.onDramaChanged?.call(
logic.queue.entryAt(next).drama,
);
unawaited(logic.selectEpisode(next));
},
itemBuilder: (context, position) =>
_buildCardVideo(
context,
logic.visibleIndices[position],
interactions,
),
),
),
),
),
const SizedBox(height: 6),
CardSeekBar(
controller: ready ? videoPort!.controller : null,
semanticsLabel: l10n.seekVideo,
episodeLabel: l10n.episodeLabel(episode.number),
totalEpisodes: drama.episodeCount,
onScrubStart: ready
? () => logic.beginScrub(videoPort!)
: null,
onSeek: ready
? (position) => logic.seekTo(
context,
videoPort!,
position,
)
: null,
),
const SizedBox(height: 10),
_cardActions(context, l10n, drama, episode),
const SizedBox(height: 12),
PlayerFeaturedComment(
drama: drama,
onViewAll: () =>
logic.showComments(context, drama),
),
],
),
),
),
],
);
},
),
),
),
),
);
}
Widget _cardTopBar(
BuildContext context,
AppLocalizations l10n,
Drama drama,
Episode episode,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 6),
child: Row(
children: [
IconButton(
key: const Key('player-back'),
tooltip: l10n.backToSeries,
onPressed: () => unawaited(logic.leave(context)),
style: IconButton.styleFrom(
backgroundColor: AppColors.raised,
foregroundColor: AppColors.onSurface,
),
icon: const Icon(Icons.chevron_left_rounded),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${localizedDramaTitle(l10n, drama)} · '
'${l10n.episodeLabel(episode.number)}',
key: const Key('player-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
l10n.seasonOneOriginalRelease,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.subtle, fontSize: 10),
),
],
),
),
const SizedBox(width: 8),
IconButton(
key: const Key('player-more'),
tooltip: l10n.more,
onPressed: () => unawaited(_showCardMore(context, l10n, episode)),
style: IconButton.styleFrom(
backgroundColor: AppColors.raised,
foregroundColor: AppColors.onSurface,
),
icon: const Icon(Icons.more_horiz_rounded),
),
],
),
);
}
Widget _buildCardVideo(
BuildContext context,
int index,
InteractionController interactions,
) {
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = logic.displayEpisode(drama, entry.episode);
final selected = index == logic.session.currentIndex;
final port =
(selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final ready = videoPort?.controller.value.isInitialized ?? false;
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: AppColors.ink),
if (ready)
GestureDetector(
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: videoPort!.controller.value.size.width,
height: videoPort.controller.value.size.height,
child: VideoPlayer(
videoPort.controller,
key: ValueKey(videoPort.controller),
),
),
),
)
else
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
const IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x66000000),
Color(0x11000000),
Color(0x99000000),
],
stops: [0, 0.46, 1],
),
),
),
),
Positioned(
top: 10,
left: 10,
right: 10,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GlassPanel(
radius: 999,
blur: 12,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.skip_next_rounded,
size: 12,
color: AppColors.secondary,
),
const SizedBox(width: 3),
Text(
l10n.autoNextOn,
style: const TextStyle(
color: AppColors.secondary,
fontSize: 9.5,
fontWeight: FontWeight.w600,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withValues(alpha: 0.18),
),
),
child: const Text(
'1.0x',
style: TextStyle(
color: AppColors.cream,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
if (selected && logic.ticketLoading)
const Center(child: CircularProgressIndicator(color: AppColors.blue)),
if (selected && !logic.ticketLoading && logic.ticketError != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.wifi_off_rounded,
color: AppColors.muted,
size: 30,
),
const SizedBox(height: 8),
Text(
errorText(l10n, logic.ticketError, l10n.playbackError),
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.cream),
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => unawaited(logic.retryTicket()),
child: Text(l10n.retry),
),
],
),
),
),
if (selected && logic.session.loading)
const Center(child: CircularProgressIndicator(color: AppColors.blue)),
if (selected && !ready && logic.session.error != null)
Center(
child: Text(
l10n.playbackError,
style: const TextStyle(color: AppColors.cream),
),
),
if (selected && !ready && logic.blockedEpisodeId == episode.id)
_BlockedNotice(
l10n: l10n,
episode: episode,
accessType: logic.blockedTicket?.accessType,
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
),
if (selected &&
!ready &&
logic.blockedEpisodeId != episode.id &&
logic.session.error == null &&
logic.session.isLocked)
Center(
child: Text(
episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.cream),
),
),
if (selected && ready && !logic.session.userWantsPlay)
Center(
child: GestureDetector(
key: const Key('card-player-play'),
onTap: () => logic.session.setUserWantsPlay(true),
child: Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: AppColors.blue.withValues(alpha: 0.92),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 2,
),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 32,
),
),
),
),
Positioned(
right: 10,
bottom: 12,
child: Column(
children: [
_CardIconButton(
key: const Key('card-player-like'),
tooltip: l10n.like,
icon: interactions.isLiked(drama.id)
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
color: interactions.isLiked(drama.id)
? AppColors.blue
: AppColors.onSurface,
onPressed: () =>
unawaited(_toggleLike(context, drama, interactions)),
),
const SizedBox(height: 8),
_CardIconButton(
key: const Key('card-player-fullscreen'),
tooltip: l10n.enterFullscreen,
icon: Icons.fullscreen_rounded,
onPressed: () => unawaited(logic.setFullscreen(true)),
),
],
),
),
],
);
}
Widget _cardActions(
BuildContext context,
AppLocalizations l10n,
Drama drama,
Episode episode,
) {
return Row(
children: [
Expanded(
child: _CardActionButton(
key: const Key('card-player-episodes'),
icon: Icons.view_list_rounded,
label: l10n.episodes,
onPressed: () => unawaited(logic.showEpisodes(context)),
),
),
const SizedBox(width: 8),
Expanded(
child: _CardActionButton(
key: const Key('card-player-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments,
onPressed: () => unawaited(logic.showComments(context, drama)),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 42,
child: FilledButton(
key: const Key('card-player-next'),
onPressed: logic.hasNextEpisode
? () => unawaited(logic.playNextEpisode())
: null,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
disabledBackgroundColor: AppColors.raised,
disabledForegroundColor: AppColors.subtle,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 4),
textStyle: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
l10n.nextEpisode,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 2),
const Icon(Icons.skip_next_rounded, size: 15),
],
),
),
),
),
],
);
}
Future<void> _showCardMore(
BuildContext context,
AppLocalizations l10n,
Episode episode,
) 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-download'),
leading: const Icon(
Icons.download_outlined,
color: AppColors.secondary,
),
title: Text(l10n.downloads),
onTap: () {
Navigator.pop(sheetContext);
requestEpisodeDownload(context, episode.id);
},
),
if (logic.pictureInPicture.isPlatformSupported)
ListTile(
key: const Key('player-pip'),
leading: const Icon(
Icons.picture_in_picture_alt_outlined,
color: AppColors.secondary,
),
title: Text(l10n.pictureInPicture),
onTap: () {
Navigator.pop(sheetContext);
unawaited(logic.enterPip(context));
},
),
],
),
),
);
}
Future<void> _toggleLike(
BuildContext context,
Drama drama,
InteractionController interactions,
) async {
if (!await ensureSignedIn(context)) return;
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,
),
),
);
}
}
Widget _buildEpisode(
BuildContext context,
int index,
@@ -561,6 +1095,92 @@ class _BlockedNotice extends StatelessWidget {
}
}
class _CardIconButton extends StatelessWidget {
const _CardIconButton({
super.key,
required this.tooltip,
required this.icon,
required this.onPressed,
this.color = AppColors.onSurface,
});
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
final Color color;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: tooltip,
onPressed: onPressed,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.55),
foregroundColor: color,
side: BorderSide(color: Colors.white.withValues(alpha: 0.15)),
),
icon: Icon(icon, size: 18),
),
);
}
}
class _CardActionButton extends StatelessWidget {
const _CardActionButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
});
final IconData icon;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.raised,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 15, color: AppColors.secondary),
const SizedBox(width: 4),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}
/// Coin lock presented as a reward card: gold chip, chili only on the icon.
class _UnlockCard extends StatelessWidget {
const _UnlockCard({
+257
View File
@@ -0,0 +1,257 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart';
/// 1.5px player scrubber with a buffered segment and white thumb.
class CardSeekBar extends StatefulWidget {
const CardSeekBar({
super.key,
required this.controller,
required this.semanticsLabel,
required this.episodeLabel,
required this.totalEpisodes,
this.onScrubStart,
this.onSeek,
});
final VideoPlayerController? controller;
final String semanticsLabel;
final String episodeLabel;
final int totalEpisodes;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
@override
State<CardSeekBar> createState() => _CardSeekBarState();
}
class _CardSeekBarState extends State<CardSeekBar> {
final ValueNotifier<double?> _preview = ValueNotifier(null);
@override
void dispose() {
_preview.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;
if (controller == null) {
return _track(context, const VideoPlayerValue.uninitialized());
}
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: controller,
builder: (context, value, _) {
return ValueListenableBuilder<double?>(
valueListenable: _preview,
builder: (context, _, _) => _track(context, value),
);
},
);
}
Widget _track(BuildContext context, VideoPlayerValue video) {
final total = video.duration.inMilliseconds;
final enabled = video.isInitialized && total > 0 && widget.onSeek != null;
final played = total > 0
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
: 0.0;
final fraction = _preview.value ?? played;
final buffered = _bufferedFraction(video);
final previewPosition = _preview.value == null
? video.position
: Duration(milliseconds: (fraction * total).round());
return Column(
key: const Key('card-player-progress-bar'),
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 24,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 1.5,
activeTrackColor: AppColors.blue,
inactiveTrackColor: Colors.white.withValues(alpha: 0.15),
thumbColor: Colors.white,
overlayColor: AppColors.blue.withValues(alpha: 0.16),
trackShape: _BufferedTrackShape(buffered: buffered),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
key: const Key('card-player-progress'),
value: fraction,
onChangeStart: enabled
? (value) {
_preview.value = value;
widget.onScrubStart?.call();
}
: null,
onChanged: enabled ? (value) => _preview.value = value : null,
onChangeEnd: enabled
? (value) => unawaited(_finish(value, video.duration))
: null,
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
children: [
Text(
_format(previewPosition),
key: const Key('card-player-time-current'),
style: const TextStyle(
color: AppColors.cream,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
Expanded(
child: Center(
child: Text(
'${widget.episodeLabel} / ${widget.totalEpisodes}',
key: const Key('card-player-episode'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: 10,
),
),
),
),
Text(
_format(video.duration),
key: const Key('card-player-time-total'),
style: const TextStyle(color: AppColors.subtle, fontSize: 10),
),
],
),
),
],
);
}
Future<void> _finish(double fraction, Duration duration) async {
final source = widget.controller;
try {
final total = duration.inMilliseconds;
final target = total > 250
? (fraction * total).round().clamp(0, total - 250)
: 0;
await widget.onSeek?.call(Duration(milliseconds: target));
} finally {
if (mounted && widget.controller == source) _preview.value = null;
}
}
double _bufferedFraction(VideoPlayerValue video) {
final total = video.duration.inMilliseconds;
if (total <= 0 || video.buffered.isEmpty) return 0;
var end = Duration.zero;
for (final range in video.buffered) {
if (range.end > end) end = range.end;
}
return (end.inMilliseconds / total).clamp(0.0, 1.0);
}
String _format(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';
}
}
class _BufferedTrackShape extends SliderTrackShape with BaseSliderTrackShape {
const _BufferedTrackShape({required this.buffered});
final double buffered;
@override
Rect getPreferredRect({
required RenderBox parentBox,
Offset offset = Offset.zero,
required SliderThemeData sliderTheme,
bool isEnabled = false,
bool isDiscrete = false,
}) {
final thumbWidth =
sliderTheme.thumbShape?.getPreferredSize(isEnabled, isDiscrete).width ??
0;
final overlayWidth =
sliderTheme.overlayShape
?.getPreferredSize(isEnabled, isDiscrete)
.width ??
0;
final trackHeight = sliderTheme.trackHeight ?? 1.5;
final trackLeft = offset.dx + overlayWidth / 2;
final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2;
final trackWidth =
parentBox.size.width - (overlayWidth - thumbWidth) - thumbWidth;
return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight);
}
@override
void paint(
PaintingContext context,
Offset offset, {
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required Animation<double> enableAnimation,
required TextDirection textDirection,
required Offset thumbCenter,
Offset? secondaryOffset,
bool isEnabled = false,
bool isDiscrete = false,
bool isSecondaryTrack = false,
}) {
final rect = getPreferredRect(
parentBox: parentBox,
offset: offset,
sliderTheme: sliderTheme,
isEnabled: isEnabled,
isDiscrete: isDiscrete,
);
final radius = Radius.circular(rect.height);
final canvas = context.canvas;
canvas.drawRRect(
RRect.fromRectAndRadius(rect, radius),
Paint()..color = Colors.white.withValues(alpha: 0.15),
);
if (buffered > 0) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(
rect.left,
rect.top,
rect.width * buffered,
rect.height,
),
radius,
),
Paint()..color = Colors.white.withValues(alpha: 0.30),
);
}
final played = ((thumbCenter.dx - rect.left) / rect.width).clamp(0.0, 1.0);
if (played > 0) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(rect.left, rect.top, rect.width * played, rect.height),
radius,
),
Paint()..color = AppColors.blue,
);
}
}
}
@@ -0,0 +1,164 @@
// 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';
/// First loaded top-level comment shown under the card player.
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)!;
return Container(
key: const Key('player-featured-comment'),
padding: const EdgeInsets.fromLTRB(13, 12, 13, 10),
decoration: BoxDecoration(
color: const Color(0xFF14161E),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.secondary.withValues(alpha: 0.18),
border: Border.all(
color: AppColors.secondary.withValues(alpha: 0.4),
),
),
child: Text(
_initial(comment.nickname),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
comment.nickname.isEmpty
? l10n.localViewer
: comment.nickname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
),
),
if (comment.likeCount > 0) ...[
const Icon(
Icons.favorite_rounded,
size: 13,
color: AppColors.blue,
),
const SizedBox(width: 3),
Text(
'${comment.likeCount}',
style: const TextStyle(color: AppColors.muted, fontSize: 10),
),
],
],
),
const SizedBox(height: 8),
Text(
comment.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 11.5,
height: 1.4,
),
),
const SizedBox(height: 7),
Align(
alignment: Alignment.centerRight,
child: TextButton(
key: const Key('player-comments-view-all'),
onPressed: widget.onViewAll,
style: TextButton.styleFrom(
foregroundColor: AppColors.secondary,
minimumSize: const Size(44, 32),
padding: const EdgeInsets.symmetric(horizontal: 4),
textStyle: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w600,
),
),
child: Text(l10n.viewAll),
),
),
],
),
);
}
String _initial(String nickname) {
final trimmed = nickname.trim();
return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase();
}
}
@@ -0,0 +1,151 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/data/services/interaction_controller.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart';
import '../support/fake_catalog.dart';
import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
void main() {
testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host(_detailDrama()));
await tester.pumpAndSettle();
final hero = tester.widget<AspectRatio>(
find.byKey(const Key('detail-hero')),
);
expect(hero.aspectRatio, closeTo(16 / 9, 0.001));
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
expect(find.byKey(const Key('detail-favorite')), findsOneWidget);
expect(find.byKey(const Key('detail-download')), findsOneWidget);
expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget);
expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('detail-episode-chip-1'))),
const Size(46, 44),
);
final synopsis = tester.widget<Text>(
find.byKey(const Key('detail-synopsis')),
);
expect(synopsis.maxLines, 2);
await tester.tap(find.byKey(const Key('detail-synopsis-toggle')));
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.byKey(const Key('detail-synopsis'))).maxLines,
100,
);
final grid = tester.widget<SliverGrid>(
find.descendant(
of: find.byKey(const Key('detail-episode-grid')),
matching: find.byType(SliverGrid),
),
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 2);
expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))),
const Size(96, 64),
);
expect(tester.takeException(), isNull);
});
testWidgets('should_fit_compact_viewport_when_detail_scrolls', (
tester,
) async {
tester.view.physicalSize = const Size(320, 568);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host(_detailDrama()));
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const Key('detail-scroll')),
const Offset(0, -520),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
Widget _host(Drama drama) {
final catalog = FakeCatalogRepository(dramas: [drama]);
return MultiProvider(
providers: [
Provider<WatchHistoryRepository>.value(
value: InMemoryWatchHistoryRepository(),
),
Provider<PlaybackRepository>.value(value: FakePlaybackRepository()),
Provider<Haptics>.value(value: FakeHaptics()),
ChangeNotifierProvider(
create: (_) => FavoriteController(InMemoryFavoriteRepository()),
),
ChangeNotifierProvider(
create: (_) => InteractionController(FakeInteractionRepository()),
),
ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()),
],
child: MaterialApp(
theme: AppTheme.dark(),
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: DramaDetailPage(drama: drama),
),
);
}
Drama _detailDrama() {
return Drama(
id: 'detail-k25',
title: 'Detail K25',
synopsis:
'She agreed to marry a mysterious stranger to save her family, '
'until she discovered his secret identity and the city he controls.',
posterUrl: '/files/detail-k25.png',
genre: 'Urban Romance',
category: 'Romance',
totalEpisodes: 8,
viewCount: 4800000,
detailLoaded: true,
episodes: [
for (var number = 1; number <= 8; number++)
Episode(
id: 'detail-k25-$number',
number: number,
title: 'Episode $number',
isFree: number == 1,
durationSec: 135,
locked: number > 1,
videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '',
),
],
);
}
+169
View File
@@ -0,0 +1,169 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/comment.dart';
import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/theme/app_colors.dart';
import '../support/fake_catalog.dart';
import '../support/fake_category.dart';
import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
void main() {
setUp(() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
});
testWidgets('should_render_card_player_progress_and_three_action_buttons', (
tester,
) async {
await _pumpApp(tester);
await _openCityLights(tester);
final frame = tester.widget<AspectRatio>(
find.byKey(const Key('card-player-video-frame')),
);
expect(frame.aspectRatio, closeTo(9 / 10, 0.001));
expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(find.byKey(const Key('player-title')), findsOneWidget);
expect(find.byKey(const Key('player-more')), findsOneWidget);
expect(find.byKey(const Key('card-player-progress')), findsOneWidget);
expect(find.byKey(const Key('card-player-episodes')), findsOneWidget);
expect(find.byKey(const Key('card-player-comments')), findsOneWidget);
expect(find.byKey(const Key('card-player-next')), findsOneWidget);
final theme = tester.widget<SliderTheme>(
find.ancestor(
of: find.byKey(const Key('card-player-progress')),
matching: find.byType(SliderTheme),
),
);
expect(theme.data.trackHeight, 1.5);
expect(theme.data.activeTrackColor, AppColors.blue);
expect(theme.data.thumbColor, Colors.white);
expect(theme.data.inactiveTrackColor, Colors.white.withValues(alpha: 0.15));
final next = tester.widget<FilledButton>(
find.byKey(const Key('card-player-next')),
);
expect(next.onPressed, isNotNull);
await tester.tap(find.byKey(const Key('card-player-next')));
await tester.pumpAndSettle();
expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
contains('Episode 2'),
);
expect(tester.takeException(), isNull);
});
testWidgets('should_disable_next_episode_on_the_last_episode', (
tester,
) async {
await _pumpApp(tester);
await _openCityLights(tester);
for (var index = 0; index < 6; index++) {
await tester.tap(find.byKey(const Key('card-player-next')));
await tester.pumpAndSettle();
}
expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
contains('Episode 7'),
);
final next = tester.widget<FilledButton>(
find.byKey(const Key('card-player-next')),
);
expect(next.onPressed, isNull);
expect(tester.takeException(), isNull);
});
testWidgets('should_show_featured_comment_when_comments_exist', (
tester,
) async {
await _pumpApp(
tester,
comments: FakeCommentRepository(
pages: const [
CommentPage(
comments: [
DramaComment(
id: 'c1',
dramaId: 'city-lights-demo',
episodeId: '0',
parentId: '0',
rootId: '0',
userId: '9',
uid: 'U9',
nickname: 'Lina',
avatarUrl: '',
content: 'The banquet scene gave me chills.',
status: 'approved',
auditReason: '',
likeCount: 428,
replyCount: 0,
reportCount: 0,
createdAt: null,
mine: false,
liked: false,
),
],
),
],
),
);
await _openCityLights(tester);
expect(find.byKey(const Key('player-featured-comment')), findsOneWidget);
expect(find.text('Lina'), findsOneWidget);
expect(find.text('428'), findsOneWidget);
expect(find.byKey(const Key('player-comments-view-all')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-comments-view-all')));
await tester.pumpAndSettle();
expect(find.text('The banquet scene gave me chills.'), findsWidgets);
});
}
Future<void> _openCityLights(WidgetTester tester) async {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
await tester.tap(find.text('City Lights & Velvet Nights'));
await tester.pumpAndSettle();
}
Future<void> _pumpApp(
WidgetTester tester, {
CommentRepository? comments,
}) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
playbackRepository: FakePlaybackRepository(),
commentRepository: comments ?? FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(),
haptics: FakeHaptics(),
),
);
await tester.pumpAndSettle();
}
+7 -4
View File
@@ -184,7 +184,7 @@ void main() {
await tester.tap(find.byKey(const Key('drawer-open-series')));
await tester.pumpAndSettle();
expect(find.text('Watch episode 1'), findsOneWidget);
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(
@@ -445,8 +445,11 @@ void main() {
expect(find.byType(Slider), findsOneWidget);
await tester.drag(find.byType(PageView), const Offset(0, -550));
await tester.pumpAndSettle();
expect(find.text('Episode 2 · Playing now'), findsOneWidget);
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
expect(
tester.widget<Text>(find.byKey(const Key('player-title'))).data,
contains('Episode 2'),
);
expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets);
},
);
@@ -654,7 +657,7 @@ void main() {
expect(find.text('City Lights & Velvet Nights'), findsWidgets);
await tester.tap(find.text('The Signal'));
await tester.pumpAndSettle();
expect(find.text('Watch episode 1'), findsOneWidget);
expect(find.byKey(const Key('detail-cta')), findsOneWidget);
},
);
}