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/favorites.dart b/lib/ui/screens/favorites.dart index d842b138..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: 'title', order: SortOrder.asc), + PlayableSortConfig( + field: supportsCustomOrder ? 'position' : 'title', + order: SortOrder.asc, + ), )!; final emptyWidget = SliverFillRemaining( @@ -138,7 +143,12 @@ class _FavoritesScreenState extends State { provider.playables), actions: [ SortButton( - fields: ['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/ui/screens/playlist_details.dart b/lib/ui/screens/playlist_details.dart index 91478a54..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: 'title', 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: ['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/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/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/extensions/playable_list_test.dart b/test/extensions/playable_list_test.dart new file mode 100644 index 00000000..d2ac67c3 --- /dev/null +++ b/test/extensions/playable_list_test.dart @@ -0,0 +1,46 @@ +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 songA, songB, songC; + + setUp(() { + 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() => [songC, songA, songB]; + + 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, [songC, songA, songB]); + }); + + test('position descending reverses the server-provided order', () { + final sorted = customOrder().$sort(config('position', SortOrder.desc)); + expect(sorted, [songB, songA, songC]); + }); + + 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)); + original.$sort(config('position', SortOrder.asc)); + original.$sort(config('position', SortOrder.desc)); + expect(original, [songC, songA, songB]); + }); + }); +} 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'));