feat(flutter): 重设计播放页与操作面板

This commit is contained in:
Zhou Weidong committed 2026-10-20 10:41:56 +08:00
1 parent 148fbc3c93
commit 9a4269b847
24 files changed
+2395 -1101

No files matched your search

+13 -1
View File
@@ -578,5 +578,17 @@
"qualityAutoHint": "HLS adjusts the bitrate automatically for your connection.",
"searchFailed": "Search failed. Please try again.",
"searchResultCount": "{count} results",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } },
"episodeCountLabel": "{count} episodes",
"@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } },
"commentsWithCount": "Comments ({count})",
"@commentsWithCount": { "placeholders": { "count": { "type": "int" } } },
"textSize": "Text size",
"textSizeSmall": "Small",
"textSizeStandard": "Standard",
"textSizeLarge": "Large",
"watchAdToUnlock": "Watch an ad to unlock",
"unlockWithVip": "Unlock with VIP",
"unlockMethodsVary": "Available unlock methods vary by title.",
"unlockEpisode": "Unlock this episode"
}
+13 -1
View File
@@ -576,5 +576,17 @@
"qualityAutoHint": "HLS menyesuaikan bitrate secara otomatis dengan koneksi Anda.",
"searchFailed": "Pencarian gagal. Coba lagi.",
"searchResultCount": "{count} hasil",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } },
"episodeCountLabel": "{count} episode",
"@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } },
"commentsWithCount": "Komentar ({count})",
"@commentsWithCount": { "placeholders": { "count": { "type": "int" } } },
"textSize": "Ukuran teks",
"textSizeSmall": "Kecil",
"textSizeStandard": "Standar",
"textSizeLarge": "Besar",
"watchAdToUnlock": "Tonton iklan untuk membuka",
"unlockWithVip": "Buka dengan VIP",
"unlockMethodsVary": "Metode pembukaan tersedia dapat berbeda per judul.",
"unlockEpisode": "Buka episode ini"
}
+60
View File
@@ -2571,6 +2571,66 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{count} results'**
String searchResultCount(int count);
/// No description provided for @episodeCountLabel.
///
/// In en, this message translates to:
/// **'{count} episodes'**
String episodeCountLabel(int count);
/// No description provided for @commentsWithCount.
///
/// In en, this message translates to:
/// **'Comments ({count})'**
String commentsWithCount(int count);
/// No description provided for @textSize.
///
/// In en, this message translates to:
/// **'Text size'**
String get textSize;
/// No description provided for @textSizeSmall.
///
/// In en, this message translates to:
/// **'Small'**
String get textSizeSmall;
/// No description provided for @textSizeStandard.
///
/// In en, this message translates to:
/// **'Standard'**
String get textSizeStandard;
/// No description provided for @textSizeLarge.
///
/// In en, this message translates to:
/// **'Large'**
String get textSizeLarge;
/// No description provided for @watchAdToUnlock.
///
/// In en, this message translates to:
/// **'Watch an ad to unlock'**
String get watchAdToUnlock;
/// No description provided for @unlockWithVip.
///
/// In en, this message translates to:
/// **'Unlock with VIP'**
String get unlockWithVip;
/// No description provided for @unlockMethodsVary.
///
/// In en, this message translates to:
/// **'Available unlock methods vary by title.'**
String get unlockMethodsVary;
/// No description provided for @unlockEpisode.
///
/// In en, this message translates to:
/// **'Unlock this episode'**
String get unlockEpisode;
}
class _AppLocalizationsDelegate
+34
View File
@@ -1330,4 +1330,38 @@ class AppLocalizationsEn extends AppLocalizations {
String searchResultCount(int count) {
return '$count results';
}
@override
String episodeCountLabel(int count) {
return '$count episodes';
}
@override
String commentsWithCount(int count) {
return 'Comments ($count)';
}
@override
String get textSize => 'Text size';
@override
String get textSizeSmall => 'Small';
@override
String get textSizeStandard => 'Standard';
@override
String get textSizeLarge => 'Large';
@override
String get watchAdToUnlock => 'Watch an ad to unlock';
@override
String get unlockWithVip => 'Unlock with VIP';
@override
String get unlockMethodsVary => 'Available unlock methods vary by title.';
@override
String get unlockEpisode => 'Unlock this episode';
}
+35
View File
@@ -1329,4 +1329,39 @@ class AppLocalizationsId extends AppLocalizations {
String searchResultCount(int count) {
return '$count hasil';
}
@override
String episodeCountLabel(int count) {
return '$count episode';
}
@override
String commentsWithCount(int count) {
return 'Komentar ($count)';
}
@override
String get textSize => 'Ukuran teks';
@override
String get textSizeSmall => 'Kecil';
@override
String get textSizeStandard => 'Standar';
@override
String get textSizeLarge => 'Besar';
@override
String get watchAdToUnlock => 'Tonton iklan untuk membuka';
@override
String get unlockWithVip => 'Buka dengan VIP';
@override
String get unlockMethodsVary =>
'Metode pembukaan tersedia dapat berbeda per judul.';
@override
String get unlockEpisode => 'Buka episode ini';
}
+34
View File
@@ -1306,4 +1306,38 @@ class AppLocalizationsZh extends AppLocalizations {
String searchResultCount(int count) {
return '$count 个结果';
}
@override
String episodeCountLabel(int count) {
return '共 $count 集';
}
@override
String commentsWithCount(int count) {
return '评论($count)';
}
@override
String get textSize => '文字大小';
@override
String get textSizeSmall => '小';
@override
String get textSizeStandard => '标准';
@override
String get textSizeLarge => '大';
@override
String get watchAdToUnlock => '看广告解锁';
@override
String get unlockWithVip => '会员解锁';
@override
String get unlockMethodsVary => '具体解锁方式以作品而定。';
@override
String get unlockEpisode => '解锁本集';
}
+13 -1
View File
@@ -538,5 +538,17 @@
"qualityAutoHint": "HLS 会根据当前网络自动切换码率。",
"searchFailed": "搜索失败,请重试。",
"searchResultCount": "{count} 个结果",
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } }
"@searchResultCount": { "placeholders": { "count": { "type": "int" } } },
"episodeCountLabel": "共 {count} 集",
"@episodeCountLabel": { "placeholders": { "count": { "type": "int" } } },
"commentsWithCount": "评论({count})",
"@commentsWithCount": { "placeholders": { "count": { "type": "int" } } },
"textSize": "文字大小",
"textSizeSmall": "小",
"textSizeStandard": "标准",
"textSizeLarge": "大",
"watchAdToUnlock": "看广告解锁",
"unlockWithVip": "会员解锁",
"unlockMethodsVary": "具体解锁方式以作品而定。",
"unlockEpisode": "解锁本集"
}
+23 -89
View File
@@ -21,7 +21,6 @@ import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/theme/app_colors.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
@@ -30,9 +29,7 @@ import '../mine/mine_types.dart';
import '../mine/mine_view.dart';
import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart';
import 'widgets/episode_picker_cell.dart';
const double _episodeCellAspect = 1.05;
import 'widgets/episode_picker_sheet.dart';
class PlayerLogic extends ChangeNotifier {
PlayerLogic({
@@ -628,12 +625,10 @@ class PlayerLogic extends ChangeNotifier {
}
Future<void> showEpisodes(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final entry = queue.entryAt(session.currentIndex);
final drama = await _withFullEpisodes(context, entry.drama);
if (!context.mounted) return;
final homeFeed = feedDramas != null;
final episodes = drama.episodes;
final currentNumber = entry.episode.number;
session.setOverlayOpen(true);
await session.playbackSettled;
@@ -644,62 +639,13 @@ class PlayerLogic extends ChangeNotifier {
try {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.ink,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: SizedBox(
height: MediaQuery.sizeOf(sheetContext).height * 0.46,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 4),
child: Text(
l10n.episodes,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: _episodeCellAspect,
),
itemCount: episodes.length,
itemBuilder: (_, index) => _episodeCell(
context,
sheetContext,
drama,
episodes[index],
index,
homeFeed,
currentNumber,
),
),
),
],
),
),
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (_) => EpisodePickerSheet(
drama: drama,
currentNumber: currentNumber,
onSelect: (index) =>
_selectFromSheet(context, drama, index, homeFeed, currentNumber),
),
);
} finally {
@@ -724,37 +670,25 @@ class PlayerLogic extends ChangeNotifier {
}
}
Widget _episodeCell(
void _selectFromSheet(
BuildContext pageContext,
BuildContext sheetContext,
Drama drama,
Episode episode,
int index,
bool homeFeed,
int currentNumber,
) {
final selected = episode.number == currentNumber;
final l10n = AppLocalizations.of(pageContext)!;
return EpisodePickerCell(
episode: episode,
selected: selected,
lockLabel: l10n.locked,
onTap: () {
Navigator.of(sheetContext).pop();
if (homeFeed) {
if (episode.number == currentNumber) return;
unawaited(openSeriesAt(pageContext, drama, index));
return;
}
if (index == session.currentIndex) return;
unawaited(
pages.animateToPage(
index,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
},
if (homeFeed) {
if (drama.episodes[index].number == currentNumber) return;
unawaited(openSeriesAt(pageContext, drama, index));
return;
}
if (index == session.currentIndex) return;
unawaited(
pages.animateToPage(
index,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
}
@@ -839,8 +773,8 @@ class PlayerLogic extends ChangeNotifier {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.ink,
showDragHandle: true,
backgroundColor: Colors.transparent,
showDragHandle: false,
builder: (_) => DramaCommentsSheet(drama: drama),
);
} finally {
+79 -802
View File
@@ -8,7 +8,6 @@ import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/play_ticket.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/interaction_controller.dart';
@@ -16,24 +15,31 @@ import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/data/services/wallet_controller.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_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import '../interaction/widgets/report_dialog.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_more_sheet.dart';
import 'widgets/player_overlay.dart';
import 'widgets/player_overlay_models.dart';
import 'widgets/player_speed_sheet.dart';
import 'widgets/player_text_size_sheet.dart';
import 'widgets/player_unlock_sheet.dart';
import 'widgets/quality_sheet.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';
part 'player_view_actions.dart';
class PlayerPage extends StatefulWidget {
const PlayerPage({
super.key,
@@ -80,6 +86,7 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
bool _chromeVisible = true;
double _playbackSpeed = 1.0;
double _textScale = playerTextSizeStandard;
@override
void initState() {
@@ -117,6 +124,14 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
super.dispose();
}
void _updatePlaybackSpeed(double value) {
setState(() => _playbackSpeed = value);
}
void _updateTextScale(double value) {
setState(() => _textScale = value);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
@@ -198,7 +213,16 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: AppColors.ink),
ColoredBox(
color: AppColors.onSurface,
child: widget.homeMode
? null
: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
?video,
if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible))
IgnorePointer(
@@ -287,19 +311,6 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
),
),
),
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
if (selected && !ready && logic.blockedEpisodeId == episode.id)
_BlockedNotice(
l10n: l10n,
episode: episode,
accessType: logic.blockedTicket?.accessType,
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
unlocking: logic.unlockingEpisodeId == episode.id,
error: logic.unlockError,
onUnlock: logic.wallet == null
? null
: () => _unlockEpisode(context, index),
),
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
if (selected &&
!ready &&
@@ -384,72 +395,57 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
)
: const SizedBox.shrink()
else
_PlaybackOverlay(
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id),
likeCount: interactions.likeCount(drama.id),
// 收藏以服务端为准(FavoriteController 由 app 注入,登录后自动加载)
saved: context.watch<FavoriteController>().isSaved(drama.id),
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onEpisodes: () => logic.showEpisodes(context),
onNext: logic.hasNextEpisode
? () => unawaited(logic.playNextEpisode())
: null,
playing: logic.session.userWantsPlay,
playbackSpeed: _playbackSpeed,
onSpeedTap: () => unawaited(_openSpeedSheet(context)),
qualities: logic.qualities,
onQualityTap: () => unawaited(_openQualitySheet(context)),
PlayerPlaybackOverlay(
data: PlayerOverlayData(
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: interactions.isLiked(drama.id),
likeCount: interactions.likeCount(drama.id),
saved: context.watch<FavoriteController>().isSaved(drama.id),
playing: logic.session.userWantsPlay,
textScale: _textScale,
currentIndex: logic.session.currentIndex,
totalEpisodes: logic.visibleIndices.length,
),
actions: PlayerOverlayActions(
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
onClear: () => setState(() => _chromeVisible = false),
onExitClear: () => setState(() => _chromeVisible = true),
onEpisodes: () => logic.showEpisodes(context),
onNext: logic.hasNextEpisode
? () => unawaited(logic.playNextEpisode())
: null,
onComments: () => logic.showComments(context, drama),
onLike: () =>
unawaited(_toggleLike(context, interactions, drama)),
onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, drama, episode),
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
),
controlsVisible: _chromeVisible,
onClear: () => setState(() => _chromeVisible = false),
onExitClear: () => setState(() => _chromeVisible = true),
onTogglePlay: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
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)),
);
}
},
onDownload: () => requestEpisodeDownload(context, episode.id),
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
),
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
if (selected && !ready && logic.blockedEpisodeId == episode.id)
PlayerBlockedNotice(
l10n: l10n,
episode: episode,
accessType: logic.blockedTicket?.accessType,
unlockCoin: logic.blockedTicket?.unlockCoin ?? 0,
unlocking: logic.unlockingEpisodeId == episode.id,
error: logic.unlockError,
onOpenUnlock: logic.wallet == null
? null
: () => _openUnlockSheet(context, index, episode),
),
if (widget.homeMode && !_chromeVisible)
Positioned.fill(
@@ -490,723 +486,4 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
],
);
}
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);
}
Future<void> _openQualitySheet(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (_) => QualitySheet(qualities: logic.qualities),
);
}
Future<void> _unlockEpisode(BuildContext context, int index) async {
if (!await ensureSignedIn(context)) return;
if (!context.mounted) return;
final ok = await logic.unlockEpisode(index);
if (!context.mounted || ok) return;
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)),
),
);
}
}
/// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。
class _BlockedNotice extends StatelessWidget {
const _BlockedNotice({
required this.l10n,
required this.episode,
required this.accessType,
required this.unlockCoin,
required this.unlocking,
required this.error,
required this.onUnlock,
});
final AppLocalizations l10n;
final Episode episode;
final String? accessType;
final int unlockCoin;
final bool unlocking;
final String? error;
final VoidCallback? onUnlock;
@override
Widget build(BuildContext context) {
final needsUnlock =
accessType == PlayTicket.coinRequired ||
accessType == PlayTicket.vipRequired;
final title = needsUnlock
? l10n.lockedTitle(episode.number)
: accessType == PlayTicket.noMedia
? l10n.noMediaNotice
: episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice;
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: _UnlockCard(
locked: needsUnlock,
title: title,
coinLabel: needsUnlock && unlockCoin > 0
? '$unlockCoin ${l10n.coins}'
: null,
footnote: needsUnlock && error != null
? errorText(l10n, error, l10n.unlockFailed)
: null,
actionLabel: needsUnlock ? l10n.unlockWithCoins : null,
busy: unlocking,
onAction: needsUnlock ? onUnlock : null,
),
),
);
}
}
/// Coin lock presented as a reward card: gold chip, chili only on the icon.
class _UnlockCard extends StatelessWidget {
const _UnlockCard({
required this.locked,
required this.title,
required this.coinLabel,
required this.footnote,
this.actionLabel,
this.busy = false,
this.onAction,
});
final bool locked;
final String title;
final String? coinLabel;
final String? footnote;
final String? actionLabel;
final bool busy;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.gold.withValues(alpha: 0.55)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
locked ? Icons.lock_outline_rounded : Icons.movie_filter_outlined,
color: locked ? AppColors.gold : AppColors.cream,
size: 32,
),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.cream,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
if (coinLabel != null) ...[
const SizedBox(height: 12),
DecoratedBox(
decoration: BoxDecoration(
color: AppColors.gold,
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Text(
coinLabel!,
style: const TextStyle(
color: AppColors.onGold,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
),
),
],
if (footnote != null) ...[
const SizedBox(height: 8),
Text(
footnote!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
if (actionLabel != null) ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
height: 40,
child: FilledButton(
key: const Key('unlock-with-coins'),
onPressed: busy ? null : onAction,
style: FilledButton.styleFrom(
backgroundColor: AppColors.gold,
foregroundColor: AppColors.onGold,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
actionLabel!,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
],
],
),
),
);
}
}
class _PlaybackOverlay extends StatefulWidget {
const _PlaybackOverlay({
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
this.likeCount,
required this.saved,
required this.playing,
required this.playbackSpeed,
required this.onSpeedTap,
required this.qualities,
required this.onQualityTap,
required this.controlsVisible,
required this.onTogglePlay,
required this.onClear,
required this.onExitClear,
required this.onBack,
required this.onEpisodes,
required this.onComments,
required this.onLike,
required this.onSave,
required this.onDownload,
required this.onShare,
required this.onPip,
required this.onScrubStart,
required this.onSeek,
this.onNext,
});
final AppLocalizations l10n;
final Drama drama;
final Episode episode;
final VideoPlayerController? controller;
final bool liked;
final int? likeCount;
final bool saved;
final bool playing;
final double playbackSpeed;
final VoidCallback onSpeedTap;
final List<String> qualities;
final VoidCallback onQualityTap;
final bool controlsVisible;
final VoidCallback onTogglePlay;
final VoidCallback onClear;
final VoidCallback onExitClear;
final VoidCallback? onBack;
final VoidCallback onEpisodes;
final VoidCallback onComments;
final VoidCallback? onNext;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onDownload;
final VoidCallback onShare;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
@override
State<_PlaybackOverlay> createState() => _PlaybackOverlayState();
}
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,
),
),
],
),
);
}
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: SingleChildScrollView(
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();
});
},
),
if (widget.qualities.isNotEmpty)
ListTile(
key: const Key('player-more-quality'),
leading: const Icon(
Icons.high_quality_rounded,
color: AppColors.secondary,
),
title: Text(widget.l10n.playbackQuality),
trailing: Text(
widget.qualities.contains(QualitySheet.autoQuality)
? widget.l10n.qualityAuto
: widget.qualities.first,
style: const TextStyle(
color: AppColors.blue,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
onTap: () {
Navigator.pop(sheetContext);
widget.onQualityTap();
},
),
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),
trailing: widget.likeCount == null
? null
: Text(
'${widget.likeCount}',
key: const Key('player-more-like-count'),
),
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: AppColors.card.withValues(alpha: 0.94),
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,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.card.withValues(alpha: 0.94),
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,
),
),
),
],
),
),
),
);
}
}
+162
View File
@@ -0,0 +1,162 @@
// Author: fruit.com.cn
part of 'player_view.dart';
extension _PlayerViewActions on _PlayerPageState {
Future<void> _openSpeedSheet(BuildContext context) async {
final selected = await showModalBottomSheet<double>(
context: context,
backgroundColor: Colors.transparent,
showDragHandle: false,
builder: (_) => PlayerSpeedSheet(selected: _playbackSpeed),
);
if (selected == null || !mounted) return;
_updatePlaybackSpeed(selected);
await logic.setPlaybackSpeed(selected);
}
Future<void> _openQualitySheet(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.card,
showDragHandle: true,
builder: (_) => QualitySheet(qualities: logic.qualities),
);
}
Future<void> _openMore(
BuildContext context,
Drama drama,
Episode episode,
) async {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
showDragHandle: false,
isScrollControlled: true,
builder: (_) => PlayerMoreSheet(
l10n: l10n,
state: PlayerMoreState(
speed: _playbackSpeed,
qualities: logic.qualities,
textScale: _textScale,
pipAvailable: logic.pictureInPicture.isPlatformSupported,
),
actions: PlayerMoreActions(
onSpeed: () => unawaited(_openSpeedSheet(context)),
onQuality: () => unawaited(_openQualitySheet(context)),
onDownload: () => requestEpisodeDownload(context, episode.id),
onTextSize: () => unawaited(_openTextSizeSheet(context)),
onReport: () => unawaited(_reportDrama(context, drama, l10n)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
),
),
);
}
Future<void> _openTextSizeSheet(BuildContext context) async {
final selected = await showModalBottomSheet<double>(
context: context,
backgroundColor: Colors.transparent,
showDragHandle: false,
builder: (_) => PlayerTextSizeSheet(selected: _textScale),
);
if (selected == null || !mounted) return;
_updateTextScale(selected);
}
Future<void> _openUnlockSheet(
BuildContext context,
int index,
Episode episode,
) async {
final wallet = logic.wallet;
if (wallet == null) return;
await showModalBottomSheet<bool>(
context: context,
backgroundColor: Colors.transparent,
showDragHandle: false,
isScrollControlled: true,
builder: (_) => PlayerUnlockSheet(
l10n: AppLocalizations.of(context)!,
episodeNumber: episode.number,
coinCost: logic.blockedTicket?.unlockCoin ?? 0,
balance: wallet.snapshot == null ? null : wallet.balance,
onUnlock: () => _unlockEpisode(context, index),
),
);
}
Future<bool> _unlockEpisode(BuildContext context, int index) async {
if (!await ensureSignedIn(context) || !context.mounted) return false;
final ok = await logic.unlockEpisode(index);
if (ok || !context.mounted) return ok;
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorText(l10n, logic.unlockError, l10n.unlockFailed)),
),
);
return false;
}
Future<void> _reportDrama(
BuildContext context,
Drama drama,
AppLocalizations l10n,
) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
final draft = await showReportDialog(context, title: l10n.reportDrama);
if (draft == null || !context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await context.read<InteractionController>().reportDrama(
drama.id,
reason: draft.reason,
description: draft.description,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.isSuccess
? l10n.reportSubmitted
: result.errorMessage ?? l10n.reportFailed,
),
),
);
}
Future<void> _toggleLike(
BuildContext context,
InteractionController interactions,
Drama drama,
) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
unawaited(context.read<Haptics>().lightImpact());
final result = await interactions.toggleDramaLike(drama.id);
if (result.isSuccess || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
result.errorMessage ?? AppLocalizations.of(context)!.likeFailed,
),
),
);
}
Future<void> _toggleSave(BuildContext context, Drama drama) async {
final favorites = context.read<FavoriteController>();
if (!await ensureSignedIn(context) || !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(AppLocalizations.of(context)!.favoriteToggleFailed),
),
);
}
}
@@ -23,6 +23,7 @@ const double _minSheetHeight = 280;
const double _maxSheetHeight = 520;
const int _maxCommentLength = 300;
const double _composerRadius = 22;
const double _sheetRadius = 20;
/// 服务端评论底部弹窗:匿名列表、游标翻页、回复、点赞和本人删除。
class DramaCommentsSheet extends StatefulWidget {
@@ -160,15 +161,24 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
24;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: available.clamp(_minSheetHeight, _maxSheetHeight),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _content(context),
child: Container(
key: const Key('player-comments-sheet'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
),
child: SizedBox(
height: available.clamp(_minSheetHeight, _maxSheetHeight),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: ListenableBuilder(
listenable: _controller,
builder: (context, _) => _content(context),
),
),
),
),
@@ -182,13 +192,35 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
count == null ? l10n.comments : l10n.commentsWithCount(count),
key: count == null ? null : const Key('comment-count-label'),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
key: const Key('player-comments-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
),
],
),
Text(
count == null ? l10n.comments : '${l10n.comments} · $count',
key: count == null ? null : const Key('comment-count-label'),
widget.drama.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.cream,
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.muted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 12),
@@ -312,7 +344,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
maxLength: _maxCommentLength,
maxLines: 1,
style: const TextStyle(color: AppColors.cream, fontSize: 14),
cursorColor: AppColors.gold,
cursorColor: AppColors.blue,
decoration: _composerDecoration(l10n.writeComment),
onSubmitted: (_) => _post(),
),
@@ -10,7 +10,7 @@ const double _numberSize = 15;
const double _lockSize = 16;
const double _lockGap = 4;
const double _lockWeight = 300;
const double _coverAlpha = 0.45;
const double _coverAlpha = 0.55;
/// 选集格子。需要解锁时铺一层透明底,锁在上,集数在锁下面。
class EpisodePickerCell extends StatelessWidget {
@@ -38,12 +38,19 @@ class EpisodePickerCell extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
if (!episode.locked) Center(child: _EpisodeNumber(episode.number)),
if (!episode.locked)
Center(
child: _EpisodeNumber(
episode.number,
color: selected ? AppColors.onPrimary : AppColors.cream,
),
),
if (episode.locked)
_LockCover(
episodeId: episode.id,
number: episode.number,
label: lockLabel,
selected: selected,
),
],
),
@@ -53,16 +60,17 @@ class EpisodePickerCell extends StatelessWidget {
}
class _EpisodeNumber extends StatelessWidget {
const _EpisodeNumber(this.number);
const _EpisodeNumber(this.number, {required this.color});
final int number;
final Color color;
@override
Widget build(BuildContext context) {
return Text(
'$number',
style: const TextStyle(
color: AppColors.cream,
style: TextStyle(
color: color,
fontSize: _numberSize,
fontWeight: FontWeight.w600,
height: 1,
@@ -77,11 +85,13 @@ class _LockCover extends StatelessWidget {
required this.episodeId,
required this.number,
required this.label,
required this.selected,
});
final String episodeId;
final int number;
final String label;
final bool selected;
@override
Widget build(BuildContext context) {
@@ -90,7 +100,7 @@ class _LockCover extends StatelessWidget {
children: [
ColoredBox(
key: ValueKey('episode-lock-cover-$episodeId'),
color: AppColors.ink.withValues(alpha: _coverAlpha),
color: AppColors.card.withValues(alpha: _coverAlpha),
),
Center(
child: Column(
@@ -102,11 +112,14 @@ class _LockCover extends StatelessWidget {
size: _lockSize,
weight: _lockWeight,
fill: 0,
color: AppColors.gold,
color: AppColors.secondary,
semanticLabel: label,
),
const SizedBox(height: _lockGap),
_EpisodeNumber(number),
_EpisodeNumber(
number,
color: selected ? AppColors.onPrimary : AppColors.cream,
),
],
),
),
@@ -0,0 +1,131 @@
// Author: fruit.com.cn
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 '../../../l10n/app_localizations.dart';
import 'episode_picker_cell.dart';
const int _columns = 6;
const double _sheetHeightFactor = 0.52;
const double _cellAspect = 1.05;
const double _sheetRadius = 20;
/// K28 选集面板:六列等宽网格、当前集高亮、锁定集显示锁标。
class EpisodePickerSheet extends StatelessWidget {
/// 创建选集面板。
const EpisodePickerSheet({
super.key,
required this.drama,
required this.currentNumber,
required this.onSelect,
});
/// 当前短剧。
final Drama drama;
/// 当前集号。
final int currentNumber;
/// 点击某集,参数为该集在原始列表中的索引。
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final episodes = drama.episodes;
return Container(
key: const Key('player-episodes-sheet'),
height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
),
child: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 8, 4),
child: Row(
children: [
Expanded(
child: Text(
l10n.episodes,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
key: const Key('player-episodes-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: Row(
children: [
Expanded(
child: Text(
localizedDramaTitle(l10n, drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
Text(
l10n.episodeCountLabel(episodes.length),
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
],
),
),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: _columns,
mainAxisSpacing: AppDimens.spaceSm,
crossAxisSpacing: AppDimens.spaceSm,
childAspectRatio: _cellAspect,
),
itemCount: episodes.length,
itemBuilder: (context, index) {
final episode = episodes[index];
return EpisodePickerCell(
key: Key('player-episode-${episode.number}'),
episode: episode,
selected: episode.number == currentNumber,
lockLabel: l10n.locked,
onTap: () {
Navigator.pop(context);
onSelect(index);
},
);
},
),
),
],
),
),
);
}
}
@@ -1,158 +0,0 @@
// 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: AppColors.muted,
fontSize: 9,
),
),
],
],
),
),
],
),
const SizedBox(height: 5),
Text(
comment.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
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();
}
}
@@ -0,0 +1,228 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import 'player_text_size_sheet.dart';
import 'quality_sheet.dart';
const double _sheetRadius = 20;
/// 更多面板运行状态。
class PlayerMoreState {
/// 创建更多面板状态。
const PlayerMoreState({
required this.speed,
required this.qualities,
required this.textScale,
required this.pipAvailable,
});
/// 当前倍速。
final double speed;
/// 后端下发的清晰度档位。
final List<String> qualities;
/// 当前信息文字缩放比例。
final double textScale;
/// 设备是否提供画中画入口。
final bool pipAvailable;
}
/// 更多面板动作集合。
class PlayerMoreActions {
/// 创建动作集合。
const PlayerMoreActions({
required this.onSpeed,
required this.onQuality,
required this.onDownload,
required this.onTextSize,
required this.onReport,
this.onPip,
});
/// 打开倍速面板。
final VoidCallback onSpeed;
/// 打开清晰度面板。
final VoidCallback onQuality;
/// 下载当前集。
final VoidCallback onDownload;
/// 打开文字大小面板。
final VoidCallback onTextSize;
/// 举报当前剧。
final VoidCallback onReport;
/// 进入画中画。
final VoidCallback? onPip;
}
/// K28 播放页更多操作面板。
class PlayerMoreSheet extends StatelessWidget {
/// 创建更多操作面板。
const PlayerMoreSheet({
super.key,
required this.l10n,
required this.state,
required this.actions,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 面板状态。
final PlayerMoreState state;
/// 面板动作。
final PlayerMoreActions actions;
@override
Widget build(BuildContext context) {
return Container(
key: const Key('player-more-sheet'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
child: Text(
l10n.more,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
_MoreRow(
key: const Key('player-more-speed'),
icon: Icons.speed_rounded,
title: l10n.playbackSpeed,
value: '${state.speed}x',
action: actions.onSpeed,
),
if (state.qualities.isNotEmpty)
_MoreRow(
key: const Key('player-more-quality'),
icon: Icons.high_quality_rounded,
title: l10n.playbackQuality,
value: state.qualities.contains(QualitySheet.autoQuality)
? l10n.qualityAuto
: state.qualities.first,
action: actions.onQuality,
),
_MoreRow(
key: const Key('player-more-download'),
icon: Icons.download_rounded,
title: l10n.downloads,
value: '',
action: actions.onDownload,
),
_MoreRow(
key: const Key('player-more-text-size'),
icon: Icons.format_size_rounded,
title: l10n.textSize,
value: playerTextSizeLabel(l10n, state.textScale),
action: actions.onTextSize,
),
if (state.pipAvailable && actions.onPip != null)
_MoreRow(
key: const Key('player-more-pip'),
icon: Icons.picture_in_picture_alt_rounded,
title: l10n.pictureInPicture,
value: '',
action: actions.onPip!,
),
_MoreRow(
key: const Key('player-more-report'),
icon: Icons.flag_outlined,
title: l10n.reportDrama,
value: '',
action: actions.onReport,
),
],
),
),
),
);
}
}
class _MoreRow extends StatelessWidget {
const _MoreRow({
super.key,
required this.icon,
required this.title,
required this.value,
required this.action,
});
final IconData icon;
final String title;
final String value;
final VoidCallback action;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
Navigator.pop(context);
WidgetsBinding.instance.addPostFrameCallback((_) => action());
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 13),
child: Row(
children: [
Icon(icon, color: AppColors.muted, size: 21),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
if (value.isNotEmpty)
Text(
value,
style: const TextStyle(
color: AppColors.blue,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 4),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.subtle,
size: 20,
),
],
),
),
),
);
}
}
@@ -0,0 +1,416 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
import 'exit_clear_screen_button.dart';
import 'player_center_toggle.dart';
import 'player_overlay_models.dart';
import 'seek_progress_bar.dart';
part 'player_overlay_parts.dart';
const double _topGradientHeight = 230;
const double _bottomGradientHeight = 390;
const double _topButtonSize = 36;
const double _titleSize = 24;
const double _dotSize = 7;
const double _railButtonSize = 36;
const double _railGap = 9;
const double _chipRadius = 999;
const double _dotRadius = 3;
const double _railTopFactor = 0.28;
const double _railTopCompactFactor = 0.18;
/// K28 竖屏播放页浮层。
class PlayerPlaybackOverlay extends StatelessWidget {
/// 创建播放浮层。
const PlayerPlaybackOverlay({
super.key,
required this.data,
required this.actions,
required this.controlsVisible,
});
/// 展示数据。
final PlayerOverlayData data;
/// 交互动作。
final PlayerOverlayActions actions;
/// 控制层是否可见。
final bool controlsVisible;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
key: const Key('player-video-tap'),
behavior: HitTestBehavior.opaque,
onTap: actions.onTogglePlay,
),
),
if (controlsVisible) ...[
_top(context),
_bottom(context),
_rail(context),
],
if (!controlsVisible)
Positioned(
top: AppDimens.spaceSm,
right: AppDimens.spaceMd,
child: ExitClearScreenButton(
key: const Key('player-exit-clear-screen'),
tooltip: data.l10n.exitClearScreen,
onPressed: actions.onExitClear,
),
),
if (!data.playing)
Center(
child: PlayerCenterToggle(
key: const Key('player-play-toggle'),
playing: false,
onTap: actions.onTogglePlay,
),
),
],
),
);
}
Widget _top(BuildContext context) {
final title = data.episode.title.trim().isEmpty
? data.l10n.episodeLabel(data.episode.number)
: data.episode.title;
final series = localizedDramaTitle(data.l10n, data.drama);
return Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
key: const Key('player-controls-top'),
height: _topGradientHeight,
padding: const EdgeInsets.fromLTRB(14, 4, 14, 20),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xCC000000), Color(0x00000000)],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (actions.onBack != null)
_TopButton(
key: const Key('player-back'),
tooltip: data.l10n.backToSeries,
icon: Icons.chevron_left_rounded,
onPressed: actions.onBack!,
)
else
const SizedBox(width: _topButtonSize),
const Spacer(),
_TopButton(
key: const Key('player-clear-screen'),
tooltip: data.l10n.clearScreen,
icon: Icons.fullscreen_rounded,
onPressed: actions.onClear,
),
const SizedBox(width: AppDimens.spaceSm),
_TopButton(
key: const Key('player-more'),
tooltip: data.l10n.more,
icon: Icons.more_horiz_rounded,
onPressed: actions.onMore,
),
],
),
const SizedBox(height: 18),
Row(
children: [
Flexible(
child: Text(
title,
key: const Key('player-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.onPrimary,
fontFamily: 'Outfit',
fontSize: _titleSize * data.textScale,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 7),
Container(
key: const Key('player-title-dot'),
width: _dotSize,
height: _dotSize,
decoration: const BoxDecoration(
color: AppColors.blue,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 6),
Text(
'$series · ${data.l10n.episodeLabel(data.episode.number)}',
key: const Key('player-subtitle'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white70,
fontSize: 14 * data.textScale,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 10),
Row(
children: [
if (_genre.isNotEmpty)
_infoChip(const Key('player-genre-chip'), _genre),
const Spacer(),
_PageDots(
key: const Key('player-page-dots'),
current: data.currentIndex,
total: data.totalEpisodes,
),
],
),
],
),
),
);
}
Widget _rail(BuildContext context) {
final likeCount = data.likeCount;
final compact = MediaQuery.sizeOf(context).height < 700;
return Positioned(
right: AppDimens.spaceMd,
top:
MediaQuery.sizeOf(context).height *
(compact ? _railTopCompactFactor : _railTopFactor),
child: _Rail(
l10n: data.l10n,
liked: data.liked,
saved: data.saved,
likeCount: likeCount,
onLike: actions.onLike,
onComments: actions.onComments,
onSave: actions.onSave,
onShare: actions.onShare,
compact: compact,
),
);
}
Widget _bottom(BuildContext context) {
final series = localizedDramaTitle(data.l10n, data.drama);
final synopsis = localizedDramaSynopsis(data.l10n, data.drama);
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Stack(
children: [
const Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x00000000),
Color(0xDD000000),
Color(0xF2000000),
],
),
),
),
),
),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: _bottomGradientHeight),
child: Padding(
key: const Key('player-controls-bottom'),
padding: const EdgeInsets.fromLTRB(14, 48, 14, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
series,
key: const Key('player-drama-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.onPrimary,
fontFamily: 'Outfit',
fontSize: 22 * data.textScale,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 8),
Text(
data.l10n.episodeLabel(data.episode.number),
key: const Key('player-episode-label'),
style: TextStyle(
color: AppColors.blue,
fontSize: 12 * data.textScale,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: _genre.isEmpty
? const SizedBox.shrink()
: _softChip(_genre),
),
),
const SizedBox(width: 8),
SizedBox(
height: AppDimens.buttonSmall,
child: FilledButton(
key: const Key('player-follow'),
onPressed: actions.onSave,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
),
child: Text(
data.saved
? data.l10n.followingTab
: data.l10n.followSeries,
),
),
),
],
),
const SizedBox(height: 8),
Text(
synopsis,
key: const Key('player-synopsis'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white70,
fontSize: 12 * data.textScale,
height: 1.45,
),
),
const SizedBox(height: 4),
_TimeLabel(
key: const Key('player-time-label'),
controller: data.controller,
),
SeekProgressBar(
key: const Key('player-progress'),
valueListenable: data.controller,
semanticsLabel: data.l10n.seekVideo,
showTimes: true,
compact: true,
onScrubStart: actions.onScrubStart,
onSeek: actions.onSeek,
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: _ActionButton(
key: const Key('player-episodes'),
icon: Icons.grid_view_rounded,
label: data.l10n.episodes,
onTap: actions.onEpisodes,
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
key: const Key('player-next'),
icon: Icons.skip_next_rounded,
label: data.l10n.nextEpisode,
onTap: actions.onNext,
),
),
const SizedBox(width: 8),
Expanded(
child: _ActionButton(
key: const Key('player-bottom-more'),
icon: Icons.more_horiz_rounded,
label: data.l10n.more,
onTap: actions.onMore,
),
),
],
),
],
),
),
),
],
),
);
}
Widget _infoChip(Key key, String label) => Container(
key: key,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(_chipRadius),
),
child: Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
);
Widget _softChip(String label) => Container(
constraints: const BoxConstraints(maxWidth: 180),
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(_chipRadius),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
);
String get _genre =>
data.drama.genre.isNotEmpty ? data.drama.genre : data.drama.category;
}
@@ -0,0 +1,117 @@
// Author: fruit.com.cn
import 'package:flutter/widgets.dart';
import 'package:video_player/video_player.dart';
import '../../../core/data/models/drama.dart';
import '../../../l10n/app_localizations.dart';
/// 播放浮层展示数据。
class PlayerOverlayData {
/// 创建播放浮层数据。
const PlayerOverlayData({
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
required this.likeCount,
required this.saved,
required this.playing,
required this.textScale,
required this.currentIndex,
required this.totalEpisodes,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 当前短剧。
final Drama drama;
/// 当前剧集。
final Episode episode;
/// 当前播放器。
final VideoPlayerController? controller;
/// 是否已点赞。
final bool liked;
/// 点赞数;未知时隐藏。
final int? likeCount;
/// 是否已收藏。
final bool saved;
/// 是否处于播放状态。
final bool playing;
/// 信息文字缩放比例。
final double textScale;
/// 当前集索引。
final int currentIndex;
/// 可见集数量。
final int totalEpisodes;
}
/// 播放浮层动作集合。
class PlayerOverlayActions {
/// 创建动作集合。
const PlayerOverlayActions({
required this.onBack,
required this.onTogglePlay,
required this.onClear,
required this.onExitClear,
required this.onEpisodes,
required this.onComments,
required this.onLike,
required this.onSave,
required this.onShare,
required this.onMore,
required this.onScrubStart,
required this.onSeek,
this.onNext,
});
/// 返回上一页。
final VoidCallback? onBack;
/// 切换播放状态。
final VoidCallback onTogglePlay;
/// 进入清屏。
final VoidCallback onClear;
/// 退出清屏。
final VoidCallback onExitClear;
/// 打开选集。
final VoidCallback onEpisodes;
/// 打开评论。
final VoidCallback onComments;
/// 切换点赞。
final VoidCallback onLike;
/// 切换收藏。
final VoidCallback onSave;
/// 分享。
final VoidCallback onShare;
/// 打开更多面板。
final VoidCallback onMore;
/// 下一集。
final VoidCallback? onNext;
/// 开始拖动进度。
final VoidCallback? onScrubStart;
/// 提交进度跳转。
final Future<void> Function(Duration)? onSeek;
}
@@ -0,0 +1,243 @@
// Author: fruit.com.cn
part of 'player_overlay.dart';
class _TopButton extends StatelessWidget {
const _TopButton({
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.square(
dimension: _topButtonSize,
child: IconButton(
tooltip: tooltip,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: AppColors.card,
foregroundColor: AppColors.onSurface,
),
onPressed: onPressed,
icon: Icon(icon, size: 18),
),
);
}
}
class _Rail extends StatelessWidget {
const _Rail({
required this.l10n,
required this.liked,
required this.saved,
required this.likeCount,
required this.onLike,
required this.onComments,
required this.onSave,
required this.onShare,
required this.compact,
});
final AppLocalizations l10n;
final bool liked;
final bool saved;
final int? likeCount;
final VoidCallback onLike;
final VoidCallback onComments;
final VoidCallback onSave;
final VoidCallback onShare;
final bool compact;
@override
Widget build(BuildContext context) {
return Column(
key: const Key('player-interaction-rail'),
children: [
_RailButton(
key: const Key('player-rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
color: liked ? AppColors.blueSoft : AppColors.onPrimary,
label: likeCount?.toString() ?? l10n.like,
onTap: onLike,
compact: compact,
),
_RailButton(
key: const Key('player-rail-comments'),
icon: Icons.chat_bubble_outline_rounded,
label: l10n.comments,
onTap: onComments,
compact: compact,
),
_RailButton(
key: const Key('player-rail-save'),
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
label: l10n.favorite,
onTap: onSave,
compact: compact,
),
_RailButton(
key: const Key('player-rail-share'),
icon: Icons.ios_share_rounded,
label: l10n.share,
onTap: onShare,
compact: compact,
),
],
);
}
}
class _RailButton extends StatelessWidget {
const _RailButton({
super.key,
required this.icon,
required this.label,
required this.onTap,
required this.compact,
this.color = AppColors.onPrimary,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool compact;
final Color color;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: compact ? 2 : _railGap),
child: Column(
children: [
SizedBox.square(
dimension: _railButtonSize,
child: IconButton(
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: AppColors.overlayDark,
foregroundColor: color,
),
icon: Icon(icon, size: 18),
),
),
SizedBox(height: compact ? 1 : 3),
Text(
label,
maxLines: 1,
style: TextStyle(
color: AppColors.onPrimary,
fontSize: compact ? 8 : 9,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
class _PageDots extends StatelessWidget {
const _PageDots({super.key, required this.current, required this.total});
final int current;
final int total;
@override
Widget build(BuildContext context) {
if (total <= 1) return const SizedBox.shrink();
final start = (current - 2).clamp(0, total > 5 ? total - 5 : 0);
final end = (start + 5).clamp(0, total);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var index = start; index < end; index++)
Container(
width: index == current ? 14 : _dotSize,
height: _dotSize,
margin: const EdgeInsets.only(left: 4),
decoration: BoxDecoration(
color: index == current ? AppColors.blue : Colors.white54,
borderRadius: BorderRadius.circular(_dotRadius),
),
),
],
);
}
}
class _TimeLabel extends StatelessWidget {
const _TimeLabel({super.key, required this.controller});
final VideoPlayerController? controller;
@override
Widget build(BuildContext context) {
final source = controller;
if (source == null) return _label(Duration.zero, Duration.zero);
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) => _label(value.position, value.duration),
);
}
Widget _label(Duration position, Duration duration) => Text(
'${_format(position)} / ${_format(duration)}',
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
);
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 _ActionButton extends StatelessWidget {
const _ActionButton({
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 SizedBox(
height: 38,
child: FilledButton.icon(
onPressed: onTap,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
backgroundColor: AppColors.card,
foregroundColor: AppColors.onSurface,
disabledBackgroundColor: Colors.black38,
disabledForegroundColor: Colors.white54,
),
icon: Icon(icon, size: 16),
label: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w700),
),
),
);
}
}
@@ -0,0 +1,75 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const List<double> _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const double _sheetRadius = 20;
/// 播放倍速选择弹窗。
class PlayerSpeedSheet extends StatelessWidget {
/// 创建倍速选择弹窗。
const PlayerSpeedSheet({super.key, required this.selected});
/// 当前倍速。
final double selected;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Material(
key: const Key('player-speed-sheet'),
color: AppColors.card,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.playbackSpeed,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
),
for (final value in _speedOptions) _option(context, value),
const SizedBox(height: 8),
],
),
),
);
}
Widget _option(BuildContext context, double value) {
final active = value == selected;
return ListTile(
key: Key('player-speed-${value.toString()}'),
dense: true,
title: Text(
'${value.toString()}x',
style: TextStyle(
color: active ? AppColors.blue : AppColors.onSurface,
fontSize: 14,
fontWeight: active ? FontWeight.w800 : FontWeight.w500,
),
),
trailing: active
? const Icon(Icons.check_rounded, color: AppColors.blue, size: 20)
: null,
onTap: () => Navigator.pop(context, value),
);
}
}
@@ -0,0 +1,110 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double playerTextSizeSmall = 0.9;
const double playerTextSizeStandard = 1.0;
const double playerTextSizeLarge = 1.15;
const double _sheetRadius = 20;
/// 返回播放信息文字档位的本地化名称。
String playerTextSizeLabel(AppLocalizations l10n, double scale) {
if (scale <= playerTextSizeSmall) return l10n.textSizeSmall;
if (scale >= playerTextSizeLarge) return l10n.textSizeLarge;
return l10n.textSizeStandard;
}
/// 播放页文字大小选择弹窗。
class PlayerTextSizeSheet extends StatelessWidget {
/// 创建文字大小选择弹窗。
const PlayerTextSizeSheet({super.key, required this.selected});
/// 当前文字缩放比例。
final double selected;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final options = [
(playerTextSizeSmall, 'small', l10n.textSizeSmall),
(playerTextSizeStandard, 'standard', l10n.textSizeStandard),
(playerTextSizeLarge, 'large', l10n.textSizeLarge),
];
return Container(
key: const Key('player-text-size-sheet'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_sheetRadius)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.textSize,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
for (final option in options) ...[
_option(context, option.$1, option.$2, option.$3),
if (option != options.last) const SizedBox(height: 8),
],
],
),
),
),
);
}
Widget _option(
BuildContext context,
double scale,
String keySuffix,
String label,
) {
final active = (scale - selected).abs() < 0.01;
return Material(
key: Key('player-text-size-$keySuffix'),
color: active ? AppColors.quick : AppColors.raised,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => Navigator.pop(context, scale),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
color: active ? AppColors.blue : AppColors.onSurface,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
if (active)
const Icon(
Icons.check_circle_rounded,
color: AppColors.blue,
size: 20,
),
],
),
),
),
);
}
}
@@ -0,0 +1,373 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/models/play_ticket.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const double _cardRadius = 20;
const double _optionRadius = 14;
const double _sheetWidth = 360;
/// 视频区占位提示:由后端票据决定是解锁、无片源还是会员限制。
class PlayerBlockedNotice extends StatelessWidget {
/// 创建不可播放提示。
const PlayerBlockedNotice({
super.key,
required this.l10n,
required this.episode,
required this.accessType,
required this.unlockCoin,
required this.unlocking,
required this.error,
required this.onOpenUnlock,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 当前剧集。
final Episode episode;
/// 后端下发的内容访问类型。
final String? accessType;
/// 金币解锁价格。
final int unlockCoin;
/// 是否正在解锁。
final bool unlocking;
/// 最近一次解锁失败信息。
final String? error;
/// 打开内容解锁面板。
final VoidCallback? onOpenUnlock;
bool get _needsUnlock =>
accessType == PlayTicket.coinRequired ||
accessType == PlayTicket.vipRequired;
@override
Widget build(BuildContext context) {
final title = _needsUnlock
? l10n.lockedTitle(episode.number)
: accessType == PlayTicket.noMedia || !episode.locked
? l10n.noMediaNotice
: l10n.lockedTitle(episode.number);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _sheetWidth),
child: Container(
key: const Key('player-unlock-notice'),
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(_cardRadius),
boxShadow: const [
BoxShadow(
color: AppColors.cardShadow,
blurRadius: 18,
offset: Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_needsUnlock ? Icons.lock_rounded : Icons.movie_filter_outlined,
color: _needsUnlock ? AppColors.blue : AppColors.muted,
size: 30,
),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
if (_needsUnlock && unlockCoin > 0) ...[
const SizedBox(height: 10),
_coinLabel(l10n),
],
if (error != null) ...[
const SizedBox(height: 8),
Text(
errorText(l10n, error, l10n.unlockFailed),
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.badgePinkText,
fontSize: 12,
),
),
],
if (_needsUnlock) ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const Key('player-open-unlock'),
onPressed: unlocking ? null : onOpenUnlock,
child: unlocking
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.unlockEpisode),
),
),
],
],
),
),
),
);
}
Widget _coinLabel(AppLocalizations l10n) => DecoratedBox(
decoration: BoxDecoration(
color: AppColors.goldCard,
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Text(
l10n.walletBalanceShort(unlockCoin),
style: const TextStyle(
color: AppColors.onGold,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
),
);
}
/// 内容解锁面板:金币可用,广告与会员在契约缺失时明确禁用。
class PlayerUnlockSheet extends StatefulWidget {
/// 创建内容解锁面板。
const PlayerUnlockSheet({
super.key,
required this.l10n,
required this.episodeNumber,
required this.coinCost,
required this.balance,
required this.onUnlock,
});
/// 当前语言包。
final AppLocalizations l10n;
/// 当前集号。
final int episodeNumber;
/// 金币解锁价格。
final int coinCost;
/// 当前金币余额;未知时隐藏。
final int? balance;
/// 金币解锁动作,成功后返回 true。
final Future<bool> Function()? onUnlock;
@override
State<PlayerUnlockSheet> createState() => _PlayerUnlockSheetState();
}
class _PlayerUnlockSheetState extends State<PlayerUnlockSheet> {
bool _busy = false;
String? _error;
Future<void> _unlock() async {
final action = widget.onUnlock;
if (action == null || _busy) return;
setState(() {
_busy = true;
_error = null;
});
final success = await action();
if (!mounted) return;
if (success) {
Navigator.of(context).pop(true);
return;
}
setState(() {
_busy = false;
_error = widget.l10n.unlockFailed;
});
}
@override
Widget build(BuildContext context) {
final l10n = widget.l10n;
return Container(
key: const Key('player-unlock-panel'),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(_cardRadius)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.lockedTitle(widget.episodeNumber),
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
IconButton(
key: const Key('player-unlock-close'),
tooltip: l10n.close,
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 8),
_UnlockOption(
optionKey: const Key('player-unlock-coins'),
icon: Icons.toll_rounded,
title: l10n.unlockWithCoins,
subtitle: widget.balance == null
? l10n.walletBalanceShort(widget.coinCost)
: '${l10n.walletBalanceShort(widget.coinCost)} · '
'${l10n.walletBalanceShort(widget.balance!)}',
enabled: widget.onUnlock != null && !_busy,
busy: _busy,
onTap: _unlock,
),
const SizedBox(height: 10),
_UnlockOption(
optionKey: const Key('player-unlock-ad'),
icon: Icons.ondemand_video_rounded,
title: l10n.watchAdToUnlock,
subtitle: l10n.comingSoon,
enabled: false,
onTap: null,
),
const SizedBox(height: 10),
_UnlockOption(
optionKey: const Key('player-unlock-vip'),
icon: Icons.workspace_premium_rounded,
title: l10n.unlockWithVip,
subtitle: l10n.comingSoon,
enabled: false,
onTap: null,
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(
_error!,
style: const TextStyle(
color: AppColors.badgePinkText,
fontSize: 12,
),
),
],
const SizedBox(height: 14),
Text(
l10n.unlockMethodsVary,
style: const TextStyle(color: AppColors.muted, fontSize: 12),
),
],
),
),
),
);
}
}
class _UnlockOption extends StatelessWidget {
const _UnlockOption({
required this.optionKey,
required this.icon,
required this.title,
required this.subtitle,
required this.enabled,
this.busy = false,
this.onTap,
});
final Key optionKey;
final IconData icon;
final String title;
final String subtitle;
final bool enabled;
final bool busy;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Material(
key: optionKey,
color: enabled ? AppColors.quick : AppColors.raised,
borderRadius: BorderRadius.circular(_optionRadius),
child: InkWell(
borderRadius: BorderRadius.circular(_optionRadius),
onTap: enabled ? onTap : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Icon(
icon,
color: enabled ? AppColors.blue : AppColors.subtle,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
color: enabled ? AppColors.onSurface : AppColors.subtle,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
),
),
],
),
),
if (busy)
const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (enabled)
const Icon(Icons.chevron_right_rounded, color: AppColors.blue),
],
),
),
),
);
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Second page'), findsOneWidget);
expect(find.text('Comments · 2'), findsOneWidget);
expect(find.text('Comments (2)'), findsOneWidget);
expect(repository.pageCalls.last.cursor, '10');
});
+156 -10
View File
@@ -6,6 +6,8 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/play_ticket.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';
@@ -287,29 +289,145 @@ void main() {
);
expect(bar.showTimes, isTrue);
});
testWidgets('should_render_k28_player_structure_and_episode_grid', (
tester,
) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
_expectFullscreenOverlay(tester);
await tester.tap(find.byKey(const Key('player-episodes')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget);
expect(find.byKey(const Key('player-episodes-close')), findsOneWidget);
final grid = tester.widget<GridView>(
find.descendant(
of: find.byKey(const Key('player-episodes-sheet')),
matching: find.byType(GridView),
),
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 6);
expect(tester.takeException(), isNull);
});
testWidgets('should_show_locked_episode_in_episode_picker', (tester) async {
await _pumpApp(
tester,
catalog: FakeCatalogRepository(dramas: const [_lockedDrama]),
);
await _openSeriesPlayer(
tester,
cardKey: const Key('home-drama-card-locked-demo'),
);
await tester.tap(find.byKey(const Key('player-episodes')));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('episode-lock-locked-demo-1')),
findsOneWidget,
);
expect(find.byKey(const Key('player-episode-2')), findsOneWidget);
});
testWidgets('should_open_more_actions_and_change_text_size', (tester) async {
await _pumpApp(tester);
await _openSeriesPlayer(tester);
await tester.tap(find.byKey(const Key('player-more')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-more-sheet')), findsOneWidget);
expect(find.byKey(const Key('player-more-speed')), findsOneWidget);
expect(find.byKey(const Key('player-more-quality')), findsOneWidget);
expect(find.byKey(const Key('player-more-download')), findsOneWidget);
expect(find.byKey(const Key('player-more-text-size')), findsOneWidget);
expect(find.byKey(const Key('player-more-report')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-more-text-size')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-text-size-sheet')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-text-size-large')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('player-more')));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byKey(const Key('player-more-text-size')),
matching: find.text('Large'),
),
findsOneWidget,
);
});
testWidgets('should_open_unlock_panel_with_honest_method_states', (
tester,
) async {
await _pumpApp(
tester,
playback: FakePlaybackRepository(
ticket: const PlayTicket(
episodeId: 'city-lights-demo-1',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
),
),
);
await _openSeriesPlayer(tester);
expect(find.byKey(const Key('player-unlock-notice')), findsOneWidget);
await tester.tap(find.byKey(const Key('player-open-unlock')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('player-unlock-panel')), findsOneWidget);
expect(find.byKey(const Key('player-unlock-coins')), findsOneWidget);
expect(find.byKey(const Key('player-unlock-ad')), findsOneWidget);
expect(find.byKey(const Key('player-unlock-vip')), findsOneWidget);
expect(
find.text('Available unlock methods vary by title.'),
findsOneWidget,
);
});
}
void _expectFullscreenOverlay(WidgetTester tester) {
expect(find.byKey(const Key('player-back')), findsOneWidget);
expect(find.byKey(const Key('player-title')), findsOneWidget);
expect(find.byKey(const Key('player-title-dot')), findsOneWidget);
expect(find.byKey(const Key('player-subtitle')), findsOneWidget);
expect(find.byKey(const Key('player-genre-chip')), findsOneWidget);
expect(find.byKey(const Key('player-page-dots')), findsOneWidget);
expect(find.byKey(const Key('player-drama-title')), findsOneWidget);
expect(find.byKey(const Key('player-episode-label')), findsOneWidget);
expect(find.byKey(const Key('player-follow')), findsOneWidget);
expect(find.byKey(const Key('player-synopsis')), findsOneWidget);
expect(find.byKey(const Key('player-time-label')), findsOneWidget);
expect(find.byKey(const Key('player-more')), findsOneWidget);
for (final key in const [Key('player-back'), Key('player-more')]) {
expect(tester.getSize(find.byKey(key)), const Size(36, 36));
}
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
expect(find.byKey(const Key('player-episodes')), findsOneWidget);
expect(find.byKey(const Key('player-comments')), findsOneWidget);
expect(find.byKey(const Key('player-rail-comments')), findsOneWidget);
expect(find.byKey(const Key('player-next')), findsOneWidget);
expect(find.byType(Slider), findsOneWidget);
expect(find.byKey(const Key('rail-like')), findsNothing);
expect(find.byKey(const Key('rail-save')), findsNothing);
expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget);
expect(find.byKey(const Key('player-rail-like')), findsOneWidget);
expect(find.byKey(const Key('player-rail-save')), findsOneWidget);
expect(find.byKey(const Key('player-rail-share')), findsOneWidget);
}
Future<void> _openSeriesPlayer(WidgetTester tester) async {
Future<void> _openSeriesPlayer(
WidgetTester tester, {
Key cardKey = const Key('home-drama-card-city-lights-demo'),
}) async {
await tester.tap(find.byKey(const Key('tab-theater')));
await tester.pumpAndSettle();
final card = find.byKey(const Key('home-drama-card-city-lights-demo'));
final card = find.byKey(cardKey);
await tester.scrollUntilVisible(
card,
220,
@@ -329,20 +447,22 @@ Future<void> _openSeriesPlayer(WidgetTester tester) async {
Future<void> _pumpApp(
WidgetTester tester, {
Size size = const Size(390, 844),
FakeCatalogRepository? catalog,
FakePlaybackRepository? playback,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
final repository = catalog ?? FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
catalogRepository: repository,
categoryRepository: FakeCategoryRepository(catalog: repository),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
playbackRepository: FakePlaybackRepository(),
favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas),
playbackRepository: playback ?? FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(),
@@ -351,3 +471,29 @@ Future<void> _pumpApp(
);
await tester.pumpAndSettle();
}
const _lockedDrama = Drama(
id: 'locked-demo',
title: 'Locked Demo',
synopsis: 'Locked demo synopsis',
posterUrl: '/files/locked-demo.png',
genre: 'Suspense',
detailLoaded: true,
episodes: [
Episode(
id: 'locked-demo-1',
number: 1,
title: 'Locked Episode',
isFree: false,
locked: true,
videoUrl: 'https://example.test/locked-demo-1.mp4',
),
Episode(
id: 'locked-demo-2',
number: 2,
title: 'Open Episode',
isFree: true,
videoUrl: 'https://example.test/locked-demo-2.mp4',
),
],
);
+11 -15
View File
@@ -226,7 +226,7 @@ void main() {
await tester.pumpAndSettle();
await _openTheaterPlayer(tester);
await tester.tap(find.byKey(const Key('player-comments')));
await tester.tap(find.byKey(const Key('player-rail-comments')));
await tester.pumpAndSettle();
expect(find.text('No comments yet.'), findsOneWidget);
await tester.enterText(find.byKey(const Key('comment-input')), 'Nice demo');
@@ -236,11 +236,14 @@ void main() {
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
await _tapPlayerMore(tester, const Key('player-more-like'));
await tester.tap(find.byKey(const Key('player-rail-like')));
await tester.pumpAndSettle();
expect(interactions.likeTargets, contains('DRAMA:city-lights-demo'));
await _tapPlayerMore(tester, const Key('player-more-favorite'));
await _tapPlayerMore(tester, const Key('player-more-share'));
await tester.tap(find.byKey(const Key('player-rail-save')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('player-rail-share')));
await tester.pumpAndSettle();
expect(sharedText, contains('City Lights & Velvet Nights'));
expect(sharedText, contains('FruitShorts'));
expect(tester.takeException(), isNull);
@@ -256,7 +259,8 @@ void main() {
await tester.pumpWidget(appUnderTest(interactionRepository: interactions));
await tester.pumpAndSettle();
await _openTheaterPlayer(tester);
await _tapPlayerMore(tester, const Key('player-more-like'));
await tester.tap(find.byKey(const Key('player-rail-like')));
await tester.pumpAndSettle();
expect(find.text('Sign in to continue'), findsOneWidget);
expect(interactions.likeTargets, isEmpty);
@@ -271,7 +275,8 @@ void main() {
await tester.pumpWidget(appUnderTest());
await tester.pumpAndSettle();
await _openTheaterPlayer(tester);
await _tapPlayerMore(tester, const Key('player-more-favorite'));
await tester.tap(find.byKey(const Key('player-rail-save')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('player-back')));
await tester.pumpAndSettle();
@@ -303,12 +308,3 @@ Future<void> _openTheaterPlayer(WidgetTester tester) async {
await tester.tap(card);
await tester.pumpAndSettle();
}
Future<void> _tapPlayerMore(WidgetTester tester, Key action) async {
await tester.tap(find.byKey(const Key('player-more')));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(action));
await tester.pumpAndSettle();
await tester.tap(find.byKey(action));
await tester.pumpAndSettle();
}