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);
|
||||
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 默认内缩。
|
||||
|
||||
Reference in new issue
Block a user