Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/extensions/playable_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ extension PlayableListExtension on List<Playable> {
}

List<Playable> $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)));
Expand Down
14 changes: 12 additions & 2 deletions lib/ui/screens/favorites.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,9 +51,13 @@ class _FavoritesScreenState extends State<FavoritesScreen> {

@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(
Expand Down Expand Up @@ -138,7 +143,12 @@ class _FavoritesScreenState extends State<FavoritesScreen> {
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) {
Expand Down
14 changes: 12 additions & 2 deletions lib/ui/screens/playlist_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -55,9 +56,13 @@ class _PlaylistDetailsScreen extends State<PlaylistDetailsScreen> {
@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(
Expand Down Expand Up @@ -98,7 +103,12 @@ class _PlaylistDetailsScreen extends State<PlaylistDetailsScreen> {
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) {
Expand Down
1 change: 1 addition & 0 deletions lib/ui/widgets/playable_list_sort_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class SortButton extends StatelessWidget {
SortOrder currentOrder;

static const sortFields = {
'position': 'Custom order',
'track': 'Track number',
'disc': 'Disc number',
'title': 'Title',
Expand Down
6 changes: 6 additions & 0 deletions lib/utils/features.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ 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<Feature, String> supportedVersionMap = {
Feature.podcasts: '7.0.0',
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 {
Expand Down
46 changes: 46 additions & 0 deletions test/extensions/playable_list_test.dart
Original file line number Diff line number Diff line change
@@ -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<Playable> customOrder() => <Playable>[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]);
});
});
}
30 changes: 30 additions & 0 deletions test/utils/features_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
Loading