Aves_mio0.88/lib/services/cast/cast_server.dart.ok
2026-10-03 12:51:15 +02:00

426 lines
13 KiB
Text

// 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<int, Uint8List> _optimizedImages = <int, Uint8List>{};
final Map<int, _ServingChoice> _servingChoices = <int, _ServingChoice>{};
final Map<String, Uint8List> _assetCache = <String, Uint8List>{};
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<void> 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<int> 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<Uint8List?> 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<Response> _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<Response> _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<Uint8List> _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<String, String> _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<Response> _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<Response> _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<List<int>> _logStreamCompletion(
Stream<List<int>> 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<String?> _getFilePath(int id) async {
const channel = MethodChannel('aves/entry_path');
try {
return await channel.invokeMethod<String>(
'getEntryPath',
{'id': id},
);
} catch (error) {
_tlog('getFilePath error id=$id error=$error');
return null;
}
}
Future<void> ensureStarted() async {
if (!isRunning) await start();
}
}
final castServer = CastServer();