import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:aves/services/cast/cast_hls_cache.dart'; class CastFmp4SegmenterException implements Exception { final String message; const CastFmp4SegmenterException(this.message); @override String toString() { return 'CastFmp4SegmenterException: $message'; } } class _Mp4BoxHeader { final int offset; final int size; final int headerSize; final String type; const _Mp4BoxHeader({ required this.offset, required this.size, required this.headerSize, required this.type, }); int get end { return offset + size; } } class _PendingMediaFragment { final int offset; final int moofSize; const _PendingMediaFragment({ required this.offset, required this.moofSize, }); } class CastFmp4Segmenter { final int mediaId; final String sourcePath; final int minimumReadySegments; final Duration pollingInterval; final VoidCallback? onReady; static const int _targetDurationSeconds = 3; static const double _defaultSegmentDurationSeconds = 3.0; CastFmp4Segmenter({ required this.mediaId, required this.sourcePath, this.minimumReadySegments = 3, this.pollingInterval = const Duration(milliseconds: 150), this.onReady, }); final Completer _readyCompleter = Completer(); RandomAccessFile? _source; bool _running = false; bool _stopRequested = false; bool _conversionCompleted = false; int _nextOffset = 0; int _publishedSegments = 0; _PendingMediaFragment? _pendingFragment; final BytesBuilder _initializationBytes = BytesBuilder( copy: false, ); Future get ready { return _readyCompleter.future; } bool get isReady { return _readyCompleter.isCompleted; } bool get isRunning { return _running; } int get publishedSegments { return _publishedSegments; } static void _log(String message) { debugPrint('[CAST FMP4 SEG] $message'); } Future start() async { if (_running) { return; } _running = true; _stopRequested = false; _log( 'START ' 'mediaId=$mediaId ' 'sourcePath=$sourcePath ' 'minimumReadySegments=$minimumReadySegments ' 'pollingMs=${pollingInterval.inMilliseconds}', ); try { await _run(); } catch (error, stackTrace) { _log( 'ERROR ' 'mediaId=$mediaId ' 'offset=$_nextOffset ' 'segments=$_publishedSegments ' 'error=$error', ); debugPrint( '[CAST FMP4 SEG] STACK ' 'mediaId=$mediaId\n' '$stackTrace', ); if (!_readyCompleter.isCompleted) { _readyCompleter.completeError( error, stackTrace, ); } rethrow; } finally { await _closeSource(); _running = false; _log( 'STOP ' 'mediaId=$mediaId ' 'offset=$_nextOffset ' 'segments=$_publishedSegments ' 'conversionCompleted=$_conversionCompleted', ); } } void notifyConversionCompleted() { _conversionCompleted = true; _log( 'CONVERSION COMPLETED ' 'mediaId=$mediaId ' 'offset=$_nextOffset ' 'segments=$_publishedSegments', ); } void requestStop() { _stopRequested = true; _log( 'STOP REQUESTED ' 'mediaId=$mediaId', ); } Future _run() async { final sourceFile = File(sourcePath); while (!_stopRequested) { if (!await sourceFile.exists()) { if (_conversionCompleted) { throw CastFmp4SegmenterException( 'File fragmented MP4 non trovato: $sourcePath', ); } await Future.delayed(pollingInterval); continue; } _source ??= await sourceFile.open( mode: FileMode.read, ); final progressed = await _consumeAvailableBoxes( sourceFile, ); if (!progressed) { if (_conversionCompleted) { await _validateCompletedFile(sourceFile); if (!_readyCompleter.isCompleted) { throw CastFmp4SegmenterException( 'Conversione terminata con soli ' '$_publishedSegments segmenti pronti', ); } return; } await Future.delayed(pollingInterval); } } } Future _consumeAvailableBoxes( File sourceFile, ) async { var progressed = false; while (!_stopRequested) { final fileLength = await sourceFile.length(); final header = await _readBoxHeader( fileLength, ); if (header == null) { break; } if (header.end > fileLength) { break; } await _consumeBox(header); _nextOffset = header.end; progressed = true; } return progressed; } Future<_Mp4BoxHeader?> _readBoxHeader( int fileLength, ) async { const normalHeaderSize = 8; if (fileLength - _nextOffset < normalHeaderSize) { return null; } final source = _source; if (source == null) { return null; } await source.setPosition(_nextOffset); final normalHeader = await source.read( normalHeaderSize, ); if (normalHeader.length < normalHeaderSize) { return null; } final normalData = ByteData.sublistView( Uint8List.fromList(normalHeader), ); final size32 = normalData.getUint32( 0, Endian.big, ); final type = String.fromCharCodes( normalHeader.sublist(4, 8), ); var headerSize = normalHeaderSize; int boxSize; if (size32 == 1) { const extendedSizeLength = 8; if (fileLength - _nextOffset < normalHeaderSize + extendedSizeLength) { return null; } final extendedBytes = await source.read( extendedSizeLength, ); if (extendedBytes.length < extendedSizeLength) { return null; } final extendedData = ByteData.sublistView( Uint8List.fromList(extendedBytes), ); boxSize = extendedData.getUint64( 0, Endian.big, ); headerSize = normalHeaderSize + extendedSizeLength; } else if (size32 == 0) { if (!_conversionCompleted) { return null; } boxSize = fileLength - _nextOffset; } else { boxSize = size32; } if (boxSize < headerSize) { throw CastFmp4SegmenterException( 'Box MP4 non valido ' 'offset=$_nextOffset ' 'type=$type ' 'size=$boxSize ' 'headerSize=$headerSize', ); } return _Mp4BoxHeader( offset: _nextOffset, size: boxSize, headerSize: headerSize, type: type, ); } Future _consumeBox( _Mp4BoxHeader header, ) async { switch (header.type) { case 'ftyp': case 'moov': await _consumeInitializationBox(header); break; case 'moof': _consumeMoof(header); break; case 'mdat': await _consumeMdat(header); break; default: _log( 'BOX SKIP ' 'mediaId=$mediaId ' 'offset=${header.offset} ' 'type=${header.type} ' 'size=${header.size}', ); break; } } Future _consumeInitializationBox( _Mp4BoxHeader header, ) async { if (_publishedSegments > 0 || _pendingFragment != null || await castHlsCache.hasInitializationSegment(mediaId)) { throw CastFmp4SegmenterException( 'Box di inizializzazione inatteso ' 'type=${header.type} ' 'offset=${header.offset}', ); } final bytes = await _readRange( header.offset, header.size, ); _initializationBytes.add(bytes); _log( 'INIT BOX ' 'mediaId=$mediaId ' 'type=${header.type} ' 'offset=${header.offset} ' 'size=${header.size}', ); if (header.type == 'moov') { final temporaryPath = await castHlsCache.getInitTemporaryPath(mediaId); final temporaryFile = File(temporaryPath); await temporaryFile.writeAsBytes( _initializationBytes.takeBytes(), flush: true, ); await castHlsCache.publishInitializationSegment( mediaId, ); await _writeSession( state: 'initialization-ready', ); } } void _consumeMoof( _Mp4BoxHeader header, ) { if (_pendingFragment != null) { throw CastFmp4SegmenterException( 'Nuovo moof prima del relativo mdat ' 'offset=${header.offset}', ); } _pendingFragment = _PendingMediaFragment( offset: header.offset, moofSize: header.size, ); _log( 'MOOF READY ' 'mediaId=$mediaId ' 'offset=${header.offset} ' 'size=${header.size}', ); } Future _consumeMdat( _Mp4BoxHeader header, ) async { final pending = _pendingFragment; if (pending == null) { throw CastFmp4SegmenterException( 'mdat senza moof ' 'offset=${header.offset} ' 'size=${header.size}', ); } final expectedMdatOffset = pending.offset + pending.moofSize; if (header.offset != expectedMdatOffset) { throw CastFmp4SegmenterException( 'moof e mdat non adiacenti ' 'moofOffset=${pending.offset} ' 'moofSize=${pending.moofSize} ' 'mdatOffset=${header.offset}', ); } final segmentSize = pending.moofSize + header.size; final segmentBytes = await _readRange( pending.offset, segmentSize, ); final temporaryPath = await castHlsCache.getSegmentTemporaryPath( mediaId, _publishedSegments, ); final temporaryFile = File(temporaryPath); await temporaryFile.writeAsBytes( segmentBytes, flush: true, ); await castHlsCache.publishSegment( mediaId, _publishedSegments, ); _publishedSegments++; _pendingFragment = null; await _writePlaylist( completed: false, ); await _writeSession( state: 'segment-published', ); if (_publishedSegments >= minimumReadySegments && !_readyCompleter.isCompleted) { _readyCompleter.complete(); onReady?.call(); _log( 'READY ' 'mediaId=$mediaId ' 'segments=$_publishedSegments ' 'minimumSegments=$minimumReadySegments', ); } } Future _readRange( int offset, int length, ) async { final source = _source; if (source == null) { throw const CastFmp4SegmenterException( 'File fragmented MP4 non aperto', ); } await source.setPosition(offset); final output = Uint8List(length); var written = 0; while (written < length) { final chunk = await source.read( length - written, ); if (chunk.isEmpty) { throw CastFmp4SegmenterException( 'Lettura fMP4 interrotta ' 'offset=$offset ' 'expected=$length ' 'read=$written', ); } output.setRange( written, written + chunk.length, chunk, ); written += chunk.length; } return output; } Future _writePlaylist({ required bool completed, }) async { final playlistType = completed ? 'VOD' : 'EVENT'; final buffer = StringBuffer() ..writeln('#EXTM3U') ..writeln('#EXT-X-VERSION:7') ..writeln('#EXT-X-TARGETDURATION:$_targetDurationSeconds') ..writeln('#EXT-X-MEDIA-SEQUENCE:0') ..writeln('#EXT-X-PLAYLIST-TYPE:$playlistType') ..writeln('#EXT-X-INDEPENDENT-SEGMENTS') ..writeln('#EXT-X-MAP:URI="init.mp4"'); for (var index = 0; index < _publishedSegments; index++) { final number = index.toString().padLeft(5, '0'); buffer ..writeln( '#EXTINF:${_defaultSegmentDurationSeconds.toStringAsFixed(3)},', ) ..writeln('segment_$number.m4s'); } if (completed) { buffer.writeln('#EXT-X-ENDLIST'); } await castHlsCache.writePlaylistAtomically( mediaId, buffer.toString(), ); _log( 'PLAYLIST UPDATED ' 'mediaId=$mediaId ' 'segments=$_publishedSegments ' 'completed=$completed', ); } Future _writeSession({ required String state, }) async { await castHlsCache.writeSessionAtomically( mediaId, { 'mediaId': mediaId, 'state': state, 'sourcePath': sourcePath, 'nextOffset': _nextOffset, 'publishedSegments': _publishedSegments, 'minimumReadySegments': minimumReadySegments, 'ready': isReady, 'conversionCompleted': _conversionCompleted, 'updatedAt': DateTime.now().toIso8601String(), }, ); } Future _validateCompletedFile( File sourceFile, ) async { final fileLength = await sourceFile.length(); if (_pendingFragment != null) { throw const CastFmp4SegmenterException( 'Conversione completata con un moof senza mdat', ); } if (_nextOffset != fileLength) { throw CastFmp4SegmenterException( 'File fragmented MP4 non consumato completamente ' 'parsedBytes=$_nextOffset ' 'fileBytes=$fileLength', ); } await _writePlaylist( completed: true, ); await castHlsCache.markComplete(mediaId); await _writeSession( state: 'segmentation-complete', ); _log( 'COMPLETE ' 'mediaId=$mediaId ' 'fileBytes=$fileLength ' 'segments=$_publishedSegments', ); } Future _closeSource() async { final source = _source; _source = null; if (source == null) { return; } await source.close(); } }