feat(ui): 重做三 Tab 首页与发现页
This commit is contained in:
1 parent
eae1c533ce
commit
e8c1859e70
23 files changed
+1734
-432
No files matched your search
@@ -24,6 +24,7 @@ import '../../core/widgets/download_action.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'player_binding.dart';
|
||||
import 'player_logic.dart';
|
||||
import 'widgets/home_feed_overlay.dart';
|
||||
import 'widgets/seek_progress_bar.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/repositories/playback_repository.dart';
|
||||
@@ -334,6 +335,57 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
? (position) => logic.seekTo(context, videoPort!, position)
|
||||
: null,
|
||||
)
|
||||
else if (!logic.screenCleared && widget.homeMode)
|
||||
HomeFeedOverlay(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
liked: interactions.isLiked(drama.id),
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
if (!context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final result = await interactions.toggleDramaLike(drama.id);
|
||||
if (!result.isSuccess && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.errorMessage ??
|
||||
AppLocalizations.of(context)!.likeFailed,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSave: () async {
|
||||
final favorites = context.read<FavoriteController>();
|
||||
if (await ensureSignedIn(context)) {
|
||||
if (!context.mounted) return;
|
||||
unawaited(context.read<Haptics>().lightImpact());
|
||||
final ok = await favorites.toggle(drama.id, card: drama);
|
||||
if (ok || !context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l10n.favoriteToggleFailed)),
|
||||
);
|
||||
}
|
||||
},
|
||||
onComments: () => logic.showComments(context, drama),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onClear: () => logic.setScreenCleared(true),
|
||||
onSearch: widget.onSearch ?? () => logic.showDiscover(context),
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
// 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';
|
||||
}
|
||||
@@ -26,6 +26,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
this.onScrubStart,
|
||||
this.onSeek,
|
||||
this.showTimes = true,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? valueListenable;
|
||||
@@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget {
|
||||
final VoidCallback? onScrubStart;
|
||||
final Future<void> Function(Duration)? onSeek;
|
||||
final bool showTimes;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
State<SeekProgressBar> createState() => _SeekProgressBarState();
|
||||
@@ -116,17 +118,27 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: scrubbing ? 48 : 44,
|
||||
height: widget.compact
|
||||
? (scrubbing ? 32 : 20)
|
||||
: (scrubbing ? 48 : 44),
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: scrubbing ? 4 : 3,
|
||||
activeTrackColor: AppColors.gold,
|
||||
inactiveTrackColor: Colors.white30,
|
||||
thumbColor: Colors.white,
|
||||
trackHeight: widget.compact
|
||||
? (scrubbing ? 3 : 1.5)
|
||||
: (scrubbing ? 4 : 3),
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.compact
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: Colors.white30,
|
||||
thumbColor: widget.compact ? AppColors.blue : Colors.white,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
||||
enabledThumbRadius: widget.compact
|
||||
? (scrubbing ? 6 : 4)
|
||||
: (scrubbing ? 8 : 6),
|
||||
),
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 18),
|
||||
),
|
||||
child: Semantics(
|
||||
label: widget.semanticsLabel,
|
||||
|
||||
Reference in new issue
Block a user