feat(app): 完善播放协议搜索与空状态
This commit is contained in:
1 parent
671e0ce143
commit
ef29fc5f15
85 files changed
+2976
-768
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -88,6 +89,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
String? blockedEpisodeId;
|
||||
List<Episode> sessionEpisodes = const [];
|
||||
List<String> qualities = const [];
|
||||
List<PlayQualityTrack> qualityTracks = const [];
|
||||
String selectedQuality = 'AUTO';
|
||||
Duration? _qualitySeek;
|
||||
int pageRequest = 0;
|
||||
bool muted = false;
|
||||
double playbackSpeed = 1.0;
|
||||
@@ -426,6 +430,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
blockedTicket = null;
|
||||
blockedEpisodeId = null;
|
||||
qualities = const [];
|
||||
qualityTracks = const [];
|
||||
sessionEpisodes[index] = episode.withVideoUrl(_fileUrl(localPath));
|
||||
return true;
|
||||
}
|
||||
@@ -437,10 +442,18 @@ class PlayerLogic extends ChangeNotifier {
|
||||
blockedTicket = null;
|
||||
blockedEpisodeId = null;
|
||||
qualities = ticket.qualities;
|
||||
if (ticket.accessible && ticket.playUrl.isNotEmpty) {
|
||||
qualityTracks = ticket.tracks;
|
||||
final preferred = qualityPlayUrl(
|
||||
quality: selectedQuality,
|
||||
tracks: qualityTracks,
|
||||
currentUrl: ticket.playUrl,
|
||||
);
|
||||
final playUrl = preferred ?? ticket.playUrl;
|
||||
if (preferred == null) selectedQuality = 'AUTO';
|
||||
if (ticket.accessible && playUrl.isNotEmpty) {
|
||||
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
||||
sessionEpisodes[index] = episode.withVideoUrl(
|
||||
ticket.playUrl,
|
||||
playUrl,
|
||||
likeCount: ticket.likeCount,
|
||||
commentCount: ticket.commentCount,
|
||||
thumbnailsUrl: ticket.thumbnailsUrl,
|
||||
@@ -470,6 +483,64 @@ class PlayerLogic extends ChangeNotifier {
|
||||
String _fileUrl(String path) =>
|
||||
path.startsWith('file:') ? path : Uri.file(path).toString();
|
||||
|
||||
/// 切到指定清晰度。新片源出画后再替换,失败则继续播原来的。
|
||||
Future<bool> selectQuality(String quality) async {
|
||||
final index = session.currentIndex;
|
||||
if (index < 0 || index >= sessionEpisodes.length) return false;
|
||||
final current = sessionEpisodes[index];
|
||||
final url = qualityPlayUrl(
|
||||
quality: quality,
|
||||
tracks: qualityTracks,
|
||||
currentUrl: current.videoUrl,
|
||||
);
|
||||
if (url == null || url.isEmpty) return false;
|
||||
if (selectedQuality.toUpperCase() == quality.toUpperCase() &&
|
||||
current.videoUrl == url) {
|
||||
return true;
|
||||
}
|
||||
final previousQuality = selectedQuality;
|
||||
final active = session.active;
|
||||
if (active is VideoPlayerPort && active.controller.value.isInitialized) {
|
||||
_qualitySeek = active.controller.value.position;
|
||||
}
|
||||
selectedQuality = quality.toUpperCase();
|
||||
sessionEpisodes[index] = current.withVideoUrl(url);
|
||||
final switched = await session.replaceCurrent();
|
||||
if (switched) {
|
||||
if (isMounted()) notify();
|
||||
return true;
|
||||
}
|
||||
_qualitySeek = null;
|
||||
selectedQuality = previousQuality;
|
||||
if (index < sessionEpisodes.length) {
|
||||
sessionEpisodes[index] = current;
|
||||
}
|
||||
if (isMounted()) notify();
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _waitUntilFrameReady(VideoPlayerController controller) async {
|
||||
if (controller.value.hasError) {
|
||||
throw StateError('quality source failed');
|
||||
}
|
||||
if (!controller.value.isBuffering) return;
|
||||
final done = Completer<void>();
|
||||
void onChange() {
|
||||
final value = controller.value;
|
||||
if (done.isCompleted) return;
|
||||
if (value.hasError || !value.isBuffering) done.complete();
|
||||
}
|
||||
controller.addListener(onChange);
|
||||
try {
|
||||
await done.future.timeout(const Duration(seconds: 4));
|
||||
} finally {
|
||||
controller.removeListener(onChange);
|
||||
}
|
||||
if (controller.value.hasError) {
|
||||
throw StateError('quality source failed');
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试当前集的票据(失败态的"重试"按钮)。
|
||||
Future<void> retryTicket() async {
|
||||
ticketError = null;
|
||||
@@ -667,6 +738,15 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (player is! VideoPlayerPort) return;
|
||||
try {
|
||||
await player.controller.setPlaybackSpeed(playbackSpeed);
|
||||
final override = _qualitySeek;
|
||||
_qualitySeek = null;
|
||||
if (override != null && override > Duration.zero) {
|
||||
await player.seekTo(
|
||||
clampResumePosition(override, player.controller.value.duration),
|
||||
);
|
||||
await _waitUntilFrameReady(player.controller);
|
||||
return;
|
||||
}
|
||||
await pendingWrite;
|
||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||
if (resumePoint == null) await loadResumePoint();
|
||||
@@ -689,7 +769,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// 续看位置取不到不影响播放:从头播。
|
||||
// 续看或清晰度定位失败时仍用已初始化的播放器,不把切换判失败。
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -363,7 +363,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final lastDoubleTapAt = _lastDoubleTapAt;
|
||||
return lastDoubleTapAt != null &&
|
||||
DateTime.now().difference(lastDoubleTapAt) <
|
||||
const Duration(milliseconds: 350);
|
||||
const Duration(milliseconds: 500);
|
||||
}
|
||||
|
||||
void _handleDoubleTapLike(
|
||||
@@ -545,14 +545,18 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
(selected ? logic.session.active : null) ??
|
||||
logic.session.preparedPlayerAt(index);
|
||||
final videoPort = port is VideoPlayerPort ? port : null;
|
||||
final ready = videoPort?.controller.value.isInitialized ?? false;
|
||||
final handoff = selected ? logic.session.handoffAt(index) : null;
|
||||
final handoffPort = handoff is VideoPlayerPort ? handoff : null;
|
||||
// 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。
|
||||
final videoReady = videoPort?.controller.value.isInitialized ?? false;
|
||||
final ready = videoReady || port != null;
|
||||
final theater = logic.pip || logic.pipPreparing;
|
||||
final locked = logic.session.isLocked || episode.locked;
|
||||
if (selected && ready) {
|
||||
if (selected && videoPort != null && videoPort.controller.value.isInitialized) {
|
||||
unawaited(
|
||||
_spriteAxisService.prepareAt(
|
||||
episode.thumbnailsUrl,
|
||||
videoPort!.controller.value.position,
|
||||
videoPort.controller.value.position,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -567,11 +571,30 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
),
|
||||
PlayerVideoSurface(
|
||||
controller: videoPort?.controller,
|
||||
handoff: handoffPort?.controller,
|
||||
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
|
||||
theater: theater,
|
||||
),
|
||||
if (selected &&
|
||||
!theater &&
|
||||
!ready &&
|
||||
!logic.ticketLoading &&
|
||||
logic.ticketError == null &&
|
||||
logic.session.error == null &&
|
||||
logic.blockedEpisodeId != episode.id &&
|
||||
!logic.session.isLocked)
|
||||
const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: _playerLoadingSize,
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('player-video-loading'),
|
||||
color: AppColors.blue,
|
||||
strokeWidth: _playerLoadingStrokeWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 票据三态之一:起播前正在向后端要签名地址
|
||||
if (selected && logic.ticketLoading)
|
||||
if (selected && !theater && logic.ticketLoading)
|
||||
const Center(
|
||||
child: SizedBox.square(
|
||||
dimension: _playerLoadingSize,
|
||||
@@ -583,7 +606,10 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
),
|
||||
),
|
||||
// 票据三态之二:拿票失败(断网/服务异常)→ 可重试,不假装在加载
|
||||
if (selected && !logic.ticketLoading && logic.ticketError != null)
|
||||
if (selected &&
|
||||
!theater &&
|
||||
!logic.ticketLoading &&
|
||||
logic.ticketError != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||||
@@ -610,7 +636,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected && !ready && logic.session.error != null)
|
||||
if (selected && !theater && !ready && logic.session.error != null)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||||
@@ -634,6 +660,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
),
|
||||
// 没有票据信息时的兜底(例如详情里就没地址、也没走票据流程)
|
||||
if (selected &&
|
||||
!theater &&
|
||||
!ready &&
|
||||
logic.blockedEpisodeId != episode.id &&
|
||||
logic.session.error == null &&
|
||||
@@ -668,7 +695,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
controller: videoReady ? videoPort!.controller : null,
|
||||
locked: locked,
|
||||
visible: _chromeVisible,
|
||||
boosting: _boosting,
|
||||
@@ -699,11 +726,11 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
},
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onMore: _openCurrentMore,
|
||||
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
|
||||
onScrubStart: videoReady ? () => logic.beginScrub(videoPort!) : null,
|
||||
onScrubEnd: _finishFeedScrub,
|
||||
onScrubVisibilityChanged: _setScrubbing,
|
||||
cancelScrubRequest: widget.scrubCancelRequest,
|
||||
onSeek: ready
|
||||
onSeek: videoReady
|
||||
? (position) => logic.endScrub(context, videoPort!, position)
|
||||
: null,
|
||||
thumbnailService: _thumbnailService,
|
||||
@@ -715,7 +742,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
controller: videoReady ? videoPort!.controller : null,
|
||||
locked: locked,
|
||||
liked: interactions.isEpisodeLiked(episode.id),
|
||||
likeCount:
|
||||
@@ -751,14 +778,14 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onSave: () => unawaited(_toggleSave(context, drama)),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onMore: () => _openMore(context, episode),
|
||||
onScrubStart: ready
|
||||
onScrubStart: videoReady
|
||||
? () {
|
||||
_setScrubbing(true);
|
||||
logic.beginScrub(videoPort!);
|
||||
}
|
||||
: null,
|
||||
onScrubEnd: () => _setScrubbing(false),
|
||||
onSeek: ready
|
||||
onSeek: videoReady
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
onLongPressCenter: () => _openMore(context, episode),
|
||||
@@ -768,7 +795,10 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
controlsVisible: _chromeVisible,
|
||||
),
|
||||
// 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜
|
||||
if (selected && !ready && logic.blockedEpisodeId == episode.id)
|
||||
if (selected &&
|
||||
!theater &&
|
||||
!ready &&
|
||||
logic.blockedEpisodeId == episode.id)
|
||||
PlayerBlockedNotice(
|
||||
l10n: l10n,
|
||||
episode: episode,
|
||||
|
||||
@@ -27,12 +27,14 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
state: PlayerMoreState(
|
||||
speed: _playbackSpeed,
|
||||
qualities: logic.qualities,
|
||||
selectedQuality: logic.selectedQuality,
|
||||
),
|
||||
actions: PlayerMoreActions(
|
||||
onSpeedChanged: (value) {
|
||||
_updatePlaybackSpeed(value);
|
||||
unawaited(logic.setPlaybackSpeed(value));
|
||||
},
|
||||
onQualityChanged: (quality) => _switchQuality(context, l10n, quality),
|
||||
onClear: _hideChrome,
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onPip: logic.pictureInPicture.isPlatformSupported
|
||||
@@ -43,6 +45,19 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _switchQuality(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
String quality,
|
||||
) async {
|
||||
Navigator.of(context).pop();
|
||||
final switched = await logic.selectQuality(quality);
|
||||
if (!context.mounted || !switched) return false;
|
||||
final label = quality.toUpperCase() == 'AUTO' ? l10n.qualityAuto : quality;
|
||||
showCenteredToast(context, l10n.qualitySwitched(label));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _openUnlockSheet(
|
||||
BuildContext context,
|
||||
int index,
|
||||
|
||||
@@ -401,11 +401,12 @@ class _CommentPostSuccessOverlay extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: SafeArea(
|
||||
child: Align(
|
||||
alignment: const Alignment(0, 0.55),
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: Align(
|
||||
alignment: const Alignment(0, -0.4),
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
key: const Key('comment-post-success-snackbar'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -422,6 +423,8 @@ class _CommentPostSuccessOverlay extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 14,
|
||||
decoration: TextDecoration.none,
|
||||
decorationColor: AppColors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -110,12 +110,16 @@ class HomeFeedOverlay extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
static const Duration _singleTapDelay = Duration(milliseconds: 320);
|
||||
static const Duration _singleTapDelay = Duration(milliseconds: 400);
|
||||
|
||||
bool _scrubbing = false;
|
||||
bool _wasVisibleBeforeScrub = true;
|
||||
Timer? _singleTapTimer;
|
||||
DateTime? _lastTapAt;
|
||||
DateTime? _lastDownAt;
|
||||
DateTime? _blockSingleTapUntil;
|
||||
bool _doubleTapDown = false;
|
||||
bool _likedThisPair = false;
|
||||
int _downSerial = 0;
|
||||
int _tapSeq = 0;
|
||||
int _nextHeartBurstId = 0;
|
||||
final List<({int id, Offset position})> _heartBursts = [];
|
||||
@@ -146,6 +150,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
builder: (context, constraints) => Listener(
|
||||
onPointerUp: (_) => widget.onBoostEnd(),
|
||||
onPointerCancel: (_) => widget.onBoostEnd(),
|
||||
onPointerDown: _handlePointerDown,
|
||||
child: GestureDetector(
|
||||
key: const Key('home-feed-gesture'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -258,38 +263,66 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
widget.onScrubEnd?.call(wasVisible);
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
final tapSeq = ++_tapSeq;
|
||||
final now = widget.now?.call() ?? DateTime.now();
|
||||
final lastTap = _lastTapAt;
|
||||
if (lastTap != null && now.difference(lastTap) <= _singleTapDelay) {
|
||||
_tapSeq++;
|
||||
_singleTapTimer?.cancel();
|
||||
_singleTapTimer = null;
|
||||
_lastTapAt = null;
|
||||
_showHearts(details.localPosition);
|
||||
DateTime _now() => widget.now?.call() ?? DateTime.now();
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
final now = _now();
|
||||
final previous = _lastDownAt;
|
||||
_downSerial++;
|
||||
_lastDownAt = now;
|
||||
final repeated =
|
||||
previous != null && now.difference(previous) <= _singleTapDelay;
|
||||
if (!repeated) {
|
||||
if (_singleTapBlocked(now)) return;
|
||||
_doubleTapDown = false;
|
||||
_likedThisPair = false;
|
||||
return;
|
||||
}
|
||||
_lastTapAt = now;
|
||||
_doubleTapDown = true;
|
||||
_blockSingleTapUntil = now.add(_singleTapDelay);
|
||||
_cancelPendingSingleTap();
|
||||
}
|
||||
|
||||
bool _singleTapBlocked(DateTime now) {
|
||||
final blockedUntil = _blockSingleTapUntil;
|
||||
return blockedUntil != null && now.isBefore(blockedUntil);
|
||||
}
|
||||
|
||||
void _cancelPendingSingleTap() {
|
||||
_tapSeq++;
|
||||
_singleTapTimer?.cancel();
|
||||
_singleTapTimer = null;
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
final now = _now();
|
||||
if (_doubleTapDown || _singleTapBlocked(now)) {
|
||||
_showDoubleTapLike(details.localPosition);
|
||||
return;
|
||||
}
|
||||
final serial = _downSerial;
|
||||
final tapSeq = ++_tapSeq;
|
||||
_singleTapTimer?.cancel();
|
||||
late final Timer pending;
|
||||
pending = Timer(_singleTapDelay, () {
|
||||
if (identical(_singleTapTimer, pending)) {
|
||||
_singleTapTimer = null;
|
||||
}
|
||||
if (tapSeq != _tapSeq) return;
|
||||
final latestTap = _lastTapAt;
|
||||
if (latestTap != null &&
|
||||
(widget.now?.call() ?? DateTime.now()).difference(latestTap) <
|
||||
_singleTapDelay) {
|
||||
return;
|
||||
}
|
||||
_lastTapAt = null;
|
||||
if (mounted) widget.onTogglePlay();
|
||||
if (!identical(_singleTapTimer, pending)) return;
|
||||
_singleTapTimer = null;
|
||||
if (!mounted || tapSeq != _tapSeq || serial != _downSerial) return;
|
||||
if (_doubleTapDown || _singleTapBlocked(_now())) return;
|
||||
_lastDownAt = null;
|
||||
widget.onTogglePlay();
|
||||
});
|
||||
_singleTapTimer = pending;
|
||||
}
|
||||
|
||||
void _showDoubleTapLike(Offset position) {
|
||||
if (_likedThisPair) return;
|
||||
_likedThisPair = true;
|
||||
_blockSingleTapUntil = _now().add(_singleTapDelay);
|
||||
_cancelPendingSingleTap();
|
||||
_showHearts(position);
|
||||
}
|
||||
|
||||
void _showHearts(Offset position) {
|
||||
final id = _nextHeartBurstId++;
|
||||
setState(() => _heartBursts.add((id: id, position: position)));
|
||||
@@ -302,11 +335,11 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
}
|
||||
|
||||
void _handleLongPressStart(LongPressStartDetails details, double width) {
|
||||
_tapSeq++;
|
||||
_singleTapTimer?.cancel();
|
||||
_singleTapTimer = null;
|
||||
_lastTapAt = null;
|
||||
final third = width / 3;
|
||||
_tapSeq++;
|
||||
_cancelPendingSingleTap();
|
||||
_doubleTapDown = false;
|
||||
_lastDownAt = null;
|
||||
final dx = details.localPosition.dx;
|
||||
if (dx < third || dx > width - third) {
|
||||
widget.onBoostStart();
|
||||
|
||||
@@ -316,9 +316,13 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
final dragging = _preview != null;
|
||||
final trackHeight = dragging ? _trackActiveHeight : widget.height;
|
||||
final size = dragging ? _thumbActiveSize : _thumbSize;
|
||||
final thumbSlotLeft = (width * fraction - _thumbActiveSize / 2)
|
||||
.clamp(0.0, width - _thumbActiveSize)
|
||||
.toDouble();
|
||||
final finger = (width * fraction).clamp(0.0, width).toDouble();
|
||||
final thumbSlotLeft = dragging
|
||||
? finger - _thumbActiveSize / 2
|
||||
: (finger - _thumbActiveSize / 2)
|
||||
.clamp(0.0, width - _thumbActiveSize)
|
||||
.toDouble();
|
||||
final playedWidth = dragging ? finger : width * fraction;
|
||||
final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2;
|
||||
final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2;
|
||||
return SizedBox(
|
||||
@@ -348,9 +352,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedContainer(
|
||||
duration: _trackResizeDuration,
|
||||
key: const Key('home-feed-progress-played'),
|
||||
duration: dragging ? Duration.zero : _trackResizeDuration,
|
||||
curve: Curves.easeOut,
|
||||
width: width * fraction,
|
||||
width: playedWidth,
|
||||
height: trackHeight,
|
||||
color: AppColors.blue,
|
||||
),
|
||||
|
||||
@@ -132,17 +132,19 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.favoriteEpisodeProgress(
|
||||
widget.episode.number,
|
||||
widget.drama.episodeCount,
|
||||
),
|
||||
key: const Key('home-feed-episode-progress'),
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
style: const TextStyle(
|
||||
color: AppColors.overlayLabel,
|
||||
fontSize: _tagFont,
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.favoriteEpisodeProgress(
|
||||
widget.episode.number,
|
||||
widget.drama.episodeCount,
|
||||
),
|
||||
key: const Key('home-feed-episode-progress'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.overlayLabel,
|
||||
fontSize: _tagFont,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,11 +1,39 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
const int _heartDurationMs = 680;
|
||||
const double _heartPopPortion = 0.18;
|
||||
const double _heartStartScale = 0.7;
|
||||
const double _heartHoldPortion = 0.45;
|
||||
|
||||
const List<_HeartParticle> _hearts = [
|
||||
_HeartParticle(
|
||||
delay: 0,
|
||||
travel: 108,
|
||||
drift: 0,
|
||||
size: 48,
|
||||
color: AppColors.doubleTapHeartRed,
|
||||
),
|
||||
_HeartParticle(
|
||||
delay: 50,
|
||||
travel: 96,
|
||||
drift: -28,
|
||||
size: 36,
|
||||
color: AppColors.doubleTapHeartPink,
|
||||
),
|
||||
_HeartParticle(
|
||||
delay: 90,
|
||||
travel: 118,
|
||||
drift: 26,
|
||||
size: 40,
|
||||
color: AppColors.doubleTapHeartRed,
|
||||
),
|
||||
];
|
||||
|
||||
/// 双击点赞时,在点击位置飘出一小簇心。
|
||||
class PlayerDoubleTapHeartBurst extends StatefulWidget {
|
||||
const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted});
|
||||
|
||||
@@ -19,30 +47,14 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget {
|
||||
class _PlayerDoubleTapHeartBurstState extends State<PlayerDoubleTapHeartBurst>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final List<_HeartParticle> _particles;
|
||||
bool _completed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final random = Random();
|
||||
var delay = 0;
|
||||
_particles = List.generate(3 + random.nextInt(2), (index) {
|
||||
if (index > 0) delay += 40 + random.nextInt(21);
|
||||
return _HeartParticle(
|
||||
delay: delay,
|
||||
duration: 600 + random.nextInt(101),
|
||||
travel: 90 + random.nextInt(31),
|
||||
drift: -18 + random.nextInt(37),
|
||||
size: (20 + random.nextInt(7)).toDouble(),
|
||||
color: random.nextBool()
|
||||
? AppColors.doubleTapHeartRed
|
||||
: AppColors.doubleTapHeartPink,
|
||||
);
|
||||
});
|
||||
final total = _particles
|
||||
.map((particle) => particle.delay + particle.duration)
|
||||
.reduce(max);
|
||||
final total = _hearts
|
||||
.map((heart) => heart.delay + _heartDurationMs)
|
||||
.reduce((left, right) => left > right ? left : right);
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: total),
|
||||
@@ -76,7 +88,8 @@ class _PlayerDoubleTapHeartBurstState extends State<PlayerDoubleTapHeartBurst>
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
for (final particle in _particles) _particle(particle, elapsed),
|
||||
for (var index = 0; index < _hearts.length; index++)
|
||||
_heart(_hearts[index], elapsed, index),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -85,27 +98,32 @@ class _PlayerDoubleTapHeartBurstState extends State<PlayerDoubleTapHeartBurst>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _particle(_HeartParticle particle, double elapsed) {
|
||||
final progress = ((elapsed - particle.delay) / particle.duration).clamp(
|
||||
Widget _heart(_HeartParticle heart, double elapsed, int index) {
|
||||
final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
if (progress <= 0) return const SizedBox.shrink();
|
||||
final move = Curves.easeOutCubic.transform(progress);
|
||||
final scale = progress < 0.3
|
||||
? 0.55 + (1.15 - 0.55) * (progress / 0.3)
|
||||
: 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7);
|
||||
final scale = progress < _heartPopPortion
|
||||
? _heartStartScale +
|
||||
(1 - _heartStartScale) * (progress / _heartPopPortion)
|
||||
: 1.0;
|
||||
final opacity = progress < _heartHoldPortion
|
||||
? 1.0
|
||||
: 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion));
|
||||
return Positioned(
|
||||
left: particle.drift * move - particle.size / 2,
|
||||
top: -particle.travel * move,
|
||||
left: heart.drift * move - heart.size / 2,
|
||||
top: -heart.size / 2 - heart.travel * move,
|
||||
child: Opacity(
|
||||
opacity: 1 - Curves.easeIn.transform(progress),
|
||||
opacity: opacity.clamp(0.0, 1.0),
|
||||
child: Transform.scale(
|
||||
scale: scale,
|
||||
child: Icon(
|
||||
key: Key('double-tap-heart-$index'),
|
||||
Icons.favorite_rounded,
|
||||
color: particle.color,
|
||||
size: particle.size,
|
||||
color: heart.color,
|
||||
size: heart.size,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -116,7 +134,6 @@ class _PlayerDoubleTapHeartBurstState extends State<PlayerDoubleTapHeartBurst>
|
||||
class _HeartParticle {
|
||||
const _HeartParticle({
|
||||
required this.delay,
|
||||
required this.duration,
|
||||
required this.travel,
|
||||
required this.drift,
|
||||
required this.size,
|
||||
@@ -124,9 +141,8 @@ class _HeartParticle {
|
||||
});
|
||||
|
||||
final int delay;
|
||||
final int duration;
|
||||
final int travel;
|
||||
final int drift;
|
||||
final double travel;
|
||||
final double drift;
|
||||
final double size;
|
||||
final Color color;
|
||||
}
|
||||
@@ -22,19 +22,25 @@ const double _segmentTrackRadius = 10;
|
||||
const double _segmentTrackPadding = 4;
|
||||
const double _segmentPadding = 10;
|
||||
const List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
|
||||
const List<String> _qualityOptions = ['AUTO', '720P', '1080P'];
|
||||
const String _autoQuality = 'AUTO';
|
||||
const List<String> _qualityOptions = ['AUTO', '360P', '480P', '720P', '1080P'];
|
||||
|
||||
/// 更多面板运行状态。
|
||||
class PlayerMoreState {
|
||||
/// 创建更多面板状态。
|
||||
const PlayerMoreState({required this.speed, required this.qualities});
|
||||
const PlayerMoreState({
|
||||
required this.speed,
|
||||
required this.qualities,
|
||||
this.selectedQuality = 'AUTO',
|
||||
});
|
||||
|
||||
/// 当前倍速。
|
||||
final double speed;
|
||||
|
||||
/// 后端下发的清晰度档位。
|
||||
final List<String> qualities;
|
||||
|
||||
/// 当前选中的档位,如 AUTO / 720P。
|
||||
final String selectedQuality;
|
||||
}
|
||||
|
||||
/// 更多面板动作集合。
|
||||
@@ -44,6 +50,7 @@ class PlayerMoreActions {
|
||||
required this.onSpeedChanged,
|
||||
required this.onClear,
|
||||
required this.onDownload,
|
||||
required this.onQualityChanged,
|
||||
this.onPip,
|
||||
});
|
||||
|
||||
@@ -56,6 +63,9 @@ class PlayerMoreActions {
|
||||
/// 离线缓存当前集。
|
||||
final VoidCallback onDownload;
|
||||
|
||||
/// 切换清晰度。成功返回 true;失败时面板恢复到上一档。
|
||||
final Future<bool> Function(String quality) onQualityChanged;
|
||||
|
||||
/// 进入小窗播放;平台不支持时为 null。
|
||||
final VoidCallback? onPip;
|
||||
}
|
||||
@@ -85,6 +95,7 @@ class PlayerMoreSheet extends StatefulWidget {
|
||||
|
||||
class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
|
||||
late double _speed = widget.state.speed;
|
||||
late String _quality = widget.state.selectedQuality.toUpperCase();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -247,19 +258,31 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _chooseQuality(String quality) async {
|
||||
if (_quality == quality) return;
|
||||
final previous = _quality;
|
||||
setState(() => _quality = quality);
|
||||
final switched = await widget.actions.onQualityChanged(quality);
|
||||
if (!mounted || switched) return;
|
||||
setState(() => _quality = previous);
|
||||
}
|
||||
|
||||
bool _hasQuality(String quality) {
|
||||
final wanted = quality.toUpperCase();
|
||||
for (final item in widget.state.qualities) {
|
||||
if (item.toUpperCase() == wanted) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _qualitySegment(AppLocalizations l10n, String quality) {
|
||||
final enabled = widget.state.qualities.contains(quality);
|
||||
final selected =
|
||||
quality == _autoQuality && enabled ||
|
||||
!widget.state.qualities.contains(_autoQuality) &&
|
||||
widget.state.qualities.isNotEmpty &&
|
||||
widget.state.qualities.first == quality;
|
||||
final enabled = _hasQuality(quality);
|
||||
return _SegmentButton(
|
||||
key: Key('player-more-quality-$quality'),
|
||||
label: enabled ? quality : '$quality · ${l10n.qualityUnavailable}',
|
||||
selected: selected,
|
||||
selected: enabled && _quality == quality,
|
||||
enabled: enabled,
|
||||
onTap: enabled ? () {} : null,
|
||||
onTap: enabled ? () => _chooseQuality(quality) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -220,11 +220,12 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
),
|
||||
if (data.boosting) _boostHint(),
|
||||
_bottomProgress(),
|
||||
// 加速或拖动进度时只留进度条,避免挡住画面。
|
||||
Offstage(
|
||||
offstage: data.boosting || scrubbing,
|
||||
child: _bottomActions(),
|
||||
),
|
||||
// 加速时收起底栏。拖动进度时底栏只隐藏,占位保留,进度条不挪位。
|
||||
scrubbing
|
||||
? IgnorePointer(
|
||||
child: Opacity(opacity: 0, child: _bottomActions()),
|
||||
)
|
||||
: Offstage(offstage: data.boosting, child: _bottomActions()),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
|
||||
@@ -10,16 +10,18 @@ const Duration _videoFadeDuration = Duration(milliseconds: 200);
|
||||
const double _errorHorizontal = 32;
|
||||
const int _errorMaxLines = 3;
|
||||
|
||||
/// 黑色底层与视频层共用同一控制器,视频首帧就绪后再淡入。
|
||||
/// 黑色底层与视频层共用同一控制器。清晰度切换时用仍在播放的旧画面盖住新纹理的黑帧。
|
||||
class PlayerVideoSurface extends StatefulWidget {
|
||||
const PlayerVideoSurface({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.videoUrl,
|
||||
required this.theater,
|
||||
this.handoff,
|
||||
});
|
||||
|
||||
final VideoPlayerController? controller;
|
||||
final VideoPlayerController? handoff;
|
||||
final String videoUrl;
|
||||
final bool theater;
|
||||
|
||||
@@ -48,7 +50,8 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
||||
|
||||
Widget _frame(VideoPlayerValue? value) {
|
||||
final controller = widget.controller;
|
||||
final reveal = _shouldReveal(value);
|
||||
final handoff = _liveHandoff(controller);
|
||||
final reveal = _shouldReveal(value, handoff != null);
|
||||
_logStateChange(value);
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
@@ -60,7 +63,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
||||
AnimatedOpacity(
|
||||
key: const Key('player-video-opacity'),
|
||||
opacity: reveal ? 1 : 0,
|
||||
duration: _videoFadeDuration,
|
||||
duration: handoff != null ? Duration.zero : _videoFadeDuration,
|
||||
curve: Curves.easeOut,
|
||||
child: reveal && controller != null
|
||||
? _VideoLayer(
|
||||
@@ -70,6 +73,13 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
||||
)
|
||||
: const SizedBox.expand(),
|
||||
),
|
||||
if (handoff != null)
|
||||
_VideoLayer(
|
||||
key: const Key('player-video-handoff'),
|
||||
controller: handoff,
|
||||
value: handoff.value,
|
||||
theater: widget.theater,
|
||||
),
|
||||
if (kDebugMode && value?.hasError == true)
|
||||
Center(
|
||||
child: Padding(
|
||||
@@ -88,12 +98,18 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldReveal(VideoPlayerValue? value) {
|
||||
return value != null &&
|
||||
!value.hasError &&
|
||||
value.isInitialized &&
|
||||
value.size.width > 0 &&
|
||||
(value.position > Duration.zero || value.isPlaying);
|
||||
bool _shouldReveal(VideoPlayerValue? value, bool covered) {
|
||||
if (value == null || value.hasError || !value.isInitialized) return false;
|
||||
if (value.size.width <= 0) return false;
|
||||
if (covered) return true;
|
||||
return value.position > Duration.zero || value.isPlaying;
|
||||
}
|
||||
|
||||
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。
|
||||
VideoPlayerController? _liveHandoff(VideoPlayerController? controller) {
|
||||
final handoff = widget.handoff;
|
||||
if (handoff == null || identical(handoff, controller)) return null;
|
||||
return handoff;
|
||||
}
|
||||
|
||||
void _logStateChange(VideoPlayerValue? value) {
|
||||
@@ -135,6 +151,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
|
||||
|
||||
class _VideoLayer extends StatelessWidget {
|
||||
const _VideoLayer({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.value,
|
||||
required this.theater,
|
||||
|
||||
@@ -13,6 +13,8 @@ import 'loading_progress_track.dart';
|
||||
import 'scrub_preview_card.dart';
|
||||
|
||||
const Key _inactiveTrackKey = Key('progress-inactive');
|
||||
const double _compactTrackSlot = 20;
|
||||
const double _trackSlot = 44;
|
||||
|
||||
/// Keeps seeks within the playable range so dragging to the edge does not
|
||||
/// trigger the player's completion callback before playback resumes.
|
||||
@@ -241,11 +243,7 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
widget.thumbnailService != null &&
|
||||
(widget.episodeId?.isNotEmpty ?? false);
|
||||
final trackHeight = scrubbing ? 6.0 : 2.0;
|
||||
final trackAreaHeight = loading
|
||||
? (widget.compact ? 20.0 : 44.0)
|
||||
: widget.compact
|
||||
? (scrubbing ? 32.0 : 20.0)
|
||||
: (scrubbing ? 48.0 : 44.0);
|
||||
final trackAreaHeight = widget.compact ? _compactTrackSlot : _trackSlot;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
|
||||
Reference in new issue
Block a user