From e654001d17c5b475250fdd5c8c3cff93a32263c3 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:47:14 -0400 Subject: [PATCH] use hls in video viewer --- .../ios/Runner/Core/URLSessionManager.swift | 12 ++-- .../server_info/server_features.model.dart | 15 +++-- .../asset_viewer/video_viewer.widget.dart | 61 ++++++++++++++++--- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/mobile/ios/Runner/Core/URLSessionManager.swift b/mobile/ios/Runner/Core/URLSessionManager.swift index 48963aa577..aed2b96fce 100644 --- a/mobile/ios/Runner/Core/URLSessionManager.swift +++ b/mobile/ios/Runner/Core/URLSessionManager.swift @@ -67,6 +67,9 @@ class URLSessionManager: NSObject { delegate = URLSessionManagerDelegate() session = Self.buildSession(delegate: delegate) super.init() + if #available(iOS 15, *) { + VideoProxyServer.shared.session = session + } Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? [] NotificationCenter.default.addObserver( Self.self, @@ -78,6 +81,9 @@ class URLSessionManager: NSObject { func recreateSession() { session = Self.buildSession(delegate: delegate) + if #available(iOS 15, *) { + VideoProxyServer.shared.session = session + } } static func setServerUrls(_ urls: [String]) { @@ -249,9 +255,6 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb let credential = URLCredential(identity: identity as! SecIdentity, certificates: nil, persistence: .forSession) - if #available(iOS 15, *) { - VideoProxyServer.shared.session = session - } return completion(.useCredential, credential) } completion(.performDefaultHandling, nil) @@ -268,9 +271,6 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb else { return completion(.performDefaultHandling, nil) } - if #available(iOS 15, *) { - VideoProxyServer.shared.session = session - } let credential = URLCredential(user: user, password: password, persistence: .forSession) completion(.useCredential, credential) } diff --git a/mobile/lib/models/server_info/server_features.model.dart b/mobile/lib/models/server_info/server_features.model.dart index c288c1bfbf..d8b6970764 100644 --- a/mobile/lib/models/server_info/server_features.model.dart +++ b/mobile/lib/models/server_info/server_features.model.dart @@ -7,6 +7,7 @@ class ServerFeatures { final bool passwordLogin; final bool ocr; final bool smartSearch; + final bool realtimeTranscoding; const ServerFeatures({ required this.trash, @@ -15,6 +16,7 @@ class ServerFeatures { required this.passwordLogin, this.ocr = false, this.smartSearch = false, + this.realtimeTranscoding = false, }); ServerFeatures copyWith({ @@ -24,6 +26,7 @@ class ServerFeatures { bool? passwordLogin, bool? ocr, bool? smartSearch, + bool? realtimeTranscoding, }) { return ServerFeatures( trash: trash ?? this.trash, @@ -32,12 +35,13 @@ class ServerFeatures { passwordLogin: passwordLogin ?? this.passwordLogin, ocr: ocr ?? this.ocr, smartSearch: smartSearch ?? this.smartSearch, + realtimeTranscoding: realtimeTranscoding ?? this.realtimeTranscoding, ); } @override String toString() { - return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)'; + return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch, realtimeTranscoding: $realtimeTranscoding)'; } ServerFeatures.fromDto(ServerFeaturesDto dto) @@ -46,7 +50,8 @@ class ServerFeatures { oauthEnabled = dto.oauth, passwordLogin = dto.passwordLogin, ocr = dto.ocr, - smartSearch = dto.smartSearch; + smartSearch = dto.smartSearch, + realtimeTranscoding = dto.realtimeTranscoding; @override bool operator ==(covariant ServerFeatures other) { @@ -59,7 +64,8 @@ class ServerFeatures { other.oauthEnabled == oauthEnabled && other.passwordLogin == passwordLogin && other.ocr == ocr && - other.smartSearch == smartSearch; + other.smartSearch == smartSearch && + other.realtimeTranscoding == realtimeTranscoding; } @override @@ -69,6 +75,7 @@ class ServerFeatures { oauthEnabled.hashCode ^ passwordLogin.hashCode ^ ocr.hashCode ^ - smartSearch.hashCode; + smartSearch.hashCode ^ + realtimeTranscoding.hashCode; } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart index 2be7bb91e8..e4fe167592 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart @@ -10,11 +10,14 @@ import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/hls.dart'; import 'package:logging/logging.dart'; import 'package:native_video_player/native_video_player.dart'; @@ -46,6 +49,8 @@ class _NativeVideoViewerState extends ConsumerState with Widg Timer? _loadTimer; bool _isVideoReady = false; bool _shouldPlayOnForeground = true; + String? _sessionId; + String? _remoteAssetId; VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier); @@ -67,6 +72,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg if (!widget.isCurrent) { _loadTimer?.cancel(); _notifier.pause(); + _endSession(); return; } @@ -79,6 +85,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg WidgetsBinding.instance.removeObserver(this); _loadTimer?.cancel(); _removeListeners(); + _endSession(); super.dispose(); } @@ -141,14 +148,22 @@ class _NativeVideoViewerState extends ConsumerState with Widg ); } - final remoteId = (videoAsset as RemoteAsset).id; - - final serverEndpoint = Store.get(StoreKey.serverEndpoint); final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo; - final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; - final String videoUrl = videoAsset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' - : '$serverEndpoint/assets/$remoteId/$postfixUrl'; + final realtimeTranscoding = ref.read(serverInfoProvider).serverFeatures.realtimeTranscoding; + // Motion photo clips are short, so spinning up a transcoding session for them is wasteful + final useHls = !isOriginalVideo && videoAsset.livePhotoVideoId == null && realtimeTranscoding; + final remoteId = (videoAsset as RemoteAsset).id; + final serverEndpoint = Store.get(StoreKey.serverEndpoint); + final String videoUrl; + if (useHls) { + videoUrl = '$serverEndpoint/assets/$remoteId/video/stream/main.m3u8'; + } else { + final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback'; + videoUrl = videoAsset.livePhotoVideoId != null + ? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl' + : '$serverEndpoint/assets/$remoteId/$postfixUrl'; + } + _remoteAssetId = remoteId; return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders()); } catch (error) { @@ -209,11 +224,42 @@ class _NativeVideoViewerState extends ConsumerState with Widg _notifier.onNativeStatusChanged(); } + void _onSourceResolved() { + final url = _controller?.onPlaybackSourceResolved.value; + if (url == null) { + return; + } + + final sessionId = extractHlsSessionId(url); + if (sessionId == null || sessionId == _sessionId) { + return; + } + // If the player started a new session without us reloading, end the old one + _endSession(); + _sessionId = sessionId; + } + + void _endSession() { + final sessionId = _sessionId; + final assetId = _remoteAssetId; + _sessionId = null; + if (sessionId == null || assetId == null) { + return; + } + + unawaited( + ref.read(apiServiceProvider).assetsApi.endSession(assetId, sessionId).onError((error, stackTrace) { + _log.warning('Failed to end HLS session $sessionId for asset $assetId', error, stackTrace); + }), + ); + } + void _removeListeners() { _controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged); _controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged); _controller?.onPlaybackReady.removeListener(_onPlaybackReady); _controller?.onPlaybackEnded.removeListener(_onPlaybackEnded); + _controller?.onPlaybackSourceResolved.removeListener(_onSourceResolved); } void _loadVideo() async { @@ -244,6 +290,7 @@ class _NativeVideoViewerState extends ConsumerState with Widg nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged); nc.onPlaybackReady.addListener(_onPlaybackReady); nc.onPlaybackEnded.addListener(_onPlaybackEnded); + nc.onPlaybackSourceResolved.addListener(_onSourceResolved); _controller = nc;