From e8d4980c718835f9c392325495b22437df9552ba Mon Sep 17 00:00:00 2001 From: David Federman Date: Thu, 22 Jan 2026 16:20:36 -0800 Subject: [PATCH] Fix bad uses of async --- src/JellyBox/App.xaml.cs | 17 +- .../Behaviors/FocusFirstItemOnLoadBehavior.cs | 16 +- src/JellyBox/MainPage.xaml.cs | 8 +- src/JellyBox/ViewModels/HomeViewModel.cs | 39 +- .../ViewModels/ItemDetailsViewModel.cs | 208 +++++----- src/JellyBox/ViewModels/LoginViewModel.cs | 66 ++-- src/JellyBox/ViewModels/MainPageViewModel.cs | 4 +- src/JellyBox/ViewModels/MoviesViewModel.cs | 6 +- src/JellyBox/ViewModels/ShowsViewModel.cs | 6 +- src/JellyBox/ViewModels/VideoViewModel.cs | 369 ++++++++++-------- src/JellyBox/Views/Home.xaml.cs | 5 +- src/JellyBox/Views/Login.xaml.cs | 4 +- src/JellyBox/Views/Video.xaml.cs | 4 +- 13 files changed, 419 insertions(+), 333 deletions(-) diff --git a/src/JellyBox/App.xaml.cs b/src/JellyBox/App.xaml.cs index e6d018f..bce1ebf 100644 --- a/src/JellyBox/App.xaml.cs +++ b/src/JellyBox/App.xaml.cs @@ -113,14 +113,27 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) _navigationManager.Initialize(rootFrame); - // TODO: Do properly async. Or defer until it's needed? - Task.Run(_deviceProfileManager.InitializeAsync); + // Initialize the device profile asynchronously. If this fails, playback features may not work correctly. + _ = InitializeDeviceProfileAsync(); // Ensure the current window is active Window.Current.Activate(); } } + private async Task InitializeDeviceProfileAsync() + { + try + { + await _deviceProfileManager.InitializeAsync(); + } + catch (Exception ex) + { + // Log initialization failure but don't crash the app - playback will handle missing profile gracefully + System.Diagnostics.Debug.WriteLine($"Failed to initialize device profile: {ex}"); + } + } + /// /// Invoked when Navigation to a certain page fails. /// diff --git a/src/JellyBox/Behaviors/FocusFirstItemOnLoadBehavior.cs b/src/JellyBox/Behaviors/FocusFirstItemOnLoadBehavior.cs index e120426..f38a43f 100644 --- a/src/JellyBox/Behaviors/FocusFirstItemOnLoadBehavior.cs +++ b/src/JellyBox/Behaviors/FocusFirstItemOnLoadBehavior.cs @@ -53,11 +53,19 @@ private void DataContextChanged(FrameworkElement sender, DataContextChangedEvent private async void CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { - // TODO: This happens *every* time an item is added. How can we wait until it's stable? - DependencyObject firstItem = AssociatedObject.ContainerFromIndex(0); - if (firstItem is not null) + try { - await FocusManager.TryFocusAsync(firstItem, FocusState.Programmatic); + // TODO: This happens *every* time an item is added. How can we wait until it's stable? + DependencyObject firstItem = AssociatedObject.ContainerFromIndex(0); + if (firstItem is not null) + { + await FocusManager.TryFocusAsync(firstItem, FocusState.Programmatic); + } + } + catch (Exception ex) + { + // Prevent app crash from async void event handler + System.Diagnostics.Debug.WriteLine($"Error in CollectionChanged: {ex}"); } } } diff --git a/src/JellyBox/MainPage.xaml.cs b/src/JellyBox/MainPage.xaml.cs index a3866b1..fc1d856 100644 --- a/src/JellyBox/MainPage.xaml.cs +++ b/src/JellyBox/MainPage.xaml.cs @@ -1,7 +1,6 @@ using JellyBox.Services; using JellyBox.ViewModels; using Microsoft.Extensions.DependencyInjection; -using Windows.UI.Core; using Windows.UI.Xaml.Controls; using Windows.UI.Xaml.Input; using Windows.UI.Xaml.Navigation; @@ -48,11 +47,10 @@ protected override void OnNavigatedTo(NavigationEventArgs e) base.OnNavigatedTo(e); } - private async void ContentFrameNavigated(object sender, NavigationEventArgs e) + private void ContentFrameNavigated(object sender, NavigationEventArgs e) { - // Update the selected item when a page navigation occurs in the body frame - await Dispatcher.RunAsync( - CoreDispatcherPriority.Normal, + _ = Dispatcher.RunAsync( + Windows.UI.Core.CoreDispatcherPriority.Normal, () => { ViewModel.IsMenuOpen = false; diff --git a/src/JellyBox/ViewModels/HomeViewModel.cs b/src/JellyBox/ViewModels/HomeViewModel.cs index ee8b10f..eecdd6c 100644 --- a/src/JellyBox/ViewModels/HomeViewModel.cs +++ b/src/JellyBox/ViewModels/HomeViewModel.cs @@ -23,27 +23,34 @@ public HomeViewModel(JellyfinApiClient jellyfinApiClient, NavigationManager navi _navigationManager = navigationManager; } - public async Task InitializeAsync() + public async void Initialize() { - Task[] sectionTasks = - [ - GetUserViewsSectionAsync(), - GetResumeSectionAsync("Continue Watching", MediaType.Video), - GetResumeSectionAsync("Continue Listening", MediaType.Audio), - GetResumeSectionAsync("Continue Reading", MediaType.Book), - // TODO: LiveTV Section, - GetNextUpSectionAsync(), - // TODO: LatestMedia Sections - ]; - - Section?[] sections = await Task.WhenAll(sectionTasks); - foreach (Section? section in sections) + try { - if (section is not null) + Task[] sectionTasks = + [ + GetUserViewsSectionAsync(), + GetResumeSectionAsync("Continue Watching", MediaType.Video), + GetResumeSectionAsync("Continue Listening", MediaType.Audio), + GetResumeSectionAsync("Continue Reading", MediaType.Book), + // TODO: LiveTV Section, + GetNextUpSectionAsync(), + // TODO: LatestMedia Sections + ]; + + Section?[] sections = await Task.WhenAll(sectionTasks); + foreach (Section? section in sections) { - Sections.Add(section); + if (section is not null) + { + Sections.Add(section); + } } } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in HomeViewModel.Initialize: {ex}"); + } } private async Task GetUserViewsSectionAsync() diff --git a/src/JellyBox/ViewModels/ItemDetailsViewModel.cs b/src/JellyBox/ViewModels/ItemDetailsViewModel.cs index c107b39..f1575ff 100644 --- a/src/JellyBox/ViewModels/ItemDetailsViewModel.cs +++ b/src/JellyBox/ViewModels/ItemDetailsViewModel.cs @@ -100,80 +100,87 @@ public ItemDetailsViewModel(JellyfinApiClient jellyfinApiClient, NavigationManag internal async void HandleParameters(ItemDetails.Parameters parameters) { - Item = await _jellyfinApiClient.Items[parameters.ItemId].GetAsync(); + try + { + Item = await _jellyfinApiClient.Items[parameters.ItemId].GetAsync(); - Name = Item!.Name; - BackdropImageUri = _jellyfinApiClient.GetItemBackdropImageUrl(Item, 1920); + Name = Item!.Name; + BackdropImageUri = _jellyfinApiClient.GetItemBackdropImageUrl(Item, 1920); - List mediaInfo = new(); - if (Item.ProductionYear.HasValue) - { - mediaInfo.Add(new MediaInfoItem(Item.ProductionYear.Value.ToString())); - } + List mediaInfo = new(); + if (Item.ProductionYear.HasValue) + { + mediaInfo.Add(new MediaInfoItem(Item.ProductionYear.Value.ToString())); + } - if (Item.RunTimeTicks.HasValue) - { - mediaInfo.Add(new MediaInfoItem(GetDisplayDuration(Item.RunTimeTicks.Value))); - } + if (Item.RunTimeTicks.HasValue) + { + mediaInfo.Add(new MediaInfoItem(GetDisplayDuration(Item.RunTimeTicks.Value))); + } - if (!string.IsNullOrEmpty(Item.OfficialRating)) - { - // TODO: Style correctly - mediaInfo.Add(new MediaInfoItem(Item.OfficialRating)); - } + if (!string.IsNullOrEmpty(Item.OfficialRating)) + { + // TODO: Style correctly + mediaInfo.Add(new MediaInfoItem(Item.OfficialRating)); + } - if (Item.CommunityRating.HasValue) - { - // TODO: Style correctly - mediaInfo.Add(new MediaInfoItem(Item.CommunityRating.Value.ToString("F1"))); - } + if (Item.CommunityRating.HasValue) + { + // TODO: Style correctly + mediaInfo.Add(new MediaInfoItem(Item.CommunityRating.Value.ToString("F1"))); + } - if (Item.CriticRating.HasValue) - { - // TODO: Style correctly - mediaInfo.Add(new MediaInfoItem(Item.CriticRating.Value.ToString())); - } + if (Item.CriticRating.HasValue) + { + // TODO: Style correctly + mediaInfo.Add(new MediaInfoItem(Item.CriticRating.Value.ToString())); + } - if (Item.RunTimeTicks.HasValue) - { - mediaInfo.Add(new MediaInfoItem(GetEndsAt(Item.RunTimeTicks.Value))); - } + if (Item.RunTimeTicks.HasValue) + { + mediaInfo.Add(new MediaInfoItem(GetEndsAt(Item.RunTimeTicks.Value))); + } - MediaInfo = new ObservableCollection(mediaInfo); + MediaInfo = new ObservableCollection(mediaInfo); - if (Item.MediaSources is not null && Item.MediaSources.Count > 0) - { - SourceContainers = new ObservableCollection(Item.MediaSources.Select(s => new MediaSourceInfoWrapper(s.Name!, s))); + if (Item.MediaSources is not null && Item.MediaSources.Count > 0) + { + SourceContainers = new ObservableCollection(Item.MediaSources.Select(s => new MediaSourceInfoWrapper(s.Name!, s))); - // This will trigger OnSelectedSourceContainerChanged, which populates the video, audio, and subtitle drop-downs. - SelectedSourceContainer = SourceContainers[0]; - } + // This will trigger OnSelectedSourceContainerChanged, which populates the video, audio, and subtitle drop-downs. + SelectedSourceContainer = SourceContainers[0]; + } - TagLine = Item.Taglines is not null && Item.Taglines.Count > 0 ? Item.Taglines[0] : null; - Overview = Item.Overview; - Tags = Item.Tags is not null ? $"Tags: {string.Join(", ", Item.Tags)}" : null; + TagLine = Item.Taglines is not null && Item.Taglines.Count > 0 ? Item.Taglines[0] : null; + Overview = Item.Overview; + Tags = Item.Tags is not null ? $"Tags: {string.Join(", ", Item.Tags)}" : null; - UpdateUserData(); + UpdateUserData(); - Task[] sectionTasks = - [ - GetNextUpSectionAsync(), - GetChildrenSectionAsync(), - // TODO: Cast & Crew --> - // TODO: More Like This --> - ]; + Task[] sectionTasks = + [ + GetNextUpSectionAsync(), + GetChildrenSectionAsync(), + // TODO: Cast & Crew --> + // TODO: More Like This --> + ]; - List
sections = new(sectionTasks.Length); - foreach (Task sectionTask in sectionTasks) - { - Section? section = await sectionTask; - if (section is not null) + List
sections = new(sectionTasks.Length); + foreach (Task sectionTask in sectionTasks) { - sections.Add(section); + Section? section = await sectionTask; + if (section is not null) + { + sections.Add(section); + } } - } - Sections = sections; + Sections = sections; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in HandleParameters: {ex}"); + } } partial void OnSelectedSourceContainerChanged(MediaSourceInfoWrapper? value) @@ -296,60 +303,81 @@ public void Play() public async void PlayTrailer() { - if (Item is null) + try { - return; - } + if (Item is null) + { + return; + } - if (Item.LocalTrailerCount > 0) - { - List? localTrailers = await _jellyfinApiClient.Items[Item.Id!.Value].LocalTrailers.GetAsync(); - if (localTrailers is not null && localTrailers.Count > 0) + if (Item.LocalTrailerCount > 0) + { + List? localTrailers = await _jellyfinApiClient.Items[Item.Id!.Value].LocalTrailers.GetAsync(); + if (localTrailers is not null && localTrailers.Count > 0) + { + // TODO play all the trailers instead of just the first? + _navigationManager.NavigateToVideo( + localTrailers[0], + mediaSourceId: null, + audioStreamIndex: null, + subtitleStreamIndex: null); + return; + } + } + + if (Item.RemoteTrailers is not null && Item.RemoteTrailers.Count > 0) { // TODO play all the trailers instead of just the first? - _navigationManager.NavigateToVideo( - localTrailers[0], - mediaSourceId: null, - audioStreamIndex: null, - subtitleStreamIndex: null); + Uri videoUri = GetWebVideoUri(Item.RemoteTrailers[0].Url!); + + _navigationManager.NavigateToWebVideo(videoUri); return; } } - - if (Item.RemoteTrailers is not null && Item.RemoteTrailers.Count > 0) + catch (Exception ex) { - // TODO play all the trailers instead of just the first? - Uri videoUri = GetWebVideoUri(Item.RemoteTrailers[0].Url!); - - _navigationManager.NavigateToWebVideo(videoUri); - return; + System.Diagnostics.Debug.WriteLine($"Error in PlayTrailer: {ex}"); } } public async void TogglePlayed() { - if (Item is null) + try { - return; - } + if (Item is null) + { + return; + } - Item.UserData = Item.UserData!.Played.GetValueOrDefault() - ? await _jellyfinApiClient.UserPlayedItems[Item.Id!.Value].DeleteAsync() - : await _jellyfinApiClient.UserPlayedItems[Item.Id!.Value].PostAsync(); - UpdateUserData(); + Item.UserData = Item.UserData!.Played.GetValueOrDefault() + ? await _jellyfinApiClient.UserPlayedItems[Item.Id!.Value].DeleteAsync() + : await _jellyfinApiClient.UserPlayedItems[Item.Id!.Value].PostAsync(); + UpdateUserData(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in TogglePlayed: {ex}"); + } } public async void ToggleFavorite() { - if (Item is null) + try { - return; - } + if (Item is null) + { + return; + } - Item.UserData = Item.UserData!.IsFavorite.GetValueOrDefault() - ? await _jellyfinApiClient.UserFavoriteItems[Item.Id!.Value].DeleteAsync() - : await _jellyfinApiClient.UserFavoriteItems[Item.Id!.Value].PostAsync(); - UpdateUserData(); + Item.UserData = Item.UserData!.IsFavorite.GetValueOrDefault() + ? await _jellyfinApiClient.UserFavoriteItems[Item.Id!.Value].DeleteAsync() + : await _jellyfinApiClient.UserFavoriteItems[Item.Id!.Value].PostAsync(); + UpdateUserData(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in ToggleFavorite: {ex}"); + } } // Return a string in '{}h {}m' format for duration. diff --git a/src/JellyBox/ViewModels/LoginViewModel.cs b/src/JellyBox/ViewModels/LoginViewModel.cs index 76f2b50..fa6411d 100644 --- a/src/JellyBox/ViewModels/LoginViewModel.cs +++ b/src/JellyBox/ViewModels/LoginViewModel.cs @@ -60,9 +60,16 @@ public void Dispose() _quickConnectPollingTimer?.Cancel(); } - public async Task InitializeAsync() + public async void Initialize() { - IsQuickConnectEnabled = (await _jellyfinApiClient.QuickConnect.Enabled.GetAsync()).GetValueOrDefault(); + try + { + IsQuickConnectEnabled = (await _jellyfinApiClient.QuickConnect.Enabled.GetAsync()).GetValueOrDefault(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in LoginViewModel.Initialize: {ex}"); + } } partial void OnUserNameChanged(string value) => SignInCommand.NotifyCanExecuteChanged(); @@ -136,33 +143,42 @@ private async Task QuickConnectAsync(CancellationToken cancellationToken) _quickConnectPollingTimer = ThreadPoolTimer.CreatePeriodicTimer(PollQuickConnectAsync, QuickConnectPollingInterval); async void PollQuickConnectAsync(ThreadPoolTimer _) { - QuickConnectResult? connectResult = await _jellyfinApiClient.QuickConnect.Connect.GetAsync( - request => - { - request.QueryParameters.Secret = initializeResult.Secret; - }, - cancellationToken); - if (connectResult is null - || !connectResult.Authenticated.GetValueOrDefault()) - { - return; - } - - QuickConnectDto quickConnect = new() + try { - Secret = initializeResult.Secret, - }; - AuthenticationResult? authenticationResult = await _jellyfinApiClient.Users.AuthenticateWithQuickConnect.PostAsync(quickConnect, cancellationToken: cancellationToken); + QuickConnectResult? connectResult = await _jellyfinApiClient.QuickConnect.Connect.GetAsync( + request => + { + request.QueryParameters.Secret = initializeResult.Secret; + }, + cancellationToken); + if (connectResult is null + || !connectResult.Authenticated.GetValueOrDefault()) + { + return; + } - await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync( - CoreDispatcherPriority.Normal, - () => + QuickConnectDto quickConnect = new() { - if (HandleAuthenticationResult(authenticationResult)) + Secret = initializeResult.Secret, + }; + AuthenticationResult? authenticationResult = await _jellyfinApiClient.Users.AuthenticateWithQuickConnect.PostAsync(quickConnect, cancellationToken: cancellationToken); + + await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync( + CoreDispatcherPriority.Normal, + () => { - quickConnectDialog.Hide(); - } - }); + if (HandleAuthenticationResult(authenticationResult)) + { + quickConnectDialog.Hide(); + } + }); + } + catch (Exception ex) + { + // Timer callbacks with async void can crash the app if exceptions propagate. + // Log and suppress to prevent app termination. + System.Diagnostics.Debug.WriteLine($"Error in PollQuickConnectAsync: {ex}"); + } } _ = await quickConnectDialog.ShowAsync(); diff --git a/src/JellyBox/ViewModels/MainPageViewModel.cs b/src/JellyBox/ViewModels/MainPageViewModel.cs index 6cf4c5b..29bc068 100644 --- a/src/JellyBox/ViewModels/MainPageViewModel.cs +++ b/src/JellyBox/ViewModels/MainPageViewModel.cs @@ -28,7 +28,7 @@ public MainPageViewModel( _jellyfinApiClient = jellyfinApiClient; _navigationManager = navigationManager; - InitializeNavigationItems(); + _ = InitializeNavigationItemsAsync(); } public void HandleParameters(MainPage.Parameters? parameters, Frame contentFrame) @@ -77,7 +77,7 @@ public void UpdateSelectedMenuItem() } } - private async void InitializeNavigationItems() + private async Task InitializeNavigationItemsAsync() { List navigationItems = new(); diff --git a/src/JellyBox/ViewModels/MoviesViewModel.cs b/src/JellyBox/ViewModels/MoviesViewModel.cs index f772f0e..7d0433c 100644 --- a/src/JellyBox/ViewModels/MoviesViewModel.cs +++ b/src/JellyBox/ViewModels/MoviesViewModel.cs @@ -24,17 +24,15 @@ public MoviesViewModel(JellyfinApiClient jellyfinApiClient, NavigationManager na { _jellyfinApiClient = jellyfinApiClient; _navigationManager = navigationManager; - - InitializeMovies(); } public void HandleParameters(Movies.Parameters parameters) { _collectionItemId = parameters.CollectionItemId; - InitializeMovies(); + _ = InitializeMoviesAsync(); } - private async void InitializeMovies() + private async Task InitializeMoviesAsync() { // Uninitialized if (_collectionItemId is null) diff --git a/src/JellyBox/ViewModels/ShowsViewModel.cs b/src/JellyBox/ViewModels/ShowsViewModel.cs index 76b2636..07d8ece 100644 --- a/src/JellyBox/ViewModels/ShowsViewModel.cs +++ b/src/JellyBox/ViewModels/ShowsViewModel.cs @@ -24,17 +24,15 @@ public ShowsViewModel(JellyfinApiClient jellyfinApiClient, NavigationManager nav { _jellyfinApiClient = jellyfinApiClient; _navigationManager = navigationManager; - - InitializeShows(); } public void HandleParameters(Shows.Parameters parameters) { _collectionItemId = parameters.CollectionItemId; - InitializeShows(); + _ = InitializeShowsAsync(); } - private async void InitializeShows() + private async Task InitializeShowsAsync() { // Uninitialized if (_collectionItemId is null) diff --git a/src/JellyBox/ViewModels/VideoViewModel.cs b/src/JellyBox/ViewModels/VideoViewModel.cs index 19c7f91..e28f2ff 100644 --- a/src/JellyBox/ViewModels/VideoViewModel.cs +++ b/src/JellyBox/ViewModels/VideoViewModel.cs @@ -47,230 +47,244 @@ public VideoViewModel( public bool ShowBackdropImage { get; set => SetProperty(ref field, value); } - public async Task PlayVideoAsync(Video.Parameters parameters, MediaPlayerElement playerElement) + public async void PlayVideo(Video.Parameters parameters, MediaPlayerElement playerElement) { - BaseItemDto item = parameters.Item; - _playerElement = playerElement; - - DeviceProfile deviceProfile = _deviceProfileManager.Profile; - - BackdropImageUri = _jellyfinApiClient.GetItemBackdropImageUrl(item, 1920); - ShowBackdropImage = true; - - // Note: This mutates the shared device profile. That's probably OK as long as all accesses do this. - // TODO: Look into making a copy instead. - deviceProfile.MaxStreamingBitrate = await DetectBitrateAsync(); - - PlaybackInfoDto playbackInfo = new() + try { - DeviceProfile = deviceProfile, - MediaSourceId = parameters.MediaSourceId, - AudioStreamIndex = parameters.AudioStreamIndex, - SubtitleStreamIndex = parameters.SubtitleStreamIndex, - }; + BaseItemDto item = parameters.Item; + _playerElement = playerElement; - // TODO: Does this create a play session? If so, update progress properly. - PlaybackInfoResponse? playbackInfoResponse = await _jellyfinApiClient.Items[item.Id!.Value].PlaybackInfo.PostAsync(playbackInfo); + DeviceProfile deviceProfile = _deviceProfileManager.Profile; - // TODO: Always the first? What if 0 or > 1? - MediaSourceInfo mediaSourceInfo = playbackInfoResponse!.MediaSources![0]; + BackdropImageUri = _jellyfinApiClient.GetItemBackdropImageUrl(item, 1920); + ShowBackdropImage = true; - _playbackProgressInfo = new PlaybackProgressInfo - { - ItemId = item.Id.Value, - MediaSourceId = mediaSourceInfo.Id, - PlaySessionId = playbackInfoResponse.PlaySessionId, - AudioStreamIndex = playbackInfo.AudioStreamIndex, - SubtitleStreamIndex = playbackInfo.SubtitleStreamIndex, - }; + // Note: This mutates the shared device profile. That's probably OK as long as all accesses do this. + // TODO: Look into making a copy instead. + deviceProfile.MaxStreamingBitrate = await DetectBitrateAsync(); - bool isAdaptive; - Uri? mediaUri; + PlaybackInfoDto playbackInfo = new() + { + DeviceProfile = deviceProfile, + MediaSourceId = parameters.MediaSourceId, + AudioStreamIndex = parameters.AudioStreamIndex, + SubtitleStreamIndex = parameters.SubtitleStreamIndex, + }; - if (mediaSourceInfo.SupportsDirectPlay.GetValueOrDefault() || mediaSourceInfo.SupportsDirectStream.GetValueOrDefault()) - { - RequestInformation request = _jellyfinApiClient.Videos[item.Id.Value].StreamWithContainer(mediaSourceInfo.Container).ToGetRequestInformation( - parameters => - { - parameters.QueryParameters.Static = true; - parameters.QueryParameters.MediaSourceId = mediaSourceInfo.Id; + // TODO: Does this create a play session? If so, update progress properly. + PlaybackInfoResponse? playbackInfoResponse = await _jellyfinApiClient.Items[item.Id!.Value].PlaybackInfo.PostAsync(playbackInfo); - // TODO Copied from AppServices. Get this in a better way, shared by the Jellyfin SDK settings initialization. - parameters.QueryParameters.DeviceId = new EasClientDeviceInformation().Id.ToString(); + // TODO: Always the first? What if 0 or > 1? + MediaSourceInfo mediaSourceInfo = playbackInfoResponse!.MediaSources![0]; - if (mediaSourceInfo.ETag is not null) - { - parameters.QueryParameters.Tag = mediaSourceInfo.ETag; - } + _playbackProgressInfo = new PlaybackProgressInfo + { + ItemId = item.Id.Value, + MediaSourceId = mediaSourceInfo.Id, + PlaySessionId = playbackInfoResponse.PlaySessionId, + AudioStreamIndex = playbackInfo.AudioStreamIndex, + SubtitleStreamIndex = playbackInfo.SubtitleStreamIndex, + }; + + bool isAdaptive; + Uri? mediaUri; - if (mediaSourceInfo.LiveStreamId is not null) + if (mediaSourceInfo.SupportsDirectPlay.GetValueOrDefault() || mediaSourceInfo.SupportsDirectStream.GetValueOrDefault()) + { + RequestInformation request = _jellyfinApiClient.Videos[item.Id.Value].StreamWithContainer(mediaSourceInfo.Container).ToGetRequestInformation( + parameters => { - parameters.QueryParameters.LiveStreamId = mediaSourceInfo.LiveStreamId; - } - }); - mediaUri = _jellyfinApiClient.BuildUri(request); + parameters.QueryParameters.Static = true; + parameters.QueryParameters.MediaSourceId = mediaSourceInfo.Id; + + // TODO Copied from AppServices. Get this in a better way, shared by the Jellyfin SDK settings initialization. + parameters.QueryParameters.DeviceId = new EasClientDeviceInformation().Id.ToString(); + + if (mediaSourceInfo.ETag is not null) + { + parameters.QueryParameters.Tag = mediaSourceInfo.ETag; + } + + if (mediaSourceInfo.LiveStreamId is not null) + { + parameters.QueryParameters.LiveStreamId = mediaSourceInfo.LiveStreamId; + } + }); + mediaUri = _jellyfinApiClient.BuildUri(request); + + // TODO: The Jellyfin SDK doesn't appear to provide a way to add this query param. + mediaUri = new Uri($"{mediaUri.AbsoluteUri}&api_key={_sdkClientSettings.AccessToken}"); + isAdaptive = false; + } + else if (mediaSourceInfo.SupportsTranscoding.GetValueOrDefault()) + { + if (!Uri.TryCreate(_sdkClientSettings.ServerUrl + mediaSourceInfo.TranscodingUrl, UriKind.Absolute, out mediaUri)) + { + // TODO: Error handling + return; + } - // TODO: The Jellyfin SDK doesn't appear to provide a way to add this query param. - mediaUri = new Uri($"{mediaUri.AbsoluteUri}&api_key={_sdkClientSettings.AccessToken}"); - isAdaptive = false; - } - else if (mediaSourceInfo.SupportsTranscoding.GetValueOrDefault()) - { - if (!Uri.TryCreate(_sdkClientSettings.ServerUrl + mediaSourceInfo.TranscodingUrl, UriKind.Absolute, out mediaUri)) + isAdaptive = mediaSourceInfo.TranscodingSubProtocol == MediaSourceInfo_TranscodingSubProtocol.Hls; + } + else { - // TODO: Error handling + // TODO: Default handling return; } - isAdaptive = mediaSourceInfo.TranscodingSubProtocol == MediaSourceInfo_TranscodingSubProtocol.Hls; - } - else - { - // TODO: Default handling - return; - } - -#pragma warning disable CA2000 // Dispose objects before losing scope. The media source is disposed in StopVideoAsync. - MediaSource mediaSource; - if (isAdaptive) - { - AdaptiveMediaSourceCreationResult result = await AdaptiveMediaSource.CreateFromUriAsync(mediaUri); - if (result.Status == AdaptiveMediaSourceCreationStatus.Success) +#pragma warning disable CA2000 // Dispose objects before losing scope. The media source is disposed in StopVideo. + MediaSource mediaSource; + if (isAdaptive) { - AdaptiveMediaSource ams = result.MediaSource; - ams.InitialBitrate = ams.AvailableBitrates.Max(); + AdaptiveMediaSourceCreationResult result = await AdaptiveMediaSource.CreateFromUriAsync(mediaUri); + if (result.Status == AdaptiveMediaSourceCreationStatus.Success) + { + AdaptiveMediaSource ams = result.MediaSource; + ams.InitialBitrate = ams.AvailableBitrates.Max(); - mediaSource = MediaSource.CreateFromAdaptiveMediaSource(ams); + mediaSource = MediaSource.CreateFromAdaptiveMediaSource(ams); + } + else + { + // Fall back to creating from the Uri directly + mediaSource = MediaSource.CreateFromUri(mediaUri); + } } else { - // Fall back to creating from the Uri directly mediaSource = MediaSource.CreateFromUri(mediaUri); } - } - else - { - mediaSource = MediaSource.CreateFromUri(mediaUri); - } #pragma warning restore CA2000 // Dispose objects before losing scope - if (mediaSourceInfo.DefaultSubtitleStreamIndex.HasValue - && mediaSourceInfo.DefaultSubtitleStreamIndex.Value != -1) - { - MediaStream subtitleTrack = mediaSourceInfo.MediaStreams![mediaSourceInfo.DefaultSubtitleStreamIndex.Value]; - if (subtitleTrack.IsExternal.GetValueOrDefault()) + if (mediaSourceInfo.DefaultSubtitleStreamIndex.HasValue + && mediaSourceInfo.DefaultSubtitleStreamIndex.Value != -1) { - // TODO: Check the subtitle format (Codec property), as some mayneed to be handled differently. - string? subtitleUrl = subtitleTrack.DeliveryUrl; - if (!subtitleTrack.IsExternalUrl.GetValueOrDefault()) + MediaStream subtitleTrack = mediaSourceInfo.MediaStreams![mediaSourceInfo.DefaultSubtitleStreamIndex.Value]; + if (subtitleTrack.IsExternal.GetValueOrDefault()) { - subtitleUrl = _sdkClientSettings.ServerUrl + subtitleUrl; - } + // TODO: Check the subtitle format (Codec property), as some mayneed to be handled differently. + string? subtitleUrl = subtitleTrack.DeliveryUrl; + if (!subtitleTrack.IsExternalUrl.GetValueOrDefault()) + { + subtitleUrl = _sdkClientSettings.ServerUrl + subtitleUrl; + } - if (Uri.TryCreate(subtitleUrl, UriKind.Absolute, out Uri? subtitleUri)) - { - TimedTextSource timedTextSource = TimedTextSource.CreateFromUri(subtitleUri); - mediaSource.ExternalTimedTextSources.Add(timedTextSource); - } - else - { - // TODO: Error handling + if (Uri.TryCreate(subtitleUrl, UriKind.Absolute, out Uri? subtitleUri)) + { + TimedTextSource timedTextSource = TimedTextSource.CreateFromUri(subtitleUri); + mediaSource.ExternalTimedTextSources.Add(timedTextSource); + } + else + { + // TODO: Error handling + } } } - } - MediaPlaybackItem playbackItem = new(mediaSource); + MediaPlaybackItem playbackItem = new(mediaSource); - // Present the first track, which is the subtitles - playbackItem.TimedMetadataTracksChanged += (sender, args) => - { - playbackItem.TimedMetadataTracks.SetPresentationMode(0, TimedMetadataTrackPresentationMode.PlatformPresented); - }; + // Present the first track, which is the subtitles + playbackItem.TimedMetadataTracksChanged += (sender, args) => + { + playbackItem.TimedMetadataTracks.SetPresentationMode(0, TimedMetadataTrackPresentationMode.PlatformPresented); + }; -#pragma warning disable CA2000 // Dispose objects before losing scope. Disposed in StopVideoAsync. - _playerElement.SetMediaPlayer(new MediaPlayer()); +#pragma warning disable CA2000 // Dispose objects before losing scope. Disposed in StopVideo. + _playerElement.SetMediaPlayer(new MediaPlayer()); #pragma warning restore CA2000 // Dispose objects before losing scope - _playerElement.MediaPlayer.Source = playbackItem; - - _playerElement.MediaPlayer.MediaEnded += async (mp, o) => - { - await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync( - CoreDispatcherPriority.Normal, - UpdatePositionTicks); - - await ReportStoppedAsync(); - }; - - _playerElement.MediaPlayer.PlaybackSession.PlaybackStateChanged += async (session, obj) => - { - if (session.PlaybackState == MediaPlaybackState.None) - { - // The calls below throw in this scenario - return; - } + _playerElement.MediaPlayer.Source = playbackItem; - if (session.PlaybackState == MediaPlaybackState.Playing && ShowBackdropImage) + _playerElement.MediaPlayer.MediaEnded += async (mp, o) => { await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync( CoreDispatcherPriority.Normal, - () => ShowBackdropImage = false); - } + UpdatePositionTicks); - _playbackProgressInfo.CanSeek = session.CanSeek; - _playbackProgressInfo.PositionTicks = session.Position.Ticks; + await ReportStoppedAsync(); + }; - if (session.PlaybackState == MediaPlaybackState.Playing) - { - _playbackProgressInfo.IsPaused = false; - } - else if (session.PlaybackState == MediaPlaybackState.Paused) + _playerElement.MediaPlayer.PlaybackSession.PlaybackStateChanged += async (session, obj) => { - _playbackProgressInfo.IsPaused = true; - } + if (session.PlaybackState == MediaPlaybackState.None) + { + // The calls below throw in this scenario + return; + } - // TODO: Only update if something actually changed? - await ReportProgressAsync(); - }; + if (session.PlaybackState == MediaPlaybackState.Playing && ShowBackdropImage) + { + await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync( + CoreDispatcherPriority.Normal, + () => ShowBackdropImage = false); + } - MediaStream videoStream = mediaSourceInfo.MediaStreams!.First(stream => stream.Type == MediaStream_Type.Video); - await DisplayModeManager.SetBestDisplayModeAsync( - (uint)videoStream.Width!.Value, - (uint)videoStream.Height!.Value, - (double)videoStream.RealFrameRate!.Value, - videoStream.VideoRangeType!.Value); + _playbackProgressInfo.CanSeek = session.CanSeek; + _playbackProgressInfo.PositionTicks = session.Position.Ticks; - _playerElement.MediaPlayer.Play(); + if (session.PlaybackState == MediaPlaybackState.Playing) + { + _playbackProgressInfo.IsPaused = false; + } + else if (session.PlaybackState == MediaPlaybackState.Paused) + { + _playbackProgressInfo.IsPaused = true; + } + + // TODO: Only update if something actually changed? + await ReportProgressAsync(); + }; + + MediaStream videoStream = mediaSourceInfo.MediaStreams!.First(stream => stream.Type == MediaStream_Type.Video); + await DisplayModeManager.SetBestDisplayModeAsync( + (uint)videoStream.Width!.Value, + (uint)videoStream.Height!.Value, + (double)videoStream.RealFrameRate!.Value, + videoStream.VideoRangeType!.Value); + + _playerElement.MediaPlayer.Play(); - await ReportStartedAsync(); + await ReportStartedAsync(); - _progressTimer.Start(); + _progressTimer.Start(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in PlayVideo: {ex}"); + } } - public async Task StopVideoAsync() + public async void StopVideo() { - _progressTimer.Stop(); + try + { + _progressTimer.Stop(); - UpdatePositionTicks(); + UpdatePositionTicks(); - MediaPlayer? player = _playerElement?.MediaPlayer; - if (player is not null) - { - player.Pause(); + MediaPlayer? player = _playerElement?.MediaPlayer; + if (player is not null) + { + player.Pause(); - MediaPlaybackItem mediaPlaybackItem = (MediaPlaybackItem)player.Source; + MediaPlaybackItem mediaPlaybackItem = (MediaPlaybackItem)player.Source; - // Detach components from each other - _playerElement!.SetMediaPlayer(null); - player.Source = null; + // Detach components from each other + _playerElement!.SetMediaPlayer(null); + player.Source = null; - // Dispose components - mediaPlaybackItem.Source.Dispose(); - player.Dispose(); - } + // Dispose components + mediaPlaybackItem.Source.Dispose(); + player.Dispose(); + } - await DisplayModeManager.SetDefaultDisplayModeAsync(); + await DisplayModeManager.SetDefaultDisplayModeAsync(); - await ReportStoppedAsync(); + await ReportStoppedAsync(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error in StopVideo: {ex}"); + } } private async Task ReportStartedAsync() @@ -326,13 +340,22 @@ private void UpdatePositionTicks() private async void TimerTick() { - UpdatePositionTicks(); + try + { + UpdatePositionTicks(); - // Only report progress when playing. - if (_playerElement is not null - && _playerElement.MediaPlayer.PlaybackSession.PlaybackState == MediaPlaybackState.Playing) + // Only report progress when playing. + if (_playerElement is not null + && _playerElement.MediaPlayer.PlaybackSession.PlaybackState == MediaPlaybackState.Playing) + { + await ReportProgressAsync(); + } + } + catch (Exception ex) { - await ReportProgressAsync(); + // Timer callbacks with async void can crash the app if exceptions propagate. + // Log and suppress to prevent app termination. + System.Diagnostics.Debug.WriteLine($"Error in TimerTick: {ex}"); } } diff --git a/src/JellyBox/Views/Home.xaml.cs b/src/JellyBox/Views/Home.xaml.cs index fc699ad..8997d8c 100644 --- a/src/JellyBox/Views/Home.xaml.cs +++ b/src/JellyBox/Views/Home.xaml.cs @@ -14,10 +14,7 @@ public Home() ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService(); } - protected override async void OnNavigatedTo(NavigationEventArgs e) - { - await ViewModel.InitializeAsync(); - } + protected override void OnNavigatedTo(NavigationEventArgs e) => ViewModel.Initialize(); public HomeViewModel ViewModel { get; } } diff --git a/src/JellyBox/Views/Login.xaml.cs b/src/JellyBox/Views/Login.xaml.cs index 57cd30b..f36b7ca 100644 --- a/src/JellyBox/Views/Login.xaml.cs +++ b/src/JellyBox/Views/Login.xaml.cs @@ -14,9 +14,9 @@ public Login() ViewModel = AppServices.Instance.ServiceProvider.GetRequiredService(); } - protected override async void OnNavigatedTo(NavigationEventArgs e) + protected override void OnNavigatedTo(NavigationEventArgs e) { - await ViewModel.InitializeAsync(); + ViewModel.Initialize(); base.OnNavigatedTo(e); } diff --git a/src/JellyBox/Views/Video.xaml.cs b/src/JellyBox/Views/Video.xaml.cs index 59c5b24..09a94b1 100644 --- a/src/JellyBox/Views/Video.xaml.cs +++ b/src/JellyBox/Views/Video.xaml.cs @@ -17,9 +17,9 @@ public Video() internal VideoViewModel ViewModel { get; } - protected override async void OnNavigatedTo(NavigationEventArgs e) => await ViewModel.PlayVideoAsync((Parameters)e.Parameter, PlayerElement); + protected override void OnNavigatedTo(NavigationEventArgs e) => ViewModel.PlayVideo((Parameters)e.Parameter, PlayerElement); - protected override async void OnNavigatingFrom(NavigatingCancelEventArgs e) => await ViewModel.StopVideoAsync(); + protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) => ViewModel.StopVideo(); internal sealed record Parameters(BaseItemDto Item, string? MediaSourceId, int? AudioStreamIndex, int? SubtitleStreamIndex); }