fix(player): 完善进度预览清屏与下拉刷新
This commit is contained in:
1 parent
9b1afbdb36
commit
f8500bbaca
15 files changed
+949
-251
No files matched your search
@@ -122,6 +122,8 @@ abstract interface class SpriteThumbnailAxisSource implements Listenable {
|
|||||||
SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position);
|
SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position);
|
||||||
|
|
||||||
bool hasAxis(String? thumbnailsUrl);
|
bool hasAxis(String? thumbnailsUrl);
|
||||||
|
|
||||||
|
Future<void> prepareAt(String? thumbnailsUrl, Duration position);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。
|
/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。
|
||||||
@@ -153,6 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
|
|||||||
List<String> get spriteCacheKeys => _spriteCache.keys.toList();
|
List<String> get spriteCacheKeys => _spriteCache.keys.toList();
|
||||||
|
|
||||||
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
|
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
|
||||||
|
@override
|
||||||
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
|
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
|
||||||
final url = _normalizeUrl(thumbnailsUrl);
|
final url = _normalizeUrl(thumbnailsUrl);
|
||||||
if (_disposed || url == null) return;
|
if (_disposed || url == null) return;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import 'player_logic.dart';
|
|||||||
import 'widgets/home_feed_header.dart';
|
import 'widgets/home_feed_header.dart';
|
||||||
import 'widgets/home_feed_overlay.dart';
|
import 'widgets/home_feed_overlay.dart';
|
||||||
import 'widgets/exit_clear_screen_button.dart';
|
import 'widgets/exit_clear_screen_button.dart';
|
||||||
|
import 'widgets/feed_pull_refresh.dart';
|
||||||
import 'widgets/player_center_toggle.dart';
|
import 'widgets/player_center_toggle.dart';
|
||||||
import 'widgets/player_more_sheet.dart';
|
import 'widgets/player_more_sheet.dart';
|
||||||
import 'widgets/player_overlay.dart';
|
import 'widgets/player_overlay.dart';
|
||||||
@@ -49,6 +50,7 @@ part 'player_view_actions.dart';
|
|||||||
const double _boostPlaybackSpeed = 2.0;
|
const double _boostPlaybackSpeed = 2.0;
|
||||||
const double _playerLoadingSize = 24;
|
const double _playerLoadingSize = 24;
|
||||||
const double _playerLoadingStrokeWidth = 2;
|
const double _playerLoadingStrokeWidth = 2;
|
||||||
|
const double _feedRefreshTopGap = 56;
|
||||||
|
|
||||||
class PlayerPage extends StatefulWidget {
|
class PlayerPage extends StatefulWidget {
|
||||||
const PlayerPage({
|
const PlayerPage({
|
||||||
@@ -305,6 +307,16 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
unawaited(logic.setPlaybackSpeed(speed));
|
unawaited(logic.setPlaybackSpeed(speed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshHomeFeed() async {
|
||||||
|
final refresh = widget.onRefreshFeed;
|
||||||
|
if (refresh == null) return;
|
||||||
|
final ok = await refresh();
|
||||||
|
if (!mounted || ok) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _setScrubbing(bool value) {
|
void _setScrubbing(bool value) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_scrubbing == value) return;
|
if (_scrubbing == value) return;
|
||||||
@@ -404,26 +416,37 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
final interactions = context.watch<InteractionController>();
|
final interactions = context.watch<InteractionController>();
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final indices = logic.visibleIndices;
|
final indices = logic.visibleIndices;
|
||||||
final pages = logic.pages;
|
|
||||||
final content = AnimatedBuilder(
|
final content = AnimatedBuilder(
|
||||||
animation: logic.session,
|
animation: logic.session,
|
||||||
builder: (context, _) => PageView.builder(
|
builder: (context, _) {
|
||||||
controller: pages,
|
final pages = PageView.builder(
|
||||||
scrollDirection: Axis.vertical,
|
controller: logic.pages,
|
||||||
allowImplicitScrolling: true,
|
scrollDirection: Axis.vertical,
|
||||||
itemCount: indices.length,
|
allowImplicitScrolling: true,
|
||||||
onPageChanged: (position) {
|
physics: widget.onRefreshFeed == null
|
||||||
final index = indices[position];
|
? null
|
||||||
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
: const FeedRefreshPhysics(),
|
||||||
unawaited(logic.selectEpisode(index));
|
itemCount: indices.length,
|
||||||
if (position >= indices.length - 3) {
|
onPageChanged: (position) {
|
||||||
final loadMore = widget.onLoadMore;
|
final index = indices[position];
|
||||||
if (loadMore != null) unawaited(loadMore());
|
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||||
}
|
unawaited(logic.selectEpisode(index));
|
||||||
},
|
if (position >= indices.length - 3) {
|
||||||
itemBuilder: (context, position) =>
|
final loadMore = widget.onLoadMore;
|
||||||
_buildEpisode(context, indices[position], interactions),
|
if (loadMore != null) unawaited(loadMore());
|
||||||
),
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context, position) =>
|
||||||
|
_buildEpisode(context, indices[position], interactions),
|
||||||
|
);
|
||||||
|
if (widget.onRefreshFeed == null) return pages;
|
||||||
|
return FeedPullRefresh(
|
||||||
|
pageController: logic.pages,
|
||||||
|
topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap,
|
||||||
|
onRefresh: _refreshHomeFeed,
|
||||||
|
child: pages,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
final body = Stack(
|
final body = Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
@@ -504,9 +527,17 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
|
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
|
||||||
? logic.sessionEpisodes[index]
|
? logic.sessionEpisodes[index]
|
||||||
: entry.episode;
|
: entry.episode;
|
||||||
final episode = widget.homeMode
|
final displayed = widget.homeMode
|
||||||
? logic.displayEpisode(drama, sessionEpisode)
|
? logic.displayEpisode(drama, sessionEpisode)
|
||||||
: sessionEpisode;
|
: sessionEpisode;
|
||||||
|
final episode =
|
||||||
|
(displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) &&
|
||||||
|
sessionEpisode.thumbnailsUrl != null
|
||||||
|
? displayed.withVideoUrl(
|
||||||
|
displayed.videoUrl,
|
||||||
|
thumbnailsUrl: sessionEpisode.thumbnailsUrl,
|
||||||
|
)
|
||||||
|
: displayed;
|
||||||
final selected = index == logic.session.currentIndex;
|
final selected = index == logic.session.currentIndex;
|
||||||
// Selection changes before the prepared slot is promoted. Use its controller
|
// Selection changes before the prepared slot is promoted. Use its controller
|
||||||
// throughout the handoff instead of briefly falling back to a placeholder.
|
// throughout the handoff instead of briefly falling back to a placeholder.
|
||||||
@@ -702,6 +733,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
thumbnailService: _thumbnailService,
|
thumbnailService: _thumbnailService,
|
||||||
spriteAxisService: _spriteAxisService,
|
spriteAxisService: _spriteAxisService,
|
||||||
),
|
),
|
||||||
|
scrubbing: _scrubbing,
|
||||||
actions: PlayerOverlayActions(
|
actions: PlayerOverlayActions(
|
||||||
onBack: widget.embedded
|
onBack: widget.embedded
|
||||||
? null
|
? null
|
||||||
@@ -719,7 +751,13 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
onSave: () => unawaited(_toggleSave(context, drama)),
|
onSave: () => unawaited(_toggleSave(context, drama)),
|
||||||
onShare: () => logic.shareDrama(context, drama),
|
onShare: () => logic.shareDrama(context, drama),
|
||||||
onMore: () => _openMore(context, episode),
|
onMore: () => _openMore(context, episode),
|
||||||
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
onScrubStart: ready
|
||||||
|
? () {
|
||||||
|
_setScrubbing(true);
|
||||||
|
logic.beginScrub(videoPort!);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onScrubEnd: () => _setScrubbing(false),
|
||||||
onSeek: ready
|
onSeek: ready
|
||||||
? (position) => logic.seekTo(context, videoPort!, position)
|
? (position) => logic.seekTo(context, videoPort!, position)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/theme/app_dimens.dart';
|
||||||
|
|
||||||
|
const double _maxPull = 96;
|
||||||
|
const double _refreshThreshold = 56;
|
||||||
|
const double _firstPageEpsilon = 0.01;
|
||||||
|
|
||||||
|
/// 第一条已经到顶时,向下的拖动不再翻页。
|
||||||
|
class FeedRefreshPhysics extends PageScrollPhysics {
|
||||||
|
const FeedRefreshPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
FeedRefreshPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return FeedRefreshPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||||
|
if (position.pixels <= position.minScrollExtent && offset > 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return super.applyPhysicsToUserOffset(position, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Simulation? createBallisticSimulation(
|
||||||
|
ScrollMetrics position,
|
||||||
|
double velocity,
|
||||||
|
) {
|
||||||
|
if (position.pixels <= position.minScrollExtent && velocity < 0) {
|
||||||
|
return super.createBallisticSimulation(position, 0);
|
||||||
|
}
|
||||||
|
return super.createBallisticSimulation(position, velocity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 竖滑到第一条后继续下拉时刷新,其它页仍交给翻页。
|
||||||
|
class FeedPullRefresh extends StatefulWidget {
|
||||||
|
const FeedPullRefresh({
|
||||||
|
super.key,
|
||||||
|
required this.pageController,
|
||||||
|
required this.onRefresh,
|
||||||
|
required this.child,
|
||||||
|
this.topInset = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PageController pageController;
|
||||||
|
final Future<void> Function() onRefresh;
|
||||||
|
final Widget child;
|
||||||
|
final double topInset;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FeedPullRefresh> createState() => _FeedPullRefreshState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedPullRefreshState extends State<FeedPullRefresh> {
|
||||||
|
Offset? _origin;
|
||||||
|
double _pull = 0;
|
||||||
|
bool _tracking = false;
|
||||||
|
bool _refreshing = false;
|
||||||
|
|
||||||
|
bool _atFirstPage() {
|
||||||
|
final controller = widget.pageController;
|
||||||
|
if (!controller.hasClients) return false;
|
||||||
|
return (controller.page ?? 0) <= _firstPageEpsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setPull(double value) {
|
||||||
|
final next = value.clamp(0.0, _maxPull);
|
||||||
|
if (next == _pull) return;
|
||||||
|
setState(() => _pull = next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerDown(PointerDownEvent event) {
|
||||||
|
if (_refreshing || !_atFirstPage()) {
|
||||||
|
_tracking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_origin = event.position;
|
||||||
|
_tracking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerMove(PointerMoveEvent event) {
|
||||||
|
final origin = _origin;
|
||||||
|
if (!_tracking || origin == null || _refreshing) return;
|
||||||
|
final delta = event.position - origin;
|
||||||
|
if (delta.dx.abs() > delta.dy.abs() && delta.dy < _refreshThreshold) {
|
||||||
|
_tracking = false;
|
||||||
|
_setPull(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (delta.dy > 0) _holdFirstPage();
|
||||||
|
_setPull(delta.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _holdFirstPage() {
|
||||||
|
final controller = widget.pageController;
|
||||||
|
if (!controller.hasClients) return;
|
||||||
|
final position = controller.position;
|
||||||
|
if (position.pixels <= position.minScrollExtent) return;
|
||||||
|
controller.jumpTo(position.minScrollExtent);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerEnd() {
|
||||||
|
final shouldRefresh = _tracking && _pull >= _refreshThreshold;
|
||||||
|
_origin = null;
|
||||||
|
_tracking = false;
|
||||||
|
if (shouldRefresh) {
|
||||||
|
_refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_setPull(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
if (_refreshing) return;
|
||||||
|
_refreshing = true;
|
||||||
|
_setPull(_refreshThreshold);
|
||||||
|
try {
|
||||||
|
await widget.onRefresh();
|
||||||
|
} finally {
|
||||||
|
_refreshing = false;
|
||||||
|
if (mounted) _setPull(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: _onPointerDown,
|
||||||
|
onPointerMove: _onPointerMove,
|
||||||
|
onPointerUp: (_) => _onPointerEnd(),
|
||||||
|
onPointerCancel: (_) => _onPointerEnd(),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
widget.child,
|
||||||
|
if (_pull > 0)
|
||||||
|
Positioned(
|
||||||
|
top: widget.topInset,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Container(
|
||||||
|
key: const Key('pull-refresh-indicator'),
|
||||||
|
width: AppDimens.refreshIndicatorSize,
|
||||||
|
height: AppDimens.refreshIndicatorSize,
|
||||||
|
padding: const EdgeInsets.all(
|
||||||
|
(AppDimens.refreshIndicatorSize -
|
||||||
|
AppDimens.refreshSpinnerSize) /
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.refreshIndicatorSurface,
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppDimens.refreshIndicatorRadius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const SizedBox.square(
|
||||||
|
dimension: AppDimens.refreshSpinnerSize,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: AppDimens.refreshSpinnerStroke,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import 'home_feed_recommend_panel.dart';
|
|||||||
import 'loading_progress_track.dart';
|
import 'loading_progress_track.dart';
|
||||||
import 'player_double_tap_hearts.dart';
|
import 'player_double_tap_hearts.dart';
|
||||||
import 'seek_progress_bar.dart';
|
import 'seek_progress_bar.dart';
|
||||||
|
import 'scrub_preview_card.dart';
|
||||||
|
|
||||||
part 'home_feed_overlay_parts.dart';
|
part 'home_feed_overlay_parts.dart';
|
||||||
|
|
||||||
@@ -207,6 +208,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
|||||||
height: _progressHeight,
|
height: _progressHeight,
|
||||||
hitHeight: _progressHitHeight,
|
hitHeight: _progressHitHeight,
|
||||||
cancelScrubRequest: widget.cancelScrubRequest,
|
cancelScrubRequest: widget.cancelScrubRequest,
|
||||||
|
thumbnailService: widget.thumbnailService,
|
||||||
|
spriteAxisService: widget.spriteAxisService,
|
||||||
|
thumbnailsUrl: widget.episode.thumbnailsUrl,
|
||||||
|
episodeId: widget.episode.id,
|
||||||
|
posterUrl: widget.drama.posterUrl,
|
||||||
onScrubStart: _startScrub,
|
onScrubStart: _startScrub,
|
||||||
onScrubEnd: _endScrub,
|
onScrubEnd: _endScrub,
|
||||||
onSeek: widget.onSeek,
|
onSeek: widget.onSeek,
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ class _FeedProgressBar extends StatefulWidget {
|
|||||||
required this.height,
|
required this.height,
|
||||||
required this.hitHeight,
|
required this.hitHeight,
|
||||||
this.cancelScrubRequest = 0,
|
this.cancelScrubRequest = 0,
|
||||||
|
this.thumbnailService,
|
||||||
|
this.spriteAxisService,
|
||||||
|
this.thumbnailsUrl,
|
||||||
|
this.episodeId,
|
||||||
|
this.posterUrl = '',
|
||||||
this.onScrubStart,
|
this.onScrubStart,
|
||||||
this.onScrubEnd,
|
this.onScrubEnd,
|
||||||
this.onSeek,
|
this.onSeek,
|
||||||
@@ -29,6 +34,11 @@ class _FeedProgressBar extends StatefulWidget {
|
|||||||
final double height;
|
final double height;
|
||||||
final double hitHeight;
|
final double hitHeight;
|
||||||
final int cancelScrubRequest;
|
final int cancelScrubRequest;
|
||||||
|
final PlaybackThumbnailService? thumbnailService;
|
||||||
|
final SpriteThumbnailAxisSource? spriteAxisService;
|
||||||
|
final String? thumbnailsUrl;
|
||||||
|
final String? episodeId;
|
||||||
|
final String posterUrl;
|
||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
final VoidCallback? onScrubEnd;
|
final VoidCallback? onScrubEnd;
|
||||||
final ValueChanged<Duration>? onSeek;
|
final ValueChanged<Duration>? onSeek;
|
||||||
@@ -142,7 +152,26 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
track,
|
track,
|
||||||
if (showPreview)
|
if (showPreview &&
|
||||||
|
widget.thumbnailService != null &&
|
||||||
|
widget.episodeId != null)
|
||||||
|
Positioned(
|
||||||
|
left: scrubPreviewLeft(
|
||||||
|
trackWidth: width,
|
||||||
|
fraction: _preview!,
|
||||||
|
),
|
||||||
|
bottom: _lineCenterFromBottom + 16,
|
||||||
|
child: ScrubPreviewCard(
|
||||||
|
service: widget.thumbnailService!,
|
||||||
|
spriteAxisService: widget.spriteAxisService,
|
||||||
|
thumbnailsUrl: widget.thumbnailsUrl,
|
||||||
|
episodeId: widget.episodeId!,
|
||||||
|
posterUrl: widget.posterUrl,
|
||||||
|
position: previewPosition,
|
||||||
|
duration: video!.duration,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (showPreview)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: _timeBubbleLeft(width, _preview!),
|
left: _timeBubbleLeft(width, _preview!),
|
||||||
bottom: _lineCenterFromBottom + 16,
|
bottom: _lineCenterFromBottom + 16,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import '../../../core/theme/app_colors.dart';
|
|||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
import '../../home/widgets/home_drama_classification.dart';
|
import '../../home/widgets/home_drama_classification.dart';
|
||||||
|
|
||||||
const double _headlineFont = 26;
|
|
||||||
const double _seriesFont = 19;
|
const double _seriesFont = 19;
|
||||||
const double _tagFont = 13;
|
const double _tagFont = 13;
|
||||||
const double _synopsisFont = 13;
|
const double _synopsisFont = 13;
|
||||||
@@ -49,34 +48,12 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
|
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
|
||||||
final episodeTitle = widget.episode.title.trim();
|
|
||||||
final headline = _headline(l10n, dramaTitle, episodeTitle);
|
|
||||||
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
||||||
final genres = _genreValues();
|
final genres = _genreValues();
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
|
||||||
key: const Key('home-feed-title-open'),
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: widget.onOpenFullPlayer,
|
|
||||||
child: Text(
|
|
||||||
headline,
|
|
||||||
key: const Key('home-feed-title'),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
fontSize: _headlineFont,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
height: 1.2,
|
|
||||||
letterSpacing: 0,
|
|
||||||
shadows: _textShadows,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
key: const Key('home-feed-series-name'),
|
key: const Key('home-feed-series-name'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
@@ -119,23 +96,6 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _headline(
|
|
||||||
AppLocalizations l10n,
|
|
||||||
String dramaTitle,
|
|
||||||
String episodeTitle,
|
|
||||||
) {
|
|
||||||
if (episodeTitle.isEmpty) return dramaTitle;
|
|
||||||
final normalized = episodeTitle.toLowerCase().replaceAll(' ', '');
|
|
||||||
final localizedEpisode = l10n
|
|
||||||
.episodeLabel(widget.episode.number)
|
|
||||||
.toLowerCase()
|
|
||||||
.replaceAll(' ', '');
|
|
||||||
final episodeOnly = RegExp(r'^(第?\d+集|episode\d+)$').hasMatch(normalized);
|
|
||||||
return normalized == localizedEpisode || episodeOnly
|
|
||||||
? dramaTitle
|
|
||||||
: episodeTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
|
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
|
||||||
return Row(
|
return Row(
|
||||||
key: const Key('home-feed-tags'),
|
key: const Key('home-feed-tags'),
|
||||||
@@ -213,21 +173,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_synopsisExpanded) {
|
if (_synopsisExpanded) {
|
||||||
return Column(
|
return _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n);
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
synopsis,
|
|
||||||
key: const Key('home-feed-synopsis'),
|
|
||||||
style: style,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
_synopsisToggle(
|
|
||||||
key: const Key('home-feed-synopsis-more'),
|
|
||||||
label: l10n.showLess,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -255,6 +201,30 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _expandedSynopsis(
|
||||||
|
double maxWidth,
|
||||||
|
String synopsis,
|
||||||
|
TextStyle style,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
) {
|
||||||
|
return SizedBox(
|
||||||
|
width: maxWidth,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(synopsis, key: const Key('home-feed-synopsis'), style: style),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: _synopsisToggle(
|
||||||
|
key: const Key('home-feed-synopsis-more'),
|
||||||
|
label: l10n.showLess,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _synopsisToggle({required Key key, required String label}) {
|
Widget _synopsisToggle({required Key key, required String label}) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
key: key,
|
key: key,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../../core/data/models/drama.dart';
|
||||||
|
import '../../../core/data/services/catalog_controller.dart';
|
||||||
import '../../../core/formatting/localized_content.dart';
|
import '../../../core/formatting/localized_content.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
@@ -17,6 +22,7 @@ const double _topButtonSize = 36;
|
|||||||
const double _topBarHeight = 58;
|
const double _topBarHeight = 58;
|
||||||
const double _boostHintIconSize = 16;
|
const double _boostHintIconSize = 16;
|
||||||
const double _boostHintFontSize = 13;
|
const double _boostHintFontSize = 13;
|
||||||
|
const double _synopsisRailInset = 54;
|
||||||
const List<Shadow> _playerTextShadows = [
|
const List<Shadow> _playerTextShadows = [
|
||||||
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
|
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
|
||||||
];
|
];
|
||||||
@@ -29,6 +35,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
required this.data,
|
required this.data,
|
||||||
required this.actions,
|
required this.actions,
|
||||||
required this.controlsVisible,
|
required this.controlsVisible,
|
||||||
|
required this.scrubbing,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 展示数据。
|
/// 展示数据。
|
||||||
@@ -40,6 +47,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
/// 控制层是否可见。
|
/// 控制层是否可见。
|
||||||
final bool controlsVisible;
|
final bool controlsVisible;
|
||||||
|
|
||||||
|
/// 正在拖动进度条。拖动期间清屏,只留进度条。
|
||||||
|
final bool scrubbing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
@@ -61,7 +71,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
_top(context),
|
_top(context),
|
||||||
_bottom(context),
|
_bottom(context),
|
||||||
if (controlsVisible) _rail(context),
|
if (controlsVisible) _rail(context),
|
||||||
if (!data.playing)
|
if (!data.playing && !scrubbing)
|
||||||
Center(
|
Center(
|
||||||
child: PlayerCenterToggle(
|
child: PlayerCenterToggle(
|
||||||
key: const Key('player-play-toggle'),
|
key: const Key('player-play-toggle'),
|
||||||
@@ -79,49 +89,55 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Container(
|
child: IgnorePointer(
|
||||||
key: const Key('player-controls-top'),
|
ignoring: scrubbing,
|
||||||
height: _topBarHeight,
|
child: Opacity(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
opacity: scrubbing ? 0 : 1,
|
||||||
child: Row(
|
child: Container(
|
||||||
children: [
|
key: const Key('player-controls-top'),
|
||||||
if (actions.onBack != null)
|
height: _topBarHeight,
|
||||||
_TopButton(
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
key: const Key('player-back'),
|
child: Row(
|
||||||
tooltip: data.l10n.backToSeries,
|
children: [
|
||||||
icon: Icons.chevron_left_rounded,
|
if (actions.onBack != null)
|
||||||
onPressed: actions.onBack!,
|
_TopButton(
|
||||||
)
|
key: const Key('player-back'),
|
||||||
else
|
tooltip: data.l10n.backToSeries,
|
||||||
const SizedBox(width: _topButtonSize),
|
icon: Icons.chevron_left_rounded,
|
||||||
const SizedBox(width: 8),
|
onPressed: actions.onBack!,
|
||||||
Expanded(
|
)
|
||||||
child: Text(
|
else
|
||||||
data.l10n.episodeLabel(data.episode.number),
|
const SizedBox(width: _topButtonSize),
|
||||||
key: const Key('player-episode-heading'),
|
const SizedBox(width: 8),
|
||||||
maxLines: 1,
|
Expanded(
|
||||||
overflow: TextOverflow.ellipsis,
|
child: Text(
|
||||||
style: const TextStyle(
|
data.l10n.episodeLabel(data.episode.number),
|
||||||
color: AppColors.onPrimary,
|
key: const Key('player-episode-heading'),
|
||||||
fontSize: 15,
|
maxLines: 1,
|
||||||
fontWeight: FontWeight.w700,
|
overflow: TextOverflow.ellipsis,
|
||||||
shadows: _playerTextShadows,
|
style: const TextStyle(
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
shadows: _playerTextShadows,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
_SpeedEntry(
|
||||||
|
key: const Key('player-speed-entry'),
|
||||||
|
speed: data.playbackSpeed,
|
||||||
|
onTap: actions.onSpeed,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_TopButton(
|
||||||
|
key: const Key('player-more'),
|
||||||
|
tooltip: data.l10n.more,
|
||||||
|
icon: Icons.more_vert_rounded,
|
||||||
|
onPressed: actions.onMore,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
_SpeedEntry(
|
),
|
||||||
key: const Key('player-speed-entry'),
|
|
||||||
speed: data.playbackSpeed,
|
|
||||||
onTap: actions.onSpeed,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_TopButton(
|
|
||||||
key: const Key('player-more'),
|
|
||||||
tooltip: data.l10n.more,
|
|
||||||
icon: Icons.more_vert_rounded,
|
|
||||||
onPressed: actions.onMore,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -143,43 +159,49 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
return Positioned(
|
return Positioned(
|
||||||
right: AppDimens.spaceMd,
|
right: AppDimens.spaceMd,
|
||||||
top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43),
|
top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43),
|
||||||
child: InteractionRail(
|
child: IgnorePointer(
|
||||||
key: const Key('player-interaction-rail'),
|
ignoring: scrubbing,
|
||||||
items: [
|
child: Opacity(
|
||||||
InteractionRailItem(
|
opacity: scrubbing ? 0 : 1,
|
||||||
key: const Key('player-rail-save'),
|
child: InteractionRail(
|
||||||
asset: 'assets/icon/rail/rail_save.png',
|
key: const Key('player-interaction-rail'),
|
||||||
label: data.l10n.favorite,
|
items: [
|
||||||
count: data.favoriteCount,
|
InteractionRailItem(
|
||||||
countKey: const Key('player-rail-save-count'),
|
key: const Key('player-rail-save'),
|
||||||
selected: data.saved,
|
asset: 'assets/icon/rail/rail_save.png',
|
||||||
onTap: actions.onSave,
|
label: data.l10n.favorite,
|
||||||
|
count: data.favoriteCount,
|
||||||
|
countKey: const Key('player-rail-save-count'),
|
||||||
|
selected: data.saved,
|
||||||
|
onTap: actions.onSave,
|
||||||
|
),
|
||||||
|
InteractionRailItem(
|
||||||
|
key: const Key('player-rail-comments'),
|
||||||
|
asset: 'assets/icon/rail/rail_comment.png',
|
||||||
|
label: data.l10n.comments,
|
||||||
|
count: data.commentCount,
|
||||||
|
countKey: const Key('player-rail-comments-count'),
|
||||||
|
onTap: actions.onComments,
|
||||||
|
),
|
||||||
|
InteractionRailItem(
|
||||||
|
key: const Key('player-rail-like'),
|
||||||
|
asset: 'assets/icon/rail/rail_like.png',
|
||||||
|
label: data.l10n.like,
|
||||||
|
count: data.likeCount,
|
||||||
|
countKey: const Key('player-rail-like-count'),
|
||||||
|
selected: data.liked,
|
||||||
|
selectedColor: AppColors.doubleTapHeartRed,
|
||||||
|
onTap: actions.onLike,
|
||||||
|
),
|
||||||
|
InteractionRailItem(
|
||||||
|
key: const Key('player-rail-share'),
|
||||||
|
asset: 'assets/icon/rail/rail_share.png',
|
||||||
|
label: data.l10n.share,
|
||||||
|
onTap: actions.onShare,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
InteractionRailItem(
|
),
|
||||||
key: const Key('player-rail-comments'),
|
|
||||||
asset: 'assets/icon/rail/rail_comment.png',
|
|
||||||
label: data.l10n.comments,
|
|
||||||
count: data.commentCount,
|
|
||||||
countKey: const Key('player-rail-comments-count'),
|
|
||||||
onTap: actions.onComments,
|
|
||||||
),
|
|
||||||
InteractionRailItem(
|
|
||||||
key: const Key('player-rail-like'),
|
|
||||||
asset: 'assets/icon/rail/rail_like.png',
|
|
||||||
label: data.l10n.like,
|
|
||||||
count: data.likeCount,
|
|
||||||
countKey: const Key('player-rail-like-count'),
|
|
||||||
selected: data.liked,
|
|
||||||
selectedColor: AppColors.doubleTapHeartRed,
|
|
||||||
onTap: actions.onLike,
|
|
||||||
),
|
|
||||||
InteractionRailItem(
|
|
||||||
key: const Key('player-rail-share'),
|
|
||||||
asset: 'assets/icon/rail/rail_share.png',
|
|
||||||
label: data.l10n.share,
|
|
||||||
onTap: actions.onShare,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -192,11 +214,17 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (controlsVisible) _bottomInfo(context),
|
Offstage(
|
||||||
|
offstage: !controlsVisible || scrubbing,
|
||||||
|
child: _bottomInfo(context),
|
||||||
|
),
|
||||||
if (data.boosting) _boostHint(),
|
if (data.boosting) _boostHint(),
|
||||||
_bottomProgress(),
|
_bottomProgress(),
|
||||||
// 加速态只保留进度条与提示,便于全屏快进时看清画面。
|
// 加速或拖动进度时只留进度条,避免挡住画面。
|
||||||
if (!data.boosting) _bottomActions(),
|
Offstage(
|
||||||
|
offstage: data.boosting || scrubbing,
|
||||||
|
child: _bottomActions(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -243,12 +271,16 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_detailRow(series),
|
_detailRow(series),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
_EpisodeSynopsis(
|
Padding(
|
||||||
key: const Key('player-episode-synopsis'),
|
padding: const EdgeInsets.only(right: _synopsisRailInset),
|
||||||
l10n: data.l10n,
|
child: _EpisodeSynopsis(
|
||||||
episodeNumber: data.episode.number,
|
key: const Key('player-episode-synopsis'),
|
||||||
synopsis: synopsis,
|
l10n: data.l10n,
|
||||||
authorNote: data.authorNote,
|
drama: data.drama,
|
||||||
|
episodeNumber: data.episode.number,
|
||||||
|
synopsis: synopsis,
|
||||||
|
authorNote: data.authorNote,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -273,6 +305,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
|||||||
posterUrl: data.drama.posterUrl,
|
posterUrl: data.drama.posterUrl,
|
||||||
inactiveTrackColor: AppColors.inactiveTrack,
|
inactiveTrackColor: AppColors.inactiveTrack,
|
||||||
onScrubStart: actions.onScrubStart,
|
onScrubStart: actions.onScrubStart,
|
||||||
|
onScrubEnd: actions.onScrubEnd,
|
||||||
onSeek: actions.onSeek,
|
onSeek: actions.onSeek,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
@@ -40,8 +41,8 @@ class PlayerOverlayData {
|
|||||||
/// 当前剧集。
|
/// 当前剧集。
|
||||||
final Episode episode;
|
final Episode episode;
|
||||||
|
|
||||||
/// 当前播放器。
|
/// 当前播放器进度。
|
||||||
final VideoPlayerController? controller;
|
final ValueListenable<VideoPlayerValue>? controller;
|
||||||
|
|
||||||
/// 是否已点赞。
|
/// 是否已点赞。
|
||||||
final bool liked;
|
final bool liked;
|
||||||
@@ -98,6 +99,7 @@ class PlayerOverlayActions {
|
|||||||
required this.onShare,
|
required this.onShare,
|
||||||
required this.onMore,
|
required this.onMore,
|
||||||
required this.onScrubStart,
|
required this.onScrubStart,
|
||||||
|
required this.onScrubEnd,
|
||||||
required this.onSeek,
|
required this.onSeek,
|
||||||
required this.onLongPressCenter,
|
required this.onLongPressCenter,
|
||||||
required this.onBoostStart,
|
required this.onBoostStart,
|
||||||
@@ -143,6 +145,9 @@ class PlayerOverlayActions {
|
|||||||
/// 开始拖动进度。
|
/// 开始拖动进度。
|
||||||
final VoidCallback? onScrubStart;
|
final VoidCallback? onScrubStart;
|
||||||
|
|
||||||
|
/// 结束拖动进度。
|
||||||
|
final VoidCallback? onScrubEnd;
|
||||||
|
|
||||||
/// 提交进度跳转。
|
/// 提交进度跳转。
|
||||||
final Future<void> Function(Duration)? onSeek;
|
final Future<void> Function(Duration)? onSeek;
|
||||||
|
|
||||||
|
|||||||
@@ -109,12 +109,14 @@ class _EpisodeSynopsis extends StatefulWidget {
|
|||||||
const _EpisodeSynopsis({
|
const _EpisodeSynopsis({
|
||||||
super.key,
|
super.key,
|
||||||
required this.l10n,
|
required this.l10n,
|
||||||
|
required this.drama,
|
||||||
required this.episodeNumber,
|
required this.episodeNumber,
|
||||||
required this.synopsis,
|
required this.synopsis,
|
||||||
required this.authorNote,
|
required this.authorNote,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
|
final Drama drama;
|
||||||
final int episodeNumber;
|
final int episodeNumber;
|
||||||
final String synopsis;
|
final String synopsis;
|
||||||
final String authorNote;
|
final String authorNote;
|
||||||
@@ -125,6 +127,9 @@ class _EpisodeSynopsis extends StatefulWidget {
|
|||||||
|
|
||||||
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||||
bool _expanded = false;
|
bool _expanded = false;
|
||||||
|
String? _fullSynopsis;
|
||||||
|
int _detailRequest = 0;
|
||||||
|
bool _detailRequested = false;
|
||||||
|
|
||||||
/// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。
|
/// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。
|
||||||
static const TextStyle _lineStyle = TextStyle(
|
static const TextStyle _lineStyle = TextStyle(
|
||||||
@@ -134,53 +139,114 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
|||||||
height: 1.35,
|
height: 1.35,
|
||||||
shadows: _playerTextShadows,
|
shadows: _playerTextShadows,
|
||||||
);
|
);
|
||||||
|
static const TextStyle _linkStyle = TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
shadows: _playerTextShadows,
|
||||||
|
);
|
||||||
|
static const int _collapsedMaxLines = 2;
|
||||||
|
static const double _expandGap = 6;
|
||||||
|
static const double _expandTrailing = 28;
|
||||||
|
static const double _separatorGap = 6;
|
||||||
|
|
||||||
|
String get _synopsis => _fullSynopsis ?? widget.synopsis;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (_detailRequested || widget.drama.detailLoaded) return;
|
||||||
|
_detailRequested = true;
|
||||||
|
unawaited(_loadFullSynopsis());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant _EpisodeSynopsis oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.drama.id == widget.drama.id &&
|
||||||
|
oldWidget.synopsis == widget.synopsis) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_fullSynopsis = null;
|
||||||
|
_expanded = false;
|
||||||
|
_detailRequested = !widget.drama.detailLoaded;
|
||||||
|
if (_detailRequested) unawaited(_loadFullSynopsis());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadFullSynopsis() async {
|
||||||
|
if (widget.drama.detailLoaded) return;
|
||||||
|
final request = ++_detailRequest;
|
||||||
|
final CatalogController catalog;
|
||||||
|
try {
|
||||||
|
catalog = context.read<CatalogController>();
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final detail = await catalog.requireDetail(widget.drama);
|
||||||
|
if (!mounted || request != _detailRequest) return;
|
||||||
|
final full = localizedDramaSynopsis(widget.l10n, detail);
|
||||||
|
if (full == widget.synopsis) return;
|
||||||
|
setState(() => _fullSynopsis = full);
|
||||||
|
} catch (_) {
|
||||||
|
// 详情失败时继续用列表里的简介。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return LayoutBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, constraints) => _block(constraints.maxWidth),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _block(double maxWidth) {
|
||||||
|
final label = widget.l10n.episodeLabel(widget.episodeNumber);
|
||||||
|
if (_expanded) return _expandedBlock(label, maxWidth);
|
||||||
|
return _collapsedBlock(label, maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _collapsedBlock(String label, double maxWidth) {
|
||||||
|
final link = widget.l10n.expandSynopsis;
|
||||||
|
final body = _collapsedBody(label, maxWidth, link);
|
||||||
|
final text = _synopsisText(label, body, link: null, expanded: false);
|
||||||
|
if (body == _synopsis) return text;
|
||||||
|
final caret = _caret(label, body, maxWidth);
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
text,
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
Positioned(
|
||||||
textBaseline: TextBaseline.alphabetic,
|
left: caret.dx,
|
||||||
children: [
|
top: caret.dy,
|
||||||
Text(
|
child: Padding(
|
||||||
widget.l10n.episodeLabel(widget.episodeNumber),
|
key: const Key('player-synopsis-gap'),
|
||||||
key: const Key('player-episode-label'),
|
padding: const EdgeInsets.only(left: _expandGap),
|
||||||
maxLines: 1,
|
child: _toggle(link),
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
style: _lineStyle,
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
||||||
child: Text(
|
|
||||||
'|',
|
|
||||||
maxLines: 1,
|
|
||||||
style: _lineStyle.copyWith(
|
|
||||||
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
widget.synopsis,
|
|
||||||
key: const Key('player-synopsis'),
|
|
||||||
maxLines: _expanded ? null : 1,
|
|
||||||
overflow: _expanded ? TextOverflow.clip : TextOverflow.ellipsis,
|
|
||||||
style: _lineStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!_expanded) _toggle(widget.l10n.expandSynopsis),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
if (_expanded) ...[
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _expandedBlock(String label, double maxWidth) {
|
||||||
|
return SizedBox(
|
||||||
|
width: maxWidth,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_synopsisText(label, _synopsis, link: null, expanded: true),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: _toggle(widget.l10n.showLess),
|
||||||
|
),
|
||||||
if (widget.authorNote.isNotEmpty) ...[
|
if (widget.authorNote.isNotEmpty) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
widget.authorNote,
|
widget.authorNote,
|
||||||
key: const Key('player-author-note'),
|
key: const Key('player-author-note'),
|
||||||
maxLines: 2,
|
maxLines: _collapsedMaxLines,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.playerMuted,
|
color: AppColors.playerMuted,
|
||||||
@@ -189,31 +255,181 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
_toggle(widget.l10n.showLess),
|
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _synopsisText(
|
||||||
|
String label,
|
||||||
|
String body, {
|
||||||
|
required String? link,
|
||||||
|
required bool expanded,
|
||||||
|
}) {
|
||||||
|
return Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: _lineStyle,
|
||||||
|
children: _spans(label, body, link),
|
||||||
|
),
|
||||||
|
key: const Key('player-synopsis'),
|
||||||
|
style: _lineStyle,
|
||||||
|
maxLines: expanded ? null : _collapsedMaxLines,
|
||||||
|
overflow: expanded ? TextOverflow.clip : TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InlineSpan> _spans(String label, String body, String? link) {
|
||||||
|
return [
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
key: const Key('player-episode-label'),
|
||||||
|
style: _lineStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: _separatorGap),
|
||||||
|
child: Text(
|
||||||
|
'|',
|
||||||
|
style: _lineStyle.copyWith(
|
||||||
|
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(text: body, style: _lineStyle),
|
||||||
|
if (link != null)
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: Padding(
|
||||||
|
key: const Key('player-synopsis-gap'),
|
||||||
|
padding: const EdgeInsets.only(left: _expandGap),
|
||||||
|
child: _toggle(link),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _collapsedBody(String label, double maxWidth, String link) {
|
||||||
|
if (_fits(label, _synopsis, maxWidth, reserve: 0)) {
|
||||||
|
return _synopsis;
|
||||||
|
}
|
||||||
|
final reserve =
|
||||||
|
_textWidth(link, _linkStyle) + _expandGap + _expandTrailing;
|
||||||
|
var low = 0;
|
||||||
|
var high = _synopsis.length;
|
||||||
|
var best = '…';
|
||||||
|
while (low <= high) {
|
||||||
|
final mid = (low + high) >> 1;
|
||||||
|
final slice = '${_synopsis.substring(0, mid)}…';
|
||||||
|
if (_fits(label, slice, maxWidth, reserve: reserve)) {
|
||||||
|
best = slice;
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _fits(
|
||||||
|
String label,
|
||||||
|
String body,
|
||||||
|
double maxWidth, {
|
||||||
|
required double reserve,
|
||||||
|
}) {
|
||||||
|
final painter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(text: label, style: _lineStyle),
|
||||||
|
const WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: SizedBox(width: _separatorGap),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '|',
|
||||||
|
style: _lineStyle.copyWith(
|
||||||
|
color: AppColors.playerMuted.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: SizedBox(width: _separatorGap),
|
||||||
|
),
|
||||||
|
TextSpan(text: body, style: _lineStyle),
|
||||||
|
if (reserve > 0)
|
||||||
|
WidgetSpan(
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: SizedBox(width: reserve),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
maxLines: _collapsedMaxLines,
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
);
|
||||||
|
painter.setPlaceholderDimensions(_slots(reserve));
|
||||||
|
painter.layout(maxWidth: maxWidth);
|
||||||
|
return !painter.didExceedMaxLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PlaceholderDimensions> _slots(double reserve) {
|
||||||
|
return [
|
||||||
|
const PlaceholderDimensions(
|
||||||
|
size: Size(_separatorGap, 1),
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
),
|
||||||
|
const PlaceholderDimensions(
|
||||||
|
size: Size(_separatorGap, 1),
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
),
|
||||||
|
if (reserve > 0)
|
||||||
|
PlaceholderDimensions(
|
||||||
|
size: Size(reserve, _lineStyle.fontSize ?? 12),
|
||||||
|
alignment: PlaceholderAlignment.baseline,
|
||||||
|
baseline: TextBaseline.alphabetic,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _caret(String label, String body, double maxWidth) {
|
||||||
|
final painter = TextPainter(
|
||||||
|
text: TextSpan(children: _spans(label, body, null)),
|
||||||
|
maxLines: _collapsedMaxLines,
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
);
|
||||||
|
painter.setPlaceholderDimensions(_slots(0));
|
||||||
|
painter.layout(maxWidth: maxWidth);
|
||||||
|
return painter.getOffsetForCaret(
|
||||||
|
TextPosition(offset: painter.plainText.length),
|
||||||
|
Rect.zero,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
double _textWidth(String text, TextStyle style) {
|
||||||
|
final painter = TextPainter(
|
||||||
|
text: TextSpan(text: text, style: style),
|
||||||
|
maxLines: 1,
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
return painter.width;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _toggle(String label) {
|
Widget _toggle(String label) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
key: const Key('player-synopsis-toggle'),
|
key: const Key('player-synopsis-toggle'),
|
||||||
onTap: () => setState(() => _expanded = !_expanded),
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: Padding(
|
child: Text(label, maxLines: 1, style: _linkStyle),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.blue,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
shadows: _playerTextShadows,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,9 @@ import '../../../core/data/services/sprite_thumbnail_axis.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/widgets/remote_image.dart';
|
import '../../../core/widgets/remote_image.dart';
|
||||||
|
|
||||||
const double scrubPreviewWidth = 120;
|
const double scrubPreviewWidth = 96;
|
||||||
const double scrubPreviewSafeMargin = 12;
|
const double scrubPreviewSafeMargin = 12;
|
||||||
|
const double _portraitFrameAspect = 9 / 16;
|
||||||
|
|
||||||
/// 把预览卡片限制在进度条可用宽度内。
|
/// 把预览卡片限制在进度条可用宽度内。
|
||||||
double scrubPreviewLeft({
|
double scrubPreviewLeft({
|
||||||
@@ -64,6 +65,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
|
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
|
||||||
|
_prepareSprite();
|
||||||
_syncFallbackSource();
|
_syncFallbackSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,10 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
|||||||
oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged);
|
oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged);
|
||||||
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
|
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
|
||||||
}
|
}
|
||||||
|
if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl ||
|
||||||
|
oldWidget.position != widget.position) {
|
||||||
|
_prepareSprite();
|
||||||
|
}
|
||||||
if (oldWidget.service != widget.service ||
|
if (oldWidget.service != widget.service ||
|
||||||
oldWidget.episodeId != widget.episodeId ||
|
oldWidget.episodeId != widget.episodeId ||
|
||||||
oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
|
oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
|
||||||
@@ -97,6 +103,12 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _prepareSprite() {
|
||||||
|
final service = widget.spriteAxisService;
|
||||||
|
if (service == null) return;
|
||||||
|
unawaited(service.prepareAt(widget.thumbnailsUrl, widget.position));
|
||||||
|
}
|
||||||
|
|
||||||
void _handleSpriteAxisChanged() {
|
void _handleSpriteAxisChanged() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_spriteFrame != null) {
|
if (_spriteFrame != null) {
|
||||||
@@ -190,7 +202,8 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
aspectRatio: 16 / 9,
|
key: const Key('scrub-preview-frame-box'),
|
||||||
|
aspectRatio: _frameAspect(spriteFrame),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
@@ -229,6 +242,14 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double _frameAspect(SpriteThumbnailFrame? frame) {
|
||||||
|
final rect = frame?.sourceRect;
|
||||||
|
if (rect == null || rect.width <= 0 || rect.height <= 0) {
|
||||||
|
return _portraitFrameAspect;
|
||||||
|
}
|
||||||
|
return rect.width / rect.height;
|
||||||
|
}
|
||||||
|
|
||||||
/// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。
|
/// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。
|
||||||
class SpriteCropPreview extends StatelessWidget {
|
class SpriteCropPreview extends StatelessWidget {
|
||||||
const SpriteCropPreview({
|
const SpriteCropPreview({
|
||||||
|
|||||||
@@ -170,11 +170,28 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
setState(() => _buffering = buffering);
|
setState(() => _buffering = buffering);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _endPointerDrag() {
|
||||||
|
if (!_dragging) {
|
||||||
|
_pointerMoved = false;
|
||||||
|
_pointerStart = null;
|
||||||
|
_dragStartFraction = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final duration = widget.valueListenable?.value.duration ?? Duration.zero;
|
||||||
|
final fraction = _previewFraction.value ?? _dragStartFraction ?? 0;
|
||||||
|
unawaited(_finish(fraction, duration));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _finish(double fraction, Duration duration) async {
|
Future<void> _finish(double fraction, Duration duration) async {
|
||||||
final startedAt = _dragStartFraction;
|
final startedAt = _dragStartFraction;
|
||||||
_dragStartFraction = null;
|
_dragStartFraction = null;
|
||||||
if (widget.locked || !_dragging || startedAt == null) return;
|
if (widget.locked || !_dragging || startedAt == null) return;
|
||||||
setState(() => _dragging = false);
|
_dragging = false;
|
||||||
|
if (!mounted) {
|
||||||
|
widget.onScrubEnd?.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
_pointerMoved = false;
|
_pointerMoved = false;
|
||||||
_pointerStart = null;
|
_pointerStart = null;
|
||||||
widget.thumbnailService?.cancelPending();
|
widget.thumbnailService?.cancelPending();
|
||||||
@@ -287,20 +304,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
|||||||
_pointerMoved = true;
|
_pointerMoved = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onPointerUp: (_) {
|
onPointerUp: (_) => _endPointerDrag(),
|
||||||
if (!_dragging) {
|
onPointerCancel: (_) => _endPointerDrag(),
|
||||||
_pointerMoved = false;
|
|
||||||
_pointerStart = null;
|
|
||||||
_dragStartFraction = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPointerCancel: (_) {
|
|
||||||
if (!_dragging) {
|
|
||||||
_pointerMoved = false;
|
|
||||||
_pointerStart = null;
|
|
||||||
_dragStartFraction = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Slider(
|
child: Slider(
|
||||||
value: fraction,
|
value: fraction,
|
||||||
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
|
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ void main() {
|
|||||||
expect(find.textContaining('/ 00:12'), findsOneWidget);
|
expect(find.textContaining('/ 00:12'), findsOneWidget);
|
||||||
expect(
|
expect(
|
||||||
tester.getSize(find.byKey(const Key('scrub-preview-card'))).width,
|
tester.getSize(find.byKey(const Key('scrub-preview-card'))).width,
|
||||||
120,
|
96,
|
||||||
);
|
);
|
||||||
|
|
||||||
await gesture.up();
|
await gesture.up();
|
||||||
|
|||||||
@@ -570,14 +570,11 @@ void main() {
|
|||||||
32,
|
32,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(find.byKey(const Key('player-synopsis-toggle')));
|
final bottomPanel = tester.getRect(
|
||||||
await tester.pumpAndSettle();
|
|
||||||
final expandedBottom = tester.getRect(
|
|
||||||
find.byKey(const Key('player-controls-bottom')),
|
find.byKey(const Key('player-controls-bottom')),
|
||||||
);
|
);
|
||||||
// K29.9 展开态展示简介全文,高度会增长,但仍需留在画面下半区。
|
expect(bottomPanel.height, lessThanOrEqualTo(330));
|
||||||
expect(expandedBottom.height, lessThanOrEqualTo(330));
|
expect(bottomPanel.top, greaterThanOrEqualTo(screenHeight * 0.5));
|
||||||
expect(expandedBottom.top, greaterThanOrEqualTo(screenHeight * 0.5));
|
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -753,8 +750,33 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async {
|
testWidgets('should_toggle_synopsis_with_inline_text_button', (tester) async {
|
||||||
await _pumpApp(tester);
|
final longCopy = Drama(
|
||||||
await _openSeriesPlayer(tester);
|
id: 'long-copy-demo',
|
||||||
|
title: 'Long Title',
|
||||||
|
synopsis:
|
||||||
|
'A very long synopsis that keeps describing the story far beyond '
|
||||||
|
'the two lines available under the title in the fullscreen player.',
|
||||||
|
posterUrl: '/files/long-copy-demo.png',
|
||||||
|
genre: 'Drama',
|
||||||
|
detailLoaded: true,
|
||||||
|
episodes: const [
|
||||||
|
Episode(
|
||||||
|
id: 'long-copy-demo-1',
|
||||||
|
number: 1,
|
||||||
|
title: 'Episode 1',
|
||||||
|
isFree: true,
|
||||||
|
videoUrl: 'https://example.test/long-demo-1.mp4',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await _pumpApp(
|
||||||
|
tester,
|
||||||
|
catalog: FakeCatalogRepository(dramas: [longCopy]),
|
||||||
|
);
|
||||||
|
await _openSeriesPlayer(
|
||||||
|
tester,
|
||||||
|
cardKey: const Key('home-feed-card-long-copy-demo'),
|
||||||
|
);
|
||||||
|
|
||||||
final toggle = find.byKey(const Key('player-synopsis-toggle'));
|
final toggle = find.byKey(const Key('player-synopsis-toggle'));
|
||||||
final synopsis = find.byKey(const Key('player-synopsis'));
|
final synopsis = find.byKey(const Key('player-synopsis'));
|
||||||
@@ -762,10 +784,26 @@ void main() {
|
|||||||
find.descendant(of: toggle, matching: find.text('More')),
|
find.descendant(of: toggle, matching: find.text('More')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(tester.widget<Text>(synopsis).maxLines, 1);
|
|
||||||
expect(
|
expect(
|
||||||
tester.getRect(toggle).left - tester.getRect(synopsis).right,
|
tester
|
||||||
lessThanOrEqualTo(4),
|
.widget<Text>(
|
||||||
|
find.descendant(of: toggle, matching: find.text('More')),
|
||||||
|
)
|
||||||
|
.style
|
||||||
|
?.color,
|
||||||
|
AppColors.textPrimary,
|
||||||
|
);
|
||||||
|
expect(tester.widget<Text>(synopsis).maxLines, 2);
|
||||||
|
expect(
|
||||||
|
tester.widget<Padding>(find.byKey(const Key('player-synopsis-gap'))).padding,
|
||||||
|
const EdgeInsets.only(left: 6),
|
||||||
|
);
|
||||||
|
final synopsisRect = tester.getRect(synopsis);
|
||||||
|
final toggleRect = tester.getRect(toggle);
|
||||||
|
expect(toggleRect.left, greaterThan(synopsisRect.left));
|
||||||
|
expect(
|
||||||
|
toggleRect.center.dy,
|
||||||
|
greaterThan(synopsisRect.top + synopsisRect.height * 0.45),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.tap(toggle);
|
await tester.tap(toggle);
|
||||||
@@ -774,7 +812,12 @@ void main() {
|
|||||||
find.descendant(of: toggle, matching: find.text('Less')),
|
find.descendant(of: toggle, matching: find.text('Less')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(tester.widget<Text>(synopsis).maxLines, isNull);
|
expect(tester.widget<Text>(find.byKey(const Key('player-synopsis'))).maxLines, isNull);
|
||||||
|
final lessRect = tester.getRect(toggle);
|
||||||
|
final textRect = tester.getRect(find.byKey(const Key('player-synopsis')));
|
||||||
|
final block = tester.getRect(find.byKey(const Key('player-episode-synopsis')));
|
||||||
|
expect(lessRect.top, greaterThanOrEqualTo(textRect.bottom - 1));
|
||||||
|
expect(block.right - lessRect.right, lessThanOrEqualTo(2));
|
||||||
|
|
||||||
await tester.tap(toggle);
|
await tester.tap(toggle);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -782,7 +825,7 @@ void main() {
|
|||||||
find.descendant(of: toggle, matching: find.text('More')),
|
find.descendant(of: toggle, matching: find.text('More')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(tester.widget<Text>(synopsis).maxLines, 1);
|
expect(tester.widget<Text>(synopsis).maxLines, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_share_one_muted_style_across_label_and_synopsis', (
|
testWidgets('should_share_one_muted_style_across_label_and_synopsis', (
|
||||||
@@ -860,7 +903,11 @@ void main() {
|
|||||||
final synopsis = tester.widget<Text>(
|
final synopsis = tester.widget<Text>(
|
||||||
find.byKey(const Key('player-synopsis')),
|
find.byKey(const Key('player-synopsis')),
|
||||||
);
|
);
|
||||||
expect(synopsis.maxLines, 1);
|
expect(
|
||||||
|
tester.getRect(find.byKey(const Key('player-synopsis'))).right,
|
||||||
|
lessThanOrEqualTo(320 - 14 - 54),
|
||||||
|
);
|
||||||
|
expect(synopsis.maxLines, 2);
|
||||||
expect(synopsis.overflow, TextOverflow.ellipsis);
|
expect(synopsis.overflow, TextOverflow.ellipsis);
|
||||||
expect(
|
expect(
|
||||||
tester.getRect(find.byKey(const Key('player-episodes-pill'))).right,
|
tester.getRect(find.byKey(const Key('player-episodes-pill'))).right,
|
||||||
|
|||||||
@@ -140,6 +140,9 @@ class _FakeAxisSource extends ChangeNotifier
|
|||||||
Duration position,
|
Duration position,
|
||||||
) => frame;
|
) => frame;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool hasAxis(String? thumbnailsUrl) => axisReady;
|
bool hasAxis(String? thumbnailsUrl) => axisReady;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/player_overlay.dart';
|
||||||
|
import 'package:fruitshorts/pages/player/widgets/player_overlay_models.dart';
|
||||||
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
@@ -151,6 +153,56 @@ void main() {
|
|||||||
await gesture.up();
|
await gesture.up();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('full player drag clears chrome and release restores it', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _pumpFullPlayerOverlay(tester);
|
||||||
|
expect(find.byKey(const Key('player-controls-top')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget);
|
||||||
|
|
||||||
|
final gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(find.byKey(const Key('player-progress'))),
|
||||||
|
);
|
||||||
|
await gesture.moveBy(const Offset(80, 0));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(_opacity(tester, const Key('player-controls-top')), 0);
|
||||||
|
expect(_opacity(tester, const Key('player-interaction-rail')), 0);
|
||||||
|
expect(_offstage(tester, const Key('player-controls-bottom')), isTrue);
|
||||||
|
expect(_offstage(tester, const Key('player-bottom-actions')), isTrue);
|
||||||
|
expect(find.byKey(const Key('player-progress')), findsOneWidget);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(_opacity(tester, const Key('player-controls-top')), 1);
|
||||||
|
expect(_opacity(tester, const Key('player-interaction-rail')), 1);
|
||||||
|
expect(_offstage(tester, const Key('player-bottom-actions')), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
double _opacity(WidgetTester tester, Key key) {
|
||||||
|
return tester
|
||||||
|
.widget<Opacity>(
|
||||||
|
find
|
||||||
|
.ancestor(of: find.byKey(key), matching: find.byType(Opacity))
|
||||||
|
.first,
|
||||||
|
)
|
||||||
|
.opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _offstage(WidgetTester tester, Key key) {
|
||||||
|
return tester
|
||||||
|
.widget<Offstage>(
|
||||||
|
find
|
||||||
|
.ancestor(
|
||||||
|
of: find.byKey(key, skipOffstage: false),
|
||||||
|
matching: find.byType(Offstage),
|
||||||
|
)
|
||||||
|
.first,
|
||||||
|
)
|
||||||
|
.offstage;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_HarnessState> _pumpHarness(WidgetTester tester) async {
|
Future<_HarnessState> _pumpHarness(WidgetTester tester) async {
|
||||||
@@ -245,6 +297,96 @@ Future<ValueNotifier<VideoPlayerValue>> _pumpFeedOverlay(
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FullPlayerHarness extends StatefulWidget {
|
||||||
|
const _FullPlayerHarness({required this.controller});
|
||||||
|
|
||||||
|
final ValueNotifier<VideoPlayerValue> controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_FullPlayerHarness> createState() => _FullPlayerHarnessState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FullPlayerHarnessState extends State<_FullPlayerHarness> {
|
||||||
|
bool _scrubbing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return PlayerPlaybackOverlay(
|
||||||
|
scrubbing: _scrubbing,
|
||||||
|
controlsVisible: true,
|
||||||
|
data: PlayerOverlayData(
|
||||||
|
l10n: l10n,
|
||||||
|
drama: const Drama(
|
||||||
|
id: 'full-drama',
|
||||||
|
title: 'Full Drama',
|
||||||
|
synopsis: 'A short synopsis for the full player.',
|
||||||
|
posterUrl: '',
|
||||||
|
totalEpisodes: 8,
|
||||||
|
detailLoaded: true,
|
||||||
|
),
|
||||||
|
episode: const Episode(
|
||||||
|
id: 'full-episode',
|
||||||
|
number: 2,
|
||||||
|
title: 'Episode 2',
|
||||||
|
isFree: true,
|
||||||
|
),
|
||||||
|
controller: widget.controller,
|
||||||
|
locked: false,
|
||||||
|
liked: false,
|
||||||
|
likeCount: 1,
|
||||||
|
commentCount: 1,
|
||||||
|
favoriteCount: 1,
|
||||||
|
saved: false,
|
||||||
|
playing: true,
|
||||||
|
playbackSpeed: 1,
|
||||||
|
totalEpisodes: 8,
|
||||||
|
boosting: false,
|
||||||
|
),
|
||||||
|
actions: PlayerOverlayActions(
|
||||||
|
onBack: () {},
|
||||||
|
onTogglePlay: () {},
|
||||||
|
onClear: () {},
|
||||||
|
onExitClear: () {},
|
||||||
|
onDetail: () {},
|
||||||
|
onSpeed: () {},
|
||||||
|
onEpisodes: () {},
|
||||||
|
onComments: () {},
|
||||||
|
onLike: () {},
|
||||||
|
onSave: () {},
|
||||||
|
onShare: () {},
|
||||||
|
onMore: () {},
|
||||||
|
onScrubStart: () => setState(() => _scrubbing = true),
|
||||||
|
onScrubEnd: () => setState(() => _scrubbing = false),
|
||||||
|
onSeek: (_) async {},
|
||||||
|
onLongPressCenter: () {},
|
||||||
|
onBoostStart: () {},
|
||||||
|
onBoostEnd: () {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpFullPlayerOverlay(WidgetTester tester) async {
|
||||||
|
final controller = ValueNotifier<VideoPlayerValue>(
|
||||||
|
const VideoPlayerValue(
|
||||||
|
duration: Duration(minutes: 2),
|
||||||
|
position: Duration(seconds: 20),
|
||||||
|
isInitialized: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
home: Scaffold(body: _FullPlayerHarness(controller: controller)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
class _Harness extends StatefulWidget {
|
class _Harness extends StatefulWidget {
|
||||||
const _Harness({super.key, required this.controller});
|
const _Harness({super.key, required this.controller});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user