aves_mio100/lib/widgets/cast/google_cast_controller.dart
2026-07-06 14:52:45 +02:00

416 lines
12 KiB
Dart

// 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> 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);
// slideshow
Timer? _slideshowTimer;
bool _slideshowActive = false;
int _slideshowIntervalSeconds = 5;
// remote share cache
final Map<String, String> _shareCache = {};
String? _cachedToken;
DateTime? _tokenExpiry;
List<String>? resolvedUrls;
List<bool>? resolvedIsVideo;
int _currentIndex = 0;
bool _currentIsVideo = false;
Completer<void> _castReadyCompleter = Completer<void>();
// ------------------------------------------------------------
// 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<void>();
_resetPlaybackState();
}
// ------------------------------------------------------------
// TOKEN
// ------------------------------------------------------------
Future<String> _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<String> _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<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;
resolvedUrls = null;
resolvedIsVideo = null;
_currentIsVideo = false;
_castReadyCompleter = Completer<void>();
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<void> _waitReady() async {
if (_castReadyCompleter.isCompleted) return;
await _castReadyCompleter.future;
}
// ------------------------------------------------------------
// QUEUE
// ------------------------------------------------------------
Future<List<Map<String, dynamic>>> _buildQueue(
List<AvesEntry> 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<void> startManualQueue({
required List<AvesEntry> 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<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) return;
await _channel.invokeMethod('next');
},
);
}
void stopSlideshow() {
_slideshowTimer?.cancel();
_slideshowTimer = null;
_slideshowActive = false;
}
// ------------------------------------------------------------
// CONTROLLI
// ------------------------------------------------------------
Future<void> playCast() => _channel.invokeMethod('play');
Future<void> pauseCast() => _channel.invokeMethod('pause');
Future<void> 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<void> setCastVolume(double value) async {
final clamped = value.clamp(0.0, 1.0);
volumeNotifier.value = clamped;
volumeNotifier.notifyListeners();
await _channel.invokeMethod('setVolume', {
"volume": clamped,
});
}
Future<void> toggleMuteCast() async {
final mute = !isMutedNotifier.value;
await _channel.invokeMethod('setMute', {
"mute": mute,
});
isMutedNotifier.value = mute;
isMutedNotifier.notifyListeners();
}
Future<void> nextManual() => _channel.invokeMethod('next');
Future<void> prevManual() => _channel.invokeMethod('prev');
// ------------------------------------------------------------
// STOP
// ------------------------------------------------------------
Future<void> stopGoogleCast() async {
stopSlideshow();
resolvedUrls = null;
resolvedIsVideo = null;
castIconState.value = CastIconState.idle;
isCastingNotifier.value = false;
_currentIsVideo = false;
_castReadyCompleter = Completer<void>();
_resetPlaybackState();
return _channel.invokeMethod('stopCast');
}
// ------------------------------------------------------------
// UI
// ------------------------------------------------------------
Future<void> showGoogleCastDialog() {
CastController.instance.castIconState.value = CastIconState.connecting;
return _channel.invokeMethod('showCastDialog');
}
// ------------------------------------------------------------
// API pubbliche per FAST backend
// ------------------------------------------------------------
Future<List<Map<String, dynamic>>> buildQueue(List<AvesEntry> entries) {
return _buildQueue(entries);
}
MethodChannel get channel => _channel;
}