refa: dir

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-05-03 13:57:47 +08:00
parent 57fa8b4f3e
commit 7f70ee5045
260 changed files with 748 additions and 967 deletions

View File

@@ -0,0 +1,242 @@
import 'dart:async';
import 'dart:math';
import 'package:PiliPlus/utils/extension.dart';
import 'package:flutter/material.dart';
import 'package:PiliPlus/utils/feed_back.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
class ActionItem extends StatefulWidget {
final Icon icon;
final Icon? selectIcon;
final Function? onTap;
final Function? onLongPress;
final bool? loadingStatus;
final String? text;
final bool selectStatus;
final String semanticsLabel;
final bool needAnim;
final bool hasTriple;
final Function? callBack;
final bool? expand;
const ActionItem({
super.key,
required this.icon,
this.selectIcon,
this.onTap,
this.onLongPress,
this.loadingStatus,
this.text,
this.selectStatus = false,
this.needAnim = false,
this.hasTriple = false,
this.callBack,
required this.semanticsLabel,
this.expand,
});
@override
State<ActionItem> createState() => ActionItemState();
}
class ActionItemState extends State<ActionItem>
with SingleTickerProviderStateMixin {
AnimationController? controller;
Animation<double>? _animation;
late final _isThumbsUp = widget.semanticsLabel == '点赞';
late int _lastTime;
late bool _hideCircle = false;
Timer? _timer;
void _startLongPress() {
_lastTime = DateTime.now().millisecondsSinceEpoch;
_timer ??= Timer(const Duration(milliseconds: 200), () {
if (widget.hasTriple) {
HapticFeedback.lightImpact();
SmartDialog.showToast('已经完成三连');
} else {
controller?.forward();
widget.callBack?.call(true);
}
cancelTimer();
});
}
void _cancelLongPress([bool isCancel = false]) {
int duration = DateTime.now().millisecondsSinceEpoch - _lastTime;
if (duration >= 200 && duration < 1500) {
if (widget.hasTriple.not) {
controller?.reverse();
widget.callBack?.call(false);
}
} else if (duration < 200) {
cancelTimer();
if (!isCancel) {
feedBack();
widget.onTap?.call();
}
}
}
@override
void initState() {
super.initState();
if (widget.needAnim) {
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
reverseDuration: const Duration(milliseconds: 400),
)..addListener(listener);
_animation = Tween<double>(begin: 0, end: -2 * pi).animate(
CurvedAnimation(
parent: controller!,
curve: Curves.easeInOut,
),
);
}
}
void listener() {
setState(() {
_hideCircle = controller?.value == 1;
if (_hideCircle) {
controller?.reset();
if (_isThumbsUp) {
widget.onLongPress?.call();
}
}
});
}
void cancelTimer() {
_timer?.cancel();
_timer = null;
}
@override
void dispose() {
cancelTimer();
controller?.removeListener(listener);
controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return widget.expand == false
? _buildItem(theme)
: Expanded(child: _buildItem(theme));
}
Widget _buildItem(ThemeData theme) => Semantics(
label: (widget.text ?? "") +
(widget.selectStatus ? "" : "") +
widget.semanticsLabel,
child: InkWell(
borderRadius: BorderRadius.circular(6),
onTap: _isThumbsUp
? null
: () {
feedBack();
widget.onTap?.call();
},
onLongPress: _isThumbsUp
? null
: () {
widget.onLongPress?.call();
},
onTapDown: (details) => _isThumbsUp ? _startLongPress() : null,
onTapUp: (details) => _isThumbsUp ? _cancelLongPress() : null,
onTapCancel: () => _isThumbsUp ? _cancelLongPress(true) : null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
if (widget.needAnim && !_hideCircle)
CustomPaint(
size: const Size(28, 28),
painter: _ArcPainter(
color: theme.colorScheme.primary,
sweepAngle: _animation!.value,
),
)
else
const SizedBox(width: 28, height: 28),
Icon(
widget.selectStatus
? widget.selectIcon!.icon!
: widget.icon.icon,
size: 18,
color: widget.selectStatus
? theme.colorScheme.primary
: widget.icon.color ?? theme.colorScheme.outline,
),
],
),
if (widget.text != null)
AnimatedOpacity(
opacity: widget.loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
widget.text!,
key: ValueKey<String>(widget.text!),
style: TextStyle(
color: widget.selectStatus
? theme.colorScheme.primary
: theme.colorScheme.outline,
fontSize: theme.textTheme.labelSmall!.fontSize,
),
semanticsLabel: "",
),
),
),
],
),
),
);
}
class _ArcPainter extends CustomPainter {
const _ArcPainter({
required this.color,
required this.sweepAngle,
});
final Color color;
final double sweepAngle;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke;
final rect = Rect.fromCircle(
center: Offset(size.width / 2, size.height / 2),
radius: size.width / 2,
);
const startAngle = -pi / 2;
canvas.drawArc(rect, startAngle, sweepAngle, false, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:PiliPlus/utils/feed_back.dart';
class ActionRowItem extends StatelessWidget {
final Icon? icon;
final Icon? selectIcon;
final Function? onTap;
final bool? loadingStatus;
final String? text;
final bool selectStatus;
final Function? onLongPress;
const ActionRowItem({
super.key,
this.icon,
this.selectIcon,
this.onTap,
this.loadingStatus,
this.text,
this.selectStatus = false,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: selectStatus
? theme.colorScheme.primaryContainer.withOpacity(0.6)
: theme.highlightColor.withOpacity(0.2),
borderRadius: const BorderRadius.all(Radius.circular(30)),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => {
feedBack(),
onTap?.call(),
},
onLongPress: () {
feedBack();
onLongPress?.call();
},
child: Padding(
padding: const EdgeInsets.fromLTRB(15, 7, 15, 7),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon!.icon!,
size: 13,
color: selectStatus
? theme.colorScheme.primary
: theme.colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
],
AnimatedOpacity(
opacity: loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder:
(Widget child, Animation<double> animation) {
return ScaleTransition(scale: animation, child: child);
},
child: Text(
text ?? '',
key: ValueKey<String>(text ?? ''),
style: TextStyle(
color: selectStatus ? theme.colorScheme.primary : null,
fontSize: theme.textTheme.labelMedium!.fontSize),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:PiliPlus/utils/feed_back.dart';
class MenuRow extends StatelessWidget {
const MenuRow({
super.key,
this.loadingStatus,
});
final bool? loadingStatus;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
color: theme.colorScheme.surface,
padding: const EdgeInsets.only(top: 9, bottom: 9, left: 12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: [
ActionRowLineItem(
onTap: () => {},
loadingStatus: loadingStatus,
text: '推荐',
selectStatus: false,
),
const SizedBox(width: 8),
ActionRowLineItem(
onTap: () => {},
loadingStatus: loadingStatus,
text: '弹幕',
selectStatus: false,
),
const SizedBox(width: 8),
ActionRowLineItem(
onTap: () => {},
loadingStatus: loadingStatus,
text: '评论列表',
selectStatus: false,
),
const SizedBox(width: 8),
ActionRowLineItem(
onTap: () => {},
loadingStatus: loadingStatus,
text: '播放列表',
selectStatus: false,
),
]),
),
);
}
Widget actionRowLineItem(
ThemeData theme, Function? onTap, bool? loadingStatus, String? text,
{bool selectStatus = false}) {
return Material(
color: selectStatus
? theme.highlightColor.withOpacity(0.2)
: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(30)),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => {
feedBack(),
onTap?.call(),
},
child: Container(
padding: const EdgeInsets.fromLTRB(13, 5.5, 13, 4.5),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(30)),
border: Border.all(
color: selectStatus
? Colors.transparent
: theme.highlightColor.withOpacity(0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
opacity: loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: Text(
text!,
style: TextStyle(
fontSize: 13,
color: selectStatus
? theme.colorScheme.onSurface
: theme.colorScheme.outline),
),
),
],
),
),
),
);
}
}
class ActionRowLineItem extends StatelessWidget {
const ActionRowLineItem({
super.key,
required this.selectStatus,
this.onTap,
this.text,
this.loadingStatus = false,
this.iconData,
this.icon,
});
final bool selectStatus;
final Function? onTap;
final bool? loadingStatus;
final String? text;
final IconData? iconData;
final Widget? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: selectStatus
? theme.colorScheme.secondaryContainer
: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(30)),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => {
feedBack(),
onTap?.call(),
},
child: Container(
padding: const EdgeInsets.fromLTRB(13, 5.5, 13, 4.5),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(30)),
border: Border.all(
color: selectStatus
? Colors.transparent
: theme.colorScheme.secondaryContainer,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (iconData != null)
Icon(
iconData,
size: 13,
color: selectStatus
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.outline,
)
else if (icon != null)
icon!,
AnimatedOpacity(
opacity: loadingStatus! ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: Text(
text!,
style: TextStyle(
fontSize: 13,
color: selectStatus
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.outline),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,208 @@
import 'dart:async';
import 'dart:math';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
import 'package:PiliPlus/utils/id_utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/models/video_detail_res.dart';
class PagesPanel extends StatefulWidget {
const PagesPanel({
super.key,
this.list,
this.cover,
required this.bvid,
required this.heroTag,
this.showEpisodes,
required this.videoIntroController,
});
final List<Part>? list;
final String? cover;
final String bvid;
final String heroTag;
final Function? showEpisodes;
final VideoIntroController videoIntroController;
@override
State<PagesPanel> createState() => _PagesPanelState();
}
class _PagesPanelState extends State<PagesPanel> {
late int cid;
int pageIndex = -1;
late VideoDetailController _videoDetailController;
final ScrollController _scrollController = ScrollController();
StreamSubscription? _listener;
List<Part> get pages =>
widget.list ?? widget.videoIntroController.videoDetail.value.pages!;
@override
void initState() {
super.initState();
_videoDetailController =
Get.find<VideoDetailController>(tag: widget.heroTag);
if (widget.list == null) {
cid = widget.videoIntroController.lastPlayCid.value;
pageIndex = pages.indexWhere((Part e) => e.cid == cid);
_listener = _videoDetailController.cid.listen((int cid) {
this.cid = cid;
pageIndex = max(0, pages.indexWhere((Part e) => e.cid == cid));
if (!mounted) return;
setState(() {});
jumpToCurr();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
jumpToCurr();
});
}
}
void jumpToCurr() {
if (!_scrollController.hasClients || pages.isEmpty) {
return;
}
const double itemWidth = 150;
final double targetOffset = (pageIndex * itemWidth - itemWidth / 2).clamp(
_scrollController.position.minScrollExtent,
_scrollController.position.maxScrollExtent);
_scrollController.animateTo(
targetOffset,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_listener?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: <Widget>[
if (widget.showEpisodes != null)
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('视频选集 '),
Expanded(
child: Text(
' 正在播放:${pages[pageIndex].pagePart}',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.outline,
),
),
),
const SizedBox(width: 10),
SizedBox(
height: 34,
child: TextButton(
style: ButtonStyle(
padding: WidgetStateProperty.all(EdgeInsets.zero),
),
onPressed: () => widget.showEpisodes!(
null,
null,
pages,
widget.bvid,
IdUtils.bv2av(widget.bvid),
cid,
),
child: Text(
'${pages.length}',
style: const TextStyle(fontSize: 13),
),
),
),
],
),
),
SizedBox(
height: 35,
child: ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
itemCount: pages.length,
itemExtent: 150,
itemBuilder: (BuildContext context, int i) {
bool isCurrentIndex = pageIndex == i;
return Container(
width: 150,
margin: EdgeInsets.only(
right: i != pages.length - 1 ? 10 : 0,
),
child: Material(
color: theme.colorScheme.onInverseSurface,
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () {
if (widget.showEpisodes == null) {
Get.back();
}
widget.videoIntroController.changeSeasonOrbangu(
null,
widget.bvid,
pages[i].cid,
IdUtils.bv2av(widget.bvid),
widget.cover,
);
if (widget.list != null &&
widget.videoIntroController.videoDetail.value
.ugcSeason !=
null) {
_videoDetailController.seasonCid = pages.first.cid;
}
},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8, horizontal: 8),
child: Row(
children: <Widget>[
if (isCurrentIndex) ...<Widget>[
Image.asset(
'assets/images/live.png',
color: theme.colorScheme.primary,
height: 12,
semanticLabel: "正在播放:",
),
const SizedBox(width: 6)
],
Expanded(
child: Text(
pages[i].pagePart!,
maxLines: 1,
style: TextStyle(
fontSize: 13,
color: isCurrentIndex
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
))
],
),
),
),
),
);
},
),
)
],
);
}
}

View File

@@ -0,0 +1,171 @@
import 'dart:async';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:PiliPlus/models/video_detail_res.dart';
class SeasonPanel extends StatefulWidget {
const SeasonPanel({
super.key,
required this.changeFuc,
required this.heroTag,
required this.showEpisodes,
this.onTap,
required this.videoIntroController,
});
final Function changeFuc;
final String heroTag;
final Function showEpisodes;
final bool? onTap;
final VideoIntroController videoIntroController;
@override
State<SeasonPanel> createState() => _SeasonPanelState();
}
class _SeasonPanelState extends State<SeasonPanel> {
RxInt currentIndex = 0.obs;
late VideoDetailController _videoDetailController;
StreamSubscription? _listener;
List<EpisodeItem> episodes = <EpisodeItem>[];
VideoIntroController get videoIntroController => widget.videoIntroController;
VideoDetailData get videoDetail =>
widget.videoIntroController.videoDetail.value;
@override
void initState() {
super.initState();
_videoDetailController =
Get.find<VideoDetailController>(tag: widget.heroTag);
_videoDetailController.seasonCid =
videoIntroController.lastPlayCid.value != 0
? (videoDetail.pages?.isNotEmpty == true
? videoDetail.isPageReversed
? videoDetail.pages!.last.cid
: videoDetail.pages!.first.cid
: videoIntroController.lastPlayCid.value)
: videoDetail.isPageReversed
? videoDetail.pages!.last.cid
: videoDetail.pages!.first.cid;
/// 根据 cid 找到对应集,找到对应 episodes
/// 有多个episodes时只显示其中一个
_findEpisode();
if (episodes.isEmpty) {
return;
}
/// 取对应 season_id 的 episodes
currentIndex.value = episodes.indexWhere(
(EpisodeItem e) => e.cid == _videoDetailController.seasonCid);
_listener = _videoDetailController.cid.listen((int cid) {
if (_videoDetailController.seasonCid != cid) {
bool isPart =
videoDetail.pages?.indexWhere((item) => item.cid == cid) != -1;
if (isPart.not) {
_videoDetailController.seasonCid = cid;
}
}
_findEpisode();
currentIndex.value = episodes.indexWhere(
(EpisodeItem e) => e.cid == _videoDetailController.seasonCid);
});
}
@override
void dispose() {
_listener?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (episodes.isEmpty) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return Builder(builder: (BuildContext context) {
return Container(
margin: const EdgeInsets.only(
top: 8,
left: 2,
right: 2,
),
child: Material(
color: theme.colorScheme.onInverseSurface,
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: widget.onTap == false
? null
: () => widget.showEpisodes(
_videoDetailController.seasonIndex.value,
videoDetail.ugcSeason,
null,
_videoDetailController.bvid,
null,
_videoDetailController.seasonCid,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 12),
child: Row(
children: <Widget>[
Expanded(
child: Text(
'合集:${videoDetail.ugcSeason!.title!}',
style: theme.textTheme.labelMedium,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 15),
Image.asset(
'assets/images/live.png',
color: theme.colorScheme.primary,
height: 12,
semanticLabel: "正在播放:",
),
const SizedBox(width: 10),
Obx(
() => Text(
'${currentIndex.value + 1}/${episodes.length}',
style: theme.textTheme.labelMedium,
semanticsLabel:
'${currentIndex.value + 1}集,共${episodes.length}',
),
),
const SizedBox(width: 6),
const Icon(
Icons.arrow_forward_ios_outlined,
size: 13,
semanticLabel: '查看',
)
],
),
),
),
),
);
});
}
void _findEpisode() {
final List<SectionItem> sections = videoDetail.ugcSeason!.sections!;
for (int i = 0; i < sections.length; i++) {
final List<EpisodeItem> episodesList = sections[i].episodes!;
for (int j = 0; j < episodesList.length; j++) {
if (episodesList[j].cid == _videoDetailController.seasonCid) {
if (_videoDetailController.seasonIndex.value != i) {
_videoDetailController.seasonIndex.value = i;
}
episodes = episodesList;
break;
}
}
}
}
}