416 lines
12 KiB
Dart
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;
|
|
|
|
|
|
}
|
|
|