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);
}