为什么:客户端此前完全没有账号概念(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 全过。
684 lines
20 KiB
Dart
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|