mirror of
https://github.com/bggRGjQaUbCoE/PiliPlus.git
synced 2026-04-23 04:00:28 +08:00
refa player (#1848)
* tweak
* Reapply "opt: audio uri" (#1833)
This reverts commit 8e726f49b2.
* opt: player
* opt: player
* refa: create player
* refa: player
* opt: UaType
* fix: sb seek preview
* opt: setSub
* fix: screenshot
* opt: unnecessary final player state
* opt: player track
* opt FileSource constructor [skip ci]
* fixes
* fix: dispose player
* fix: quote
* update
* fix: multi ua & remove unused loop
* remove unneeded check [skip ci]
---------
Co-authored-by: dom <githubaccount56556@proton.me>
This commit is contained in:
committed by
GitHub
parent
6782bee11a
commit
7276cde48a
@@ -1,13 +1,14 @@
|
||||
import 'dart:async' show StreamSubscription, Timer;
|
||||
import 'dart:convert' show ascii;
|
||||
import 'dart:io' show Platform, File, Directory;
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:math' show max, min;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:PiliPlus/common/constants.dart';
|
||||
import 'package:PiliPlus/http/browser_ua.dart';
|
||||
import 'package:PiliPlus/http/constants.dart';
|
||||
import 'package:PiliPlus/http/init.dart';
|
||||
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';
|
||||
@@ -31,6 +32,7 @@ import 'package:PiliPlus/plugin/pl_player/models/video_fit_type.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/utils/fullscreen.dart';
|
||||
import 'package:PiliPlus/services/service_locator.dart';
|
||||
import 'package:PiliPlus/utils/accounts.dart';
|
||||
import 'package:PiliPlus/utils/asset_utils.dart';
|
||||
import 'package:PiliPlus/utils/extension/box_ext.dart';
|
||||
import 'package:PiliPlus/utils/extension/num_ext.dart';
|
||||
import 'package:PiliPlus/utils/extension/string_ext.dart';
|
||||
@@ -50,8 +52,7 @@ import 'package:easy_debounce/easy_throttle.dart';
|
||||
import 'package:floating/floating.dart';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart'
|
||||
show rootBundle, HapticFeedback, Uint8List;
|
||||
import 'package:flutter/services.dart' show HapticFeedback;
|
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
|
||||
import 'package:flutter_volume_controller/flutter_volume_controller.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -156,8 +157,6 @@ class PlPlayerController with BlockConfigMixin {
|
||||
///
|
||||
final RxBool isSliderMoving = false.obs;
|
||||
|
||||
/// 是否循环
|
||||
PlaylistMode _looping = PlaylistMode.none;
|
||||
bool _autoPlay = false;
|
||||
|
||||
// 记录历史记录
|
||||
@@ -177,7 +176,7 @@ class PlPlayerController with BlockConfigMixin {
|
||||
late DataSource dataSource;
|
||||
|
||||
Timer? _timer;
|
||||
Timer? _timerForSeek;
|
||||
StreamSubscription<Duration>? _subForSeek;
|
||||
|
||||
Box setting = GStorage.setting;
|
||||
|
||||
@@ -255,10 +254,16 @@ class PlPlayerController with BlockConfigMixin {
|
||||
windowManager.setTitleBarStyle(TitleBarStyle.hidden);
|
||||
}
|
||||
|
||||
late final Size size;
|
||||
final state = videoController!.player.state;
|
||||
final width = state.width ?? this.width ?? 16;
|
||||
final height = state.height ?? this.height ?? 9;
|
||||
final Size size;
|
||||
final state = videoPlayerController!.state;
|
||||
int width = state.width;
|
||||
int height = state.height;
|
||||
if (width == 0) {
|
||||
width = this.width ?? 16;
|
||||
}
|
||||
if (height == 0) {
|
||||
height = this.height ?? 9;
|
||||
}
|
||||
if (height > width) {
|
||||
size = Size(280.0, 280.0 * height / width);
|
||||
} else {
|
||||
@@ -299,13 +304,13 @@ class PlPlayerController with BlockConfigMixin {
|
||||
}
|
||||
|
||||
void enterPip({bool isAuto = false}) {
|
||||
if (videoController != null) {
|
||||
if (videoPlayerController != null) {
|
||||
controls = false;
|
||||
final state = videoController!.player.state;
|
||||
final state = videoPlayerController!.state;
|
||||
PageUtils.enterPip(
|
||||
isAuto: isAuto,
|
||||
width: state.width ?? width,
|
||||
height: state.height ?? height,
|
||||
width: state.width == 0 ? width : state.width,
|
||||
height: state.height == 0 ? height : state.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -387,9 +392,7 @@ class PlPlayerController with BlockConfigMixin {
|
||||
late int? cacheVideoQa = PlatformUtils.isMobile ? null : Pref.defaultVideoQa;
|
||||
late int cacheAudioQa = Pref.defaultAudioQa;
|
||||
bool enableHeart = true;
|
||||
|
||||
late final bool enableHA = Pref.enableHA;
|
||||
late final String hwdec = Pref.hardwareDecoding;
|
||||
late final String? hwdec = Pref.enableHA ? Pref.hardwareDecoding : null;
|
||||
|
||||
late final progressType = Pref.btmProgressBehavior;
|
||||
late final enableQuickDouble = Pref.enableQuickDouble;
|
||||
@@ -517,8 +520,8 @@ class PlPlayerController with BlockConfigMixin {
|
||||
return _instance?.volume.value;
|
||||
}
|
||||
|
||||
static Future<void> setVolumeIfExists(double volumeNew) async {
|
||||
await _instance?.setVolume(volumeNew);
|
||||
static Future<void>? setVolumeIfExists(double volumeNew) {
|
||||
return _instance?.setVolume(volumeNew);
|
||||
}
|
||||
|
||||
Box video = GStorage.video;
|
||||
@@ -549,29 +552,22 @@ class PlPlayerController with BlockConfigMixin {
|
||||
// 获取实例 传参
|
||||
static PlPlayerController getInstance({bool isLive = false}) {
|
||||
// 如果实例尚未创建,则创建一个新实例
|
||||
_instance ??= PlPlayerController._();
|
||||
_instance!
|
||||
return (_instance ??= PlPlayerController._())
|
||||
..isLive = isLive
|
||||
.._playerCount += 1;
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
bool _processing = false;
|
||||
bool get processing => _processing;
|
||||
|
||||
// offline
|
||||
bool isFileSource = false;
|
||||
String? dirPath;
|
||||
String? typeTag;
|
||||
int? mediaType;
|
||||
bool get isFileSource => dataSource is FileSource;
|
||||
|
||||
// 初始化资源
|
||||
Future<void> setDataSource(
|
||||
DataSource dataSource, {
|
||||
bool isLive = false,
|
||||
bool autoplay = true,
|
||||
// 默认不循环
|
||||
PlaylistMode looping = PlaylistMode.none,
|
||||
// 初始化播放位置
|
||||
Duration? seekTo,
|
||||
// 初始化播放速度
|
||||
@@ -591,15 +587,8 @@ class PlPlayerController with BlockConfigMixin {
|
||||
VideoType? videoType,
|
||||
VoidCallback? onInit,
|
||||
Volume? volume,
|
||||
String? dirPath,
|
||||
String? typeTag,
|
||||
int? mediaType,
|
||||
}) async {
|
||||
try {
|
||||
this.dirPath = dirPath;
|
||||
this.typeTag = typeTag;
|
||||
this.mediaType = mediaType;
|
||||
isFileSource = dataSource.type == DataSourceType.file;
|
||||
_processing = true;
|
||||
this.isLive = isLive;
|
||||
_videoType = videoType ?? VideoType.ugc;
|
||||
@@ -607,7 +596,6 @@ class PlPlayerController with BlockConfigMixin {
|
||||
this.height = height;
|
||||
this.dataSource = dataSource;
|
||||
_autoPlay = autoplay;
|
||||
_looping = looping;
|
||||
// 初始化视频倍速
|
||||
// _playbackSpeed.value = speed;
|
||||
// 初始化数据加载状态
|
||||
@@ -634,12 +622,15 @@ class PlPlayerController with BlockConfigMixin {
|
||||
return;
|
||||
}
|
||||
// 配置Player 音轨、字幕等等
|
||||
_videoPlayerController = await _createVideoController(
|
||||
dataSource,
|
||||
_looping,
|
||||
seekTo,
|
||||
volume,
|
||||
);
|
||||
await _createVideoController(dataSource, seekTo, volume);
|
||||
|
||||
if (_playerCount == 0) {
|
||||
_videoPlayerController?.dispose();
|
||||
_videoPlayerController = null;
|
||||
_videoController = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取视频时长 00:00
|
||||
this.duration.value = duration ?? _videoPlayerController!.state.duration;
|
||||
position = buffered.value = sliderPosition = seekTo ?? Duration.zero;
|
||||
@@ -670,32 +661,11 @@ class PlPlayerController with BlockConfigMixin {
|
||||
return shadersDirPath!;
|
||||
}
|
||||
|
||||
final dir = Directory(path.join(appSupportDirPath, 'anime_shaders'));
|
||||
if (!dir.existsSync()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
final shaderFilesPath =
|
||||
(Constants.mpvAnime4KShaders + Constants.mpvAnime4KShadersLite)
|
||||
.map((e) => 'assets/shaders/$e')
|
||||
.toList();
|
||||
|
||||
for (final filePath in shaderFilesPath) {
|
||||
final fileName = filePath.split('/').last;
|
||||
final targetFile = File(path.join(dir.path, fileName));
|
||||
if (targetFile.existsSync()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
final data = await rootBundle.load(filePath);
|
||||
final List<int> bytes = data.buffer.asUint8List();
|
||||
await targetFile.writeAsBytes(bytes);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('$e');
|
||||
}
|
||||
}
|
||||
return shadersDirPath = dir.path;
|
||||
return shadersDirPath = await AssetUtils.getOrCopy(
|
||||
'assets/shaders',
|
||||
Constants.mpvAnime4KShaders.followedBy(Constants.mpvAnime4KShadersLite),
|
||||
path.join(appSupportDirPath, 'anime_shaders'),
|
||||
);
|
||||
}
|
||||
|
||||
late final isAnim = _pgcType == 1 || _pgcType == 4;
|
||||
@@ -710,12 +680,10 @@ class PlPlayerController with BlockConfigMixin {
|
||||
setting.put(SettingBoxKey.superResolutionType, type.index);
|
||||
}
|
||||
}
|
||||
pp ??= _videoPlayerController!.platform!;
|
||||
await pp.waitForPlayerInitialization;
|
||||
await pp.waitForVideoControllerInitializationIfAttached;
|
||||
pp ??= _videoPlayerController!;
|
||||
switch (type) {
|
||||
case SuperResolutionType.disable:
|
||||
return pp.command(['change-list', 'glsl-shaders', 'clr', '']);
|
||||
return pp.command(const ['change-list', 'glsl-shaders', 'clr', '']);
|
||||
case SuperResolutionType.efficiency:
|
||||
return pp.command([
|
||||
'change-list',
|
||||
@@ -741,10 +709,54 @@ class PlPlayerController with BlockConfigMixin {
|
||||
|
||||
static final loudnormRegExp = RegExp('loudnorm=([^,]+)');
|
||||
|
||||
Future<Player> _initPlayer() async {
|
||||
assert(_videoPlayerController == null);
|
||||
final opt = {
|
||||
'video-sync': Pref.videoSync,
|
||||
};
|
||||
if (Platform.isAndroid) {
|
||||
opt['volume-max'] = '100';
|
||||
opt['ao'] = Pref.audioOutput;
|
||||
} else if (PlatformUtils.isDesktop) {
|
||||
opt['volume'] = (volume.value * 100).toString();
|
||||
}
|
||||
final autosync = Pref.autosync;
|
||||
if (autosync != '0') {
|
||||
opt['autosync'] = autosync;
|
||||
}
|
||||
|
||||
final player = await Player.create(
|
||||
configuration: PlayerConfiguration(
|
||||
bufferSize: Pref.expandBuffer
|
||||
? (isLive ? 64 * 1024 * 1024 : 32 * 1024 * 1024)
|
||||
: (isLive ? 16 * 1024 * 1024 : 4 * 1024 * 1024),
|
||||
logLevel: kDebugMode ? .warn : .error,
|
||||
options: opt,
|
||||
),
|
||||
);
|
||||
|
||||
assert(_videoController == null);
|
||||
|
||||
_videoController = await VideoController.create(
|
||||
player,
|
||||
configuration: VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: hwdec != null,
|
||||
androidAttachSurfaceAfterVideoParameters: false,
|
||||
hwdec: hwdec,
|
||||
),
|
||||
);
|
||||
|
||||
player.setMediaHeader(
|
||||
userAgent: BrowserUa.pc,
|
||||
referer: HttpString.baseUrl,
|
||||
);
|
||||
// await player.setAudioTrack(.auto());
|
||||
return player;
|
||||
}
|
||||
|
||||
// 配置播放器
|
||||
Future<Player> _createVideoController(
|
||||
Future<void> _createVideoController(
|
||||
DataSource dataSource,
|
||||
PlaylistMode looping,
|
||||
Duration? seekTo,
|
||||
Volume? volume,
|
||||
) async {
|
||||
@@ -757,81 +769,34 @@ class PlPlayerController with BlockConfigMixin {
|
||||
// 初始化时清空弹幕,防止上次重叠
|
||||
danmakuController?.clear();
|
||||
|
||||
Player player =
|
||||
_videoPlayerController ??
|
||||
Player(
|
||||
configuration: PlayerConfiguration(
|
||||
// 默认缓冲 4M 大小
|
||||
bufferSize: Pref.expandBuffer
|
||||
? (isLive ? 64 * 1024 * 1024 : 32 * 1024 * 1024)
|
||||
: (isLive ? 16 * 1024 * 1024 : 4 * 1024 * 1024),
|
||||
logLevel: kDebugMode ? MPVLogLevel.warn : MPVLogLevel.error,
|
||||
),
|
||||
);
|
||||
final pp = player.platform!;
|
||||
if (_videoPlayerController == null) {
|
||||
if (PlatformUtils.isDesktop) {
|
||||
pp.setVolume(this.volume.value * 100);
|
||||
var player = _videoPlayerController;
|
||||
|
||||
if (player == null) {
|
||||
player = await _initPlayer();
|
||||
if (_playerCount == 0) {
|
||||
player.dispose();
|
||||
player = null;
|
||||
_videoController = null;
|
||||
return;
|
||||
}
|
||||
if (isAnim) {
|
||||
setShader(superResolutionType.value, pp);
|
||||
}
|
||||
// await pp.setProperty('audio-pitch-correction', 'yes'); // default yes
|
||||
if (Platform.isAndroid) {
|
||||
await pp.setProperty("volume-max", "100");
|
||||
await pp.setProperty("ao", Pref.audioOutput);
|
||||
}
|
||||
// video-sync=display-resample
|
||||
await pp.setProperty("video-sync", Pref.videoSync);
|
||||
final autosync = Pref.autosync;
|
||||
if (autosync != '0') {
|
||||
await pp.setProperty("autosync", autosync);
|
||||
}
|
||||
// vo=gpu-next & gpu-context=android & gpu-api=opengl
|
||||
// await pp.setProperty("vo", "gpu-next");
|
||||
// await pp.setProperty("gpu-context", "android");
|
||||
// await pp.setProperty("gpu-api", "opengl");
|
||||
await player.setAudioTrack(AudioTrack.auto());
|
||||
if (Pref.enableSystemProxy) {
|
||||
final systemProxyHost = Pref.systemProxyHost;
|
||||
final systemProxyPort = int.tryParse(Pref.systemProxyPort);
|
||||
if (systemProxyPort != null && systemProxyHost.isNotEmpty) {
|
||||
await pp.setProperty(
|
||||
"http-proxy",
|
||||
'http://$systemProxyHost:$systemProxyPort',
|
||||
);
|
||||
}
|
||||
_videoPlayerController = player;
|
||||
}
|
||||
|
||||
if (isAnim) await setShader();
|
||||
|
||||
final Map<String, String> extras = {};
|
||||
|
||||
String video = dataSource.videoSource;
|
||||
if (dataSource.audioSource case final audio? when (audio.isNotEmpty)) {
|
||||
if (onlyPlayAudio.value) {
|
||||
video = audio;
|
||||
} else {
|
||||
extras['audio-files'] =
|
||||
'"${Platform.isWindows ? audio.replaceAll(';', r'\;') : audio.replaceAll(':', r'\:')}"';
|
||||
}
|
||||
}
|
||||
|
||||
// 音轨
|
||||
final String audioUri;
|
||||
if (isFileSource) {
|
||||
audioUri = onlyPlayAudio.value || mediaType == 1
|
||||
? ''
|
||||
: path.join(dirPath!, typeTag!, PathUtils.audioNameType2);
|
||||
} else if (dataSource.audioSource?.isNotEmpty == true) {
|
||||
audioUri = Platform.isWindows
|
||||
? dataSource.audioSource!.replaceAll(';', r'\;')
|
||||
: dataSource.audioSource!.replaceAll(':', r'\:');
|
||||
} else {
|
||||
audioUri = '';
|
||||
}
|
||||
await pp.setProperty('audio-files', audioUri);
|
||||
|
||||
_videoController ??= VideoController(
|
||||
player,
|
||||
configuration: VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: enableHA,
|
||||
androidAttachSurfaceAfterVideoParameters: false,
|
||||
hwdec: enableHA ? hwdec : null,
|
||||
),
|
||||
);
|
||||
|
||||
player.setPlaylistMode(looping);
|
||||
|
||||
final Map<String, String>? filters;
|
||||
if (Platform.isAndroid) {
|
||||
if (kDebugMode || Platform.isAndroid) {
|
||||
String audioNormalization = AudioNormalization.getParamFromConfig(
|
||||
Pref.audioNormalization,
|
||||
);
|
||||
@@ -854,44 +819,23 @@ class PlPlayerController with BlockConfigMixin {
|
||||
AudioNormalization.getParamFromConfig(Pref.fallbackNormalization),
|
||||
);
|
||||
}
|
||||
filters = audioNormalization.isEmpty
|
||||
? null
|
||||
: {'lavfi-complex': '"[aid1] $audioNormalization [ao]"'};
|
||||
} else {
|
||||
filters = null;
|
||||
if (audioNormalization.isNotEmpty) {
|
||||
extras['lavfi-complex'] = '"[aid1] $audioNormalization [ao]"';
|
||||
}
|
||||
}
|
||||
|
||||
// if (kDebugMode) debugPrint(filters.toString());
|
||||
|
||||
late final String videoUri;
|
||||
if (isFileSource) {
|
||||
videoUri = path.join(
|
||||
dirPath!,
|
||||
typeTag!,
|
||||
mediaType == 1
|
||||
? PathUtils.videoNameType1
|
||||
: onlyPlayAudio.value
|
||||
? PathUtils.audioNameType2
|
||||
: PathUtils.videoNameType2,
|
||||
);
|
||||
} else {
|
||||
videoUri = dataSource.videoSource!;
|
||||
}
|
||||
await player.open(
|
||||
Media(
|
||||
videoUri,
|
||||
httpHeaders: dataSource.httpHeaders,
|
||||
video,
|
||||
start: seekTo,
|
||||
extras: filters,
|
||||
extras: extras.isEmpty ? null : extras,
|
||||
),
|
||||
play: false,
|
||||
);
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
Future<bool> refreshPlayer() async {
|
||||
if (isFileSource) {
|
||||
if (dataSource is FileSource) {
|
||||
return true;
|
||||
}
|
||||
if (_videoPlayerController == null) {
|
||||
@@ -902,23 +846,21 @@ class PlPlayerController with BlockConfigMixin {
|
||||
SmartDialog.showToast('视频源为空,请重新进入本页面');
|
||||
return false;
|
||||
}
|
||||
String? audioUri;
|
||||
if (!isLive) {
|
||||
if (dataSource.audioSource.isNullOrEmpty) {
|
||||
SmartDialog.showToast('音频源为空');
|
||||
} else {
|
||||
await (_videoPlayerController!.platform!).setProperty(
|
||||
'audio-files',
|
||||
Platform.isWindows
|
||||
? dataSource.audioSource!.replaceAll(';', '\\;')
|
||||
: dataSource.audioSource!.replaceAll(':', '\\:'),
|
||||
);
|
||||
audioUri = Platform.isWindows
|
||||
? dataSource.audioSource!.replaceAll(';', '\\;')
|
||||
: dataSource.audioSource!.replaceAll(':', '\\:');
|
||||
}
|
||||
}
|
||||
await _videoPlayerController!.open(
|
||||
Media(
|
||||
dataSource.videoSource!,
|
||||
httpHeaders: dataSource.httpHeaders,
|
||||
dataSource.videoSource,
|
||||
start: position,
|
||||
extras: audioUri == null ? null : {'audio-files': '"$audioUri"'},
|
||||
),
|
||||
play: true,
|
||||
);
|
||||
@@ -974,14 +916,14 @@ class PlPlayerController with BlockConfigMixin {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<StreamSubscription> subscriptions = {};
|
||||
List<StreamSubscription> subscriptions = [];
|
||||
final Set<Function(Duration position)> _positionListeners = {};
|
||||
final Set<Function(PlayerStatus status)> _statusListeners = {};
|
||||
|
||||
/// 播放事件监听
|
||||
void startListeners() {
|
||||
final controllerStream = videoPlayerController!.stream;
|
||||
subscriptions = {
|
||||
subscriptions = [
|
||||
controllerStream.playing.listen((event) {
|
||||
WakelockPlus.toggle(enable: event);
|
||||
if (event) {
|
||||
@@ -1062,7 +1004,8 @@ class PlPlayerController with BlockConfigMixin {
|
||||
}
|
||||
})),
|
||||
controllerStream.error.listen((String event) {
|
||||
if (isFileSource && event.startsWith("Failed to open file")) {
|
||||
if (dataSource is FileSource &&
|
||||
event.startsWith("Failed to open file")) {
|
||||
return;
|
||||
}
|
||||
if (isLive) {
|
||||
@@ -1131,7 +1074,7 @@ class PlPlayerController with BlockConfigMixin {
|
||||
videoPlayerServiceHandler!.onPositionChange(Duration(seconds: event));
|
||||
}),
|
||||
],
|
||||
};
|
||||
];
|
||||
}
|
||||
|
||||
/// 移除事件监听
|
||||
@@ -1139,6 +1082,13 @@ class PlPlayerController with BlockConfigMixin {
|
||||
return Future.wait(subscriptions.map((e) => e.cancel()));
|
||||
}
|
||||
|
||||
void _cancelSubForSeek() {
|
||||
if (_subForSeek != null) {
|
||||
_subForSeek!.cancel();
|
||||
_subForSeek = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转至指定位置
|
||||
Future<void> seekTo(Duration position, {bool isSeek = true}) async {
|
||||
// if (position >= duration.value) {
|
||||
@@ -1153,7 +1103,8 @@ class PlPlayerController with BlockConfigMixin {
|
||||
this.position = position;
|
||||
updatePositionSecond();
|
||||
_heartDuration = position.inSeconds;
|
||||
if (duration.value.inSeconds != 0) {
|
||||
|
||||
Future<void> seek() async {
|
||||
if (isSeek) {
|
||||
/// 拖动进度条调节时,不等待第一帧,防止抖动
|
||||
await _videoPlayerController?.stream.buffer.first;
|
||||
@@ -1164,33 +1115,16 @@ class PlPlayerController with BlockConfigMixin {
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('seek failed: $e');
|
||||
}
|
||||
// if (playerStatus.stopped) {
|
||||
// play();
|
||||
// }
|
||||
}
|
||||
|
||||
if (duration.value != Duration.zero) {
|
||||
seek();
|
||||
} else {
|
||||
// if (kDebugMode) debugPrint('seek duration else');
|
||||
_timerForSeek?.cancel();
|
||||
_timerForSeek = Timer.periodic(const Duration(milliseconds: 200), (
|
||||
Timer t,
|
||||
) async {
|
||||
//_timerForSeek = null;
|
||||
if (_playerCount == 0) {
|
||||
_timerForSeek?.cancel();
|
||||
_timerForSeek = null;
|
||||
} else if (duration.value != Duration.zero) {
|
||||
try {
|
||||
await _videoPlayerController?.stream.buffer.first;
|
||||
danmakuController?.clear();
|
||||
await _videoPlayerController?.seek(position);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('seek failed: $e');
|
||||
}
|
||||
// if (playerStatus.isPaused) {
|
||||
// play();
|
||||
// }
|
||||
t.cancel();
|
||||
_timerForSeek = null;
|
||||
}
|
||||
_subForSeek?.cancel();
|
||||
_subForSeek = duration.listen((_) {
|
||||
seek();
|
||||
_cancelSubForSeek();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1304,7 +1238,7 @@ class PlPlayerController with BlockConfigMixin {
|
||||
|
||||
final RxBool volumeIndicator = false.obs;
|
||||
Timer? volumeTimer;
|
||||
final RxBool volumeInterceptEventStream = false.obs;
|
||||
bool volumeInterceptEventStream = false;
|
||||
|
||||
static final double maxVolume = PlatformUtils.isDesktop ? 2.0 : 1.0;
|
||||
Future<void> setVolume(double volume) async {
|
||||
@@ -1322,11 +1256,11 @@ class PlPlayerController with BlockConfigMixin {
|
||||
}
|
||||
}
|
||||
volumeIndicator.value = true;
|
||||
volumeInterceptEventStream.value = true;
|
||||
volumeInterceptEventStream = true;
|
||||
volumeTimer?.cancel();
|
||||
volumeTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
volumeIndicator.value = false;
|
||||
volumeInterceptEventStream.value = false;
|
||||
volumeInterceptEventStream = false;
|
||||
if (PlatformUtils.isDesktop) {
|
||||
setting.put(SettingBoxKey.desktopVolume, volume.toPrecision(3));
|
||||
}
|
||||
@@ -1341,7 +1275,7 @@ class PlPlayerController with BlockConfigMixin {
|
||||
|
||||
/// 读取fit
|
||||
int fitValue = Pref.cacheVideoFit;
|
||||
Future<void> getVideoFit() async {
|
||||
void getVideoFit() {
|
||||
var attr = VideoFitType.values[fitValue];
|
||||
// 由于none与scaleDown涉及视频原始尺寸,需要等待视频加载后再设置,否则尺寸会变为0,出现错误;
|
||||
if (attr == VideoFitType.none || attr == VideoFitType.scaleDown) {
|
||||
@@ -1570,14 +1504,6 @@ class PlPlayerController with BlockConfigMixin {
|
||||
void removeStatusLister(Function(PlayerStatus status) listener) =>
|
||||
_statusListeners.remove(listener);
|
||||
|
||||
/// 截屏
|
||||
Future<Uint8List?> screenshot() async {
|
||||
final Uint8List? screenshot = await _videoPlayerController!.screenshot(
|
||||
format: 'image/png',
|
||||
);
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
// 记录播放记录
|
||||
Future<void>? makeHeartBeat(
|
||||
int progress, {
|
||||
@@ -1657,9 +1583,10 @@ class PlPlayerController with BlockConfigMixin {
|
||||
}
|
||||
|
||||
bool isCloseAll = false;
|
||||
Future<void> dispose() async {
|
||||
void dispose() {
|
||||
// 每次减1,最后销毁
|
||||
cancelLongPressTimer();
|
||||
_cancelSubForSeek();
|
||||
if (!isCloseAll && _playerCount > 1) {
|
||||
_playerCount -= 1;
|
||||
_heartDuration = 0;
|
||||
@@ -1681,7 +1608,6 @@ class PlPlayerController with BlockConfigMixin {
|
||||
}
|
||||
Utils.channel.setMethodCallHandler(null);
|
||||
_timer?.cancel();
|
||||
_timerForSeek?.cancel();
|
||||
// _position.close();
|
||||
// _playerEventSubs?.cancel();
|
||||
// _sliderPosition.close();
|
||||
@@ -1699,7 +1625,7 @@ class PlPlayerController with BlockConfigMixin {
|
||||
windowManager.setAlwaysOnTop(false);
|
||||
}
|
||||
|
||||
await removeListeners();
|
||||
removeListeners();
|
||||
subscriptions.clear();
|
||||
_positionListeners.clear();
|
||||
_statusListeners.clear();
|
||||
@@ -1781,7 +1707,7 @@ class PlPlayerController with BlockConfigMixin {
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'user-agent': UaType.pc.ua,
|
||||
'user-agent': BrowserUa.pc,
|
||||
'referer': 'https://www.bilibili.com/video/$bvid',
|
||||
},
|
||||
),
|
||||
@@ -1802,7 +1728,7 @@ class PlPlayerController with BlockConfigMixin {
|
||||
|
||||
void takeScreenshot() {
|
||||
SmartDialog.showToast('截图中');
|
||||
videoPlayerController?.screenshot(format: 'image/png').then((value) {
|
||||
videoPlayerController?.screenshot(format: .png).then((value) {
|
||||
if (value != null) {
|
||||
SmartDialog.showToast('点击弹窗保存截图');
|
||||
showDialog(
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
/// The way in which the video was originally loaded.
|
||||
///
|
||||
/// This has nothing to do with the video's file type. It's just the place
|
||||
/// from which the video is fetched from.
|
||||
enum DataSourceType {
|
||||
/// The video was downloaded from the internet.
|
||||
network,
|
||||
import 'package:PiliPlus/utils/path_utils.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
/// The video was loaded off of the local filesystem.
|
||||
file,
|
||||
}
|
||||
|
||||
class DataSource {
|
||||
String? videoSource;
|
||||
String? audioSource;
|
||||
DataSourceType type;
|
||||
Map<String, String>? httpHeaders; // for headers
|
||||
sealed class DataSource {
|
||||
final String videoSource;
|
||||
final String? audioSource;
|
||||
|
||||
DataSource({
|
||||
this.videoSource,
|
||||
this.audioSource,
|
||||
required this.type,
|
||||
this.httpHeaders,
|
||||
required this.videoSource,
|
||||
required this.audioSource,
|
||||
});
|
||||
|
||||
DataSource copyWith({
|
||||
String? videoSource,
|
||||
String? audioSource,
|
||||
DataSourceType? type,
|
||||
Map<String, String>? httpHeaders,
|
||||
}) {
|
||||
return DataSource(
|
||||
videoSource: videoSource ?? this.videoSource,
|
||||
audioSource: audioSource ?? this.audioSource,
|
||||
type: type ?? this.type,
|
||||
httpHeaders: httpHeaders ?? this.httpHeaders,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkSource extends DataSource {
|
||||
NetworkSource({
|
||||
required super.videoSource,
|
||||
required super.audioSource,
|
||||
});
|
||||
}
|
||||
|
||||
class FileSource extends DataSource {
|
||||
final String dir;
|
||||
final bool isMp4;
|
||||
|
||||
FileSource({
|
||||
required this.dir,
|
||||
required this.isMp4,
|
||||
required String typeTag,
|
||||
}) : super(
|
||||
videoSource: path.join(
|
||||
dir,
|
||||
typeTag,
|
||||
isMp4 ? PathUtils.videoNameType1 : PathUtils.videoNameType2,
|
||||
),
|
||||
audioSource: isMp4
|
||||
? null
|
||||
: path.join(dir, typeTag, PathUtils.audioNameType2),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:screen_brightness_platform_interface/screen_brightness_platform_interface.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
part 'widgets.dart';
|
||||
|
||||
class PLVideoPlayer extends StatefulWidget {
|
||||
const PLVideoPlayer({
|
||||
required this.maxWidth,
|
||||
@@ -226,8 +228,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
|
||||
plPlayerController.volume.value =
|
||||
(await FlutterVolumeController.getVolume())!;
|
||||
FlutterVolumeController.addListener((double value) {
|
||||
if (mounted &&
|
||||
!plPlayerController.volumeInterceptEventStream.value) {
|
||||
if (mounted && !plPlayerController.volumeInterceptEventStream) {
|
||||
plPlayerController.volume.value = value;
|
||||
if (Platform.isIOS && !FlutterVolumeController.showSystemUI) {
|
||||
plPlayerController
|
||||
@@ -240,7 +241,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}, emitOnStart: false);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
@@ -293,7 +294,7 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (!plPlayerController.continuePlayInBackground.value) {
|
||||
late final player = plPlayerController.videoController?.player;
|
||||
late final player = plPlayerController.videoPlayerController;
|
||||
if (const [
|
||||
AppLifecycleState.paused,
|
||||
AppLifecycleState.detached,
|
||||
@@ -2436,505 +2437,3 @@ class _PLVideoPlayerState extends State<PLVideoPlayer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildDmChart(
|
||||
Color color,
|
||||
List<double> dmTrend,
|
||||
VideoDetailController videoDetailController, [
|
||||
double offset = 0,
|
||||
]) {
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
height: 12,
|
||||
margin: EdgeInsets.only(
|
||||
bottom:
|
||||
videoDetailController.viewPointList.isNotEmpty &&
|
||||
videoDetailController.showVP.value
|
||||
? 19.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: (dmTrend.length - 1).toDouble(),
|
||||
minY: 0,
|
||||
maxY: dmTrend.max,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: List.generate(
|
||||
dmTrend.length,
|
||||
(index) => FlSpot(
|
||||
index.toDouble(),
|
||||
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,
|
||||
double maxWidth,
|
||||
double maxHeight,
|
||||
ValueGetter<bool> isMounted,
|
||||
) {
|
||||
return Obx(
|
||||
() {
|
||||
if (!plPlayerController.showPreview.value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
try {
|
||||
final data = plPlayerController.videoShot!.data;
|
||||
|
||||
final double scale =
|
||||
plPlayerController.isFullScreen.value &&
|
||||
(PlatformUtils.isDesktop || !plPlayerController.isVertical)
|
||||
? 4
|
||||
: 3;
|
||||
double height = 27 * scale;
|
||||
final compatHeight = maxHeight - 140;
|
||||
if (compatHeight > 50) {
|
||||
height = math.min(height, compatHeight);
|
||||
}
|
||||
|
||||
final int imgXLen = data.imgXLen;
|
||||
final int imgYLen = data.imgYLen;
|
||||
final int totalPerImage = data.totalPerImage;
|
||||
double imgXSize = data.imgXSize;
|
||||
double imgYSize = data.imgYSize;
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: Obx(
|
||||
() {
|
||||
final index = plPlayerController.previewIndex.value!;
|
||||
int pageIndex = (index ~/ totalPerImage).clamp(
|
||||
0,
|
||||
data.image.length - 1,
|
||||
);
|
||||
int align = index % totalPerImage;
|
||||
int x = align % imgXLen;
|
||||
int y = align ~/ imgYLen;
|
||||
final url = data.image[pageIndex];
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: StyleString.mdRadius,
|
||||
child: VideoShotImage(
|
||||
url: url,
|
||||
x: x,
|
||||
y: y,
|
||||
imgXSize: imgXSize,
|
||||
imgYSize: imgYSize,
|
||||
height: height,
|
||||
imageCache: plPlayerController.previewCache,
|
||||
onSetSize: (xSize, ySize) => data
|
||||
..imgXSize = imgXSize = xSize
|
||||
..imgYSize = imgYSize = ySize,
|
||||
isMounted: isMounted,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) rethrow;
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class VideoShotImage extends StatefulWidget {
|
||||
const VideoShotImage({
|
||||
super.key,
|
||||
required this.imageCache,
|
||||
required this.url,
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.imgXSize,
|
||||
required this.imgYSize,
|
||||
required this.height,
|
||||
required this.onSetSize,
|
||||
required this.isMounted,
|
||||
});
|
||||
|
||||
final Map<String, ui.Image?> imageCache;
|
||||
final String url;
|
||||
final int x;
|
||||
final int y;
|
||||
final double imgXSize;
|
||||
final double imgYSize;
|
||||
final double height;
|
||||
final Function(double imgXSize, double imgYSize) onSetSize;
|
||||
final ValueGetter<bool> isMounted;
|
||||
|
||||
@override
|
||||
State<VideoShotImage> createState() => _VideoShotImageState();
|
||||
}
|
||||
|
||||
Future<ui.Image?> _getImg(String url) async {
|
||||
final cacheManager = DefaultCacheManager();
|
||||
final cacheKey = Utils.getFileName(url, fileExt: false);
|
||||
try {
|
||||
final fileInfo = await cacheManager.getSingleFile(
|
||||
ImageUtils.safeThumbnailUrl(url),
|
||||
key: cacheKey,
|
||||
headers: Constants.baseHeaders,
|
||||
);
|
||||
return _loadImg(fileInfo.path);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ui.Image?> _loadImg(String path) async {
|
||||
final codec = await ui.instantiateImageCodecFromBuffer(
|
||||
await ImmutableBuffer.fromFilePath(path),
|
||||
);
|
||||
final frame = await codec.getNextFrame();
|
||||
codec.dispose();
|
||||
return frame.image;
|
||||
}
|
||||
|
||||
class _VideoShotImageState extends State<VideoShotImage> {
|
||||
late Size _size;
|
||||
late Rect _srcRect;
|
||||
late Rect _dstRect;
|
||||
late RRect _rrect;
|
||||
ui.Image? _image;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initSize();
|
||||
_loadImg();
|
||||
}
|
||||
|
||||
void _initSizeIfNeeded() {
|
||||
if (_size.width.isNaN) {
|
||||
_initSize();
|
||||
}
|
||||
}
|
||||
|
||||
void _initSize() {
|
||||
if (widget.imgXSize == 0) {
|
||||
if (_image != null) {
|
||||
final imgXSize = _image!.width / 10;
|
||||
final imgYSize = _image!.height / 10;
|
||||
final height = widget.height;
|
||||
final width = height * imgXSize / imgYSize;
|
||||
_setRect(width, height);
|
||||
_setSrcRect(imgXSize, imgYSize);
|
||||
widget.onSetSize(imgXSize, imgYSize);
|
||||
} else {
|
||||
_setRect(double.nan, double.nan);
|
||||
_setSrcRect(widget.imgXSize, widget.imgYSize);
|
||||
}
|
||||
} else {
|
||||
final height = widget.height;
|
||||
final width = height * widget.imgXSize / widget.imgYSize;
|
||||
_setRect(width, height);
|
||||
_setSrcRect(widget.imgXSize, widget.imgYSize);
|
||||
}
|
||||
}
|
||||
|
||||
void _setRect(double width, double height) {
|
||||
_size = Size(width, height);
|
||||
_dstRect = Rect.fromLTRB(0, 0, width, height);
|
||||
_rrect = RRect.fromRectAndRadius(_dstRect, const Radius.circular(10));
|
||||
}
|
||||
|
||||
void _setSrcRect(double imgXSize, double imgYSize) {
|
||||
_srcRect = Rect.fromLTWH(
|
||||
widget.x * imgXSize,
|
||||
widget.y * imgYSize,
|
||||
imgXSize,
|
||||
imgYSize,
|
||||
);
|
||||
}
|
||||
|
||||
void _loadImg() {
|
||||
final url = widget.url;
|
||||
_image = widget.imageCache[url];
|
||||
if (_image != null) {
|
||||
_initSizeIfNeeded();
|
||||
} else if (!widget.imageCache.containsKey(url)) {
|
||||
widget.imageCache[url] = null;
|
||||
_getImg(url).then((image) {
|
||||
if (image != null) {
|
||||
if (widget.isMounted()) {
|
||||
widget.imageCache[url] = image;
|
||||
}
|
||||
if (mounted) {
|
||||
_image = image;
|
||||
_initSizeIfNeeded();
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
widget.imageCache.remove(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(VideoShotImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.url != widget.url) {
|
||||
_loadImg();
|
||||
}
|
||||
if (oldWidget.x != widget.x || oldWidget.y != widget.y) {
|
||||
_setSrcRect(widget.imgXSize, widget.imgYSize);
|
||||
}
|
||||
}
|
||||
|
||||
late final _imgPaint = Paint()..filterQuality = FilterQuality.medium;
|
||||
late final _borderPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_image != null) {
|
||||
return CroppedImage(
|
||||
size: _size,
|
||||
image: _image!,
|
||||
srcRect: _srcRect,
|
||||
dstRect: _dstRect,
|
||||
rrect: _rrect,
|
||||
imgPaint: _imgPaint,
|
||||
borderPaint: _borderPaint,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
const double _triangleHeight = 5.6;
|
||||
|
||||
class _DanmakuTip extends SingleChildRenderObjectWidget {
|
||||
const _DanmakuTip({
|
||||
this.offset = 0,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final double offset;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderDanmakuTip(offset: offset);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderDanmakuTip renderObject,
|
||||
) {
|
||||
renderObject.offset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderDanmakuTip extends RenderProxyBox {
|
||||
_RenderDanmakuTip({
|
||||
required double offset,
|
||||
}) : _offset = offset;
|
||||
|
||||
double _offset;
|
||||
double get offset => _offset;
|
||||
set offset(double value) {
|
||||
if (_offset == value) return;
|
||||
_offset = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
final paint = Paint()
|
||||
..color = const Color(0xB3000000)
|
||||
..style = .fill;
|
||||
|
||||
final radius = size.height / 2;
|
||||
const triangleBase = _triangleHeight * 2 / 3;
|
||||
|
||||
final triangleCenterX = (size.width / 2 + _offset).clamp(
|
||||
radius + triangleBase,
|
||||
size.width - radius - triangleBase,
|
||||
);
|
||||
final path = Path()
|
||||
// triangle (exceed)
|
||||
..moveTo(triangleCenterX - triangleBase, 0)
|
||||
..lineTo(triangleCenterX, -_triangleHeight)
|
||||
..lineTo(triangleCenterX + triangleBase, 0)
|
||||
// top
|
||||
..lineTo(size.width - radius, 0)
|
||||
// right
|
||||
..arcToPoint(
|
||||
Offset(size.width - radius, size.height),
|
||||
radius: Radius.circular(radius),
|
||||
)
|
||||
// bottom
|
||||
..lineTo(radius, size.height)
|
||||
// left
|
||||
..arcToPoint(
|
||||
Offset(radius, 0),
|
||||
radius: Radius.circular(radius),
|
||||
)
|
||||
..close();
|
||||
|
||||
context.canvas
|
||||
..save()
|
||||
..translate(offset.dx, offset.dy)
|
||||
..drawPath(path, paint)
|
||||
..drawPath(
|
||||
path,
|
||||
paint
|
||||
..color = const Color(0x7EFFFFFF)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.25,
|
||||
)
|
||||
..restore();
|
||||
|
||||
super.paint(context, offset);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoTime extends LeafRenderObjectWidget {
|
||||
const _VideoTime({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
final String position;
|
||||
final String duration;
|
||||
|
||||
@override
|
||||
_RenderVideoTime createRenderObject(BuildContext context) => _RenderVideoTime(
|
||||
position: position,
|
||||
duration: duration,
|
||||
);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
covariant _RenderVideoTime renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..position = position
|
||||
..duration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderVideoTime extends RenderBox {
|
||||
_RenderVideoTime({
|
||||
required String position,
|
||||
required String duration,
|
||||
}) : _position = position,
|
||||
_duration = duration;
|
||||
|
||||
String _duration;
|
||||
set duration(String value) {
|
||||
_duration = value;
|
||||
final paragraph = _buildParagraph(const Color(0xFFD0D0D0), _duration);
|
||||
if (paragraph.maxIntrinsicWidth != _cache?.maxIntrinsicWidth) {
|
||||
markNeedsLayout();
|
||||
}
|
||||
_cache?.dispose();
|
||||
_cache = paragraph;
|
||||
markNeedsSemanticsUpdate();
|
||||
}
|
||||
|
||||
String _position;
|
||||
set position(String value) {
|
||||
_position = value;
|
||||
markNeedsPaint();
|
||||
markNeedsSemanticsUpdate();
|
||||
}
|
||||
|
||||
ui.Paragraph? _cache;
|
||||
|
||||
ui.Paragraph _buildParagraph(Color color, String time) {
|
||||
final builder =
|
||||
ui.ParagraphBuilder(
|
||||
ui.ParagraphStyle(
|
||||
fontSize: 10,
|
||||
height: 1.4,
|
||||
fontFamily: 'Monospace',
|
||||
),
|
||||
)
|
||||
..pushStyle(
|
||||
ui.TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
height: 1.4,
|
||||
fontFamily: 'Monospace',
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
)
|
||||
..addText(time);
|
||||
return builder.build()
|
||||
..layout(const ui.ParagraphConstraints(width: .infinity));
|
||||
}
|
||||
|
||||
@override
|
||||
ui.Size computeDryLayout(covariant BoxConstraints constraints) {
|
||||
final paragraph = _cache ??= _buildParagraph(
|
||||
const Color(0xFFD0D0D0),
|
||||
_duration,
|
||||
);
|
||||
return Size(paragraph.maxIntrinsicWidth, paragraph.height * 2);
|
||||
}
|
||||
|
||||
@override
|
||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||
super.describeSemanticsConfiguration(config);
|
||||
config.label = 'position:$_position\nduration:$_duration';
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
size = computeDryLayout(constraints);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, ui.Offset offset) {
|
||||
final para = _buildParagraph(Colors.white, _position);
|
||||
context.canvas
|
||||
..drawParagraph(
|
||||
para,
|
||||
Offset(
|
||||
offset.dx + _cache!.maxIntrinsicWidth - para.maxIntrinsicWidth,
|
||||
offset.dy,
|
||||
),
|
||||
)
|
||||
..drawParagraph(_cache!, Offset(offset.dx, offset.dy + para.height));
|
||||
para.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cache?.dispose();
|
||||
_cache = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isRepaintBoundary => true;
|
||||
}
|
||||
503
lib/plugin/pl_player/view/widgets.dart
Normal file
503
lib/plugin/pl_player/view/widgets.dart
Normal file
@@ -0,0 +1,503 @@
|
||||
part of 'view.dart';
|
||||
|
||||
Widget buildDmChart(
|
||||
Color color,
|
||||
List<double> dmTrend,
|
||||
VideoDetailController videoDetailController, [
|
||||
double offset = 0,
|
||||
]) {
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
height: 12,
|
||||
margin: EdgeInsets.only(
|
||||
bottom:
|
||||
videoDetailController.viewPointList.isNotEmpty &&
|
||||
videoDetailController.showVP.value
|
||||
? 19.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: (dmTrend.length - 1).toDouble(),
|
||||
minY: 0,
|
||||
maxY: dmTrend.max,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: List.generate(
|
||||
dmTrend.length,
|
||||
(index) => FlSpot(
|
||||
index.toDouble(),
|
||||
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,
|
||||
double maxWidth,
|
||||
double maxHeight,
|
||||
ValueGetter<bool> isMounted,
|
||||
) {
|
||||
return Obx(
|
||||
() {
|
||||
if (!plPlayerController.showPreview.value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
try {
|
||||
final data = plPlayerController.videoShot!.data;
|
||||
|
||||
final double scale =
|
||||
plPlayerController.isFullScreen.value &&
|
||||
(PlatformUtils.isDesktop || !plPlayerController.isVertical)
|
||||
? 4
|
||||
: 3;
|
||||
double height = 27 * scale;
|
||||
final compatHeight = maxHeight - 140;
|
||||
if (compatHeight > 50) {
|
||||
height = math.min(height, compatHeight);
|
||||
}
|
||||
|
||||
final int imgXLen = data.imgXLen;
|
||||
final int imgYLen = data.imgYLen;
|
||||
final int totalPerImage = data.totalPerImage;
|
||||
double imgXSize = data.imgXSize;
|
||||
double imgYSize = data.imgYSize;
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: Obx(
|
||||
() {
|
||||
final index = plPlayerController.previewIndex.value!;
|
||||
int pageIndex = (index ~/ totalPerImage).clamp(
|
||||
0,
|
||||
data.image.length - 1,
|
||||
);
|
||||
int align = index % totalPerImage;
|
||||
int x = align % imgXLen;
|
||||
int y = align ~/ imgYLen;
|
||||
final url = data.image[pageIndex];
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: StyleString.mdRadius,
|
||||
child: VideoShotImage(
|
||||
url: url,
|
||||
x: x,
|
||||
y: y,
|
||||
imgXSize: imgXSize,
|
||||
imgYSize: imgYSize,
|
||||
height: height,
|
||||
imageCache: plPlayerController.previewCache,
|
||||
onSetSize: (xSize, ySize) => data
|
||||
..imgXSize = imgXSize = xSize
|
||||
..imgYSize = imgYSize = ySize,
|
||||
isMounted: isMounted,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (kDebugMode) rethrow;
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class VideoShotImage extends StatefulWidget {
|
||||
const VideoShotImage({
|
||||
super.key,
|
||||
required this.imageCache,
|
||||
required this.url,
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.imgXSize,
|
||||
required this.imgYSize,
|
||||
required this.height,
|
||||
required this.onSetSize,
|
||||
required this.isMounted,
|
||||
});
|
||||
|
||||
final Map<String, ui.Image?> imageCache;
|
||||
final String url;
|
||||
final int x;
|
||||
final int y;
|
||||
final double imgXSize;
|
||||
final double imgYSize;
|
||||
final double height;
|
||||
final Function(double imgXSize, double imgYSize) onSetSize;
|
||||
final ValueGetter<bool> isMounted;
|
||||
|
||||
@override
|
||||
State<VideoShotImage> createState() => _VideoShotImageState();
|
||||
}
|
||||
|
||||
Future<ui.Image?> _getImg(String url) async {
|
||||
final cacheManager = DefaultCacheManager();
|
||||
final cacheKey = Utils.getFileName(url, fileExt: false);
|
||||
try {
|
||||
final fileInfo = await cacheManager.getSingleFile(
|
||||
ImageUtils.safeThumbnailUrl(url),
|
||||
key: cacheKey,
|
||||
headers: Constants.baseHeaders,
|
||||
);
|
||||
return _loadImg(fileInfo.path);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ui.Image?> _loadImg(String path) async {
|
||||
final codec = await ui.instantiateImageCodecFromBuffer(
|
||||
await ImmutableBuffer.fromFilePath(path),
|
||||
);
|
||||
final frame = await codec.getNextFrame();
|
||||
codec.dispose();
|
||||
return frame.image;
|
||||
}
|
||||
|
||||
class _VideoShotImageState extends State<VideoShotImage> {
|
||||
late Size _size;
|
||||
late Rect _srcRect;
|
||||
late Rect _dstRect;
|
||||
late RRect _rrect;
|
||||
ui.Image? _image;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initSize();
|
||||
_loadImg();
|
||||
}
|
||||
|
||||
void _initSizeIfNeeded() {
|
||||
if (_size.width.isNaN) {
|
||||
_initSize();
|
||||
}
|
||||
}
|
||||
|
||||
void _initSize() {
|
||||
if (widget.imgXSize == 0) {
|
||||
if (_image != null) {
|
||||
final imgXSize = _image!.width / 10;
|
||||
final imgYSize = _image!.height / 10;
|
||||
final height = widget.height;
|
||||
final width = height * imgXSize / imgYSize;
|
||||
_setRect(width, height);
|
||||
_setSrcRect(imgXSize, imgYSize);
|
||||
widget.onSetSize(imgXSize, imgYSize);
|
||||
} else {
|
||||
_setRect(double.nan, double.nan);
|
||||
_setSrcRect(widget.imgXSize, widget.imgYSize);
|
||||
}
|
||||
} else {
|
||||
final height = widget.height;
|
||||
final width = height * widget.imgXSize / widget.imgYSize;
|
||||
_setRect(width, height);
|
||||
_setSrcRect(widget.imgXSize, widget.imgYSize);
|
||||
}
|
||||
}
|
||||
|
||||
void _setRect(double width, double height) {
|
||||
_size = Size(width, height);
|
||||
_dstRect = Rect.fromLTRB(0, 0, width, height);
|
||||
_rrect = RRect.fromRectAndRadius(_dstRect, const Radius.circular(10));
|
||||
}
|
||||
|
||||
void _setSrcRect(double imgXSize, double imgYSize) {
|
||||
_srcRect = Rect.fromLTWH(
|
||||
widget.x * imgXSize,
|
||||
widget.y * imgYSize,
|
||||
imgXSize,
|
||||
imgYSize,
|
||||
);
|
||||
}
|
||||
|
||||
void _loadImg() {
|
||||
final url = widget.url;
|
||||
_image = widget.imageCache[url];
|
||||
if (_image != null) {
|
||||
_initSizeIfNeeded();
|
||||
} else if (!widget.imageCache.containsKey(url)) {
|
||||
widget.imageCache[url] = null;
|
||||
_getImg(url).then((image) {
|
||||
if (image != null) {
|
||||
if (widget.isMounted()) {
|
||||
widget.imageCache[url] = image;
|
||||
}
|
||||
if (mounted) {
|
||||
_image = image;
|
||||
_initSizeIfNeeded();
|
||||
setState(() {});
|
||||
}
|
||||
} else {
|
||||
widget.imageCache.remove(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(VideoShotImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.url != widget.url) {
|
||||
_loadImg();
|
||||
}
|
||||
if (oldWidget.x != widget.x || oldWidget.y != widget.y) {
|
||||
_setSrcRect(widget.imgXSize, widget.imgYSize);
|
||||
}
|
||||
}
|
||||
|
||||
late final _imgPaint = Paint()..filterQuality = FilterQuality.medium;
|
||||
late final _borderPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_image != null) {
|
||||
return CroppedImage(
|
||||
size: _size,
|
||||
image: _image!,
|
||||
srcRect: _srcRect,
|
||||
dstRect: _dstRect,
|
||||
rrect: _rrect,
|
||||
imgPaint: _imgPaint,
|
||||
borderPaint: _borderPaint,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
const double _triangleHeight = 5.6;
|
||||
|
||||
class _DanmakuTip extends SingleChildRenderObjectWidget {
|
||||
const _DanmakuTip({
|
||||
this.offset = 0,
|
||||
super.child,
|
||||
});
|
||||
|
||||
final double offset;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return _RenderDanmakuTip(offset: offset);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderDanmakuTip renderObject,
|
||||
) {
|
||||
renderObject.offset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderDanmakuTip extends RenderProxyBox {
|
||||
_RenderDanmakuTip({
|
||||
required double offset,
|
||||
}) : _offset = offset;
|
||||
|
||||
double _offset;
|
||||
double get offset => _offset;
|
||||
set offset(double value) {
|
||||
if (_offset == value) return;
|
||||
_offset = value;
|
||||
markNeedsPaint();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
final paint = Paint()
|
||||
..color = const Color(0xB3000000)
|
||||
..style = .fill;
|
||||
|
||||
final radius = size.height / 2;
|
||||
const triangleBase = _triangleHeight * 2 / 3;
|
||||
|
||||
final triangleCenterX = (size.width / 2 + _offset).clamp(
|
||||
radius + triangleBase,
|
||||
size.width - radius - triangleBase,
|
||||
);
|
||||
final path = Path()
|
||||
// triangle (exceed)
|
||||
..moveTo(triangleCenterX - triangleBase, 0)
|
||||
..lineTo(triangleCenterX, -_triangleHeight)
|
||||
..lineTo(triangleCenterX + triangleBase, 0)
|
||||
// top
|
||||
..lineTo(size.width - radius, 0)
|
||||
// right
|
||||
..arcToPoint(
|
||||
Offset(size.width - radius, size.height),
|
||||
radius: Radius.circular(radius),
|
||||
)
|
||||
// bottom
|
||||
..lineTo(radius, size.height)
|
||||
// left
|
||||
..arcToPoint(
|
||||
Offset(radius, 0),
|
||||
radius: Radius.circular(radius),
|
||||
)
|
||||
..close();
|
||||
|
||||
context.canvas
|
||||
..save()
|
||||
..translate(offset.dx, offset.dy)
|
||||
..drawPath(path, paint)
|
||||
..drawPath(
|
||||
path,
|
||||
paint
|
||||
..color = const Color(0x7EFFFFFF)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.25,
|
||||
)
|
||||
..restore();
|
||||
|
||||
super.paint(context, offset);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoTime extends LeafRenderObjectWidget {
|
||||
const _VideoTime({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
});
|
||||
|
||||
final String position;
|
||||
final String duration;
|
||||
|
||||
@override
|
||||
_RenderVideoTime createRenderObject(BuildContext context) => _RenderVideoTime(
|
||||
position: position,
|
||||
duration: duration,
|
||||
);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
covariant _RenderVideoTime renderObject,
|
||||
) {
|
||||
renderObject
|
||||
..position = position
|
||||
..duration = duration;
|
||||
}
|
||||
}
|
||||
|
||||
class _RenderVideoTime extends RenderBox {
|
||||
_RenderVideoTime({
|
||||
required String position,
|
||||
required String duration,
|
||||
}) : _position = position,
|
||||
_duration = duration;
|
||||
|
||||
String _duration;
|
||||
set duration(String value) {
|
||||
_duration = value;
|
||||
final paragraph = _buildParagraph(const Color(0xFFD0D0D0), _duration);
|
||||
if (paragraph.maxIntrinsicWidth != _cache?.maxIntrinsicWidth) {
|
||||
markNeedsLayout();
|
||||
}
|
||||
_cache?.dispose();
|
||||
_cache = paragraph;
|
||||
markNeedsSemanticsUpdate();
|
||||
}
|
||||
|
||||
String _position;
|
||||
set position(String value) {
|
||||
_position = value;
|
||||
markNeedsPaint();
|
||||
markNeedsSemanticsUpdate();
|
||||
}
|
||||
|
||||
ui.Paragraph? _cache;
|
||||
|
||||
ui.Paragraph _buildParagraph(Color color, String time) {
|
||||
final builder =
|
||||
ui.ParagraphBuilder(
|
||||
ui.ParagraphStyle(
|
||||
fontSize: 10,
|
||||
height: 1.4,
|
||||
fontFamily: 'Monospace',
|
||||
),
|
||||
)
|
||||
..pushStyle(
|
||||
ui.TextStyle(
|
||||
color: color,
|
||||
fontSize: 10,
|
||||
height: 1.4,
|
||||
fontFamily: 'Monospace',
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
)
|
||||
..addText(time);
|
||||
return builder.build()
|
||||
..layout(const ui.ParagraphConstraints(width: .infinity));
|
||||
}
|
||||
|
||||
@override
|
||||
ui.Size computeDryLayout(covariant BoxConstraints constraints) {
|
||||
final paragraph = _cache ??= _buildParagraph(
|
||||
const Color(0xFFD0D0D0),
|
||||
_duration,
|
||||
);
|
||||
return Size(paragraph.maxIntrinsicWidth, paragraph.height * 2);
|
||||
}
|
||||
|
||||
@override
|
||||
void describeSemanticsConfiguration(SemanticsConfiguration config) {
|
||||
super.describeSemanticsConfiguration(config);
|
||||
config.label = 'position:$_position\nduration:$_duration';
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
size = computeDryLayout(constraints);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, ui.Offset offset) {
|
||||
final para = _buildParagraph(Colors.white, _position);
|
||||
context.canvas
|
||||
..drawParagraph(
|
||||
para,
|
||||
Offset(
|
||||
offset.dx + _cache!.maxIntrinsicWidth - para.maxIntrinsicWidth,
|
||||
offset.dy,
|
||||
),
|
||||
)
|
||||
..drawParagraph(_cache!, Offset(offset.dx, offset.dy + para.height));
|
||||
para.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cache?.dispose();
|
||||
_cache = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isRepaintBoundary => true;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:PiliPlus/common/widgets/progress_bar/audio_video_progress_bar.da
|
||||
import 'package:PiliPlus/common/widgets/progress_bar/segment_progress_bar.dart';
|
||||
import 'package:PiliPlus/pages/video/controller.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/controller.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/view.dart';
|
||||
import 'package:PiliPlus/plugin/pl_player/view/view.dart';
|
||||
import 'package:PiliPlus/utils/extension/theme_ext.dart';
|
||||
import 'package:PiliPlus/utils/feed_back.dart';
|
||||
import 'package:PiliPlus/utils/platform_utils.dart';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
|
||||
import 'package:PiliPlus/http/browser_ua.dart';
|
||||
import 'package:PiliPlus/http/constants.dart';
|
||||
import 'package:PiliPlus/utils/storage_pref.dart';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
@@ -13,10 +14,9 @@ import 'package:media_kit/ffi/src/utf8.dart';
|
||||
import 'package:media_kit/generated/libmpv/bindings.dart' as generated;
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
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));
|
||||
final _mpv = NativePlayer.mpv;
|
||||
late final Pointer<generated.mpv_handle> _ctx;
|
||||
final _completer = Completer<bool>();
|
||||
|
||||
@@ -41,7 +41,7 @@ class MpvConvertWebp {
|
||||
Future<void> _init() async {
|
||||
final enableHA = Pref.enableHA;
|
||||
_ctx = await Initializer.create(
|
||||
NativeLibrary.path,
|
||||
_mpv,
|
||||
_onEvent,
|
||||
options: {
|
||||
'o': outFile,
|
||||
@@ -58,13 +58,10 @@ class MpvConvertWebp {
|
||||
},
|
||||
);
|
||||
NativePlayer.setHeader(
|
||||
const {
|
||||
'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,
|
||||
},
|
||||
_mpv,
|
||||
_ctx,
|
||||
userAgent: BrowserUa.pc,
|
||||
referer: HttpString.baseUrl,
|
||||
);
|
||||
if (progress != null) {
|
||||
_observeProperty('time-pos');
|
||||
@@ -86,7 +83,7 @@ class MpvConvertWebp {
|
||||
return _completer.future;
|
||||
}
|
||||
|
||||
Future<void> _onEvent(Pointer<generated.mpv_event> event) async {
|
||||
Future<void>? _onEvent(Pointer<generated.mpv_event> event) {
|
||||
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;
|
||||
@@ -117,11 +114,12 @@ class MpvConvertWebp {
|
||||
dispose();
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _command(List<String> args) {
|
||||
final pointers = args.map((e) => e.toNativeUtf8()).toList();
|
||||
final arr = calloc<Pointer<Uint8>>(128);
|
||||
final arr = calloc<Pointer<Uint8>>(pointers.length + 1);
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
arr[i] = pointers[i];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user