From d5e0b30ddd0d7147b531d0830aec0738825ed36b Mon Sep 17 00:00:00 2001 From: Phan An Date: Sun, 12 Jul 2026 22:01:40 +0200 Subject: [PATCH 1/4] Respect a playlist's custom order (#202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app always re-sorted playlist songs (default by title), discarding the order the server returns — which for a regular playlist is the custom order defined on the web (pivot `position`). Add a "Custom order" sort that preserves the fetched order (reversible), make it the default for playlists, and make `$sort` return a new list so it no longer mutates the cached playlist in place. --- lib/extensions/playable_list.dart | 8 +++- lib/ui/screens/playlist_details.dart | 4 +- lib/ui/widgets/playable_list_sort_button.dart | 1 + test/extensions/playable_list_test.dart | 44 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 test/extensions/playable_list_test.dart diff --git a/lib/extensions/playable_list.dart b/lib/extensions/playable_list.dart index 86159923..5158051d 100644 --- a/lib/extensions/playable_list.dart +++ b/lib/extensions/playable_list.dart @@ -14,7 +14,13 @@ extension PlayableListExtension on List { } List $sort(PlayableSortConfig config) { - return this + // 'position' keeps the order the server returned (e.g. a playlist's own + // order defined on the web), reversible via the sort direction. + if (config.field == 'position') { + return config.order == SortOrder.asc ? [...this] : reversed.toList(); + } + + return [...this] ..sort((a, b) => config.order == SortOrder.asc ? a.valueToCompare(config).compareTo(b.valueToCompare(config)) : b.valueToCompare(config).compareTo(a.valueToCompare(config))); diff --git a/lib/ui/screens/playlist_details.dart b/lib/ui/screens/playlist_details.dart index 91478a54..eae4c1ec 100644 --- a/lib/ui/screens/playlist_details.dart +++ b/lib/ui/screens/playlist_details.dart @@ -57,7 +57,7 @@ class _PlaylistDetailsScreen extends State { final playlist = ModalRoute.of(context)!.settings.arguments as Playlist; var sortConfig = AppState.get( 'playlist.sort', - PlayableSortConfig(field: 'title', order: SortOrder.asc), + PlayableSortConfig(field: 'position', order: SortOrder.asc), )!; return Scaffold( @@ -98,7 +98,7 @@ class _PlaylistDetailsScreen extends State { backgroundImage: _buildBackgroundImage(playlist, playables), actions: [ SortButton( - fields: ['title', 'artist_name', 'created_at'], + fields: ['position', 'title', 'artist_name', 'created_at'], currentField: sortConfig.field, currentOrder: sortConfig.order, onMenuItemSelected: (_sortConfig) { diff --git a/lib/ui/widgets/playable_list_sort_button.dart b/lib/ui/widgets/playable_list_sort_button.dart index 3b0e082f..c68aee55 100644 --- a/lib/ui/widgets/playable_list_sort_button.dart +++ b/lib/ui/widgets/playable_list_sort_button.dart @@ -12,6 +12,7 @@ class SortButton extends StatelessWidget { SortOrder currentOrder; static const sortFields = { + 'position': 'Custom order', 'track': 'Track number', 'disc': 'Disc number', 'title': 'Title', diff --git a/test/extensions/playable_list_test.dart b/test/extensions/playable_list_test.dart new file mode 100644 index 00000000..04b71aa5 --- /dev/null +++ b/test/extensions/playable_list_test.dart @@ -0,0 +1,44 @@ +import 'package:app/enums.dart'; +import 'package:app/extensions/extensions.dart'; +import 'package:app/models/models.dart'; +import 'package:app/values/values.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Song a, b, c; + + setUp(() { + a = Song.fake(title: 'A'); + b = Song.fake(title: 'B'); + c = Song.fake(title: 'C'); + }); + + // Deliberately not alphabetical — mimics a playlist's custom order. + List customOrder() => [c, a, b]; + + PlayableSortConfig config(String field, SortOrder order) => + PlayableSortConfig(field: field, order: order); + + group('\$sort', () { + test('position keeps the server-provided order', () { + final sorted = customOrder().$sort(config('position', SortOrder.asc)); + expect(sorted, [c, a, b]); + }); + + test('position descending reverses the server-provided order', () { + final sorted = customOrder().$sort(config('position', SortOrder.desc)); + expect(sorted, [b, a, c]); + }); + + test('other fields still sort', () { + final sorted = customOrder().$sort(config('title', SortOrder.asc)); + expect(sorted.map((playable) => (playable as Song).title), ['A', 'B', 'C']); + }); + + test('does not mutate the original list', () { + final original = customOrder(); + original.$sort(config('title', SortOrder.asc)); + expect(original, [c, a, b]); + }); + }); +} From 7677c2b42dbb21b8bcd01f86e8e8d9c1adb74be2 Mon Sep 17 00:00:00 2001 From: Phan An Date: Sun, 12 Jul 2026 22:04:20 +0200 Subject: [PATCH 2/4] Respect the custom order on the Favorites screen too Favorites carry a server-side custom order (favorites.position, with a favorites/move endpoint) just like playlists. Default the Favorites screen to Custom order and offer it in the sort button. --- lib/ui/screens/favorites.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/ui/screens/favorites.dart b/lib/ui/screens/favorites.dart index d842b138..d12b0487 100644 --- a/lib/ui/screens/favorites.dart +++ b/lib/ui/screens/favorites.dart @@ -52,7 +52,7 @@ class _FavoritesScreenState extends State { Widget build(BuildContext context) { var sortConfig = AppState.get( 'favorites.sort', - PlayableSortConfig(field: 'title', order: SortOrder.asc), + PlayableSortConfig(field: 'position', order: SortOrder.asc), )!; final emptyWidget = SliverFillRemaining( @@ -138,7 +138,12 @@ class _FavoritesScreenState extends State { provider.playables), actions: [ SortButton( - fields: ['title', 'artist_name', 'created_at'], + fields: [ + 'position', + 'title', + 'artist_name', + 'created_at' + ], currentField: sortConfig.field, currentOrder: sortConfig.order, onMenuItemSelected: (_sortConfig) { From 90d077f6ed3035316c643589db67a34774436095 Mon Sep 17 00:00:00 2001 From: Phan An Date: Sun, 12 Jul 2026 22:19:00 +0200 Subject: [PATCH 3/4] Gate the Custom order default on server support Playlist ordering landed in koel 7.0.2 and favorites ordering in 9.0.0. On older servers the returned order isn't a custom order, so gate the "Custom order" default (and menu entry) behind the server version, falling back to the previous Title default. Nothing reads a per-song position field, so the sort itself was already safe on any server. --- lib/ui/screens/favorites.dart | 9 +++++++-- lib/ui/screens/playlist_details.dart | 14 +++++++++++-- lib/utils/features.dart | 6 ++++++ test/utils/features_test.dart | 30 ++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/lib/ui/screens/favorites.dart b/lib/ui/screens/favorites.dart index d12b0487..e97c3649 100644 --- a/lib/ui/screens/favorites.dart +++ b/lib/ui/screens/favorites.dart @@ -5,6 +5,7 @@ import 'package:app/extensions/extensions.dart'; import 'package:app/providers/providers.dart'; import 'package:app/ui/placeholders/placeholders.dart'; import 'package:app/ui/widgets/widgets.dart'; +import 'package:app/utils/features.dart'; import 'package:app/values/values.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' hide AppBar; @@ -50,9 +51,13 @@ class _FavoritesScreenState extends State { @override Widget build(BuildContext context) { + final supportsCustomOrder = Feature.customFavoritesOrder.isSupported(); var sortConfig = AppState.get( 'favorites.sort', - PlayableSortConfig(field: 'position', order: SortOrder.asc), + PlayableSortConfig( + field: supportsCustomOrder ? 'position' : 'title', + order: SortOrder.asc, + ), )!; final emptyWidget = SliverFillRemaining( @@ -139,7 +144,7 @@ class _FavoritesScreenState extends State { actions: [ SortButton( fields: [ - 'position', + if (supportsCustomOrder) 'position', 'title', 'artist_name', 'created_at' diff --git a/lib/ui/screens/playlist_details.dart b/lib/ui/screens/playlist_details.dart index eae4c1ec..bdbf4f65 100644 --- a/lib/ui/screens/playlist_details.dart +++ b/lib/ui/screens/playlist_details.dart @@ -5,6 +5,7 @@ import 'package:app/models/models.dart'; import 'package:app/providers/providers.dart'; import 'package:app/ui/placeholders/placeholders.dart'; import 'package:app/ui/widgets/widgets.dart'; +import 'package:app/utils/features.dart'; import 'package:app/values/values.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -55,9 +56,13 @@ class _PlaylistDetailsScreen extends State { @override Widget build(BuildContext context) { final playlist = ModalRoute.of(context)!.settings.arguments as Playlist; + final supportsCustomOrder = Feature.customPlaylistOrder.isSupported(); var sortConfig = AppState.get( 'playlist.sort', - PlayableSortConfig(field: 'position', order: SortOrder.asc), + PlayableSortConfig( + field: supportsCustomOrder ? 'position' : 'title', + order: SortOrder.asc, + ), )!; return Scaffold( @@ -98,7 +103,12 @@ class _PlaylistDetailsScreen extends State { backgroundImage: _buildBackgroundImage(playlist, playables), actions: [ SortButton( - fields: ['position', 'title', 'artist_name', 'created_at'], + fields: [ + if (supportsCustomOrder) 'position', + 'title', + 'artist_name', + 'created_at' + ], currentField: sortConfig.field, currentOrder: sortConfig.order, onMenuItemSelected: (_sortConfig) { diff --git a/lib/utils/features.dart b/lib/utils/features.dart index a3ab1245..a94cb409 100644 --- a/lib/utils/features.dart +++ b/lib/utils/features.dart @@ -8,6 +8,10 @@ enum Feature { // Favorite/unfavorite for albums, artists, radio stations, and // podcasts (the song-level "like" predates this). favoriteEntities, + // A user-defined order for a playlist's songs (pivot `position`). + customPlaylistOrder, + // A user-defined order for favorite songs (`favorites.position`). + customFavoritesOrder, } Map supportedVersionMap = { @@ -15,6 +19,8 @@ Map supportedVersionMap = { Feature.queueStateSync: '6.11.6', Feature.radioStations: '7.13.0', Feature.favoriteEntities: '7.11.0', + Feature.customPlaylistOrder: '7.0.2', + Feature.customFavoritesOrder: '9.0.0', }; extension FeatureExtension on Feature { diff --git a/test/utils/features_test.dart b/test/utils/features_test.dart index b3c751bf..7d9ab1b6 100644 --- a/test/utils/features_test.dart +++ b/test/utils/features_test.dart @@ -48,6 +48,36 @@ void main() { }); }); + group('Feature.customPlaylistOrder', () { + test('is supported at 7.0.2 and above', () { + AppState.set(['app', 'apiVersion'], Version.parse('7.0.2')); + expect(Feature.customPlaylistOrder.isSupported(), isTrue); + AppState.set(['app', 'apiVersion'], Version.parse('9.0.0')); + expect(Feature.customPlaylistOrder.isSupported(), isTrue); + }); + + test('is not supported below 7.0.2', () { + AppState.set(['app', 'apiVersion'], Version.parse('7.0.1')); + expect(Feature.customPlaylistOrder.isSupported(), isFalse); + }); + }); + + group('Feature.customFavoritesOrder', () { + test('is supported at 9.0.0 and above', () { + AppState.set(['app', 'apiVersion'], Version.parse('9.0.0')); + expect(Feature.customFavoritesOrder.isSupported(), isTrue); + }); + + test('is not supported below 9.0.0', () { + AppState.set(['app', 'apiVersion'], Version.parse('8.9.9')); + expect(Feature.customFavoritesOrder.isSupported(), isFalse); + }); + + test('is not supported when API version is not set', () { + expect(Feature.customFavoritesOrder.isSupported(), isFalse); + }); + }); + group('Feature.podcasts', () { test('is supported when API version is 7.0.0 or above', () { AppState.set(['app', 'apiVersion'], Version.parse('7.0.0')); From e99d7aad1370b7a76f52df53f34f85dbabc780cf Mon Sep 17 00:00:00 2001 From: Phan An Date: Sun, 12 Jul 2026 22:21:25 +0200 Subject: [PATCH 4/4] Address review: descriptive test names + position immutability check Rename songA/B/C and also assert $sort doesn't mutate the original list when sorting by position. --- test/extensions/playable_list_test.dart | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/test/extensions/playable_list_test.dart b/test/extensions/playable_list_test.dart index 04b71aa5..d2ac67c3 100644 --- a/test/extensions/playable_list_test.dart +++ b/test/extensions/playable_list_test.dart @@ -5,16 +5,16 @@ import 'package:app/values/values.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - late Song a, b, c; + late Song songA, songB, songC; setUp(() { - a = Song.fake(title: 'A'); - b = Song.fake(title: 'B'); - c = Song.fake(title: 'C'); + songA = Song.fake(title: 'A'); + songB = Song.fake(title: 'B'); + songC = Song.fake(title: 'C'); }); // Deliberately not alphabetical — mimics a playlist's custom order. - List customOrder() => [c, a, b]; + List customOrder() => [songC, songA, songB]; PlayableSortConfig config(String field, SortOrder order) => PlayableSortConfig(field: field, order: order); @@ -22,12 +22,12 @@ void main() { group('\$sort', () { test('position keeps the server-provided order', () { final sorted = customOrder().$sort(config('position', SortOrder.asc)); - expect(sorted, [c, a, b]); + expect(sorted, [songC, songA, songB]); }); test('position descending reverses the server-provided order', () { final sorted = customOrder().$sort(config('position', SortOrder.desc)); - expect(sorted, [b, a, c]); + expect(sorted, [songB, songA, songC]); }); test('other fields still sort', () { @@ -38,7 +38,9 @@ void main() { test('does not mutate the original list', () { final original = customOrder(); original.$sort(config('title', SortOrder.asc)); - expect(original, [c, a, b]); + original.$sort(config('position', SortOrder.asc)); + original.$sort(config('position', SortOrder.desc)); + expect(original, [songC, songA, songB]); }); }); }