feat: support dynaudnorm & webp (#1186)

* feat: support dynaudnorm & webp

* Revert "remove audio_normalization"

This reverts commit 477b59ce89.

* feat: save webp

* mod: strokeWidth

* feat: webp preset

* feat: save webp select qa

* upgrade volume_controller
This commit is contained in:
My-Responsitories
2025-09-04 20:09:50 +08:00
committed by GitHub
parent f0828ea18c
commit e8a674ca2a
16 changed files with 792 additions and 328 deletions

View File

@@ -11,6 +11,7 @@ import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/ua_type.dart';
import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/common/account_type.dart';
import 'package:PiliPlus/models/common/audio_normalization.dart';
import 'package:PiliPlus/models/common/sponsor_block/skip_type.dart';
import 'package:PiliPlus/models/common/super_resolution_type.dart';
import 'package:PiliPlus/models/common/video/video_type.dart';
@@ -43,7 +44,6 @@ import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
import 'package:get/get.dart';
import 'package:hive/hive.dart';
import 'package:media_kit/media_kit.dart';
@@ -52,6 +52,7 @@ import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:screen_brightness/screen_brightness.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:volume_controller/volume_controller.dart';
class PlPlayerController {
Player? _videoPlayerController;
@@ -717,7 +718,16 @@ class PlPlayerController {
if (isAnim) {
setShader(superResolutionType.value, pp);
}
await pp.setProperty("af", "scaletempo2=max-speed=8");
String audioNormalization = Pref.audioNormalization;
audioNormalization = switch (audioNormalization) {
'0' => '',
'1' => ',${AudioNormalization.dynaudnorm.param}',
_ => ',$audioNormalization',
};
await pp.setProperty(
"af",
"scaletempo2=max-speed=8$audioNormalization",
);
if (Platform.isAndroid) {
await pp.setProperty("volume-max", "100");
String ao = Pref.useOpenSLES
@@ -943,7 +953,12 @@ class PlPlayerController {
isLive,
);
}),
if (kDebugMode)
videoPlayerController!.stream.log.listen(((PlayerLog log) {
debugPrint(log.toString());
})),
videoPlayerController!.stream.error.listen((String event) {
debugPrint('MPV Exception: $event');
if (isLive) {
if (event.startsWith('tcp: ffurl_read returned ') ||
event.startsWith("Failed to open https://") ||
@@ -982,16 +997,13 @@ class PlPlayerController {
);
} else if (event.startsWith('Could not open codec')) {
SmartDialog.showToast('无法加载解码器, $event,可能会切换至软解');
} else {
if (!onlyPlayAudio.value) {
if (event.startsWith("Failed to open .") ||
event.startsWith("Cannot open") ||
event.startsWith("Can not open")) {
return;
}
SmartDialog.showToast('视频加载错误, $event');
if (kDebugMode) debugPrint('视频加载错误, $event');
} else if (!onlyPlayAudio.value) {
if (event.startsWith("Failed to open .") ||
event.startsWith("Cannot open") ||
event.startsWith("Can not open")) {
return;
}
SmartDialog.showToast('视频加载错误, $event');
}
}),
// videoPlayerController!.stream.volume.listen((event) {
@@ -1200,7 +1212,7 @@ class PlPlayerController {
Future<void> getCurrentVolume() async {
// mac try...catch
try {
_currentVolume.value = (await FlutterVolumeController.getVolume())!;
_currentVolume.value = await VolumeController.instance.getVolume();
} catch (_) {}
}
@@ -1219,8 +1231,9 @@ class PlPlayerController {
volume.value = volumeNew;
try {
FlutterVolumeController.updateShowSystemUI(false);
await FlutterVolumeController.setVolume(volumeNew);
await (VolumeController.instance..showSystemUI = false).setVolume(
volumeNew,
);
} catch (err) {
if (kDebugMode) debugPrint(err.toString());
}

View File

@@ -4,10 +4,15 @@ import 'dart:math';
import 'dart:ui' as ui;
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/loading_widget.dart';
import 'package:PiliPlus/common/widgets/pair.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/common/widgets/view_safe_area.dart';
import 'package:PiliPlus/http/init.dart';
import 'package:PiliPlus/models/common/sponsor_block/action_type.dart';
import 'package:PiliPlus/models/common/sponsor_block/post_segment_model.dart';
import 'package:PiliPlus/models/common/sponsor_block/segment_type.dart';
import 'package:PiliPlus/models/common/super_resolution_type.dart';
import 'package:PiliPlus/models/common/video/video_quality.dart';
import 'package:PiliPlus/models/video/play/url.dart';
@@ -18,6 +23,7 @@ import 'package:PiliPlus/models_new/video/video_shot/data.dart';
import 'package:PiliPlus/pages/common/common_intro_controller.dart';
import 'package:PiliPlus/pages/video/controller.dart';
import 'package:PiliPlus/pages/video/introduction/pgc/controller.dart';
import 'package:PiliPlus/pages/video/post_panel/view.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';
@@ -31,11 +37,14 @@ 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/mpv_convert_webp.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:PiliPlus/utils/storage.dart';
import 'package:PiliPlus/utils/storage_key.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:easy_debounce/easy_throttle.dart';
@@ -44,7 +53,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.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' hide ContextExtensionss;
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
@@ -52,6 +60,7 @@ 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';
import 'package:volume_controller/volume_controller.dart';
class PLVideoPlayer extends StatefulWidget {
const PLVideoPlayer({
@@ -203,12 +212,12 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
videoController = plPlayerController.videoController!;
Future.microtask(() async {
try {
FlutterVolumeController.updateShowSystemUI(true);
_volumeValue.value = (await FlutterVolumeController.getVolume())!;
FlutterVolumeController.addListener((double value) {
final volumeCtr = VolumeController.instance..showSystemUI = true;
_volumeValue.value = await volumeCtr.getVolume();
volumeCtr.addListener((double value) {
if (mounted && !_volumeInterceptEventStream.value) {
_volumeValue.value = value;
if (Platform.isIOS && !FlutterVolumeController.showSystemUI) {
if (Platform.isIOS && !volumeCtr.showSystemUI) {
_volumeIndicator.value = true;
_volumeTimer?.cancel();
_volumeTimer = Timer(const Duration(milliseconds: 800), () {
@@ -239,9 +248,9 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
Future<void> setVolume(double value) async {
try {
FlutterVolumeController.updateShowSystemUI(false);
await FlutterVolumeController.setVolume(value);
await (VolumeController.instance..showSystemUI = false).setVolume(value);
} catch (_) {}
_volumeValue.value = value;
_volumeIndicator.value = true;
_volumeInterceptEventStream.value = true;
@@ -273,7 +282,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
_listener?.cancel();
_controlsListener?.cancel();
animationController.dispose();
FlutterVolumeController.removeListener();
VolumeController.instance.removeListener();
transformationController.dispose();
super.dispose();
}
@@ -403,6 +412,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
/// 超分辨率
BottomControlType.superResolution => Obx(
() => PopupMenuButton<SuperResolutionType>(
tooltip: '超分辨率',
requestFocus: false,
initialValue: plPlayerController.superResolutionType.value,
color: Colors.black.withValues(alpha: 0.8),
@@ -511,6 +521,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
/// 画面比例
BottomControlType.fit => Obx(
() => PopupMenuButton<VideoFitType>(
tooltip: '画面比例',
requestFocus: false,
initialValue: plPlayerController.videoFit.value,
color: Colors.black.withValues(alpha: 0.8),
@@ -545,6 +556,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
() => widget.videoDetailController?.subtitles.isEmpty == true
? const SizedBox.shrink()
: PopupMenuButton<int>(
tooltip: '选择字幕',
requestFocus: false,
initialValue: widget
.videoDetailController!
@@ -597,6 +609,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
/// 播放速度
BottomControlType.speed => Obx(
() => PopupMenuButton<double>(
tooltip: '倍速',
requestFocus: false,
initialValue: plPlayerController.playbackSpeed,
color: Colors.black.withValues(alpha: 0.8),
@@ -650,6 +663,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
}
}
return PopupMenuButton<int>(
tooltip: '画质',
requestFocus: false,
initialValue: currentVideoQa.code,
color: Colors.black.withValues(alpha: 0.8),
@@ -1655,6 +1669,10 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
size: 20,
color: Colors.white,
),
onLongPress:
(Platform.isAndroid || kDebugMode) && !isLive
? screenshotWebp
: null,
onTap: () {
SmartDialog.showToast('截图中');
plPlayerController.videoPlayerController
@@ -1857,6 +1875,155 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
],
);
}
Future<void> screenshotWebp() async {
final videoCtr = widget.videoDetailController!;
final videoInfo = widget.videoDetailController!.data;
final ids = videoInfo.dash!.video!.map((i) => i.id!).toSet();
final video = videoCtr.findVideoByQa(ids.reduce((p, n) => p < n ? p : n));
VideoQuality qa = video.quality;
String? url = video.baseUrl;
if (url == null) return;
final ctr = plPlayerController;
final theme = Theme.of(context);
final currentPos = ctr.position.value.inMilliseconds / 1000.0;
final duration = ctr.durationSeconds.value.inMilliseconds / 1000.0;
final segment = Pair(first: currentPos, second: currentPos + 10.0);
final model = PostSegmentModel(
segment: segment,
category: SegmentType.sponsor,
actionType: ActionType.skip,
);
final isPlay = ctr.playerStatus.playing;
if (isPlay) ctr.pause();
WebpPreset preset = WebpPreset.def;
final success =
await Get.dialog<bool>(
AlertDialog(
title: const Text('动态截图'),
content: Column(
spacing: 12,
mainAxisSize: MainAxisSize.min,
children: [
PostPanel.segmentWidget(
theme,
item: model,
currentPos: currentPos,
videoDuration: duration,
),
Builder(
builder: (context) => PopupMenuButton(
initialValue: qa.code,
onSelected: (value) {
if (value == qa.code) return;
final video = videoCtr.findVideoByQa(value);
url = video.baseUrl;
qa = video.quality;
(context as Element).markNeedsBuild();
},
itemBuilder: (_) => videoInfo.supportFormats!
.map(
(i) => PopupMenuItem<int>(
enabled: ids.contains(i.quality),
value: i.quality,
child: Text(i.newDesc ?? ''),
),
)
.toList(),
child: Text('转码画质:${qa.shortDesc}'),
),
),
Builder(
builder: (context) => PopupMenuButton(
initialValue: preset,
onSelected: (value) {
if (preset == value) return;
preset = value;
(context as Element).markNeedsBuild();
},
itemBuilder: (_) => WebpPreset.values
.map(
(i) => PopupMenuItem(value: i, child: Text(i.name)),
)
.toList(),
child: Text('webp预设${preset.name}${preset.desc}'),
),
),
Text(
'*转码使用软解,速度可能慢于播放,请不要选择过长的时间段或过高画质',
style: theme.textTheme.bodySmall,
),
],
),
actions: [
TextButton(
onPressed: Get.back,
child: Text(
'取消',
style: TextStyle(
color: theme.colorScheme.outline,
),
),
),
TextButton(
onPressed: () {
if (segment.first < segment.second) {
Get.back(result: true);
}
},
child: const Text('确定'),
),
],
),
) ??
false;
if (!success) return;
final progress = 0.0.obs;
final name =
'${ctr.cid}-${segment.first.toStringAsFixed(3)}_${segment.second.toStringAsFixed(3)}.webp';
final file = '${await Utils.temporaryDirectory}/$name';
final mpv = MpvConvertWebp(
url!,
file,
segment.first,
segment.second,
progress: progress,
preset: preset,
);
final future = mpv.convert().whenComplete(
() => SmartDialog.dismiss(status: SmartStatus.loading),
);
SmartDialog.showLoading(
backType: SmartBackType.normal,
builder: (_) => LoadingWidget(progress: progress, msg: '正在保存,可能需要较长时间'),
onDismiss: () async {
if (progress.value < 1.0) {
mpv.dispose();
}
if (await future) {
await SaverGallery.saveFile(
filePath: file,
fileName: name,
androidRelativePath: 'Pictures/Screenshots',
skipIfExists: false,
);
SmartDialog.showToast('$name已保存到相册/截图');
} else {
SmartDialog.showToast('转码出现错误或已取消');
}
progress.close();
File(file).delSync();
if (isPlay) ctr.play();
},
);
}
}
Widget buildDmChart(

View File

@@ -0,0 +1,186 @@
import 'dart:async';
import 'dart:ffi';
import 'package:PiliPlus/http/constants.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:get/get_rx/get_rx.dart';
import 'package:media_kit/ffi/src/allocation.dart';
import 'package:media_kit/ffi/src/utf8.dart';
import 'package:media_kit/generated/libmpv/bindings.dart' as generated;
import 'package:media_kit/src/player/native/core/initializer.dart';
import 'package:media_kit/src/player/native/core/native_library.dart';
class MpvConvertWebp {
final _mpv = generated.MPV(DynamicLibrary.open(NativeLibrary.path));
late final Pointer<generated.mpv_handle> _ctx;
final _completer = Completer<bool>();
bool _success = false;
final String url;
final String outFile;
final double start;
final double duration;
final RxDouble? progress;
final WebpPreset preset;
MpvConvertWebp(
this.url,
this.outFile,
this.start,
double end, {
this.progress,
this.preset = WebpPreset.def,
}) : duration = end - start;
Future<void> _init() async {
_ctx = await Initializer.create(
NativeLibrary.path,
_onEvent,
options: {
'o': outFile,
'start': start.toStringAsFixed(3),
'end': (start + duration).toStringAsFixed(3),
'of': 'webp',
'ovc': 'libwebp_anim',
'ofopts': 'loop=0',
'ovcopts': 'preset=${preset.flag}',
},
);
_setHeader();
if (progress != null) {
_observeProperty('time-pos');
}
final level = (kDebugMode ? 'info' : 'error').toNativeUtf8();
_mpv.mpv_request_log_messages(_ctx, level.cast());
calloc.free(level);
}
void dispose() {
Initializer.dispose(_ctx);
_mpv.mpv_terminate_destroy(_ctx);
if (!_completer.isCompleted) _completer.complete(false);
}
Future<bool> convert() async {
await _init();
_command(['loadfile', url]);
return _completer.future;
}
Future<void> _onEvent(Pointer<generated.mpv_event> event) async {
switch (event.ref.event_id) {
case generated.mpv_event_id.MPV_EVENT_PROPERTY_CHANGE:
final prop = event.ref.data.cast<generated.mpv_event_property>().ref;
if (prop.name.cast<Utf8>().toDartString() == 'time-pos' &&
prop.format == generated.mpv_format.MPV_FORMAT_DOUBLE) {
progress!.value = (prop.data.cast<Double>().value - start) / duration;
}
break;
case generated.mpv_event_id.MPV_EVENT_FILE_LOADED:
_success = true;
break;
case generated.mpv_event_id.MPV_EVENT_LOG_MESSAGE:
final log = event.ref.data.cast<generated.mpv_event_log_message>().ref;
final prefix = log.prefix.cast<Utf8>().toDartString().trim();
final level = log.level.cast<Utf8>().toDartString().trim();
final text = log.text.cast<Utf8>().toDartString().trim();
debugPrint('WebpConvert: $level $prefix : $text');
if (kDebugMode) {
_success = level != 'error' && level != 'fatal';
} else {
_success = false;
}
break;
case generated.mpv_event_id.MPV_EVENT_END_FILE ||
generated.mpv_event_id.MPV_EVENT_SHUTDOWN:
progress?.value = 1;
_completer.complete(_success);
dispose();
break;
}
}
void _command(List<String> args) {
final pointers = args.map((e) => e.toNativeUtf8()).toList();
final arr = calloc<Pointer<Utf8>>(128);
for (int i = 0; i < args.length; i++) {
arr[i] = pointers[i];
}
_mpv.mpv_command(_ctx, arr.cast());
calloc.free(arr);
pointers.forEach(calloc.free);
}
void _observeProperty(String property) {
final name = property.toNativeUtf8();
_mpv.mpv_observe_property(
_ctx,
property.hashCode,
name.cast(),
generated.mpv_format.MPV_FORMAT_DOUBLE,
);
calloc.free(name);
}
void _setHeader() {
final property = 'http-header-fields'.toNativeUtf8();
// Allocate & fill the [mpv_node] with the headers.
final value = calloc<generated.mpv_node>();
final valRef = value.ref
..format = generated.mpv_format.MPV_FORMAT_NODE_ARRAY;
valRef.u.list = calloc<generated.mpv_node_list>();
final valList = valRef.u.list.ref
..num = 2
..values = calloc<generated.mpv_node>(2);
const entries = [
(
'user-agent',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36',
),
('referer', HttpString.baseUrl),
];
for (int i = 0; i < 2; i++) {
final (k, v) = entries[i];
valList.values[i]
..format = generated.mpv_format.MPV_FORMAT_STRING
..u.string = '$k: $v'.toNativeUtf8().cast();
}
_mpv.mpv_set_property(
_ctx,
property.cast(),
generated.mpv_format.MPV_FORMAT_NODE,
value.cast(),
);
// Free the allocated memory.
calloc.free(property);
for (int i = 0; i < valList.num; i++) {
calloc.free(valList.values[i].u.string);
}
calloc
..free(valList.values)
..free(valRef.u.list)
..free(value);
}
}
enum WebpPreset {
none('none', '', '不使用预设'),
def('default', '默认', '默认预设'),
picture('picture', '图片', '数码照片,如人像、室内拍摄'),
photo('photo', '照片', '户外摄影,自然光环境'),
drawing('drawing', '绘图', '手绘或线稿,高对比度细节'),
icon('icon', '图标', '小型彩色图像'),
text('text', '文本', '文字类');
final String flag;
final String name;
final String desc;
const WebpPreset(this.flag, this.name, this.desc);
}