359 lines
9.7 KiB
Dart
359 lines
9.7 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
part of 'home_feed_overlay.dart';
|
|
|
|
const double _thumbSize = 6;
|
|
const double _thumbActiveSize = 14;
|
|
const double _trackActiveHeight = 6;
|
|
const double _lineCenterFromBottom = 8;
|
|
|
|
/// 顶部频道 tab:漫剧点击提示即将上线。
|
|
enum _FeedTab {
|
|
anime,
|
|
real,
|
|
recommend;
|
|
|
|
bool get comingSoon => this == _FeedTab.anime;
|
|
|
|
String get keyName => switch (this) {
|
|
_FeedTab.anime => 'anime',
|
|
_FeedTab.real => 'real',
|
|
_FeedTab.recommend => 'recommend',
|
|
};
|
|
|
|
String label(AppLocalizations l10n) => switch (this) {
|
|
_FeedTab.anime => l10n.feedTabAnime,
|
|
_FeedTab.real => l10n.feedTabReal,
|
|
_FeedTab.recommend => l10n.feedTabForYou,
|
|
};
|
|
}
|
|
|
|
class _TopIconButton extends StatelessWidget {
|
|
const _TopIconButton({
|
|
super.key,
|
|
required this.tooltip,
|
|
required this.icon,
|
|
required this.dimension,
|
|
required this.glyphSize,
|
|
required this.onPressed,
|
|
});
|
|
|
|
final String tooltip;
|
|
final IconData icon;
|
|
final double dimension;
|
|
final double glyphSize;
|
|
final VoidCallback onPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox.square(
|
|
dimension: dimension,
|
|
child: IconButton(
|
|
tooltip: tooltip,
|
|
onPressed: onPressed,
|
|
padding: EdgeInsets.zero,
|
|
icon: Icon(
|
|
icon,
|
|
size: glyphSize,
|
|
color: Colors.white,
|
|
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 刷新入口:点击重新加载 feed,长按仍可打开更多(清屏等)。
|
|
class _RefreshButton extends StatelessWidget {
|
|
const _RefreshButton({
|
|
required this.tooltip,
|
|
required this.dimension,
|
|
required this.glyphSize,
|
|
required this.refreshing,
|
|
required this.onTap,
|
|
required this.onLongPress,
|
|
});
|
|
|
|
final String tooltip;
|
|
final double dimension;
|
|
final double glyphSize;
|
|
final bool refreshing;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onLongPress;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: GestureDetector(
|
|
key: const Key('home-feed-refresh'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
onLongPress: onLongPress,
|
|
child: SizedBox.square(
|
|
dimension: dimension,
|
|
child: refreshing
|
|
? const Center(
|
|
child: SizedBox.square(
|
|
dimension: 18,
|
|
child: CircularProgressIndicator(
|
|
key: Key('home-feed-refreshing'),
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
)
|
|
: Icon(
|
|
Icons.refresh_rounded,
|
|
size: glyphSize,
|
|
color: Colors.white,
|
|
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FeedTabItem extends StatelessWidget {
|
|
const _FeedTabItem({
|
|
required this.tab,
|
|
required this.label,
|
|
required this.selected,
|
|
required this.fontSize,
|
|
required this.underlineWidth,
|
|
required this.underlineHeight,
|
|
required this.onTap,
|
|
});
|
|
|
|
final _FeedTab tab;
|
|
final String label;
|
|
final bool selected;
|
|
final double fontSize;
|
|
final double underlineWidth;
|
|
final double underlineHeight;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
key: Key('home-feed-tab-${tab.keyName}'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
label,
|
|
maxLines: 1,
|
|
style: TextStyle(
|
|
color: selected ? Colors.white : Colors.white70,
|
|
fontSize: fontSize,
|
|
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
|
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Container(
|
|
width: underlineWidth,
|
|
height: underlineHeight,
|
|
decoration: BoxDecoration(
|
|
color: selected ? Colors.white : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(1),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 整集条底部的进度线:20px 手势区内可点按/拖动 seek。
|
|
class _FeedProgressBar extends StatefulWidget {
|
|
const _FeedProgressBar({
|
|
required this.controller,
|
|
required this.height,
|
|
required this.hitHeight,
|
|
this.onScrubStart,
|
|
this.onSeek,
|
|
});
|
|
|
|
final ValueListenable<VideoPlayerValue>? controller;
|
|
final double height;
|
|
final double hitHeight;
|
|
final VoidCallback? onScrubStart;
|
|
final ValueChanged<Duration>? onSeek;
|
|
|
|
@override
|
|
State<_FeedProgressBar> createState() => _FeedProgressBarState();
|
|
}
|
|
|
|
class _FeedProgressBarState extends State<_FeedProgressBar> {
|
|
double? _preview;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth;
|
|
final source = widget.controller;
|
|
final track = source == null
|
|
? _track(0, width)
|
|
: ValueListenableBuilder<VideoPlayerValue>(
|
|
valueListenable: source,
|
|
builder: (context, value, _) {
|
|
final total = value.duration.inMilliseconds;
|
|
final played = total <= 0
|
|
? 0.0
|
|
: (value.position.inMilliseconds / total)
|
|
.clamp(0.0, 1.0)
|
|
.toDouble();
|
|
return _track(_preview ?? played, width);
|
|
},
|
|
);
|
|
return GestureDetector(
|
|
key: const Key('home-feed-progress'),
|
|
behavior: HitTestBehavior.opaque,
|
|
onHorizontalDragStart: (details) {
|
|
widget.onScrubStart?.call();
|
|
_update(details.localPosition.dx / width);
|
|
},
|
|
onHorizontalDragUpdate: (details) =>
|
|
_update(details.localPosition.dx / width),
|
|
onHorizontalDragEnd: (_) => _commit(_preview),
|
|
onHorizontalDragCancel: () => setState(() => _preview = null),
|
|
child: track,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _update(double fraction) {
|
|
setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble());
|
|
}
|
|
|
|
void _commit(double? fraction) {
|
|
if (fraction == null) return;
|
|
final total = widget.controller?.value.duration.inMilliseconds ?? 0;
|
|
if (total > 0) {
|
|
widget.onSeek?.call(
|
|
Duration(milliseconds: (total * fraction.clamp(0.0, 1.0)).round()),
|
|
);
|
|
}
|
|
setState(() => _preview = null);
|
|
}
|
|
|
|
Widget _track(double fraction, double width) {
|
|
final dragging = _preview != null;
|
|
final trackHeight = dragging ? _trackActiveHeight : widget.height;
|
|
final size = dragging ? _thumbActiveSize : _thumbSize;
|
|
final left = (width * fraction - size / 2)
|
|
.clamp(0.0, width - size)
|
|
.toDouble();
|
|
final lineBottom = _lineCenterFromBottom - trackHeight / 2;
|
|
final thumbBottom = _lineCenterFromBottom - size / 2;
|
|
return SizedBox(
|
|
height: widget.hitHeight,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: lineBottom,
|
|
child: Container(
|
|
key: const Key('home-feed-progress-track'),
|
|
height: trackHeight,
|
|
color: Colors.white24,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: 0,
|
|
bottom: lineBottom,
|
|
child: Container(
|
|
width: width * fraction,
|
|
height: trackHeight,
|
|
color: AppColors.blue,
|
|
),
|
|
),
|
|
Positioned(
|
|
left: left,
|
|
bottom: thumbBottom,
|
|
child: Container(
|
|
key: const Key('home-feed-progress-thumb'),
|
|
width: size,
|
|
height: size,
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FeedMoreSheet extends StatelessWidget {
|
|
const _FeedMoreSheet({
|
|
required this.l10n,
|
|
required this.onDownload,
|
|
required this.onClear,
|
|
this.onPip,
|
|
});
|
|
|
|
final AppLocalizations l10n;
|
|
final VoidCallback onDownload;
|
|
final VoidCallback onClear;
|
|
final VoidCallback? onPip;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
key: const Key('home-feed-download'),
|
|
leading: const Icon(
|
|
Icons.download_outlined,
|
|
color: AppColors.secondary,
|
|
),
|
|
title: Text(l10n.downloads),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
onDownload();
|
|
},
|
|
),
|
|
if (onPip != null)
|
|
ListTile(
|
|
key: const Key('home-feed-pip'),
|
|
leading: const Icon(
|
|
Icons.picture_in_picture_alt_outlined,
|
|
color: AppColors.secondary,
|
|
),
|
|
title: Text(l10n.pictureInPicture),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
onPip!();
|
|
},
|
|
),
|
|
ListTile(
|
|
key: const Key('home-feed-clear'),
|
|
leading: const Icon(
|
|
Icons.fullscreen_rounded,
|
|
color: AppColors.secondary,
|
|
),
|
|
title: Text(l10n.clearScreen),
|
|
onTap: () {
|
|
Navigator.pop(context);
|
|
onClear();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|