// lib/services/cast/cast_server.dart import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:mime/mime.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; class _ByteRange { final int start; final int end; const _ByteRange(this.start, this.end); } class _ServingChoice { final bool optimized; final DateTime createdAt; const _ServingChoice({ required this.optimized, required this.createdAt, }); } class CastServer { HttpServer? _server; String? _ip; final Map _optimizedImages = {}; final Map _servingChoices = {}; final Map _assetCache = {}; bool get isRunning => _server != null; static final Stopwatch _timingSw = Stopwatch()..start(); static int _ms() => _timingSw.elapsedMilliseconds; static String _isoNow() => DateTime.now().toIso8601String(); static void _tlog(String message) { debugPrint('CASTT [${_ms()} ms][${_isoNow()}] $message'); } Future start() async { if (_server != null) { _tlog( 'castServer.start skip already-running ip=$_ip port=${_server?.port}', ); return; } _tlog('castServer.start begin'); final interfaces = await NetworkInterface.list( includeLoopback: false, type: InternetAddressType.IPv4, ); if (interfaces.isEmpty) { throw StateError('Nessuna interfaccia IPv4 disponibile per Google Cast'); } final wifi = interfaces.firstWhere( (interface) { final name = interface.name.toLowerCase(); return name == 'wlan0' || name.contains('wifi') || name.contains('wlan'); }, orElse: () => interfaces.first, ); if (wifi.addresses.isEmpty) { throw StateError('Nessun indirizzo IPv4 disponibile su ${wifi.name}'); } _ip = wifi.addresses.first.address; _server = await HttpServer.bind( InternetAddress.anyIPv4, 0, shared: true, ); final handler = const Pipeline().addHandler(_handleRequest); shelf_io.serveRequests(_server!, handler); _tlog('castServer.start active url=http://$_ip:${_server!.port}'); } String buildUrlForEntry(dynamic entry) { if (_server == null || _ip == null) { throw StateError('CastServer non avviato'); } final uri = '${entry.uri}'; final segments = Uri.tryParse(uri)?.pathSegments; final mediaId = segments != null && segments.isNotEmpty ? segments.last : uri.split('/').last; final url = 'http://$_ip:${_server!.port}/media/$mediaId'; _tlog('buildUrlForEntry mediaId=$mediaId url=$url'); return url; } String buildUrlForAsset(String fileName) { if (_server == null || _ip == null) { throw StateError('CastServer non avviato'); } if (fileName != 'start.jpg' && fileName != 'end.jpg') { throw ArgumentError.value(fileName, 'fileName', 'Asset Cast non valido'); } final url = 'http://$_ip:${_server!.port}/assets/$fileName'; _tlog('[CAST QUEUE] ASSET URL file=$fileName url=$url'); return url; } bool hasOptimizedImage(int mediaId) => _optimizedImages.containsKey(mediaId); void storeOptimizedImage(int mediaId, Uint8List bytes) { _optimizedImages[mediaId] = bytes; _tlog('optimized stored id=$mediaId size=${bytes.length}'); } void retainOptimizedImages(Set mediaIds) { _optimizedImages.removeWhere((id, _) => !mediaIds.contains(id)); _servingChoices.removeWhere((id, _) => !mediaIds.contains(id)); } void clearOptimizedImages() { _optimizedImages.clear(); _servingChoices.clear(); } // Chiamato quando una voce diventa quella visualizzata. La prima richiesta // successiva scegliera nuovamente tra ottimizzata e originale. void resetServingChoice(int mediaId) { _servingChoices.remove(mediaId); } Future readOriginalBytes(int mediaId) async { final path = await _getFilePath(mediaId); if (path == null) return null; final file = File(path); if (!await file.exists()) return null; return file.readAsBytes(); } Future _handleRequest(Request request) async { final stopwatch = Stopwatch()..start(); final path = request.requestedUri.path; _tlog( 'request method=${request.method} path=$path ' 'range=${request.headers['range'] ?? '-'}', ); if (request.url.pathSegments.length >= 2 && request.url.pathSegments.first == 'assets') { return _handleAssetRequest(request, stopwatch); } if (request.url.pathSegments.length < 2 || request.url.pathSegments.first != 'media') { return Response.notFound('Invalid path'); } final id = int.tryParse(request.url.pathSegments[1]); if (id == null) return Response.notFound('Invalid ID'); // Mantiene coerenti HEAD/GET/Range ravvicinati. Dopo 30 secondi la scelta // puo essere rivalutata, oppure viene resettata quando l'indice cambia. final now = DateTime.now(); var choice = _servingChoices[id]; if (choice == null || now.difference(choice.createdAt).inSeconds >= 30) { choice = _ServingChoice( optimized: _optimizedImages.containsKey(id), createdAt: now, ); _servingChoices[id] = choice; } if (choice.optimized) { final bytes = _optimizedImages[id]; if (bytes != null) { return _serveBytesWithRange( bytes, request, resourceId: 'media:$id', mime: 'image/jpeg', requestStopwatch: stopwatch, representation: 'optimized', ); } // La cache puo essere stata ruotata tra due richieste. _servingChoices[id] = _ServingChoice( optimized: false, createdAt: now, ); } final filePath = await _getFilePath(id); if (filePath == null) return Response.notFound('No file for ID=$id'); final file = File(filePath); if (!await file.exists()) return Response.notFound('File not found'); final mime = lookupMimeType(file.path) ?? 'application/octet-stream'; return _serveFileWithRange( file, request, id: id, mime: mime, requestStopwatch: stopwatch, ); } Future _handleAssetRequest( Request request, Stopwatch requestStopwatch, ) async { final fileName = request.url.pathSegments[1]; if (fileName != 'start.jpg' && fileName != 'end.jpg') { _tlog('[CAST QUEUE] ASSET NOT FOUND file=$fileName'); return Response.notFound('Asset not found'); } try { final bytes = await _loadAssetBytes(fileName); _tlog( '[CAST QUEUE] ASSET SERVE file=$fileName method=${request.method} ' 'bytes=${bytes.length} range=${request.headers['range'] ?? '-'}', ); return _serveBytesWithRange( bytes, request, resourceId: 'asset:$fileName', mime: 'image/jpeg', requestStopwatch: requestStopwatch, representation: 'asset', ); } catch (error, stackTrace) { _tlog('[CAST QUEUE] ASSET ERROR file=$fileName error=$error'); debugPrint('[CAST QUEUE] ASSET STACK file=$fileName\n$stackTrace'); return Response.internalServerError(body: 'Unable to load asset'); } } Future _loadAssetBytes(String fileName) async { final cached = _assetCache[fileName]; if (cached != null) { _tlog('[CAST QUEUE] ASSET CACHE HIT file=$fileName bytes=${cached.length}'); return cached; } final byteData = await rootBundle.load('assets/$fileName'); final bytes = byteData.buffer.asUint8List( byteData.offsetInBytes, byteData.lengthInBytes, ); _assetCache[fileName] = bytes; _tlog('[CAST QUEUE] ASSET CACHE STORE file=$fileName bytes=${bytes.length}'); return bytes; } static Map _baseHeaders(String mime, int length) => { 'Content-Type': mime, 'Content-Length': '$length', 'Accept-Ranges': 'bytes', }; static _ByteRange? _parseRange( String? header, int length, ) { if (header == null || !header.startsWith('bytes=')) return null; if (length <= 0) return null; final spec = header.substring('bytes='.length).split(',').first.trim(); final separator = spec.indexOf('-'); if (separator < 0) return null; final startText = spec.substring(0, separator).trim(); final endText = spec.substring(separator + 1).trim(); int start; int end; if (startText.isEmpty) { final suffixLength = int.tryParse(endText); if (suffixLength == null || suffixLength <= 0) return null; start = (length - suffixLength).clamp(0, length - 1).toInt(); end = length - 1; } else { start = int.tryParse(startText) ?? -1; end = endText.isEmpty ? length - 1 : int.tryParse(endText) ?? -1; if (start < 0 || start >= length || end < start) return null; if (end >= length) end = length - 1; } return _ByteRange(start, end); } static Response _rangeNotSatisfiable(int length) { return Response( HttpStatus.requestedRangeNotSatisfiable, headers: { 'Content-Range': 'bytes */$length', 'Accept-Ranges': 'bytes', }, ); } static Future _serveBytesWithRange( Uint8List bytes, Request request, { required String resourceId, required String mime, required Stopwatch requestStopwatch, required String representation, }) async { final length = bytes.length; if (request.method == 'HEAD') { return Response.ok(null, headers: _baseHeaders(mime, length)); } final rangeHeader = request.headers['range']; if (rangeHeader == null || !rangeHeader.startsWith('bytes=')) { _tlog('[CAST QUEUE] HTTP 200 resource=$resourceId representation=$representation size=$length'); return Response.ok(bytes, headers: _baseHeaders(mime, length)); } final range = _parseRange(rangeHeader, length); if (range == null) return _rangeNotSatisfiable(length); final slice = Uint8List.sublistView(bytes, range.start, range.end + 1); _tlog( '[CAST QUEUE] HTTP 206 resource=$resourceId representation=$representation range=${range.start}-${range.end}/$length ' 'ms=${requestStopwatch.elapsedMilliseconds}', ); return Response( HttpStatus.partialContent, body: slice, headers: { ..._baseHeaders(mime, slice.length), 'Content-Range': 'bytes ${range.start}-${range.end}/$length', }, ); } static Future _serveFileWithRange( File file, Request request, { required int id, required String mime, required Stopwatch requestStopwatch, }) async { final length = await file.length(); if (request.method == 'HEAD') { return Response.ok(null, headers: _baseHeaders(mime, length)); } final rangeHeader = request.headers['range']; if (rangeHeader == null || !rangeHeader.startsWith('bytes=')) { final stream = _logStreamCompletion( file.openRead(), label: '200 original', id: id, requestStopwatch: requestStopwatch, ); return Response.ok(stream, headers: _baseHeaders(mime, length)); } final range = _parseRange(rangeHeader, length); if (range == null) return _rangeNotSatisfiable(length); final contentLength = range.end - range.start + 1; final stream = _logStreamCompletion( file.openRead(range.start, range.end + 1), label: '206 original', id: id, requestStopwatch: requestStopwatch, ); return Response( HttpStatus.partialContent, body: stream, headers: { ..._baseHeaders(mime, contentLength), 'Content-Range': 'bytes ${range.start}-${range.end}/$length', }, ); } static Stream> _logStreamCompletion( Stream> source, { required String label, required int id, required Stopwatch requestStopwatch, }) async* { var sentBytes = 0; try { await for (final chunk in source) { sentBytes += chunk.length; yield chunk; } _tlog( '$label end id=$id sentBytes=$sentBytes ' 'ms=${requestStopwatch.elapsedMilliseconds}', ); } catch (error) { _tlog('$label error id=$id sentBytes=$sentBytes error=$error'); rethrow; } } static Future _getFilePath(int id) async { const channel = MethodChannel('aves/entry_path'); try { return await channel.invokeMethod( 'getEntryPath', {'id': id}, ); } catch (error) { _tlog('getFilePath error id=$id error=$error'); return null; } } Future ensureStarted() async { if (!isRunning) await start(); } } final castServer = CastServer();