fix(player): 完善进度预览清屏与下拉刷新

This commit is contained in:
Zhou Weidong committed 2026-10-23 03:18:24 +08:00
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);
bool hasAxis(String? thumbnailsUrl);
Future<void> prepareAt(String? thumbnailsUrl, Duration position);
}
/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。
@@ -153,6 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
List<String> get spriteCacheKeys => _spriteCache.keys.toList();
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
@override
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
final url = _normalizeUrl(thumbnailsUrl);
if (_disposed || url == null) return;
+58 -20
View File
@@ -34,6 +34,7 @@ import 'player_logic.dart';
import 'widgets/home_feed_header.dart';
import 'widgets/home_feed_overlay.dart';
import 'widgets/exit_clear_screen_button.dart';
import 'widgets/feed_pull_refresh.dart';
import 'widgets/player_center_toggle.dart';
import 'widgets/player_more_sheet.dart';
import 'widgets/player_overlay.dart';
@@ -49,6 +50,7 @@ part 'player_view_actions.dart';
const double _boostPlaybackSpeed = 2.0;
const double _playerLoadingSize = 24;
const double _playerLoadingStrokeWidth = 2;
const double _feedRefreshTopGap = 56;
class PlayerPage extends StatefulWidget {
const PlayerPage({
@@ -305,6 +307,16 @@ class _PlayerPageState extends State<PlayerPage>
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) {
if (!mounted) return;
if (_scrubbing == value) return;
@@ -404,26 +416,37 @@ class _PlayerPageState extends State<PlayerPage>
final interactions = context.watch<InteractionController>();
final l10n = AppLocalizations.of(context)!;
final indices = logic.visibleIndices;
final pages = logic.pages;
final content = AnimatedBuilder(
animation: logic.session,
builder: (context, _) => PageView.builder(
controller: pages,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
itemCount: indices.length,
onPageChanged: (position) {
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
if (position >= indices.length - 3) {
final loadMore = widget.onLoadMore;
if (loadMore != null) unawaited(loadMore());
}
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], interactions),
),
builder: (context, _) {
final pages = PageView.builder(
controller: logic.pages,
scrollDirection: Axis.vertical,
allowImplicitScrolling: true,
physics: widget.onRefreshFeed == null
? null
: const FeedRefreshPhysics(),
itemCount: indices.length,
onPageChanged: (position) {
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
if (position >= indices.length - 3) {
final loadMore = widget.onLoadMore;
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(
fit: StackFit.expand,
@@ -504,9 +527,17 @@ class _PlayerPageState extends State<PlayerPage>
final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length
? logic.sessionEpisodes[index]
: entry.episode;
final episode = widget.homeMode
final displayed = widget.homeMode
? logic.displayEpisode(drama, 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;
// Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder.
@@ -702,6 +733,7 @@ class _PlayerPageState extends State<PlayerPage>
thumbnailService: _thumbnailService,
spriteAxisService: _spriteAxisService,
),
scrubbing: _scrubbing,
actions: PlayerOverlayActions(
onBack: widget.embedded
? null
@@ -719,7 +751,13 @@ class _PlayerPageState extends State<PlayerPage>
onSave: () => unawaited(_toggleSave(context, drama)),
onShare: () => logic.shareDrama(context, drama),
onMore: () => _openMore(context, episode),
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onScrubStart: ready
? () {
_setScrubbing(true);
logic.beginScrub(videoPort!);
}
: null,
onScrubEnd: () => _setScrubbing(false),
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: 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 'player_double_tap_hearts.dart';
import 'seek_progress_bar.dart';
import 'scrub_preview_card.dart';
part 'home_feed_overlay_parts.dart';
@@ -207,6 +208,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
height: _progressHeight,
hitHeight: _progressHitHeight,
cancelScrubRequest: widget.cancelScrubRequest,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
posterUrl: widget.drama.posterUrl,
onScrubStart: _startScrub,
onScrubEnd: _endScrub,
onSeek: widget.onSeek,
@@ -19,6 +19,11 @@ class _FeedProgressBar extends StatefulWidget {
required this.height,
required this.hitHeight,
this.cancelScrubRequest = 0,
this.thumbnailService,
this.spriteAxisService,
this.thumbnailsUrl,
this.episodeId,
this.posterUrl = '',
this.onScrubStart,
this.onScrubEnd,
this.onSeek,
@@ -29,6 +34,11 @@ class _FeedProgressBar extends StatefulWidget {
final double height;
final double hitHeight;
final int cancelScrubRequest;
final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisSource? spriteAxisService;
final String? thumbnailsUrl;
final String? episodeId;
final String posterUrl;
final VoidCallback? onScrubStart;
final VoidCallback? onScrubEnd;
final ValueChanged<Duration>? onSeek;
@@ -142,7 +152,26 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
clipBehavior: Clip.none,
children: [
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(
left: _timeBubbleLeft(width, _preview!),
bottom: _lineCenterFromBottom + 16,
@@ -11,7 +11,6 @@ import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart';
const double _headlineFont = 26;
const double _seriesFont = 19;
const double _tagFont = 13;
const double _synopsisFont = 13;
@@ -49,34 +48,12 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
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 genres = _genreValues();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
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(
key: const Key('home-feed-series-name'),
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) {
return Row(
key: const Key('home-feed-tags'),
@@ -213,21 +173,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
);
}
if (_synopsisExpanded) {
return Column(
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 _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n);
}
return Stack(
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}) {
return GestureDetector(
key: key,
+121 -88
View File
@@ -1,7 +1,12 @@
// 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/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
@@ -17,6 +22,7 @@ const double _topButtonSize = 36;
const double _topBarHeight = 58;
const double _boostHintIconSize = 16;
const double _boostHintFontSize = 13;
const double _synopsisRailInset = 54;
const List<Shadow> _playerTextShadows = [
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
];
@@ -29,6 +35,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
required this.data,
required this.actions,
required this.controlsVisible,
required this.scrubbing,
});
/// 展示数据。
@@ -40,6 +47,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
/// 控制层是否可见。
final bool controlsVisible;
/// 正在拖动进度条。拖动期间清屏,只留进度条。
final bool scrubbing;
@override
Widget build(BuildContext context) {
return SafeArea(
@@ -61,7 +71,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
_top(context),
_bottom(context),
if (controlsVisible) _rail(context),
if (!data.playing)
if (!data.playing && !scrubbing)
Center(
child: PlayerCenterToggle(
key: const Key('player-play-toggle'),
@@ -79,49 +89,55 @@ class PlayerPlaybackOverlay extends StatelessWidget {
top: 0,
left: 0,
right: 0,
child: Container(
key: const Key('player-controls-top'),
height: _topBarHeight,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: 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 SizedBox(width: 8),
Expanded(
child: Text(
data.l10n.episodeLabel(data.episode.number),
key: const Key('player-episode-heading'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 15,
fontWeight: FontWeight.w700,
shadows: _playerTextShadows,
child: IgnorePointer(
ignoring: scrubbing,
child: Opacity(
opacity: scrubbing ? 0 : 1,
child: Container(
key: const Key('player-controls-top'),
height: _topBarHeight,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
child: 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 SizedBox(width: 8),
Expanded(
child: Text(
data.l10n.episodeLabel(data.episode.number),
key: const Key('player-episode-heading'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
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(
right: AppDimens.spaceMd,
top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43),
child: InteractionRail(
key: const Key('player-interaction-rail'),
items: [
InteractionRailItem(
key: const Key('player-rail-save'),
asset: 'assets/icon/rail/rail_save.png',
label: data.l10n.favorite,
count: data.favoriteCount,
countKey: const Key('player-rail-save-count'),
selected: data.saved,
onTap: actions.onSave,
child: IgnorePointer(
ignoring: scrubbing,
child: Opacity(
opacity: scrubbing ? 0 : 1,
child: InteractionRail(
key: const Key('player-interaction-rail'),
items: [
InteractionRailItem(
key: const Key('player-rail-save'),
asset: 'assets/icon/rail/rail_save.png',
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(
mainAxisSize: MainAxisSize.min,
children: [
if (controlsVisible) _bottomInfo(context),
Offstage(
offstage: !controlsVisible || scrubbing,
child: _bottomInfo(context),
),
if (data.boosting) _boostHint(),
_bottomProgress(),
// 加速态只保留进度条与提示,便于全屏快进时看清画面。
if (!data.boosting) _bottomActions(),
// 加速或拖动进度时只留进度条,避免挡住画面。
Offstage(
offstage: data.boosting || scrubbing,
child: _bottomActions(),
),
],
),
);
@@ -243,12 +271,16 @@ class PlayerPlaybackOverlay extends StatelessWidget {
children: [
_detailRow(series),
const SizedBox(height: 4),
_EpisodeSynopsis(
key: const Key('player-episode-synopsis'),
l10n: data.l10n,
episodeNumber: data.episode.number,
synopsis: synopsis,
authorNote: data.authorNote,
Padding(
padding: const EdgeInsets.only(right: _synopsisRailInset),
child: _EpisodeSynopsis(
key: const Key('player-episode-synopsis'),
l10n: data.l10n,
drama: data.drama,
episodeNumber: data.episode.number,
synopsis: synopsis,
authorNote: data.authorNote,
),
),
],
),
@@ -273,6 +305,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
posterUrl: data.drama.posterUrl,
inactiveTrackColor: AppColors.inactiveTrack,
onScrubStart: actions.onScrubStart,
onScrubEnd: actions.onScrubEnd,
onSeek: actions.onSeek,
),
);
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:video_player/video_player.dart';
@@ -40,8 +41,8 @@ class PlayerOverlayData {
/// 当前剧集。
final Episode episode;
/// 当前播放器。
final VideoPlayerController? controller;
/// 当前播放器进度。
final ValueListenable<VideoPlayerValue>? controller;
/// 是否已点赞。
final bool liked;
@@ -98,6 +99,7 @@ class PlayerOverlayActions {
required this.onShare,
required this.onMore,
required this.onScrubStart,
required this.onScrubEnd,
required this.onSeek,
required this.onLongPressCenter,
required this.onBoostStart,
@@ -143,6 +145,9 @@ class PlayerOverlayActions {
/// 开始拖动进度。
final VoidCallback? onScrubStart;
/// 结束拖动进度。
final VoidCallback? onScrubEnd;
/// 提交进度跳转。
final Future<void> Function(Duration)? onSeek;
@@ -109,12 +109,14 @@ class _EpisodeSynopsis extends StatefulWidget {
const _EpisodeSynopsis({
super.key,
required this.l10n,
required this.drama,
required this.episodeNumber,
required this.synopsis,
required this.authorNote,
});
final AppLocalizations l10n;
final Drama drama;
final int episodeNumber;
final String synopsis;
final String authorNote;
@@ -125,6 +127,9 @@ class _EpisodeSynopsis extends StatefulWidget {
class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
bool _expanded = false;
String? _fullSynopsis;
int _detailRequest = 0;
bool _detailRequested = false;
/// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。
static const TextStyle _lineStyle = TextStyle(
@@ -134,53 +139,114 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
height: 1.35,
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
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
return LayoutBuilder(
builder: (context, constraints) => _block(constraints.maxWidth),
);
}
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: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
widget.l10n.episodeLabel(widget.episodeNumber),
key: const Key('player-episode-label'),
maxLines: 1,
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),
],
text,
Positioned(
left: caret.dx,
top: caret.dy,
child: Padding(
key: const Key('player-synopsis-gap'),
padding: const EdgeInsets.only(left: _expandGap),
child: _toggle(link),
),
),
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) ...[
const SizedBox(height: 4),
Text(
widget.authorNote,
key: const Key('player-author-note'),
maxLines: 2,
maxLines: _collapsedMaxLines,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
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) {
return InkWell(
key: const Key('player-synopsis-toggle'),
onTap: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(4),
child: Padding(
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,
),
),
),
child: Text(label, maxLines: 1, style: _linkStyle),
);
}
}
@@ -11,8 +11,9 @@ import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
const double scrubPreviewWidth = 120;
const double scrubPreviewWidth = 96;
const double scrubPreviewSafeMargin = 12;
const double _portraitFrameAspect = 9 / 16;
/// 把预览卡片限制在进度条可用宽度内。
double scrubPreviewLeft({
@@ -64,6 +65,7 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
void initState() {
super.initState();
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
_prepareSprite();
_syncFallbackSource();
}
@@ -74,6 +76,10 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged);
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
}
if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl ||
oldWidget.position != widget.position) {
_prepareSprite();
}
if (oldWidget.service != widget.service ||
oldWidget.episodeId != widget.episodeId ||
oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
@@ -97,6 +103,12 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
super.dispose();
}
void _prepareSprite() {
final service = widget.spriteAxisService;
if (service == null) return;
unawaited(service.prepareAt(widget.thumbnailsUrl, widget.position));
}
void _handleSpriteAxisChanged() {
if (!mounted) return;
if (_spriteFrame != null) {
@@ -190,7 +202,8 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
),
),
AspectRatio(
aspectRatio: 16 / 9,
key: const Key('scrub-preview-frame-box'),
aspectRatio: _frameAspect(spriteFrame),
child: Stack(
fit: StackFit.expand,
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 裁剪,不触发再次解码。
class SpriteCropPreview extends StatelessWidget {
const SpriteCropPreview({
+20 -15
View File
@@ -170,11 +170,28 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
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 {
final startedAt = _dragStartFraction;
_dragStartFraction = null;
if (widget.locked || !_dragging || startedAt == null) return;
setState(() => _dragging = false);
_dragging = false;
if (!mounted) {
widget.onScrubEnd?.call();
return;
}
setState(() {});
_pointerMoved = false;
_pointerStart = null;
widget.thumbnailService?.cancelPending();
@@ -287,20 +304,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
_pointerMoved = true;
}
},
onPointerUp: (_) {
if (!_dragging) {
_pointerMoved = false;
_pointerStart = null;
_dragStartFraction = null;
}
},
onPointerCancel: (_) {
if (!_dragging) {
_pointerMoved = false;
_pointerStart = null;
_dragStartFraction = null;
}
},
onPointerUp: (_) => _endPointerDrag(),
onPointerCancel: (_) => _endPointerDrag(),
child: Slider(
value: fraction,
// 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。