671 lines
20 KiB
Dart
671 lines
20 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:video_player/video_player.dart';
|
|
|
|
import '../../../core/data/models/drama.dart';
|
|
import '../../../core/formatting/localized_content.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/widgets/glass_panel.dart';
|
|
import '../../../core/widgets/remote_image.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
import 'seek_progress_bar.dart';
|
|
|
|
/// Immersive home layer from `screens/01-home.html`.
|
|
///
|
|
/// Playback, ticket, progress, favorite, like, comment, and share callbacks are
|
|
/// all owned by [PlayerPage]; this widget only paints the design overlay.
|
|
class HomeFeedOverlay extends StatelessWidget {
|
|
const HomeFeedOverlay({
|
|
super.key,
|
|
required this.l10n,
|
|
required this.drama,
|
|
required this.episode,
|
|
required this.controller,
|
|
required this.liked,
|
|
required this.saved,
|
|
required this.onLike,
|
|
required this.onSave,
|
|
required this.onComments,
|
|
required this.onShare,
|
|
required this.onEpisodes,
|
|
required this.onDownload,
|
|
required this.onClear,
|
|
required this.onSearch,
|
|
required this.onScrubStart,
|
|
required this.onSeek,
|
|
this.onPip,
|
|
});
|
|
|
|
final AppLocalizations l10n;
|
|
final Drama drama;
|
|
final Episode episode;
|
|
final VideoPlayerController? controller;
|
|
final bool liked;
|
|
final bool saved;
|
|
final VoidCallback onLike;
|
|
final VoidCallback onSave;
|
|
final VoidCallback onComments;
|
|
final VoidCallback onShare;
|
|
final VoidCallback onEpisodes;
|
|
final VoidCallback onDownload;
|
|
final VoidCallback onClear;
|
|
final VoidCallback onSearch;
|
|
final VoidCallback? onPip;
|
|
final VoidCallback? onScrubStart;
|
|
final Future<void> Function(Duration)? onSeek;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final compact = MediaQuery.sizeOf(context).height < 620;
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
children: [
|
|
_header(context),
|
|
Expanded(
|
|
child: Align(
|
|
alignment: Alignment.bottomRight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(0, 0, 12, 4),
|
|
child: _rail(compact),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
|
child: _infoCard(context, compact),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _header(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 6, 12, 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.blue,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 7),
|
|
Flexible(
|
|
child: Text(
|
|
localizedDramaTitle(l10n, drama),
|
|
key: const Key('home-feed-title'),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontFamily: 'Outfit',
|
|
color: AppColors.onSurface,
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.1,
|
|
shadows: [
|
|
Shadow(blurRadius: 10, color: Colors.black87),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()],
|
|
],
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
'${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: Colors.white.withValues(alpha: 0.64),
|
|
fontSize: 10.5,
|
|
fontWeight: FontWeight.w500,
|
|
shadows: const [
|
|
Shadow(blurRadius: 8, color: Colors.black87),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
_GlassIconButton(
|
|
tooltip: l10n.search,
|
|
icon: Icons.search_rounded,
|
|
onPressed: onSearch,
|
|
),
|
|
const SizedBox(width: 4),
|
|
_GlassIconButton(
|
|
tooltip: l10n.clearScreen,
|
|
icon: Icons.fit_screen_outlined,
|
|
onPressed: onClear,
|
|
),
|
|
const SizedBox(width: 4),
|
|
_GlassIconButton(
|
|
tooltip: l10n.more,
|
|
icon: Icons.more_horiz_rounded,
|
|
onPressed: () => _openMore(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _hotBadge() {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.blue.withValues(alpha: 0.18),
|
|
borderRadius: BorderRadius.circular(5),
|
|
border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
|
child: Text(
|
|
l10n.hot,
|
|
style: const TextStyle(
|
|
color: AppColors.blue,
|
|
fontSize: 8,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.4,
|
|
height: 1.1,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _rail(bool compact) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Semantics(
|
|
button: true,
|
|
label: saved ? l10n.followingTab : l10n.followSeries,
|
|
child: InkWell(
|
|
key: const Key('home-feed-follow'),
|
|
onTap: onSave,
|
|
customBorder: const CircleBorder(),
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Container(
|
|
width: compact ? 40 : 44,
|
|
height: compact ? 40 : 44,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: AppColors.blue, width: 2),
|
|
color: AppColors.raised,
|
|
),
|
|
child: ClipOval(
|
|
child: RemoteImage(
|
|
drama.posterUrl,
|
|
fit: BoxFit.cover,
|
|
showFallbackIcon: true,
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: -4,
|
|
left: 14,
|
|
child: Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: saved ? AppColors.success : AppColors.blue,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: AppColors.ink, width: 2),
|
|
),
|
|
child: Icon(
|
|
saved ? Icons.check_rounded : Icons.add_rounded,
|
|
color: AppColors.onPrimary,
|
|
size: 11,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_RailAction(
|
|
compact: compact,
|
|
key: const Key('rail-like'),
|
|
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
|
label: l10n.like,
|
|
selected: liked,
|
|
onTap: onLike,
|
|
),
|
|
_RailAction(
|
|
compact: compact,
|
|
key: const Key('rail-save'),
|
|
icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
|
label: l10n.save,
|
|
selected: saved,
|
|
onTap: onSave,
|
|
),
|
|
_RailAction(
|
|
compact: compact,
|
|
key: const Key('rail-comments'),
|
|
icon: Icons.chat_bubble_outline_rounded,
|
|
label: l10n.comments,
|
|
onTap: onComments,
|
|
),
|
|
_RailAction(
|
|
compact: compact,
|
|
key: const Key('rail-share'),
|
|
icon: Icons.share_outlined,
|
|
label: l10n.share,
|
|
onTap: onShare,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _infoCard(BuildContext context, bool compact) {
|
|
final genres = [
|
|
if (drama.genre.isNotEmpty) drama.genre,
|
|
if (drama.category.isNotEmpty) drama.category,
|
|
].take(2).toList();
|
|
return GlassPanel(
|
|
key: const Key('home-feed-info-card'),
|
|
radius: 26,
|
|
blur: 24,
|
|
padding: const EdgeInsets.fromLTRB(16, 13, 16, 12),
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [Color(0x260B0C10), Color(0x590B0C10)],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Wrap(
|
|
spacing: 5,
|
|
runSpacing: 4,
|
|
children: [
|
|
for (var i = 0; i < genres.length; i++)
|
|
_genreChip(genres[i], i.isEven),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.06),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: AppColors.border),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(
|
|
width: 5,
|
|
height: 5,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: AppColors.success,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${l10n.episodeLabel(episode.number)} / '
|
|
'${drama.episodeCount}',
|
|
key: const Key('home-feed-episode'),
|
|
style: const TextStyle(
|
|
color: AppColors.cream,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
drama.synopsis,
|
|
maxLines: compact ? 1 : 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.cream,
|
|
fontSize: 12,
|
|
height: 1.35,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
SeekProgressBar(
|
|
valueListenable: controller,
|
|
semanticsLabel: l10n.seekVideo,
|
|
showTimes: false,
|
|
compact: true,
|
|
onScrubStart: onScrubStart,
|
|
onSeek: onSeek,
|
|
),
|
|
_timeRow(),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 40,
|
|
child: FilledButton.icon(
|
|
key: const Key('home-feed-follow-series'),
|
|
onPressed: onSave,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: AppColors.blue,
|
|
foregroundColor: AppColors.onPrimary,
|
|
shape: const StadiumBorder(),
|
|
textStyle: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
icon: Icon(
|
|
saved ? Icons.check_rounded : Icons.add_rounded,
|
|
size: 16,
|
|
),
|
|
label: Text(
|
|
saved ? l10n.followingTab : l10n.followSeries,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_GlassAction(
|
|
tooltip: l10n.episodes,
|
|
icon: Icons.grid_view_rounded,
|
|
label: l10n.episodes,
|
|
onPressed: onEpisodes,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _genreChip(String label, bool secondary) {
|
|
final color = secondary ? AppColors.secondary : AppColors.blue;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
|
),
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: color,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.1,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _timeRow() {
|
|
final source = controller;
|
|
if (source == null) {
|
|
return const _Times(position: Duration.zero, duration: Duration.zero);
|
|
}
|
|
return ValueListenableBuilder<VideoPlayerValue>(
|
|
valueListenable: source,
|
|
builder: (context, value, _) =>
|
|
_Times(position: value.position, duration: value.duration),
|
|
);
|
|
}
|
|
|
|
Future<void> _openMore(BuildContext context) async {
|
|
await showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: AppColors.card,
|
|
showDragHandle: true,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
key: const Key('home-feed-download'),
|
|
leading: const Icon(
|
|
Icons.download_outlined,
|
|
color: AppColors.blueSoft,
|
|
),
|
|
title: Text(l10n.downloads),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
onDownload();
|
|
},
|
|
),
|
|
if (onPip != null)
|
|
ListTile(
|
|
key: const Key('home-feed-pip'),
|
|
leading: const Icon(
|
|
Icons.picture_in_picture_alt_outlined,
|
|
color: AppColors.blueSoft,
|
|
),
|
|
title: Text(l10n.pictureInPicture),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
onPip!();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.fit_screen_outlined,
|
|
color: AppColors.blueSoft,
|
|
),
|
|
title: Text(l10n.clearScreen),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
onClear();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Times extends StatelessWidget {
|
|
const _Times({required this.position, required this.duration});
|
|
|
|
final Duration position;
|
|
final Duration duration;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
_format(position),
|
|
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
|
),
|
|
Text(
|
|
_format(duration),
|
|
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GlassIconButton extends StatelessWidget {
|
|
const _GlassIconButton({
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final VoidCallback? onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 34,
|
|
height: 34,
|
|
child: IconButton(
|
|
tooltip: tooltip,
|
|
onPressed: onPressed,
|
|
padding: EdgeInsets.zero,
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.black.withValues(alpha: 0.28),
|
|
foregroundColor: AppColors.onSurface,
|
|
side: BorderSide(color: Colors.white.withValues(alpha: 0.16)),
|
|
),
|
|
icon: Icon(icon, size: 18),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GlassAction extends StatelessWidget {
|
|
const _GlassAction({
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: InkWell(
|
|
onTap: onPressed,
|
|
borderRadius: BorderRadius.circular(999),
|
|
child: Container(
|
|
height: 40,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.15)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: AppColors.secondary, size: 15),
|
|
const SizedBox(width: 5),
|
|
Text(
|
|
label,
|
|
maxLines: 1,
|
|
style: const TextStyle(
|
|
color: AppColors.cream,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RailAction extends StatelessWidget {
|
|
const _RailAction({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onTap,
|
|
this.selected = false,
|
|
this.compact = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
final bool selected;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: compact ? 2 : 3),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
GlassPanel(
|
|
radius: 999,
|
|
blur: 16,
|
|
child: SizedBox(
|
|
width: compact ? 40 : 38,
|
|
height: compact ? 40 : 38,
|
|
child: IconButton(
|
|
tooltip: label,
|
|
onPressed: onTap,
|
|
padding: EdgeInsets.zero,
|
|
icon: Icon(
|
|
icon,
|
|
size: 19,
|
|
color: selected ? AppColors.blue : AppColors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!compact) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
maxLines: 1,
|
|
style: const TextStyle(
|
|
color: AppColors.cream,
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.w600,
|
|
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _format(Duration value) {
|
|
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
return '$minutes:$seconds';
|
|
}
|