mirror of https://github.com/immich-app/immich.git
feat(mobile): add cast support (#18341)
* initial cast framework complete and mocked cast dialog working * wip casting * casting works! just need to add session key check and remote video controls * cleanup of classes * add session expiration checks * cast dialog now shows connected device at top of list with a list header. Discovered devices are also cached for app session. * cast video player finalized * show fullsize assets on casting * translation already happens on the text element * remove prints * fix lintings * code review changes from @shenlong-tanwen * fix connect method override * fix alphabetization * remove important * filter chromecast audio devices * fix some disconnect command ordering issues and unawaited futures * remove prints * only disconnect if we are connected * don't try to reconnect if its the current device * add cast button to top bar * format sessions api * more formatting issues fixed * add snack bar to tell user that we cannot cast an asset that is not uploaded to server * make casting icon change to primary color when casting is active * only show casting snackbar if we are casting * dont show cast button if asset is remote and we are not casting * stop playing media if we seek to an asset that is not remote * remove https check since it works with local http IP addresses * remove unneeded imports * fix recasting when socket closes * fix info plist formatting * only show cast button if there is an active websocket connection (ie the server is accessible) * add device capability bitmask checks * small comment about bitmaskpull/19031/head
parent
e88eb44aba
commit
5574b2dd39
@ -0,0 +1,27 @@
|
|||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
|
|
||||||
|
abstract interface class ICastDestinationService {
|
||||||
|
Future<bool> initialize();
|
||||||
|
CastDestinationType getType();
|
||||||
|
|
||||||
|
void Function(bool)? onConnectionState;
|
||||||
|
|
||||||
|
void Function(Duration)? onCurrentTime;
|
||||||
|
void Function(Duration)? onDuration;
|
||||||
|
|
||||||
|
void Function(String)? onReceiverName;
|
||||||
|
void Function(CastState)? onCastState;
|
||||||
|
|
||||||
|
Future<void> connect(dynamic device);
|
||||||
|
|
||||||
|
void loadMedia(Asset asset, bool reload);
|
||||||
|
|
||||||
|
void play();
|
||||||
|
void pause();
|
||||||
|
void seekTo(Duration position);
|
||||||
|
void stop();
|
||||||
|
Future<void> disconnect();
|
||||||
|
|
||||||
|
Future<List<(String, CastDestinationType, dynamic)>> getDevices();
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
|
||||||
|
|
||||||
|
abstract interface class ISessionAPIRepository {
|
||||||
|
Future<SessionCreateResponse> createSession(
|
||||||
|
String deviceName,
|
||||||
|
String deviceOS, {
|
||||||
|
int? duration,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
enum CastDestinationType { googleCast }
|
||||||
|
|
||||||
|
enum CastState { idle, playing, paused, buffering }
|
||||||
|
|
||||||
|
class CastManagerState {
|
||||||
|
final bool isCasting;
|
||||||
|
final String receiverName;
|
||||||
|
final CastState castState;
|
||||||
|
final Duration currentTime;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
const CastManagerState({
|
||||||
|
required this.isCasting,
|
||||||
|
required this.receiverName,
|
||||||
|
required this.castState,
|
||||||
|
required this.currentTime,
|
||||||
|
required this.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
CastManagerState copyWith({
|
||||||
|
bool? isCasting,
|
||||||
|
String? receiverName,
|
||||||
|
CastState? castState,
|
||||||
|
Duration? currentTime,
|
||||||
|
Duration? duration,
|
||||||
|
}) {
|
||||||
|
return CastManagerState(
|
||||||
|
isCasting: isCasting ?? this.isCasting,
|
||||||
|
receiverName: receiverName ?? this.receiverName,
|
||||||
|
castState: castState ?? this.castState,
|
||||||
|
currentTime: currentTime ?? this.currentTime,
|
||||||
|
duration: duration ?? this.duration,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
result.addAll({'isCasting': isCasting});
|
||||||
|
result.addAll({'receiverName': receiverName});
|
||||||
|
result.addAll({'castState': castState});
|
||||||
|
result.addAll({'currentTime': currentTime.inSeconds});
|
||||||
|
result.addAll({'duration': duration.inSeconds});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory CastManagerState.fromMap(Map<String, dynamic> map) {
|
||||||
|
return CastManagerState(
|
||||||
|
isCasting: map['isCasting'] ?? false,
|
||||||
|
receiverName: map['receiverName'] ?? '',
|
||||||
|
castState: map['castState'] ?? CastState.idle,
|
||||||
|
currentTime: Duration(seconds: map['currentTime']?.toInt() ?? 0),
|
||||||
|
duration: Duration(seconds: map['duration']?.toInt() ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() => json.encode(toMap());
|
||||||
|
|
||||||
|
factory CastManagerState.fromJson(String source) =>
|
||||||
|
CastManagerState.fromMap(json.decode(source));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'CastManagerState(isCasting: $isCasting, receiverName: $receiverName, castState: $castState, currentTime: $currentTime, duration: $duration)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
|
return other is CastManagerState &&
|
||||||
|
other.isCasting == isCasting &&
|
||||||
|
other.receiverName == receiverName &&
|
||||||
|
other.castState == castState &&
|
||||||
|
other.currentTime == currentTime &&
|
||||||
|
other.duration == duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
isCasting.hashCode ^
|
||||||
|
receiverName.hashCode ^
|
||||||
|
castState.hashCode ^
|
||||||
|
currentTime.hashCode ^
|
||||||
|
duration.hashCode;
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
class SessionCreateResponse {
|
||||||
|
final String createdAt;
|
||||||
|
final bool current;
|
||||||
|
final String deviceOS;
|
||||||
|
final String deviceType;
|
||||||
|
final String? expiresAt;
|
||||||
|
final String id;
|
||||||
|
final String token;
|
||||||
|
final String updatedAt;
|
||||||
|
|
||||||
|
const SessionCreateResponse({
|
||||||
|
required this.createdAt,
|
||||||
|
required this.current,
|
||||||
|
required this.deviceOS,
|
||||||
|
required this.deviceType,
|
||||||
|
this.expiresAt,
|
||||||
|
required this.id,
|
||||||
|
required this.token,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SessionCreateResponse[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, token=$token, updatedAt=$updatedAt]';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart';
|
||||||
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
|
import 'package:immich_mobile/services/gcast.service.dart';
|
||||||
|
|
||||||
|
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
|
||||||
|
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
class CastNotifier extends StateNotifier<CastManagerState> {
|
||||||
|
// more cast providers can be added here (ie Fcast)
|
||||||
|
final ICastDestinationService _gCastService;
|
||||||
|
|
||||||
|
List<(String, CastDestinationType, dynamic)> discovered = List.empty();
|
||||||
|
|
||||||
|
CastNotifier(this._gCastService)
|
||||||
|
: super(
|
||||||
|
const CastManagerState(
|
||||||
|
isCasting: false,
|
||||||
|
currentTime: Duration.zero,
|
||||||
|
duration: Duration.zero,
|
||||||
|
receiverName: '',
|
||||||
|
castState: CastState.idle,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
_gCastService.onConnectionState = _onConnectionState;
|
||||||
|
_gCastService.onCurrentTime = _onCurrentTime;
|
||||||
|
_gCastService.onDuration = _onDuration;
|
||||||
|
_gCastService.onReceiverName = _onReceiverName;
|
||||||
|
_gCastService.onCastState = _onCastState;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onConnectionState(bool isCasting) {
|
||||||
|
state = state.copyWith(isCasting: isCasting);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCurrentTime(Duration currentTime) {
|
||||||
|
state = state.copyWith(currentTime: currentTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDuration(Duration duration) {
|
||||||
|
state = state.copyWith(duration: duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onReceiverName(String receiverName) {
|
||||||
|
state = state.copyWith(receiverName: receiverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCastState(CastState castState) {
|
||||||
|
state = state.copyWith(castState: castState);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadMedia(Asset asset, bool reload) {
|
||||||
|
_gCastService.loadMedia(asset, reload);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect(CastDestinationType type, dynamic device) async {
|
||||||
|
switch (type) {
|
||||||
|
case CastDestinationType.googleCast:
|
||||||
|
await _gCastService.connect(device);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||||
|
if (discovered.isEmpty) {
|
||||||
|
discovered = await _gCastService.getDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
void play() {
|
||||||
|
_gCastService.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
void pause() {
|
||||||
|
_gCastService.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
void seekTo(Duration position) {
|
||||||
|
_gCastService.seekTo(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
_gCastService.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
await _gCastService.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import 'package:cast/device.dart';
|
||||||
|
import 'package:cast/session.dart';
|
||||||
|
import 'package:cast/session_manager.dart';
|
||||||
|
import 'package:cast/discovery_service.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
final gCastRepositoryProvider = Provider((_) {
|
||||||
|
return GCastRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
class GCastRepository {
|
||||||
|
CastSession? _castSession;
|
||||||
|
|
||||||
|
void Function(CastSessionState)? onCastStatus;
|
||||||
|
void Function(Map<String, dynamic>)? onCastMessage;
|
||||||
|
|
||||||
|
Map<String, dynamic>? _receiverStatus;
|
||||||
|
|
||||||
|
GCastRepository();
|
||||||
|
|
||||||
|
Future<void> connect(CastDevice device) async {
|
||||||
|
_castSession = await CastSessionManager().startSession(device);
|
||||||
|
|
||||||
|
_castSession?.stateStream.listen((state) {
|
||||||
|
onCastStatus?.call(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
_castSession?.messageStream.listen((message) {
|
||||||
|
onCastMessage?.call(message);
|
||||||
|
if (message['type'] == 'RECEIVER_STATUS') {
|
||||||
|
_receiverStatus = message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// open the default receiver
|
||||||
|
sendMessage(CastSession.kNamespaceReceiver, {
|
||||||
|
'type': 'LAUNCH',
|
||||||
|
'appId': 'CC1AD845',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
final sessionID = getSessionId();
|
||||||
|
|
||||||
|
sendMessage(CastSession.kNamespaceReceiver, {
|
||||||
|
'type': "STOP",
|
||||||
|
"sessionId": sessionID,
|
||||||
|
});
|
||||||
|
|
||||||
|
// wait 500ms to ensure the stop command is processed
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
await _castSession?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getSessionId() {
|
||||||
|
if (_receiverStatus == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return _receiverStatus!['status']['applications'][0]['sessionId'];
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendMessage(String namespace, Map<String, dynamic> message) {
|
||||||
|
if (_castSession == null) {
|
||||||
|
throw Exception("Cast session is not established");
|
||||||
|
}
|
||||||
|
|
||||||
|
_castSession!.sendMessage(namespace, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<CastDevice>> listDestinations() async {
|
||||||
|
return await CastDiscoveryService()
|
||||||
|
.search(timeout: const Duration(seconds: 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/sessions_api.interface.dart';
|
||||||
|
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
|
||||||
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
|
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
final sessionsAPIRepositoryProvider = Provider(
|
||||||
|
(ref) => SessionsAPIRepository(
|
||||||
|
ref.watch(apiServiceProvider).sessionsApi,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class SessionsAPIRepository extends ApiRepository
|
||||||
|
implements ISessionAPIRepository {
|
||||||
|
final SessionsApi _api;
|
||||||
|
|
||||||
|
SessionsAPIRepository(this._api);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SessionCreateResponse> createSession(
|
||||||
|
String deviceType,
|
||||||
|
String deviceOS, {
|
||||||
|
int? duration,
|
||||||
|
}) async {
|
||||||
|
final dto = await checkNull(
|
||||||
|
_api.createSession(
|
||||||
|
SessionCreateDto(
|
||||||
|
deviceType: deviceType,
|
||||||
|
deviceOS: deviceOS,
|
||||||
|
duration: duration,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return SessionCreateResponse(
|
||||||
|
id: dto.id,
|
||||||
|
current: dto.current,
|
||||||
|
deviceType: deviceType,
|
||||||
|
deviceOS: deviceOS,
|
||||||
|
expiresAt: dto.expiresAt,
|
||||||
|
createdAt: dto.createdAt,
|
||||||
|
updatedAt: dto.updatedAt,
|
||||||
|
token: dto.token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,295 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cast/session.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/cast_destination_service.interface.dart';
|
||||||
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
|
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/gcast.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
|
||||||
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
|
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
|
final gCastServiceProvider = Provider(
|
||||||
|
(ref) => GCastService(
|
||||||
|
ref.watch(gCastRepositoryProvider),
|
||||||
|
ref.watch(sessionsAPIRepositoryProvider),
|
||||||
|
ref.watch(assetApiRepositoryProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
class GCastService implements ICastDestinationService {
|
||||||
|
final GCastRepository _gCastRepository;
|
||||||
|
final SessionsAPIRepository _sessionsApiService;
|
||||||
|
final AssetApiRepository _assetApiRepository;
|
||||||
|
|
||||||
|
SessionCreateResponse? sessionKey;
|
||||||
|
String? currentAssetId;
|
||||||
|
bool isConnected = false;
|
||||||
|
int? _sessionId;
|
||||||
|
Timer? _mediaStatusPollingTimer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void Function(bool)? onConnectionState;
|
||||||
|
@override
|
||||||
|
void Function(Duration)? onCurrentTime;
|
||||||
|
@override
|
||||||
|
void Function(Duration)? onDuration;
|
||||||
|
@override
|
||||||
|
void Function(String)? onReceiverName;
|
||||||
|
@override
|
||||||
|
void Function(CastState)? onCastState;
|
||||||
|
|
||||||
|
GCastService(
|
||||||
|
this._gCastRepository,
|
||||||
|
this._sessionsApiService,
|
||||||
|
this._assetApiRepository,
|
||||||
|
) {
|
||||||
|
_gCastRepository.onCastStatus = _onCastStatusCallback;
|
||||||
|
_gCastRepository.onCastMessage = _onCastMessageCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCastStatusCallback(CastSessionState state) {
|
||||||
|
if (state == CastSessionState.connected) {
|
||||||
|
onConnectionState?.call(true);
|
||||||
|
isConnected = true;
|
||||||
|
} else if (state == CastSessionState.closed) {
|
||||||
|
onConnectionState?.call(false);
|
||||||
|
isConnected = false;
|
||||||
|
onReceiverName?.call("");
|
||||||
|
currentAssetId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCastMessageCallback(Map<String, dynamic> message) {
|
||||||
|
switch (message['type']) {
|
||||||
|
case "MEDIA_STATUS":
|
||||||
|
_handleMediaStatus(message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMediaStatus(Map<String, dynamic> message) {
|
||||||
|
final statusList =
|
||||||
|
(message['status'] as List).whereType<Map<String, dynamic>>().toList();
|
||||||
|
|
||||||
|
if (statusList.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final status = statusList[0];
|
||||||
|
switch (status['playerState']) {
|
||||||
|
case "PLAYING":
|
||||||
|
onCastState?.call(CastState.playing);
|
||||||
|
break;
|
||||||
|
case "PAUSED":
|
||||||
|
onCastState?.call(CastState.paused);
|
||||||
|
break;
|
||||||
|
case "BUFFERING":
|
||||||
|
onCastState?.call(CastState.buffering);
|
||||||
|
break;
|
||||||
|
case "IDLE":
|
||||||
|
onCastState?.call(CastState.idle);
|
||||||
|
|
||||||
|
// stop polling for media status if the video finished playing
|
||||||
|
if (status["idleReason"] == "FINISHED") {
|
||||||
|
_mediaStatusPollingTimer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status["media"] != null && status["media"]["duration"] != null) {
|
||||||
|
final duration = Duration(
|
||||||
|
milliseconds: (status["media"]["duration"] * 1000 ?? 0).toInt(),
|
||||||
|
);
|
||||||
|
onDuration?.call(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status["mediaSessionId"] != null) {
|
||||||
|
_sessionId = status["mediaSessionId"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status["currentTime"] != null) {
|
||||||
|
final currentTime =
|
||||||
|
Duration(milliseconds: (status["currentTime"] * 1000 ?? 0).toInt());
|
||||||
|
onCurrentTime?.call(currentTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> connect(dynamic device) async {
|
||||||
|
await _gCastRepository.connect(device);
|
||||||
|
|
||||||
|
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
CastDestinationType getType() {
|
||||||
|
return CastDestinationType.googleCast;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> initialize() async {
|
||||||
|
// there is nothing blocking us from using Google Cast that we can check for
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
onReceiverName?.call("");
|
||||||
|
currentAssetId = null;
|
||||||
|
await _gCastRepository.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSessionValid() {
|
||||||
|
// check if we already have a session token
|
||||||
|
// we should always have a expiration date
|
||||||
|
if (sessionKey == null || sessionKey?.expiresAt == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
|
||||||
|
|
||||||
|
// we want to make sure we have at least 10 seconds remaining in the session
|
||||||
|
// this is to account for network latency and other delays when sending the request
|
||||||
|
final bufferedExpiration =
|
||||||
|
tokenExpiration.subtract(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
return bufferedExpiration.isAfter(DateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void loadMedia(Asset asset, bool reload) async {
|
||||||
|
if (!isConnected) {
|
||||||
|
return;
|
||||||
|
} else if (asset.remoteId == null) {
|
||||||
|
return;
|
||||||
|
} else if (asset.remoteId == currentAssetId && !reload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a session key
|
||||||
|
if (!isSessionValid()) {
|
||||||
|
sessionKey = await _sessionsApiService.createSession(
|
||||||
|
"Cast",
|
||||||
|
"Google Cast",
|
||||||
|
duration: const Duration(minutes: 15).inSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final unauthenticatedUrl = asset.isVideo
|
||||||
|
? getPlaybackUrlForRemoteId(
|
||||||
|
asset.remoteId!,
|
||||||
|
)
|
||||||
|
: getThumbnailUrlForRemoteId(
|
||||||
|
asset.remoteId!,
|
||||||
|
type: AssetMediaSize.fullsize,
|
||||||
|
);
|
||||||
|
|
||||||
|
final authenticatedURL =
|
||||||
|
"$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
|
||||||
|
|
||||||
|
// get image mime type
|
||||||
|
final mimeType =
|
||||||
|
await _assetApiRepository.getAssetMIMEType(asset.remoteId!);
|
||||||
|
|
||||||
|
if (mimeType == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||||
|
"type": "LOAD",
|
||||||
|
"media": {
|
||||||
|
"contentId": authenticatedURL,
|
||||||
|
"streamType": "BUFFERED",
|
||||||
|
"contentType": mimeType,
|
||||||
|
"contentUrl": authenticatedURL,
|
||||||
|
},
|
||||||
|
"autoplay": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentAssetId = asset.remoteId;
|
||||||
|
|
||||||
|
// we need to poll for media status since the cast device does not
|
||||||
|
// send a message when the media is loaded for whatever reason
|
||||||
|
// only do this on videos
|
||||||
|
_mediaStatusPollingTimer?.cancel();
|
||||||
|
|
||||||
|
if (asset.isVideo) {
|
||||||
|
_mediaStatusPollingTimer =
|
||||||
|
Timer.periodic(const Duration(milliseconds: 500), (timer) {
|
||||||
|
if (isConnected) {
|
||||||
|
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||||
|
"type": "GET_STATUS",
|
||||||
|
"mediaSessionId": _sessionId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void play() {
|
||||||
|
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||||
|
"type": "PLAY",
|
||||||
|
"mediaSessionId": _sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pause() {
|
||||||
|
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||||
|
"type": "PAUSE",
|
||||||
|
"mediaSessionId": _sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void seekTo(Duration position) {
|
||||||
|
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||||
|
"type": "SEEK",
|
||||||
|
"mediaSessionId": _sessionId,
|
||||||
|
"currentTime": position.inSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void stop() {
|
||||||
|
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||||
|
"type": "STOP",
|
||||||
|
"mediaSessionId": _sessionId,
|
||||||
|
});
|
||||||
|
_mediaStatusPollingTimer?.cancel();
|
||||||
|
|
||||||
|
currentAssetId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0x01 is display capability bitmask
|
||||||
|
bool isDisplay(int ca) => (ca & 0x01) != 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||||
|
final dests = await _gCastRepository.listDestinations();
|
||||||
|
|
||||||
|
return dests
|
||||||
|
.map(
|
||||||
|
(device) => (
|
||||||
|
device.extras["fn"] ?? "Google Cast",
|
||||||
|
CastDestinationType.googleCast,
|
||||||
|
device
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where((device) {
|
||||||
|
final caString = device.$3.extras["ca"];
|
||||||
|
final caNumber = int.tryParse(caString ?? "0") ?? 0;
|
||||||
|
|
||||||
|
return isDisplay(caNumber);
|
||||||
|
}).toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
|
|
||||||
|
class CastDialog extends ConsumerWidget {
|
||||||
|
const CastDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final castManager = ref.watch(castProvider);
|
||||||
|
|
||||||
|
bool isCurrentDevice(String deviceName) {
|
||||||
|
return castManager.receiverName == deviceName && castManager.isCasting;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isDeviceConnecting(String deviceName) {
|
||||||
|
return castManager.receiverName == deviceName && !castManager.isCasting;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text(
|
||||||
|
"cast",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
).tr(),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 250,
|
||||||
|
height: 250,
|
||||||
|
child: FutureBuilder<List<(String, CastDestinationType, dynamic)>>(
|
||||||
|
future: ref.read(castProvider.notifier).getDevices(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Text(
|
||||||
|
'Error: ${snapshot.error.toString()}',
|
||||||
|
);
|
||||||
|
} else if (!snapshot.hasData) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.data!.isEmpty) {
|
||||||
|
return const Text(
|
||||||
|
'no_cast_devices_found',
|
||||||
|
).tr();
|
||||||
|
}
|
||||||
|
|
||||||
|
final devices = snapshot.data!;
|
||||||
|
final connected =
|
||||||
|
devices.where((d) => isCurrentDevice(d.$1)).toList();
|
||||||
|
final others =
|
||||||
|
devices.where((d) => !isCurrentDevice(d.$1)).toList();
|
||||||
|
|
||||||
|
final List<dynamic> sectionedList = [];
|
||||||
|
|
||||||
|
if (connected.isNotEmpty) {
|
||||||
|
sectionedList.add("connected_device");
|
||||||
|
sectionedList.addAll(connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (others.isNotEmpty) {
|
||||||
|
sectionedList.add("discovered_devices");
|
||||||
|
sectionedList.addAll(others);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: sectionedList.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = sectionedList[index];
|
||||||
|
|
||||||
|
if (item is String) {
|
||||||
|
// It's a section header
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
item,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final (deviceName, type, deviceObj) =
|
||||||
|
item as (String, CastDestinationType, dynamic);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
deviceName,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrentDevice(deviceName)
|
||||||
|
? context.colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leading: Icon(
|
||||||
|
type == CastDestinationType.googleCast
|
||||||
|
? Icons.cast
|
||||||
|
: Icons.cast_connected,
|
||||||
|
color: isCurrentDevice(deviceName)
|
||||||
|
? context.colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
trailing: isCurrentDevice(deviceName)
|
||||||
|
? Icon(Icons.check, color: context.colorScheme.primary)
|
||||||
|
: isDeviceConnecting(deviceName)
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: null,
|
||||||
|
onTap: () async {
|
||||||
|
if (isDeviceConnecting(deviceName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (castManager.isCasting) {
|
||||||
|
await ref.read(castProvider.notifier).disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentDevice(deviceName)) {
|
||||||
|
ref
|
||||||
|
.read(castProvider.notifier)
|
||||||
|
.connect(type, deviceObj);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (castManager.isCasting)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ref.read(castProvider.notifier).disconnect(),
|
||||||
|
child: Text(
|
||||||
|
"stop_casting",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.colorScheme.secondary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
child: Text(
|
||||||
|
"close",
|
||||||
|
style: TextStyle(
|
||||||
|
color: context.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
).tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue