mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
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 bitmask
This commit is contained in:
parent
e88eb44aba
commit
5574b2dd39
@ -649,6 +649,7 @@
|
|||||||
"confirm_password": "Confirm password",
|
"confirm_password": "Confirm password",
|
||||||
"confirm_tag_face": "Do you want to tag this face as {name}?",
|
"confirm_tag_face": "Do you want to tag this face as {name}?",
|
||||||
"confirm_tag_face_unnamed": "Do you want to tag this face?",
|
"confirm_tag_face_unnamed": "Do you want to tag this face?",
|
||||||
|
"connected_device": "Connected device",
|
||||||
"connected_to": "Connected to",
|
"connected_to": "Connected to",
|
||||||
"contain": "Contain",
|
"contain": "Contain",
|
||||||
"context": "Context",
|
"context": "Context",
|
||||||
@ -749,6 +750,7 @@
|
|||||||
"disallow_edits": "Disallow edits",
|
"disallow_edits": "Disallow edits",
|
||||||
"discord": "Discord",
|
"discord": "Discord",
|
||||||
"discover": "Discover",
|
"discover": "Discover",
|
||||||
|
"discovered_devices": "Discovered devices",
|
||||||
"dismiss_all_errors": "Dismiss all errors",
|
"dismiss_all_errors": "Dismiss all errors",
|
||||||
"dismiss_error": "Dismiss error",
|
"dismiss_error": "Dismiss error",
|
||||||
"display_options": "Display options",
|
"display_options": "Display options",
|
||||||
@ -1133,6 +1135,7 @@
|
|||||||
"list": "List",
|
"list": "List",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
"loading_search_results_failed": "Loading search results failed",
|
"loading_search_results_failed": "Loading search results failed",
|
||||||
|
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
|
||||||
"local_network": "Local network",
|
"local_network": "Local network",
|
||||||
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
|
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
|
||||||
"location_permission": "Location permission",
|
"location_permission": "Location permission",
|
||||||
@ -1273,6 +1276,7 @@
|
|||||||
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
|
"no_archived_assets_message": "Archive photos and videos to hide them from your Photos view",
|
||||||
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
|
"no_assets_message": "CLICK TO UPLOAD YOUR FIRST PHOTO",
|
||||||
"no_assets_to_show": "No assets to show",
|
"no_assets_to_show": "No assets to show",
|
||||||
|
"no_cast_devices_found": "No cast devices found",
|
||||||
"no_duplicates_found": "No duplicates were found.",
|
"no_duplicates_found": "No duplicates were found.",
|
||||||
"no_exif_info_available": "No exif info available",
|
"no_exif_info_available": "No exif info available",
|
||||||
"no_explore_results_message": "Upload more photos to explore your collection.",
|
"no_explore_results_message": "Upload more photos to explore your collection.",
|
||||||
@ -1769,6 +1773,7 @@
|
|||||||
"start_date": "Start date",
|
"start_date": "Start date",
|
||||||
"state": "State",
|
"state": "State",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
|
"stop_casting": "Stop casting",
|
||||||
"stop_motion_photo": "Stop Motion Photo",
|
"stop_motion_photo": "Stop Motion Photo",
|
||||||
"stop_photo_sharing": "Stop sharing your photos?",
|
"stop_photo_sharing": "Stop sharing your photos?",
|
||||||
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
|
"stop_photo_sharing_description": "{partner} will no longer be able to access your photos.",
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- background_downloader (0.0.1):
|
- background_downloader (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- bonsoir_darwin (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- device_info_plus (0.0.1):
|
- device_info_plus (0.0.1):
|
||||||
@ -129,6 +132,7 @@ PODS:
|
|||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||||
|
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
@ -173,6 +177,8 @@ SPEC REPOS:
|
|||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
background_downloader:
|
background_downloader:
|
||||||
:path: ".symlinks/plugins/background_downloader/ios"
|
:path: ".symlinks/plugins/background_downloader/ios"
|
||||||
|
bonsoir_darwin:
|
||||||
|
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
@ -235,44 +241,45 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
|
background_downloader: a05c77d32a0d70615b9c04577aa203535fc924ff
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
|
||||||
|
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||||
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
|
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
|
||||||
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
|
flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603
|
||||||
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
|
fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f
|
||||||
geolocator_apple: 1560c3c875af2a412242c7a923e15d0d401966ff
|
geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
|
||||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||||
isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26
|
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
|
||||||
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
|
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
|
||||||
MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e
|
MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e
|
||||||
maplibre_gl: eab61cca6e1cfa9187249bacd3f08b51e8cd8ae9
|
maplibre_gl: be7b98f1c3ed75bf77f321eec04df359d0ff6f62
|
||||||
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
|
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
|
||||||
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
|
network_info_plus: 6613d9d7cdeb0e6f366ed4dbe4b3c51c52d567a9
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||||
photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413
|
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
|
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
|
||||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
share_handler_ios: 6dd3a4ac5ca0d955274aec712ba0ecdcaf583e7c
|
||||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||||
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
||||||
sqlite3_flutter_libs: f8fc13346870e73fe35ebf6dbb997fbcd156b241
|
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||||
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
|
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
|
||||||
|
|
||||||
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
|
PODFILE CHECKSUM: 7ce312f2beab01395db96f6969d90a447279cf45
|
||||||
|
|
||||||
|
@ -113,6 +113,11 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true />
|
<true />
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_googlecast._tcp</string>
|
||||||
|
<string>_CC1AD845._googlecast._tcp</string>
|
||||||
|
</array>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
@ -21,4 +21,6 @@ abstract interface class IAssetApiRepository {
|
|||||||
List<String> list,
|
List<String> list,
|
||||||
AssetVisibilityEnum visibility,
|
AssetVisibilityEnum visibility,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<String?> getAssetMIMEType(String id);
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
}
|
9
mobile/lib/interfaces/sessions_api.interface.dart
Normal file
9
mobile/lib/interfaces/sessions_api.interface.dart
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
}
|
88
mobile/lib/models/cast/cast_manager_state.dart
Normal file
88
mobile/lib/models/cast/cast_manager_state.dart
Normal file
@ -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]';
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,7 @@ import 'dart:math';
|
|||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
@ -20,6 +21,7 @@ import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart
|
|||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||||
@ -62,6 +64,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
final currentIndex = useValueNotifier(initialIndex);
|
final currentIndex = useValueNotifier(initialIndex);
|
||||||
final loadAsset = renderList.loadAsset;
|
final loadAsset = renderList.loadAsset;
|
||||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||||
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
final videoPlayerKeys = useRef<Map<int, GlobalKey>>({});
|
final videoPlayerKeys = useRef<Map<int, GlobalKey>>({});
|
||||||
|
|
||||||
@ -118,6 +121,36 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
const [],
|
const [],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
final asset = loadAsset(currentIndex.value);
|
||||||
|
|
||||||
|
if (asset.isRemote) {
|
||||||
|
ref.read(castProvider.notifier).loadMedia(asset, false);
|
||||||
|
} else {
|
||||||
|
if (isCasting) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ref.read(castProvider.notifier).stop();
|
||||||
|
context.scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
content: Text(
|
||||||
|
"local_asset_cast_failed".tr(),
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [
|
||||||
|
ref.watch(castProvider).isCasting,
|
||||||
|
]);
|
||||||
|
|
||||||
void showInfo() {
|
void showInfo() {
|
||||||
final asset = ref.read(currentAssetProvider);
|
final asset = ref.read(currentAssetProvider);
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
@ -356,6 +389,30 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
Timer(const Duration(milliseconds: 400), () {
|
Timer(const Duration(milliseconds: 400), () {
|
||||||
precacheNextImage(next);
|
precacheNextImage(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
context.scaffoldMessenger.hideCurrentSnackBar();
|
||||||
|
|
||||||
|
// send image to casting if the server has it
|
||||||
|
if (newAsset.isRemote) {
|
||||||
|
ref.read(castProvider.notifier).loadMedia(newAsset, false);
|
||||||
|
} else {
|
||||||
|
context.scaffoldMessenger.clearSnackBars();
|
||||||
|
|
||||||
|
if (isCasting) {
|
||||||
|
ref.read(castProvider.notifier).stop();
|
||||||
|
context.scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
content: Text(
|
||||||
|
"local_asset_cast_failed".tr(),
|
||||||
|
style: context.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: context.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
builder: buildAsset,
|
builder: buildAsset,
|
||||||
),
|
),
|
||||||
|
@ -13,6 +13,7 @@ import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart
|
|||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/asset.service.dart';
|
import 'package:immich_mobile/services/asset.service.dart';
|
||||||
@ -60,6 +61,8 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final log = Logger('NativeVideoViewerPage');
|
final log = Logger('NativeVideoViewerPage');
|
||||||
|
|
||||||
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
Future<VideoSource?> createSource() async {
|
Future<VideoSource?> createSource() async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return null;
|
return null;
|
||||||
@ -391,7 +394,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
// This remains under the video to avoid flickering
|
// This remains under the video to avoid flickering
|
||||||
// For motion videos, this is the image portion of the asset
|
// For motion videos, this is the image portion of the asset
|
||||||
Center(key: ValueKey(asset.id), child: image),
|
Center(key: ValueKey(asset.id), child: image),
|
||||||
if (aspectRatio.value != null)
|
if (aspectRatio.value != null && !isCasting)
|
||||||
Visibility.maintain(
|
Visibility.maintain(
|
||||||
key: ValueKey(asset),
|
key: ValueKey(asset),
|
||||||
visible: isVisible.value,
|
visible: isVisible.value,
|
||||||
|
93
mobile/lib/providers/cast.provider.dart
Normal file
93
mobile/lib/providers/cast.provider.dart
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -72,4 +72,12 @@ class AssetApiRepository extends ApiRepository implements IAssetApiRepository {
|
|||||||
return AssetVisibility.archive;
|
return AssetVisibility.archive;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> getAssetMIMEType(String assetId) async {
|
||||||
|
final response = await checkNull(_api.getAssetInfo(assetId));
|
||||||
|
|
||||||
|
// we need to get the MIME of the thumbnail once that gets added to the API
|
||||||
|
return response.originalMimeType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
75
mobile/lib/repositories/gcast.repository.dart
Normal file
75
mobile/lib/repositories/gcast.repository.dart
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
47
mobile/lib/repositories/sessions_api.repository.dart
Normal file
47
mobile/lib/repositories/sessions_api.repository.dart
Normal file
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -34,6 +34,7 @@ class ApiService implements Authentication {
|
|||||||
late StacksApi stacksApi;
|
late StacksApi stacksApi;
|
||||||
late ViewApi viewApi;
|
late ViewApi viewApi;
|
||||||
late MemoriesApi memoriesApi;
|
late MemoriesApi memoriesApi;
|
||||||
|
late SessionsApi sessionsApi;
|
||||||
|
|
||||||
ApiService() {
|
ApiService() {
|
||||||
// The below line ensures that the api clients are initialized when the service is instantiated
|
// The below line ensures that the api clients are initialized when the service is instantiated
|
||||||
@ -72,6 +73,7 @@ class ApiService implements Authentication {
|
|||||||
stacksApi = StacksApi(_apiClient);
|
stacksApi = StacksApi(_apiClient);
|
||||||
viewApi = ViewApi(_apiClient);
|
viewApi = ViewApi(_apiClient);
|
||||||
memoriesApi = MemoriesApi(_apiClient);
|
memoriesApi = MemoriesApi(_apiClient);
|
||||||
|
sessionsApi = SessionsApi(_apiClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setUserAgentHeader() async {
|
Future<void> _setUserAgentHeader() async {
|
||||||
|
295
mobile/lib/services/gcast.service.dart
Normal file
295
mobile/lib/services/gcast.service.dart
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -73,6 +73,10 @@ String getThumbnailUrlForRemoteId(
|
|||||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
|
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getPlaybackUrlForRemoteId(final String id) {
|
||||||
|
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
|
||||||
|
}
|
||||||
|
|
||||||
String getFaceThumbnailUrl(final String personId) {
|
String getFaceThumbnailUrl(final String personId) {
|
||||||
return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail';
|
return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail';
|
||||||
}
|
}
|
||||||
|
160
mobile/lib/widgets/asset_viewer/cast_dialog.dart
Normal file
160
mobile/lib/widgets/asset_viewer/cast_dialog.dart
Normal file
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||||
@ -25,6 +27,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
final VideoPlaybackState state =
|
final VideoPlaybackState state =
|
||||||
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||||
|
|
||||||
|
final cast = ref.watch(castProvider);
|
||||||
|
|
||||||
// A timer to hide the controls
|
// A timer to hide the controls
|
||||||
final hideTimer = useTimer(
|
final hideTimer = useTimer(
|
||||||
hideTimerDuration,
|
hideTimerDuration,
|
||||||
@ -42,7 +46,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final showBuffering = state == VideoPlaybackState.buffering;
|
final showBuffering =
|
||||||
|
state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
/// Shows the controls and starts the timer to hide them
|
||||||
void showControlsAndStartHideTimer() {
|
void showControlsAndStartHideTimer() {
|
||||||
@ -59,6 +64,23 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
/// Toggles between playing and pausing depending on the state of the video
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
void togglePlay() {
|
void togglePlay() {
|
||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
|
|
||||||
|
if (cast.isCasting) {
|
||||||
|
if (cast.castState == CastState.playing) {
|
||||||
|
ref.read(castProvider.notifier).pause();
|
||||||
|
} else if (cast.castState == CastState.paused) {
|
||||||
|
ref.read(castProvider.notifier).play();
|
||||||
|
} else if (cast.castState == CastState.idle) {
|
||||||
|
// resend the play command since its finished
|
||||||
|
final asset = ref.read(currentAssetProvider);
|
||||||
|
if (asset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(castProvider.notifier).loadMedia(asset, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
if (state == VideoPlaybackState.playing) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
} else if (state == VideoPlaybackState.completed) {
|
} else if (state == VideoPlaybackState.completed) {
|
||||||
@ -89,7 +111,8 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
iconColor: Colors.white,
|
iconColor: Colors.white,
|
||||||
isFinished: state == VideoPlaybackState.completed,
|
isFinished: state == VideoPlaybackState.completed,
|
||||||
isPlaying: state == VideoPlaybackState.playing,
|
isPlaying: state == VideoPlaybackState.playing ||
|
||||||
|
(cast.isCasting && cast.castState == CastState.playing),
|
||||||
show: assetIsVideo && showControls,
|
show: assetIsVideo && showControls,
|
||||||
onPressed: togglePlay,
|
onPressed: togglePlay,
|
||||||
),
|
),
|
||||||
|
@ -1,12 +1,16 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
import 'package:immich_mobile/providers/activity_statistics.provider.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
|
|
||||||
@ -44,6 +48,10 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
const double iconSize = 22.0;
|
const double iconSize = 22.0;
|
||||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||||
final album = ref.watch(currentAlbumProvider);
|
final album = ref.watch(currentAlbumProvider);
|
||||||
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
final websocketConnected =
|
||||||
|
ref.watch(websocketProvider.select((c) => c.isConnected));
|
||||||
|
|
||||||
final comments = album != null &&
|
final comments = album != null &&
|
||||||
album.remoteId != null &&
|
album.remoteId != null &&
|
||||||
asset.remoteId != null
|
asset.remoteId != null
|
||||||
@ -169,6 +177,22 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget buildCastButton() {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const CastDialog(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
|
||||||
|
size: 20.0,
|
||||||
|
color: isCasting ? context.primaryColor : Colors.grey[200],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home;
|
bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home;
|
||||||
bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed;
|
bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed;
|
||||||
|
|
||||||
@ -193,6 +217,8 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
!asset.isTrashed &&
|
!asset.isTrashed &&
|
||||||
!isInLockedView)
|
!isInLockedView)
|
||||||
buildAddToAlbumButton(),
|
buildAddToAlbumButton(),
|
||||||
|
if (isCasting || (asset.isRemote && websocketConnected))
|
||||||
|
buildCastButton(),
|
||||||
if (asset.isTrashed) buildRestoreButton(),
|
if (asset.isTrashed) buildRestoreButton(),
|
||||||
if (album != null && album.shared && !isInLockedView)
|
if (album != null && album.shared && !isInLockedView)
|
||||||
buildActivitiesButton(),
|
buildActivitiesButton(),
|
||||||
|
@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/colors.dart';
|
import 'package:immich_mobile/constants/colors.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||||
|
|
||||||
class VideoPosition extends HookConsumerWidget {
|
class VideoPosition extends HookConsumerWidget {
|
||||||
@ -13,9 +14,16 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final (position, duration) = ref.watch(
|
final isCasting = ref.watch(castProvider).isCasting;
|
||||||
videoPlaybackValueProvider.select((v) => (v.position, v.duration)),
|
|
||||||
);
|
final (position, duration) = isCasting
|
||||||
|
? ref.watch(
|
||||||
|
castProvider.select((c) => (c.currentTime, c.duration)),
|
||||||
|
)
|
||||||
|
: ref.watch(
|
||||||
|
videoPlaybackValueProvider.select((v) => (v.position, v.duration)),
|
||||||
|
);
|
||||||
|
|
||||||
final wasPlaying = useRef<bool>(true);
|
final wasPlaying = useRef<bool>(true);
|
||||||
return duration == Duration.zero
|
return duration == Duration.zero
|
||||||
? const _VideoPositionPlaceholder()
|
? const _VideoPositionPlaceholder()
|
||||||
@ -57,15 +65,22 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
final inSeconds =
|
final seekToDuration = (duration * (value / 100.0));
|
||||||
(duration * (value / 100.0)).inSeconds;
|
|
||||||
final position = inSeconds.toDouble();
|
if (isCasting) {
|
||||||
|
ref
|
||||||
|
.read(castProvider.notifier)
|
||||||
|
.seekTo(seekToDuration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ref
|
ref
|
||||||
.read(videoPlayerControlsProvider.notifier)
|
.read(videoPlayerControlsProvider.notifier)
|
||||||
.position = position;
|
.position = seekToDuration.inSeconds.toDouble();
|
||||||
|
|
||||||
// This immediately updates the slider position without waiting for the video to update
|
// This immediately updates the slider position without waiting for the video to update
|
||||||
ref.read(videoPlaybackValueProvider.notifier).position =
|
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||||
Duration(seconds: inSeconds);
|
seekToDuration;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -8,9 +8,11 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||||
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
||||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||||
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart';
|
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart';
|
||||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||||
|
|
||||||
@ -31,6 +33,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
final isDarkTheme = context.isDarkTheme;
|
final isDarkTheme = context.isDarkTheme;
|
||||||
const widgetSize = 30.0;
|
const widgetSize = 30.0;
|
||||||
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
buildProfileIndicator() {
|
buildProfileIndicator() {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
@ -184,6 +187,21 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
icon: const Icon(Icons.science_rounded),
|
icon: const Icon(Icons.science_rounded),
|
||||||
onPressed: () => context.pushRoute(const FeatInDevRoute()),
|
onPressed: () => context.pushRoute(const FeatInDevRoute()),
|
||||||
),
|
),
|
||||||
|
if (isCasting)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const CastDialog(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (showUploadButton)
|
if (showUploadButton)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 20),
|
padding: const EdgeInsets.only(right: 20),
|
||||||
|
@ -81,6 +81,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.2.0"
|
version: "9.2.0"
|
||||||
|
bonsoir:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir
|
||||||
|
sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.11"
|
||||||
|
bonsoir_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_android
|
||||||
|
sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.6"
|
||||||
|
bonsoir_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_darwin
|
||||||
|
sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.3"
|
||||||
|
bonsoir_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_linux
|
||||||
|
sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.3"
|
||||||
|
bonsoir_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_platform_interface
|
||||||
|
sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.3"
|
||||||
|
bonsoir_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_windows
|
||||||
|
sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.5"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -193,6 +241,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
cast:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cast
|
||||||
|
sha256: de1856e1a31aa60a6fed627f827921f7ec6539c67c60d0c899e89646dcbe773e
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1404,6 +1460,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.3"
|
version: "5.0.3"
|
||||||
|
protobuf:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: protobuf
|
||||||
|
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -19,6 +19,7 @@ dependencies:
|
|||||||
background_downloader: ^9.2.0
|
background_downloader: ^9.2.0
|
||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
cancellation_token_http: ^2.1.0
|
cancellation_token_http: ^2.1.0
|
||||||
|
cast: ^2.1.0
|
||||||
collection: ^1.18.0
|
collection: ^1.18.0
|
||||||
connectivity_plus: ^6.1.3
|
connectivity_plus: ^6.1.3
|
||||||
crop_image: ^1.0.16
|
crop_image: ^1.0.16
|
||||||
|
Loading…
x
Reference in New Issue
Block a user