From f50f4fe5c4a4e7a440e5d2727fc5c6af824fa774 Mon Sep 17 00:00:00 2001
From: Tung Huynh <tung75605@gmail.com>
Date: Sat, 4 Jan 2025 22:07:00 -0800
Subject: [PATCH 1/6] feat: add option to always resume from the last position

---
 Screenbox.Core/Services/ISettingsService.cs        |  1 +
 Screenbox.Core/Services/SettingsService.cs         |  7 +++++++
 Screenbox.Core/ViewModels/PlayerPageViewModel.cs   | 11 ++++++++++-
 Screenbox.Core/ViewModels/SettingsPageViewModel.cs |  8 ++++++++
 Screenbox/Pages/SettingsPage.xaml                  |  7 +++++++
 5 files changed, 33 insertions(+), 1 deletion(-)

diff --git a/Screenbox.Core/Services/ISettingsService.cs b/Screenbox.Core/Services/ISettingsService.cs
index 3513dd59f..009c2a5c8 100644
--- a/Screenbox.Core/Services/ISettingsService.cs
+++ b/Screenbox.Core/Services/ISettingsService.cs
@@ -16,6 +16,7 @@ public interface ISettingsService
         bool ShowRecent { get; set; }
         ThemeOption Theme { get; set; }
         bool EnqueueAllFilesInFolder { get; set; }
+        bool RestorePlaybackPosition { get; set; }
         bool SearchRemovableStorage { get; set; }
         int MaxVolume { get; set; }
         string GlobalArguments { get; set; }
diff --git a/Screenbox.Core/Services/SettingsService.cs b/Screenbox.Core/Services/SettingsService.cs
index 8bdba9b20..c1888f144 100644
--- a/Screenbox.Core/Services/SettingsService.cs
+++ b/Screenbox.Core/Services/SettingsService.cs
@@ -25,6 +25,7 @@ public sealed class SettingsService : ISettingsService
         private const string LibrariesSearchRemovableStorageKey = "Libraries/SearchRemovableStorage";
         private const string GeneralShowRecent = "General/ShowRecent";
         private const string GeneralEnqueueAllInFolder = "General/EnqueueAllInFolder";
+        private const string GeneralRestorePlaybackPosition = "General/RestorePlaybackPosition";
         private const string AdvancedModeKey = "Advanced/IsEnabled";
         private const string AdvancedMultipleInstancesKey = "Advanced/MultipleInstances";
         private const string GlobalArgumentsKey = "Values/GlobalArguments";
@@ -99,6 +100,12 @@ public bool EnqueueAllFilesInFolder
             set => SetValue(GeneralEnqueueAllInFolder, value);
         }
 
+        public bool RestorePlaybackPosition
+        {
+            get => GetValue<bool>(GeneralRestorePlaybackPosition);
+            set => SetValue(GeneralRestorePlaybackPosition, value);
+        }
+
         public bool PlayerShowControls
         {
             get => GetValue<bool>(PlayerShowControlsKey);
diff --git a/Screenbox.Core/ViewModels/PlayerPageViewModel.cs b/Screenbox.Core/ViewModels/PlayerPageViewModel.cs
index 8faadc404..a27a360dc 100644
--- a/Screenbox.Core/ViewModels/PlayerPageViewModel.cs
+++ b/Screenbox.Core/ViewModels/PlayerPageViewModel.cs
@@ -177,7 +177,16 @@ public void Receive(PlaylistCurrentItemChangedMessage message)
             if (message.Value != null)
             {
                 TimeSpan lastPosition = _lastPositionTracker.GetPosition(message.Value.Location);
-                Messenger.Send(new RaiseResumePositionNotificationMessage(lastPosition));
+                if (lastPosition <= TimeSpan.Zero) return;
+                if (_settingsService.RestorePlaybackPosition)
+                {
+                    // TODO: Wait until playback starts then send time change message
+                    Messenger.Send(new ChangeTimeRequestMessage(lastPosition, debounce: false));
+                }
+                else
+                {
+                    Messenger.Send(new RaiseResumePositionNotificationMessage(lastPosition));
+                }
             }
         }
 
diff --git a/Screenbox.Core/ViewModels/SettingsPageViewModel.cs b/Screenbox.Core/ViewModels/SettingsPageViewModel.cs
index a2ccff135..da050f063 100644
--- a/Screenbox.Core/ViewModels/SettingsPageViewModel.cs
+++ b/Screenbox.Core/ViewModels/SettingsPageViewModel.cs
@@ -30,6 +30,7 @@ public sealed partial class SettingsPageViewModel : ObservableRecipient
         [ObservableProperty] private bool _showRecent;
         [ObservableProperty] private int _theme;
         [ObservableProperty] private bool _enqueueAllFilesInFolder;
+        [ObservableProperty] private bool _restorePlaybackPosition;
         [ObservableProperty] private bool _searchRemovableStorage;
         [ObservableProperty] private bool _advancedMode;
         [ObservableProperty] private bool _useMultipleInstances;
@@ -80,6 +81,7 @@ public SettingsPageViewModel(ISettingsService settingsService, ILibraryService l
             _showRecent = _settingsService.ShowRecent;
             _theme = ((int)_settingsService.Theme + 2) % 3;
             _enqueueAllFilesInFolder = _settingsService.EnqueueAllFilesInFolder;
+            _restorePlaybackPosition = _settingsService.RestorePlaybackPosition;
             _searchRemovableStorage = _settingsService.SearchRemovableStorage;
             _advancedMode = _settingsService.AdvancedMode;
             _useMultipleInstances = _settingsService.UseMultipleInstances;
@@ -154,6 +156,12 @@ partial void OnEnqueueAllFilesInFolderChanged(bool value)
             Messenger.Send(new SettingsChangedMessage(nameof(EnqueueAllFilesInFolder), typeof(SettingsPageViewModel)));
         }
 
+        partial void OnRestorePlaybackPositionChanged(bool value)
+        {
+            _settingsService.RestorePlaybackPosition = value;
+            Messenger.Send(new SettingsChangedMessage(nameof(RestorePlaybackPosition), typeof(SettingsPageViewModel)));
+        }
+
         async partial void OnSearchRemovableStorageChanged(bool value)
         {
             _settingsService.SearchRemovableStorage = value;
diff --git a/Screenbox/Pages/SettingsPage.xaml b/Screenbox/Pages/SettingsPage.xaml
index c41842637..6a638bf9e 100644
--- a/Screenbox/Pages/SettingsPage.xaml
+++ b/Screenbox/Pages/SettingsPage.xaml
@@ -315,6 +315,13 @@
                     <ToggleSwitch AutomationProperties.HelpText="{x:Bind SettingsEnqueueAllFilesInFolderCard.Description}" IsOn="{x:Bind ViewModel.EnqueueAllFilesInFolder, Mode=TwoWay}" />
                 </ctc:SettingsCard>
 
+                <ctc:SettingsCard
+                    Margin="{StaticResource SettingsCardMargin}"
+                    Description="Continue playing from where you last stopped when opening a file"
+                    Header="Always resume from last position">
+                    <ToggleSwitch IsOn="{x:Bind ViewModel.RestorePlaybackPosition, Mode=TwoWay}" />
+                </ctc:SettingsCard>
+
                 <!--  Player section  -->
                 <TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="{strings:Resources Key=SettingsCategoryPlayer}" />
 

From 5072c2b3afef36a72edf70850d38ac5d5524aa28 Mon Sep 17 00:00:00 2001
From: Tung Huynh <tung75605@gmail.com>
Date: Fri, 14 Feb 2025 00:36:26 -0800
Subject: [PATCH 2/6] move LastPositionTracker to SeekBarViewModel
 LastPositionTracker now participates in messaging and dependency injection

---
 Screenbox.Core/Common/ServiceHelpers.cs       |   4 +
 Screenbox.Core/Helpers/LastPositionTracker.cs |  38 ++++++-
 .../ViewModels/PlayerPageViewModel.cs         |  48 +--------
 Screenbox.Core/ViewModels/SeekBarViewModel.cs | 100 ++++++++++++++++--
 4 files changed, 131 insertions(+), 59 deletions(-)

diff --git a/Screenbox.Core/Common/ServiceHelpers.cs b/Screenbox.Core/Common/ServiceHelpers.cs
index 51bd7a135..5bc9082f1 100644
--- a/Screenbox.Core/Common/ServiceHelpers.cs
+++ b/Screenbox.Core/Common/ServiceHelpers.cs
@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Screenbox.Core.Factories;
+using Screenbox.Core.Helpers;
 using Screenbox.Core.Services;
 using Screenbox.Core.ViewModels;
 
@@ -41,6 +42,9 @@ public static void PopulateCoreServices(ServiceCollection services)
         services.AddSingleton<VolumeViewModel>();   // Avoid thread lock
         services.AddSingleton<MediaListViewModel>(); // Global playlist
 
+        // Misc
+        services.AddTransient<LastPositionTracker>();
+
         // Factories
         services.AddSingleton<MediaViewModelFactory>();
         services.AddSingleton<StorageItemViewModelFactory>();
diff --git a/Screenbox.Core/Helpers/LastPositionTracker.cs b/Screenbox.Core/Helpers/LastPositionTracker.cs
index 0da06d0fc..2e2bfcfe8 100644
--- a/Screenbox.Core/Helpers/LastPositionTracker.cs
+++ b/Screenbox.Core/Helpers/LastPositionTracker.cs
@@ -1,5 +1,8 @@
 #nullable enable
 
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Messaging;
+using Screenbox.Core.Messages;
 using Screenbox.Core.Models;
 using Screenbox.Core.Services;
 using System;
@@ -11,17 +14,41 @@
 
 namespace Screenbox.Core.Helpers
 {
-    internal class LastPositionTracker
+    public sealed class LastPositionTracker : ObservableRecipient,
+        IRecipient<SuspendingMessage>,
+        IRecipient<MediaPlayerChangedMessage>
     {
         private const int Capacity = 64;
         private const string SaveFileName = "last_positions.bin";
 
+        public DateTimeOffset LastUpdated { get; private set; }
+
+        private readonly IFilesService _filesService;
         private List<MediaLastPosition> _lastPositions = new(Capacity + 1);
         private MediaLastPosition? _updateCache;
         private string? _removeCache;
 
+        public LastPositionTracker(IFilesService filesService)
+        {
+            _filesService = filesService;
+
+            IsActive = true;
+        }
+
+        public void Receive(SuspendingMessage message)
+        {
+            message.Reply(SaveToDiskAsync());
+        }
+
+        public async void Receive(MediaPlayerChangedMessage message)
+        {
+            if (_lastPositions.Count > 0) return;
+            await LoadFromDiskAsync();
+        }
+
         public void UpdateLastPosition(string location, TimeSpan position)
         {
+            LastUpdated = DateTimeOffset.Now;
             _removeCache = null;
             MediaLastPosition? item = _updateCache;
             if (item?.Location == location)
@@ -66,16 +93,17 @@ public TimeSpan GetPosition(string location)
 
         public void RemovePosition(string location)
         {
+            LastUpdated = DateTimeOffset.Now;
             if (_removeCache == location) return;
             _lastPositions.RemoveAll(x => x.Location == location);
             _removeCache = location;
         }
 
-        public async Task SaveToDiskAsync(IFilesService filesService)
+        public async Task SaveToDiskAsync()
         {
             try
             {
-                await filesService.SaveToDiskAsync(ApplicationData.Current.TemporaryFolder, SaveFileName, _lastPositions.ToList());
+                await _filesService.SaveToDiskAsync(ApplicationData.Current.TemporaryFolder, SaveFileName, _lastPositions.ToList());
             }
             catch (FileLoadException)
             {
@@ -83,12 +111,12 @@ public async Task SaveToDiskAsync(IFilesService filesService)
             }
         }
 
-        public async Task LoadFromDiskAsync(IFilesService filesService)
+        public async Task LoadFromDiskAsync()
         {
             try
             {
                 List<MediaLastPosition> lastPositions =
-                    await filesService.LoadFromDiskAsync<List<MediaLastPosition>>(ApplicationData.Current.TemporaryFolder, SaveFileName);
+                    await _filesService.LoadFromDiskAsync<List<MediaLastPosition>>(ApplicationData.Current.TemporaryFolder, SaveFileName);
                 lastPositions.Capacity = Capacity;
                 _lastPositions = lastPositions;
             }
diff --git a/Screenbox.Core/ViewModels/PlayerPageViewModel.cs b/Screenbox.Core/ViewModels/PlayerPageViewModel.cs
index a27a360dc..91dc9ee46 100644
--- a/Screenbox.Core/ViewModels/PlayerPageViewModel.cs
+++ b/Screenbox.Core/ViewModels/PlayerPageViewModel.cs
@@ -32,7 +32,6 @@ public sealed partial class PlayerPageViewModel : ObservableRecipient,
         IRecipient<UpdateStatusMessage>,
         IRecipient<UpdateVolumeStatusMessage>,
         IRecipient<TogglePlayerVisibilityMessage>,
-        IRecipient<SuspendingMessage>,
         IRecipient<MediaPlayerChangedMessage>,
         IRecipient<PlaylistCurrentItemChangedMessage>,
         IRecipient<ShowPlayPauseBadgeMessage>,
@@ -71,7 +70,6 @@ public sealed partial class PlayerPageViewModel : ObservableRecipient,
         private readonly ISettingsService _settingsService;
         private readonly IResourceService _resourceService;
         private readonly IFilesService _filesService;
-        private readonly LastPositionTracker _lastPositionTracker;
         private IMediaPlayer? _mediaPlayer;
         private bool _visibilityOverride;
         private bool _resizeNext;
@@ -90,7 +88,6 @@ public PlayerPageViewModel(IWindowService windowService, IResourceService resour
             _playPauseBadgeTimer = _dispatcherQueue.CreateTimer();
             _navigationViewDisplayMode = Messenger.Send<NavigationViewDisplayModeRequestMessage>();
             _playerVisibility = PlayerVisibilityState.Hidden;
-            _lastPositionTracker = new LastPositionTracker();
             _lastUpdated = DateTimeOffset.MinValue;
 
             FocusManager.GotFocus += FocusManagerOnFocusChanged;
@@ -132,19 +129,11 @@ private void WindowServiceOnViewModeChanged(object sender, ViewModeChangedEventA
             });
         }
 
-        public void Receive(SuspendingMessage message)
-        {
-            message.Reply(_lastPositionTracker.SaveToDiskAsync(_filesService));
-        }
-
-        public async void Receive(MediaPlayerChangedMessage message)
+        public void Receive(MediaPlayerChangedMessage message)
         {
             _mediaPlayer = message.Value;
             _mediaPlayer.PlaybackStateChanged += OnStateChanged;
-            _mediaPlayer.PositionChanged += OnPositionChanged;
             _mediaPlayer.NaturalVideoSizeChanged += OnNaturalVideoSizeChanged;
-
-            await _lastPositionTracker.LoadFromDiskAsync(_filesService);
         }
 
         public void Receive(UpdateVolumeStatusMessage message)
@@ -174,20 +163,6 @@ public void Receive(UpdateStatusMessage message)
         public void Receive(PlaylistCurrentItemChangedMessage message)
         {
             _dispatcherQueue.TryEnqueue(() => ProcessOpeningMedia(message.Value));
-            if (message.Value != null)
-            {
-                TimeSpan lastPosition = _lastPositionTracker.GetPosition(message.Value.Location);
-                if (lastPosition <= TimeSpan.Zero) return;
-                if (_settingsService.RestorePlaybackPosition)
-                {
-                    // TODO: Wait until playback starts then send time change message
-                    Messenger.Send(new ChangeTimeRequestMessage(lastPosition, debounce: false));
-                }
-                else
-                {
-                    Messenger.Send(new RaiseResumePositionNotificationMessage(lastPosition));
-                }
-            }
         }
 
         public void Receive(ShowPlayPauseBadgeMessage message)
@@ -673,27 +648,6 @@ private void OnStateChanged(IMediaPlayer sender, object? args)
             });
         }
 
-        private void OnPositionChanged(IMediaPlayer sender, object? args)
-        {
-            // Only record position for media over 1 minute
-            // Update every 3 seconds
-            TimeSpan position = sender.Position;
-            if (Media == null || sender.NaturalDuration <= TimeSpan.FromMinutes(1) ||
-                DateTimeOffset.Now - _lastUpdated <= TimeSpan.FromSeconds(3))
-                return;
-
-            if (position > TimeSpan.FromSeconds(30) && position + TimeSpan.FromSeconds(10) < sender.NaturalDuration)
-            {
-                _lastUpdated = DateTimeOffset.Now;
-                _lastPositionTracker.UpdateLastPosition(Media.Location, position);
-            }
-            else if (position > TimeSpan.FromSeconds(5))
-            {
-                _lastUpdated = DateTimeOffset.Now;
-                _lastPositionTracker.RemovePosition(Media.Location);
-            }
-        }
-
         private void OnNaturalVideoSizeChanged(IMediaPlayer sender, EventArgs args)
         {
             if (!_resizeNext && _settingsService.PlayerAutoResize != PlayerAutoResizeOption.Always) return;
diff --git a/Screenbox.Core/ViewModels/SeekBarViewModel.cs b/Screenbox.Core/ViewModels/SeekBarViewModel.cs
index 4f5571e1c..b35101fc7 100644
--- a/Screenbox.Core/ViewModels/SeekBarViewModel.cs
+++ b/Screenbox.Core/ViewModels/SeekBarViewModel.cs
@@ -5,10 +5,12 @@
 using CommunityToolkit.Mvvm.Messaging.Messages;
 using CommunityToolkit.WinUI;
 using Screenbox.Core.Enums;
+using Screenbox.Core.Events;
 using Screenbox.Core.Helpers;
 using Screenbox.Core.Messages;
 using Screenbox.Core.Models;
 using Screenbox.Core.Playback;
+using Screenbox.Core.Services;
 using System;
 using System.Collections.ObjectModel;
 using Windows.Media.Core;
@@ -25,6 +27,7 @@ public sealed partial class SeekBarViewModel :
         IRecipient<TimeChangeOverrideMessage>,
         IRecipient<ChangeTimeRequestMessage>,
         IRecipient<PlayerControlsVisibilityChangedMessage>,
+        IRecipient<PlaylistCurrentItemChangedMessage>,
         IRecipient<PropertyChangedMessage<PlayerVisibilityState>>,
         IRecipient<MediaPlayerChangedMessage>
     {
@@ -44,17 +47,31 @@ public sealed partial class SeekBarViewModel :
 
         public ObservableCollection<ChapterCue> Chapters { get; }
 
+        private TimeSpan NaturalDuration => TimeSpan.FromMilliseconds(Length);
+
+        private TimeSpan Position
+        {
+            get => TimeSpan.FromMilliseconds(Time);
+            set => Time = value.TotalMilliseconds;
+        }
+
         private IMediaPlayer? _mediaPlayer;
 
+        private readonly ISettingsService _settingsService;
         private readonly DispatcherQueue _dispatcherQueue;
         private readonly DispatcherQueueTimer _bufferingTimer;
         private readonly DispatcherQueueTimer _seekTimer;
         private readonly DispatcherQueueTimer _originalPositionTimer;
+        private readonly LastPositionTracker _lastPositionTracker;
         private TimeSpan _originalPosition;
+        private TimeSpan _lastTrackedPosition;
         private bool _timeChangeOverride;
+        private MediaViewModel? _currentItem;
 
-        public SeekBarViewModel()
+        public SeekBarViewModel(ISettingsService settingsService, LastPositionTracker lastPositionTracker)
         {
+            _settingsService = settingsService;
+            _lastPositionTracker = lastPositionTracker;
             _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
             _bufferingTimer = _dispatcherQueue.CreateTimer();
             _seekTimer = _dispatcherQueue.CreateTimer();
@@ -68,6 +85,26 @@ public SeekBarViewModel()
             IsActive = true;
         }
 
+        public void Receive(PlaylistCurrentItemChangedMessage message)
+        {
+            _lastTrackedPosition = TimeSpan.Zero;
+            _currentItem = message.Value;
+            if (message.Value != null)
+            {
+                TimeSpan lastPosition = _lastPositionTracker.GetPosition(message.Value.Location);
+                if (lastPosition <= TimeSpan.Zero) return;
+                if (_settingsService.RestorePlaybackPosition)
+                {
+                    // Media is not seekable yet, so we need to wait for the PlaybackStateChanged event
+                    _lastTrackedPosition = lastPosition;
+                }
+                else
+                {
+                    Messenger.Send(new RaiseResumePositionNotificationMessage(lastPosition));
+                }
+            }
+        }
+
         public void Receive(PropertyChangedMessage<PlayerVisibilityState> message)
         {
             ShouldHandleKeyDown = message.NewValue != PlayerVisibilityState.Visible;
@@ -83,7 +120,20 @@ public void Receive(PlayerControlsVisibilityChangedMessage message)
 
         public void Receive(MediaPlayerChangedMessage message)
         {
+            if (_mediaPlayer != null)
+            {
+                _mediaPlayer.PlaybackStateChanged -= OnPlaybackStateChanged;
+                _mediaPlayer.NaturalDurationChanged -= OnNaturalDurationChanged;
+                _mediaPlayer.PositionChanged -= OnPositionChanged;
+                _mediaPlayer.MediaEnded -= OnEndReached;
+                _mediaPlayer.BufferingStarted -= OnBufferingStarted;
+                _mediaPlayer.BufferingEnded -= OnBufferingEnded;
+                _mediaPlayer.PlaybackItemChanged -= OnPlaybackItemChanged;
+                _mediaPlayer.CanSeekChanged -= OnCanSeekChanged;
+            }
+
             _mediaPlayer = message.Value;
+            _mediaPlayer.PlaybackStateChanged += OnPlaybackStateChanged;
             _mediaPlayer.NaturalDurationChanged += OnNaturalDurationChanged;
             _mediaPlayer.PositionChanged += OnPositionChanged;
             _mediaPlayer.MediaEnded += OnEndReached;
@@ -131,6 +181,7 @@ public void OnSeekBarPointerWheelChanged(double pointerWheelDelta)
 
         public void OnSeekBarValueChanged(object sender, RangeBaseValueChangedEventArgs args)
         {
+            var newPosition = TimeSpan.FromMilliseconds(args.NewValue);
             // Only update player position when there is a user interaction.
             // SeekBar should have OneWay binding to Time, so when Time changes and invokes
             // this handler, Time = args.NewValue. The only exception is when the change is
@@ -146,22 +197,28 @@ public void OnSeekBarValueChanged(object sender, RangeBaseValueChangedEventArgs
                 bool paused = _mediaPlayer.PlaybackState is MediaPlaybackState.Paused or MediaPlaybackState.Buffering;
                 if (shouldUpdate || paused || shouldOverride)
                 {
-                    SetPlayerPosition(TimeSpan.FromMilliseconds(args.NewValue), true);
+                    SetPlayerPosition(newPosition, true);
                 }
             }
+
+            UpdateLastPosition(newPosition);
         }
 
         private PositionChangedResult UpdatePosition(TimeSpan position, bool isOffset, bool debounce)
         {
-            TimeSpan currentPosition = TimeSpan.FromMilliseconds(Time);
+            TimeSpan currentPosition = Position;
             _originalPositionTimer.Debounce(() => _originalPosition = currentPosition, TimeSpan.FromSeconds(1), true);
 
             // Assume UI thread
-            Time = isOffset ? Math.Clamp(Time + position.TotalMilliseconds, 0, Length) : position.TotalMilliseconds;
-            SetPlayerPosition(TimeSpan.FromMilliseconds(Time), debounce);
+            Position = isOffset ? (currentPosition + position) switch
+            {
+                var newPosition when newPosition < TimeSpan.Zero => TimeSpan.Zero,
+                var newPosition when newPosition > NaturalDuration => NaturalDuration,
+                var newPosition => newPosition
+            } : position;
+            SetPlayerPosition(Position, debounce);
 
-            return new PositionChangedResult(currentPosition, TimeSpan.FromMilliseconds(Time),
-                _originalPosition, TimeSpan.FromMilliseconds(Length));
+            return new PositionChangedResult(currentPosition, Position, _originalPosition, NaturalDuration);
         }
 
         private void SetPlayerPosition(TimeSpan position, bool debounce)
@@ -186,6 +243,19 @@ private void OnCanSeekChanged(IMediaPlayer sender, EventArgs args)
             });
         }
 
+        private void OnPlaybackStateChanged(IMediaPlayer sender, ValueChangedEventArgs<MediaPlaybackState> args)
+        {
+            if (args.NewValue is not (MediaPlaybackState.None or MediaPlaybackState.Opening) &&
+                _lastTrackedPosition > TimeSpan.Zero)
+            {
+                _dispatcherQueue.TryEnqueue(() =>
+                {
+                    SetPlayerPosition(_lastTrackedPosition, false);
+                    _lastTrackedPosition = TimeSpan.Zero;
+                });
+            }
+        }
+
         private void OnPlaybackItemChanged(IMediaPlayer sender, object? args)
         {
             _seekTimer.Stop();
@@ -276,5 +346,21 @@ private void UpdateChapters(PlaybackChapterList? chapterList)
             }
             // Chapters.SyncItems(chapterList);
         }
+
+        private void UpdateLastPosition(TimeSpan position)
+        {
+            if (_currentItem == null || NaturalDuration <= TimeSpan.FromMinutes(1) ||
+                DateTimeOffset.Now - _lastPositionTracker.LastUpdated <= TimeSpan.FromSeconds(3))
+                return;
+
+            if (position > TimeSpan.FromSeconds(30) && position + TimeSpan.FromSeconds(10) < NaturalDuration)
+            {
+                _lastPositionTracker.UpdateLastPosition(_currentItem.Location, position);
+            }
+            else if (position > TimeSpan.FromSeconds(5))
+            {
+                _lastPositionTracker.RemovePosition(_currentItem.Location);
+            }
+        }
     }
 }
\ No newline at end of file

From 5c153071877d77bf333a4865bb570aa04a22b1cd Mon Sep 17 00:00:00 2001
From: Tung Huynh <tung75605@gmail.com>
Date: Fri, 21 Feb 2025 00:43:46 -0800
Subject: [PATCH 3/6] localize raw strings

---
 Screenbox/Pages/SettingsPage.xaml             |  4 +--
 .../Strings/en-US/Resources.generated.cs      | 28 +++++++++++++++++++
 Screenbox/Strings/en-US/Resources.resw        |  6 ++++
 3 files changed, 36 insertions(+), 2 deletions(-)

diff --git a/Screenbox/Pages/SettingsPage.xaml b/Screenbox/Pages/SettingsPage.xaml
index 6a638bf9e..2993402aa 100644
--- a/Screenbox/Pages/SettingsPage.xaml
+++ b/Screenbox/Pages/SettingsPage.xaml
@@ -317,8 +317,8 @@
 
                 <ctc:SettingsCard
                     Margin="{StaticResource SettingsCardMargin}"
-                    Description="Continue playing from where you last stopped when opening a file"
-                    Header="Always resume from last position">
+                    Description="{strings:Resources Key=SettingsRestorePlaybackPositionDescription}"
+                    Header="{strings:Resources Key=SettingsRestorePlaybackPositionHeader}">
                     <ToggleSwitch IsOn="{x:Bind ViewModel.RestorePlaybackPosition, Mode=TwoWay}" />
                 </ctc:SettingsCard>
 
diff --git a/Screenbox/Strings/en-US/Resources.generated.cs b/Screenbox/Strings/en-US/Resources.generated.cs
index 661b2a92d..f35092c89 100644
--- a/Screenbox/Strings/en-US/Resources.generated.cs
+++ b/Screenbox/Strings/en-US/Resources.generated.cs
@@ -3170,6 +3170,32 @@ public static string SettingsCategoryPersonalization
             }
         }
         #endregion
+
+        #region SettingsRestorePlaybackPositionHeader
+        /// <summary>
+        ///   Looks up a localized string similar to: Always resume from last position
+        /// </summary>
+        public static string SettingsRestorePlaybackPositionHeader
+        {
+            get
+            {
+                return _resourceLoader.GetString("SettingsRestorePlaybackPositionHeader");
+            }
+        }
+        #endregion
+
+        #region SettingsRestorePlaybackPositionDescription
+        /// <summary>
+        ///   Looks up a localized string similar to: Continue playing from where you last stopped when opening a file
+        /// </summary>
+        public static string SettingsRestorePlaybackPositionDescription
+        {
+            get
+            {
+                return _resourceLoader.GetString("SettingsRestorePlaybackPositionDescription");
+            }
+        }
+        #endregion
     }
 
     [global::System.CodeDom.Compiler.GeneratedCodeAttribute("DotNetPlus.ReswPlus", "2.1.3")]
@@ -3423,6 +3449,8 @@ public enum KeyEnum
             SettingsThemeSelectionDescription,
             SettingsThemeSelectionHeader,
             SettingsCategoryPersonalization,
+            SettingsRestorePlaybackPositionHeader,
+            SettingsRestorePlaybackPositionDescription,
         }
 
         private static ResourceLoader _resourceLoader;
diff --git a/Screenbox/Strings/en-US/Resources.resw b/Screenbox/Strings/en-US/Resources.resw
index a5e711c04..645e5b918 100644
--- a/Screenbox/Strings/en-US/Resources.resw
+++ b/Screenbox/Strings/en-US/Resources.resw
@@ -911,4 +911,10 @@
   <data name="SettingsCategoryPersonalization" xml:space="preserve">
     <value>Personalization</value>
   </data>
+  <data name="SettingsRestorePlaybackPositionHeader" xml:space="preserve">
+    <value>Always resume from last position</value>
+  </data>
+  <data name="SettingsRestorePlaybackPositionDescription" xml:space="preserve">
+    <value>Continue playing from where you last stopped when opening a file</value>
+  </data>
 </root>
\ No newline at end of file

From 16042999695d996f4167b33a095b214e23b1201a Mon Sep 17 00:00:00 2001
From: Tung Huynh <31434093+huynhsontung@users.noreply.github.com>
Date: Mon, 24 Feb 2025 00:13:36 -0800
Subject: [PATCH 4/6] style: update icon for the restore playback position
 option

Co-authored-by: United600 <United600@users.noreply.github.com>
---
 Screenbox/Pages/SettingsPage.xaml | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/Screenbox/Pages/SettingsPage.xaml b/Screenbox/Pages/SettingsPage.xaml
index 2993402aa..93d0a02e1 100644
--- a/Screenbox/Pages/SettingsPage.xaml
+++ b/Screenbox/Pages/SettingsPage.xaml
@@ -318,7 +318,8 @@
                 <ctc:SettingsCard
                     Margin="{StaticResource SettingsCardMargin}"
                     Description="{strings:Resources Key=SettingsRestorePlaybackPositionDescription}"
-                    Header="{strings:Resources Key=SettingsRestorePlaybackPositionHeader}">
+                    Header="{strings:Resources Key=SettingsRestorePlaybackPositionHeader}"
+                    HeaderIcon="{ui:FontIcon Glyph=&#xEF3B;}">
                     <ToggleSwitch IsOn="{x:Bind ViewModel.RestorePlaybackPosition, Mode=TwoWay}" />
                 </ctc:SettingsCard>
 

From ca75d51668f5bb8d862d572e08575ce3d3ec1c00 Mon Sep 17 00:00:00 2001
From: Tung Huynh <tung75605@gmail.com>
Date: Mon, 24 Feb 2025 00:18:22 -0800
Subject: [PATCH 5/6] a11y: add automation help text for the restore playback
 position option adjust other help text on the SettingsPage to avoid using
 x:Bind since they are static values

---
 Screenbox/Pages/SettingsPage.xaml | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/Screenbox/Pages/SettingsPage.xaml b/Screenbox/Pages/SettingsPage.xaml
index 93d0a02e1..56b3874a9 100644
--- a/Screenbox/Pages/SettingsPage.xaml
+++ b/Screenbox/Pages/SettingsPage.xaml
@@ -172,7 +172,7 @@
                     </interactivity:Interaction.Behaviors>
                     <Button
                         x:Name="AddMusicFolderButton"
-                        AutomationProperties.HelpText="{x:Bind AddMusicFolderButton.(ToolTipService.ToolTip)}"
+                        AutomationProperties.HelpText="{strings:Resources Key=AddMusicFolderToolTip}"
                         AutomationProperties.Name="{strings:Resources Key=AddFolder}"
                         Command="{x:Bind ViewModel.AddMusicFolderCommand}"
                         ToolTipService.ToolTip="{strings:Resources Key=AddMusicFolderToolTip}">
@@ -201,7 +201,7 @@
                     </interactivity:Interaction.Behaviors>
                     <Button
                         x:Name="AddVideoFolderButton"
-                        AutomationProperties.HelpText="{x:Bind AddVideoFolderButton.(ToolTipService.ToolTip)}"
+                        AutomationProperties.HelpText="{strings:Resources Key=AddVideoFolderToolTip}"
                         AutomationProperties.Name="{strings:Resources Key=AddFolder}"
                         Command="{x:Bind ViewModel.AddVideosFolderCommand}"
                         ToolTipService.ToolTip="{strings:Resources Key=AddVideoFolderToolTip}">
@@ -312,7 +312,7 @@
                     Header="{strings:Resources Key=SettingsEnqueueAllFilesInFolderHeader}"
                     HeaderIcon="{ui:FontIcon FontFamily={StaticResource ScreenboxSymbolThemeFontFamily},
                                              Glyph={StaticResource FolderListGlyph}}">
-                    <ToggleSwitch AutomationProperties.HelpText="{x:Bind SettingsEnqueueAllFilesInFolderCard.Description}" IsOn="{x:Bind ViewModel.EnqueueAllFilesInFolder, Mode=TwoWay}" />
+                    <ToggleSwitch AutomationProperties.HelpText="{strings:Resources Key=SettingsEnqueueAllFilesInFolderDescription}" IsOn="{x:Bind ViewModel.EnqueueAllFilesInFolder, Mode=TwoWay}" />
                 </ctc:SettingsCard>
 
                 <ctc:SettingsCard
@@ -320,7 +320,7 @@
                     Description="{strings:Resources Key=SettingsRestorePlaybackPositionDescription}"
                     Header="{strings:Resources Key=SettingsRestorePlaybackPositionHeader}"
                     HeaderIcon="{ui:FontIcon Glyph=&#xEF3B;}">
-                    <ToggleSwitch IsOn="{x:Bind ViewModel.RestorePlaybackPosition, Mode=TwoWay}" />
+                    <ToggleSwitch AutomationProperties.HelpText="{strings:Resources Key=SettingsRestorePlaybackPositionDescription}" IsOn="{x:Bind ViewModel.RestorePlaybackPosition, Mode=TwoWay}" />
                 </ctc:SettingsCard>
 
                 <!--  Player section  -->

From 31a089772a7dd0f9c383d9b0fa012ad5c90a15a2 Mon Sep 17 00:00:00 2001
From: Tung Huynh <tung75605@gmail.com>
Date: Tue, 25 Feb 2025 00:56:36 -0800
Subject: [PATCH 6/6] fix: last postion doesn't restore on file launch

---
 Screenbox.Core/Helpers/LastPositionTracker.cs | 12 ++---
 Screenbox.Core/ViewModels/SeekBarViewModel.cs | 47 ++++++++++++++-----
 2 files changed, 38 insertions(+), 21 deletions(-)

diff --git a/Screenbox.Core/Helpers/LastPositionTracker.cs b/Screenbox.Core/Helpers/LastPositionTracker.cs
index 2e2bfcfe8..e612b85db 100644
--- a/Screenbox.Core/Helpers/LastPositionTracker.cs
+++ b/Screenbox.Core/Helpers/LastPositionTracker.cs
@@ -15,12 +15,13 @@
 namespace Screenbox.Core.Helpers
 {
     public sealed class LastPositionTracker : ObservableRecipient,
-        IRecipient<SuspendingMessage>,
-        IRecipient<MediaPlayerChangedMessage>
+        IRecipient<SuspendingMessage>
     {
         private const int Capacity = 64;
         private const string SaveFileName = "last_positions.bin";
 
+        public bool IsLoaded => LastUpdated != default;
+
         public DateTimeOffset LastUpdated { get; private set; }
 
         private readonly IFilesService _filesService;
@@ -40,12 +41,6 @@ public void Receive(SuspendingMessage message)
             message.Reply(SaveToDiskAsync());
         }
 
-        public async void Receive(MediaPlayerChangedMessage message)
-        {
-            if (_lastPositions.Count > 0) return;
-            await LoadFromDiskAsync();
-        }
-
         public void UpdateLastPosition(string location, TimeSpan position)
         {
             LastUpdated = DateTimeOffset.Now;
@@ -119,6 +114,7 @@ public async Task LoadFromDiskAsync()
                     await _filesService.LoadFromDiskAsync<List<MediaLastPosition>>(ApplicationData.Current.TemporaryFolder, SaveFileName);
                 lastPositions.Capacity = Capacity;
                 _lastPositions = lastPositions;
+                LastUpdated = DateTimeOffset.UtcNow;
             }
             catch (FileNotFoundException)
             {
diff --git a/Screenbox.Core/ViewModels/SeekBarViewModel.cs b/Screenbox.Core/ViewModels/SeekBarViewModel.cs
index b35101fc7..9f5e695c7 100644
--- a/Screenbox.Core/ViewModels/SeekBarViewModel.cs
+++ b/Screenbox.Core/ViewModels/SeekBarViewModel.cs
@@ -89,19 +89,9 @@ public void Receive(PlaylistCurrentItemChangedMessage message)
         {
             _lastTrackedPosition = TimeSpan.Zero;
             _currentItem = message.Value;
-            if (message.Value != null)
+            if (message.Value != null && _lastPositionTracker.IsLoaded)
             {
-                TimeSpan lastPosition = _lastPositionTracker.GetPosition(message.Value.Location);
-                if (lastPosition <= TimeSpan.Zero) return;
-                if (_settingsService.RestorePlaybackPosition)
-                {
-                    // Media is not seekable yet, so we need to wait for the PlaybackStateChanged event
-                    _lastTrackedPosition = lastPosition;
-                }
-                else
-                {
-                    Messenger.Send(new RaiseResumePositionNotificationMessage(lastPosition));
-                }
+                RestoreLastPosition(message.Value);
             }
         }
 
@@ -118,7 +108,7 @@ public void Receive(PlayerControlsVisibilityChangedMessage message)
             }
         }
 
-        public void Receive(MediaPlayerChangedMessage message)
+        public async void Receive(MediaPlayerChangedMessage message)
         {
             if (_mediaPlayer != null)
             {
@@ -141,6 +131,15 @@ public void Receive(MediaPlayerChangedMessage message)
             _mediaPlayer.BufferingEnded += OnBufferingEnded;
             _mediaPlayer.PlaybackItemChanged += OnPlaybackItemChanged;
             _mediaPlayer.CanSeekChanged += OnCanSeekChanged;
+
+            if (!_lastPositionTracker.IsLoaded)
+            {
+                await _lastPositionTracker.LoadFromDiskAsync();
+                if (_currentItem != null)
+                {
+                    RestoreLastPosition(_currentItem);
+                }
+            }
         }
 
         public void Receive(TimeChangeOverrideMessage message)
@@ -204,6 +203,28 @@ public void OnSeekBarValueChanged(object sender, RangeBaseValueChangedEventArgs
             UpdateLastPosition(newPosition);
         }
 
+        private void RestoreLastPosition(MediaViewModel media)
+        {
+            TimeSpan lastPosition = _lastPositionTracker.GetPosition(media.Location);
+            if (lastPosition <= TimeSpan.Zero) return;
+            if (_settingsService.RestorePlaybackPosition)
+            {
+                if (_mediaPlayer?.PlaybackState is { } and not (MediaPlaybackState.None or MediaPlaybackState.Opening))
+                {
+                    UpdatePosition(lastPosition, false, false);
+                }
+                else
+                {
+                    // Media is not seekable yet, so we need to wait for the PlaybackStateChanged event
+                    _lastTrackedPosition = lastPosition;
+                }
+            }
+            else
+            {
+                Messenger.Send(new RaiseResumePositionNotificationMessage(lastPosition));
+            }
+        }
+
         private PositionChangedResult UpdatePosition(TimeSpan position, bool isOffset, bool debounce)
         {
             TimeSpan currentPosition = Position;