938 lines
21 KiB
Dart
938 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'media_cache_settings_service.dart';
|
|
|
|
class WindowsEmbeddedProgressiveSession {
|
|
const WindowsEmbeddedProgressiveSession({
|
|
required this.videoUrl,
|
|
required this.audioUri,
|
|
required this.videoFromCache,
|
|
required this.audioFromCache,
|
|
});
|
|
|
|
final String videoUrl;
|
|
final String audioUri;
|
|
final bool videoFromCache;
|
|
final bool audioFromCache;
|
|
}
|
|
|
|
class WindowsEmbeddedProgressiveStatus {
|
|
const WindowsEmbeddedProgressiveStatus({
|
|
required this.videoBytes,
|
|
required this.videoLength,
|
|
required this.audioBytes,
|
|
required this.audioLength,
|
|
required this.videoComplete,
|
|
required this.audioComplete,
|
|
required this.estimatedBufferedDuration,
|
|
});
|
|
|
|
final int videoBytes;
|
|
final int videoLength;
|
|
final int audioBytes;
|
|
final int audioLength;
|
|
final bool videoComplete;
|
|
final bool audioComplete;
|
|
final Duration estimatedBufferedDuration;
|
|
}
|
|
|
|
/// Streaming progressif Windows basé sur le transport déjà validé yt-dlp.
|
|
///
|
|
/// yt-dlp télécharge le fichier web_embedded pendant que libmpv lit une
|
|
/// façade HTTP locale du fichier qui grandit.
|
|
///
|
|
/// Différence critique avec l'étape 17.15 :
|
|
/// on N'OUVRE PLUS libmpv lorsque le fichier fait 0 octet. Le lecteur 360p
|
|
/// reste actif jusqu'à ce qu'une vraie réserve HD soit disponible autour du
|
|
/// timestamp courant.
|
|
///
|
|
/// Les fichiers restent dans le cache persistant :
|
|
/// %LOCALAPPDATA%\YouTubePersonal\media_cache\<videoId>
|
|
class WindowsEmbeddedProgressiveService {
|
|
HttpServer? _server;
|
|
StreamSubscription<HttpRequest>? _serverSubscription;
|
|
|
|
final Set<Process> _activeProcesses =
|
|
<Process>{};
|
|
|
|
_GrowingSource? _video;
|
|
_GrowingSource? _audio;
|
|
String? _activeVideoCacheId;
|
|
Directory? _activeVideoDirectory;
|
|
|
|
Future<WindowsEmbeddedProgressiveSession> start({
|
|
required String videoId,
|
|
required String ytDlpPath,
|
|
required String denoPath,
|
|
required String watchUrl,
|
|
required String videoFormatId,
|
|
required String audioFormatId,
|
|
required String videoExt,
|
|
required String audioExt,
|
|
required String videoMimeType,
|
|
required int videoLength,
|
|
required int audioLength,
|
|
}) async {
|
|
if (!Platform.isWindows) {
|
|
throw UnsupportedError(
|
|
'Le streaming progressif HD est réservé à Windows.',
|
|
);
|
|
}
|
|
|
|
await stop();
|
|
|
|
final ytDlp = File(ytDlpPath);
|
|
final deno = File(denoPath);
|
|
|
|
if (!await ytDlp.exists()) {
|
|
throw StateError(
|
|
'yt-dlp.exe introuvable : $ytDlpPath',
|
|
);
|
|
}
|
|
|
|
if (!await deno.exists()) {
|
|
throw StateError(
|
|
'deno.exe introuvable : $denoPath',
|
|
);
|
|
}
|
|
|
|
if (videoLength <= 0 || audioLength <= 0) {
|
|
throw StateError(
|
|
'Tailles web_embedded invalides : '
|
|
'video=$videoLength audio=$audioLength',
|
|
);
|
|
}
|
|
|
|
final cacheSettings = MediaCacheSettingsService.instance;
|
|
final cacheRoot = await cacheSettings.resolveCacheRoot();
|
|
final safeVideoId = _safeName(videoId);
|
|
|
|
await cacheSettings.enforceLimit(keepVideoId: safeVideoId);
|
|
|
|
final videoDirectory = Directory(
|
|
'${cacheRoot.path}'
|
|
'${Platform.pathSeparator}'
|
|
'$safeVideoId',
|
|
);
|
|
|
|
await videoDirectory.create(
|
|
recursive: true,
|
|
);
|
|
_activeVideoCacheId = safeVideoId;
|
|
_activeVideoDirectory = videoDirectory;
|
|
await cacheSettings.touchVideoDirectory(videoDirectory);
|
|
|
|
final videoFile = File(
|
|
'${videoDirectory.path}'
|
|
'${Platform.pathSeparator}'
|
|
'video_${_safeName(videoFormatId)}.$videoExt',
|
|
);
|
|
|
|
final audioFile = File(
|
|
'${videoDirectory.path}'
|
|
'${Platform.pathSeparator}'
|
|
'audio_${_safeName(audioFormatId)}.$audioExt',
|
|
);
|
|
|
|
final videoHit =
|
|
await _isComplete(
|
|
videoFile,
|
|
videoLength,
|
|
);
|
|
|
|
final audioHit =
|
|
await _isComplete(
|
|
audioFile,
|
|
audioLength,
|
|
);
|
|
|
|
// Un ancien fichier incomplet ne doit pas être pris pour un HIT.
|
|
// On le recrée proprement; yt-dlp continuera ensuite à le remplir
|
|
// pendant la lecture.
|
|
if (!videoHit) {
|
|
await _deleteIfExists(videoFile);
|
|
}
|
|
|
|
if (!audioHit) {
|
|
await _deleteIfExists(audioFile);
|
|
}
|
|
|
|
final video = _GrowingSource(
|
|
id: 'video',
|
|
file: videoFile,
|
|
expectedLength: videoLength,
|
|
formatId: videoFormatId,
|
|
);
|
|
|
|
final audio = _GrowingSource(
|
|
id: 'audio',
|
|
file: audioFile,
|
|
expectedLength: audioLength,
|
|
formatId: audioFormatId,
|
|
);
|
|
|
|
if (videoHit) {
|
|
video.exitCode = 0;
|
|
}
|
|
|
|
if (audioHit) {
|
|
audio.exitCode = 0;
|
|
}
|
|
|
|
_video = video;
|
|
_audio = audio;
|
|
|
|
final server = await HttpServer.bind(
|
|
InternetAddress.loopbackIPv4,
|
|
0,
|
|
shared: false,
|
|
);
|
|
|
|
_server = server;
|
|
|
|
_serverSubscription = server.listen(
|
|
(request) {
|
|
unawaited(
|
|
_handleVideoRequest(
|
|
request,
|
|
video,
|
|
videoMimeType,
|
|
),
|
|
);
|
|
},
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
debugPrint(
|
|
'[HD-PROGRESSIVE][17.19] serveur local: $error',
|
|
);
|
|
},
|
|
cancelOnError: false,
|
|
);
|
|
|
|
if (!videoHit) {
|
|
await _startDownload(
|
|
source: video,
|
|
ytDlpPath: ytDlp.path,
|
|
denoPath: deno.path,
|
|
watchUrl: watchUrl,
|
|
);
|
|
}
|
|
|
|
if (!audioHit) {
|
|
await _startDownload(
|
|
source: audio,
|
|
ytDlpPath: ytDlp.path,
|
|
denoPath: deno.path,
|
|
watchUrl: watchUrl,
|
|
);
|
|
}
|
|
|
|
final base =
|
|
'http://127.0.0.1:${server.port}';
|
|
|
|
debugPrint(
|
|
'[HD-PROGRESSIVE][17.19] session prête '
|
|
'video=${videoHit ? 'HIT' : 'STREAM'} '
|
|
'audio=${audioHit ? 'HIT' : 'DOWNLOAD'} '
|
|
'video=$videoFormatId audio=$audioFormatId',
|
|
);
|
|
|
|
return WindowsEmbeddedProgressiveSession(
|
|
videoUrl: '$base/video',
|
|
audioUri: Uri.file(
|
|
audioFile.path,
|
|
windows: true,
|
|
).toString(),
|
|
videoFromCache: videoHit,
|
|
audioFromCache: audioHit,
|
|
);
|
|
}
|
|
|
|
Future<WindowsEmbeddedProgressiveStatus> status({
|
|
required Duration mediaDuration,
|
|
}) async {
|
|
final video = _video;
|
|
final audio = _audio;
|
|
|
|
if (video == null || audio == null) {
|
|
throw StateError(
|
|
'Aucune session progressive active.',
|
|
);
|
|
}
|
|
|
|
final videoBytes =
|
|
await _safeLength(video.file);
|
|
|
|
final audioBytes =
|
|
await _safeLength(audio.file);
|
|
|
|
final videoComplete =
|
|
videoBytes == video.expectedLength;
|
|
|
|
final audioComplete =
|
|
audioBytes == audio.expectedLength;
|
|
|
|
Duration estimated = Duration.zero;
|
|
|
|
if (mediaDuration > Duration.zero &&
|
|
video.expectedLength > 0 &&
|
|
videoBytes > 0) {
|
|
final ratio = (
|
|
videoBytes / video.expectedLength
|
|
).clamp(0.0, 1.0);
|
|
|
|
estimated = Duration(
|
|
milliseconds:
|
|
(mediaDuration.inMilliseconds * ratio)
|
|
.round(),
|
|
);
|
|
}
|
|
|
|
return WindowsEmbeddedProgressiveStatus(
|
|
videoBytes: videoBytes,
|
|
videoLength: video.expectedLength,
|
|
audioBytes: audioBytes,
|
|
audioLength: audio.expectedLength,
|
|
videoComplete: videoComplete,
|
|
audioComplete: audioComplete,
|
|
estimatedBufferedDuration: estimated,
|
|
);
|
|
}
|
|
|
|
/// Vrai lorsque le fichier vidéo contient suffisamment d'octets pour
|
|
/// couvrir approximativement [position] + [ahead], avec une marge.
|
|
///
|
|
/// On n'utilise pas --download-sections ici : le fichier DASH continue de
|
|
/// grandir normalement, ce qui évite une découpe/re-encapsulation ffmpeg.
|
|
Future<bool> hasEnoughBuffer({
|
|
required Duration position,
|
|
required Duration mediaDuration,
|
|
Duration ahead = const Duration(seconds: 20),
|
|
}) async {
|
|
final video = _video;
|
|
final audio = _audio;
|
|
|
|
if (video == null || audio == null) {
|
|
return false;
|
|
}
|
|
|
|
final videoBytes =
|
|
await _safeLength(video.file);
|
|
|
|
final audioBytes =
|
|
await _safeLength(audio.file);
|
|
|
|
// L'audio 251 est petit. Pour garantir le son lors de la bascule,
|
|
// on attend qu'il soit complet.
|
|
if (audioBytes != audio.expectedLength) {
|
|
return false;
|
|
}
|
|
|
|
if (videoBytes == video.expectedLength) {
|
|
return true;
|
|
}
|
|
|
|
if (mediaDuration <= Duration.zero) {
|
|
return videoBytes >= 24 * 1024 * 1024;
|
|
}
|
|
|
|
final wantedPosition = Duration(
|
|
milliseconds: math.min(
|
|
mediaDuration.inMilliseconds,
|
|
position.inMilliseconds +
|
|
ahead.inMilliseconds,
|
|
),
|
|
);
|
|
|
|
final ratio = (
|
|
wantedPosition.inMilliseconds /
|
|
mediaDuration.inMilliseconds
|
|
).clamp(0.0, 1.0);
|
|
|
|
// 6 MiB de marge pour l'init/index + variations de bitrate.
|
|
final estimatedRequired =
|
|
(video.expectedLength * ratio).ceil() +
|
|
6 * 1024 * 1024;
|
|
|
|
// Même au début, on exige un prébuffer réel pour ne pas reproduire
|
|
// l'échec 17.15 où mpv ouvrait le flux alors qu'il était vide.
|
|
final required = math.min(
|
|
video.expectedLength,
|
|
math.max(
|
|
16 * 1024 * 1024,
|
|
estimatedRequired,
|
|
),
|
|
);
|
|
|
|
return videoBytes >= required;
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
final subscription =
|
|
_serverSubscription;
|
|
_serverSubscription = null;
|
|
|
|
if (subscription != null) {
|
|
await subscription.cancel();
|
|
}
|
|
|
|
final server = _server;
|
|
_server = null;
|
|
|
|
if (server != null) {
|
|
await server.close(
|
|
force: true,
|
|
);
|
|
}
|
|
|
|
final processes =
|
|
_activeProcesses.toList(
|
|
growable: false,
|
|
);
|
|
|
|
_activeProcesses.clear();
|
|
|
|
for (final process in processes) {
|
|
process.kill();
|
|
}
|
|
|
|
if (processes.isNotEmpty) {
|
|
await Future<void>.delayed(
|
|
const Duration(milliseconds: 150),
|
|
);
|
|
}
|
|
|
|
// Les fichiers COMPLETS restent en cache.
|
|
// Les fichiers incomplets sont supprimés pour éviter un faux HIT.
|
|
final video = _video;
|
|
final audio = _audio;
|
|
|
|
_video = null;
|
|
_audio = null;
|
|
|
|
if (video != null) {
|
|
final length =
|
|
await _safeLength(video.file);
|
|
|
|
if (length != video.expectedLength) {
|
|
await _deleteIfExists(video.file);
|
|
}
|
|
}
|
|
|
|
if (audio != null) {
|
|
final length =
|
|
await _safeLength(audio.file);
|
|
|
|
if (length != audio.expectedLength) {
|
|
await _deleteIfExists(audio.file);
|
|
}
|
|
}
|
|
|
|
final cacheId = _activeVideoCacheId;
|
|
final cacheDirectory = _activeVideoDirectory;
|
|
_activeVideoCacheId = null;
|
|
_activeVideoDirectory = null;
|
|
|
|
if (cacheDirectory != null) {
|
|
final cacheSettings = MediaCacheSettingsService.instance;
|
|
await cacheSettings.touchVideoDirectory(cacheDirectory);
|
|
await cacheSettings.enforceLimit(keepVideoId: cacheId);
|
|
}
|
|
}
|
|
|
|
Future<void> _startDownload({
|
|
required _GrowingSource source,
|
|
required String ytDlpPath,
|
|
required String denoPath,
|
|
required String watchUrl,
|
|
}) async {
|
|
final args = <String>[
|
|
'--ignore-config',
|
|
'--no-playlist',
|
|
'--no-warnings',
|
|
'--newline',
|
|
'--socket-timeout',
|
|
'20',
|
|
'--retries',
|
|
'5',
|
|
'--fragment-retries',
|
|
'5',
|
|
'--js-runtimes',
|
|
'deno:$denoPath',
|
|
'--extractor-args',
|
|
'youtube:player_client=web_embedded',
|
|
'--force-overwrites',
|
|
'--no-part',
|
|
'-f',
|
|
source.formatId,
|
|
'-o',
|
|
source.file.path,
|
|
watchUrl,
|
|
];
|
|
|
|
debugPrint(
|
|
'[HD-PROGRESSIVE][17.19] yt-dlp '
|
|
'${source.id} format=${source.formatId}',
|
|
);
|
|
|
|
final process = await Process.start(
|
|
ytDlpPath,
|
|
args,
|
|
workingDirectory:
|
|
File(ytDlpPath).parent.path,
|
|
runInShell: false,
|
|
mode: ProcessStartMode.normal,
|
|
);
|
|
|
|
source.process = process;
|
|
_activeProcesses.add(process);
|
|
|
|
process.stdout
|
|
.transform(
|
|
const SystemEncoding().decoder,
|
|
)
|
|
.listen(
|
|
(_) {
|
|
// Le player affiche l'état à partir de la taille réelle du fichier,
|
|
// plus fiable que les lignes de progression yt-dlp.
|
|
},
|
|
);
|
|
|
|
process.stderr
|
|
.transform(
|
|
const SystemEncoding().decoder,
|
|
)
|
|
.listen(
|
|
(text) {
|
|
if (source.stderr.length < 5000) {
|
|
final remaining =
|
|
5000 - source.stderr.length;
|
|
|
|
source.stderr.write(
|
|
text.length <= remaining
|
|
? text
|
|
: text.substring(0, remaining),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
unawaited(
|
|
process.exitCode.then(
|
|
(code) async {
|
|
_activeProcesses.remove(process);
|
|
source.exitCode = code;
|
|
|
|
final length =
|
|
await _safeLength(source.file);
|
|
|
|
if (code == 0 &&
|
|
length == source.expectedLength) {
|
|
debugPrint(
|
|
'[HD-PROGRESSIVE][17.19] '
|
|
'${source.id} complet: '
|
|
'$length/${source.expectedLength}',
|
|
);
|
|
} else {
|
|
final stderr = source.stderr
|
|
.toString()
|
|
.replaceAll(
|
|
RegExp(r'\s+'),
|
|
' ',
|
|
)
|
|
.trim();
|
|
|
|
debugPrint(
|
|
'[HD-PROGRESSIVE][17.19] '
|
|
'${source.id} fin code=$code '
|
|
'$length/${source.expectedLength}'
|
|
'${stderr.isEmpty ? '' : ': $stderr'}',
|
|
);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleVideoRequest(
|
|
HttpRequest request,
|
|
_GrowingSource source,
|
|
String contentType,
|
|
) async {
|
|
if (request.uri.path != '/video') {
|
|
request.response.statusCode =
|
|
HttpStatus.notFound;
|
|
await request.response.close();
|
|
return;
|
|
}
|
|
|
|
if (request.method != 'GET' &&
|
|
request.method != 'HEAD') {
|
|
request.response.statusCode =
|
|
HttpStatus.methodNotAllowed;
|
|
await request.response.close();
|
|
return;
|
|
}
|
|
|
|
final rangeHeader =
|
|
request.headers.value(
|
|
HttpHeaders.rangeHeader,
|
|
);
|
|
|
|
final parsed = _parseRange(
|
|
rangeHeader,
|
|
source.expectedLength,
|
|
);
|
|
|
|
if (rangeHeader != null &&
|
|
parsed == null) {
|
|
request.response.statusCode =
|
|
HttpStatus.requestedRangeNotSatisfiable;
|
|
|
|
request.response.headers.set(
|
|
'content-range',
|
|
'bytes */${source.expectedLength}',
|
|
);
|
|
|
|
await request.response.close();
|
|
return;
|
|
}
|
|
|
|
final start = parsed?.$1 ?? 0;
|
|
final end =
|
|
parsed?.$2 ??
|
|
source.expectedLength - 1;
|
|
|
|
// Ne pas envoyer de réponse vide à mpv.
|
|
// Si mpv demande directement un offset légèrement au-delà du buffer
|
|
// estimé, on attend que yt-dlp atteigne réellement cet offset.
|
|
await _waitForOffset(
|
|
source,
|
|
start,
|
|
);
|
|
|
|
final response = request.response;
|
|
|
|
response.statusCode = parsed == null
|
|
? HttpStatus.ok
|
|
: HttpStatus.partialContent;
|
|
|
|
response.headers.set(
|
|
'accept-ranges',
|
|
'bytes',
|
|
);
|
|
|
|
response.headers.set(
|
|
HttpHeaders.cacheControlHeader,
|
|
'no-store',
|
|
);
|
|
|
|
response.headers.set(
|
|
HttpHeaders.contentTypeHeader,
|
|
contentType,
|
|
);
|
|
|
|
response.contentLength =
|
|
end - start + 1;
|
|
|
|
if (parsed != null) {
|
|
response.headers.set(
|
|
'content-range',
|
|
'bytes $start-$end/'
|
|
'${source.expectedLength}',
|
|
);
|
|
}
|
|
|
|
debugPrint(
|
|
'[HD-PROGRESSIVE][17.19] /video '
|
|
'${request.method} bytes=$start-$end',
|
|
);
|
|
|
|
if (request.method == 'HEAD') {
|
|
await response.close();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await _streamGrowingFile(
|
|
source: source,
|
|
start: start,
|
|
end: end,
|
|
response: response,
|
|
);
|
|
} on Object catch (error) {
|
|
debugPrint(
|
|
'[HD-PROGRESSIVE][17.19] /video: $error',
|
|
);
|
|
|
|
try {
|
|
await response.close();
|
|
} catch (_) {
|
|
// mpv peut fermer la connexion lors d'un seek/changement source.
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _waitForOffset(
|
|
_GrowingSource source,
|
|
int offset,
|
|
) async {
|
|
final deadline =
|
|
DateTime.now().add(
|
|
const Duration(seconds: 45),
|
|
);
|
|
|
|
while (true) {
|
|
final available =
|
|
await _safeLength(source.file);
|
|
|
|
if (available > offset ||
|
|
available == source.expectedLength) {
|
|
return;
|
|
}
|
|
|
|
final exitCode = source.exitCode;
|
|
|
|
if (exitCode != null &&
|
|
exitCode != 0) {
|
|
throw HttpException(
|
|
'yt-dlp ${source.id} code=$exitCode',
|
|
);
|
|
}
|
|
|
|
if (DateTime.now().isAfter(deadline)) {
|
|
throw TimeoutException(
|
|
'Offset $offset non disponible '
|
|
'après 45 s.',
|
|
);
|
|
}
|
|
|
|
await Future<void>.delayed(
|
|
const Duration(milliseconds: 40),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _streamGrowingFile({
|
|
required _GrowingSource source,
|
|
required int start,
|
|
required int end,
|
|
required HttpResponse response,
|
|
}) async {
|
|
var cursor = start;
|
|
RandomAccessFile? raf;
|
|
|
|
try {
|
|
while (cursor <= end) {
|
|
final available =
|
|
await _safeLength(source.file);
|
|
|
|
if (available > cursor) {
|
|
raf ??=
|
|
await source.file.open(
|
|
mode: FileMode.read,
|
|
);
|
|
|
|
final readable = math.min(
|
|
available - cursor,
|
|
end - cursor + 1,
|
|
);
|
|
|
|
final count = math.min(
|
|
readable,
|
|
512 * 1024,
|
|
);
|
|
|
|
await raf.setPosition(cursor);
|
|
|
|
final data =
|
|
await raf.read(count);
|
|
|
|
if (data.isEmpty) {
|
|
await Future<void>.delayed(
|
|
const Duration(milliseconds: 20),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
response.add(data);
|
|
await response.flush();
|
|
|
|
cursor += data.length;
|
|
continue;
|
|
}
|
|
|
|
final exitCode = source.exitCode;
|
|
|
|
if (exitCode != null) {
|
|
if (exitCode != 0) {
|
|
throw HttpException(
|
|
'yt-dlp ${source.id} code=$exitCode',
|
|
);
|
|
}
|
|
|
|
if (available <= cursor) {
|
|
throw HttpException(
|
|
'EOF progressif à $available, '
|
|
'offset demandé=$cursor.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Le Player a rejoint le bord du téléchargement.
|
|
// On attend que yt-dlp reprenne de l'avance.
|
|
await Future<void>.delayed(
|
|
const Duration(milliseconds: 30),
|
|
);
|
|
}
|
|
|
|
await response.close();
|
|
} finally {
|
|
await raf?.close();
|
|
}
|
|
}
|
|
|
|
(int, int)? _parseRange(
|
|
String? value,
|
|
int totalLength,
|
|
) {
|
|
if (value == null ||
|
|
value.trim().isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final match = RegExp(
|
|
r'^bytes=(\d*)-(\d*)$',
|
|
caseSensitive: false,
|
|
).firstMatch(
|
|
value.trim(),
|
|
);
|
|
|
|
if (match == null) {
|
|
return null;
|
|
}
|
|
|
|
final startText =
|
|
match.group(1) ?? '';
|
|
final endText =
|
|
match.group(2) ?? '';
|
|
|
|
if (startText.isEmpty) {
|
|
final suffix =
|
|
int.tryParse(endText);
|
|
|
|
if (suffix == null ||
|
|
suffix <= 0) {
|
|
return null;
|
|
}
|
|
|
|
final start = math.max(
|
|
0,
|
|
totalLength - suffix,
|
|
);
|
|
|
|
return (
|
|
start,
|
|
totalLength - 1,
|
|
);
|
|
}
|
|
|
|
final start =
|
|
int.tryParse(startText);
|
|
|
|
if (start == null ||
|
|
start < 0 ||
|
|
start >= totalLength) {
|
|
return null;
|
|
}
|
|
|
|
final parsedEnd =
|
|
endText.isEmpty
|
|
? totalLength - 1
|
|
: int.tryParse(endText);
|
|
|
|
if (parsedEnd == null ||
|
|
parsedEnd < start) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
start,
|
|
math.min(
|
|
parsedEnd,
|
|
totalLength - 1,
|
|
),
|
|
);
|
|
}
|
|
|
|
String _safeName(
|
|
String value,
|
|
) {
|
|
final safe =
|
|
value.replaceAll(
|
|
RegExp(r'[^A-Za-z0-9._-]'),
|
|
'_',
|
|
);
|
|
|
|
return safe.isEmpty
|
|
? 'media'
|
|
: safe;
|
|
}
|
|
|
|
Future<bool> _isComplete(
|
|
File file,
|
|
int expectedLength,
|
|
) async {
|
|
return await _safeLength(file) ==
|
|
expectedLength;
|
|
}
|
|
|
|
Future<int> _safeLength(
|
|
File file,
|
|
) async {
|
|
try {
|
|
if (!await file.exists()) {
|
|
return 0;
|
|
}
|
|
|
|
return await file.length();
|
|
} catch (_) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteIfExists(
|
|
File file,
|
|
) async {
|
|
try {
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
} catch (_) {
|
|
// Une erreur ultérieure sera plus explicite.
|
|
}
|
|
}
|
|
}
|
|
|
|
class _GrowingSource {
|
|
_GrowingSource({
|
|
required this.id,
|
|
required this.file,
|
|
required this.expectedLength,
|
|
required this.formatId,
|
|
});
|
|
|
|
final String id;
|
|
final File file;
|
|
final int expectedLength;
|
|
final String formatId;
|
|
|
|
Process? process;
|
|
int? exitCode;
|
|
final StringBuffer stderr =
|
|
StringBuffer();
|
|
}
|