426 lines
13 KiB
Text
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();
|