1991 lines
60 KiB
Dart
1991 lines
60 KiB
Dart
// lib/widgets/cast/google_cast_controller.dart
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
import 'package:aves/model/entry/entry.dart';
|
|
import 'package:aves/model/entry/extensions/remote_identity.dart';
|
|
import 'package:aves/remote/remote_origin.dart';
|
|
import 'package:aves/remote/remote_settings.dart';
|
|
import 'package:aves/services/cast/cast_server.dart';
|
|
import 'package:aves/widgets/cast/cast_controller.dart';
|
|
|
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
|
import 'package:aves/services/cast/cast_hls_optimizer.dart';
|
|
import 'package:aves/services/cast/cast_hls_cache.dart';
|
|
import 'package:aves/services/cast/cast_video_cache.dart';
|
|
import 'package:aves/services/cast/cast_fmp4_first_ts_segment_test.dart';
|
|
import 'package:aves/services/cast/cast_progressive_ts_optimizer.dart';
|
|
|
|
// 4k 3840x2160 quality 80
|
|
// 2k 1920x1080 quality 85
|
|
|
|
// con soglia 500kb
|
|
Future<Uint8List> optimizeCastImage(Uint8List bytes) async {
|
|
final stopwatch = Stopwatch()..start();
|
|
|
|
// JPEG: FF D8 FF
|
|
final isJpeg = bytes.length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
|
|
|
const skipThreshold = 500 * 1024; // 500 KiB
|
|
|
|
if (isJpeg && bytes.length < skipThreshold) {
|
|
stopwatch.stop();
|
|
|
|
debugPrint(
|
|
'[CAST OPT] '
|
|
'SKIP JPEG '
|
|
'input=${bytes.length}B '
|
|
'elapsedMs=${stopwatch.elapsedMilliseconds}',
|
|
);
|
|
|
|
return bytes;
|
|
}
|
|
|
|
final result = await FlutterImageCompress.compressWithList(
|
|
bytes,
|
|
minWidth: 1920,
|
|
minHeight: 1080,
|
|
quality: 85,
|
|
format: CompressFormat.jpeg,
|
|
keepExif: false,
|
|
);
|
|
|
|
stopwatch.stop();
|
|
|
|
debugPrint(
|
|
'[CAST OPT] '
|
|
'input=${bytes.length}B '
|
|
'output=${result.length}B '
|
|
'elapsedMs=${stopwatch.elapsedMilliseconds}',
|
|
);
|
|
|
|
return Uint8List.fromList(result);
|
|
}
|
|
|
|
Future<List<R>> _mapConcurrent<T, R>({
|
|
required List<T> values,
|
|
required int maxConcurrent,
|
|
required Future<R> Function(T value, int index) convert,
|
|
}) async {
|
|
if (values.isEmpty) return <R>[];
|
|
if (maxConcurrent <= 0) {
|
|
throw ArgumentError.value(maxConcurrent, 'maxConcurrent');
|
|
}
|
|
|
|
final results = List<R?>.filled(values.length, null);
|
|
var nextIndex = 0;
|
|
|
|
Future<void> worker() async {
|
|
while (true) {
|
|
final index = nextIndex;
|
|
if (index >= values.length) return;
|
|
nextIndex++;
|
|
results[index] = await convert(values[index], index);
|
|
}
|
|
}
|
|
|
|
final workerCount = values.length < maxConcurrent ? values.length : maxConcurrent;
|
|
|
|
await Future.wait(List.generate(workerCount, (_) => worker()));
|
|
return results.cast<R>();
|
|
}
|
|
|
|
class _CachedShare {
|
|
final String url;
|
|
final DateTime expiresAt;
|
|
|
|
const _CachedShare({required this.url, required this.expiresAt});
|
|
|
|
bool get isValid => DateTime.now().isBefore(
|
|
expiresAt.subtract(const Duration(seconds: 15)),
|
|
);
|
|
}
|
|
|
|
enum _CastQueueItemKind {
|
|
startAsset,
|
|
media,
|
|
endAsset,
|
|
}
|
|
|
|
class _CastQueueEntry {
|
|
final _CastQueueItemKind kind;
|
|
final AvesEntry? entry;
|
|
final String? assetFileName;
|
|
|
|
const _CastQueueEntry.start() : kind = _CastQueueItemKind.startAsset, entry = null, assetFileName = 'start.jpg';
|
|
|
|
const _CastQueueEntry.media(AvesEntry this.entry) : kind = _CastQueueItemKind.media, assetFileName = null;
|
|
|
|
const _CastQueueEntry.end() : kind = _CastQueueItemKind.endAsset, entry = null, assetFileName = 'end.jpg';
|
|
|
|
bool get isAsset => kind != _CastQueueItemKind.media;
|
|
|
|
bool get isVideo => kind == _CastQueueItemKind.media && (entry!.durationMillis ?? 0) > 0;
|
|
|
|
bool get isRemote => kind == _CastQueueItemKind.media && entry!.origin == RemoteOrigin.value;
|
|
}
|
|
|
|
class _CastBufferItem {
|
|
final _CastQueueEntry queueEntry;
|
|
final int? mediaId;
|
|
|
|
const _CastBufferItem({
|
|
required this.queueEntry,
|
|
required this.mediaId,
|
|
});
|
|
|
|
bool get isOptimizable => !queueEntry.isAsset && !queueEntry.isVideo && !queueEntry.isRemote && mediaId != null;
|
|
}
|
|
|
|
class GoogleCastController {
|
|
GoogleCastController._() {
|
|
_channel.setMethodCallHandler(
|
|
_onNativeEvent,
|
|
);
|
|
|
|
_optimizerChannel.setMethodCallHandler(
|
|
_onOptimizerNativeEvent,
|
|
);
|
|
|
|
Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (isPlayingNotifier.value) {
|
|
final position = positionNotifier.value + const Duration(seconds: 1);
|
|
|
|
if (position < durationNotifier.value) {
|
|
positionNotifier.value = position;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
static final GoogleCastController instance = GoogleCastController._();
|
|
|
|
static const MethodChannel _channel = MethodChannel(
|
|
'google_cast',
|
|
);
|
|
|
|
static const MethodChannel _optimizerChannel = MethodChannel(
|
|
'aves/cast_video_optimizer',
|
|
);
|
|
|
|
static const bool _testTransportStreamPipeline = false;
|
|
static const bool _useProgressiveTransportStreamPipeline = true;
|
|
|
|
final Set<int> _incrementalCastStartedMediaIds = <int>{};
|
|
|
|
void activateMethodChannelHandler() {
|
|
_channel.setMethodCallHandler(
|
|
_onNativeEvent,
|
|
);
|
|
|
|
_optimizerChannel.setMethodCallHandler(
|
|
_onOptimizerNativeEvent,
|
|
);
|
|
}
|
|
|
|
void deactivateMethodChannelHandler() {}
|
|
|
|
final ValueNotifier<CastIconState> castIconState = ValueNotifier(CastIconState.idle);
|
|
final ValueNotifier<bool> isCastingNotifier = ValueNotifier(false);
|
|
final ValueNotifier<int> currentIndexNotifier = ValueNotifier(0);
|
|
final ValueNotifier<bool> isPlayingNotifier = ValueNotifier(false);
|
|
final ValueNotifier<bool> isBufferingNotifier = ValueNotifier(false);
|
|
final ValueNotifier<Duration> positionNotifier = ValueNotifier(Duration.zero);
|
|
final ValueNotifier<Duration> durationNotifier = ValueNotifier(Duration.zero);
|
|
final ValueNotifier<double> volumeNotifier = ValueNotifier(1.0);
|
|
final ValueNotifier<bool> isMutedNotifier = ValueNotifier(false);
|
|
|
|
Timer? _slideshowTimer;
|
|
bool _slideshowActive = false;
|
|
int _slideshowIntervalSeconds = 5;
|
|
|
|
static const int _maxConcurrentRemoteShares = 3;
|
|
static const int _maxRemoteShareAttempts = 3;
|
|
|
|
final Map<String, _CachedShare> _shareCache = {};
|
|
final Map<String, Future<String>> _pendingShares = <String, Future<String>>{};
|
|
String? _cachedToken;
|
|
DateTime? _tokenExpiry;
|
|
Future<String>? _tokenRequest;
|
|
|
|
List<String>? resolvedUrls;
|
|
List<bool>? resolvedIsVideo;
|
|
List<Map<String, dynamic>>? _lastQueueItems;
|
|
List<_CastQueueEntry>? _castQueueEntries;
|
|
bool _currentIsVideo = false;
|
|
bool? _lastLoggedIsPlaying;
|
|
bool? _lastLoggedIsBuffering;
|
|
|
|
// Buffer globale della queue: 5 elementi precedenti, quello corrente
|
|
// e 4 elementi successivi. Video e contenuti remoti restano nel buffer,
|
|
// ma solo le foto locali vengono ridimensionate.
|
|
final Map<int, _CastBufferItem> _castBuffer = <int, _CastBufferItem>{};
|
|
final List<int> _decodeQueue = <int>[];
|
|
final Set<int> _optimizingIds = <int>{};
|
|
final Set<int> _tsInspectionTestsStarted = <int>{};
|
|
|
|
bool _decodeWorkerRunning = false;
|
|
int _optimizationGeneration = 0;
|
|
int? _lastOptimizationIndex;
|
|
|
|
void _queueLog(String message) {
|
|
debugPrint('[CAST QUEUE] $message');
|
|
}
|
|
|
|
String _queueEntryKind(_CastQueueEntry queueEntry) {
|
|
switch (queueEntry.kind) {
|
|
case _CastQueueItemKind.startAsset:
|
|
return 'start-asset';
|
|
case _CastQueueItemKind.endAsset:
|
|
return 'end-asset';
|
|
case _CastQueueItemKind.media:
|
|
if (queueEntry.isRemote) {
|
|
return queueEntry.isVideo ? 'remote-video' : 'remote-photo';
|
|
}
|
|
return queueEntry.isVideo ? 'local-video' : 'local-photo';
|
|
}
|
|
}
|
|
|
|
Completer<void> _castReadyCompleter = Completer<void>();
|
|
|
|
void forceReset() {
|
|
stopSlideshow();
|
|
_clearQueueAndOptimization();
|
|
castIconState.value = CastIconState.idle;
|
|
isCastingNotifier.value = false;
|
|
_castReadyCompleter = Completer<void>();
|
|
_resetPlaybackState();
|
|
}
|
|
|
|
void _clearQueueAndOptimization() {
|
|
resolvedUrls = null;
|
|
resolvedIsVideo = null;
|
|
_lastQueueItems = null;
|
|
_castQueueEntries = null;
|
|
_currentIsVideo = false;
|
|
_tsInspectionTestsStarted.clear();
|
|
_startNewOptimizationSession();
|
|
}
|
|
|
|
void _startNewOptimizationSession() {
|
|
final oldBufferLength = _castBuffer.length;
|
|
final oldQueueLength = _decodeQueue.length;
|
|
_optimizationGeneration++;
|
|
_castBuffer.clear();
|
|
_decodeQueue.clear();
|
|
_optimizingIds.clear();
|
|
_lastOptimizationIndex = null;
|
|
castServer.clearOptimizedImages();
|
|
_queueLog(
|
|
'SESSION RESET generation=$_optimizationGeneration '
|
|
'removedBuffer=$oldBufferLength removedDecodeQueue=$oldQueueLength',
|
|
);
|
|
}
|
|
|
|
Future<String> _getToken(RemoteSettings settings) async {
|
|
if (_cachedToken != null && _tokenExpiry != null && DateTime.now().isBefore(_tokenExpiry!)) {
|
|
return _cachedToken!;
|
|
}
|
|
|
|
final pendingRequest = _tokenRequest;
|
|
if (pendingRequest != null) {
|
|
_queueLog('REMOTE TOKEN reuse-pending');
|
|
return pendingRequest;
|
|
}
|
|
|
|
final request = _requestNewToken(settings);
|
|
_tokenRequest = request;
|
|
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
if (identical(_tokenRequest, request)) {
|
|
_tokenRequest = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<String> _requestNewToken(RemoteSettings settings) async {
|
|
_queueLog('REMOTE TOKEN login-start');
|
|
|
|
final response = await http
|
|
.post(
|
|
Uri.parse('${settings.baseUrl}/auth/login'),
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: jsonEncode({
|
|
'email': settings.email,
|
|
'password': settings.password,
|
|
}),
|
|
)
|
|
.timeout(const Duration(seconds: 20));
|
|
|
|
if (response.statusCode != 200) {
|
|
throw Exception(
|
|
'Login remoto fallito '
|
|
'status=${response.statusCode} body=${response.body}',
|
|
);
|
|
}
|
|
|
|
final data = jsonDecode(response.body);
|
|
final token = data['token'] as String?;
|
|
if (token == null || token.isEmpty) {
|
|
throw StateError('Token remoto assente nella risposta di login');
|
|
}
|
|
|
|
_cachedToken = token;
|
|
_tokenExpiry = DateTime.now().add(const Duration(minutes: 10));
|
|
_queueLog('REMOTE TOKEN login-done');
|
|
return token;
|
|
}
|
|
|
|
Future<String> _resolveCastUrl(AvesEntry entry) async {
|
|
if (entry.origin != RemoteOrigin.value) {
|
|
return castServer.buildUrlForEntry(entry);
|
|
}
|
|
|
|
final remoteId = entry.effectiveRemoteId;
|
|
const shareVariant = 'cast';
|
|
if (remoteId == null || remoteId.isEmpty) {
|
|
throw StateError(
|
|
'[GoogleCast] missing remote id for entry id=${entry.id}, '
|
|
'uri=${entry.uri}',
|
|
);
|
|
}
|
|
|
|
final cacheKey = '$remoteId|$shareVariant';
|
|
final cachedShare = _shareCache[cacheKey];
|
|
if (cachedShare != null && cachedShare.isValid) {
|
|
_queueLog('REMOTE SHARE cache-hit remoteId=$remoteId');
|
|
return cachedShare.url;
|
|
}
|
|
|
|
final pendingShare = _pendingShares[cacheKey];
|
|
if (pendingShare != null) {
|
|
_queueLog('REMOTE SHARE reuse-pending remoteId=$remoteId');
|
|
return await pendingShare;
|
|
}
|
|
|
|
final request = _createRemoteShare(
|
|
remoteId: remoteId,
|
|
shareVariant: shareVariant,
|
|
cacheKey: cacheKey,
|
|
);
|
|
|
|
_pendingShares[cacheKey] = request;
|
|
|
|
try {
|
|
return await request;
|
|
} finally {
|
|
if (identical(_pendingShares[cacheKey], request)) {
|
|
unawaited(_pendingShares.remove(cacheKey));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<String> _createRemoteShare({
|
|
required String remoteId,
|
|
required String shareVariant,
|
|
required String cacheKey,
|
|
}) async {
|
|
final settings = await RemoteSettings.load();
|
|
final token = await _getToken(settings);
|
|
|
|
_queueLog('REMOTE SHARE start remoteId=$remoteId variant=$shareVariant');
|
|
|
|
var response = await _postShareWithRetry(
|
|
settings: settings,
|
|
token: token,
|
|
remoteId: remoteId,
|
|
shareVariant: shareVariant,
|
|
);
|
|
|
|
// Se il token e scaduto prima dei 10 minuti stimati, lo rinnova una volta.
|
|
if (response.statusCode == 401) {
|
|
_cachedToken = null;
|
|
_tokenExpiry = null;
|
|
final refreshedToken = await _getToken(settings);
|
|
_queueLog('REMOTE SHARE token-refreshed remoteId=$remoteId');
|
|
response = await _postShareWithRetry(
|
|
settings: settings,
|
|
token: refreshedToken,
|
|
remoteId: remoteId,
|
|
shareVariant: shareVariant,
|
|
);
|
|
}
|
|
|
|
if (response.statusCode != 200) {
|
|
throw Exception(
|
|
'Creazione share remoto fallita remoteId=$remoteId '
|
|
'status=${response.statusCode} body=${response.body}',
|
|
);
|
|
}
|
|
|
|
final data = jsonDecode(response.body);
|
|
final url = data['url'] as String?;
|
|
if (url == null || url.isEmpty) {
|
|
throw StateError('URL share remoto assente remoteId=$remoteId');
|
|
}
|
|
|
|
final expiresAtMs = data['expiresAt'];
|
|
final expiresAt = expiresAtMs is int ? DateTime.fromMillisecondsSinceEpoch(expiresAtMs) : DateTime.now().add(const Duration(minutes: 5));
|
|
|
|
_shareCache[cacheKey] = _CachedShare(url: url, expiresAt: expiresAt);
|
|
_queueLog('REMOTE SHARE done remoteId=$remoteId expiresAt=$expiresAt');
|
|
return url;
|
|
}
|
|
|
|
Future<http.Response> _postShareWithRetry({
|
|
required RemoteSettings settings,
|
|
required String token,
|
|
required String remoteId,
|
|
required String shareVariant,
|
|
}) async {
|
|
Object? lastError;
|
|
|
|
for (var attempt = 1; attempt <= _maxRemoteShareAttempts; attempt++) {
|
|
try {
|
|
final response = await http
|
|
.post(
|
|
Uri.parse('${settings.baseUrl}/share/create'),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer $token',
|
|
},
|
|
body: jsonEncode({
|
|
'entryId': remoteId,
|
|
'variant': shareVariant,
|
|
'ttlSeconds': 300,
|
|
'oneTime': false,
|
|
}),
|
|
)
|
|
.timeout(const Duration(seconds: 20));
|
|
|
|
final retryable = response.statusCode == 429 || response.statusCode == 502 || response.statusCode == 503 || response.statusCode == 504;
|
|
|
|
if (!retryable || attempt == _maxRemoteShareAttempts) {
|
|
return response;
|
|
}
|
|
|
|
final delay = Duration(milliseconds: 500 * attempt);
|
|
_queueLog(
|
|
'REMOTE SHARE retry remoteId=$remoteId attempt=$attempt '
|
|
'status=${response.statusCode} delayMs=${delay.inMilliseconds}',
|
|
);
|
|
await Future<void>.delayed(delay);
|
|
} on TimeoutException catch (error) {
|
|
lastError = error;
|
|
if (attempt == _maxRemoteShareAttempts) rethrow;
|
|
|
|
final delay = Duration(milliseconds: 500 * attempt);
|
|
_queueLog(
|
|
'REMOTE SHARE retry remoteId=$remoteId attempt=$attempt '
|
|
'reason=timeout delayMs=${delay.inMilliseconds}',
|
|
);
|
|
await Future<void>.delayed(delay);
|
|
}
|
|
}
|
|
|
|
throw StateError(
|
|
'Retry share remoto terminato remoteId=$remoteId error=$lastError',
|
|
);
|
|
}
|
|
|
|
Future<void> _onOptimizerNativeEvent(
|
|
MethodCall call,
|
|
) async {
|
|
_queueLog(
|
|
'OPTIMIZER NATIVE METHOD RECEIVED '
|
|
'method=${call.method} '
|
|
'argumentsType=${call.arguments.runtimeType}',
|
|
);
|
|
|
|
switch (call.method) {
|
|
case 'tsHlsIncrementalProgress':
|
|
_handleTsHlsIncrementalProgress(
|
|
call.arguments,
|
|
);
|
|
break;
|
|
|
|
default:
|
|
_queueLog(
|
|
'OPTIMIZER NATIVE EVENT IGNORED '
|
|
'method=${call.method}',
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _onNativeEvent(MethodCall call) async {
|
|
switch (call.method) {
|
|
case 'onCastReady':
|
|
castIconState.value = CastIconState.connected;
|
|
isCastingNotifier.value = true;
|
|
if (!_castReadyCompleter.isCompleted) {
|
|
_castReadyCompleter.complete();
|
|
}
|
|
break;
|
|
|
|
case 'onCastStopped':
|
|
castIconState.value = CastIconState.idle;
|
|
isCastingNotifier.value = false;
|
|
_clearQueueAndOptimization();
|
|
_incrementalCastStartedMediaIds.clear();
|
|
_castReadyCompleter = Completer<void>();
|
|
stopSlideshow();
|
|
_resetPlaybackState();
|
|
break;
|
|
|
|
case 'onQueueIndexChanged':
|
|
final arguments = (call.arguments as Map?) ?? {};
|
|
final index = (arguments['index'] as num?)?.toInt() ?? 0;
|
|
final isVideo = arguments['isVideo'] == true;
|
|
|
|
currentIndexNotifier.value = index;
|
|
_currentIsVideo = isVideo;
|
|
|
|
final castEntries = _castQueueEntries;
|
|
final isEndAsset = castEntries != null && index >= 0 && index < castEntries.length && castEntries[index].kind == _CastQueueItemKind.endAsset;
|
|
|
|
if (_slideshowActive && !isVideo && !isEndAsset) {
|
|
_scheduleNextPhoto();
|
|
} else if (_slideshowActive) {
|
|
_slideshowTimer?.cancel();
|
|
}
|
|
|
|
final previousIndex = _lastOptimizationIndex;
|
|
final direction = previousIndex != null && index < previousIndex ? -1 : 1;
|
|
_logMediaSelectedForDisplay(index, source: 'onQueueIndexChanged');
|
|
_prepareOptimizationWindow(index, direction: direction);
|
|
_resetServingChoiceForIndex(index);
|
|
break;
|
|
|
|
case 'onVideoFinished':
|
|
if (_slideshowActive) await nextManual();
|
|
break;
|
|
|
|
case 'onMediaStatus':
|
|
final data = (call.arguments as Map?) ?? {};
|
|
|
|
final isPlaying = data['isPlaying'] == true;
|
|
final isBuffering = data['isBuffering'] == true;
|
|
final positionMs = (data['position'] as num?)?.toInt() ?? 0;
|
|
final durationMs = (data['duration'] as num?)?.toInt() ?? 0;
|
|
|
|
isPlayingNotifier.value = isPlaying;
|
|
isBufferingNotifier.value = isBuffering;
|
|
positionNotifier.value = Duration(milliseconds: positionMs);
|
|
durationNotifier.value = Duration(milliseconds: durationMs);
|
|
|
|
/*
|
|
* Registra soltanto i cambiamenti dello stato di riproduzione
|
|
* o buffering. Gli aggiornamenti della sola posizione non vengono
|
|
* stampati, così il log rimane leggibile.
|
|
*/
|
|
if (_lastLoggedIsPlaying != isPlaying || _lastLoggedIsBuffering != isBuffering) {
|
|
_queueLog(
|
|
'MEDIA STATUS '
|
|
'index=${currentIndexNotifier.value} '
|
|
'isVideo=$_currentIsVideo '
|
|
'isPlaying=$isPlaying '
|
|
'isBuffering=$isBuffering '
|
|
'positionMs=$positionMs '
|
|
'durationMs=$durationMs',
|
|
);
|
|
|
|
_lastLoggedIsPlaying = isPlaying;
|
|
_lastLoggedIsBuffering = isBuffering;
|
|
}
|
|
break;
|
|
|
|
case 'onVolumeChanged':
|
|
final data = (call.arguments as Map?) ?? {};
|
|
volumeNotifier.value = ((data['volume'] as num?)?.toDouble() ?? 1.0).clamp(0.0, 1.0).toDouble();
|
|
isMutedNotifier.value = data['isMuted'] == true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
void _handleTsHlsIncrementalProgress(
|
|
dynamic arguments,
|
|
) {
|
|
if (arguments is! Map) {
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL EVENT '
|
|
'result=ignored '
|
|
'reason=invalidArguments '
|
|
'argumentsType=${arguments.runtimeType}',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final progress = Map<String, dynamic>.from(
|
|
arguments,
|
|
);
|
|
|
|
final mediaId = progress['mediaId'];
|
|
final event = progress['event'];
|
|
final segmentIndex = progress['segmentIndex'];
|
|
final publishedSegments = progress['publishedSegmentCount'];
|
|
final readySegmentCount = progress['readySegmentCount'];
|
|
final ready = progress['ready'];
|
|
final playlistExists = progress['playlistExists'];
|
|
final playlistBytes = progress['playlistBytes'];
|
|
final playlistPath = progress['playlistPath'];
|
|
final outputDirectory = progress['outputDirectory'];
|
|
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL EVENT '
|
|
'mediaId=$mediaId '
|
|
'event=$event '
|
|
'segmentIndex=$segmentIndex '
|
|
'publishedSegments=$publishedSegments '
|
|
'readySegmentCount=$readySegmentCount '
|
|
'ready=$ready '
|
|
'playlistExists=$playlistExists '
|
|
'playlistBytes=$playlistBytes '
|
|
'playlistPath=$playlistPath '
|
|
'outputDirectory=$outputDirectory',
|
|
);
|
|
|
|
if (event == 'ready' && ready == true) {
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL READY RECEIVED '
|
|
'mediaId=$mediaId '
|
|
'publishedSegments=$publishedSegments '
|
|
'playlistBytes=$playlistBytes '
|
|
'playlistPath=$playlistPath',
|
|
);
|
|
|
|
final normalizedMediaId = switch (mediaId) {
|
|
final num value => value.toInt(),
|
|
_ => null,
|
|
};
|
|
|
|
if (normalizedMediaId == null) {
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL READY IGNORED '
|
|
'reason=invalidMediaId '
|
|
'mediaId=$mediaId',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!_incrementalCastStartedMediaIds.add(normalizedMediaId)) {
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL READY IGNORED '
|
|
'reason=alreadyStarted '
|
|
'mediaId=$normalizedMediaId',
|
|
);
|
|
return;
|
|
}
|
|
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL CAST TRIGGER '
|
|
'mediaId=$normalizedMediaId '
|
|
'publishedSegments=$publishedSegments '
|
|
'playlistPath=$playlistPath',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _resetPlaybackState() {
|
|
isPlayingNotifier.value = false;
|
|
isBufferingNotifier.value = false;
|
|
positionNotifier.value = Duration.zero;
|
|
durationNotifier.value = Duration.zero;
|
|
|
|
_lastLoggedIsPlaying = null;
|
|
_lastLoggedIsBuffering = null;
|
|
}
|
|
|
|
int? _mediaIdOf(AvesEntry entry) {
|
|
final uri = entry.uri;
|
|
final segments = Uri.tryParse(uri)?.pathSegments;
|
|
if (segments != null && segments.isNotEmpty) {
|
|
final parsed = int.tryParse(segments.last);
|
|
if (parsed != null) return parsed;
|
|
}
|
|
return int.tryParse('${entry.id}');
|
|
}
|
|
|
|
void _logMediaSelectedForDisplay(
|
|
int index, {
|
|
required String source,
|
|
}) {
|
|
final entries = _castQueueEntries;
|
|
final items = _lastQueueItems;
|
|
if (entries == null || index < 0 || index >= entries.length) {
|
|
_queueLog('DISPLAY source=$source index=$index unavailable');
|
|
return;
|
|
}
|
|
|
|
final queueEntry = entries[index];
|
|
final mediaId = queueEntry.entry == null ? null : _mediaIdOf(queueEntry.entry!);
|
|
final optimizedReady = mediaId != null && castServer.hasOptimizedImage(mediaId);
|
|
final url = items != null && index < items.length ? items[index]['url'] : null;
|
|
|
|
_queueLog(
|
|
'DISPLAY source=$source index=$index mediaId=${mediaId ?? '-'} '
|
|
'type=${_queueEntryKind(queueEntry)} optimizedReady=$optimizedReady '
|
|
'url=${url ?? '-'}',
|
|
);
|
|
}
|
|
|
|
void _resetServingChoiceForIndex(int index) {
|
|
final entries = _castQueueEntries;
|
|
if (entries == null || index < 0 || index >= entries.length) return;
|
|
final entry = entries[index].entry;
|
|
if (entry == null) return;
|
|
final mediaId = _mediaIdOf(entry);
|
|
if (mediaId != null) castServer.resetServingChoice(mediaId);
|
|
}
|
|
|
|
String _bufferIdentity(_CastBufferItem item) {
|
|
final queueEntry = item.queueEntry;
|
|
if (queueEntry.kind == _CastQueueItemKind.startAsset) return 'asset:start';
|
|
if (queueEntry.kind == _CastQueueItemKind.endAsset) return 'asset:end';
|
|
return 'media:${queueEntry.entry!.id}';
|
|
}
|
|
|
|
void _prepareOptimizationWindow(
|
|
int currentIndex, {
|
|
required int direction,
|
|
}) {
|
|
final entries = _castQueueEntries;
|
|
if (entries == null || entries.isEmpty) return;
|
|
|
|
final safeIndex = currentIndex.clamp(0, entries.length - 1).toInt();
|
|
final oldBuffer = Map<int, _CastBufferItem>.from(_castBuffer);
|
|
final oldQueueKeys = oldBuffer.values.map(_bufferIdentity).toSet();
|
|
_lastOptimizationIndex = safeIndex;
|
|
|
|
final newBuffer = <int, _CastBufferItem>{};
|
|
|
|
// Nessun ritorno circolare: agli estremi della queue gli slot mancanti
|
|
// restano semplicemente vuoti.
|
|
for (var slot = -5; slot <= 4; slot++) {
|
|
final queueIndex = safeIndex + slot;
|
|
if (queueIndex < 0 || queueIndex >= entries.length) continue;
|
|
|
|
final queueEntry = entries[queueIndex];
|
|
final mediaEntry = queueEntry.entry;
|
|
newBuffer[slot] = _CastBufferItem(
|
|
queueEntry: queueEntry,
|
|
mediaId: mediaEntry == null ? null : _mediaIdOf(mediaEntry),
|
|
);
|
|
}
|
|
|
|
final newQueueKeys = newBuffer.values.map(_bufferIdentity).toSet();
|
|
|
|
for (final oldEntry in oldBuffer.entries) {
|
|
if (!newQueueKeys.contains(_bufferIdentity(oldEntry.value))) {
|
|
_queueLog(
|
|
'BUFFER OUT slot=${oldEntry.key} mediaId=${oldEntry.value.mediaId ?? '-'} '
|
|
'type=${_queueEntryKind(oldEntry.value.queueEntry)}',
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final newEntry in newBuffer.entries) {
|
|
if (!oldQueueKeys.contains(_bufferIdentity(newEntry.value))) {
|
|
_queueLog(
|
|
'BUFFER IN slot=${newEntry.key} mediaId=${newEntry.value.mediaId ?? '-'} '
|
|
'type=${_queueEntryKind(newEntry.value.queueEntry)}',
|
|
);
|
|
}
|
|
}
|
|
|
|
_castBuffer
|
|
..clear()
|
|
..addAll(newBuffer);
|
|
|
|
_queueLog(
|
|
'BUFFER READY currentIndex=$safeIndex direction=${direction < 0 ? 'backward' : 'forward'} '
|
|
'length=${_castBuffer.length} slots=${_castBuffer.keys.toList()..sort()}',
|
|
);
|
|
|
|
final retainedIds = _castBuffer.values.where((item) => item.isOptimizable).map((item) => item.mediaId!).toSet();
|
|
|
|
// Conserva in memoria solo le foto locali ancora comprese nella finestra
|
|
// globale -5 ... +4.
|
|
castServer.retainOptimizedImages(retainedIds);
|
|
|
|
_rebuildDecodeQueue(direction);
|
|
}
|
|
|
|
void _rebuildDecodeQueue(int direction) {
|
|
// Quando si procede in avanti si prepara la corrente e poi le quattro
|
|
// posizioni successive. Andando indietro si prepara la corrente e poi le
|
|
// cinque posizioni precedenti.
|
|
final prioritySlots = direction < 0 ? const <int>[0, -1, -2, -3, -4, -5] : const <int>[0, 1, 2, 3, 4];
|
|
|
|
final oldDecodeQueue = List<int>.from(_decodeQueue);
|
|
final queuedIds = <int>{};
|
|
_decodeQueue.clear();
|
|
|
|
for (final slot in prioritySlots) {
|
|
final item = _castBuffer[slot];
|
|
if (item == null || !item.isOptimizable) continue;
|
|
|
|
final mediaId = item.mediaId!;
|
|
if (castServer.hasOptimizedImage(mediaId)) continue;
|
|
if (_optimizingIds.contains(mediaId)) continue;
|
|
if (!queuedIds.add(mediaId)) continue;
|
|
|
|
_decodeQueue.add(mediaId);
|
|
_queueLog(
|
|
'DECODE ENQUEUE slot=$slot mediaId=$mediaId '
|
|
'queueLength=${_decodeQueue.length}',
|
|
);
|
|
}
|
|
|
|
for (final mediaId in oldDecodeQueue) {
|
|
if (!_decodeQueue.contains(mediaId)) {
|
|
_queueLog(
|
|
'DECODE DEQUEUE reason=rebuild mediaId=$mediaId '
|
|
'queueLength=${_decodeQueue.length}',
|
|
);
|
|
}
|
|
}
|
|
|
|
_queueLog(
|
|
'DECODE QUEUE READY direction=${direction < 0 ? 'backward' : 'forward'} '
|
|
'length=${_decodeQueue.length} mediaIds=$_decodeQueue',
|
|
);
|
|
unawaited(_runDecodeWorker());
|
|
}
|
|
|
|
bool _isMediaStillInBuffer(int mediaId) {
|
|
return _castBuffer.values.any(
|
|
(item) => item.isOptimizable && item.mediaId == mediaId,
|
|
);
|
|
}
|
|
|
|
Future<void> _runDecodeWorker() async {
|
|
if (_decodeWorkerRunning) {
|
|
_queueLog('DECODE WORKER already-running queueLength=${_decodeQueue.length}');
|
|
return;
|
|
}
|
|
_decodeWorkerRunning = true;
|
|
_queueLog('DECODE WORKER START queueLength=${_decodeQueue.length}');
|
|
|
|
try {
|
|
while (_decodeQueue.isNotEmpty) {
|
|
final mediaId = _decodeQueue.removeAt(0);
|
|
_queueLog(
|
|
'DECODE DEQUEUE reason=processing mediaId=$mediaId '
|
|
'queueLength=${_decodeQueue.length}',
|
|
);
|
|
|
|
if (!_isMediaStillInBuffer(mediaId)) {
|
|
_queueLog('DECODE SKIP mediaId=$mediaId reason=outside-buffer');
|
|
continue;
|
|
}
|
|
if (castServer.hasOptimizedImage(mediaId)) {
|
|
_queueLog('DECODE SKIP mediaId=$mediaId reason=already-ready');
|
|
continue;
|
|
}
|
|
if (!_optimizingIds.add(mediaId)) {
|
|
_queueLog('DECODE SKIP mediaId=$mediaId reason=already-processing');
|
|
continue;
|
|
}
|
|
|
|
final generation = _optimizationGeneration;
|
|
final decodeStopwatch = Stopwatch()..start();
|
|
_queueLog(
|
|
'DECODE START mediaId=$mediaId generation=$generation '
|
|
'remainingQueue=${_decodeQueue.length}',
|
|
);
|
|
|
|
try {
|
|
final readStopwatch = Stopwatch()..start();
|
|
|
|
final originalBytes = await castServer.readOriginalBytes(mediaId);
|
|
|
|
readStopwatch.stop();
|
|
|
|
if (originalBytes == null || originalBytes.isEmpty) continue;
|
|
|
|
_queueLog(
|
|
'DECODE INPUT mediaId=$mediaId '
|
|
'bytes=${originalBytes.length} '
|
|
'readMs=${readStopwatch.elapsedMilliseconds}',
|
|
);
|
|
|
|
final optimizeStopwatch = Stopwatch()..start();
|
|
|
|
final optimized = await optimizeCastImage(originalBytes);
|
|
|
|
optimizeStopwatch.stop();
|
|
|
|
_queueLog(
|
|
'DECODE OPTIMIZE mediaId=$mediaId '
|
|
'inputBytes=${originalBytes.length} '
|
|
'outputBytes=${optimized.length} '
|
|
'optimizeMs=${optimizeStopwatch.elapsedMilliseconds}',
|
|
);
|
|
|
|
if (generation != _optimizationGeneration || !_isMediaStillInBuffer(mediaId)) {
|
|
_queueLog(
|
|
'DECODE END mediaId=$mediaId result=discarded '
|
|
'reason=${generation != _optimizationGeneration ? 'old-generation' : 'outside-buffer'}',
|
|
);
|
|
continue;
|
|
}
|
|
|
|
castServer.storeOptimizedImage(mediaId, optimized);
|
|
|
|
_queueLog(
|
|
'DECODE END mediaId=$mediaId result=stored '
|
|
'inputBytes=${originalBytes.length} '
|
|
'outputBytes=${optimized.length} '
|
|
'readMs=${readStopwatch.elapsedMilliseconds} '
|
|
'optimizeMs=${optimizeStopwatch.elapsedMilliseconds}',
|
|
);
|
|
} catch (error, stackTrace) {
|
|
// Se la riduzione fallisce, il server continua a servire l'originale.
|
|
_queueLog(
|
|
'DECODE END mediaId=$mediaId result=error '
|
|
'elapsedMs=${decodeStopwatch.elapsedMilliseconds} error=$error',
|
|
);
|
|
debugPrint('[CAST QUEUE] DECODE STACK mediaId=$mediaId\n$stackTrace');
|
|
} finally {
|
|
_optimizingIds.remove(mediaId);
|
|
}
|
|
}
|
|
} finally {
|
|
_decodeWorkerRunning = false;
|
|
_queueLog('DECODE WORKER STOP queueLength=${_decodeQueue.length}');
|
|
|
|
// Un cambio indice puo avere ricostruito la coda mentre il worker stava
|
|
// terminando. In quel caso riavvia il worker sulla nuova priorita.
|
|
if (_decodeQueue.isNotEmpty) {
|
|
unawaited(_runDecodeWorker());
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _waitReady() async {
|
|
if (_castReadyCompleter.isCompleted) return;
|
|
await _castReadyCompleter.future;
|
|
}
|
|
|
|
Future<void> _startFmp4FirstTsSegmentTest(
|
|
int mediaId,
|
|
) async {
|
|
try {
|
|
final inputPath = await castHlsCache.getWorkingMp4Path(
|
|
mediaId,
|
|
);
|
|
|
|
final directory = await castHlsCache.getMediaDirectory(
|
|
mediaId,
|
|
);
|
|
|
|
final outputPath = '${directory.path}/segment_00000.ts';
|
|
|
|
_queueLog(
|
|
'FMP4 FIRST TS TOOL START '
|
|
'mediaId=$mediaId '
|
|
'inputPath=$inputPath '
|
|
'outputPath=$outputPath',
|
|
);
|
|
|
|
final report = await CastFmp4FirstTsSegmentTest.run(
|
|
inputPath: inputPath,
|
|
outputPath: outputPath,
|
|
);
|
|
|
|
_queueLog(
|
|
'FMP4 FIRST TS TOOL END '
|
|
'mediaId=$mediaId '
|
|
'videoSamples=${report['videoSamples']} '
|
|
'audioSamples=${report['audioSamples']} '
|
|
'keyframes=${report['keyframes']} '
|
|
'aud=${report['accessUnitDelimiterCount']} '
|
|
'outputBytes=${report['outputBytes']} '
|
|
'outputPath=${report['outputPath']}',
|
|
);
|
|
} catch (error, stackTrace) {
|
|
_queueLog(
|
|
'FMP4 FIRST TS TOOL ERROR '
|
|
'mediaId=$mediaId '
|
|
'error=$error',
|
|
);
|
|
|
|
debugPrint(
|
|
'[CAST QUEUE] FMP4 FIRST TS TOOL STACK '
|
|
'mediaId=$mediaId\n'
|
|
'$stackTrace',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _startTsInspectionTest(AvesEntry entry) {
|
|
final mediaId = _mediaIdOf(entry);
|
|
|
|
if (mediaId == null) {
|
|
_queueLog(
|
|
'TS INSPECT SKIP '
|
|
'reason=missing-media-id '
|
|
'entryId=${entry.id}',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!_tsInspectionTestsStarted.add(mediaId)) {
|
|
_queueLog(
|
|
'TS INSPECT SKIP '
|
|
'mediaId=$mediaId '
|
|
'reason=already-started',
|
|
);
|
|
return;
|
|
}
|
|
|
|
Future<void> runInspection() async {
|
|
try {
|
|
final mp4File = await castVideoCache.getValidFile(
|
|
mediaId,
|
|
);
|
|
|
|
if (mp4File == null) {
|
|
_queueLog(
|
|
'TS INSPECT SKIP '
|
|
'mediaId=$mediaId '
|
|
'reason=mp4-cache-miss',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final inputBytes = await mp4File.length();
|
|
|
|
if (inputBytes <= 0) {
|
|
_queueLog(
|
|
'TS INSPECT SKIP '
|
|
'mediaId=$mediaId '
|
|
'reason=mp4-cache-empty '
|
|
'path=${mp4File.path}',
|
|
);
|
|
return;
|
|
}
|
|
|
|
_queueLog(
|
|
'TS INSPECT START '
|
|
'mediaId=$mediaId '
|
|
'inputBytes=$inputBytes '
|
|
'inputPath=${mp4File.path}',
|
|
);
|
|
|
|
final stopwatch = Stopwatch()..start();
|
|
|
|
final report = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'inspectMp4ForHlsTs',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
'inputPath': mp4File.path,
|
|
'maximumDurationMs': 15000,
|
|
},
|
|
);
|
|
|
|
stopwatch.stop();
|
|
|
|
if (report == null) {
|
|
throw StateError(
|
|
'Report TS Inspector assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS INSPECT END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'elapsedMs=${stopwatch.elapsedMilliseconds} '
|
|
'nativeElapsedMs=${report['elapsedMs']} '
|
|
'trackCount=${report['trackCount']} '
|
|
'videoTrackIndex=${report['videoTrackIndex']} '
|
|
'videoMime=${report['videoMime']} '
|
|
'videoDurationUs=${report['videoDurationUs']} '
|
|
'size=${report['width']}x${report['height']} '
|
|
'frameRate=${report['frameRate']} '
|
|
'videoBitrate=${report['videoBitrate']} '
|
|
'videoCsd0Bytes=${report['videoCsd0Bytes']} '
|
|
'videoCsd1Bytes=${report['videoCsd1Bytes']} '
|
|
'audioTrackIndex=${report['audioTrackIndex']} '
|
|
'audioMime=${report['audioMime']} '
|
|
'audioDurationUs=${report['audioDurationUs']} '
|
|
'sampleRate=${report['sampleRate']} '
|
|
'channelCount=${report['channelCount']} '
|
|
'audioBitrate=${report['audioBitrate']} '
|
|
'audioCsd0Bytes=${report['audioCsd0Bytes']} '
|
|
'videoSamples=${report['videoSampleCount']} '
|
|
'audioSamples=${report['audioSampleCount']} '
|
|
'keyframes=${report['videoKeyframeCount']} '
|
|
'keyframeTimesUs=${report['keyframeTimesUs']} '
|
|
'inspectedDurationUs=${report['inspectedDurationUs']}',
|
|
);
|
|
|
|
_queueLog(
|
|
'TS PSI TEST START '
|
|
'mediaId=$mediaId',
|
|
);
|
|
|
|
final psiReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'writeTsPsiTest',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
},
|
|
);
|
|
|
|
if (psiReport == null) {
|
|
throw StateError(
|
|
'Report TS PSI Test assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS PSI TEST END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'success=${psiReport['success']} '
|
|
'outputBytes=${psiReport['outputBytes']} '
|
|
'packetCount=${psiReport['packetCount']} '
|
|
'packetSize=${psiReport['packetSize']} '
|
|
'patPid=${psiReport['patPid']} '
|
|
'pmtPid=${psiReport['pmtPid']} '
|
|
'videoPid=${psiReport['videoPid']} '
|
|
'audioPid=${psiReport['audioPid']} '
|
|
'pcrPid=${psiReport['pcrPid']} '
|
|
'elapsedMs=${psiReport['elapsedMs']} '
|
|
'outputPath=${psiReport['outputPath']}',
|
|
);
|
|
|
|
_queueLog(
|
|
'TS ANNEX B TEST START '
|
|
'mediaId=$mediaId',
|
|
);
|
|
|
|
final annexBReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'writeTsAnnexBTest',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
'inputPath': mp4File.path,
|
|
},
|
|
);
|
|
|
|
if (annexBReport == null) {
|
|
throw StateError(
|
|
'Report TS Annex B Test assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS ANNEX B TEST END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'inputBytes=${annexBReport['inputBytes']} '
|
|
'outputBytes=${annexBReport['outputBytes']} '
|
|
'sampleTimeUs=${annexBReport['sampleTimeUs']} '
|
|
'sampleFlags=${annexBReport['sampleFlags']} '
|
|
'csd0Bytes=${annexBReport['csd0Bytes']} '
|
|
'csd1Bytes=${annexBReport['csd1Bytes']} '
|
|
'nalUnitCount=${annexBReport['nalUnitCount']} '
|
|
'nalUnitTypes=${annexBReport['nalUnitTypes']} '
|
|
'sourceWasAnnexB=${annexBReport['sourceWasAnnexB']} '
|
|
'elapsedMs=${annexBReport['elapsedMs']} '
|
|
'outputPath=${annexBReport['outputPath']}',
|
|
);
|
|
|
|
_queueLog(
|
|
'TS VIDEO SAMPLE TEST START '
|
|
'mediaId=$mediaId',
|
|
);
|
|
|
|
final videoSampleReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'writeTsVideoSampleTest',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
},
|
|
);
|
|
|
|
if (videoSampleReport == null) {
|
|
throw StateError(
|
|
'Report TS Video Sample Test assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS VIDEO SAMPLE TEST END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'annexBBytes=${videoSampleReport['annexBBytes']} '
|
|
'outputBytes=${videoSampleReport['outputBytes']} '
|
|
'packetSize=${videoSampleReport['packetSize']} '
|
|
'psiPacketCount=${videoSampleReport['psiPacketCount']} '
|
|
'videoPacketCount=${videoSampleReport['videoPacketCount']} '
|
|
'totalPacketCount=${videoSampleReport['totalPacketCount']} '
|
|
'videoPid=${videoSampleReport['videoPid']} '
|
|
'firstVideoContinuityCounter='
|
|
'${videoSampleReport['firstVideoContinuityCounter']} '
|
|
'nextVideoContinuityCounter='
|
|
'${videoSampleReport['nextVideoContinuityCounter']} '
|
|
'pts90Khz=${videoSampleReport['pts90Khz']} '
|
|
'dts90Khz=${videoSampleReport['dts90Khz']} '
|
|
'pcr90Khz=${videoSampleReport['pcr90Khz']} '
|
|
'elapsedMs=${videoSampleReport['elapsedMs']} '
|
|
'outputPath=${videoSampleReport['outputPath']}',
|
|
);
|
|
|
|
_queueLog(
|
|
'TS VIDEO SEGMENT TEST START '
|
|
'mediaId=$mediaId',
|
|
);
|
|
|
|
final videoSegmentReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'writeTsVideoSegmentTest',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
'inputPath': mp4File.path,
|
|
'segmentDurationMs': 3000,
|
|
},
|
|
);
|
|
|
|
if (videoSegmentReport == null) {
|
|
throw StateError(
|
|
'Report TS Video Segment Test assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS VIDEO SEGMENT TEST END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'videoSamples=${videoSegmentReport['videoSampleCount']} '
|
|
'keyframes=${videoSegmentReport['keyframeCount']} '
|
|
'videoPackets=${videoSegmentReport['videoPacketCount']} '
|
|
'psiPackets=${videoSegmentReport['psiPacketCount']} '
|
|
'totalPackets=${videoSegmentReport['totalPacketCount']} '
|
|
'outputBytes=${videoSegmentReport['outputBytes']} '
|
|
'packetSize=${videoSegmentReport['packetSize']} '
|
|
'videoPid=${videoSegmentReport['videoPid']} '
|
|
'durationUs=${videoSegmentReport['durationUs']} '
|
|
'firstPts90Khz=${videoSegmentReport['firstPts90Khz']} '
|
|
'lastPts90Khz=${videoSegmentReport['lastPts90Khz']} '
|
|
'nextVideoContinuityCounter='
|
|
'${videoSegmentReport['nextVideoContinuityCounter']} '
|
|
'elapsedMs=${videoSegmentReport['elapsedMs']} '
|
|
'outputPath=${videoSegmentReport['outputPath']}',
|
|
);
|
|
|
|
_queueLog(
|
|
'TS AUDIO SAMPLE TEST START '
|
|
'mediaId=$mediaId',
|
|
);
|
|
|
|
final audioSampleReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'writeTsAudioSampleTest',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
'inputPath': mp4File.path,
|
|
},
|
|
);
|
|
|
|
if (audioSampleReport == null) {
|
|
throw StateError(
|
|
'Report TS Audio Sample Test assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS AUDIO SAMPLE TEST END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'rawAacBytes=${audioSampleReport['rawAacBytes']} '
|
|
'adtsBytes=${audioSampleReport['adtsBytes']} '
|
|
'audioSpecificConfigBytes='
|
|
'${audioSampleReport['audioSpecificConfigBytes']} '
|
|
'audioObjectType=${audioSampleReport['audioObjectType']} '
|
|
'sampleRateIndex=${audioSampleReport['sampleRateIndex']} '
|
|
'sampleRate=${audioSampleReport['sampleRate']} '
|
|
'channelCount=${audioSampleReport['channelCount']} '
|
|
'sampleTimeUs=${audioSampleReport['sampleTimeUs']} '
|
|
'pts90Khz=${audioSampleReport['pts90Khz']} '
|
|
'audioPackets=${audioSampleReport['audioPacketCount']} '
|
|
'psiPackets=${audioSampleReport['psiPacketCount']} '
|
|
'totalPackets=${audioSampleReport['totalPacketCount']} '
|
|
'audioPid=${audioSampleReport['audioPid']} '
|
|
'firstAudioContinuityCounter='
|
|
'${audioSampleReport['firstAudioContinuityCounter']} '
|
|
'nextAudioContinuityCounter='
|
|
'${audioSampleReport['nextAudioContinuityCounter']} '
|
|
'outputBytes=${audioSampleReport['outputBytes']} '
|
|
'elapsedMs=${audioSampleReport['elapsedMs']} '
|
|
'outputPath=${audioSampleReport['outputPath']}',
|
|
);
|
|
|
|
_queueLog(
|
|
'TS AV SEGMENT TEST START '
|
|
'mediaId=$mediaId',
|
|
);
|
|
|
|
final avSegmentReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'writeTsAvSegmentTest',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
'inputPath': mp4File.path,
|
|
'segmentDurationMs': 3000,
|
|
},
|
|
);
|
|
|
|
if (avSegmentReport == null) {
|
|
throw StateError(
|
|
'Report TS A/V Segment Test assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS AV SEGMENT TEST END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'videoSamples=${avSegmentReport['videoSampleCount']} '
|
|
'audioSamples=${avSegmentReport['audioSampleCount']} '
|
|
'keyframes=${avSegmentReport['keyframeCount']} '
|
|
'videoPackets=${avSegmentReport['videoPacketCount']} '
|
|
'audioPackets=${avSegmentReport['audioPacketCount']} '
|
|
'psiPackets=${avSegmentReport['psiPacketCount']} '
|
|
'totalPackets=${avSegmentReport['totalPacketCount']} '
|
|
'outputBytes=${avSegmentReport['outputBytes']} '
|
|
'packetSize=${avSegmentReport['packetSize']} '
|
|
'timelineBaseUs=${avSegmentReport['timelineBaseUs']} '
|
|
'videoCutTimeUs=${avSegmentReport['videoCutTimeUs']} '
|
|
'firstVideoPts90Khz=${avSegmentReport['firstVideoPts90Khz']} '
|
|
'lastVideoPts90Khz=${avSegmentReport['lastVideoPts90Khz']} '
|
|
'firstAudioPts90Khz=${avSegmentReport['firstAudioPts90Khz']} '
|
|
'lastAudioPts90Khz=${avSegmentReport['lastAudioPts90Khz']} '
|
|
'nextVideoContinuityCounter='
|
|
'${avSegmentReport['nextVideoContinuityCounter']} '
|
|
'nextAudioContinuityCounter='
|
|
'${avSegmentReport['nextAudioContinuityCounter']} '
|
|
'sampleRate=${avSegmentReport['sampleRate']} '
|
|
'channelCount=${avSegmentReport['channelCount']} '
|
|
'elapsedMs=${avSegmentReport['elapsedMs']} '
|
|
'outputPath=${avSegmentReport['outputPath']}'
|
|
'originalSpsLevels=${avSegmentReport['originalSpsLevels']} '
|
|
'targetLevelIdc=${avSegmentReport['targetLevelIdc']} '
|
|
'patchedSpsCount=${avSegmentReport['patchedSpsCount']} ',
|
|
);
|
|
|
|
_queueLog(
|
|
'TS HLS VOD TEST START '
|
|
'mediaId=$mediaId',
|
|
);
|
|
|
|
final hlsVodReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'writeTsHlsVodTest',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
'inputPath': mp4File.path,
|
|
'segmentCount': 5,
|
|
'segmentDurationMs': 3000,
|
|
},
|
|
);
|
|
|
|
if (hlsVodReport == null) {
|
|
throw StateError(
|
|
'Report TS HLS VOD Test assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS HLS VOD TEST END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'segmentCount=${hlsVodReport['segmentCount']} '
|
|
'segmentFiles=${hlsVodReport['segmentFileNames']} '
|
|
'segmentDurationsUs=${hlsVodReport['segmentDurationsUs']} '
|
|
'totalDurationUs=${hlsVodReport['totalDurationUs']} '
|
|
'totalOutputBytes=${hlsVodReport['totalOutputBytes']} '
|
|
'targetDurationSeconds='
|
|
'${hlsVodReport['targetDurationSeconds']} '
|
|
'playlistBytes=${hlsVodReport['playlistBytes']} '
|
|
'elapsedMs=${hlsVodReport['elapsedMs']} '
|
|
'outputDirectory=${hlsVodReport['outputDirectory']} '
|
|
'playlistPath=${hlsVodReport['playlistPath']}',
|
|
);
|
|
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL TEST START '
|
|
'mediaId=$mediaId',
|
|
);
|
|
|
|
await castHlsCache.resetSession(mediaId);
|
|
|
|
final incrementalHlsDirectory = await castHlsCache.getMediaDirectory(
|
|
mediaId,
|
|
);
|
|
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL OUTPUT DIRECTORY '
|
|
'mediaId=$mediaId '
|
|
'path=${incrementalHlsDirectory.path}',
|
|
);
|
|
|
|
final incrementalReport = await _optimizerChannel.invokeMapMethod<String, dynamic>(
|
|
'writeTsHlsIncrementalTest',
|
|
<String, dynamic>{
|
|
'mediaId': mediaId,
|
|
'inputPath': mp4File.path,
|
|
'outputDirectory': incrementalHlsDirectory.path,
|
|
'segmentCount': 5,
|
|
'segmentDurationMs': 3000,
|
|
'readySegmentCount': 3,
|
|
'publicationDelayMs': 1000,
|
|
},
|
|
);
|
|
|
|
if (incrementalReport == null) {
|
|
throw StateError(
|
|
'Report TS HLS Incremental Test assente '
|
|
'mediaId=$mediaId',
|
|
);
|
|
}
|
|
|
|
_queueLog(
|
|
'TS HLS INCREMENTAL TEST END '
|
|
'mediaId=$mediaId '
|
|
'result=success '
|
|
'segmentCount=${incrementalReport['segmentCount']} '
|
|
'publishedSegments='
|
|
'${incrementalReport['publishedSegmentCount']} '
|
|
'readySegmentCount='
|
|
'${incrementalReport['readySegmentCount']} '
|
|
'readyElapsedMs=${incrementalReport['readyElapsedMs']} '
|
|
'segmentFiles=${incrementalReport['segmentFileNames']} '
|
|
'segmentDurationsUs='
|
|
'${incrementalReport['segmentDurationsUs']} '
|
|
'totalDurationUs=${incrementalReport['totalDurationUs']} '
|
|
'totalOutputBytes=${incrementalReport['totalOutputBytes']} '
|
|
'playlistBytes=${incrementalReport['playlistBytes']} '
|
|
'endList=${incrementalReport['endList']} '
|
|
'elapsedMs=${incrementalReport['elapsedMs']} '
|
|
'outputDirectory=${incrementalReport['outputDirectory']} '
|
|
'playlistPath=${incrementalReport['playlistPath']}',
|
|
);
|
|
} catch (error, stackTrace) {
|
|
_queueLog(
|
|
'TS INSPECT END '
|
|
'mediaId=$mediaId '
|
|
'result=error '
|
|
'error=$error',
|
|
);
|
|
|
|
debugPrint(
|
|
'[CAST QUEUE] TS INSPECT STACK '
|
|
'mediaId=$mediaId\n'
|
|
'$stackTrace',
|
|
);
|
|
}
|
|
}
|
|
|
|
unawaited(runInspection());
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> _buildQueue(
|
|
List<AvesEntry> entries,
|
|
) async {
|
|
await castServer.ensureStarted();
|
|
|
|
final castEntries = <_CastQueueEntry>[
|
|
const _CastQueueEntry.start(),
|
|
...entries.map(_CastQueueEntry.media),
|
|
const _CastQueueEntry.end(),
|
|
];
|
|
_castQueueEntries = List<_CastQueueEntry>.unmodifiable(castEntries);
|
|
|
|
final mediaItems = List<Map<String, dynamic>?>.filled(entries.length, null);
|
|
final remoteIndexes = <int>[];
|
|
|
|
// Gli URL locali sono economici e vengono creati subito. Solo gli share
|
|
// remoti vengono messi nel pool a concorrenza limitata.
|
|
|
|
for (var index = 0; index < entries.length; index++) {
|
|
final entry = entries[index];
|
|
final isVideo = (entry.durationMillis ?? 0) > 0 || entry.mimeType.startsWith('video/');
|
|
|
|
if (entry.origin == RemoteOrigin.value) {
|
|
remoteIndexes.add(index);
|
|
continue;
|
|
}
|
|
|
|
if (isVideo) {
|
|
final mediaId = _mediaIdOf(entry);
|
|
|
|
final hlsReady =
|
|
mediaId != null &&
|
|
await castHlsCache.isReadyForPlayback(
|
|
mediaId,
|
|
minimumSegments: 3,
|
|
);
|
|
|
|
if (hlsReady) {
|
|
final hlsUrl = castServer.buildHlsUrl(mediaId);
|
|
|
|
mediaItems[index] = <String, dynamic>{
|
|
'url': hlsUrl,
|
|
'isVideo': true,
|
|
'mimeType': 'application/vnd.apple.mpegurl',
|
|
'title': entry.bestTitle ?? '',
|
|
};
|
|
|
|
_queueLog(
|
|
'HLS QUEUE ITEM '
|
|
'entryIndex=$index '
|
|
'mediaId=$mediaId '
|
|
'ready=true '
|
|
'url=$hlsUrl',
|
|
);
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
mediaItems[index] = <String, dynamic>{
|
|
'url': castServer.buildUrlForEntry(entry),
|
|
'isVideo': isVideo,
|
|
'mimeType': entry.mimeType,
|
|
'title': entry.bestTitle ?? '',
|
|
};
|
|
}
|
|
|
|
_queueLog(
|
|
'REMOTE QUEUE READY remote=${remoteIndexes.length} '
|
|
'local=${entries.length - remoteIndexes.length} '
|
|
'maxConcurrent=$_maxConcurrentRemoteShares',
|
|
);
|
|
|
|
var activeRemote = 0;
|
|
var completedRemote = 0;
|
|
|
|
await _mapConcurrent<int, void>(
|
|
values: remoteIndexes,
|
|
maxConcurrent: _maxConcurrentRemoteShares,
|
|
convert: (entryIndex, _) async {
|
|
final entry = entries[entryIndex];
|
|
activeRemote++;
|
|
_queueLog(
|
|
'REMOTE QUEUE START entryIndex=$entryIndex active=$activeRemote '
|
|
'completed=$completedRemote total=${remoteIndexes.length}',
|
|
);
|
|
|
|
try {
|
|
final url = await _resolveCastUrl(entry);
|
|
mediaItems[entryIndex] = <String, dynamic>{
|
|
'url': url,
|
|
'isVideo': (entry.durationMillis ?? 0) > 0,
|
|
'mimeType': entry.mimeType,
|
|
'title': entry.bestTitle ?? '',
|
|
};
|
|
completedRemote++;
|
|
} finally {
|
|
activeRemote--;
|
|
_queueLog(
|
|
'REMOTE QUEUE END entryIndex=$entryIndex active=$activeRemote '
|
|
'completed=$completedRemote total=${remoteIndexes.length}',
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
final resolvedMediaItems = mediaItems.cast<Map<String, dynamic>>();
|
|
final resolved = <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'url': castServer.buildUrlForAsset('start.jpg'),
|
|
'isVideo': false,
|
|
'mimeType': 'image/jpeg',
|
|
'title': '',
|
|
},
|
|
...resolvedMediaItems,
|
|
<String, dynamic>{
|
|
'url': castServer.buildUrlForAsset('end.jpg'),
|
|
'isVideo': false,
|
|
'mimeType': 'image/jpeg',
|
|
'title': '',
|
|
},
|
|
];
|
|
|
|
resolvedUrls = resolved.map((item) => item['url'] as String).toList();
|
|
resolvedIsVideo = resolved.map((item) => item['isVideo'] as bool).toList();
|
|
|
|
_queueLog(
|
|
'QUEUE BUILT total=${resolved.length} media=${entries.length} '
|
|
'remote=${remoteIndexes.length} startAssetIndex=0 '
|
|
'endAssetIndex=${resolved.length - 1}',
|
|
);
|
|
return resolved;
|
|
}
|
|
|
|
Future<void> _showHlsPreparingScreen() async {
|
|
await castServer.ensureStarted();
|
|
|
|
final startUrl = castServer.buildUrlForAsset(
|
|
'start.jpg',
|
|
);
|
|
|
|
final preparingItems = <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'url': startUrl,
|
|
'isVideo': false,
|
|
'mimeType': 'image/jpeg',
|
|
'title': '',
|
|
},
|
|
];
|
|
|
|
resolvedUrls = <String>[startUrl];
|
|
resolvedIsVideo = <bool>[false];
|
|
|
|
_castQueueEntries = const <_CastQueueEntry>[
|
|
_CastQueueEntry.start(),
|
|
];
|
|
|
|
_lastQueueItems = preparingItems;
|
|
currentIndexNotifier.value = 0;
|
|
_currentIsVideo = false;
|
|
|
|
_queueLog(
|
|
'HLS PREPARING LOAD '
|
|
'items=1 '
|
|
'startIndex=0 '
|
|
'url=$startUrl',
|
|
);
|
|
|
|
await _channel.invokeMethod(
|
|
'loadQueue',
|
|
<String, dynamic>{
|
|
'items': preparingItems,
|
|
'startIndex': 0,
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> startManualQueue({
|
|
required List<AvesEntry> entries,
|
|
required int startIndex,
|
|
}) async {
|
|
if (entries.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
await _waitReady();
|
|
await castServer.ensureStarted();
|
|
|
|
_startNewOptimizationSession();
|
|
final requestGeneration = _optimizationGeneration;
|
|
|
|
_lastQueueItems = const <Map<String, dynamic>>[];
|
|
_castQueueEntries = const <_CastQueueEntry>[];
|
|
resolvedUrls = const <String>[];
|
|
resolvedIsVideo = const <bool>[];
|
|
|
|
currentIndexNotifier.value = 0;
|
|
_currentIsVideo = false;
|
|
|
|
_queueLog(
|
|
'CAST REQUEST RESET '
|
|
'generation=$requestGeneration '
|
|
'entries=${entries.length} '
|
|
'startIndex=$startIndex',
|
|
);
|
|
|
|
final requestedEntryIndex = startIndex.clamp(
|
|
0,
|
|
entries.length - 1,
|
|
);
|
|
|
|
final requestedEntry = entries[requestedEntryIndex];
|
|
|
|
final requestedIsVideo = (requestedEntry.durationMillis ?? 0) > 0 || requestedEntry.mimeType.startsWith('video/');
|
|
|
|
final requestedIsLocal = requestedEntry.origin != RemoteOrigin.value;
|
|
|
|
if (requestedIsVideo && requestedIsLocal) {
|
|
_startTsInspectionTest(
|
|
requestedEntry,
|
|
);
|
|
}
|
|
|
|
if (requestedIsVideo && requestedIsLocal) {
|
|
final mediaId = _mediaIdOf(requestedEntry);
|
|
|
|
if (mediaId == null) {
|
|
throw StateError(
|
|
'Media ID non disponibile per il video selezionato',
|
|
);
|
|
}
|
|
|
|
if (_testTransportStreamPipeline) {
|
|
_queueLog(
|
|
'LEGACY FMP4 PIPELINE SKIPPED '
|
|
'entryIndex=$requestedEntryIndex '
|
|
'mediaId=$mediaId '
|
|
'reason=transportStreamPipelineTest',
|
|
);
|
|
unawaited(
|
|
_startFmp4FirstTsSegmentTest(
|
|
mediaId,
|
|
),
|
|
);
|
|
} else {
|
|
await _showHlsPreparingScreen();
|
|
|
|
_queueLog(
|
|
'HLS PREPARE START '
|
|
'entryIndex=$requestedEntryIndex '
|
|
'mediaId=$mediaId '
|
|
'mime=${requestedEntry.mimeType}',
|
|
);
|
|
|
|
final stopwatch = Stopwatch()..start();
|
|
|
|
try {
|
|
final String hlsUrl;
|
|
|
|
if (_useProgressiveTransportStreamPipeline) {
|
|
hlsUrl = await castProgressiveTsOptimizer.prepareUntilReady(
|
|
mediaId,
|
|
);
|
|
|
|
_queueLog(
|
|
'PROGRESSIVE TS READY '
|
|
'entryIndex=$requestedEntryIndex '
|
|
'mediaId=$mediaId '
|
|
'url=$hlsUrl',
|
|
);
|
|
} else {
|
|
hlsUrl = await castHlsOptimizer.prepareUntilReady(
|
|
mediaId,
|
|
);
|
|
}
|
|
|
|
if (requestGeneration != _optimizationGeneration) {
|
|
_queueLog(
|
|
'CAST REQUEST STALE '
|
|
'stage=hls-ready '
|
|
'requestGeneration=$requestGeneration '
|
|
'currentGeneration=$_optimizationGeneration '
|
|
'entryIndex=$requestedEntryIndex '
|
|
'mediaId=$mediaId',
|
|
);
|
|
return;
|
|
}
|
|
|
|
stopwatch.stop();
|
|
|
|
_queueLog(
|
|
'HLS PREPARE READY '
|
|
'entryIndex=$requestedEntryIndex '
|
|
'mediaId=$mediaId '
|
|
'pipeline='
|
|
'${_useProgressiveTransportStreamPipeline ? 'mpeg-ts' : 'fragmented-mp4'} '
|
|
'elapsedMs=${stopwatch.elapsedMilliseconds} '
|
|
'url=$hlsUrl',
|
|
);
|
|
} catch (error, stackTrace) {
|
|
stopwatch.stop();
|
|
|
|
_queueLog(
|
|
'HLS PREPARE ERROR '
|
|
'entryIndex=$requestedEntryIndex '
|
|
'mediaId=$mediaId '
|
|
'elapsedMs=${stopwatch.elapsedMilliseconds} '
|
|
'error=$error',
|
|
);
|
|
|
|
debugPrint(
|
|
'[CAST QUEUE] HLS PREPARE STACK '
|
|
'mediaId=$mediaId\n'
|
|
'$stackTrace',
|
|
);
|
|
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
final items = await _buildQueue(entries);
|
|
|
|
if (requestGeneration != _optimizationGeneration) {
|
|
_queueLog(
|
|
'CAST REQUEST STALE '
|
|
'stage=queue-built '
|
|
'requestGeneration=$requestGeneration '
|
|
'currentGeneration=$_optimizationGeneration '
|
|
'entryIndex=$requestedEntryIndex',
|
|
);
|
|
return;
|
|
}
|
|
|
|
_lastQueueItems = items;
|
|
|
|
if (items.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final queueStartIndex = requestedEntryIndex + 1;
|
|
|
|
currentIndexNotifier.value = queueStartIndex;
|
|
_currentIsVideo = items[queueStartIndex]['isVideo'] == true;
|
|
|
|
_logMediaSelectedForDisplay(
|
|
queueStartIndex,
|
|
source: 'hlsLoadQueue',
|
|
);
|
|
|
|
_prepareOptimizationWindow(
|
|
queueStartIndex,
|
|
direction: 1,
|
|
);
|
|
|
|
_resetServingChoiceForIndex(
|
|
queueStartIndex,
|
|
);
|
|
|
|
_queueLog(
|
|
'CAST LOAD '
|
|
'items=${items.length} '
|
|
'requestedEntryIndex=$requestedEntryIndex '
|
|
'queueStartIndex=$queueStartIndex '
|
|
'isVideo=$_currentIsVideo '
|
|
'decodeQueueLength=${_decodeQueue.length}',
|
|
);
|
|
|
|
if (requestGeneration != _optimizationGeneration) {
|
|
_queueLog(
|
|
'CAST REQUEST STALE '
|
|
'stage=before-load '
|
|
'requestGeneration=$requestGeneration '
|
|
'currentGeneration=$_optimizationGeneration '
|
|
'entryIndex=$requestedEntryIndex',
|
|
);
|
|
return;
|
|
}
|
|
|
|
await _channel.invokeMethod(
|
|
'loadQueue',
|
|
<String, dynamic>{
|
|
'items': items,
|
|
'startIndex': queueStartIndex,
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> startSlideshow({
|
|
required List<AvesEntry> entries,
|
|
required int startIndex,
|
|
int intervalSeconds = 5,
|
|
}) async {
|
|
stopSlideshow();
|
|
_slideshowActive = true;
|
|
_slideshowIntervalSeconds = intervalSeconds;
|
|
await _waitReady();
|
|
await startManualQueue(entries: entries, startIndex: startIndex);
|
|
}
|
|
|
|
void _scheduleNextPhoto() {
|
|
_slideshowTimer?.cancel();
|
|
if (_currentIsVideo) return;
|
|
|
|
_slideshowTimer = Timer(
|
|
Duration(seconds: _slideshowIntervalSeconds),
|
|
() async {
|
|
if (_slideshowActive) await nextManual();
|
|
},
|
|
);
|
|
}
|
|
|
|
void stopSlideshow() {
|
|
_slideshowTimer?.cancel();
|
|
_slideshowTimer = null;
|
|
_slideshowActive = false;
|
|
}
|
|
|
|
Future<void> playCast() => _channel.invokeMethod('play');
|
|
Future<void> pauseCast() => _channel.invokeMethod('pause');
|
|
|
|
Future<void> seekCast(Duration position) async {
|
|
final milliseconds = position.inMilliseconds;
|
|
if ((positionNotifier.value.inMilliseconds - milliseconds).abs() < 500) {
|
|
return;
|
|
}
|
|
await _channel.invokeMethod('seekTo', {'position': milliseconds});
|
|
positionNotifier.value = position;
|
|
}
|
|
|
|
Future<void> setCastVolume(double value) async {
|
|
final clamped = value.clamp(0.0, 1.0).toDouble();
|
|
volumeNotifier.value = clamped;
|
|
await _channel.invokeMethod('setVolume', {'volume': clamped});
|
|
}
|
|
|
|
Future<void> toggleMuteCast() async {
|
|
final mute = !isMutedNotifier.value;
|
|
await _channel.invokeMethod('setMute', {'mute': mute});
|
|
isMutedNotifier.value = mute;
|
|
}
|
|
|
|
Future<void> nextManual() async {
|
|
final count = _lastQueueItems?.length ?? resolvedUrls?.length ?? 0;
|
|
if (count <= 0) return;
|
|
|
|
final current = currentIndexNotifier.value;
|
|
if (current >= count - 1) {
|
|
_queueLog('NAV NEXT ignored index=$current reason=end-of-queue');
|
|
return;
|
|
}
|
|
await _channel.invokeMethod('next');
|
|
}
|
|
|
|
Future<void> prevManual() async {
|
|
final count = _lastQueueItems?.length ?? resolvedUrls?.length ?? 0;
|
|
if (count <= 0) return;
|
|
|
|
final current = currentIndexNotifier.value;
|
|
if (current <= 0) {
|
|
_queueLog('NAV PREV ignored index=$current reason=start-of-queue');
|
|
return;
|
|
}
|
|
await _channel.invokeMethod('prev');
|
|
}
|
|
|
|
Future<void> stopGoogleCast() async {
|
|
stopSlideshow();
|
|
_clearQueueAndOptimization();
|
|
castIconState.value = CastIconState.idle;
|
|
isCastingNotifier.value = false;
|
|
_castReadyCompleter = Completer<void>();
|
|
_resetPlaybackState();
|
|
return _channel.invokeMethod('stopCast');
|
|
}
|
|
|
|
Future<void> showGoogleCastDialog() {
|
|
CastController.instance.castIconState.value = CastIconState.connecting;
|
|
return _channel.invokeMethod('showCastDialog');
|
|
}
|
|
|
|
Future<List<Map<String, dynamic>>> buildQueue(List<AvesEntry> entries) {
|
|
return _buildQueue(entries);
|
|
}
|
|
|
|
MethodChannel get channel => _channel;
|
|
}
|