// lib/widgets/cast/google_cast_controller.dart import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:aves/model/entry/entry.dart'; import 'package:aves/services/cast/cast_server.dart'; import 'package:aves/remote/remote_settings.dart'; import 'package:aves/widgets/cast/cast_controller.dart'; class GoogleCastController { GoogleCastController._() { _channel.setMethodCallHandler(_onNativeEvent); // Timer leggero: aggiorna progress bar ogni 1 secondo Timer.periodic(const Duration(seconds: 1), (_) { if (isPlayingNotifier.value) { final pos = positionNotifier.value + const Duration(seconds: 1); if (pos < durationNotifier.value) { positionNotifier.value = pos; positionNotifier.notifyListeners(); } } }); } static final GoogleCastController instance = GoogleCastController._(); static const MethodChannel _channel = MethodChannel('google_cast'); // ⭐ Attiva Google Cast come proprietario del channel void activateMethodChannelHandler() { _channel.setMethodCallHandler(_onNativeEvent); } // ⭐ Disattiva Google Cast quando passi al backend custom void deactivateMethodChannelHandler() { } // UI state final ValueNotifier castIconState = ValueNotifier(CastIconState.idle); final ValueNotifier isCastingNotifier = ValueNotifier(false); final ValueNotifier currentIndexNotifier = ValueNotifier(0); final ValueNotifier isPlayingNotifier = ValueNotifier(false); final ValueNotifier isBufferingNotifier = ValueNotifier(false); final ValueNotifier positionNotifier = ValueNotifier(Duration.zero); final ValueNotifier durationNotifier = ValueNotifier(Duration.zero); final ValueNotifier volumeNotifier = ValueNotifier(1.0); final ValueNotifier isMutedNotifier = ValueNotifier(false); // slideshow Timer? _slideshowTimer; bool _slideshowActive = false; int _slideshowIntervalSeconds = 5; // remote share cache final Map _shareCache = {}; String? _cachedToken; DateTime? _tokenExpiry; List? resolvedUrls; List? resolvedIsVideo; int _currentIndex = 0; bool _currentIsVideo = false; Completer _castReadyCompleter = Completer(); // ------------------------------------------------------------ // RESET TOTALE (usato dal selettore backend) // ------------------------------------------------------------ void forceReset() { stopSlideshow(); resolvedUrls = null; resolvedIsVideo = null; _currentIsVideo = false; _currentIndex = 0; castIconState.value = CastIconState.idle; isCastingNotifier.value = false; _castReadyCompleter = Completer(); _resetPlaybackState(); } // ------------------------------------------------------------ // TOKEN // ------------------------------------------------------------ Future _getToken(RemoteSettings settings) async { if (_cachedToken != null && _tokenExpiry != null && DateTime.now().isBefore(_tokenExpiry!)) { return _cachedToken!; } final res = await http.post( Uri.parse("${settings.baseUrl}/auth/login"), headers: {"Content-Type": "application/json"}, body: jsonEncode({ "email": settings.email, "password": settings.password, }), ); if (res.statusCode != 200) { throw Exception("Login remoto fallito"); } final data = jsonDecode(res.body); _cachedToken = data["token"]; _tokenExpiry = DateTime.now().add(const Duration(minutes: 10)); return _cachedToken!; } Future _resolveCastUrl(AvesEntry e) async { if ((e.origin ?? 0) == 0) { return castServer.buildUrlForEntry(e); } final remoteId = e.remoteId!; if (_shareCache.containsKey(remoteId)) { return _shareCache[remoteId]!; } final settings = await RemoteSettings.load(); final token = await _getToken(settings); final res = await http.post( Uri.parse("${settings.baseUrl}/share/create"), headers: { "Authorization": "Bearer $token", "Content-Type": "application/json", }, body: jsonEncode({ "entryId": remoteId, "ttlSeconds": 600, }), ); final data = jsonDecode(res.body); final url = (data["url"] as String).replaceFirst("http://", "https://"); _shareCache[remoteId] = url; return url; } // ------------------------------------------------------------ // EVENTI NATIVI // ------------------------------------------------------------ Future _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; resolvedUrls = null; resolvedIsVideo = null; _currentIsVideo = false; _castReadyCompleter = Completer(); stopSlideshow(); _resetPlaybackState(); break; case "onQueueIndexChanged": final args = (call.arguments as Map?) ?? {}; final index = args["index"] ?? 0; final isVideo = args["isVideo"] == true; _currentIndex = index; currentIndexNotifier.value = index; _currentIsVideo = isVideo; if (_slideshowActive && !isVideo) { _scheduleNextPhoto(); } else if (_slideshowActive && isVideo) { _slideshowTimer?.cancel(); } break; case "onVideoFinished": if (_slideshowActive) { await _channel.invokeMethod('next'); } break; case "onMediaStatus": final data = (call.arguments as Map?) ?? {}; isPlayingNotifier.value = data["isPlaying"] == true; isBufferingNotifier.value = data["isBuffering"] == true; final pos = (data["position"] as num?)?.toInt() ?? 0; final dur = (data["duration"] as num?)?.toInt() ?? 0; positionNotifier.value = Duration(milliseconds: pos); durationNotifier.value = Duration(milliseconds: dur); positionNotifier.notifyListeners(); durationNotifier.notifyListeners(); break; case "onVolumeChanged": final data = (call.arguments as Map?) ?? {}; volumeNotifier.value = ((data["volume"] as num?)?.toDouble() ?? 1.0) .clamp(0.0, 1.0); isMutedNotifier.value = data["isMuted"] == true; volumeNotifier.notifyListeners(); isMutedNotifier.notifyListeners(); break; } } void _resetPlaybackState() { isPlayingNotifier.value = false; isBufferingNotifier.value = false; positionNotifier.value = Duration.zero; durationNotifier.value = Duration.zero; } Future _waitReady() async { if (_castReadyCompleter.isCompleted) return; await _castReadyCompleter.future; } // ------------------------------------------------------------ // QUEUE // ------------------------------------------------------------ Future>> _buildQueue( List entries) async { // ⭐⭐⭐ PATCH FONDAMENTALE ⭐⭐⭐ // Avvia il server Google Cast se non è già attivo await castServer.ensureStarted(); final resolved = await Future.wait(entries.map((e) async { final url = await _resolveCastUrl(e); final isVideo = (e.durationMillis ?? 0) > 0; return { "url": url, "isVideo": isVideo, "mimeType": e.mimeType, "title": e.bestTitle ?? "", }; })); resolvedUrls = resolved.map((it) => it["url"] as String).toList(); resolvedIsVideo = resolved.map((it) => it["isVideo"] as bool).toList(); return resolved; } Future startManualQueue({ required List entries, required int startIndex, }) async { currentIndexNotifier.value = startIndex; _currentIndex = startIndex; await _waitReady(); final items = await _buildQueue(entries); _currentIsVideo = resolvedIsVideo?[startIndex] ?? false; await _channel.invokeMethod('loadQueue', { "items": items, "startIndex": startIndex, }); } // ------------------------------------------------------------ // SLIDESHOW // ------------------------------------------------------------ Future startSlideshow({ required List 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) return; await _channel.invokeMethod('next'); }, ); } void stopSlideshow() { _slideshowTimer?.cancel(); _slideshowTimer = null; _slideshowActive = false; } // ------------------------------------------------------------ // CONTROLLI // ------------------------------------------------------------ Future playCast() => _channel.invokeMethod('play'); Future pauseCast() => _channel.invokeMethod('pause'); Future seekCast(Duration position) async { final ms = position.inMilliseconds; if ((positionNotifier.value.inMilliseconds - ms).abs() < 500) return; await _channel.invokeMethod('seekTo', { "position": ms, }); positionNotifier.value = position; positionNotifier.notifyListeners(); } Future setCastVolume(double value) async { final clamped = value.clamp(0.0, 1.0); volumeNotifier.value = clamped; volumeNotifier.notifyListeners(); await _channel.invokeMethod('setVolume', { "volume": clamped, }); } Future toggleMuteCast() async { final mute = !isMutedNotifier.value; await _channel.invokeMethod('setMute', { "mute": mute, }); isMutedNotifier.value = mute; isMutedNotifier.notifyListeners(); } Future nextManual() => _channel.invokeMethod('next'); Future prevManual() => _channel.invokeMethod('prev'); // ------------------------------------------------------------ // STOP // ------------------------------------------------------------ Future stopGoogleCast() async { stopSlideshow(); resolvedUrls = null; resolvedIsVideo = null; castIconState.value = CastIconState.idle; isCastingNotifier.value = false; _currentIsVideo = false; _castReadyCompleter = Completer(); _resetPlaybackState(); return _channel.invokeMethod('stopCast'); } // ------------------------------------------------------------ // UI // ------------------------------------------------------------ Future showGoogleCastDialog() { CastController.instance.castIconState.value = CastIconState.connecting; return _channel.invokeMethod('showCastDialog'); } // ------------------------------------------------------------ // API pubbliche per FAST backend // ------------------------------------------------------------ Future>> buildQueue(List entries) { return _buildQueue(entries); } MethodChannel get channel => _channel; }