Files
ReelShort/lib/pages/player/player_view.dart
T
Vicky e1b98df509 feat(client): C 端 1-2 账号链路(邮箱登录/注册 + 令牌与 401 + 匿名/登录分叉)
为什么:客户端此前完全没有账号概念(mine 页的"登录"只是本地 demo 开关),
而受限动作(收藏/评论)要对到具体账号。本批把账号链路接上,为后续互动、进度同步、下载打底。

- AuthApi + AuthController + AuthSessionStore:注册/登录/登出/刷新,令牌与设备号存 shared_preferences
  (用 SharedPreferencesAsync,与 engagement 仓库同一套 API)
- ApiClient:统一带 Authorization 与 X-Device-Id;401 自动刷新一次并重试,刷新失败清登录态走 onUnauthorized
- 登录/注册页(pages/auth,沿用 binding/logic/view 与现有视觉):邮箱格式、密码 8-64 位含字母数字本地先校验,
  错误提示走 l10n;登录/注册一键切换
- 匿名/登录分叉:首页、发现、详情保持匿名可看;收藏/点赞等受限动作走 ensureSignedIn() 弹底部提示引导登录
- mine 页展示真实账号(昵称 + uid),拿不到才回落到演示文案
- 测试:新增 auth 控制器(注册落盘/401 刷新重试/刷新失败清会话/登出)与登录页(校验/切换)用例;
  widget_test 改为预置本地会话(账号不再是本地开关);修掉 playback 用例对 host 大小写的环境依赖断言

门禁:flutter analyze 0 问题;flutter test 148 全过。
2026-10-15 17:53:35 +08:00

684 lines
20 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:video_player/video_player.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/widgets/auth_gate.dart';
import '../../l10n/app_localizations.dart';
import 'player_binding.dart';
import 'player_logic.dart';
import 'widgets/seek_progress_bar.dart';
class PlayerPage extends StatefulWidget {
const PlayerPage({
super.key,
required this.drama,
required this.initialIndex,
this.homeMode = false,
this.embedded = false,
this.active = true,
this.resumeRequest = 0,
this.backRequest = 0,
this.onDiscover,
this.onMy,
this.onSearch,
this.onMenu,
this.feedDramas,
this.onDramaChanged,
this.onImmersiveChanged,
});
final Drama drama;
final int initialIndex;
final bool homeMode;
final bool embedded;
final bool active;
final int resumeRequest;
final int backRequest;
final VoidCallback? onDiscover;
final VoidCallback? onMy;
final VoidCallback? onSearch;
final VoidCallback? onMenu;
final List<Drama>? feedDramas;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
late final PlayerLogic logic;
@override
void initState() {
super.initState();
logic = PlayerBinding().create();
logic.attach(widget, isMounted: () => mounted);
WidgetsBinding.instance.addObserver(this);
logic.start(
factory: context.read<PlayerFactory>(),
coordinator: context.read<PlaybackCoordinator>(),
);
}
@override
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted);
logic.onPageUpdated(oldWidget);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
logic.onLifecycle(state);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
logic.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: logic,
builder: (context, _) => _buildPlayer(context),
);
}
Widget _buildPlayer(BuildContext context) {
final engagement = context.watch<EngagementController>();
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));
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], engagement),
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: ThemeData.dark(useMaterial3: true),
child: widget.embedded
? content
: Scaffold(backgroundColor: Colors.black, body: content),
),
);
}
Widget _buildEpisode(
BuildContext context,
int index,
EngagementController engagement,
) {
final l10n = AppLocalizations.of(context)!;
final entry = logic.queue.entryAt(index);
final drama = entry.drama;
final episode = widget.homeMode
? logic.displayEpisode(drama, entry.episode)
: entry.episode;
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.
final port =
(selected ? logic.session.active : null) ??
logic.session.preparedPlayerAt(index);
final videoPort = port is VideoPlayerPort ? port : null;
final ready = videoPort?.controller.value.isInitialized ?? false;
final theater = logic.fullscreen || logic.pip || logic.pipPreparing;
Widget? video;
if (ready) {
video = GestureDetector(
onTap: () =>
logic.session.setUserWantsPlay(!logic.session.userWantsPlay),
child: FittedBox(
fit: theater ? BoxFit.contain : BoxFit.cover,
child: SizedBox(
width: videoPort!.controller.value.size.width,
height: videoPort.controller.value.size.height,
child: VideoPlayer(
videoPort.controller,
key: ValueKey(videoPort.controller),
),
),
),
);
}
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
?video,
if (!logic.chromeHidden)
const IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x66000000),
Color(0x00000000),
Color(0x33000000),
Color(0xE6000000),
],
stops: [0, 0.16, 0.55, 1],
),
),
),
),
if (selected && logic.session.loading)
const Center(
child: CircularProgressIndicator(color: Color(0xFF2F7BFF)),
),
if (selected && !ready && logic.session.error != null)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline_rounded,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
l10n.playbackError,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
// 锁定集与还没上传片源的剧集都会走到这里,之前是一片纯黑,看不出原因。
if (selected &&
!ready &&
logic.session.error == null &&
logic.session.isLocked)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.movie_filter_outlined,
color: Colors.white70,
size: 36,
),
const SizedBox(height: 12),
Text(
episode.locked
? l10n.lockedTitle(episode.number)
: l10n.noMediaNotice,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
if (selected && ready && !logic.session.userWantsPlay && !logic.pip)
const Center(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: Color(0x73000000),
shape: BoxShape.circle,
),
child: Padding(
padding: EdgeInsets.all(10),
child: Icon(
Icons.play_arrow_rounded,
size: 48,
color: Colors.white,
),
),
),
),
),
if (logic.pip || logic.pipPreparing)
const SizedBox.expand()
else if (logic.fullscreen)
_FullscreenControls(
l10n: l10n,
controller: ready ? videoPort!.controller : null,
onClose: () => unawaited(logic.setFullscreen(false)),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
)
else if (!logic.screenCleared)
_PlaybackOverlay(
l10n: l10n,
drama: drama,
episode: episode,
controller: ready ? videoPort!.controller : null,
liked: engagement.isLiked(drama.id),
saved: engagement.isSaved(drama.id),
onBack: widget.embedded
? null
: () => unawaited(logic.leave(context)),
onDiscover: () => logic.showDiscover(context),
onEpisodes: () => logic.showEpisodes(context),
onMy: () => logic.showMy(context),
// 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览)
onLike: () async {
if (await ensureSignedIn(context)) {
unawaited(engagement.toggleLiked(drama.id));
}
},
onSave: () async {
if (await ensureSignedIn(context)) {
unawaited(engagement.toggleSaved(drama.id));
}
},
onComments: () => logic.showComments(context, drama),
onShare: () => logic.shareDrama(context, drama),
onClear: () => logic.setScreenCleared(true),
onPip: logic.pictureInPicture.isPlatformSupported
? () => unawaited(logic.enterPip(context))
: null,
onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null,
onSeek: ready
? (position) => logic.seekTo(context, videoPort!, position)
: null,
showBottomNav: false,
),
if (logic.screenCleared)
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.only(top: 4, right: 8),
child: IconButton(
tooltip: l10n.exitClearScreen,
onPressed: () => logic.setScreenCleared(false),
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
),
icon: const Icon(Icons.close_fullscreen_rounded),
),
),
),
),
],
);
}
}
class _FullscreenControls extends StatelessWidget {
const _FullscreenControls({
required this.l10n,
required this.controller,
required this.onClose,
required this.onPip,
required this.onScrubStart,
required this.onSeek,
});
final AppLocalizations l10n;
final VideoPlayerController? controller;
final VoidCallback onClose;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
@override
Widget build(BuildContext context) => SafeArea(
child: Column(
children: [
Row(
children: [
IconButton(
tooltip: l10n.exitFullscreen,
onPressed: onClose,
icon: const Icon(Icons.close_fullscreen_rounded),
),
const Spacer(),
if (onPip != null)
IconButton(
tooltip: l10n.pictureInPicture,
onPressed: onPip,
icon: const Icon(Icons.picture_in_picture_alt_outlined),
),
],
),
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
onScrubStart: onScrubStart,
onSeek: onSeek,
),
),
],
),
);
}
class _PlaybackOverlay extends StatelessWidget {
const _PlaybackOverlay({
required this.l10n,
required this.drama,
required this.episode,
required this.controller,
required this.liked,
required this.saved,
required this.onBack,
required this.onDiscover,
required this.onEpisodes,
required this.onMy,
required this.onLike,
required this.onSave,
required this.onComments,
required this.onShare,
required this.onClear,
required this.onPip,
required this.onScrubStart,
required this.onSeek,
required this.showBottomNav,
});
final AppLocalizations l10n;
final Drama drama;
final Episode episode;
final VideoPlayerController? controller;
final bool liked;
final bool saved;
final VoidCallback? onBack;
final VoidCallback onDiscover;
final VoidCallback onEpisodes;
final VoidCallback onMy;
final VoidCallback onLike;
final VoidCallback onSave;
final VoidCallback onComments;
final VoidCallback onShare;
final VoidCallback onClear;
final VoidCallback? onPip;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showBottomNav;
@override
Widget build(BuildContext context) => SafeArea(
bottom: showBottomNav,
child: Stack(
children: [
Column(
children: [
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 8, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(child: _info()),
_rail(),
],
),
),
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
20 + MediaQuery.paddingOf(context).bottom,
),
child: SeekProgressBar(
valueListenable: controller,
semanticsLabel: l10n.seekVideo,
onScrubStart: onScrubStart,
onSeek: onSeek,
),
),
if (showBottomNav) _fallbackNav(),
],
),
if (onBack != null)
Positioned(
top: 4,
left: 8,
child: IconButton(
tooltip: l10n.backToSeries,
onPressed: onBack,
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
),
icon: const Icon(Icons.arrow_back_rounded),
),
),
Positioned(
top: 4,
right: 8,
child: IconButton(
tooltip: l10n.clearScreen,
onPressed: onClear,
style: IconButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color(0x59000000),
),
icon: const Icon(Icons.fit_screen_outlined),
),
),
],
),
);
Widget _info() => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
height: 1.15,
shadows: [Shadow(blurRadius: 12, color: Colors.black)],
),
),
const SizedBox(height: 6),
Text(
'${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
],
);
Widget _rail() => Column(
mainAxisSize: MainAxisSize.min,
children: [
_RailAction(
key: const Key('rail-like'),
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
label: l10n.like,
selected: liked,
onTap: onLike,
),
_RailAction(
key: const Key('rail-save'),
icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
label: l10n.save,
selected: saved,
onTap: onSave,
),
_RailAction(
icon: Icons.grid_view_rounded,
label: l10n.episodes,
onTap: onEpisodes,
),
_RailAction(
key: const Key('rail-comments'),
icon: Icons.mode_comment_outlined,
label: l10n.comments,
onTap: onComments,
),
_RailAction(
key: const Key('rail-share'),
icon: Icons.share_rounded,
label: l10n.share,
onTap: onShare,
),
if (onPip != null)
IconButton(
tooltip: l10n.pictureInPicture,
onPressed: onPip,
visualDensity: VisualDensity.compact,
iconSize: 20,
color: Colors.white,
icon: const Icon(Icons.picture_in_picture_alt_outlined),
),
],
);
Widget _fallbackNav() => Container(
height: 62,
color: const Color(0xF20C0C0F),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_BottomAction(
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
selected: true,
onTap: () {},
),
_BottomAction(
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: onDiscover,
),
_BottomAction(
icon: Icons.video_library_outlined,
label: l10n.myTab,
onTap: onMy,
),
],
),
);
}
class _RailAction extends StatelessWidget {
const _RailAction({
super.key,
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback? onTap;
final bool selected;
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: label,
onPressed: onTap,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 42, height: 36),
color: selected ? const Color(0xFF8FB4FF) : Colors.white,
icon: Icon(icon, size: 24),
),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
),
),
],
);
}
class _BottomAction extends StatelessWidget {
const _BottomAction({
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: selected ? const Color(0xFF2F7BFF) : const Color(0xFFF3EEEA),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 11,
color: selected
? const Color(0xFF2F7BFF)
: const Color(0xFFF3EEEA),
),
),
],
),
),
);
}