diff --git a/src/JellyBox/App.xaml b/src/JellyBox/App.xaml
index 45b6bf2..4e8ed65 100644
--- a/src/JellyBox/App.xaml
+++ b/src/JellyBox/App.xaml
@@ -9,6 +9,7 @@
+
diff --git a/src/JellyBox/Controls/CustomMediaTransportControls.DependencyProperties.cs b/src/JellyBox/Controls/CustomMediaTransportControls.DependencyProperties.cs
new file mode 100644
index 0000000..835a714
--- /dev/null
+++ b/src/JellyBox/Controls/CustomMediaTransportControls.DependencyProperties.cs
@@ -0,0 +1,269 @@
+using System.Windows.Input;
+using Windows.UI.Xaml;
+
+namespace JellyBox.Controls;
+
+internal sealed partial class CustomMediaTransportControls
+{
+ #region State Dependency Properties
+
+ public static readonly DependencyProperty IsPlayingProperty = DependencyProperty.Register(
+ nameof(IsPlaying), typeof(bool), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(false, OnIsPlayingChanged));
+
+ public bool IsPlaying
+ {
+ get => (bool)GetValue(IsPlayingProperty);
+ set => SetValue(IsPlayingProperty, value);
+ }
+
+ public static readonly DependencyProperty IsFavoriteProperty = DependencyProperty.Register(
+ nameof(IsFavorite), typeof(bool), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(false, OnIsFavoriteChanged));
+
+ public bool IsFavorite
+ {
+ get => (bool)GetValue(IsFavoriteProperty);
+ set => SetValue(IsFavoriteProperty, value);
+ }
+
+ public static readonly DependencyProperty VolumeProperty = DependencyProperty.Register(
+ nameof(Volume), typeof(double), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(1.0, OnVolumeChanged));
+
+ public double Volume
+ {
+ get => (double)GetValue(VolumeProperty);
+ set => SetValue(VolumeProperty, value);
+ }
+
+ public static readonly DependencyProperty IsMutedProperty = DependencyProperty.Register(
+ nameof(IsMuted), typeof(bool), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(false, OnIsMutedChanged));
+
+ public bool IsMuted
+ {
+ get => (bool)GetValue(IsMutedProperty);
+ set => SetValue(IsMutedProperty, value);
+ }
+
+ public static readonly DependencyProperty IsBufferingProperty = DependencyProperty.Register(
+ nameof(IsBuffering), typeof(bool), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(false));
+
+ public bool IsBuffering
+ {
+ get => (bool)GetValue(IsBufferingProperty);
+ set => SetValue(IsBufferingProperty, value);
+ }
+
+ public static readonly DependencyProperty EndsAtTextProperty = DependencyProperty.Register(
+ nameof(EndsAtText), typeof(string), typeof(CustomMediaTransportControls),
+ new PropertyMetadata("Ends at --:--", OnEndsAtTextChanged));
+
+ public string EndsAtText
+ {
+ get => (string)GetValue(EndsAtTextProperty);
+ set => SetValue(EndsAtTextProperty, value);
+ }
+
+ public static readonly DependencyProperty AudioTracksProperty = DependencyProperty.Register(
+ nameof(AudioTracks), typeof(IReadOnlyList), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null, OnAudioTracksChanged));
+
+ public IReadOnlyList? AudioTracks
+ {
+ get => (IReadOnlyList?)GetValue(AudioTracksProperty);
+ set => SetValue(AudioTracksProperty, value);
+ }
+
+ public static readonly DependencyProperty SelectedAudioIndexProperty = DependencyProperty.Register(
+ nameof(SelectedAudioIndex), typeof(int), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(-1, OnSelectedAudioIndexChanged));
+
+ public int SelectedAudioIndex
+ {
+ get => (int)GetValue(SelectedAudioIndexProperty);
+ set => SetValue(SelectedAudioIndexProperty, value);
+ }
+
+ public static readonly DependencyProperty SubtitleTracksProperty = DependencyProperty.Register(
+ nameof(SubtitleTracks), typeof(IReadOnlyList), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null, OnSubtitleTracksChanged));
+
+ public IReadOnlyList? SubtitleTracks
+ {
+ get => (IReadOnlyList?)GetValue(SubtitleTracksProperty);
+ set => SetValue(SubtitleTracksProperty, value);
+ }
+
+ public static readonly DependencyProperty SelectedSubtitleIndexProperty = DependencyProperty.Register(
+ nameof(SelectedSubtitleIndex), typeof(int), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(-1, OnSelectedSubtitleIndexChanged));
+
+ public int SelectedSubtitleIndex
+ {
+ get => (int)GetValue(SelectedSubtitleIndexProperty);
+ set => SetValue(SelectedSubtitleIndexProperty, value);
+ }
+
+ public static readonly DependencyProperty PlaybackSpeedProperty = DependencyProperty.Register(
+ nameof(PlaybackSpeed), typeof(double), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(1.0, OnPlaybackSpeedChanged));
+
+ public double PlaybackSpeed
+ {
+ get => (double)GetValue(PlaybackSpeedProperty);
+ set => SetValue(PlaybackSpeedProperty, value);
+ }
+
+ #endregion
+
+ #region Command Dependency Properties
+
+ public static readonly DependencyProperty PlayPauseCommandProperty = DependencyProperty.Register(
+ nameof(PlayPauseCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? PlayPauseCommand
+ {
+ get => (ICommand?)GetValue(PlayPauseCommandProperty);
+ set => SetValue(PlayPauseCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty RewindCommandProperty = DependencyProperty.Register(
+ nameof(RewindCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? RewindCommand
+ {
+ get => (ICommand?)GetValue(RewindCommandProperty);
+ set => SetValue(RewindCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty FastForwardCommandProperty = DependencyProperty.Register(
+ nameof(FastForwardCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? FastForwardCommand
+ {
+ get => (ICommand?)GetValue(FastForwardCommandProperty);
+ set => SetValue(FastForwardCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty ToggleFavoriteCommandProperty = DependencyProperty.Register(
+ nameof(ToggleFavoriteCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? ToggleFavoriteCommand
+ {
+ get => (ICommand?)GetValue(ToggleFavoriteCommandProperty);
+ set => SetValue(ToggleFavoriteCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty ToggleMuteCommandProperty = DependencyProperty.Register(
+ nameof(ToggleMuteCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? ToggleMuteCommand
+ {
+ get => (ICommand?)GetValue(ToggleMuteCommandProperty);
+ set => SetValue(ToggleMuteCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty ChangeVolumeCommandProperty = DependencyProperty.Register(
+ nameof(ChangeVolumeCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? ChangeVolumeCommand
+ {
+ get => (ICommand?)GetValue(ChangeVolumeCommandProperty);
+ set => SetValue(ChangeVolumeCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty SelectAudioTrackCommandProperty = DependencyProperty.Register(
+ nameof(SelectAudioTrackCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? SelectAudioTrackCommand
+ {
+ get => (ICommand?)GetValue(SelectAudioTrackCommandProperty);
+ set => SetValue(SelectAudioTrackCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty SelectSubtitleTrackCommandProperty = DependencyProperty.Register(
+ nameof(SelectSubtitleTrackCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? SelectSubtitleTrackCommand
+ {
+ get => (ICommand?)GetValue(SelectSubtitleTrackCommandProperty);
+ set => SetValue(SelectSubtitleTrackCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty ChangePlaybackSpeedCommandProperty = DependencyProperty.Register(
+ nameof(ChangePlaybackSpeedCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? ChangePlaybackSpeedCommand
+ {
+ get => (ICommand?)GetValue(ChangePlaybackSpeedCommandProperty);
+ set => SetValue(ChangePlaybackSpeedCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty ChangeStretchModeCommandProperty = DependencyProperty.Register(
+ nameof(ChangeStretchModeCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? ChangeStretchModeCommand
+ {
+ get => (ICommand?)GetValue(ChangeStretchModeCommandProperty);
+ set => SetValue(ChangeStretchModeCommandProperty, value);
+ }
+
+ public static readonly DependencyProperty ShowPlaybackInfoCommandProperty = DependencyProperty.Register(
+ nameof(ShowPlaybackInfoCommand), typeof(ICommand), typeof(CustomMediaTransportControls),
+ new PropertyMetadata(null));
+
+ public ICommand? ShowPlaybackInfoCommand
+ {
+ get => (ICommand?)GetValue(ShowPlaybackInfoCommandProperty);
+ set => SetValue(ShowPlaybackInfoCommandProperty, value);
+ }
+
+ #endregion
+
+ #region Property Changed Callbacks
+
+ private static void OnIsPlayingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).UpdatePlayPauseIcon();
+
+ private static void OnIsFavoriteChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).UpdateFavoriteIcon();
+
+ private static void OnVolumeChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).UpdateVolumeState();
+
+ private static void OnIsMutedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).UpdateVolumeIcon();
+
+ private static void OnEndsAtTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).UpdateEndsAtText();
+
+ private static void OnAudioTracksChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).RebuildAudioTracksFlyout();
+
+ private static void OnSelectedAudioIndexChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).UpdateAudioTracksCheckedState();
+
+ private static void OnSubtitleTracksChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).RebuildSubtitlesFlyout();
+
+ private static void OnSelectedSubtitleIndexChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).UpdateSubtitlesCheckedState();
+
+ private static void OnPlaybackSpeedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((CustomMediaTransportControls)d).UpdatePlaybackSpeedCheckedState();
+
+ #endregion
+}
diff --git a/src/JellyBox/Controls/CustomMediaTransportControls.Flyouts.cs b/src/JellyBox/Controls/CustomMediaTransportControls.Flyouts.cs
new file mode 100644
index 0000000..27e6b2a
--- /dev/null
+++ b/src/JellyBox/Controls/CustomMediaTransportControls.Flyouts.cs
@@ -0,0 +1,265 @@
+using Windows.UI.Xaml;
+using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Controls.Primitives;
+using Windows.UI.Xaml.Media;
+
+namespace JellyBox.Controls;
+
+///
+/// Represents a selectable audio or subtitle track.
+///
+internal sealed record TrackInfo(int Index, string DisplayName);
+
+internal sealed partial class CustomMediaTransportControls
+{
+ #region Flyout References
+
+ private MenuFlyout? _audioTracksFlyout;
+ private MenuFlyout? _subtitlesFlyout;
+ private MenuFlyoutSubItem? _playbackSpeedSubItem;
+ private MenuFlyoutSubItem? _aspectRatioSubItem;
+
+ #endregion
+
+ #region Audio Tracks Flyout
+
+ private void RebuildAudioTracksFlyout()
+ {
+ if (_audioTracksButton is null)
+ {
+ return;
+ }
+
+ if (AudioTracks is null || AudioTracks.Count == 0)
+ {
+ _audioTracksButton.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ _audioTracksButton.Visibility = Visibility.Visible;
+
+ _audioTracksFlyout = new() { Placement = FlyoutPlacementMode.Top };
+ foreach (TrackInfo track in AudioTracks)
+ {
+ ToggleMenuFlyoutItem item = new()
+ {
+ Text = track.DisplayName,
+ Tag = track.Index,
+ IsChecked = track.Index == SelectedAudioIndex
+ };
+ item.Click += OnAudioTrackItemClicked;
+ _audioTracksFlyout.Items.Add(item);
+ }
+
+ _audioTracksButton.Flyout = _audioTracksFlyout;
+ }
+
+ private void UpdateAudioTracksCheckedState()
+ {
+ if (_audioTracksFlyout is null)
+ {
+ return;
+ }
+
+ foreach (MenuFlyoutItemBase menuItem in _audioTracksFlyout.Items)
+ {
+ if (menuItem is ToggleMenuFlyoutItem toggleItem)
+ {
+ toggleItem.IsChecked = toggleItem.Tag is int index && index == SelectedAudioIndex;
+ }
+ }
+ }
+
+ private void OnAudioTrackItemClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleMenuFlyoutItem item && item.Tag is int index)
+ {
+ SelectAudioTrackCommand?.Execute(index);
+ }
+ }
+
+ #endregion
+
+ #region Subtitles Flyout
+
+ private void RebuildSubtitlesFlyout()
+ {
+ if (_subtitlesButton is null)
+ {
+ return;
+ }
+
+ if (SubtitleTracks is null || SubtitleTracks.Count == 0)
+ {
+ _subtitlesButton.Visibility = Visibility.Collapsed;
+ return;
+ }
+
+ _subtitlesButton.Visibility = Visibility.Visible;
+
+ _subtitlesFlyout = new() { Placement = FlyoutPlacementMode.Top };
+
+ // Add "Off" option at the top
+ ToggleMenuFlyoutItem offItem = new()
+ {
+ Text = "Off",
+ Tag = -1,
+ IsChecked = SelectedSubtitleIndex == -1
+ };
+ offItem.Click += OnSubtitleTrackItemClicked;
+ _subtitlesFlyout.Items.Add(offItem);
+
+ _subtitlesFlyout.Items.Add(new MenuFlyoutSeparator());
+
+ foreach (TrackInfo track in SubtitleTracks)
+ {
+ ToggleMenuFlyoutItem item = new()
+ {
+ Text = track.DisplayName,
+ Tag = track.Index,
+ IsChecked = track.Index == SelectedSubtitleIndex
+ };
+ item.Click += OnSubtitleTrackItemClicked;
+ _subtitlesFlyout.Items.Add(item);
+ }
+
+ _subtitlesButton.Flyout = _subtitlesFlyout;
+ }
+
+ private void UpdateSubtitlesCheckedState()
+ {
+ if (_subtitlesFlyout is null)
+ {
+ return;
+ }
+
+ foreach (MenuFlyoutItemBase menuItem in _subtitlesFlyout.Items)
+ {
+ if (menuItem is ToggleMenuFlyoutItem toggleItem)
+ {
+ toggleItem.IsChecked = toggleItem.Tag is int index && index == SelectedSubtitleIndex;
+ }
+ }
+ }
+
+ private void OnSubtitleTrackItemClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleMenuFlyoutItem item && item.Tag is int index)
+ {
+ SelectSubtitleTrackCommand?.Execute(index);
+ }
+ }
+
+ #endregion
+
+ #region Settings Flyout
+
+ private void SetupSettingsFlyout()
+ {
+ if (_settingsButton is null)
+ {
+ return;
+ }
+
+ MenuFlyout flyout = new();
+
+ // Playback Speed submenu
+ _playbackSpeedSubItem = new() { Text = "Playback Speed" };
+ double[] speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
+ foreach (double speed in speeds)
+ {
+ ToggleMenuFlyoutItem speedItem = new()
+ {
+ Text = speed == 1.0 ? "Normal" : $"{speed}x",
+ Tag = speed,
+ IsChecked = speed == PlaybackSpeed
+ };
+ speedItem.Click += OnPlaybackSpeedItemClicked;
+ _playbackSpeedSubItem.Items.Add(speedItem);
+ }
+ flyout.Items.Add(_playbackSpeedSubItem);
+
+ // Aspect Ratio submenu
+ _aspectRatioSubItem = new() { Text = "Aspect Ratio" };
+ (string Name, Stretch Stretch)[] aspects =
+ [
+ ("Fit", Stretch.Uniform),
+ ("Fill", Stretch.UniformToFill),
+ ("Stretch", Stretch.Fill),
+ ("None", Stretch.None)
+ ];
+ foreach (var (name, stretch) in aspects)
+ {
+ ToggleMenuFlyoutItem aspectItem = new()
+ {
+ Text = name,
+ Tag = stretch,
+ IsChecked = stretch == Stretch.Uniform
+ };
+ aspectItem.Click += OnAspectRatioItemClicked;
+ _aspectRatioSubItem.Items.Add(aspectItem);
+ }
+ flyout.Items.Add(_aspectRatioSubItem);
+
+ // Separator before playback info
+ flyout.Items.Add(new MenuFlyoutSeparator());
+
+ // Playback Info
+ MenuFlyoutItem infoItem = new() { Text = "Playback Info" };
+ infoItem.Click += OnPlaybackInfoClicked;
+ flyout.Items.Add(infoItem);
+
+ _settingsButton.Flyout = flyout;
+ }
+
+ private void UpdatePlaybackSpeedCheckedState()
+ {
+ if (_playbackSpeedSubItem is null)
+ {
+ return;
+ }
+
+ foreach (MenuFlyoutItemBase subMenuItem in _playbackSpeedSubItem.Items)
+ {
+ if (subMenuItem is ToggleMenuFlyoutItem toggleItem)
+ {
+ toggleItem.IsChecked = toggleItem.Tag is double itemSpeed && itemSpeed == PlaybackSpeed;
+ }
+ }
+ }
+
+ private void OnPlaybackSpeedItemClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleMenuFlyoutItem clickedItem && clickedItem.Tag is double speed)
+ {
+ ChangePlaybackSpeedCommand?.Execute(speed);
+ }
+ }
+
+ private void OnAspectRatioItemClicked(object sender, RoutedEventArgs e)
+ {
+ if (sender is ToggleMenuFlyoutItem clickedItem && clickedItem.Tag is Stretch stretch)
+ {
+ // Update checked state for all aspect items
+ if (_aspectRatioSubItem is not null)
+ {
+ foreach (MenuFlyoutItemBase subMenuItem in _aspectRatioSubItem.Items)
+ {
+ if (subMenuItem is ToggleMenuFlyoutItem toggleItem)
+ {
+ toggleItem.IsChecked = toggleItem.Tag is Stretch itemStretch && itemStretch == stretch;
+ }
+ }
+ }
+
+ ChangeStretchModeCommand?.Execute(stretch);
+ }
+ }
+
+ private void OnPlaybackInfoClicked(object sender, RoutedEventArgs e)
+ {
+ ShowPlaybackInfoCommand?.Execute(null);
+ }
+
+ #endregion
+}
diff --git a/src/JellyBox/Controls/CustomMediaTransportControls.IconUpdates.cs b/src/JellyBox/Controls/CustomMediaTransportControls.IconUpdates.cs
new file mode 100644
index 0000000..739518a
--- /dev/null
+++ b/src/JellyBox/Controls/CustomMediaTransportControls.IconUpdates.cs
@@ -0,0 +1,68 @@
+using Windows.UI.Xaml.Controls.Primitives;
+
+namespace JellyBox.Controls;
+
+internal sealed partial class CustomMediaTransportControls
+{
+ private bool _isUpdatingVolumeSlider;
+
+ private void UpdatePlayPauseIcon() => _playPauseIcon?.Glyph = IsPlaying ? Glyphs.Pause : Glyphs.Play;
+
+ private void UpdateFavoriteIcon() => _favoriteIcon?.Glyph = IsFavorite ? Glyphs.HeartFilled : Glyphs.HeartOutline;
+
+ private void UpdateEndsAtText() => _endsAtTextBlock?.Text = EndsAtText;
+
+ private void UpdateVolumeState()
+ {
+ UpdateVolumeIcon();
+ UpdateVolumeSlider();
+ }
+
+ private void UpdateVolumeIcon()
+ {
+ if (_volumeIcon is null)
+ {
+ return;
+ }
+
+ if (IsMuted || Volume == 0)
+ {
+ _volumeIcon.Glyph = Glyphs.VolumeMute;
+ }
+ else if (Volume < 0.33)
+ {
+ _volumeIcon.Glyph = Glyphs.VolumeLow;
+ }
+ else if (Volume < 0.66)
+ {
+ _volumeIcon.Glyph = Glyphs.VolumeMedium;
+ }
+ else
+ {
+ _volumeIcon.Glyph = Glyphs.VolumeHigh;
+ }
+ }
+
+ private void UpdateVolumeSlider()
+ {
+ if (_volumeSlider is null)
+ {
+ return;
+ }
+
+ _isUpdatingVolumeSlider = true;
+ _volumeSlider.Value = Volume * 100;
+ _isUpdatingVolumeSlider = false;
+ }
+
+ private void OnVolumeSliderChanged(object sender, RangeBaseValueChangedEventArgs e)
+ {
+ if (_isUpdatingVolumeSlider)
+ {
+ return;
+ }
+
+ double newVolume = e.NewValue / 100.0;
+ ChangeVolumeCommand?.Execute(newVolume);
+ }
+}
diff --git a/src/JellyBox/Controls/CustomMediaTransportControls.cs b/src/JellyBox/Controls/CustomMediaTransportControls.cs
new file mode 100644
index 0000000..d688e34
--- /dev/null
+++ b/src/JellyBox/Controls/CustomMediaTransportControls.cs
@@ -0,0 +1,77 @@
+using Windows.UI.Xaml.Controls;
+
+namespace JellyBox.Controls;
+
+///
+/// Custom media transport controls with Jellyfin-specific features.
+///
+///
+/// This class is split across multiple partial class files:
+/// - CustomMediaTransportControls.cs (this file) - Constructor and template setup
+/// - CustomMediaTransportControls.DependencyProperties.cs - State and command dependency properties
+/// - CustomMediaTransportControls.IconUpdates.cs - Icon update methods and volume slider sync
+/// - CustomMediaTransportControls.Flyouts.cs - Audio, subtitle, and settings flyout management
+///
+internal sealed partial class CustomMediaTransportControls : MediaTransportControls
+{
+ #region Template Part Names
+ private const string PlayPauseIconName = "CustomPlayPauseIcon";
+ private const string VolumeSliderName = "CustomVolumeSlider";
+ private const string VolumeIconName = "CustomVolumeIcon";
+ private const string FavoriteIconName = "FavoriteIcon";
+ private const string AudioTracksButtonName = "CustomAudioTracksButton";
+ private const string SubtitlesButtonName = "CustomSubtitlesButton";
+ private const string SettingsButtonName = "SettingsButton";
+ private const string EndsAtTextBlockName = "EndsAtTextBlock";
+ #endregion
+
+ #region Template Part References
+ private FontIcon? _playPauseIcon;
+ private Slider? _volumeSlider;
+ private FontIcon? _volumeIcon;
+ private FontIcon? _favoriteIcon;
+ private Button? _audioTracksButton;
+ private Button? _subtitlesButton;
+ private Button? _settingsButton;
+ private TextBlock? _endsAtTextBlock;
+ #endregion
+
+ public CustomMediaTransportControls()
+ {
+ DefaultStyleKey = typeof(CustomMediaTransportControls);
+ }
+
+ protected override void OnApplyTemplate()
+ {
+ base.OnApplyTemplate();
+
+ // Get references to template parts
+ _playPauseIcon = GetTemplateChild(PlayPauseIconName) as FontIcon;
+ _volumeSlider = GetTemplateChild(VolumeSliderName) as Slider;
+ _volumeIcon = GetTemplateChild(VolumeIconName) as FontIcon;
+ _favoriteIcon = GetTemplateChild(FavoriteIconName) as FontIcon;
+ _audioTracksButton = GetTemplateChild(AudioTracksButtonName) as Button;
+ _subtitlesButton = GetTemplateChild(SubtitlesButtonName) as Button;
+ _settingsButton = GetTemplateChild(SettingsButtonName) as Button;
+ _endsAtTextBlock = GetTemplateChild(EndsAtTextBlockName) as TextBlock;
+
+ // Wire up volume slider (two-way sync)
+ if (_volumeSlider != null)
+ {
+ _volumeSlider.ValueChanged -= OnVolumeSliderChanged;
+ _volumeSlider.ValueChanged += OnVolumeSliderChanged;
+ _volumeSlider.Value = Volume * 100;
+ }
+
+ // Set up flyouts
+ RebuildAudioTracksFlyout();
+ RebuildSubtitlesFlyout();
+ SetupSettingsFlyout();
+
+ // Initialize icon states
+ UpdatePlayPauseIcon();
+ UpdateVolumeIcon();
+ UpdateFavoriteIcon();
+ UpdateEndsAtText();
+ }
+}
diff --git a/src/JellyBox/Glyphs.cs b/src/JellyBox/Glyphs.cs
new file mode 100644
index 0000000..82d6ed3
--- /dev/null
+++ b/src/JellyBox/Glyphs.cs
@@ -0,0 +1,21 @@
+namespace JellyBox;
+
+///
+/// Segoe MDL2 Assets font glyphs used throughout the application.
+///
+internal static class Glyphs
+{
+ // Playback
+ public const string Play = "\uE768";
+ public const string Pause = "\uE769";
+
+ // Volume
+ public const string VolumeMute = "\uE74F";
+ public const string VolumeLow = "\uE993";
+ public const string VolumeMedium = "\uE994";
+ public const string VolumeHigh = "\uE767";
+
+ // Favorites
+ public const string HeartOutline = "\uEB51";
+ public const string HeartFilled = "\uEB52";
+}
diff --git a/src/JellyBox/Resources/TransportControlsStyles.xaml b/src/JellyBox/Resources/TransportControlsStyles.xaml
new file mode 100644
index 0000000..1da4b76
--- /dev/null
+++ b/src/JellyBox/Resources/TransportControlsStyles.xaml
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/JellyBox/Resources/TransportControlsStyles.xaml.cs b/src/JellyBox/Resources/TransportControlsStyles.xaml.cs
new file mode 100644
index 0000000..6dae275
--- /dev/null
+++ b/src/JellyBox/Resources/TransportControlsStyles.xaml.cs
@@ -0,0 +1,12 @@
+namespace JellyBox.Resources;
+
+///
+/// Code-behind for TransportControlsStyles.xaml resource dictionary.
+///
+internal sealed partial class TransportControlsStyles
+{
+ public TransportControlsStyles()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/JellyBox/ViewModels/VideoViewModel.cs b/src/JellyBox/ViewModels/VideoViewModel.cs
index e28f2ff..07ccdd0 100644
--- a/src/JellyBox/ViewModels/VideoViewModel.cs
+++ b/src/JellyBox/ViewModels/VideoViewModel.cs
@@ -1,5 +1,8 @@
using System.Diagnostics;
+using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using JellyBox.Controls;
using JellyBox.Services;
using JellyBox.Views;
using Jellyfin.Sdk;
@@ -13,6 +16,7 @@
using Windows.UI.Core;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Media;
namespace JellyBox.ViewModels;
@@ -25,7 +29,11 @@ internal sealed partial class VideoViewModel : ObservableObject
private readonly DeviceProfileManager _deviceProfileManager;
private readonly DispatcherTimer _progressTimer;
private MediaPlayerElement? _playerElement;
+ private CustomMediaTransportControls? _transportControls;
private PlaybackProgressInfo? _playbackProgressInfo;
+ private BaseItemDto? _currentItem;
+ private MediaSourceInfo? _currentMediaSource;
+ private double _volumeBeforeMute = 1.0;
public VideoViewModel(
JellyfinApiClient jellyfinApiClient,
@@ -47,13 +55,204 @@ public VideoViewModel(
public bool ShowBackdropImage { get; set => SetProperty(ref field, value); }
- public async void PlayVideo(Video.Parameters parameters, MediaPlayerElement playerElement)
+ private static readonly TimeSpan RewindInterval = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan FastForwardInterval = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Rewind playback by 10 seconds.
+ ///
+ [RelayCommand]
+ public void Rewind()
+ {
+ if (_playerElement?.MediaPlayer?.PlaybackSession is null)
+ {
+ return;
+ }
+
+ TimeSpan newPosition = _playerElement.MediaPlayer.PlaybackSession.Position - RewindInterval;
+ if (newPosition < TimeSpan.Zero)
+ {
+ newPosition = TimeSpan.Zero;
+ }
+
+ _playerElement.MediaPlayer.PlaybackSession.Position = newPosition;
+ UpdateEndsAtText();
+ }
+
+ ///
+ /// Fast forward playback by 30 seconds.
+ ///
+ [RelayCommand]
+ public void FastForward()
+ {
+ if (_playerElement?.MediaPlayer?.PlaybackSession is null)
+ {
+ return;
+ }
+
+ TimeSpan duration = _playerElement.MediaPlayer.PlaybackSession.NaturalDuration;
+ TimeSpan newPosition = _playerElement.MediaPlayer.PlaybackSession.Position + FastForwardInterval;
+
+ if (newPosition > duration)
+ {
+ newPosition = duration;
+ }
+
+ _playerElement.MediaPlayer.PlaybackSession.Position = newPosition;
+ UpdateEndsAtText();
+ }
+
+ ///
+ /// Toggle play/pause.
+ ///
+ [RelayCommand]
+ public void TogglePlayPause()
+ {
+ if (_playerElement?.MediaPlayer is null)
+ {
+ return;
+ }
+
+ if (_playerElement.MediaPlayer.PlaybackSession.PlaybackState == MediaPlaybackState.Playing)
+ {
+ _playerElement.MediaPlayer.Pause();
+ }
+ else
+ {
+ _playerElement.MediaPlayer.Play();
+ }
+ }
+
+ ///
+ /// Toggle favorite status for current item.
+ ///
+ [RelayCommand]
+ public async Task ToggleFavoriteAsync()
+ {
+ try
+ {
+ if (_currentItem is null)
+ {
+ return;
+ }
+
+ bool wasFavorite = _currentItem.UserData?.IsFavorite ?? false;
+ _currentItem.UserData = wasFavorite
+ ? await _jellyfinApiClient.UserFavoriteItems[_currentItem.Id!.Value].DeleteAsync()
+ : await _jellyfinApiClient.UserFavoriteItems[_currentItem.Id!.Value].PostAsync();
+
+ bool isFavorite = _currentItem.UserData?.IsFavorite ?? false;
+ _transportControls?.IsFavorite = isFavorite;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Error in ToggleFavoriteAsync: {ex}");
+ }
+ }
+
+ ///
+ /// Toggle mute on/off.
+ ///
+ [RelayCommand]
+ public void ToggleMute()
+ {
+ if (_playerElement?.MediaPlayer is null)
+ {
+ return;
+ }
+
+ if (_playerElement.MediaPlayer.IsMuted)
+ {
+ // Unmute - restore previous volume
+ _playerElement.MediaPlayer.IsMuted = false;
+ _playerElement.MediaPlayer.Volume = _volumeBeforeMute;
+ }
+ else
+ {
+ // Mute - save current volume
+ _volumeBeforeMute = _playerElement.MediaPlayer.Volume;
+ _playerElement.MediaPlayer.IsMuted = true;
+ }
+
+ UpdateTransportControlsVolumeState();
+ }
+
+ ///
+ /// Change volume to a specific value.
+ ///
+ [RelayCommand]
+ public void ChangeVolume(double volume)
+ {
+ if (_playerElement?.MediaPlayer is null)
+ {
+ return;
+ }
+
+ _playerElement.MediaPlayer.Volume = Math.Clamp(volume, 0.0, 1.0);
+ _playerElement.MediaPlayer.IsMuted = false;
+ UpdateTransportControlsVolumeState();
+ }
+
+ private void UpdateTransportControlsVolumeState()
+ {
+ if (_transportControls is null || _playerElement?.MediaPlayer is null)
+ {
+ return;
+ }
+
+ _transportControls.Volume = _playerElement.MediaPlayer.Volume;
+ _transportControls.IsMuted = _playerElement.MediaPlayer.IsMuted;
+ }
+
+ ///
+ /// Adjust volume by a delta (-1.0 to 1.0).
+ ///
+ public void AdjustVolume(double delta)
+ {
+ if (_playerElement?.MediaPlayer is null)
+ {
+ return;
+ }
+
+ double newVolume = _playerElement.MediaPlayer.Volume + delta;
+ ChangeVolume(newVolume);
+ }
+
+ public async void PlayVideo(Video.Parameters parameters, MediaPlayerElement playerElement, CustomMediaTransportControls transportControls)
{
try
{
- BaseItemDto item = parameters.Item;
+ _transportControls = transportControls;
+ _currentItem = parameters.Item;
+ BaseItemDto item = _currentItem;
_playerElement = playerElement;
+ // Bind commands to transport controls
+ _transportControls.PlayPauseCommand = TogglePlayPauseCommand;
+ _transportControls.RewindCommand = RewindCommand;
+ _transportControls.FastForwardCommand = FastForwardCommand;
+ _transportControls.ToggleFavoriteCommand = ToggleFavoriteCommand;
+ _transportControls.ToggleMuteCommand = ToggleMuteCommand;
+ _transportControls.ChangeVolumeCommand = ChangeVolumeCommand;
+ _transportControls.SelectAudioTrackCommand = SelectAudioTrackCommand;
+ _transportControls.SelectSubtitleTrackCommand = SelectSubtitleTrackCommand;
+ _transportControls.ChangePlaybackSpeedCommand = ChangePlaybackSpeedCommand;
+ _transportControls.ChangeStretchModeCommand = ChangeStretchModeCommand;
+ _transportControls.ShowPlaybackInfoCommand = ShowPlaybackInfoCommand;
+
+ // Initialize state
+ _transportControls.IsFavorite = item.UserData?.IsFavorite ?? false;
+
+ // Calculate initial "Ends at" from metadata
+ // TODO: Once resume functionality is implemented, use the actual start position
+ // (e.g., item.UserData?.PlaybackPositionTicks) instead of assuming start from beginning.
+ if (item.RunTimeTicks.HasValue)
+ {
+ TimeSpan remaining = TimeSpan.FromTicks(item.RunTimeTicks.Value);
+ DateTime endTime = DateTime.Now + remaining;
+ _transportControls.EndsAtText = $"Ends at {endTime:t}";
+ }
+
DeviceProfile deviceProfile = _deviceProfileManager.Profile;
BackdropImageUri = _jellyfinApiClient.GetItemBackdropImageUrl(item, 1920);
@@ -76,6 +275,7 @@ public async void PlayVideo(Video.Parameters parameters, MediaPlayerElement play
// TODO: Always the first? What if 0 or > 1?
MediaSourceInfo mediaSourceInfo = playbackInfoResponse!.MediaSources![0];
+ _currentMediaSource = mediaSourceInfo;
_playbackProgressInfo = new PlaybackProgressInfo
{
@@ -86,6 +286,9 @@ public async void PlayVideo(Video.Parameters parameters, MediaPlayerElement play
SubtitleStreamIndex = playbackInfo.SubtitleStreamIndex,
};
+ // Populate audio and subtitle track lists
+ PopulateTrackLists(mediaSourceInfo, playbackInfo.AudioStreamIndex, playbackInfo.SubtitleStreamIndex);
+
bool isAdaptive;
Uri? mediaUri;
@@ -132,7 +335,7 @@ public async void PlayVideo(Video.Parameters parameters, MediaPlayerElement play
return;
}
-#pragma warning disable CA2000 // Dispose objects before losing scope. The media source is disposed in StopVideo.
+#pragma warning disable CA2000 // Dispose objects before losing scope. The media source is disposed in StopVideoAsync.
MediaSource mediaSource;
if (isAdaptive)
{
@@ -189,11 +392,14 @@ public async void PlayVideo(Video.Parameters parameters, MediaPlayerElement play
playbackItem.TimedMetadataTracks.SetPresentationMode(0, TimedMetadataTrackPresentationMode.PlatformPresented);
};
-#pragma warning disable CA2000 // Dispose objects before losing scope. Disposed in StopVideo.
+#pragma warning disable CA2000 // Dispose objects before losing scope. Disposed in StopVideoAsync.
_playerElement.SetMediaPlayer(new MediaPlayer());
#pragma warning restore CA2000 // Dispose objects before losing scope
_playerElement.MediaPlayer.Source = playbackItem;
+ // Initialize volume state on transport controls
+ UpdateTransportControlsVolumeState();
+
_playerElement.MediaPlayer.MediaEnded += async (mp, o) =>
{
await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
@@ -221,14 +427,25 @@ await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
_playbackProgressInfo.CanSeek = session.CanSeek;
_playbackProgressInfo.PositionTicks = session.Position.Ticks;
- if (session.PlaybackState == MediaPlaybackState.Playing)
- {
- _playbackProgressInfo.IsPaused = false;
- }
- else if (session.PlaybackState == MediaPlaybackState.Paused)
- {
- _playbackProgressInfo.IsPaused = true;
- }
+ await CoreApplication.MainView.CoreWindow.Dispatcher.RunAsync(
+ CoreDispatcherPriority.Normal,
+ () =>
+ {
+ // Update buffering state
+ _transportControls.IsBuffering = session.PlaybackState == MediaPlaybackState.Buffering;
+
+ if (session.PlaybackState == MediaPlaybackState.Playing)
+ {
+ _playbackProgressInfo.IsPaused = false;
+ _transportControls.IsPlaying = true;
+ UpdateEndsAtText();
+ }
+ else if (session.PlaybackState == MediaPlaybackState.Paused)
+ {
+ _playbackProgressInfo.IsPaused = true;
+ _transportControls.IsPlaying = false;
+ }
+ });
// TODO: Only update if something actually changed?
await ReportProgressAsync();
@@ -249,7 +466,7 @@ await DisplayModeManager.SetBestDisplayModeAsync(
}
catch (Exception ex)
{
- System.Diagnostics.Debug.WriteLine($"Error in PlayVideo: {ex}");
+ Debug.WriteLine($"Error in PlayVideo: {ex}");
}
}
@@ -277,13 +494,32 @@ public async void StopVideo()
player.Dispose();
}
+ // Clear command bindings
+ if (_transportControls != null)
+ {
+ _transportControls.PlayPauseCommand = null;
+ _transportControls.RewindCommand = null;
+ _transportControls.FastForwardCommand = null;
+ _transportControls.ToggleFavoriteCommand = null;
+ _transportControls.ToggleMuteCommand = null;
+ _transportControls.ChangeVolumeCommand = null;
+ _transportControls.SelectAudioTrackCommand = null;
+ _transportControls.SelectSubtitleTrackCommand = null;
+ _transportControls.ChangePlaybackSpeedCommand = null;
+ _transportControls.ChangeStretchModeCommand = null;
+ _transportControls.ShowPlaybackInfoCommand = null;
+ }
+
+ _currentItem = null;
+ _currentMediaSource = null;
+
await DisplayModeManager.SetDefaultDisplayModeAsync();
await ReportStoppedAsync();
}
catch (Exception ex)
{
- System.Diagnostics.Debug.WriteLine($"Error in StopVideo: {ex}");
+ Debug.WriteLine($"Error in StopVideo: {ex}");
}
}
@@ -338,6 +574,427 @@ private void UpdatePositionTicks()
_playbackProgressInfo.PositionTicks = currentTicks;
}
+ private void PopulateTrackLists(MediaSourceInfo mediaSourceInfo, int? selectedAudioIndex, int? selectedSubtitleIndex)
+ {
+ if (mediaSourceInfo.MediaStreams is null || _transportControls is null)
+ {
+ return;
+ }
+
+ // Audio tracks
+ List audioTracks = [];
+ int defaultAudioIndex = selectedAudioIndex ?? mediaSourceInfo.DefaultAudioStreamIndex ?? -1;
+ foreach (MediaStream stream in mediaSourceInfo.MediaStreams.Where(s => s.Type == MediaStream_Type.Audio))
+ {
+ string displayName = stream.DisplayTitle ?? stream.Language ?? $"Track {stream.Index}";
+ audioTracks.Add(new TrackInfo(stream.Index!.Value, displayName));
+ }
+ _transportControls.AudioTracks = audioTracks;
+ _transportControls.SelectedAudioIndex = defaultAudioIndex;
+
+ // Subtitle tracks
+ List subtitleTracks = [];
+ int defaultSubtitleIndex = selectedSubtitleIndex ?? mediaSourceInfo.DefaultSubtitleStreamIndex ?? -1;
+ foreach (MediaStream stream in mediaSourceInfo.MediaStreams.Where(s => s.Type == MediaStream_Type.Subtitle))
+ {
+ string displayName = stream.DisplayTitle ?? stream.Language ?? $"Track {stream.Index}";
+ subtitleTracks.Add(new TrackInfo(stream.Index!.Value, displayName));
+ }
+ _transportControls.SubtitleTracks = subtitleTracks;
+ _transportControls.SelectedSubtitleIndex = defaultSubtitleIndex;
+ }
+
+ [RelayCommand]
+ private void SelectAudioTrack(int trackIndex)
+ {
+ _playbackProgressInfo?.AudioStreamIndex = trackIndex;
+ _transportControls?.SelectedAudioIndex = trackIndex;
+
+ // Restart playback with the new audio track
+ RestartPlaybackWithCurrentPosition();
+ }
+
+ [RelayCommand]
+ private void SelectSubtitleTrack(int trackIndex)
+ {
+ // -1 means "off", otherwise use the track index
+ // Note: We store -1 directly rather than null so the API explicitly disables subtitles
+ _playbackProgressInfo?.SubtitleStreamIndex = trackIndex;
+ _transportControls?.SelectedSubtitleIndex = trackIndex;
+
+ // Restart playback with the new subtitle track
+ RestartPlaybackWithCurrentPosition();
+ }
+
+ private async void RestartPlaybackWithCurrentPosition()
+ {
+ if (_playerElement?.MediaPlayer is null || _currentItem is null || _currentMediaSource is null)
+ {
+ return;
+ }
+
+ try
+ {
+ // Save current position
+ TimeSpan currentPosition = _playerElement.MediaPlayer.PlaybackSession.Position;
+
+ // Get new playback info with updated track selections
+ DeviceProfile deviceProfile = _deviceProfileManager.Profile;
+ deviceProfile.MaxStreamingBitrate = await DetectBitrateAsync();
+
+ PlaybackInfoDto playbackInfo = new()
+ {
+ DeviceProfile = deviceProfile,
+ MediaSourceId = _currentMediaSource.Id,
+ AudioStreamIndex = _playbackProgressInfo?.AudioStreamIndex,
+ SubtitleStreamIndex = _playbackProgressInfo?.SubtitleStreamIndex,
+ };
+
+ PlaybackInfoResponse? playbackInfoResponse = await _jellyfinApiClient.Items[_currentItem.Id!.Value].PlaybackInfo.PostAsync(playbackInfo);
+ if (playbackInfoResponse?.MediaSources is null || playbackInfoResponse.MediaSources.Count == 0)
+ {
+ return;
+ }
+
+ MediaSourceInfo mediaSourceInfo = playbackInfoResponse.MediaSources[0];
+ _currentMediaSource = mediaSourceInfo;
+
+ // Update play session
+ _playbackProgressInfo?.PlaySessionId = playbackInfoResponse.PlaySessionId;
+
+ // Build new URI
+ Uri? mediaUri = BuildMediaUri(mediaSourceInfo);
+ if (mediaUri is null)
+ {
+ return;
+ }
+
+ bool isAdaptive = !mediaSourceInfo.SupportsDirectPlay.GetValueOrDefault()
+ && !mediaSourceInfo.SupportsDirectStream.GetValueOrDefault()
+ && mediaSourceInfo.TranscodingSubProtocol == MediaSourceInfo_TranscodingSubProtocol.Hls;
+
+ // Create new media source
+#pragma warning disable CA2000 // Dispose objects before losing scope. The media source is assigned to the player and disposed on track change or stop.
+ MediaSource mediaSource;
+ if (isAdaptive)
+ {
+ AdaptiveMediaSourceCreationResult result = await AdaptiveMediaSource.CreateFromUriAsync(mediaUri);
+ if (result.Status == AdaptiveMediaSourceCreationStatus.Success)
+ {
+ AdaptiveMediaSource ams = result.MediaSource;
+ ams.InitialBitrate = ams.AvailableBitrates.Max();
+ mediaSource = MediaSource.CreateFromAdaptiveMediaSource(ams);
+ }
+ else
+ {
+ mediaSource = MediaSource.CreateFromUri(mediaUri);
+ }
+ }
+ else
+ {
+ mediaSource = MediaSource.CreateFromUri(mediaUri);
+ }
+#pragma warning restore CA2000 // Dispose objects before losing scope
+
+ // Dispose old media source
+ if (_playerElement.Source is MediaPlaybackItem oldItem)
+ {
+ oldItem.Source?.Dispose();
+ }
+ else if (_playerElement.Source is MediaSource oldSource)
+ {
+ oldSource.Dispose();
+ }
+
+ // Start playback at the saved position
+ MediaPlaybackItem playbackItem = new(mediaSource);
+ _playerElement.Source = playbackItem;
+ _playerElement.MediaPlayer.Play();
+
+ // Seek to saved position after a short delay to let playback start
+ await Task.Delay(500);
+ if (_playerElement.MediaPlayer.PlaybackSession.CanSeek)
+ {
+ _playerElement.MediaPlayer.PlaybackSession.Position = currentPosition;
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Error restarting playback: {ex}");
+ }
+ }
+
+ private Uri? BuildMediaUri(MediaSourceInfo mediaSourceInfo)
+ {
+ if (mediaSourceInfo.SupportsDirectPlay.GetValueOrDefault() || mediaSourceInfo.SupportsDirectStream.GetValueOrDefault())
+ {
+ RequestInformation request = _jellyfinApiClient.Videos[_currentItem!.Id!.Value].StreamWithContainer(mediaSourceInfo.Container).ToGetRequestInformation(
+ parameters =>
+ {
+ parameters.QueryParameters.Static = true;
+ parameters.QueryParameters.MediaSourceId = mediaSourceInfo.Id;
+ 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;
+ }
+ });
+ Uri mediaUri = _jellyfinApiClient.BuildUri(request);
+ return new Uri($"{mediaUri.AbsoluteUri}&api_key={_sdkClientSettings.AccessToken}");
+ }
+ else if (mediaSourceInfo.SupportsTranscoding.GetValueOrDefault() && !string.IsNullOrEmpty(mediaSourceInfo.TranscodingUrl))
+ {
+ if (Uri.TryCreate(_sdkClientSettings.ServerUrl + mediaSourceInfo.TranscodingUrl, UriKind.Absolute, out Uri? mediaUri))
+ {
+ return mediaUri;
+ }
+ }
+
+ return null;
+ }
+
+ [RelayCommand]
+ private void ChangeStretchMode(Stretch stretch)
+ {
+ _playerElement?.Stretch = stretch;
+ }
+
+ [RelayCommand]
+ private void ChangePlaybackSpeed(double speed)
+ {
+ _playerElement?.MediaPlayer?.PlaybackSession?.PlaybackRate = speed;
+ _transportControls?.PlaybackSpeed = speed;
+
+ UpdateEndsAtText();
+ }
+
+ [RelayCommand]
+ private async Task ShowPlaybackInfoAsync()
+ {
+ try
+ {
+ if (_currentMediaSource is null || _currentItem is null)
+ {
+ return;
+ }
+
+ // Build playback info text
+ StringBuilder info = new();
+
+ info.AppendLine($"Title: {_currentItem.Name}");
+ info.AppendLine();
+
+ // Media source info
+ info.AppendLine("Media Source:");
+ if (!string.IsNullOrEmpty(_currentMediaSource.Container))
+ {
+ info.AppendLine($" Container: {_currentMediaSource.Container.ToUpperInvariant()}");
+ }
+ if (_currentMediaSource.Size.HasValue)
+ {
+ info.AppendLine($" Size: {FormatFileSize(_currentMediaSource.Size.Value)}");
+ }
+ if (_currentMediaSource.Bitrate.HasValue)
+ {
+ info.AppendLine($" Bitrate: {FormatBitrate(_currentMediaSource.Bitrate.Value)}");
+ }
+ info.AppendLine();
+
+ // Video stream info
+ MediaStream? videoStream = _currentMediaSource.MediaStreams?
+ .FirstOrDefault(s => s.Type == MediaStream_Type.Video);
+ if (videoStream != null)
+ {
+ info.AppendLine("Video:");
+ string videoCodec = videoStream.Codec?.ToUpperInvariant() ?? "Unknown";
+ if (!string.IsNullOrEmpty(videoStream.Profile))
+ {
+ videoCodec += $" {videoStream.Profile}";
+ }
+ info.AppendLine($" Codec: {videoCodec}");
+ info.AppendLine($" Resolution: {videoStream.Width}x{videoStream.Height}");
+ if (videoStream.RealFrameRate.HasValue)
+ {
+ info.AppendLine($" Frame Rate: {videoStream.RealFrameRate:F2} fps");
+ }
+ if (videoStream.BitRate.HasValue)
+ {
+ info.AppendLine($" Bitrate: {FormatBitrate(videoStream.BitRate.Value)}");
+ }
+ if (videoStream.VideoRangeType.HasValue)
+ {
+ string rangeDisplay = videoStream.VideoDoViTitle ?? videoStream.VideoRangeType.Value.ToString();
+ info.AppendLine($" Range: {rangeDisplay}");
+ }
+ if (!string.IsNullOrEmpty(videoStream.PixelFormat))
+ {
+ info.AppendLine($" Pixel Format: {videoStream.PixelFormat}");
+ }
+ info.AppendLine();
+ }
+
+ // Audio stream info
+ int audioIndex = _playbackProgressInfo?.AudioStreamIndex ?? _currentMediaSource.DefaultAudioStreamIndex ?? -1;
+ MediaStream? audioStream = _currentMediaSource.MediaStreams?
+ .FirstOrDefault(s => s.Type == MediaStream_Type.Audio && s.Index == audioIndex);
+ if (audioStream != null)
+ {
+ info.AppendLine("Audio:");
+ string audioCodec = audioStream.Codec?.ToUpperInvariant() ?? "Unknown";
+ if (!string.IsNullOrEmpty(audioStream.Profile))
+ {
+ audioCodec += $" {audioStream.Profile}";
+ }
+ info.AppendLine($" Codec: {audioCodec}");
+ if (audioStream.Channels.HasValue)
+ {
+ info.AppendLine($" Channels: {audioStream.Channels}");
+ }
+ if (audioStream.BitRate.HasValue)
+ {
+ info.AppendLine($" Bitrate: {FormatBitrate(audioStream.BitRate.Value)}");
+ }
+ if (audioStream.SampleRate.HasValue)
+ {
+ info.AppendLine($" Sample Rate: {audioStream.SampleRate} Hz");
+ }
+ if (audioStream.BitDepth.HasValue)
+ {
+ info.AppendLine($" Bit Depth: {audioStream.BitDepth}");
+ }
+ if (!string.IsNullOrEmpty(audioStream.Language))
+ {
+ info.AppendLine($" Language: {audioStream.Language}");
+ }
+ info.AppendLine();
+ }
+
+ // Subtitle stream info
+ int subtitleIndex = _playbackProgressInfo?.SubtitleStreamIndex ?? _currentMediaSource.DefaultSubtitleStreamIndex ?? -1;
+ if (subtitleIndex >= 0)
+ {
+ MediaStream? subtitleStream = _currentMediaSource.MediaStreams?
+ .FirstOrDefault(s => s.Type == MediaStream_Type.Subtitle && s.Index == subtitleIndex);
+ if (subtitleStream != null)
+ {
+ info.AppendLine("Subtitles:");
+ info.AppendLine($" Format: {subtitleStream.Codec?.ToUpperInvariant()}");
+ if (!string.IsNullOrEmpty(subtitleStream.Language))
+ {
+ info.AppendLine($" Language: {subtitleStream.Language}");
+ }
+ info.AppendLine($" Delivery: {(subtitleStream.IsExternal == true ? "External" : "Embedded")}");
+ info.AppendLine();
+ }
+ }
+
+ // Playback method
+ info.AppendLine("Playback:");
+ string playMethod;
+ if (_currentMediaSource.SupportsDirectPlay.GetValueOrDefault())
+ {
+ playMethod = "Direct Play";
+ }
+ else if (_currentMediaSource.SupportsDirectStream.GetValueOrDefault())
+ {
+ playMethod = "Direct Stream";
+ }
+ else
+ {
+ playMethod = "Transcoding";
+ }
+ info.AppendLine($" Method: {playMethod}");
+
+ // Transcoding info
+ if (playMethod == "Transcoding")
+ {
+ if (!string.IsNullOrEmpty(_currentMediaSource.TranscodingContainer))
+ {
+ info.AppendLine($" Container: {_currentMediaSource.TranscodingContainer.ToUpperInvariant()}");
+ }
+ if (!string.IsNullOrEmpty(_currentMediaSource.TranscodingUrl))
+ {
+ // Parse transcode reasons from URL if available
+ if (_currentMediaSource.TranscodingUrl.Contains("TranscodeReasons=", StringComparison.Ordinal))
+ {
+ int start = _currentMediaSource.TranscodingUrl.IndexOf("TranscodeReasons=", StringComparison.Ordinal) + 17;
+ int end = _currentMediaSource.TranscodingUrl.IndexOf('&', start);
+ if (end == -1)
+ {
+ end = _currentMediaSource.TranscodingUrl.Length;
+ }
+
+ string reasons = Uri.UnescapeDataString(_currentMediaSource.TranscodingUrl[start..end]);
+ if (!string.IsNullOrEmpty(reasons))
+ {
+ info.AppendLine($" Reasons: {reasons.Replace(",", ", ", StringComparison.Ordinal)}");
+ }
+ }
+ }
+ }
+
+ // Player info
+ if (_playerElement?.MediaPlayer != null)
+ {
+ info.AppendLine();
+ info.AppendLine("Player:");
+ info.AppendLine($" State: {_playerElement.MediaPlayer.PlaybackSession.PlaybackState}");
+ info.AppendLine($" Playback Rate: {_playerElement.MediaPlayer.PlaybackSession.PlaybackRate}x");
+ info.AppendLine($" Volume: {_playerElement.MediaPlayer.Volume * 100:F0}%");
+ }
+
+ ContentDialog dialog = new()
+ {
+ Title = "Playback Info",
+ Content = new ScrollViewer
+ {
+ Content = new TextBlock
+ {
+ Text = info.ToString(),
+ FontFamily = new FontFamily("Consolas"),
+ TextWrapping = TextWrapping.Wrap
+ },
+ MaxHeight = 400
+ },
+ CloseButtonText = "Close",
+ XamlRoot = _playerElement?.XamlRoot
+ };
+
+ await dialog.ShowAsync();
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Error showing playback info: {ex}");
+ }
+ }
+
+ private static string FormatBitrate(long bitrate)
+ {
+ if (bitrate >= 1_000_000)
+ {
+ return $"{bitrate / 1_000_000.0:F1} Mbps";
+ }
+ return $"{bitrate / 1000} kbps";
+ }
+
+ private static string FormatFileSize(long bytes)
+ {
+ const long GB = 1024L * 1024 * 1024;
+ const long MB = 1024L * 1024;
+
+ if (bytes >= GB)
+ {
+ return $"{bytes / (double)GB:F2} GB";
+ }
+ return $"{bytes / (double)MB:F1} MB";
+ }
+
private async void TimerTick()
{
try
@@ -355,10 +1012,48 @@ private async void TimerTick()
{
// 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}");
+ Debug.WriteLine($"Error in TimerTick: {ex}");
}
}
+ private void UpdateEndsAtText()
+ {
+ if (_transportControls is null || _playerElement?.MediaPlayer?.PlaybackSession is null)
+ {
+ return;
+ }
+
+ MediaPlaybackSession session = _playerElement.MediaPlayer.PlaybackSession;
+ TimeSpan duration = session.NaturalDuration;
+
+ // Fall back to item metadata if MediaPlayer duration not yet available
+ if (duration == TimeSpan.Zero && _currentItem?.RunTimeTicks.HasValue == true)
+ {
+ duration = TimeSpan.FromTicks(_currentItem.RunTimeTicks.Value);
+ }
+
+ if (duration == TimeSpan.Zero)
+ {
+ return; // No duration available from either source
+ }
+
+ TimeSpan remaining = duration - session.Position;
+ if (remaining < TimeSpan.Zero)
+ {
+ remaining = TimeSpan.Zero;
+ }
+
+ // Adjust for playback rate (e.g., at 2x speed, remaining time is halved)
+ double playbackRate = session.PlaybackRate;
+ if (playbackRate > 0 && playbackRate != 1.0)
+ {
+ remaining = TimeSpan.FromTicks((long)(remaining.Ticks / playbackRate));
+ }
+
+ DateTime endTime = DateTime.Now + remaining;
+ _transportControls.EndsAtText = $"Ends at {endTime:t}";
+ }
+
private async Task DetectBitrateAsync()
{
const int BitrateTestSize = 1024 * 1024; // 1MB
diff --git a/src/JellyBox/Views/Video.xaml b/src/JellyBox/Views/Video.xaml
index 06e9eed..4a85445 100644
--- a/src/JellyBox/Views/Video.xaml
+++ b/src/JellyBox/Views/Video.xaml
@@ -3,16 +3,16 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:JellyBox.Views"
+ xmlns:controls="using:JellyBox.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
- Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
+ Background="Black">
-
+ AutoPlay="False">
+
+
+
+
+
diff --git a/src/JellyBox/Views/Video.xaml.cs b/src/JellyBox/Views/Video.xaml.cs
index 09a94b1..4502695 100644
--- a/src/JellyBox/Views/Video.xaml.cs
+++ b/src/JellyBox/Views/Video.xaml.cs
@@ -1,7 +1,11 @@
using JellyBox.ViewModels;
using Jellyfin.Sdk.Generated.Models;
using Microsoft.Extensions.DependencyInjection;
+using Windows.System;
+using Windows.UI.Core;
+using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
+using Windows.UI.Xaml.Input;
using Windows.UI.Xaml.Navigation;
namespace JellyBox.Views;
@@ -17,9 +21,119 @@ public Video()
internal VideoViewModel ViewModel { get; }
- protected override void OnNavigatedTo(NavigationEventArgs e) => ViewModel.PlayVideo((Parameters)e.Parameter, PlayerElement);
+ protected override void OnNavigatedTo(NavigationEventArgs e)
+ {
+ ViewModel.PlayVideo((Parameters)e.Parameter, PlayerElement, TransportControls);
+
+ // Register for gamepad/keyboard input
+ Window.Current.CoreWindow.KeyDown += OnCoreWindowKeyDown;
+ }
+
+ protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
+ {
+ Window.Current.CoreWindow.KeyDown -= OnCoreWindowKeyDown;
+ ViewModel.StopVideo();
+ }
+
+ private void OnCoreWindowKeyDown(CoreWindow sender, KeyEventArgs args)
+ {
+ // Don't intercept input when a control has focus (e.g., seek bar, flyout items)
+ object? focused = FocusManager.GetFocusedElement();
+ bool hasControlFocus = focused is Control and not Page;
+
+ // Handle Xbox controller and keyboard shortcuts
+ switch (args.VirtualKey)
+ {
+ // Controller: LB, Keyboard: J - Rewind 10 seconds
+ case VirtualKey.GamepadLeftShoulder:
+ case VirtualKey.J:
+ ViewModel.Rewind();
+ args.Handled = true;
+ break;
+
+ // Controller: RB, Keyboard: L - Fast forward 30 seconds
+ case VirtualKey.GamepadRightShoulder:
+ case VirtualKey.L:
+ ViewModel.FastForward();
+ args.Handled = true;
+ break;
- protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) => ViewModel.StopVideo();
+ // Controller: Y, Keyboard: F - Toggle favorite
+ case VirtualKey.GamepadY:
+ case VirtualKey.F:
+ if (!hasControlFocus)
+ {
+ _ = ViewModel.ToggleFavoriteAsync();
+ args.Handled = true;
+ }
+ break;
+
+ // Controller: Menu, Keyboard: Space/K - Play/Pause
+ case VirtualKey.GamepadMenu:
+ ViewModel.TogglePlayPause();
+ args.Handled = true;
+ break;
+
+ case VirtualKey.Space:
+ case VirtualKey.K:
+ if (!hasControlFocus)
+ {
+ ViewModel.TogglePlayPause();
+ args.Handled = true;
+ }
+ break;
+
+ // Keyboard: M - Toggle mute
+ case VirtualKey.M:
+ if (!hasControlFocus)
+ {
+ ViewModel.ToggleMute();
+ args.Handled = true;
+ }
+ break;
+
+ // Keyboard: Up/Down arrow - Volume control (when not on a control)
+ case VirtualKey.Up:
+ if (!hasControlFocus)
+ {
+ ViewModel.AdjustVolume(0.1);
+ args.Handled = true;
+ }
+ break;
+
+ case VirtualKey.Down:
+ if (!hasControlFocus)
+ {
+ ViewModel.AdjustVolume(-0.1);
+ args.Handled = true;
+ }
+ break;
+
+ // Keyboard: Left/Right arrow - Seek (when not on a control)
+ case VirtualKey.Left:
+ if (!hasControlFocus)
+ {
+ ViewModel.Rewind();
+ args.Handled = true;
+ }
+ break;
+
+ case VirtualKey.Right:
+ if (!hasControlFocus)
+ {
+ ViewModel.FastForward();
+ args.Handled = true;
+ }
+ break;
+
+ // Keyboard: Escape - Go back
+ case VirtualKey.Escape:
+ case VirtualKey.GamepadB:
+ Frame.GoBack();
+ args.Handled = true;
+ break;
+ }
+ }
internal sealed record Parameters(BaseItemDto Item, string? MediaSourceId, int? AudioStreamIndex, int? SubtitleStreamIndex);
}