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
11 changes: 11 additions & 0 deletions lib/models/user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ class User {
String name;
String email;

/// The user's preferred order of Home screen blocks, by block id. Empty
/// when the server doesn't expose the preference (older API) or the user
/// never reordered the blocks.
final List<String> homeBlocksOrder;

User({
required this.id,
required this.name,
required this.email,
this.homeBlocksOrder = const [],
});

CachedNetworkImageProvider get avatar {
Expand All @@ -21,10 +27,15 @@ class User {
}

factory User.fromJson(Map<String, dynamic> json) {
final preferences = json['preferences'];
final order = preferences is Map ? preferences['home_blocks_order'] : null;

return User(
id: json['id'],
name: json['name'],
email: json['email'],
homeBlocksOrder:
order is List ? order.whereType<String>().toList() : const [],
);
}
}
6 changes: 4 additions & 2 deletions lib/providers/auth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import 'package:app/utils/api_request.dart';
import 'package:app/utils/preferences.dart' as preferences;

class AuthProvider with StreamSubscriber {
late User _authUser;
User? _authUser;

User get authUser => _authUser;
User get authUser => _authUser!;

User? get maybeAuthUser => _authUser;

static final _userLoggedIn = StreamController<User>.broadcast();
static final userLoggedInStream = _userLoggedIn.stream;
Expand Down
92 changes: 55 additions & 37 deletions lib/providers/overview_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,30 @@ class OverviewProvider with ChangeNotifier, StreamSubscriber {
final mostPlayedSongs = <Playable>[];
final recentlyAddedSongs = <Playable>[];
final recentlyPlayedSongs = <Playable>[];
final recentlyAddedAlbums = <Album>[];
final leastPlayedSongs = <Playable>[];
final randomSongs = <Playable>[];
final similarSongs = <Playable>[];
final mostPlayedAlbums = <Album>[];
final recentlyAddedAlbums = <Album>[];
final randomAlbums = <Album>[];
final mostPlayedArtists = <Artist>[];
final recentlyAddedArtists = <Artist>[];
final randomArtists = <Artist>[];

late final List<List<dynamic>> _allSections = [
mostPlayedSongs,
recentlyAddedSongs,
recentlyPlayedSongs,
leastPlayedSongs,
randomSongs,
similarSongs,
mostPlayedAlbums,
recentlyAddedAlbums,
randomAlbums,
mostPlayedArtists,
recentlyAddedArtists,
randomArtists,
];

OverviewProvider({
required playableProvider,
Expand All @@ -27,58 +48,55 @@ class OverviewProvider with ChangeNotifier, StreamSubscriber {
_artistProvider = artistProvider,
_recentlyPlayedProvider = recentlyPlayedProvider {
subscribe(AuthProvider.userLoggedOutStream.listen((_) {
mostPlayedSongs.clear();
recentlyAddedSongs.clear();
recentlyPlayedSongs.clear();
mostPlayedAlbums.clear();
mostPlayedArtists.clear();

for (final section in _allSections) section.clear();
notifyListeners();
}));
}

bool get isEmpty =>
mostPlayedSongs.isEmpty &&
recentlyAddedSongs.isEmpty &&
recentlyPlayedSongs.isEmpty &&
mostPlayedAlbums.isEmpty &&
mostPlayedArtists.isEmpty;
bool get isEmpty => _allSections.every((section) => section.isEmpty);

Future<void> refresh() async {
final Map<String, dynamic> response = await get('overview');

mostPlayedSongs
..clear()
..addAll(_playableProvider.parseFromJson(response['most_played_songs']));
_fill(mostPlayedSongs, _parseSongs(response['most_played_songs']));
_fill(recentlyAddedSongs, _parseSongs(response['recently_added_songs']));
_fill(recentlyPlayedSongs, _parseSongs(response['recently_played_songs']));
_fill(leastPlayedSongs, _parseSongs(response['least_played_songs']));
_fill(randomSongs, _parseSongs(response['random_songs']));
_fill(similarSongs, _parseSongs(response['similar_songs']));

recentlyAddedSongs
..clear()
..addAll(
_playableProvider.parseFromJson(response['recently_added_songs']));
_fill(mostPlayedAlbums, _parseAlbums(response['most_played_albums']));
_fill(recentlyAddedAlbums, _parseAlbums(response['recently_added_albums']));
_fill(randomAlbums, _parseAlbums(response['random_albums']));

recentlyPlayedSongs
..clear()
..addAll(
_playableProvider.parseFromJson(response['recently_played_songs']));
_fill(mostPlayedArtists, _parseArtists(response['most_played_artists']));
_fill(recentlyAddedArtists,
_parseArtists(response['recently_added_artists']));
_fill(randomArtists, _parseArtists(response['random_artists']));

_recentlyPlayedProvider.seed(recentlyPlayedSongs);

final _mostPlayedAlbums = response['most_played_albums']
.map<Album>((j) => Album.fromJson(j))
.toList();
notifyListeners();
}

mostPlayedAlbums
..clear()
..addAll(_albumProvider.syncWithVault(_mostPlayedAlbums));
List<Playable> _parseSongs(dynamic json) =>
_playableProvider.parseFromJson(json ?? const []);

final _mostPlayedArtist = response['most_played_artists']
.map<Artist>((j) => Artist.fromJson(j))
.toList();
List<Album> _parseAlbums(dynamic json) => _albumProvider.syncWithVault(
((json ?? const []) as List)
.map<Album>((entry) => Album.fromJson(entry))
.toList(),
);

mostPlayedArtists
..clear()
..addAll(_artistProvider.syncWithVault(_mostPlayedArtist));
List<Artist> _parseArtists(dynamic json) => _artistProvider.syncWithVault(
((json ?? const []) as List)
.map<Artist>((entry) => Artist.fromJson(entry))
.toList(),
);

notifyListeners();
void _fill<T>(List<T> target, List<T> source) {
target
..clear()
..addAll(source);
}
}
184 changes: 137 additions & 47 deletions lib/ui/screens/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ class HomeScreen extends StatefulWidget {
State<StatefulWidget> createState() => _HomeScreenState();
}

class _HomeBlock {
final String id;
final Widget widget;

const _HomeBlock(this.id, this.widget);
}

/// Reorders [blocks] to honour the user's saved [savedOrder] (a list of block
/// ids). Blocks named in [savedOrder] come first in that order; the rest keep
/// their default relative order. Ids in [savedOrder] with no matching block are
/// ignored. Returns [blocks] unchanged when no preference is saved.
List<T> orderByHomeBlocksPreference<T>(
List<T> blocks,
String Function(T) idOf,
List<String> savedOrder,
) {
if (savedOrder.isEmpty) return blocks;

final indexed = blocks.asMap().entries.toList();

int sortKey(MapEntry<int, T> entry) {
final saved = savedOrder.indexOf(idOf(entry.value));
return saved == -1 ? savedOrder.length + entry.key : saved;
}

indexed.sort((left, right) => sortKey(left).compareTo(sortKey(right)));

return indexed.map((entry) => entry.value).toList();
}

class _HomeScreenState extends State<HomeScreen> {
var _loading = false;
var _errored = false;
Expand Down Expand Up @@ -46,62 +76,122 @@ class _HomeScreenState extends State<HomeScreen> {
}
}

Widget _songBlock(String heading, List<Playable> songs) {
return HorizontalCardScroller(
headingText: heading,
cards: <Widget>[
...songs.map((playable) => SongCard(playable: playable)),
PlaceholderCard(
icon: CupertinoIcons.music_note,
onPressed: () => Navigator.of(context).push(
CupertinoPageRoute(builder: (_) => SongsScreen()),
),
),
],
);
}

Widget _albumBlock(String heading, List<Album> albums, Set seenAlbumIds) {
return HorizontalCardScroller(
headingText: heading,
cards: <Widget>[
...albums.map((album) =>
AlbumCard(album: album, asHero: seenAlbumIds.add(album.id))),
PlaceholderCard(
icon: CupertinoIcons.music_albums,
onPressed: () => Navigator.of(context).push(
CupertinoPageRoute(builder: (_) => AlbumsScreen()),
),
),
],
);
}

Widget _artistBlock(String heading, List<Artist> artists, Set seenArtistIds) {
return HorizontalCardScroller(
headingText: heading,
cards: <Widget>[
...artists.map((artist) =>
ArtistCard(artist: artist, asHero: seenArtistIds.add(artist.id))),
PlaceholderCard(
icon: CupertinoIcons.music_mic,
circular: true,
onPressed: () => Navigator.of(context).push(
CupertinoPageRoute(builder: (_) => const ArtistsScreen()),
),
),
],
);
}

@override
Widget build(BuildContext context) {
return Consumer<OverviewProvider>(
builder: (_, overviewProvider, __) {
if (_loading) return const HomeScreenPlaceholder();
if (_errored) return OopsBox(onRetry: fetchData);

final blocks = <Widget>[
if (overviewProvider.mostPlayedSongs.isNotEmpty)
HorizontalCardScroller(
headingText: 'Most played',
cards: <Widget>[
...overviewProvider.mostPlayedSongs
.map((playable) => SongCard(playable: playable)),
PlaceholderCard(
icon: CupertinoIcons.music_note,
onPressed: () => Navigator.of(context).push(
CupertinoPageRoute(builder: (_) => SongsScreen()),
),
),
],
),
if (overviewProvider.mostPlayedAlbums.isNotEmpty)
HorizontalCardScroller(
headingText: 'Top albums',
cards: <Widget>[
...overviewProvider.mostPlayedAlbums
.map((album) => AlbumCard(album: album)),
PlaceholderCard(
icon: CupertinoIcons.music_albums,
onPressed: () => Navigator.of(context).push(
CupertinoPageRoute(builder: (_) => AlbumsScreen()),
),
),
],
),
if (overviewProvider.mostPlayedArtists.isNotEmpty)
HorizontalCardScroller(
headingText: 'Top artists',
cards: <Widget>[
...overviewProvider.mostPlayedArtists
.map((artist) => ArtistCard(artist: artist)),
PlaceholderCard(
icon: CupertinoIcons.music_mic,
circular: true,
onPressed: () => Navigator.of(context).push(
CupertinoPageRoute(builder: (_) => const ArtistsScreen()),
),
),
],
),
]
final op = overviewProvider;

// Grant the hero animation to only the first card per entity, so the
// same album/artist appearing in several blocks doesn't produce
// duplicate hero tags within the route.
final seenAlbumIds = <dynamic>{};
final seenArtistIds = <dynamic>{};

// Default order mirrors koel web's default home layout (minus the
// recently-played section, which the mobile app pins to the top).
final defaultBlocks = <_HomeBlock>[
if (op.recentlyAddedAlbums.isNotEmpty)
_HomeBlock('recently-added-albums',
_albumBlock('Latest Albums', op.recentlyAddedAlbums, seenAlbumIds)),
if (op.similarSongs.isNotEmpty)
_HomeBlock('similar-songs',
_songBlock('You Might Also Like', op.similarSongs)),
if (op.mostPlayedAlbums.isNotEmpty)
_HomeBlock('most-played-albums',
_albumBlock('Top Albums', op.mostPlayedAlbums, seenAlbumIds)),
if (op.mostPlayedSongs.isNotEmpty)
_HomeBlock('most-played-songs',
_songBlock('Most Played', op.mostPlayedSongs)),
if (op.mostPlayedArtists.isNotEmpty)
_HomeBlock('most-played-artists',
_artistBlock('Top Artists', op.mostPlayedArtists, seenArtistIds)),
if (op.recentlyAddedSongs.isNotEmpty)
_HomeBlock('recently-added-songs',
_songBlock('New Songs', op.recentlyAddedSongs)),
if (op.recentlyAddedArtists.isNotEmpty)
_HomeBlock(
'recently-added-artists',
_artistBlock(
'New Artists', op.recentlyAddedArtists, seenArtistIds)),
if (op.leastPlayedSongs.isNotEmpty)
_HomeBlock('least-played-songs',
_songBlock('Hidden Gems', op.leastPlayedSongs)),
if (op.randomSongs.isNotEmpty)
_HomeBlock(
'random-songs', _songBlock('Random Songs', op.randomSongs)),
if (op.randomAlbums.isNotEmpty)
_HomeBlock('random-albums',
_albumBlock('Random Albums', op.randomAlbums, seenAlbumIds)),
if (op.randomArtists.isNotEmpty)
_HomeBlock('random-artists',
_artistBlock('Random Artists', op.randomArtists, seenArtistIds)),
];

final savedOrder =
context.read<AuthProvider>().maybeAuthUser?.homeBlocksOrder ??
const <String>[];

final blocks = orderByHomeBlocksPreference<_HomeBlock>(
defaultBlocks,
(block) => block.id,
savedOrder,
)
.map(
(widget) => Padding(
(block) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: widget,
child: block.widget,
),
)
.toList();
Expand Down
Loading
Loading