merge: 合并 K28-C 播放页与操作面板
This commit is contained in:
commit
f1efbc5be8
24 files changed
+2395
-1101
No files matched your search
+13
-1
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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
@@ -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": "解锁本集"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user