From 5f90eab83f84e21cc1b417a0697f1f20ad75ae4c Mon Sep 17 00:00:00 2001 From: David Federman Date: Sat, 24 Jan 2026 02:10:01 -0800 Subject: [PATCH] Implement custom playback controls --- src/JellyBox/App.xaml | 1 + ...aTransportControls.DependencyProperties.cs | 269 +++++++ .../CustomMediaTransportControls.Flyouts.cs | 265 +++++++ ...ustomMediaTransportControls.IconUpdates.cs | 68 ++ .../Controls/CustomMediaTransportControls.cs | 77 ++ src/JellyBox/Glyphs.cs | 21 + .../Resources/TransportControlsStyles.xaml | 232 ++++++ .../Resources/TransportControlsStyles.xaml.cs | 12 + src/JellyBox/ViewModels/VideoViewModel.cs | 725 +++++++++++++++++- src/JellyBox/Views/Video.xaml | 26 +- src/JellyBox/Views/Video.xaml.cs | 118 ++- 11 files changed, 1791 insertions(+), 23 deletions(-) create mode 100644 src/JellyBox/Controls/CustomMediaTransportControls.DependencyProperties.cs create mode 100644 src/JellyBox/Controls/CustomMediaTransportControls.Flyouts.cs create mode 100644 src/JellyBox/Controls/CustomMediaTransportControls.IconUpdates.cs create mode 100644 src/JellyBox/Controls/CustomMediaTransportControls.cs create mode 100644 src/JellyBox/Glyphs.cs create mode 100644 src/JellyBox/Resources/TransportControlsStyles.xaml create mode 100644 src/JellyBox/Resources/TransportControlsStyles.xaml.cs 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"> - + + Visibility="{x:Bind ViewModel.ShowBackdropImage, Mode=OneWay}"> @@ -21,6 +21,20 @@ x:Name="PlayerElement" Stretch="Uniform" AreTransportControlsEnabled="True" - Canvas.ZIndex="100" /> - + 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); }