Skip to content

Feature: Saved Queues#724

Open
WhatTheShuck wants to merge 8 commits into
BLeeEZ:masterfrom
WhatTheShuck:feature/saved-queues
Open

Feature: Saved Queues#724
WhatTheShuck wants to merge 8 commits into
BLeeEZ:masterfrom
WhatTheShuck:feature/saved-queues

Conversation

@WhatTheShuck

Copy link
Copy Markdown

Add the ability to "Save" queues after they are cleared. This is a feature I used in Symfonium when I was on Android, which I really enjoyed. It basically gives you the ability to get back your previous queues, with position and related information, after you've listened to something else. This would also solve #329, which I commented on.
I added another line to the Library View.
CleanShot 2026-06-15 at 22 09 34
Clicking on the line for it gives you a list of your previous queues. They have a small amount of metadata in the name to help identify. Clicking the queue starts playing, while the i symbol gives you a detailed section on the queue with the song list, ability to reshuffle or resume it, as well as Renaming, deleting or saving the queue to a playlist
CleanShot 2026-06-15 at 22 12 48

I decided that the "tap to play", rather than the typical tap for the list of songs approach seen throughout the rest of the app, was the right choice here due to the nature of how you interact with a saved queue. Typically you just want to resume it. I am happy to change that if necessary, but I tested both and this way felt much more natural.

My dev cert doesn't have carplay rights, so the carplay testing is only as far as the simulator, but it seems to work well in there.

Finally, this should also support other features I am working on handoff and cross device sync. That being said, I am working on those independent of this feature. They just feel nicer when you have your queue saved somewhere, rather than overwriting it.

I hope it is not too bad, this is my first contribution of this size to a project of this size. My apologies in advance! Thank you for the wonderful app though :D

@BLeeEZ

BLeeEZ commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Hi,
first of all thank you for this PR. There are a couple of points that need clarification.

  1. Amperfy is a client and all data (if possible) should be saved/pollen to/from server. In this case here we should be aligned to the subsonic/OpenSubsonic definition of a PlayQueue: https://subsonic.org/pages/api.jsp#getPlayQueue / https://opensubsonic.netlify.app/docs/endpoints/saveplayqueuebyindex/ this allows the user to keep the information save on the server.
  2. As indicated by 1. PlayQueues should be bound to accounts. So different accounts can have different PlayQueues sync with their server. This forces to save the current player playlist to filter for current active account.
  3. PlayQueues are from my point of view Playlists with a new member "position". So in CoreData PlayQueue should be derived from Playlist an enhance it with the new member.
  4. The parsing and requesting of Subsonic PlayQueues need to be supported as well as the creation/save endpoint to server.
  5. Ampache doesn't have such a endpoint and can be omitted for this feature.

@WhatTheShuck

Copy link
Copy Markdown
Author

Hi!
Sorry about the haziness of details!
The playQueue endpoint / data do not really align with what I am trying to achieve here. From my understand of the subsonic API, the get and set endpoints for the the queue are for a single queue. Any new queue set by savePlayQueue will overwrite any previous queue. This is confirmed by the "OK" response from the server when using that endpoint, rather than responding with a playQueue ID or similar (to selectively getPlayQueue). My understanding of the use case for this endpoint is between device sync, not preserving an overwritten queue. The basis of my use case is for preserving previous queues (many) not just the last queue.
I completely understand the view of trying to keep this to the server, however, the only way I could see to achieve multiple queues is to leverage a playlist as a "queue" for previous queues. However, I think this is messy and not really respecting the user (polluting their server playlists). This is why I kept everything on device, as I understand it to be local device queues.

Regarding 2. Obviously my above explanation covers why I am not using playqueues specifically, however, I do still account for per Per account binding because I agree with your stance on this. Saved queues have a required toAccount Core Data relationship (SavedQueueMO.toAccount ↔ AccountMO.savedQueues), and every save/fetch/delete in LibraryStorage is filtered by it. getSavedQueues(for:) uses NSPredicate("%K == %@", toAccount, account), and snapshots are always taken against settings.accounts.active. I also made sure to cover this in testing with testDeleteAll_RemovesQueuesForAccountOnly.

For 3. I considered using playlists as a data type, it was most first instinct on how to derive the data. However, I saw a few key drawbacks that led me into the flat JSON blob that I settled on, namely: storage and speed. A 500 track blob in the current format is about 10KB of storage and one row. Scale that to the default 20 queues kept and they are not really taking up much space: 20 rows, 200KB. A similar scenario with the playlist implementation would be about 50KB and 501 rows. Scaling that up, is much bigger than the current design.
Speed is the other factor because we update so frequently. I call snapshotIfNeeded every time playback changes, to ensure that the position is correct. My current version encodes two arrays and sets two blobs on queue save, which is one sequential write. If we used a derivative of playlist, the high-frequency saving would turn it into N row inserts with full relationship and index maintenance. It's more work landing on the hottest path of the feature.

I don't think 4 and 5 need direct addressing because of all the stuff mentioned above. It's a misalignment on the feature, from my perspective. That being said, I do plan to open PRs for this, as I do want cross device sync. I have some code working at the moment, that I am testing, which uses the playQueue endpoint for picking up your current queue on a different device. Then I built in an opt-in (opted out by default) iCloud sync as a backup for Amperfy and offline playback scenarios. This is a completely different feature though.

I hope that clears it up a bit. Sorry that it was not clear in the first place!

@BLeeEZ

BLeeEZ commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Thank you for the clarification. I understand the purpose much better now. You're right that the APIs only support a single queue for device switching.

I do have a few concerns about the proposed approach. Saved queues are separated by account, but the player itself operates globally. Since users can mix songs from different accounts in the player, wouldn't it be confusing if SavedQueues were account-specific?

There are also a couple of implementation details that would need to be adjusted:

  • Song IDs need to be persisted in Core Data. This means queues should be stored as an array of Core Data song objects, similar to how playlists are implemented.
  • SavedQueue should conform to PlayableContainable. This would allow it to display a preview and make its interaction consistent with other playable containers.
  • applying a SavedQueue should be done via the player and not via manipulting the PlayerData and QueueHandler directly (appDelegate.savedQueues.restore(savedQueue)). The function playCurrentItem should be removed. When SavedQueue conforms to PlayableContainable this could be directly applied to the player via a simple play().

Address upstream maintainer PR feedback on the saved-queues feature:

- SavedQueueMO now owns two system PlaylistMOs (context + user queue),
  mirroring PlayerMO, instead of storing JSON song-ID blobs. The v50
  model drops the Account.savedQueues relationship so queues are global
  and mixed-account queues restore correctly.
- LibraryStorage saved-queue CRUD is account-free.
- SavedQueue conforms to PlayableContainable (preview + standard play).
- Faithful resume moves behind PlayerFacade.restore(savedQueue:);
  playCurrentItem is removed from the public PlayerFacade protocol.
- saveAsPlaylist filters to the active account and reports skipped songs;
  logout no longer deletes saved queues.
- View controllers, CarPlay, and the full test suite updated for the
  object-backed, account-agnostic model.
@WhatTheShuck

Copy link
Copy Markdown
Author

Hopefully these changes should address most of your concerns. Definitely my mistake on the account specific part. I am a single account user and didn't actually realise you could mix like that. The changes should fix this to allow for it to be account agnostic. There is a small piece in there which does scrutinise the account though, which is for saving a queue to a playlist, as it it inherently needs to only pull in songs that are available for the playlists destination account.
I also made the songs persist in Core Data like playlists and added the new Core Data model version, migration code and a test for the migration. The savedQueue also conforms to PlayableContainable now, as requested.

The pieces I didn't implement was the applying a SavedQueue via the generic play() call and playCurrentItem still being in there. My reasoning being that I don't feel like it encompasses enough of what I am trying to achieve here. Restoring a queue needs to persist more state than the current play call can handle, such as shuffle, repeat and the user queue stuff. If I just pass it into play(), these are all stripped. If you want the implementation to strip that data out, that is your call, but I think it is more friendly to faithfully restore the state that the user had at the time of the queue saving.

Because of that, restore() sets up a queue manually, instead of going through play(context:), as applying in the order of shuffle and repeat -> context queue -> user queue prevents reshuffling the already filled queue. This is something I ran into while implementing and basically the reasoning behind restore(), as applying directly through clear -> fill -> play of play(context:) does not restore the extra state. The playCurrentItem is just the last step of that process where it tell s the backend to load whatever is now current, after the queue and index are set correctly. It goes through the same insertIntoPlayer call that the play(), play(context) and skip next/prev all use internally, it's just not rebuilding the queue first. So it is less of a separate playback path, more a piece needed to sync the backend after a manual restore

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants