mirror of
https://github.com/HChaZZY/PiliPlus.git
synced 2025-12-06 09:13:48 +08:00
1919 lines
77 KiB
Dart
1919 lines
77 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:PiliPlus/common/constants.dart';
|
|
import 'package:PiliPlus/common/widgets/progress_bar/audio_video_progress_bar.dart';
|
|
import 'package:PiliPlus/common/widgets/progress_bar/segment_progress_bar.dart';
|
|
import 'package:PiliPlus/models/common/super_resolution_type.dart';
|
|
import 'package:PiliPlus/models_new/video/video_detail/episode.dart';
|
|
import 'package:PiliPlus/models_new/video/video_detail/page.dart';
|
|
import 'package:PiliPlus/models_new/video/video_detail/section.dart';
|
|
import 'package:PiliPlus/models_new/video/video_shot/data.dart';
|
|
import 'package:PiliPlus/pages/video/controller.dart';
|
|
import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart';
|
|
import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/controller.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/models/bottom_control_type.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/models/bottom_progress_behavior.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/models/duration.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/models/fullscreen_mode.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/utils.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/widgets/app_bar_ani.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/widgets/backward_seek.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/widgets/bottom_control.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/widgets/common_btn.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/widgets/forward_seek.dart';
|
|
import 'package:PiliPlus/plugin/pl_player/widgets/play_pause_btn.dart';
|
|
import 'package:PiliPlus/utils/duration_util.dart';
|
|
import 'package:PiliPlus/utils/extension.dart';
|
|
import 'package:PiliPlus/utils/id_utils.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:easy_debounce/easy_throttle.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:flutter/foundation.dart' show kDebugMode;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
|
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
|
|
import 'package:media_kit/media_kit.dart';
|
|
import 'package:media_kit_video/media_kit_video.dart';
|
|
import 'package:saver_gallery/saver_gallery.dart';
|
|
import 'package:screen_brightness/screen_brightness.dart';
|
|
|
|
class PLVideoPlayer extends StatefulWidget {
|
|
const PLVideoPlayer({
|
|
required this.plPlayerController,
|
|
this.videoDetailController,
|
|
this.videoIntroController,
|
|
this.pgcIntroController,
|
|
required this.headerControl,
|
|
this.bottomControl,
|
|
this.danmuWidget,
|
|
this.customWidget,
|
|
this.customWidgets,
|
|
this.showEpisodes,
|
|
this.showViewPoints,
|
|
this.fill,
|
|
this.alignment,
|
|
super.key,
|
|
});
|
|
|
|
final PlPlayerController plPlayerController;
|
|
final VideoDetailController? videoDetailController;
|
|
final VideoIntroController? videoIntroController;
|
|
final PgcIntroController? pgcIntroController;
|
|
final Widget headerControl;
|
|
final Widget? bottomControl;
|
|
final Widget? danmuWidget;
|
|
// List<Widget> or Widget
|
|
|
|
final Widget? customWidget;
|
|
final List<Widget>? customWidgets;
|
|
final Function? showEpisodes;
|
|
final VoidCallback? showViewPoints;
|
|
final Color? fill;
|
|
final Alignment? alignment;
|
|
|
|
@override
|
|
State<PLVideoPlayer> createState() => _PLVideoPlayerState();
|
|
}
|
|
|
|
class _PLVideoPlayerState extends State<PLVideoPlayer>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController animationController;
|
|
late VideoController videoController;
|
|
late VideoIntroController? videoIntroController;
|
|
late PgcIntroController? pgcIntroController;
|
|
|
|
final GlobalKey _playerKey = GlobalKey();
|
|
final GlobalKey<VideoState> key = GlobalKey<VideoState>();
|
|
|
|
final RxBool _mountSeekBackwardButton = false.obs;
|
|
final RxBool _mountSeekForwardButton = false.obs;
|
|
|
|
final RxDouble _brightnessValue = 0.0.obs;
|
|
final RxBool _brightnessIndicator = false.obs;
|
|
Timer? _brightnessTimer;
|
|
|
|
final RxDouble _volumeValue = 0.0.obs;
|
|
final RxBool _volumeIndicator = false.obs;
|
|
Timer? _volumeTimer;
|
|
|
|
// final RxDouble _distance = 0.0.obs;
|
|
final RxBool _volumeInterceptEventStream = false.obs;
|
|
|
|
late FullScreenMode mode;
|
|
|
|
late final RxBool showRestoreScaleBtn = false.obs;
|
|
|
|
Offset _initialFocalPoint = Offset.zero;
|
|
String? _gestureType;
|
|
//播放器放缩
|
|
bool interacting = false;
|
|
|
|
// 是否在调整固定进度条
|
|
RxBool draggingFixedProgressBar = false.obs;
|
|
// 阅读器限制
|
|
// Timer? _accessibilityDebounce;
|
|
// double _lastAnnouncedValue = -1;
|
|
|
|
void onDoubleTapSeekBackward() {
|
|
_mountSeekBackwardButton.value = true;
|
|
}
|
|
|
|
void onDoubleTapSeekForward() {
|
|
_mountSeekForwardButton.value = true;
|
|
}
|
|
|
|
// 双击播放、暂停
|
|
Future<void> onDoubleTapCenter() async {
|
|
if (plPlayerController.videoPlayerController!.state.completed) {
|
|
await plPlayerController.videoPlayerController!.seek(Duration.zero);
|
|
plPlayerController.videoPlayerController!.play();
|
|
} else {
|
|
plPlayerController.videoPlayerController!.playOrPause();
|
|
}
|
|
}
|
|
|
|
void doubleTapFuc(String type) {
|
|
if (!plPlayerController.enableQuickDouble) {
|
|
onDoubleTapCenter();
|
|
return;
|
|
}
|
|
switch (type) {
|
|
case 'left':
|
|
// 双击左边区域 👈
|
|
onDoubleTapSeekBackward();
|
|
break;
|
|
case 'center':
|
|
onDoubleTapCenter();
|
|
break;
|
|
case 'right':
|
|
// 双击右边区域 👈
|
|
onDoubleTapSeekForward();
|
|
break;
|
|
}
|
|
}
|
|
|
|
StreamSubscription? _listener;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
plPlayerController.getPlayerKey = () => key;
|
|
animationController = AnimationController(
|
|
vsync: this, duration: const Duration(milliseconds: 100));
|
|
videoController = plPlayerController.videoController!;
|
|
videoIntroController = widget.videoIntroController;
|
|
pgcIntroController = widget.pgcIntroController;
|
|
Future.microtask(() async {
|
|
try {
|
|
FlutterVolumeController.updateShowSystemUI(true);
|
|
_volumeValue.value = (await FlutterVolumeController.getVolume())!;
|
|
FlutterVolumeController.addListener((double value) {
|
|
if (mounted && !_volumeInterceptEventStream.value) {
|
|
_volumeValue.value = value;
|
|
if (Platform.isIOS && !FlutterVolumeController.showSystemUI) {
|
|
_volumeIndicator.value = true;
|
|
_volumeTimer?.cancel();
|
|
_volumeTimer = Timer(const Duration(milliseconds: 800), () {
|
|
if (mounted) {
|
|
_volumeIndicator.value = false;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
} catch (_) {}
|
|
});
|
|
|
|
Future.microtask(() async {
|
|
try {
|
|
_brightnessValue.value = await ScreenBrightness().application;
|
|
_listener = ScreenBrightness()
|
|
.onApplicationScreenBrightnessChanged
|
|
.listen((double value) {
|
|
if (mounted) {
|
|
_brightnessValue.value = value;
|
|
}
|
|
});
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
|
|
Future<void> setVolume(double value) async {
|
|
try {
|
|
FlutterVolumeController.updateShowSystemUI(false);
|
|
await FlutterVolumeController.setVolume(value);
|
|
} catch (_) {}
|
|
_volumeValue.value = value;
|
|
_volumeIndicator.value = true;
|
|
_volumeInterceptEventStream.value = true;
|
|
_volumeTimer?.cancel();
|
|
_volumeTimer = Timer(const Duration(milliseconds: 200), () {
|
|
if (mounted) {
|
|
_volumeIndicator.value = false;
|
|
_volumeInterceptEventStream.value = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> setBrightness(double value) async {
|
|
try {
|
|
await ScreenBrightness().setApplicationScreenBrightness(value);
|
|
} catch (_) {}
|
|
_brightnessIndicator.value = true;
|
|
_brightnessTimer?.cancel();
|
|
_brightnessTimer = Timer(const Duration(milliseconds: 200), () {
|
|
if (mounted) {
|
|
_brightnessIndicator.value = false;
|
|
}
|
|
});
|
|
plPlayerController.brightness.value = value;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_listener?.cancel();
|
|
animationController.dispose();
|
|
FlutterVolumeController.removeListener();
|
|
transformationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// 动态构建底部控制条
|
|
Widget buildBottomControl() {
|
|
bool isSeason = videoIntroController?.videoDetail.value.ugcSeason != null;
|
|
bool isPage = videoIntroController?.videoDetail.value.pages != null &&
|
|
videoIntroController!.videoDetail.value.pages!.length > 1;
|
|
bool isPgc = pgcIntroController != null;
|
|
bool anySeason = isSeason || isPage || isPgc;
|
|
double widgetWidth =
|
|
isFullScreen && context.orientation == Orientation.landscape ? 42 : 35;
|
|
Map<BottomControlType, Widget> videoProgressWidgets = {
|
|
/// 上一集
|
|
BottomControlType.pre: Container(
|
|
width: widgetWidth,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
child: ComBtn(
|
|
icon: const Icon(
|
|
Icons.skip_previous,
|
|
semanticLabel: '上一集',
|
|
size: 22,
|
|
color: Colors.white,
|
|
),
|
|
onTap: () {
|
|
bool? res;
|
|
if (videoIntroController != null) {
|
|
res = videoIntroController!.prevPlay();
|
|
}
|
|
if (pgcIntroController != null) {
|
|
res = pgcIntroController!.prevPlay();
|
|
}
|
|
if (res == false) {
|
|
SmartDialog.showToast('已经是第一集了');
|
|
}
|
|
},
|
|
),
|
|
),
|
|
|
|
/// 播放暂停
|
|
BottomControlType.playOrPause: PlayOrPauseButton(
|
|
plPlayerController: plPlayerController,
|
|
),
|
|
|
|
/// 下一集
|
|
BottomControlType.next: Container(
|
|
width: widgetWidth,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
child: ComBtn(
|
|
icon: const Icon(
|
|
Icons.skip_next,
|
|
semanticLabel: '下一集',
|
|
size: 22,
|
|
color: Colors.white,
|
|
),
|
|
onTap: () {
|
|
bool? res;
|
|
if (videoIntroController != null) {
|
|
res = videoIntroController!.nextPlay();
|
|
}
|
|
if (pgcIntroController != null) {
|
|
res = pgcIntroController!.nextPlay();
|
|
}
|
|
if (res == false) {
|
|
SmartDialog.showToast('已经是最后一集了');
|
|
}
|
|
},
|
|
),
|
|
),
|
|
|
|
/// 时间进度
|
|
BottomControlType.time: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 播放时间
|
|
Obx(() {
|
|
return Text(
|
|
DurationUtil.formatDuration(
|
|
plPlayerController.positionSeconds.value),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
height: 1.4,
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
),
|
|
);
|
|
}),
|
|
Obx(
|
|
() => Text(
|
|
DurationUtil.formatDuration(
|
|
plPlayerController.durationSeconds.value.inSeconds),
|
|
style: const TextStyle(
|
|
color: Color(0xFFD0D0D0),
|
|
fontSize: 10,
|
|
height: 1.4,
|
|
fontFeatures: [FontFeature.tabularFigures()],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
/// 高能进度条
|
|
BottomControlType.dmChart: Obx(() => plPlayerController.dmTrend.isEmpty
|
|
? const SizedBox.shrink()
|
|
: Container(
|
|
width: widgetWidth,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
child: ComBtn(
|
|
icon: plPlayerController.showDmTreandChart.value
|
|
? const Icon(
|
|
Icons.show_chart,
|
|
size: 22,
|
|
color: Colors.white,
|
|
)
|
|
: const Stack(
|
|
clipBehavior: Clip.none,
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.show_chart,
|
|
size: 22,
|
|
color: Colors.white,
|
|
),
|
|
Icon(
|
|
Icons.hide_source,
|
|
size: 22,
|
|
color: Colors.white,
|
|
),
|
|
],
|
|
),
|
|
onTap: () => plPlayerController.showDmTreandChart.value =
|
|
!plPlayerController.showDmTreandChart.value,
|
|
),
|
|
)),
|
|
|
|
/// 超分辨率
|
|
BottomControlType.superResolution: Get.parameters['type'] == '1' ||
|
|
Get.parameters['type'] == '4'
|
|
? Container(
|
|
height: 30,
|
|
margin: const EdgeInsets.symmetric(horizontal: 10),
|
|
alignment: Alignment.center,
|
|
child: PopupMenuButton<SuperResolutionType>(
|
|
initialValue: SuperResolutionType
|
|
.values[plPlayerController.superResolutionType],
|
|
color: Colors.black.withValues(alpha: 0.8),
|
|
itemBuilder: (BuildContext context) {
|
|
return SuperResolutionType.values
|
|
.map((SuperResolutionType type) {
|
|
return PopupMenuItem<SuperResolutionType>(
|
|
height: 35,
|
|
padding: const EdgeInsets.only(left: 30),
|
|
value: type,
|
|
onTap: () => plPlayerController.setShader(type.index),
|
|
child: Text(
|
|
type.title,
|
|
style:
|
|
const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
);
|
|
}).toList();
|
|
},
|
|
child: Text(
|
|
SuperResolutionType
|
|
.values[plPlayerController.superResolutionType].title,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
|
|
/// 分段信息
|
|
BottomControlType.viewPoints: Obx(
|
|
() => plPlayerController.viewPointList.isEmpty
|
|
? const SizedBox.shrink()
|
|
: Container(
|
|
width: widgetWidth,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
child: ComBtn(
|
|
icon: Transform.rotate(
|
|
angle: pi / 2,
|
|
child: const Icon(
|
|
MdiIcons.viewHeadline,
|
|
semanticLabel: '分段信息',
|
|
size: 22,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
onTap: widget.showViewPoints,
|
|
onLongPress: () {
|
|
Feedback.forLongPress(context);
|
|
plPlayerController.showVP.value =
|
|
!plPlayerController.showVP.value;
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
/// 选集
|
|
BottomControlType.episode: Container(
|
|
width: widgetWidth,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
child: ComBtn(
|
|
icon: const Icon(
|
|
Icons.list,
|
|
semanticLabel: '选集',
|
|
size: 22,
|
|
color: Colors.white,
|
|
),
|
|
onTap: () {
|
|
if (!anySeason || widget.videoDetailController?.isPlayAll == true) {
|
|
widget.showEpisodes?.call();
|
|
return;
|
|
}
|
|
int? index;
|
|
int currentCid = plPlayerController.cid;
|
|
String bvid = plPlayerController.bvid;
|
|
List episodes = [];
|
|
if (isSeason) {
|
|
final List<SectionItem> sections =
|
|
videoIntroController!.videoDetail.value.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 == plPlayerController.cid) {
|
|
index = i;
|
|
episodes = episodesList;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else if (isPage) {
|
|
final List<Part> pages =
|
|
videoIntroController!.videoDetail.value.pages!;
|
|
episodes = pages;
|
|
} else if (isPgc) {
|
|
episodes = pgcIntroController!.pgcItem.episodes!;
|
|
}
|
|
widget.showEpisodes?.call(
|
|
index,
|
|
isSeason
|
|
? videoIntroController?.videoDetail.value.ugcSeason!
|
|
: null,
|
|
isSeason ? null : episodes,
|
|
bvid,
|
|
IdUtils.bv2av(bvid),
|
|
isSeason && isPage
|
|
? widget.videoDetailController?.seasonCid ?? currentCid
|
|
: currentCid,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
/// 画面比例
|
|
BottomControlType.fit: Container(
|
|
height: 30,
|
|
margin: const EdgeInsets.symmetric(horizontal: 10),
|
|
alignment: Alignment.center,
|
|
child: PopupMenuButton<BoxFit>(
|
|
initialValue: plPlayerController.videoFit.value,
|
|
color: Colors.black.withValues(alpha: 0.8),
|
|
itemBuilder: (BuildContext context) {
|
|
return BoxFit.values.map((BoxFit boxFit) {
|
|
return PopupMenuItem<BoxFit>(
|
|
height: 35,
|
|
padding: const EdgeInsets.only(left: 30),
|
|
value: boxFit,
|
|
onTap: () => plPlayerController.toggleVideoFit(boxFit),
|
|
child: Text(
|
|
boxFit.desc,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
);
|
|
}).toList();
|
|
},
|
|
child: Text(
|
|
plPlayerController.videoFit.value.desc,
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
),
|
|
),
|
|
),
|
|
|
|
/// 字幕
|
|
BottomControlType.subtitle: Obx(
|
|
() => widget.videoDetailController?.subtitles.isEmpty == true
|
|
? const SizedBox.shrink()
|
|
: SizedBox(
|
|
width: widgetWidth,
|
|
height: 30,
|
|
child: PopupMenuButton<int>(
|
|
initialValue: widget
|
|
.videoDetailController!.vttSubtitlesIndex.value
|
|
.clamp(0, widget.videoDetailController!.subtitles.length),
|
|
color: Colors.black.withValues(alpha: 0.8),
|
|
itemBuilder: (BuildContext context) {
|
|
return [
|
|
PopupMenuItem<int>(
|
|
value: 0,
|
|
onTap: () =>
|
|
widget.videoDetailController!.setSubtitle(0),
|
|
child: const Text(
|
|
"关闭字幕",
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
...widget.videoDetailController!.subtitles.indexed
|
|
.map((e) {
|
|
return PopupMenuItem<int>(
|
|
value: e.$1 + 1,
|
|
onTap: () => widget.videoDetailController!
|
|
.setSubtitle(e.$1 + 1),
|
|
child: Text(
|
|
"${e.$2.lanDoc}",
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
);
|
|
})
|
|
];
|
|
},
|
|
child: Container(
|
|
width: 35,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
widget.videoDetailController!.vttSubtitlesIndex.value == 0
|
|
? Icons.closed_caption_off_outlined
|
|
: Icons.closed_caption_off_rounded,
|
|
size: 22,
|
|
color: Colors.white,
|
|
semanticLabel: '字幕',
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
/// 播放速度
|
|
BottomControlType.speed: Obx(
|
|
() => Container(
|
|
height: 30,
|
|
margin: const EdgeInsets.symmetric(horizontal: 10),
|
|
alignment: Alignment.center,
|
|
child: PopupMenuButton<double>(
|
|
initialValue: plPlayerController.playbackSpeed,
|
|
color: Colors.black.withValues(alpha: 0.8),
|
|
itemBuilder: (BuildContext context) {
|
|
return plPlayerController.speedList.map((double speed) {
|
|
return PopupMenuItem<double>(
|
|
height: 35,
|
|
padding: const EdgeInsets.only(left: 30),
|
|
value: speed,
|
|
onTap: () => plPlayerController.setPlaybackSpeed(speed),
|
|
child: Text(
|
|
"${speed}X",
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
semanticsLabel: "$speed倍速",
|
|
),
|
|
);
|
|
}).toList();
|
|
},
|
|
child: Text("${plPlayerController.playbackSpeed}X",
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
semanticsLabel: "${plPlayerController.playbackSpeed}倍速"),
|
|
),
|
|
),
|
|
),
|
|
|
|
/// 全屏
|
|
BottomControlType.fullscreen: SizedBox(
|
|
width: widgetWidth,
|
|
height: 30,
|
|
child: Obx(
|
|
() => ComBtn(
|
|
icon: Icon(
|
|
isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
|
semanticLabel: isFullScreen ? '退出全屏' : '全屏',
|
|
size: 24,
|
|
color: Colors.white,
|
|
),
|
|
onTap: () => plPlayerController.triggerFullScreen(
|
|
status: !isFullScreen, duration: 800),
|
|
),
|
|
),
|
|
),
|
|
};
|
|
|
|
List<BottomControlType> userSpecifyItemLeft = [
|
|
BottomControlType.playOrPause,
|
|
BottomControlType.time,
|
|
if (anySeason || widget.videoDetailController?.isPlayAll == true) ...[
|
|
BottomControlType.pre,
|
|
BottomControlType.next,
|
|
],
|
|
];
|
|
|
|
List<BottomControlType> userSpecifyItemRight = [
|
|
BottomControlType.dmChart,
|
|
BottomControlType.superResolution,
|
|
BottomControlType.viewPoints,
|
|
if (anySeason || widget.videoDetailController?.isPlayAll == true)
|
|
BottomControlType.episode,
|
|
if (isFullScreen) BottomControlType.fit,
|
|
BottomControlType.subtitle,
|
|
BottomControlType.speed,
|
|
BottomControlType.fullscreen,
|
|
];
|
|
|
|
return Row(
|
|
children: [
|
|
...userSpecifyItemLeft.map((item) => videoProgressWidgets[item]!),
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) => FittedBox(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minWidth: constraints.maxWidth,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: userSpecifyItemRight
|
|
.map((item) => videoProgressWidgets[item]!)
|
|
.toList(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
PlPlayerController get plPlayerController => widget.plPlayerController;
|
|
|
|
bool get isFullScreen => plPlayerController.isFullScreen.value;
|
|
|
|
late final transformationController = TransformationController();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ThemeData theme = Theme.of(context);
|
|
final Color primary = theme.colorScheme.primary;
|
|
const TextStyle textStyle = TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
);
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth = constraints.maxWidth;
|
|
final maxHeight = constraints.maxHeight;
|
|
return Stack(
|
|
fit: StackFit.passthrough,
|
|
key: _playerKey,
|
|
children: <Widget>[
|
|
Obx(
|
|
() => Video(
|
|
fill: widget.fill ?? Colors.black,
|
|
key: key,
|
|
alignment: widget.alignment ?? Alignment.center,
|
|
controller: videoController,
|
|
controls: NoVideoControls,
|
|
pauseUponEnteringBackgroundMode:
|
|
!plPlayerController.continuePlayInBackground.value,
|
|
resumeUponEnteringForegroundMode: true,
|
|
// 字幕尺寸调节
|
|
subtitleViewConfiguration:
|
|
plPlayerController.subtitleViewConfiguration,
|
|
fit: plPlayerController.videoFit.value,
|
|
dmWidget: widget.danmuWidget,
|
|
transformationController: transformationController,
|
|
scaleEnabled: !plPlayerController.controlsLock.value,
|
|
enableShrinkVideoSize: plPlayerController.enableShrinkVideoSize,
|
|
onInteractionStart: (ScaleStartDetails details) {
|
|
if (plPlayerController.controlsLock.value) return;
|
|
// 如果起点太靠上则屏蔽
|
|
if (details.localFocalPoint.dy < 40) return;
|
|
if (details.localFocalPoint.dx < 40) return;
|
|
if (details.localFocalPoint.dx > maxWidth - 40) return;
|
|
if (details.localFocalPoint.dy > maxHeight - 40) return;
|
|
if (details.pointerCount == 2) {
|
|
interacting = true;
|
|
}
|
|
_initialFocalPoint = details.localFocalPoint;
|
|
// if (kDebugMode) {
|
|
// debugPrint("_initialFocalPoint$_initialFocalPoint");
|
|
// }
|
|
_gestureType = null;
|
|
},
|
|
onInteractionUpdate: (ScaleUpdateDetails details) {
|
|
showRestoreScaleBtn.value =
|
|
transformationController.value.row0.x != 1.0;
|
|
if (interacting || _initialFocalPoint == Offset.zero) return;
|
|
Offset cumulativeDelta =
|
|
details.localFocalPoint - _initialFocalPoint;
|
|
if (details.pointerCount == 2 &&
|
|
cumulativeDelta.distance < 1.5) {
|
|
interacting = true;
|
|
_gestureType = null;
|
|
return;
|
|
}
|
|
|
|
/// 锁定时禁用
|
|
if (plPlayerController.controlsLock.value) return;
|
|
|
|
if (_gestureType == null) {
|
|
if (cumulativeDelta.distance < 1) return;
|
|
if (cumulativeDelta.dx.abs() >
|
|
3 * cumulativeDelta.dy.abs()) {
|
|
_gestureType = 'horizontal';
|
|
} else if (cumulativeDelta.dy.abs() >
|
|
3 * cumulativeDelta.dx.abs()) {
|
|
if (!plPlayerController.enableSlideVolumeBrightness &&
|
|
!plPlayerController.enableSlideFS) {
|
|
return;
|
|
}
|
|
|
|
// _gestureType = 'vertical';
|
|
|
|
final double tapPosition = details.localFocalPoint.dx;
|
|
final double sectionWidth = maxWidth / 3;
|
|
if (tapPosition < sectionWidth) {
|
|
if (!plPlayerController.enableSlideVolumeBrightness) {
|
|
return;
|
|
}
|
|
// 左边区域
|
|
_gestureType = 'left';
|
|
} else if (tapPosition < sectionWidth * 2) {
|
|
if (!plPlayerController.enableSlideFS) {
|
|
return;
|
|
}
|
|
// 全屏
|
|
_gestureType = 'center';
|
|
} else {
|
|
if (!plPlayerController.enableSlideVolumeBrightness) {
|
|
return;
|
|
}
|
|
// 右边区域
|
|
_gestureType = 'right';
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
Offset delta = details.focalPointDelta;
|
|
|
|
if (_gestureType == 'horizontal') {
|
|
// live模式下禁用
|
|
if (plPlayerController.videoType.value == 'live') return;
|
|
|
|
final int curSliderPosition =
|
|
plPlayerController.sliderPosition.value.inMilliseconds;
|
|
final Duration pos = Duration(
|
|
milliseconds: curSliderPosition +
|
|
(plPlayerController.sliderScale *
|
|
delta.dx /
|
|
maxWidth)
|
|
.round()); // TODO
|
|
final Duration result = pos.clamp(
|
|
Duration.zero, plPlayerController.duration.value);
|
|
final height = maxHeight * 0.125;
|
|
if (details.localFocalPoint.dy <= height &&
|
|
(details.localFocalPoint.dx >= maxWidth * 0.875 ||
|
|
details.localFocalPoint.dx <= maxWidth * 0.125)) {
|
|
plPlayerController.cancelSeek = true;
|
|
plPlayerController.showPreview.value = false;
|
|
if (plPlayerController.hasToast != true) {
|
|
plPlayerController.hasToast = true;
|
|
SmartDialog.showAttach(
|
|
targetContext: context,
|
|
alignment: Alignment.center,
|
|
animationTime: const Duration(milliseconds: 200),
|
|
animationType: SmartAnimationType.fade,
|
|
displayTime: const Duration(milliseconds: 1500),
|
|
maskColor: Colors.transparent,
|
|
builder: (context) => Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
borderRadius:
|
|
const BorderRadius.all(Radius.circular(6)),
|
|
color: theme.colorScheme.secondaryContainer,
|
|
),
|
|
child: Text(
|
|
'松开手指,取消进退',
|
|
style: TextStyle(
|
|
color: theme.colorScheme.onSecondaryContainer,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
if (plPlayerController.cancelSeek == true) {
|
|
plPlayerController
|
|
..cancelSeek = null
|
|
..hasToast = null;
|
|
}
|
|
}
|
|
plPlayerController
|
|
..onUpdatedSliderProgress(result)
|
|
..onChangedSliderStart();
|
|
if (plPlayerController.showSeekPreview &&
|
|
plPlayerController.cancelSeek != true) {
|
|
try {
|
|
plPlayerController.previewDx.value =
|
|
result.inMilliseconds /
|
|
plPlayerController
|
|
.durationSeconds.value.inMilliseconds *
|
|
maxWidth;
|
|
if (!plPlayerController.showPreview.value) {
|
|
plPlayerController.showPreview.value = true;
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
} else if (_gestureType == 'left') {
|
|
// 左边区域 👈
|
|
final double level = maxHeight * 3;
|
|
final double brightness =
|
|
_brightnessValue.value - delta.dy / level;
|
|
final double result = brightness.clamp(0.0, 1.0);
|
|
setBrightness(result);
|
|
} else if (_gestureType == 'center') {
|
|
// 全屏
|
|
const double threshold = 2.5; // 滑动阈值
|
|
double cumulativeDy =
|
|
details.localFocalPoint.dy - _initialFocalPoint.dy;
|
|
|
|
void fullScreenTrigger(bool status) {
|
|
plPlayerController.triggerFullScreen(
|
|
status: status, duration: 800);
|
|
}
|
|
|
|
if (cumulativeDy > threshold) {
|
|
_gestureType = 'center_down';
|
|
if (isFullScreen ^
|
|
plPlayerController.fullScreenGestureReverse) {
|
|
fullScreenTrigger(
|
|
plPlayerController.fullScreenGestureReverse);
|
|
}
|
|
// if (kDebugMode) debugPrint('center_down:$cumulativeDy');
|
|
} else if (cumulativeDy < -threshold) {
|
|
_gestureType = 'center_up';
|
|
if (!isFullScreen ^
|
|
plPlayerController.fullScreenGestureReverse) {
|
|
fullScreenTrigger(
|
|
!plPlayerController.fullScreenGestureReverse);
|
|
}
|
|
// if (kDebugMode) debugPrint('center_up:$cumulativeDy');
|
|
}
|
|
} else if (_gestureType == 'right') {
|
|
// 右边区域
|
|
final double level = maxHeight * 0.5;
|
|
EasyThrottle.throttle(
|
|
'setVolume', const Duration(milliseconds: 20), () {
|
|
final double volume =
|
|
_volumeValue.value - delta.dy / level;
|
|
final double result = volume.clamp(0.0, 1.0);
|
|
setVolume(result);
|
|
});
|
|
}
|
|
},
|
|
onInteractionEnd: (ScaleEndDetails details) {
|
|
if (plPlayerController.showSeekPreview) {
|
|
plPlayerController.showPreview.value = false;
|
|
}
|
|
if (plPlayerController.isSliderMoving.value) {
|
|
if (plPlayerController.cancelSeek == true) {
|
|
plPlayerController.onUpdatedSliderProgress(
|
|
plPlayerController.position.value,
|
|
);
|
|
} else {
|
|
plPlayerController.seekTo(
|
|
plPlayerController.sliderPosition.value,
|
|
type: 'slider',
|
|
);
|
|
}
|
|
plPlayerController.onChangedSliderEnd();
|
|
}
|
|
interacting = false;
|
|
_initialFocalPoint = Offset.zero;
|
|
_gestureType = null;
|
|
},
|
|
flipX: plPlayerController.flipX.value,
|
|
flipY: plPlayerController.flipY.value,
|
|
onVerticalDragStart: (details) {
|
|
if (plPlayerController.controlsLock.value) return;
|
|
if (details.localPosition.dy < 40) return;
|
|
if (details.localPosition.dx < 40) return;
|
|
if (details.localPosition.dx > context.width - 40) return;
|
|
if (details.localPosition.dy > context.height - 40) return;
|
|
_initialFocalPoint = details.localPosition;
|
|
_gestureType = null;
|
|
},
|
|
onVerticalDragUpdate: (details) {
|
|
if (plPlayerController.controlsLock.value) return;
|
|
if (!plPlayerController.enableSlideVolumeBrightness &&
|
|
!plPlayerController.enableSlideFS) {
|
|
return;
|
|
}
|
|
final double tapPosition = details.localPosition.dx;
|
|
final double sectionWidth = maxWidth / 3;
|
|
late String gestureType;
|
|
if (tapPosition < sectionWidth) {
|
|
if (!plPlayerController.enableSlideVolumeBrightness) {
|
|
return;
|
|
}
|
|
// 左边区域
|
|
gestureType = 'left';
|
|
} else if (tapPosition < sectionWidth * 2) {
|
|
if (!plPlayerController.enableSlideFS) {
|
|
return;
|
|
}
|
|
// 全屏
|
|
gestureType = 'center';
|
|
} else {
|
|
if (!plPlayerController.enableSlideVolumeBrightness) {
|
|
return;
|
|
}
|
|
// 右边区域
|
|
gestureType = 'right';
|
|
}
|
|
|
|
if (_gestureType != null && _gestureType != gestureType) {
|
|
return;
|
|
}
|
|
_gestureType = gestureType;
|
|
|
|
if (_gestureType == 'left') {
|
|
// 左边区域 👈
|
|
final double level = maxHeight * 3;
|
|
final double brightness =
|
|
_brightnessValue.value - details.delta.dy / level;
|
|
final double result = brightness.clamp(0.0, 1.0);
|
|
setBrightness(result);
|
|
} else if (_gestureType == 'center') {
|
|
// 全屏
|
|
const double threshold = 2.5; // 滑动阈值
|
|
double cumulativeDy =
|
|
details.localPosition.dy - _initialFocalPoint.dy;
|
|
|
|
void fullScreenTrigger(bool status) {
|
|
plPlayerController.triggerFullScreen(status: status);
|
|
}
|
|
|
|
if (cumulativeDy > threshold) {
|
|
_gestureType = 'center_down';
|
|
if (isFullScreen ^
|
|
plPlayerController.fullScreenGestureReverse) {
|
|
fullScreenTrigger(
|
|
plPlayerController.fullScreenGestureReverse);
|
|
}
|
|
// if (kDebugMode) debugPrint('center_down:$cumulativeDy');
|
|
} else if (cumulativeDy < -threshold) {
|
|
_gestureType = 'center_up';
|
|
if (!isFullScreen ^
|
|
plPlayerController.fullScreenGestureReverse) {
|
|
fullScreenTrigger(
|
|
!plPlayerController.fullScreenGestureReverse);
|
|
}
|
|
// if (kDebugMode) debugPrint('center_up:$cumulativeDy');
|
|
}
|
|
} else if (_gestureType == 'right') {
|
|
// 右边区域
|
|
final double level = maxHeight * 0.5;
|
|
EasyThrottle.throttle(
|
|
'setVolume', const Duration(milliseconds: 20), () {
|
|
final double volume =
|
|
_volumeValue.value - details.delta.dy / level;
|
|
final double result = volume.clamp(0.0, 1.0);
|
|
setVolume(result);
|
|
});
|
|
}
|
|
},
|
|
onVerticalDragEnd: (details) {
|
|
interacting = false;
|
|
_initialFocalPoint = Offset.zero;
|
|
_gestureType = null;
|
|
},
|
|
onTap: () {
|
|
plPlayerController.controls =
|
|
!plPlayerController.showControls.value;
|
|
},
|
|
onDoubleTapDown: (TapDownDetails details) {
|
|
if (plPlayerController.controlsLock.value) {
|
|
return;
|
|
}
|
|
if (plPlayerController.videoType.value == 'live') {
|
|
doubleTapFuc('center');
|
|
return;
|
|
}
|
|
final double tapPosition = details.localPosition.dx;
|
|
final double sectionWidth = maxWidth / 4;
|
|
String type = 'left';
|
|
if (tapPosition < sectionWidth) {
|
|
type = 'left';
|
|
} else if (tapPosition < sectionWidth * 3) {
|
|
type = 'center';
|
|
} else {
|
|
type = 'right';
|
|
}
|
|
doubleTapFuc(type);
|
|
},
|
|
onLongPressStart: (LongPressStartDetails detail) {
|
|
plPlayerController.setLongPressStatus(true);
|
|
},
|
|
onLongPressEnd: (LongPressEndDetails details) {
|
|
plPlayerController.setLongPressStatus(false);
|
|
},
|
|
enableDragSubtitle: plPlayerController.enableDragSubtitle,
|
|
onUpdatePadding: plPlayerController.onUpdatePadding,
|
|
),
|
|
),
|
|
|
|
// /// 弹幕面板
|
|
// if (widget.danmuWidget != null)
|
|
// Positioned.fill(top: 4, child: widget.danmuWidget!),
|
|
|
|
/// 长按倍速 toast
|
|
Obx(
|
|
() => Align(
|
|
alignment: Alignment.topCenter,
|
|
child: FractionalTranslation(
|
|
translation: const Offset(0.0, 0.3), // 上下偏移量(负数向上偏移)
|
|
child: AnimatedOpacity(
|
|
curve: Curves.easeInOut,
|
|
opacity:
|
|
plPlayerController.longPressStatus.value ? 1.0 : 0.0,
|
|
duration: const Duration(milliseconds: 150),
|
|
child: Container(
|
|
alignment: Alignment.center,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0x88000000),
|
|
borderRadius: BorderRadius.all(Radius.circular(16)),
|
|
),
|
|
height: 32.0,
|
|
width: 70.0,
|
|
child: Center(
|
|
child: Obx(
|
|
() => Text(
|
|
'${plPlayerController.enableAutoLongPressSpeed ? (plPlayerController.longPressStatus.value ? plPlayerController.lastPlaybackSpeed : plPlayerController.playbackSpeed) * 2 : plPlayerController.longPressSpeed}倍速中',
|
|
style: const TextStyle(
|
|
color: Colors.white, fontSize: 13),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
/// 时间进度 toast
|
|
IgnorePointer(
|
|
ignoring: true,
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
child: FractionalTranslation(
|
|
translation: const Offset(0.0, 1.0), // 上下偏移量(负数向上偏移)
|
|
child: Obx(
|
|
() => AnimatedOpacity(
|
|
curve: Curves.easeInOut,
|
|
opacity:
|
|
plPlayerController.isSliderMoving.value ? 1.0 : 0.0,
|
|
duration: const Duration(milliseconds: 150),
|
|
child: IntrinsicWidth(
|
|
child: Container(
|
|
alignment: Alignment.center,
|
|
decoration: const BoxDecoration(
|
|
color: Color(0x88000000),
|
|
borderRadius: BorderRadius.all(Radius.circular(64)),
|
|
),
|
|
height: 34.0,
|
|
padding: const EdgeInsets.only(left: 10, right: 10),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Obx(() {
|
|
return Text(
|
|
DurationUtil.formatDuration(plPlayerController
|
|
.sliderTempPosition.value.inSeconds),
|
|
style: textStyle,
|
|
);
|
|
}),
|
|
const SizedBox(width: 2),
|
|
const Text('/', style: textStyle),
|
|
const SizedBox(width: 2),
|
|
Obx(
|
|
() => Text(
|
|
plPlayerController.durationSeconds.value
|
|
.inMinutes >=
|
|
60
|
|
? printDurationWithHours(
|
|
plPlayerController
|
|
.durationSeconds.value)
|
|
: printDuration(plPlayerController
|
|
.durationSeconds.value),
|
|
style: textStyle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
/// 音量🔊 控制条展示
|
|
IgnorePointer(
|
|
ignoring: true,
|
|
child: Align(
|
|
alignment: Alignment.center,
|
|
child: Obx(
|
|
() => AnimatedOpacity(
|
|
curve: Curves.easeInOut,
|
|
opacity: _volumeIndicator.value ? 1.0 : 0.0,
|
|
duration: const Duration(milliseconds: 150),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 5),
|
|
decoration: const BoxDecoration(
|
|
color: Color(0x88000000),
|
|
borderRadius: BorderRadius.all(Radius.circular(64)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
Icon(
|
|
_volumeValue.value == 0.0
|
|
? Icons.volume_off
|
|
: _volumeValue.value < 0.5
|
|
? Icons.volume_down
|
|
: Icons.volume_up,
|
|
color: Colors.white,
|
|
size: 20.0,
|
|
),
|
|
const SizedBox(width: 2.0),
|
|
Text(
|
|
'${(_volumeValue.value * 100.0).round()}%',
|
|
style: const TextStyle(
|
|
fontSize: 13.0,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
/// 亮度🌞 控制条展示
|
|
IgnorePointer(
|
|
ignoring: true,
|
|
child: Align(
|
|
alignment: Alignment.center,
|
|
child: Obx(
|
|
() => AnimatedOpacity(
|
|
curve: Curves.easeInOut,
|
|
opacity: _brightnessIndicator.value ? 1.0 : 0.0,
|
|
duration: const Duration(milliseconds: 150),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 5),
|
|
decoration: const BoxDecoration(
|
|
color: Color(0x88000000),
|
|
borderRadius: BorderRadius.all(Radius.circular(64)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
Icon(
|
|
_brightnessValue.value < 1.0 / 3.0
|
|
? Icons.brightness_low
|
|
: _brightnessValue.value < 2.0 / 3.0
|
|
? Icons.brightness_medium
|
|
: Icons.brightness_high,
|
|
color: Colors.white,
|
|
size: 18.0,
|
|
),
|
|
const SizedBox(width: 2.0),
|
|
Text(
|
|
'${(_brightnessValue.value * 100.0).round()}%',
|
|
style: const TextStyle(
|
|
fontSize: 13.0,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// 头部、底部控制条
|
|
Obx(
|
|
() => Positioned.fill(
|
|
child: ClipRect(
|
|
child: Column(
|
|
children: [
|
|
AppBarAni(
|
|
controller: animationController,
|
|
visible: !plPlayerController.controlsLock.value &&
|
|
plPlayerController.showControls.value,
|
|
position: 'top',
|
|
child: widget.headerControl,
|
|
),
|
|
const Spacer(),
|
|
AppBarAni(
|
|
controller: animationController,
|
|
visible: !plPlayerController.controlsLock.value &&
|
|
plPlayerController.showControls.value,
|
|
position: 'bottom',
|
|
child: widget.bottomControl ??
|
|
BottomControl(
|
|
controller: plPlayerController,
|
|
buildBottomControl: buildBottomControl,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// if (BuildConfig.isDebug)
|
|
// Positioned(
|
|
// right: 25,
|
|
// top: 125,
|
|
// child: FilledButton.tonal(
|
|
// onPressed: () {
|
|
// transformationController.value = Matrix4.identity()
|
|
// ..translate(0.5, 0.5)
|
|
// ..scale(1.2)
|
|
// ..translate(-0.5, -0.5);
|
|
|
|
// showRestoreScaleBtn.value = true;
|
|
// },
|
|
// child: const Text('scale'),
|
|
// ),
|
|
// ),
|
|
|
|
Obx(
|
|
() => showRestoreScaleBtn.value &&
|
|
plPlayerController.showControls.value
|
|
? Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 95),
|
|
child: FilledButton.tonal(
|
|
style: FilledButton.styleFrom(
|
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
backgroundColor: theme
|
|
.colorScheme.secondaryContainer
|
|
.withValues(alpha: 0.8),
|
|
visualDensity: VisualDensity.compact,
|
|
padding: const EdgeInsets.all(15),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.all(Radius.circular(6)),
|
|
),
|
|
),
|
|
onPressed: () async {
|
|
showRestoreScaleBtn.value = false;
|
|
final animController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 255),
|
|
);
|
|
final anim = Matrix4Tween(
|
|
begin: transformationController.value,
|
|
end: Matrix4.identity(),
|
|
).animate(
|
|
CurveTween(curve: Curves.easeOut)
|
|
.animate(animController),
|
|
);
|
|
void listener() {
|
|
transformationController.value = anim.value;
|
|
}
|
|
|
|
animController.addListener(listener);
|
|
await animController.forward(from: 0);
|
|
animController
|
|
..removeListener(listener)
|
|
..dispose();
|
|
},
|
|
child: const Text('还原屏幕'),
|
|
),
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
|
|
/// 进度条 live模式下禁用
|
|
Obx(
|
|
() {
|
|
final int value =
|
|
plPlayerController.sliderPositionSeconds.value;
|
|
final int max =
|
|
plPlayerController.durationSeconds.value.inSeconds;
|
|
final int buffer = plPlayerController.bufferedSeconds.value;
|
|
if (plPlayerController.showControls.value) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
switch (plPlayerController.defaultBtmProgressBehavior) {
|
|
case BtmProgressBehavior.alwaysShow:
|
|
break;
|
|
case BtmProgressBehavior.alwaysHide:
|
|
return const SizedBox.shrink();
|
|
case BtmProgressBehavior.onlyShowFullScreen:
|
|
if (!isFullScreen) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
case BtmProgressBehavior.onlyHideFullScreen:
|
|
if (isFullScreen) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
}
|
|
|
|
if (plPlayerController.videoType.value == 'live') {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
if (value > max || max <= 0) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
return Positioned(
|
|
bottom: -2.2,
|
|
left: 0,
|
|
right: 0,
|
|
child: Semantics(
|
|
// label: '${(value / max * 100).round()}%',
|
|
value: '${(value / max * 100).round()}%',
|
|
// enabled: false,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
alignment: Alignment.bottomCenter,
|
|
children: [
|
|
if (plPlayerController.dmTrend.isNotEmpty &&
|
|
plPlayerController.showDmTreandChart.value)
|
|
buildDmChart(theme, plPlayerController),
|
|
if (plPlayerController.viewPointList.isNotEmpty &&
|
|
plPlayerController.showVP.value)
|
|
buildViewPointWidget(plPlayerController, 4.25),
|
|
IgnorePointer(
|
|
child: ProgressBar(
|
|
progress: Duration(seconds: value),
|
|
buffered: Duration(seconds: buffer),
|
|
total: Duration(seconds: max),
|
|
progressBarColor: primary,
|
|
baseBarColor: Colors.white.withValues(alpha: 0.2),
|
|
bufferedBarColor: primary.withValues(alpha: 0.4),
|
|
timeLabelLocation: TimeLabelLocation.none,
|
|
thumbColor: primary,
|
|
barHeight: 3.5,
|
|
thumbRadius:
|
|
draggingFixedProgressBar.value ? 7 : 2.5,
|
|
// onDragStart: (duration) {
|
|
// feedBack();
|
|
// plPlayerController.onChangedSliderStart();
|
|
// },
|
|
// onDragUpdate: (duration) {
|
|
// plPlayerController
|
|
// .onUpdatedSliderProgress(duration.timeStamp);
|
|
// if (plPlayerController.showSeekPreview) {
|
|
// if (plPlayerController.showPreview.value.not) {
|
|
// plPlayerController.showPreview.value = true;
|
|
// }
|
|
// plPlayerController.previewDx.value =
|
|
// duration.localPosition.dx;
|
|
// }
|
|
// },
|
|
// onSeek: (duration) {
|
|
// if (plPlayerController.showSeekPreview) {
|
|
// plPlayerController.showPreview.value = false;
|
|
// }
|
|
// plPlayerController.onChangedSliderEnd();
|
|
// plPlayerController
|
|
// .onChangedSlider(duration.inSeconds.toDouble());
|
|
// plPlayerController.seekTo(
|
|
// Duration(seconds: duration.inSeconds),
|
|
// type: 'slider');
|
|
// },
|
|
),
|
|
),
|
|
if (plPlayerController.segmentList.isNotEmpty)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0.75,
|
|
child: IgnorePointer(
|
|
child: CustomPaint(
|
|
size: const Size(double.infinity, 3.5),
|
|
painter: SegmentProgressBar(
|
|
segmentColors:
|
|
plPlayerController.segmentList,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (plPlayerController.viewPointList.isNotEmpty &&
|
|
plPlayerController.showVP.value)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0.75,
|
|
child: IgnorePointer(
|
|
child: CustomPaint(
|
|
size: const Size(double.infinity, 3.5),
|
|
painter: SegmentProgressBar(
|
|
segmentColors:
|
|
plPlayerController.viewPointList,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (plPlayerController.showSeekPreview)
|
|
Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 12,
|
|
child: buildSeekPreviewWidget(plPlayerController),
|
|
),
|
|
],
|
|
),
|
|
// SlideTransition(
|
|
// position: Tween<Offset>(
|
|
// begin: Offset.zero,
|
|
// end: const Offset(0, -1),
|
|
// ).animate(CurvedAnimation(
|
|
// parent: animationController,
|
|
// curve: Curves.easeInOut,
|
|
// )),
|
|
// child: ),
|
|
));
|
|
},
|
|
),
|
|
|
|
// 锁
|
|
SafeArea(
|
|
child: Obx(
|
|
() => Visibility(
|
|
visible: plPlayerController.videoType.value != 'live' &&
|
|
isFullScreen,
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FractionalTranslation(
|
|
translation: const Offset(1, -0.4),
|
|
child: Visibility(
|
|
visible: plPlayerController.showControls.value &&
|
|
(isFullScreen ||
|
|
plPlayerController.controlsLock.value),
|
|
child: DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
color: Color(0x45000000),
|
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
),
|
|
child: ComBtn(
|
|
icon: Icon(
|
|
plPlayerController.controlsLock.value
|
|
? FontAwesomeIcons.lock
|
|
: FontAwesomeIcons.lockOpen,
|
|
semanticLabel:
|
|
plPlayerController.controlsLock.value
|
|
? '解锁'
|
|
: '锁定',
|
|
size: 15,
|
|
color: Colors.white,
|
|
),
|
|
onTap: () => plPlayerController.onLockControl(
|
|
!plPlayerController.controlsLock.value),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// 截图
|
|
SafeArea(
|
|
child: Obx(
|
|
() => Align(
|
|
alignment: Alignment.centerRight,
|
|
child: FractionalTranslation(
|
|
translation: const Offset(-1, -0.4),
|
|
child: Visibility(
|
|
visible:
|
|
plPlayerController.showControls.value && isFullScreen,
|
|
child: DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
color: Color(0x45000000),
|
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
),
|
|
child: ComBtn(
|
|
icon: const Icon(
|
|
Icons.photo_camera,
|
|
semanticLabel: '截图',
|
|
size: 20,
|
|
color: Colors.white,
|
|
),
|
|
onTap: () {
|
|
SmartDialog.showToast('截图中');
|
|
plPlayerController.videoPlayerController
|
|
?.screenshot(format: 'image/png')
|
|
.then((value) {
|
|
if (value != null && context.mounted) {
|
|
SmartDialog.showToast('点击弹窗保存截图');
|
|
showDialog(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
// title: const Text('点击保存'),
|
|
titlePadding: EdgeInsets.zero,
|
|
contentPadding: const EdgeInsets.all(8),
|
|
insetPadding: EdgeInsets.only(
|
|
left: context.width / 2),
|
|
//移除圆角
|
|
shape: const RoundedRectangleBorder(),
|
|
content: GestureDetector(
|
|
onTap: () async {
|
|
String name =
|
|
DateTime.now().toString();
|
|
final SaveResult result =
|
|
await SaverGallery.saveImage(
|
|
value,
|
|
fileName: name,
|
|
androidRelativePath:
|
|
"Pictures/Screenshots",
|
|
skipIfExists: false,
|
|
);
|
|
|
|
if (result.isSuccess) {
|
|
Get.back();
|
|
SmartDialog.showToast(
|
|
'$name.png已保存到相册/截图');
|
|
} else {
|
|
await SmartDialog.showToast(
|
|
'保存失败,${result.errorMessage}');
|
|
}
|
|
},
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: maxWidth / 3,
|
|
maxHeight: maxHeight / 3,
|
|
),
|
|
child: Image.memory(value),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
} else {
|
|
SmartDialog.showToast('截图失败');
|
|
}
|
|
});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
Obx(() {
|
|
if (plPlayerController.dataStatus.loading ||
|
|
plPlayerController.isBuffering.value) {
|
|
return Center(
|
|
child: GestureDetector(
|
|
onTap: plPlayerController.refreshPlayer,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(30),
|
|
decoration: const BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [Colors.black26, Colors.transparent],
|
|
),
|
|
),
|
|
child:
|
|
Column(mainAxisSize: MainAxisSize.min, children: [
|
|
Image.asset(
|
|
'assets/images/loading.gif',
|
|
height: 25,
|
|
semanticLabel: "加载中",
|
|
),
|
|
if (plPlayerController.isBuffering.value)
|
|
Obx(() {
|
|
if (plPlayerController.buffered.value ==
|
|
Duration.zero) {
|
|
return const Text('加载中...',
|
|
style: TextStyle(
|
|
color: Colors.white, fontSize: 12));
|
|
}
|
|
String bufferStr =
|
|
plPlayerController.buffered.toString();
|
|
return Text(
|
|
bufferStr.substring(0, bufferStr.length - 3),
|
|
style: const TextStyle(
|
|
color: Colors.white, fontSize: 12),
|
|
);
|
|
}),
|
|
]),
|
|
)));
|
|
} else {
|
|
return const SizedBox.shrink();
|
|
}
|
|
}),
|
|
|
|
/// 点击 快进/快退
|
|
Obx(
|
|
() => _mountSeekBackwardButton.value ||
|
|
_mountSeekForwardButton.value
|
|
? Positioned.fill(
|
|
child: Row(
|
|
children: [
|
|
if (_mountSeekBackwardButton.value)
|
|
Expanded(
|
|
child: TweenAnimationBuilder<double>(
|
|
tween: Tween<double>(begin: 0.0, end: 1.0),
|
|
duration: const Duration(milliseconds: 500),
|
|
builder: (context, value, child) => Opacity(
|
|
opacity: value,
|
|
child: child,
|
|
),
|
|
child: BackwardSeekIndicator(
|
|
duration: plPlayerController
|
|
.fastForBackwardDuration,
|
|
onSubmitted: (Duration value) {
|
|
_mountSeekBackwardButton.value = false;
|
|
final Player player = widget
|
|
.plPlayerController
|
|
.videoPlayerController!;
|
|
Duration result =
|
|
player.state.position - value;
|
|
result = result.clamp(
|
|
Duration.zero,
|
|
player.state.duration,
|
|
);
|
|
plPlayerController
|
|
..seekTo(
|
|
result,
|
|
type: 'slider',
|
|
)
|
|
..play();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const Spacer(flex: 2),
|
|
if (_mountSeekForwardButton.value)
|
|
Expanded(
|
|
child: TweenAnimationBuilder<double>(
|
|
tween: Tween<double>(begin: 0.0, end: 1.0),
|
|
duration: const Duration(milliseconds: 500),
|
|
builder: (context, value, child) => Opacity(
|
|
opacity: value,
|
|
child: child,
|
|
),
|
|
child: ForwardSeekIndicator(
|
|
duration: plPlayerController
|
|
.fastForBackwardDuration,
|
|
onSubmitted: (Duration value) {
|
|
_mountSeekForwardButton.value = false;
|
|
final Player player = widget
|
|
.plPlayerController
|
|
.videoPlayerController!;
|
|
Duration result =
|
|
player.state.position + value;
|
|
result = result.clamp(
|
|
Duration.zero,
|
|
player.state.duration,
|
|
);
|
|
plPlayerController
|
|
..seekTo(
|
|
result,
|
|
type: 'slider',
|
|
)
|
|
..play();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget buildDmChart(
|
|
ThemeData theme,
|
|
PlPlayerController plPlayerController, [
|
|
double offset = 0,
|
|
]) {
|
|
final color = theme.colorScheme.primary;
|
|
return IgnorePointer(
|
|
child: Container(
|
|
height: 12,
|
|
margin: EdgeInsets.only(
|
|
bottom: plPlayerController.viewPointList.isNotEmpty &&
|
|
plPlayerController.showVP.value
|
|
? 20.25 + offset
|
|
: 4.25 + offset,
|
|
),
|
|
child: LineChart(
|
|
LineChartData(
|
|
titlesData: const FlTitlesData(show: false),
|
|
lineTouchData: const LineTouchData(enabled: false),
|
|
gridData: const FlGridData(show: false),
|
|
borderData: FlBorderData(show: false),
|
|
minX: 0,
|
|
maxX: (plPlayerController.dmTrend.length - 1).toDouble(),
|
|
minY: 0,
|
|
maxY: plPlayerController.dmTrend
|
|
.reduce((a, b) => a > b ? a : b)
|
|
.toDouble(),
|
|
lineBarsData: [
|
|
LineChartBarData(
|
|
spots: List.generate(
|
|
plPlayerController.dmTrend.length,
|
|
(index) => FlSpot(
|
|
index.toDouble(),
|
|
plPlayerController.dmTrend[index],
|
|
),
|
|
),
|
|
isCurved: true,
|
|
barWidth: 1,
|
|
color: color,
|
|
dotData: const FlDotData(show: false),
|
|
belowBarData: BarAreaData(
|
|
show: true,
|
|
color: color.withValues(alpha: 0.4),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget buildSeekPreviewWidget(PlPlayerController plPlayerController) {
|
|
return Obx(
|
|
() {
|
|
if (!plPlayerController.showPreview.value ||
|
|
plPlayerController.videoShot?['status'] != true) {
|
|
if (plPlayerController.videoShot == null) {
|
|
plPlayerController.getVideoShot();
|
|
}
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
VideoShotData data = plPlayerController.videoShot!['data'];
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
try {
|
|
double scale = plPlayerController.isFullScreen.value &&
|
|
plPlayerController.direction.value == 'horizontal'
|
|
? 4
|
|
: 2.5;
|
|
// offset
|
|
double left = (plPlayerController.previewDx.value - 48 * scale / 2)
|
|
.clamp(8, constraints.maxWidth - 48 * scale - 8);
|
|
|
|
// index
|
|
// int index = plPlayerController.sliderPositionSeconds.value ~/ 5;
|
|
int index = max(
|
|
0,
|
|
(data.index!
|
|
.where((item) =>
|
|
item <=
|
|
plPlayerController.sliderPositionSeconds.value)
|
|
.length -
|
|
2));
|
|
|
|
// pageIndex
|
|
int pageIndex = (index ~/ 100).clamp(0, data.image!.length - 1);
|
|
|
|
// alignment
|
|
double cal(m) {
|
|
return -1 + 2 / 9 * m;
|
|
}
|
|
|
|
int align = index % 100;
|
|
int x = align % 10;
|
|
int y = align ~/ 10;
|
|
double dx = cal(x);
|
|
double dy = cal(y);
|
|
Alignment alignment = Alignment(dx, dy);
|
|
|
|
return Container(
|
|
alignment: Alignment.centerLeft,
|
|
padding: EdgeInsets.only(left: left),
|
|
child: UnconstrainedBox(
|
|
child: ClipRRect(
|
|
borderRadius: scale == 2.5
|
|
? const BorderRadius.all(Radius.circular(6))
|
|
: StyleString.mdRadius,
|
|
child: Align(
|
|
widthFactor: 0.1,
|
|
heightFactor: 0.1,
|
|
alignment: alignment,
|
|
child: CachedNetworkImage(
|
|
fit: BoxFit.fill,
|
|
width: 480 * scale,
|
|
height: 270 * scale,
|
|
imageUrl: data.image![pageIndex].http2https,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
if (kDebugMode) debugPrint('seek preview: $e');
|
|
return const SizedBox.shrink();
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget buildViewPointWidget(
|
|
PlPlayerController plPlayerController, double offset) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return Container(
|
|
height: 16,
|
|
margin: EdgeInsets.only(bottom: offset),
|
|
child: Listener(
|
|
behavior: HitTestBehavior.opaque,
|
|
onPointerDown: (event) {
|
|
try {
|
|
double seg = event.localPosition.dx / constraints.maxWidth;
|
|
Segment item = plPlayerController.viewPointList.where((item) {
|
|
return item.start >= seg;
|
|
}).reduce((a, b) => a.start < b.start ? a : b);
|
|
if (item.from != null) {
|
|
plPlayerController.danmakuController?.clear();
|
|
plPlayerController.videoPlayerController
|
|
?.seek(Duration(seconds: item.from!));
|
|
}
|
|
// if (kDebugMode) debugPrint('${item.title},,${item.from}');
|
|
} catch (e) {
|
|
if (kDebugMode) debugPrint('$e');
|
|
}
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|