diff --git a/build.yaml b/build.yaml index d83d6a20..8dbfe45d 100644 --- a/build.yaml +++ b/build.yaml @@ -8,3 +8,10 @@ targets: options: any_map: true explicit_to_json: true + drift_dev: + options: + sql: + dialect: sqlite + options: + modules: + - json1 diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 7391d3a0..31f97e0c 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -1,6 +1,8 @@ import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/history/summary.dart'; abstract class FakeData { static final Image image = Image() @@ -222,4 +224,36 @@ abstract class FakeData { ) ], ); + + static const historySummary = PlaybackHistorySummary( + albums: 1, + artists: 1, + duration: Duration(seconds: 1), + playlists: 1, + tracks: 1, + fees: 1, + ); + + static final historyRecentlyPlayedPlaylist = HistoryTableData( + id: 0, + type: HistoryEntryType.track, + createdAt: DateTime.now(), + itemId: "1", + data: playlist.toJson(), + ); + + static final historyRecentlyPlayedAlbum = HistoryTableData( + id: 0, + type: HistoryEntryType.track, + createdAt: DateTime.now(), + itemId: "1", + data: album.toJson(), + ); + + static final historyRecentlyPlayedItems = List.generate( + 10, + (index) => index % 2 == 0 + ? historyRecentlyPlayedPlaylist + : historyRecentlyPlayedAlbum, + ); } diff --git a/lib/components/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart index a6089cc3..df841b8d 100644 --- a/lib/components/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/tracks_view/sections/body/track_view_body.dart @@ -29,7 +29,7 @@ class TrackViewBodySection extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); diff --git a/lib/components/tracks_view/sections/body/track_view_options.dart b/lib/components/tracks_view/sections/body/track_view_options.dart index 98ddca25..23198aec 100644 --- a/lib/components/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/tracks_view/sections/body/track_view_options.dart @@ -25,7 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); diff --git a/lib/components/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart index 6769ed52..94f0baa2 100644 --- a/lib/components/tracks_view/sections/header/header_actions.dart +++ b/lib/components/tracks_view/sections/header/header_actions.dart @@ -22,7 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final isActive = playlist.collections.contains(props.collectionId); diff --git a/lib/components/tracks_view/sections/header/header_buttons.dart b/lib/components/tracks_view/sections/header/header_buttons.dart index aabca20f..e9fbf6bb 100644 --- a/lib/components/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/tracks_view/sections/header/header_buttons.dart @@ -30,7 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final props = InheritedTrackView.of(context); final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final isActive = playlist.collections.contains(props.collectionId); diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 98dc22dc..9b47aaab 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -5,10 +5,10 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:encrypt/encrypt.dart'; -import 'package:media_kit/media_kit.dart'; +import 'package:media_kit/media_kit.dart' hide Track; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:spotify/spotify.dart'; +import 'package:spotify/spotify.dart' hide Playlist; import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -27,6 +27,7 @@ part 'tables/scrobbler.dart'; part 'tables/skip_segment.dart'; part 'tables/source_match.dart'; part 'tables/audio_player_state.dart'; +part 'tables/history.dart'; part 'typeconverters/color.dart'; part 'typeconverters/locale.dart'; @@ -45,6 +46,7 @@ part 'typeconverters/map.dart'; AudioPlayerStateTable, PlaylistTable, PlaylistMediaTable, + HistoryTable, ], ) class AppDatabase extends _$AppDatabase { diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 37cc930c..bb7d2fb6 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -3414,6 +3414,301 @@ class PlaylistMediaTableCompanion } } +class $HistoryTableTable extends HistoryTable + with TableInfo<$HistoryTableTable, HistoryTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $HistoryTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedColumnWithTypeConverter type = + GeneratedColumn('type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter($HistoryTableTable.$convertertype); + static const VerificationMeta _itemIdMeta = const VerificationMeta('itemId'); + @override + late final GeneratedColumn itemId = GeneratedColumn( + 'item_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _dataMeta = const VerificationMeta('data'); + @override + late final GeneratedColumnWithTypeConverter, String> + data = GeneratedColumn('data', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter>( + $HistoryTableTable.$converterdata); + @override + List get $columns => [id, createdAt, type, itemId, data]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + context.handle(_typeMeta, const VerificationResult.success()); + if (data.containsKey('item_id')) { + context.handle(_itemIdMeta, + itemId.isAcceptableOrUnknown(data['item_id']!, _itemIdMeta)); + } else if (isInserting) { + context.missing(_itemIdMeta); + } + context.handle(_dataMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {id}; + @override + HistoryTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + type: $HistoryTableTable.$convertertype.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}type'])!), + itemId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}item_id'])!, + data: $HistoryTableTable.$converterdata.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}data'])!), + ); + } + + @override + $HistoryTableTable createAlias(String alias) { + return $HistoryTableTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $convertertype = + const EnumNameConverter(HistoryEntryType.values); + static TypeConverter, String> $converterdata = + const MapTypeConverter(); +} + +class HistoryTableData extends DataClass + implements Insertable { + final int id; + final DateTime createdAt; + final HistoryEntryType type; + final String itemId; + final Map data; + const HistoryTableData( + {required this.id, + required this.createdAt, + required this.type, + required this.itemId, + required this.data}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + { + map['type'] = + Variable($HistoryTableTable.$convertertype.toSql(type)); + } + map['item_id'] = Variable(itemId); + { + map['data'] = + Variable($HistoryTableTable.$converterdata.toSql(data)); + } + return map; + } + + HistoryTableCompanion toCompanion(bool nullToAbsent) { + return HistoryTableCompanion( + id: Value(id), + createdAt: Value(createdAt), + type: Value(type), + itemId: Value(itemId), + data: Value(data), + ); + } + + factory HistoryTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryTableData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + type: $HistoryTableTable.$convertertype + .fromJson(serializer.fromJson(json['type'])), + itemId: serializer.fromJson(json['itemId']), + data: serializer.fromJson>(json['data']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'type': serializer + .toJson($HistoryTableTable.$convertertype.toJson(type)), + 'itemId': serializer.toJson(itemId), + 'data': serializer.toJson>(data), + }; + } + + HistoryTableData copyWith( + {int? id, + DateTime? createdAt, + HistoryEntryType? type, + String? itemId, + Map? data}) => + HistoryTableData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + itemId: itemId ?? this.itemId, + data: data ?? this.data, + ); + @override + String toString() { + return (StringBuffer('HistoryTableData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('type: $type, ') + ..write('itemId: $itemId, ') + ..write('data: $data') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, type, itemId, data); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryTableData && + other.id == this.id && + other.createdAt == this.createdAt && + other.type == this.type && + other.itemId == this.itemId && + other.data == this.data); +} + +class HistoryTableCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value type; + final Value itemId; + final Value> data; + const HistoryTableCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.type = const Value.absent(), + this.itemId = const Value.absent(), + this.data = const Value.absent(), + }); + HistoryTableCompanion.insert({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + required HistoryEntryType type, + required String itemId, + required Map data, + }) : type = Value(type), + itemId = Value(itemId), + data = Value(data); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? type, + Expression? itemId, + Expression? data, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (type != null) 'type': type, + if (itemId != null) 'item_id': itemId, + if (data != null) 'data': data, + }); + } + + HistoryTableCompanion copyWith( + {Value? id, + Value? createdAt, + Value? type, + Value? itemId, + Value>? data}) { + return HistoryTableCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + itemId: itemId ?? this.itemId, + data: data ?? this.data, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (type.present) { + map['type'] = + Variable($HistoryTableTable.$convertertype.toSql(type.value)); + } + if (itemId.present) { + map['item_id'] = Variable(itemId.value); + } + if (data.present) { + map['data'] = + Variable($HistoryTableTable.$converterdata.toSql(data.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryTableCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('type: $type, ') + ..write('itemId: $itemId, ') + ..write('data: $data') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); _$AppDatabaseManager get managers => _$AppDatabaseManager(this); @@ -3432,6 +3727,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $PlaylistTableTable playlistTable = $PlaylistTableTable(this); late final $PlaylistMediaTableTable playlistMediaTable = $PlaylistMediaTableTable(this); + late final $HistoryTableTable historyTable = $HistoryTableTable(this); late final Index uniqueBlacklist = Index('unique_blacklist', 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); late final Index uniqTrackMatch = Index('uniq_track_match', @@ -3450,6 +3746,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { audioPlayerStateTable, playlistTable, playlistMediaTable, + historyTable, uniqueBlacklist, uniqTrackMatch ]; @@ -5053,6 +5350,148 @@ class $$PlaylistMediaTableTableOrderingComposer } } +typedef $$HistoryTableTableInsertCompanionBuilder = HistoryTableCompanion + Function({ + Value id, + Value createdAt, + required HistoryEntryType type, + required String itemId, + required Map data, +}); +typedef $$HistoryTableTableUpdateCompanionBuilder = HistoryTableCompanion + Function({ + Value id, + Value createdAt, + Value type, + Value itemId, + Value> data, +}); + +class $$HistoryTableTableTableManager extends RootTableManager< + _$AppDatabase, + $HistoryTableTable, + HistoryTableData, + $$HistoryTableTableFilterComposer, + $$HistoryTableTableOrderingComposer, + $$HistoryTableTableProcessedTableManager, + $$HistoryTableTableInsertCompanionBuilder, + $$HistoryTableTableUpdateCompanionBuilder> { + $$HistoryTableTableTableManager(_$AppDatabase db, $HistoryTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$HistoryTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$HistoryTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$HistoryTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + Value type = const Value.absent(), + Value itemId = const Value.absent(), + Value> data = const Value.absent(), + }) => + HistoryTableCompanion( + id: id, + createdAt: createdAt, + type: type, + itemId: itemId, + data: data, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + required HistoryEntryType type, + required String itemId, + required Map data, + }) => + HistoryTableCompanion.insert( + id: id, + createdAt: createdAt, + type: type, + itemId: itemId, + data: data, + ), + )); +} + +class $$HistoryTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $HistoryTableTable, + HistoryTableData, + $$HistoryTableTableFilterComposer, + $$HistoryTableTableOrderingComposer, + $$HistoryTableTableProcessedTableManager, + $$HistoryTableTableInsertCompanionBuilder, + $$HistoryTableTableUpdateCompanionBuilder> { + $$HistoryTableTableProcessedTableManager(super.$state); +} + +class $$HistoryTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $HistoryTableTable> { + $$HistoryTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get type => $state.composableBuilder( + column: $state.table.type, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get itemId => $state.composableBuilder( + column: $state.table.itemId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters, Map, + String> + get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); +} + +class $$HistoryTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $HistoryTableTable> { + $$HistoryTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get type => $state.composableBuilder( + column: $state.table.type, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get itemId => $state.composableBuilder( + column: $state.table.itemId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + class _$AppDatabaseManager { final _$AppDatabase _db; _$AppDatabaseManager(this._db); @@ -5074,4 +5513,6 @@ class _$AppDatabaseManager { $$PlaylistTableTableTableManager(_db, _db.playlistTable); $$PlaylistMediaTableTableTableManager get playlistMediaTable => $$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable); + $$HistoryTableTableTableManager get historyTable => + $$HistoryTableTableTableManager(_db, _db.historyTable); } diff --git a/lib/models/database/tables/history.dart b/lib/models/database/tables/history.dart new file mode 100644 index 00000000..23c16f17 --- /dev/null +++ b/lib/models/database/tables/history.dart @@ -0,0 +1,25 @@ +part of '../database.dart'; + +enum HistoryEntryType { + playlist, + album, + track, +} + +class HistoryTable extends Table { + IntColumn get id => integer().autoIncrement()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + TextColumn get type => textEnum()(); + TextColumn get itemId => text()(); + TextColumn get data => + text().map(const MapTypeConverter())(); +} + +extension HistoryItemParseExtension on HistoryTableData { + PlaylistSimple? get playlist => + type == HistoryEntryType.playlist ? PlaylistSimple.fromJson(data) : null; + AlbumSimple? get album => + type == HistoryEntryType.album ? AlbumSimple.fromJson(data) : null; + Track? get track => + type == HistoryEntryType.track ? Track.fromJson(data) : null; +} diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index de7aa5f8..dd914fad 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -35,7 +35,7 @@ class AlbumCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.read(playbackHistoryProvider.notifier); + final historyNotifier = ref.read(playbackHistoryActionsProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); bool isPlaylistPlaying = useMemoized( diff --git a/lib/modules/home/sections/recent.dart b/lib/modules/home/sections/recent.dart index 5be2fcc2..b26c0e16 100644 --- a/lib/modules/home/sections/recent.dart +++ b/lib/modules/home/sections/recent.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/history/recent.dart'; -import 'package:spotube/provider/history/state.dart'; class HomeRecentlyPlayedSection extends HookConsumerWidget { const HomeRecentlyPlayedSection({super.key}); @@ -10,23 +12,28 @@ class HomeRecentlyPlayedSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final history = ref.watch(recentlyPlayedItems); + final historyData = + history.asData?.value ?? FakeData.historyRecentlyPlayedItems; - if (history.isEmpty) { + if (history.asData?.value.isEmpty == true) { return const SizedBox(); } - return HorizontalPlaybuttonCardView( - title: const Text('Recently Played'), - items: [ - for (final item in history) - if (item is PlaybackHistoryPlaylist) - item.playlist - else if (item is PlaybackHistoryAlbum) - item.album - ], - hasNextPage: false, - isLoadingNextPage: false, - onFetchMore: () {}, + return Skeletonizer( + enabled: history.isLoading, + child: HorizontalPlaybuttonCardView( + title: const Text('Recently Played'), + items: [ + for (final item in historyData) + if (item.playlist != null) + item.playlist + else if (item.album != null) + item.album + ], + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + ), ); } } diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index 4e81a254..d6ea2a46 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -26,7 +26,7 @@ class PlaylistCard extends HookConsumerWidget { final playlistQueue = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); - final historyNotifier = ref.read(playbackHistoryProvider.notifier); + final historyNotifier = ref.read(playbackHistoryActionsProvider); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; diff --git a/lib/modules/stats/summary/summary.dart b/lib/modules/stats/summary/summary.dart index 0b6c6040..ef8aa1b0 100644 --- a/lib/modules/stats/summary/summary.dart +++ b/lib/modules/stats/summary/summary.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; @@ -18,83 +20,87 @@ class StatsPageSummarySection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final summary = ref.watch(playbackHistorySummaryProvider); + final summaryData = summary.asData?.value ?? FakeData.historySummary; - return SliverPadding( - padding: const EdgeInsets.all(10), - sliver: SliverLayoutBuilder(builder: (context, constrains) { - return SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: constrains.isXs - ? 2 - : constrains.smAndDown - ? 3 - : constrains.mdAndDown - ? 4 - : constrains.lgAndDown - ? 5 - : 6, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: constrains.isXs ? 1.3 : 1.5, - ), - delegate: SliverChildListDelegate([ - SummaryCard( - title: summary.duration.inMinutes.toDouble(), - unit: "minutes", - description: 'Listened to music', - color: Colors.purple, - onTap: () { - ServiceUtils.pushNamed(context, StatsMinutesPage.name); - }, + return Skeletonizer.sliver( + enabled: summary.isLoading, + child: SliverPadding( + padding: const EdgeInsets.all(10), + sliver: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constrains.isXs + ? 2 + : constrains.smAndDown + ? 3 + : constrains.mdAndDown + ? 4 + : constrains.lgAndDown + ? 5 + : 6, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: constrains.isXs ? 1.3 : 1.5, ), - SummaryCard( - title: summary.tracks.toDouble(), - unit: "songs", - description: 'Streamed overall', - color: Colors.lightBlue, - onTap: () { - ServiceUtils.pushNamed(context, StatsStreamsPage.name); - }, - ), - SummaryCard.unformatted( - title: usdFormatter.format(summary.fees.toDouble()), - unit: "", - description: 'Owed to artists\nthis month', - color: Colors.green, - onTap: () { - ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); - }, - ), - SummaryCard( - title: summary.artists.toDouble(), - unit: "artist's", - description: 'Music reached you', - color: Colors.yellow, - onTap: () { - ServiceUtils.pushNamed(context, StatsArtistsPage.name); - }, - ), - SummaryCard( - title: summary.albums.toDouble(), - unit: "full albums", - description: 'Got your love', - color: Colors.pink, - onTap: () { - ServiceUtils.pushNamed(context, StatsAlbumsPage.name); - }, - ), - SummaryCard( - title: summary.playlists.toDouble(), - unit: "playlists", - description: 'Were on repeat', - color: Colors.teal, - onTap: () { - ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); - }, - ), - ]), - ); - }), + delegate: SliverChildListDelegate([ + SummaryCard( + title: summaryData.duration.inMinutes.toDouble(), + unit: "minutes", + description: 'Listened to music', + color: Colors.purple, + onTap: () { + ServiceUtils.pushNamed(context, StatsMinutesPage.name); + }, + ), + SummaryCard( + title: summaryData.tracks.toDouble(), + unit: "songs", + description: 'Streamed overall', + color: Colors.lightBlue, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamsPage.name); + }, + ), + SummaryCard.unformatted( + title: usdFormatter.format(summaryData.fees.toDouble()), + unit: "", + description: 'Owed to artists\nthis month', + color: Colors.green, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); + }, + ), + SummaryCard( + title: summaryData.artists.toDouble(), + unit: "artist's", + description: 'Music reached you', + color: Colors.yellow, + onTap: () { + ServiceUtils.pushNamed(context, StatsArtistsPage.name); + }, + ), + SummaryCard( + title: summaryData.albums.toDouble(), + unit: "full albums", + description: 'Got your love', + color: Colors.pink, + onTap: () { + ServiceUtils.pushNamed(context, StatsAlbumsPage.name); + }, + ), + SummaryCard( + title: summaryData.playlists.toDouble(), + unit: "playlists", + description: 'Were on repeat', + color: Colors.teal, + onTap: () { + ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); + }, + ), + ]), + ); + }), + ), ); } } diff --git a/lib/modules/stats/top/albums.dart b/lib/modules/stats/top/albums.dart index 808a58a4..bcaa75c5 100644 --- a/lib/modules/stats/top/albums.dart +++ b/lib/modules/stats/top/albums.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/provider/history/top.dart'; @@ -11,19 +12,24 @@ class TopAlbums extends HookConsumerWidget { Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final albums = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.albums)); + .select((value) => value.whenData((s) => s.albums))); - return SliverList.builder( - itemCount: albums.length, - itemBuilder: (context, index) { - final album = albums[index]; - return StatsAlbumItem( - album: album.album, - info: Text( - "${compactNumberFormatter.format(album.count)} plays", - ), - ); - }, + final albumsData = albums.asData?.value ?? []; + + return Skeletonizer( + enabled: albums.isLoading, + child: SliverList.builder( + itemCount: albumsData.length, + itemBuilder: (context, index) { + final album = albumsData[index]; + return StatsAlbumItem( + album: album.album, + info: Text( + "${compactNumberFormatter.format(album.count)} plays", + ), + ); + }, + ), ); } } diff --git a/lib/modules/stats/top/artists.dart b/lib/modules/stats/top/artists.dart index 24e97601..094353f2 100644 --- a/lib/modules/stats/top/artists.dart +++ b/lib/modules/stats/top/artists.dart @@ -11,12 +11,14 @@ class TopArtists extends HookConsumerWidget { Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final artists = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.artists)); + .select((value) => value.whenData((s) => s.artists))); + + final artistsData = artists.asData?.value ?? []; return SliverList.builder( - itemCount: artists.length, + itemCount: artistsData.length, itemBuilder: (context, index) { - final artist = artists[index]; + final artist = artistsData[index]; return StatsArtistItem( artist: artist.artist, info: Text("${compactNumberFormatter.format(artist.count)} plays"), diff --git a/lib/modules/stats/top/tracks.dart b/lib/modules/stats/top/tracks.dart index ee37af3b..8bffa800 100644 --- a/lib/modules/stats/top/tracks.dart +++ b/lib/modules/stats/top/tracks.dart @@ -12,13 +12,15 @@ class TopTracks extends HookConsumerWidget { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final tracks = ref.watch( playbackHistoryTopProvider(historyDuration) - .select((value) => value.tracks), + .select((value) => value.whenData((s) => s.tracks)), ); + final tracksData = tracks.asData?.value ?? []; + return SliverList.builder( - itemCount: tracks.length, + itemCount: tracksData.length, itemBuilder: (context, index) { - final track = tracks[index]; + final track = tracksData[index]; return StatsTrackItem( track: track.track, info: Text( diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 3294bab5..21796725 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -139,14 +139,12 @@ class SyncedLyrics extends HookConsumerWidget { textAlign: TextAlign.center, child: InkWell( onTap: () async { - final duration = - await audioPlayer.duration ?? - Duration.zero; final time = Duration( seconds: lyricSlice.time.inSeconds - delay, ); - if (time > duration || time.isNegative) { + if (time > audioPlayer.duration || + time.isNegative) { return; } audioPlayer.seek(time); diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index 868f068a..a13e500b 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -12,10 +12,10 @@ class StatsAlbumsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final albums = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.albums), - ); + final albums = ref.watch(playbackHistoryTopProvider(HistoryDuration.allTime) + .select((value) => value.whenData((s) => s.albums))); + + final albumsData = albums.asData?.value ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -24,9 +24,9 @@ class StatsAlbumsPage extends HookConsumerWidget { title: Text("Albums"), ), body: ListView.builder( - itemCount: albums.length, + itemCount: albumsData.length, itemBuilder: (context, index) { - final album = albums[index]; + final album = albumsData[index]; return StatsAlbumItem( album: album.album, info: Text("${compactNumberFormatter.format(album.count)} plays"), diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index b3f8c240..9ebdbe5d 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -14,9 +14,11 @@ class StatsArtistsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final artists = ref.watch( playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.artists), + .select((s) => s.whenData((s) => s.artists)), ); + final artistsData = artists.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( automaticallyImplyLeading: true, @@ -24,9 +26,9 @@ class StatsArtistsPage extends HookConsumerWidget { title: Text("Artists"), ), body: ListView.builder( - itemCount: artists.length, + itemCount: artistsData.length, itemBuilder: (context, index) { - final artist = artists[index]; + final artist = artistsData[index]; return StatsArtistItem( artist: artist.artist, info: Text("${compactNumberFormatter.format(artist.count)} plays"), diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index ee141475..e881ec70 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -18,9 +18,11 @@ class StatsStreamFeesPage extends HookConsumerWidget { final artists = ref.watch( playbackHistoryTopProvider(HistoryDuration.days30) - .select((value) => value.artists), + .select((value) => value.whenData((s) => s.artists)), ); + final artistsData = artists.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( automaticallyImplyLeading: true, @@ -49,9 +51,9 @@ class StatsStreamFeesPage extends HookConsumerWidget { ), ), SliverList.builder( - itemCount: artists.length, + itemCount: artistsData.length, itemBuilder: (context, index) { - final artist = artists[index]; + final artist = artistsData[index]; return StatsArtistItem( artist: artist.artist, info: Text(usdFormatter.format(artist.count * 0.005)), diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index ea0a0c10..1d6a5844 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -16,9 +16,11 @@ class StatsMinutesPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final topTracks = ref.watch( playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.tracks), + .select((s) => s.whenData((s) => s.tracks)), ); + final topTracksData = topTracks.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( title: Text("Minutes listened"), @@ -27,9 +29,9 @@ class StatsMinutesPage extends HookConsumerWidget { ), body: ListView.separated( separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracks.length, + itemCount: topTracksData.length, itemBuilder: (context, index) { - final (:track, :count) = topTracks[index]; + final (:track, :count) = topTracksData[index]; return StatsTrackItem( track: track, diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index d31f1dfa..94f8ce9d 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -14,9 +14,11 @@ class StatsPlaylistsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlists = ref.watch( playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.playlists), + .select((s) => s.whenData((s) => s.playlists)), ); + final playlistsData = playlists.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( automaticallyImplyLeading: true, @@ -24,11 +26,11 @@ class StatsPlaylistsPage extends HookConsumerWidget { title: Text("Playlists"), ), body: ListView.builder( - itemCount: playlists.length, + itemCount: playlistsData.length, itemBuilder: (context, index) { - final playlist = playlists[index]; + final playlist = playlistsData[index]; return StatsPlaylistItem( - playlist: playlist.playlist.playlist, + playlist: playlist.playlist, info: Text("${compactNumberFormatter.format(playlist.count)} plays"), ); diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 3df34483..41f2d33a 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -16,9 +16,11 @@ class StatsStreamsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final topTracks = ref.watch( playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.tracks), + .select((s) => s.whenData((s) => s.tracks)), ); + final topTracksData = topTracks.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( title: Text("Streamed songs"), @@ -27,9 +29,9 @@ class StatsStreamsPage extends HookConsumerWidget { ), body: ListView.separated( separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracks.length, + itemCount: topTracksData.length, itemBuilder: (context, index) { - final (:track, :count) = topTracks[index]; + final (:track, :count) = topTracksData[index]; return StatsTrackItem( track: track, diff --git a/lib/provider/audio_player/audio_player_streams.dart b/lib/provider/audio_player/audio_player_streams.dart index 42944075..368fc6d9 100644 --- a/lib/provider/audio_player/audio_player_streams.dart +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -45,8 +45,8 @@ class AudioPlayerStreamListeners { UserPreferences get preferences => ref.read(userPreferencesProvider); Discord get discord => ref.read(discordProvider); AudioPlayerState get audioPlayerState => ref.read(audioPlayerProvider); - PlaybackHistoryNotifier get history => - ref.read(playbackHistoryProvider.notifier); + PlaybackHistoryActions get history => + ref.read(playbackHistoryActionsProvider); Future updatePalette() async { final palette = ref.read(paletteProvider); diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart index 4436626d..0c20a9e5 100644 --- a/lib/provider/history/history.dart +++ b/lib/provider/history/history.dart @@ -1,129 +1,68 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; -class PlaybackHistoryState { - final List items; - const PlaybackHistoryState({this.items = const []}); - - factory PlaybackHistoryState.fromJson(Map json) { - return PlaybackHistoryState( - items: json["items"] - ?.map( - (json) => PlaybackHistoryItem.fromJson(json), - ) - .toList() - .cast() ?? - [], - ); - } - - Map toJson() { - return { - "items": items.map((s) => s.toJson()).toList(), - }; - } - - PlaybackHistoryState copyWith({ - List? items, - }) { - return PlaybackHistoryState(items: items ?? this.items); - } -} - -class PlaybackHistoryNotifier - extends PersistedStateNotifier { +class PlaybackHistoryActions { final Ref ref; - PlaybackHistoryNotifier(this.ref) - : super(const PlaybackHistoryState(), "playback_history"); + AppDatabase get _db => ref.read(databaseProvider); - SpotifyApi get spotify => ref.read(spotifyProvider); + PlaybackHistoryActions(this.ref); - @override - FutureOr fromJson(Map json) => - PlaybackHistoryState.fromJson(json); - - @override - Map toJson() { - return state.toJson(); + Future _batchInsertHistoryEntries( + List entries) async { + await _db.batch((batch) { + batch.insertAll(_db.historyTable, entries); + }); } - void addPlaylists(List playlists) { - state = state.copyWith( - items: [ - ...state.items, - for (final playlist in playlists) - PlaybackHistoryItem.playlist( - date: DateTime.now(), playlist: playlist), - ], - ); + Future addPlaylists(List playlists) async { + await _batchInsertHistoryEntries([ + for (final playlist in playlists) + HistoryTableCompanion.insert( + type: HistoryEntryType.playlist, + itemId: playlist.id!, + data: playlist.toJson(), + ), + ]); } - void addAlbums(List albums) { - state = state.copyWith( - items: [ - ...state.items, - for (final album in albums) - PlaybackHistoryItem.album(date: DateTime.now(), album: album), - ], - ); + Future addAlbums(List albums) async { + await _batchInsertHistoryEntries([ + for (final albums in albums) + HistoryTableCompanion.insert( + type: HistoryEntryType.album, + itemId: albums.id!, + data: albums.toJson(), + ), + ]); } - void addTrack(Track track) async { - // For some reason Track's artists images are `null` - // so we need to fetch them from the API - final artists = - await spotify.artists.list(track.artists!.map((e) => e.id!).toList()); - - track.artists = artists.toList(); - - state = state.copyWith( - items: [ - ...state.items, - PlaybackHistoryItem.track(date: DateTime.now(), track: track), - ], - ); + Future addTracks(List tracks) async { + await _batchInsertHistoryEntries([ + for (final track in tracks) + HistoryTableCompanion.insert( + type: HistoryEntryType.track, + itemId: track.id!, + data: track.toJson(), + ), + ]); } - void clear() { - state = state.copyWith(items: []); + Future addTrack(Track track) async { + await _db.into(_db.historyTable).insert( + HistoryTableCompanion.insert( + type: HistoryEntryType.track, + itemId: track.id!, + data: track.toJson(), + ), + ); + } + + Future clear() async { + _db.delete(_db.historyTable).go(); } } -final playbackHistoryProvider = - StateNotifierProvider( - (ref) => PlaybackHistoryNotifier(ref), -); - -typedef PlaybackHistoryGrouped = ({ - List tracks, - List albums, - List playlists, -}); - -final playbackHistoryGroupedProvider = Provider((ref) { - final history = ref.watch(playbackHistoryProvider); - final tracks = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - final albums = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - final playlists = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - - return ( - tracks: tracks, - albums: albums, - playlists: playlists, - ); -}); +final playbackHistoryActionsProvider = + Provider((ref) => PlaybackHistoryActions(ref)); diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart index 9953858d..4e445500 100644 --- a/lib/provider/history/recent.dart +++ b/lib/provider/history/recent.dart @@ -1,40 +1,55 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; -final recentlyPlayedItems = Provider((ref) { - return ref.watch( - playbackHistoryProvider.select( - (s) => s.items - .toSet() - // unique items - .whereIndexed( - (index, item) => - index == - s.items.lastIndexWhere( - (e) => switch ((e, item)) { - ( - PlaybackHistoryPlaylist(:final playlist), - PlaybackHistoryPlaylist(playlist: final playlist2) - ) => - playlist.id == playlist2.id, - ( - PlaybackHistoryAlbum(:final album), - PlaybackHistoryAlbum(album: final album2) - ) => - album.id == album2.id, - _ => false, - }, - ), - ) - .where( - (s) => s is PlaybackHistoryPlaylist || s is PlaybackHistoryAlbum, - ) - .take(10) - .sortedBy((s) => s.date) - .reversed - .toList(), - ), - ); -}); +class RecentlyPlayedItemNotifier extends AsyncNotifier> { + @override + build() async { + final database = ref.watch(databaseProvider); + + final uniqueItemIds = + await (database.selectOnly(database.historyTable, distinct: true) + ..addColumns([database.historyTable.itemId]) + ..where( + database.historyTable.type.isIn([ + HistoryEntryType.playlist.name, + HistoryEntryType.album.name, + ]), + ) + ..limit(10)) + .map((row) => row.read(database.historyTable.itemId)) + .get() + .then((value) => value.whereNotNull().toList()); + + final query = database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.isIn([ + HistoryEntryType.playlist.name, + HistoryEntryType.album.name, + ]) & + tbl.itemId.isIn(uniqueItemIds), + ) + ..orderBy([ + (tbl) => OrderingTerm( + expression: tbl.createdAt, + mode: OrderingMode.desc, + ), + ]); + + final subscription = query.watch().listen((event) { + state = AsyncData(event); + }); + + ref.onDispose(() => subscription.cancel()); + + return await query.get(); + } +} + +final recentlyPlayedItems = + AsyncNotifierProvider>( + () => RecentlyPlayedItemNotifier(), +); diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart index 2aa86ac9..99df4c11 100644 --- a/lib/provider/history/summary.dart +++ b/lib/provider/history/summary.dart @@ -1,62 +1,197 @@ -import 'package:collection/collection.dart'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:drift/extensions/json1.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; -final playbackHistorySummaryProvider = Provider((ref) { - final (:tracks, :albums, :playlists) = - ref.watch(playbackHistoryGroupedProvider); +class PlaybackHistorySummary { + final Duration duration; + final int tracks; + final int artists; + final double fees; + final int albums; + final int playlists; - final totalDurationListened = tracks.fold( - Duration.zero, - (previousValue, element) => previousValue + element.track.duration!, - ); + const PlaybackHistorySummary({ + required this.duration, + required this.tracks, + required this.artists, + required this.fees, + required this.albums, + required this.playlists, + }); - final totalTracksListened = tracks - .whereIndexed( - (i, track) => - i == tracks.lastIndexWhere((e) => e.track.id == track.track.id), - ) - .length; + PlaybackHistorySummary copyWith({ + Duration? duration, + int? tracks, + int? artists, + double? fees, + int? albums, + int? playlists, + }) { + return PlaybackHistorySummary( + duration: duration ?? this.duration, + tracks: tracks ?? this.tracks, + artists: artists ?? this.artists, + fees: fees ?? this.fees, + albums: albums ?? this.albums, + playlists: playlists ?? this.playlists, + ); + } +} - final artists = - tracks.map((e) => e.track.artists).expand((e) => e ?? []).toList(); +class PlaybackHistorySummaryNotifier + extends AsyncNotifier { + @override + build() async { + final database = ref.watch(databaseProvider); - final totalArtistsListened = artists - .whereIndexed( - (i, artist) => i == artists.lastIndexWhere((e) => e.id == artist.id), - ) - .length; + final uniqItemIdCountingCol = + database.historyTable.itemId.count(distinct: true); + final itemIdCountingCol = database.historyTable.itemId.count(); + final durationSumJsonColumn = + database.historyTable.data.jsonExtract(r"$.duration_ms").sum(); + final artistCountingCol = + database.historyTable.data.jsonExtract(r"$.artists"); - final totalAlbumsListened = albums - .whereIndexed( - (i, album) => - i == albums.lastIndexWhere((e) => e.album.id == album.album.id), - ) - .length; + final totalTracksListenedQuery = (database.selectOnly(database.historyTable) + ..addColumns([uniqItemIdCountingCol]) + ..where( + database.historyTable.type.equals(HistoryEntryType.track.name))) + .map((row) => row.read(uniqItemIdCountingCol)); - final totalPlaylistsListened = playlists - .whereIndexed( - (i, playlist) => - i == - playlists - .lastIndexWhere((e) => e.playlist.id == playlist.playlist.id), - ) - .length; + final totalDurationListenedQuery = (database + .selectOnly(database.historyTable) + ..addColumns([durationSumJsonColumn]) + ..where( + database.historyTable.type.equals(HistoryEntryType.track.name))) + .map( + (row) => Duration(milliseconds: row.read(durationSumJsonColumn) ?? 0), + ); - final tracksThisMonth = ref.watch( - playbackHistoryTopProvider(HistoryDuration.days30).select((s) => s.tracks), - ); + final totalArtistsListenedQuery = + (database.selectOnly(database.historyTable) + ..addColumns([artistCountingCol]) + ..where( + database.historyTable.type.equals(HistoryEntryType.track.name), + )) + .map( + (row) { + final data = jsonDecode(row.read(artistCountingCol)!) as List; + return data.map((e) => e['id'] as String).cast().toList(); + }, + ); - final streams = tracksThisMonth.fold(0, (acc, el) => acc + el.count); + final totalAlbumsListenedQuery = (database.selectOnly(database.historyTable) + ..addColumns([uniqItemIdCountingCol]) + ..where( + database.historyTable.type.equals(HistoryEntryType.album.name))) + .map((row) => row.read(uniqItemIdCountingCol)); - return ( - duration: totalDurationListened, - tracks: totalTracksListened, - artists: totalArtistsListened, - fees: streams * 0.005, // Spotify pays $0.003 to $0.005 - albums: totalAlbumsListened, - playlists: totalPlaylistsListened, - ); -}); + final totalPlaylistsListenedQuery = + (database.selectOnly(database.historyTable) + ..addColumns([uniqItemIdCountingCol]) + ..where( + database.historyTable.type + .equals(HistoryEntryType.playlist.name), + )) + .map((row) => row.read(uniqItemIdCountingCol)); + + final oldestDate = DateTime.now().copyWith(day: 1, hour: 0, minute: 0); + final newestDate = DateTime.now().copyWith(day: 30, hour: 23, minute: 59); + final totalTracksListenedThisMonthQuery = + (database.selectOnly(database.historyTable) + ..addColumns([itemIdCountingCol]) + ..where( + database.historyTable.type.equals( + HistoryEntryType.track.name, + ) & + database.historyTable.createdAt + .isBetweenValues(oldestDate, newestDate), + )) + .map((row) => row.read(itemIdCountingCol)); + + final subscriptions = [ + totalTracksListenedQuery.watchSingle().listen((event) { + if (event == null || state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + tracks: event, + )); + }), + totalDurationListenedQuery.watchSingle().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + duration: event, + )); + }), + totalArtistsListenedQuery.watch().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + artists: event.expand((e) => e).toSet().length, + )); + }), + totalAlbumsListenedQuery.watchSingle().listen((event) { + if (event == null || state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + albums: event, + )); + }), + totalPlaylistsListenedQuery.watchSingle().listen((event) { + if (event == null || state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + playlists: event, + )); + }), + totalTracksListenedThisMonthQuery.watchSingle().listen((event) { + if (event == null || state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + fees: event * 0.005, + )); + }), + ]; + + ref.onDispose(() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); + + return database.transaction(() async { + final totalTracksListened = + await totalTracksListenedQuery.getSingle() ?? 0; + + final totalDurationListened = + await totalDurationListenedQuery.getSingle(); + + final totalArtistsListened = await totalArtistsListenedQuery + .get() + .then((value) => value.expand((e) => e).toSet().length); + + final totalAlbumsListened = + await totalAlbumsListenedQuery.getSingle() ?? 0; + + final totalPlaylistsListened = + await totalPlaylistsListenedQuery.getSingle() ?? 0; + + final totalTracksListenedThisMonth = + await totalTracksListenedThisMonthQuery.getSingle() ?? 0; + + return PlaybackHistorySummary( + duration: totalDurationListened, + tracks: totalTracksListened, + artists: totalArtistsListened, + fees: totalTracksListenedThisMonth * 0.005, + albums: totalAlbumsListened, + playlists: totalPlaylistsListened, + ); + }); + } +} + +final playbackHistorySummaryProvider = AsyncNotifierProvider< + PlaybackHistorySummaryNotifier, PlaybackHistorySummary>( + () => PlaybackHistorySummaryNotifier(), +); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index 7d4594f0..aa12c9b3 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -1,95 +1,212 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/history/state.dart'; final playbackHistoryTopDurationProvider = StateProvider((ref) => HistoryDuration.days30); -final playbackHistoryTopProvider = - Provider.family((ref, HistoryDuration durationState) { - final grouped = ref.watch(playbackHistoryGroupedProvider); +typedef PlaybackHistoryTrack = ({int count, Track track}); +typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album}); +typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist}); +typedef PlaybackHistoryArtist = ({int count, Artist artist}); - final duration = switch (durationState) { - HistoryDuration.allTime => const Duration(days: 365 * 2003), - HistoryDuration.days7 => const Duration(days: 7), - HistoryDuration.days30 => const Duration(days: 30), - HistoryDuration.months6 => const Duration(days: 30 * 6), - HistoryDuration.year => const Duration(days: 365), - HistoryDuration.years2 => const Duration(days: 365 * 2), - }; - final tracks = grouped.tracks - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); - final albums = grouped.albums - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); +class PlaybackHistoryTopState { + final List tracks; + final List albums; + final List playlists; + final List artists; - final playlists = grouped.playlists - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); + const PlaybackHistoryTopState({ + required this.tracks, + required this.albums, + required this.playlists, + required this.artists, + }); - final tracksWithCount = groupBy( - tracks, - (track) => track.track.id!, - ) - .entries - .map((entry) { - return (count: entry.value.length, track: entry.value.first.track); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); + PlaybackHistoryTopState copyWith({ + List? tracks, + List? albums, + List? playlists, + List? artists, + }) { + return PlaybackHistoryTopState( + tracks: tracks ?? this.tracks, + albums: albums ?? this.albums, + playlists: playlists ?? this.playlists, + artists: artists ?? this.artists, + ); + } +} - final albumsWithTrackAlbums = [ - for (final historicAlbum in albums) historicAlbum.album, - for (final track in tracks) track.track.album! - ]; +class PlaybackHistoryTopNotifier + extends FamilyAsyncNotifier { + @override + build(arg) async { + final database = ref.watch(databaseProvider); - final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!) - .entries - .map((entry) { - return (count: entry.value.length, album: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); + final duration = switch (arg) { + HistoryDuration.allTime => const Duration(days: 365 * 2003), + HistoryDuration.days7 => const Duration(days: 7), + HistoryDuration.days30 => const Duration(days: 30), + HistoryDuration.months6 => const Duration(days: 30 * 6), + HistoryDuration.year => const Duration(days: 365), + HistoryDuration.years2 => const Duration(days: 365 * 2), + }; - final artists = - tracks.map((track) => track.track.artists).expand((e) => e ?? []); + final tracksQuery = (database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.equalsValue(HistoryEntryType.track) & + tbl.createdAt.isBiggerOrEqualValue( + DateTime.now().subtract(duration), + ), + )); - final artistsWithCount = groupBy(artists, (artist) => artist.id!) - .entries - .map((entry) { - return (count: entry.value.length, artist: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); + final albumsQuery = database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.equalsValue(HistoryEntryType.album) & + tbl.createdAt.isBiggerOrEqualValue( + DateTime.now().subtract(duration), + ), + ); - final playlistsWithCount = - groupBy(playlists, (playlist) => playlist.playlist.id!) - .entries - .map((entry) { - return (count: entry.value.length, playlist: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); + final playlistsQuery = database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.equalsValue(HistoryEntryType.playlist) & + tbl.createdAt.isBiggerOrEqualValue( + DateTime.now().subtract(duration), + ), + ); - return ( - tracks: tracksWithCount, - albums: albumsWithCount, - artists: artistsWithCount, - playlists: playlistsWithCount, - ); -}); + final subscriptions = [ + tracksQuery.watch().listen((event) { + if (state.asData == null) return; + final artists = event + .map((track) => track.track!.artists) + .expand((e) => e ?? []); + state = AsyncData(state.asData!.value.copyWith( + tracks: getTracksWithCount(event), + artists: getArtistsWithCount(artists), + )); + }), + albumsQuery.watch().listen((event) async { + if (state.asData == null) return; + final tracks = await tracksQuery.get(); + + final albumsWithTrackAlbums = [ + for (final historicAlbum in event) historicAlbum.album!, + for (final track in tracks) track.track!.album! + ]; + + state = AsyncData(state.asData!.value.copyWith( + albums: getAlbumsWithCount(albumsWithTrackAlbums), + )); + }), + playlistsQuery.watch().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + playlists: getPlaylistsWithCount(event), + )); + }), + ]; + + ref.onDispose(() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); + + return database.transaction(() async { + final tracks = await tracksQuery.get(); + final albums = await albumsQuery.get(); + final playlists = await playlistsQuery.get(); + + final tracksWithCount = getTracksWithCount(tracks); + + final albumsWithTrackAlbums = [ + for (final historicAlbum in albums) historicAlbum.album!, + for (final track in tracks) track.track!.album! + ]; + + final albumsWithCount = getAlbumsWithCount(albumsWithTrackAlbums); + + final artists = tracks + .map((track) => track.track!.artists) + .expand((e) => e ?? []); + + final artistsWithCount = getArtistsWithCount(artists); + + final playlistsWithCount = getPlaylistsWithCount(playlists); + + return PlaybackHistoryTopState( + tracks: tracksWithCount, + albums: albumsWithCount, + artists: artistsWithCount, + playlists: playlistsWithCount, + ); + }); + } + + List getTracksWithCount(List tracks) { + return groupBy( + tracks, + (track) => track.track!.id!, + ) + .entries + .map((entry) { + return (count: entry.value.length, track: entry.value.first.track!); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } + + List getAlbumsWithCount( + List albumsWithTrackAlbums, + ) { + return groupBy(albumsWithTrackAlbums, (album) => album.id!) + .entries + .map((entry) { + return (count: entry.value.length, album: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } + + List getArtistsWithCount(Iterable artists) { + return groupBy(artists, (artist) => artist.id!) + .entries + .map((entry) { + return (count: entry.value.length, artist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } + + List getPlaylistsWithCount( + List playlists, + ) { + return groupBy(playlists, (playlist) => playlist.playlist!.id!) + .entries + .map((entry) { + return ( + count: entry.value.length, + playlist: entry.value.first.playlist!, + ); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } +} + +final playbackHistoryTopProvider = AsyncNotifierProviderFamily< + PlaybackHistoryTopNotifier, + PlaybackHistoryTopState, + HistoryDuration>(PlaybackHistoryTopNotifier.new); diff --git a/lib/provider/server/routes/connect.dart b/lib/provider/server/routes/connect.dart index a2fa70b8..8e75a87e 100644 --- a/lib/provider/server/routes/connect.dart +++ b/lib/provider/server/routes/connect.dart @@ -40,8 +40,8 @@ class ServerConnectRoutes { AudioPlayerNotifier get audioPlayerNotifier => ref.read(audioPlayerProvider.notifier); - PlaybackHistoryNotifier get historyNotifier => - ref.read(playbackHistoryProvider.notifier); + PlaybackHistoryActions get historyNotifier => + ref.read(playbackHistoryActionsProvider); Stream get connectClientStream => _connectClientStreamController.stream;