refactor(stats): migrate stats to use drift db

This commit is contained in:
Kingkor Roy Tirtho 2024-06-29 17:05:06 +06:00
parent 44418868ad
commit 08ac29c979
29 changed files with 1169 additions and 423 deletions

View File

@ -8,3 +8,10 @@ targets:
options: options:
any_map: true any_map: true
explicit_to_json: true explicit_to_json: true
drift_dev:
options:
sql:
dialect: sqlite
options:
modules:
- json1

View File

@ -1,6 +1,8 @@
import 'package:spotify/spotify.dart'; 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/home_feed.dart';
import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/models/spotify_friends.dart';
import 'package:spotube/provider/history/summary.dart';
abstract class FakeData { abstract class FakeData {
static final Image image = Image() 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,
);
} }

View File

@ -29,7 +29,7 @@ class TrackViewBodySection extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(audioPlayerProvider); final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks)); final trackViewState = ref.watch(trackViewProvider(props.tracks));

View File

@ -25,7 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget {
ref.watch(downloadManagerProvider); ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource = final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource)); ref.watch(userPreferencesProvider.select((s) => s.audioSource));

View File

@ -22,7 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget {
final playlist = ref.watch(audioPlayerProvider); final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = playlist.collections.contains(props.collectionId); final isActive = playlist.collections.contains(props.collectionId);

View File

@ -30,7 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final props = InheritedTrackView.of(context); final props = InheritedTrackView.of(context);
final playlist = ref.watch(audioPlayerProvider); final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = playlist.collections.contains(props.collectionId); final isActive = playlist.collections.contains(props.collectionId);

View File

@ -5,10 +5,10 @@ import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:encrypt/encrypt.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/path.dart';
import 'package:path_provider/path_provider.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/encrypted_kv_store.dart';
import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/enums.dart';
@ -27,6 +27,7 @@ part 'tables/scrobbler.dart';
part 'tables/skip_segment.dart'; part 'tables/skip_segment.dart';
part 'tables/source_match.dart'; part 'tables/source_match.dart';
part 'tables/audio_player_state.dart'; part 'tables/audio_player_state.dart';
part 'tables/history.dart';
part 'typeconverters/color.dart'; part 'typeconverters/color.dart';
part 'typeconverters/locale.dart'; part 'typeconverters/locale.dart';
@ -45,6 +46,7 @@ part 'typeconverters/map.dart';
AudioPlayerStateTable, AudioPlayerStateTable,
PlaylistTable, PlaylistTable,
PlaylistMediaTable, PlaylistMediaTable,
HistoryTable,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {

View File

@ -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<int> id = GeneratedColumn<int>(
'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<DateTime> createdAt = GeneratedColumn<DateTime>(
'created_at', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: currentDateAndTime);
static const VerificationMeta _typeMeta = const VerificationMeta('type');
@override
late final GeneratedColumnWithTypeConverter<HistoryEntryType, String> type =
GeneratedColumn<String>('type', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<HistoryEntryType>($HistoryTableTable.$convertertype);
static const VerificationMeta _itemIdMeta = const VerificationMeta('itemId');
@override
late final GeneratedColumn<String> itemId = GeneratedColumn<String>(
'item_id', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _dataMeta = const VerificationMeta('data');
@override
late final GeneratedColumnWithTypeConverter<Map<String, dynamic>, String>
data = GeneratedColumn<String>('data', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<Map<String, dynamic>>(
$HistoryTableTable.$converterdata);
@override
List<GeneratedColumn> 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<HistoryTableData> 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<GeneratedColumn> get $primaryKey => {id};
@override
HistoryTableData map(Map<String, dynamic> 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<HistoryEntryType, String, String> $convertertype =
const EnumNameConverter<HistoryEntryType>(HistoryEntryType.values);
static TypeConverter<Map<String, dynamic>, String> $converterdata =
const MapTypeConverter();
}
class HistoryTableData extends DataClass
implements Insertable<HistoryTableData> {
final int id;
final DateTime createdAt;
final HistoryEntryType type;
final String itemId;
final Map<String, dynamic> data;
const HistoryTableData(
{required this.id,
required this.createdAt,
required this.type,
required this.itemId,
required this.data});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['created_at'] = Variable<DateTime>(createdAt);
{
map['type'] =
Variable<String>($HistoryTableTable.$convertertype.toSql(type));
}
map['item_id'] = Variable<String>(itemId);
{
map['data'] =
Variable<String>($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<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return HistoryTableData(
id: serializer.fromJson<int>(json['id']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
type: $HistoryTableTable.$convertertype
.fromJson(serializer.fromJson<String>(json['type'])),
itemId: serializer.fromJson<String>(json['itemId']),
data: serializer.fromJson<Map<String, dynamic>>(json['data']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'createdAt': serializer.toJson<DateTime>(createdAt),
'type': serializer
.toJson<String>($HistoryTableTable.$convertertype.toJson(type)),
'itemId': serializer.toJson<String>(itemId),
'data': serializer.toJson<Map<String, dynamic>>(data),
};
}
HistoryTableData copyWith(
{int? id,
DateTime? createdAt,
HistoryEntryType? type,
String? itemId,
Map<String, dynamic>? 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<HistoryTableData> {
final Value<int> id;
final Value<DateTime> createdAt;
final Value<HistoryEntryType> type;
final Value<String> itemId;
final Value<Map<String, dynamic>> 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<String, dynamic> data,
}) : type = Value(type),
itemId = Value(itemId),
data = Value(data);
static Insertable<HistoryTableData> custom({
Expression<int>? id,
Expression<DateTime>? createdAt,
Expression<String>? type,
Expression<String>? itemId,
Expression<String>? 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<int>? id,
Value<DateTime>? createdAt,
Value<HistoryEntryType>? type,
Value<String>? itemId,
Value<Map<String, dynamic>>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<int>(id.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
if (type.present) {
map['type'] =
Variable<String>($HistoryTableTable.$convertertype.toSql(type.value));
}
if (itemId.present) {
map['item_id'] = Variable<String>(itemId.value);
}
if (data.present) {
map['data'] =
Variable<String>($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 { abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e); _$AppDatabase(QueryExecutor e) : super(e);
_$AppDatabaseManager get managers => _$AppDatabaseManager(this); _$AppDatabaseManager get managers => _$AppDatabaseManager(this);
@ -3432,6 +3727,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $PlaylistTableTable playlistTable = $PlaylistTableTable(this); late final $PlaylistTableTable playlistTable = $PlaylistTableTable(this);
late final $PlaylistMediaTableTable playlistMediaTable = late final $PlaylistMediaTableTable playlistMediaTable =
$PlaylistMediaTableTable(this); $PlaylistMediaTableTable(this);
late final $HistoryTableTable historyTable = $HistoryTableTable(this);
late final Index uniqueBlacklist = Index('unique_blacklist', late final Index uniqueBlacklist = Index('unique_blacklist',
'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)');
late final Index uniqTrackMatch = Index('uniq_track_match', late final Index uniqTrackMatch = Index('uniq_track_match',
@ -3450,6 +3746,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
audioPlayerStateTable, audioPlayerStateTable,
playlistTable, playlistTable,
playlistMediaTable, playlistMediaTable,
historyTable,
uniqueBlacklist, uniqueBlacklist,
uniqTrackMatch uniqTrackMatch
]; ];
@ -5053,6 +5350,148 @@ class $$PlaylistMediaTableTableOrderingComposer
} }
} }
typedef $$HistoryTableTableInsertCompanionBuilder = HistoryTableCompanion
Function({
Value<int> id,
Value<DateTime> createdAt,
required HistoryEntryType type,
required String itemId,
required Map<String, dynamic> data,
});
typedef $$HistoryTableTableUpdateCompanionBuilder = HistoryTableCompanion
Function({
Value<int> id,
Value<DateTime> createdAt,
Value<HistoryEntryType> type,
Value<String> itemId,
Value<Map<String, dynamic>> 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<int> id = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<HistoryEntryType> type = const Value.absent(),
Value<String> itemId = const Value.absent(),
Value<Map<String, dynamic>> data = const Value.absent(),
}) =>
HistoryTableCompanion(
id: id,
createdAt: createdAt,
type: type,
itemId: itemId,
data: data,
),
getInsertCompanionBuilder: ({
Value<int> id = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
required HistoryEntryType type,
required String itemId,
required Map<String, dynamic> 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<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<DateTime> get createdAt => $state.composableBuilder(
column: $state.table.createdAt,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<HistoryEntryType, HistoryEntryType, String>
get type => $state.composableBuilder(
column: $state.table.type,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
column,
joinBuilders: joinBuilders));
ColumnFilters<String> get itemId => $state.composableBuilder(
column: $state.table.itemId,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<Map<String, dynamic>, Map<String, dynamic>,
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<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<DateTime> get createdAt => $state.composableBuilder(
column: $state.table.createdAt,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get type => $state.composableBuilder(
column: $state.table.type,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get itemId => $state.composableBuilder(
column: $state.table.itemId,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get data => $state.composableBuilder(
column: $state.table.data,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
}
class _$AppDatabaseManager { class _$AppDatabaseManager {
final _$AppDatabase _db; final _$AppDatabase _db;
_$AppDatabaseManager(this._db); _$AppDatabaseManager(this._db);
@ -5074,4 +5513,6 @@ class _$AppDatabaseManager {
$$PlaylistTableTableTableManager(_db, _db.playlistTable); $$PlaylistTableTableTableManager(_db, _db.playlistTable);
$$PlaylistMediaTableTableTableManager get playlistMediaTable => $$PlaylistMediaTableTableTableManager get playlistMediaTable =>
$$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable); $$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable);
$$HistoryTableTableTableManager get historyTable =>
$$HistoryTableTableTableManager(_db, _db.historyTable);
} }

View File

@ -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<HistoryEntryType>()();
TextColumn get itemId => text()();
TextColumn get data =>
text().map(const MapTypeConverter<String, dynamic>())();
}
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;
}

View File

@ -35,7 +35,7 @@ class AlbumCard extends HookConsumerWidget {
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.read(playbackHistoryProvider.notifier); final historyNotifier = ref.read(playbackHistoryActionsProvider);
final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/recent.dart';
import 'package:spotube/provider/history/state.dart';
class HomeRecentlyPlayedSection extends HookConsumerWidget { class HomeRecentlyPlayedSection extends HookConsumerWidget {
const HomeRecentlyPlayedSection({super.key}); const HomeRecentlyPlayedSection({super.key});
@ -10,23 +12,28 @@ class HomeRecentlyPlayedSection extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final history = ref.watch(recentlyPlayedItems); 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 const SizedBox();
} }
return HorizontalPlaybuttonCardView( return Skeletonizer(
enabled: history.isLoading,
child: HorizontalPlaybuttonCardView(
title: const Text('Recently Played'), title: const Text('Recently Played'),
items: [ items: [
for (final item in history) for (final item in historyData)
if (item is PlaybackHistoryPlaylist) if (item.playlist != null)
item.playlist item.playlist
else if (item is PlaybackHistoryAlbum) else if (item.album != null)
item.album item.album
], ],
hasNextPage: false, hasNextPage: false,
isLoadingNextPage: false, isLoadingNextPage: false,
onFetchMore: () {}, onFetchMore: () {},
),
); );
} }
} }

View File

@ -26,7 +26,7 @@ class PlaylistCard extends HookConsumerWidget {
final playlistQueue = ref.watch(audioPlayerProvider); final playlistQueue = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
final historyNotifier = ref.read(playbackHistoryProvider.notifier); final historyNotifier = ref.read(playbackHistoryActionsProvider);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/collections/formatters.dart';
import 'package:spotube/modules/stats/summary/summary_card.dart'; import 'package:spotube/modules/stats/summary/summary_card.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
@ -18,8 +20,11 @@ class StatsPageSummarySection extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final summary = ref.watch(playbackHistorySummaryProvider); final summary = ref.watch(playbackHistorySummaryProvider);
final summaryData = summary.asData?.value ?? FakeData.historySummary;
return SliverPadding( return Skeletonizer.sliver(
enabled: summary.isLoading,
child: SliverPadding(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
sliver: SliverLayoutBuilder(builder: (context, constrains) { sliver: SliverLayoutBuilder(builder: (context, constrains) {
return SliverGrid( return SliverGrid(
@ -39,7 +44,7 @@ class StatsPageSummarySection extends HookConsumerWidget {
), ),
delegate: SliverChildListDelegate([ delegate: SliverChildListDelegate([
SummaryCard( SummaryCard(
title: summary.duration.inMinutes.toDouble(), title: summaryData.duration.inMinutes.toDouble(),
unit: "minutes", unit: "minutes",
description: 'Listened to music', description: 'Listened to music',
color: Colors.purple, color: Colors.purple,
@ -48,7 +53,7 @@ class StatsPageSummarySection extends HookConsumerWidget {
}, },
), ),
SummaryCard( SummaryCard(
title: summary.tracks.toDouble(), title: summaryData.tracks.toDouble(),
unit: "songs", unit: "songs",
description: 'Streamed overall', description: 'Streamed overall',
color: Colors.lightBlue, color: Colors.lightBlue,
@ -57,7 +62,7 @@ class StatsPageSummarySection extends HookConsumerWidget {
}, },
), ),
SummaryCard.unformatted( SummaryCard.unformatted(
title: usdFormatter.format(summary.fees.toDouble()), title: usdFormatter.format(summaryData.fees.toDouble()),
unit: "", unit: "",
description: 'Owed to artists\nthis month', description: 'Owed to artists\nthis month',
color: Colors.green, color: Colors.green,
@ -66,7 +71,7 @@ class StatsPageSummarySection extends HookConsumerWidget {
}, },
), ),
SummaryCard( SummaryCard(
title: summary.artists.toDouble(), title: summaryData.artists.toDouble(),
unit: "artist's", unit: "artist's",
description: 'Music reached you', description: 'Music reached you',
color: Colors.yellow, color: Colors.yellow,
@ -75,7 +80,7 @@ class StatsPageSummarySection extends HookConsumerWidget {
}, },
), ),
SummaryCard( SummaryCard(
title: summary.albums.toDouble(), title: summaryData.albums.toDouble(),
unit: "full albums", unit: "full albums",
description: 'Got your love', description: 'Got your love',
color: Colors.pink, color: Colors.pink,
@ -84,7 +89,7 @@ class StatsPageSummarySection extends HookConsumerWidget {
}, },
), ),
SummaryCard( SummaryCard(
title: summary.playlists.toDouble(), title: summaryData.playlists.toDouble(),
unit: "playlists", unit: "playlists",
description: 'Were on repeat', description: 'Were on repeat',
color: Colors.teal, color: Colors.teal,
@ -95,6 +100,7 @@ class StatsPageSummarySection extends HookConsumerWidget {
]), ]),
); );
}), }),
),
); );
} }
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/formatters.dart'; import 'package:spotube/collections/formatters.dart';
import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/modules/stats/common/album_item.dart';
import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top.dart';
@ -11,12 +12,16 @@ class TopAlbums extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final albums = ref.watch(playbackHistoryTopProvider(historyDuration) final albums = ref.watch(playbackHistoryTopProvider(historyDuration)
.select((value) => value.albums)); .select((value) => value.whenData((s) => s.albums)));
return SliverList.builder( final albumsData = albums.asData?.value ?? [];
itemCount: albums.length,
return Skeletonizer(
enabled: albums.isLoading,
child: SliverList.builder(
itemCount: albumsData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final album = albums[index]; final album = albumsData[index];
return StatsAlbumItem( return StatsAlbumItem(
album: album.album, album: album.album,
info: Text( info: Text(
@ -24,6 +29,7 @@ class TopAlbums extends HookConsumerWidget {
), ),
); );
}, },
),
); );
} }
} }

View File

@ -11,12 +11,14 @@ class TopArtists extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final artists = ref.watch(playbackHistoryTopProvider(historyDuration) 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( return SliverList.builder(
itemCount: artists.length, itemCount: artistsData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final artist = artists[index]; final artist = artistsData[index];
return StatsArtistItem( return StatsArtistItem(
artist: artist.artist, artist: artist.artist,
info: Text("${compactNumberFormatter.format(artist.count)} plays"), info: Text("${compactNumberFormatter.format(artist.count)} plays"),

View File

@ -12,13 +12,15 @@ class TopTracks extends HookConsumerWidget {
final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final historyDuration = ref.watch(playbackHistoryTopDurationProvider);
final tracks = ref.watch( final tracks = ref.watch(
playbackHistoryTopProvider(historyDuration) playbackHistoryTopProvider(historyDuration)
.select((value) => value.tracks), .select((value) => value.whenData((s) => s.tracks)),
); );
final tracksData = tracks.asData?.value ?? [];
return SliverList.builder( return SliverList.builder(
itemCount: tracks.length, itemCount: tracksData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = tracks[index]; final track = tracksData[index];
return StatsTrackItem( return StatsTrackItem(
track: track.track, track: track.track,
info: Text( info: Text(

View File

@ -139,14 +139,12 @@ class SyncedLyrics extends HookConsumerWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
child: InkWell( child: InkWell(
onTap: () async { onTap: () async {
final duration =
await audioPlayer.duration ??
Duration.zero;
final time = Duration( final time = Duration(
seconds: seconds:
lyricSlice.time.inSeconds - delay, lyricSlice.time.inSeconds - delay,
); );
if (time > duration || time.isNegative) { if (time > audioPlayer.duration ||
time.isNegative) {
return; return;
} }
audioPlayer.seek(time); audioPlayer.seek(time);

View File

@ -12,10 +12,10 @@ class StatsAlbumsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final albums = ref.watch( final albums = ref.watch(playbackHistoryTopProvider(HistoryDuration.allTime)
playbackHistoryTopProvider(HistoryDuration.allTime) .select((value) => value.whenData((s) => s.albums)));
.select((s) => s.albums),
); final albumsData = albums.asData?.value ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
@ -24,9 +24,9 @@ class StatsAlbumsPage extends HookConsumerWidget {
title: Text("Albums"), title: Text("Albums"),
), ),
body: ListView.builder( body: ListView.builder(
itemCount: albums.length, itemCount: albumsData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final album = albums[index]; final album = albumsData[index];
return StatsAlbumItem( return StatsAlbumItem(
album: album.album, album: album.album,
info: Text("${compactNumberFormatter.format(album.count)} plays"), info: Text("${compactNumberFormatter.format(album.count)} plays"),

View File

@ -14,9 +14,11 @@ class StatsArtistsPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final artists = ref.watch( final artists = ref.watch(
playbackHistoryTopProvider(HistoryDuration.allTime) playbackHistoryTopProvider(HistoryDuration.allTime)
.select((s) => s.artists), .select((s) => s.whenData((s) => s.artists)),
); );
final artistsData = artists.asData?.value ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
automaticallyImplyLeading: true, automaticallyImplyLeading: true,
@ -24,9 +26,9 @@ class StatsArtistsPage extends HookConsumerWidget {
title: Text("Artists"), title: Text("Artists"),
), ),
body: ListView.builder( body: ListView.builder(
itemCount: artists.length, itemCount: artistsData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final artist = artists[index]; final artist = artistsData[index];
return StatsArtistItem( return StatsArtistItem(
artist: artist.artist, artist: artist.artist,
info: Text("${compactNumberFormatter.format(artist.count)} plays"), info: Text("${compactNumberFormatter.format(artist.count)} plays"),

View File

@ -18,9 +18,11 @@ class StatsStreamFeesPage extends HookConsumerWidget {
final artists = ref.watch( final artists = ref.watch(
playbackHistoryTopProvider(HistoryDuration.days30) playbackHistoryTopProvider(HistoryDuration.days30)
.select((value) => value.artists), .select((value) => value.whenData((s) => s.artists)),
); );
final artistsData = artists.asData?.value ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
automaticallyImplyLeading: true, automaticallyImplyLeading: true,
@ -49,9 +51,9 @@ class StatsStreamFeesPage extends HookConsumerWidget {
), ),
), ),
SliverList.builder( SliverList.builder(
itemCount: artists.length, itemCount: artistsData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final artist = artists[index]; final artist = artistsData[index];
return StatsArtistItem( return StatsArtistItem(
artist: artist.artist, artist: artist.artist,
info: Text(usdFormatter.format(artist.count * 0.005)), info: Text(usdFormatter.format(artist.count * 0.005)),

View File

@ -16,9 +16,11 @@ class StatsMinutesPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final topTracks = ref.watch( final topTracks = ref.watch(
playbackHistoryTopProvider(HistoryDuration.allTime) playbackHistoryTopProvider(HistoryDuration.allTime)
.select((s) => s.tracks), .select((s) => s.whenData((s) => s.tracks)),
); );
final topTracksData = topTracks.asData?.value ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
title: Text("Minutes listened"), title: Text("Minutes listened"),
@ -27,9 +29,9 @@ class StatsMinutesPage extends HookConsumerWidget {
), ),
body: ListView.separated( body: ListView.separated(
separatorBuilder: (context, index) => const Gap(8), separatorBuilder: (context, index) => const Gap(8),
itemCount: topTracks.length, itemCount: topTracksData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final (:track, :count) = topTracks[index]; final (:track, :count) = topTracksData[index];
return StatsTrackItem( return StatsTrackItem(
track: track, track: track,

View File

@ -14,9 +14,11 @@ class StatsPlaylistsPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlists = ref.watch( final playlists = ref.watch(
playbackHistoryTopProvider(HistoryDuration.allTime) playbackHistoryTopProvider(HistoryDuration.allTime)
.select((s) => s.playlists), .select((s) => s.whenData((s) => s.playlists)),
); );
final playlistsData = playlists.asData?.value ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
automaticallyImplyLeading: true, automaticallyImplyLeading: true,
@ -24,11 +26,11 @@ class StatsPlaylistsPage extends HookConsumerWidget {
title: Text("Playlists"), title: Text("Playlists"),
), ),
body: ListView.builder( body: ListView.builder(
itemCount: playlists.length, itemCount: playlistsData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final playlist = playlists[index]; final playlist = playlistsData[index];
return StatsPlaylistItem( return StatsPlaylistItem(
playlist: playlist.playlist.playlist, playlist: playlist.playlist,
info: info:
Text("${compactNumberFormatter.format(playlist.count)} plays"), Text("${compactNumberFormatter.format(playlist.count)} plays"),
); );

View File

@ -16,9 +16,11 @@ class StatsStreamsPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final topTracks = ref.watch( final topTracks = ref.watch(
playbackHistoryTopProvider(HistoryDuration.allTime) playbackHistoryTopProvider(HistoryDuration.allTime)
.select((s) => s.tracks), .select((s) => s.whenData((s) => s.tracks)),
); );
final topTracksData = topTracks.asData?.value ?? [];
return Scaffold( return Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
title: Text("Streamed songs"), title: Text("Streamed songs"),
@ -27,9 +29,9 @@ class StatsStreamsPage extends HookConsumerWidget {
), ),
body: ListView.separated( body: ListView.separated(
separatorBuilder: (context, index) => const Gap(8), separatorBuilder: (context, index) => const Gap(8),
itemCount: topTracks.length, itemCount: topTracksData.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final (:track, :count) = topTracks[index]; final (:track, :count) = topTracksData[index];
return StatsTrackItem( return StatsTrackItem(
track: track, track: track,

View File

@ -45,8 +45,8 @@ class AudioPlayerStreamListeners {
UserPreferences get preferences => ref.read(userPreferencesProvider); UserPreferences get preferences => ref.read(userPreferencesProvider);
Discord get discord => ref.read(discordProvider); Discord get discord => ref.read(discordProvider);
AudioPlayerState get audioPlayerState => ref.read(audioPlayerProvider); AudioPlayerState get audioPlayerState => ref.read(audioPlayerProvider);
PlaybackHistoryNotifier get history => PlaybackHistoryActions get history =>
ref.read(playbackHistoryProvider.notifier); ref.read(playbackHistoryActionsProvider);
Future<void> updatePalette() async { Future<void> updatePalette() async {
final palette = ref.read(paletteProvider); final palette = ref.read(paletteProvider);

View File

@ -1,129 +1,68 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/provider/history/state.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
class PlaybackHistoryState { class PlaybackHistoryActions {
final List<PlaybackHistoryItem> items;
const PlaybackHistoryState({this.items = const []});
factory PlaybackHistoryState.fromJson(Map<String, dynamic> json) {
return PlaybackHistoryState(
items: json["items"]
?.map(
(json) => PlaybackHistoryItem.fromJson(json),
)
.toList()
.cast<PlaybackHistoryItem>() ??
<PlaybackHistoryItem>[],
);
}
Map<String, dynamic> toJson() {
return {
"items": items.map((s) => s.toJson()).toList(),
};
}
PlaybackHistoryState copyWith({
List<PlaybackHistoryItem>? items,
}) {
return PlaybackHistoryState(items: items ?? this.items);
}
}
class PlaybackHistoryNotifier
extends PersistedStateNotifier<PlaybackHistoryState> {
final Ref ref; final Ref ref;
PlaybackHistoryNotifier(this.ref) AppDatabase get _db => ref.read(databaseProvider);
: super(const PlaybackHistoryState(), "playback_history");
SpotifyApi get spotify => ref.read(spotifyProvider); PlaybackHistoryActions(this.ref);
@override Future<void> _batchInsertHistoryEntries(
FutureOr<PlaybackHistoryState> fromJson(Map<String, dynamic> json) => List<HistoryTableCompanion> entries) async {
PlaybackHistoryState.fromJson(json); await _db.batch((batch) {
batch.insertAll(_db.historyTable, entries);
@override });
Map<String, dynamic> toJson() {
return state.toJson();
} }
void addPlaylists(List<PlaylistSimple> playlists) { Future<void> addPlaylists(List<PlaylistSimple> playlists) async {
state = state.copyWith( await _batchInsertHistoryEntries([
items: [
...state.items,
for (final playlist in playlists) for (final playlist in playlists)
PlaybackHistoryItem.playlist( HistoryTableCompanion.insert(
date: DateTime.now(), playlist: playlist), type: HistoryEntryType.playlist,
], itemId: playlist.id!,
data: playlist.toJson(),
),
]);
}
Future<void> addAlbums(List<AlbumSimple> albums) async {
await _batchInsertHistoryEntries([
for (final albums in albums)
HistoryTableCompanion.insert(
type: HistoryEntryType.album,
itemId: albums.id!,
data: albums.toJson(),
),
]);
}
Future<void> addTracks(List<Track> tracks) async {
await _batchInsertHistoryEntries([
for (final track in tracks)
HistoryTableCompanion.insert(
type: HistoryEntryType.track,
itemId: track.id!,
data: track.toJson(),
),
]);
}
Future<void> addTrack(Track track) async {
await _db.into(_db.historyTable).insert(
HistoryTableCompanion.insert(
type: HistoryEntryType.track,
itemId: track.id!,
data: track.toJson(),
),
); );
} }
void addAlbums(List<AlbumSimple> albums) { Future<void> clear() async {
state = state.copyWith( _db.delete(_db.historyTable).go();
items: [
...state.items,
for (final album in albums)
PlaybackHistoryItem.album(date: DateTime.now(), album: album),
],
);
}
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),
],
);
}
void clear() {
state = state.copyWith(items: []);
} }
} }
final playbackHistoryProvider = final playbackHistoryActionsProvider =
StateNotifierProvider<PlaybackHistoryNotifier, PlaybackHistoryState>( Provider((ref) => PlaybackHistoryActions(ref));
(ref) => PlaybackHistoryNotifier(ref),
);
typedef PlaybackHistoryGrouped = ({
List<PlaybackHistoryTrack> tracks,
List<PlaybackHistoryAlbum> albums,
List<PlaybackHistoryPlaylist> playlists,
});
final playbackHistoryGroupedProvider = Provider<PlaybackHistoryGrouped>((ref) {
final history = ref.watch(playbackHistoryProvider);
final tracks = history.items
.whereType<PlaybackHistoryTrack>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
final albums = history.items
.whereType<PlaybackHistoryAlbum>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
final playlists = history.items
.whereType<PlaybackHistoryPlaylist>()
.sorted((a, b) => b.date.compareTo(a.date))
.toList();
return (
tracks: tracks,
albums: albums,
playlists: playlists,
);
});

View File

@ -1,40 +1,55 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/history/history.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/database/database.dart';
final recentlyPlayedItems = Provider((ref) { class RecentlyPlayedItemNotifier extends AsyncNotifier<List<HistoryTableData>> {
return ref.watch( @override
playbackHistoryProvider.select( build() async {
(s) => s.items final database = ref.watch(databaseProvider);
.toSet()
// unique items final uniqueItemIds =
.whereIndexed( await (database.selectOnly(database.historyTable, distinct: true)
(index, item) => ..addColumns([database.historyTable.itemId])
index == ..where(
s.items.lastIndexWhere( database.historyTable.type.isIn([
(e) => switch ((e, item)) { HistoryEntryType.playlist.name,
( HistoryEntryType.album.name,
PlaybackHistoryPlaylist(:final playlist), ]),
PlaybackHistoryPlaylist(playlist: final playlist2)
) =>
playlist.id == playlist2.id,
(
PlaybackHistoryAlbum(:final album),
PlaybackHistoryAlbum(album: final album2)
) =>
album.id == album2.id,
_ => false,
},
),
) )
.where( ..limit(10))
(s) => s is PlaybackHistoryPlaylist || s is PlaybackHistoryAlbum, .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),
) )
.take(10) ..orderBy([
.sortedBy((s) => s.date) (tbl) => OrderingTerm(
.reversed expression: tbl.createdAt,
.toList(), mode: OrderingMode.desc,
), ),
); ]);
final subscription = query.watch().listen((event) {
state = AsyncData(event);
}); });
ref.onDispose(() => subscription.cancel());
return await query.get();
}
}
final recentlyPlayedItems =
AsyncNotifierProvider<RecentlyPlayedItemNotifier, List<HistoryTableData>>(
() => RecentlyPlayedItemNotifier(),
);

View File

@ -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:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/history/history.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/history/top.dart';
final playbackHistorySummaryProvider = Provider((ref) { class PlaybackHistorySummary {
final (:tracks, :albums, :playlists) = final Duration duration;
ref.watch(playbackHistoryGroupedProvider); final int tracks;
final int artists;
final double fees;
final int albums;
final int playlists;
final totalDurationListened = tracks.fold( const PlaybackHistorySummary({
Duration.zero, required this.duration,
(previousValue, element) => previousValue + element.track.duration!, required this.tracks,
required this.artists,
required this.fees,
required this.albums,
required this.playlists,
});
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,
);
}
}
class PlaybackHistorySummaryNotifier
extends AsyncNotifier<PlaybackHistorySummary> {
@override
build() async {
final database = ref.watch(databaseProvider);
final uniqItemIdCountingCol =
database.historyTable.itemId.count(distinct: true);
final itemIdCountingCol = database.historyTable.itemId.count();
final durationSumJsonColumn =
database.historyTable.data.jsonExtract<int>(r"$.duration_ms").sum();
final artistCountingCol =
database.historyTable.data.jsonExtract<String>(r"$.artists");
final totalTracksListenedQuery = (database.selectOnly(database.historyTable)
..addColumns([uniqItemIdCountingCol])
..where(
database.historyTable.type.equals(HistoryEntryType.track.name)))
.map((row) => row.read(uniqItemIdCountingCol));
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 totalTracksListened = tracks final totalArtistsListenedQuery =
.whereIndexed( (database.selectOnly(database.historyTable)
(i, track) => ..addColumns([artistCountingCol])
i == tracks.lastIndexWhere((e) => e.track.id == track.track.id), ..where(
) database.historyTable.type.equals(HistoryEntryType.track.name),
.length; ))
.map(
final artists = (row) {
tracks.map((e) => e.track.artists).expand((e) => e ?? []).toList(); final data = jsonDecode(row.read(artistCountingCol)!) as List;
return data.map((e) => e['id'] as String).cast<String>().toList();
final totalArtistsListened = artists },
.whereIndexed(
(i, artist) => i == artists.lastIndexWhere((e) => e.id == artist.id),
)
.length;
final totalAlbumsListened = albums
.whereIndexed(
(i, album) =>
i == albums.lastIndexWhere((e) => e.album.id == album.album.id),
)
.length;
final totalPlaylistsListened = playlists
.whereIndexed(
(i, playlist) =>
i ==
playlists
.lastIndexWhere((e) => e.playlist.id == playlist.playlist.id),
)
.length;
final tracksThisMonth = ref.watch(
playbackHistoryTopProvider(HistoryDuration.days30).select((s) => s.tracks),
); );
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 ( 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 = <StreamSubscription>[
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, duration: totalDurationListened,
tracks: totalTracksListened, tracks: totalTracksListened,
artists: totalArtistsListened, artists: totalArtistsListened,
fees: streams * 0.005, // Spotify pays $0.003 to $0.005 fees: totalTracksListenedThisMonth * 0.005,
albums: totalAlbumsListened, albums: totalAlbumsListened,
playlists: totalPlaylistsListened, playlists: totalPlaylistsListened,
); );
}); });
}
}
final playbackHistorySummaryProvider = AsyncNotifierProvider<
PlaybackHistorySummaryNotifier, PlaybackHistorySummary>(
() => PlaybackHistorySummaryNotifier(),
);

View File

@ -1,17 +1,56 @@
import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.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'; import 'package:spotube/provider/history/state.dart';
final playbackHistoryTopDurationProvider = final playbackHistoryTopDurationProvider =
StateProvider((ref) => HistoryDuration.days30); StateProvider((ref) => HistoryDuration.days30);
final playbackHistoryTopProvider = typedef PlaybackHistoryTrack = ({int count, Track track});
Provider.family((ref, HistoryDuration durationState) { typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album});
final grouped = ref.watch(playbackHistoryGroupedProvider); typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist});
typedef PlaybackHistoryArtist = ({int count, Artist artist});
final duration = switch (durationState) { class PlaybackHistoryTopState {
final List<PlaybackHistoryTrack> tracks;
final List<PlaybackHistoryAlbum> albums;
final List<PlaybackHistoryPlaylist> playlists;
final List<PlaybackHistoryArtist> artists;
const PlaybackHistoryTopState({
required this.tracks,
required this.albums,
required this.playlists,
required this.artists,
});
PlaybackHistoryTopState copyWith({
List<PlaybackHistoryTrack>? tracks,
List<PlaybackHistoryAlbum>? albums,
List<PlaybackHistoryPlaylist>? playlists,
List<PlaybackHistoryArtist>? artists,
}) {
return PlaybackHistoryTopState(
tracks: tracks ?? this.tracks,
albums: albums ?? this.albums,
playlists: playlists ?? this.playlists,
artists: artists ?? this.artists,
);
}
}
class PlaybackHistoryTopNotifier
extends FamilyAsyncNotifier<PlaybackHistoryTopState, HistoryDuration> {
@override
build(arg) async {
final database = ref.watch(databaseProvider);
final duration = switch (arg) {
HistoryDuration.allTime => const Duration(days: 365 * 2003), HistoryDuration.allTime => const Duration(days: 365 * 2003),
HistoryDuration.days7 => const Duration(days: 7), HistoryDuration.days7 => const Duration(days: 7),
HistoryDuration.days30 => const Duration(days: 30), HistoryDuration.days30 => const Duration(days: 30),
@ -19,77 +58,155 @@ final playbackHistoryTopProvider =
HistoryDuration.year => const Duration(days: 365), HistoryDuration.year => const Duration(days: 365),
HistoryDuration.years2 => const Duration(days: 365 * 2), 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();
final playlists = grouped.playlists final tracksQuery = (database.select(database.historyTable)
.where( ..where(
(item) => item.date.isAfter( (tbl) =>
tbl.type.equalsValue(HistoryEntryType.track) &
tbl.createdAt.isBiggerOrEqualValue(
DateTime.now().subtract(duration), DateTime.now().subtract(duration),
), ),
) ));
.toList();
final tracksWithCount = groupBy( final albumsQuery = database.select(database.historyTable)
tracks, ..where(
(track) => track.track.id!, (tbl) =>
) tbl.type.equalsValue(HistoryEntryType.album) &
.entries tbl.createdAt.isBiggerOrEqualValue(
.map((entry) { DateTime.now().subtract(duration),
return (count: entry.value.length, track: entry.value.first.track); ),
}) );
.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),
),
);
final subscriptions = <StreamSubscription>[
tracksQuery.watch().listen((event) {
if (state.asData == null) return;
final artists = event
.map((track) => track.track!.artists)
.expand((e) => e ?? <Artist>[]);
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 = [ final albumsWithTrackAlbums = [
for (final historicAlbum in albums) historicAlbum.album, for (final historicAlbum in event) historicAlbum.album!,
for (final track in tracks) track.track.album! for (final track in tracks) track.track!.album!
]; ];
final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!) state = AsyncData(state.asData!.value.copyWith(
.entries albums: getAlbumsWithCount(albumsWithTrackAlbums),
.map((entry) { ));
return (count: entry.value.length, album: entry.value.first); }),
}) playlistsQuery.watch().listen((event) {
.sorted((a, b) => b.count.compareTo(a.count)) if (state.asData == null) return;
.toList(); state = AsyncData(state.asData!.value.copyWith(
playlists: getPlaylistsWithCount(event),
));
}),
];
final artists = ref.onDispose(() {
tracks.map((track) => track.track.artists).expand((e) => e ?? <Artist>[]); for (final subscription in subscriptions) {
subscription.cancel();
}
});
final artistsWithCount = groupBy(artists, (artist) => artist.id!) return database.transaction(() async {
.entries final tracks = await tracksQuery.get();
.map((entry) { final albums = await albumsQuery.get();
return (count: entry.value.length, artist: entry.value.first); final playlists = await playlistsQuery.get();
})
.sorted((a, b) => b.count.compareTo(a.count))
.toList();
final playlistsWithCount = final tracksWithCount = getTracksWithCount(tracks);
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();
return ( 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 ?? <Artist>[]);
final artistsWithCount = getArtistsWithCount(artists);
final playlistsWithCount = getPlaylistsWithCount(playlists);
return PlaybackHistoryTopState(
tracks: tracksWithCount, tracks: tracksWithCount,
albums: albumsWithCount, albums: albumsWithCount,
artists: artistsWithCount, artists: artistsWithCount,
playlists: playlistsWithCount, playlists: playlistsWithCount,
); );
}); });
}
List<PlaybackHistoryTrack> getTracksWithCount(List<HistoryTableData> 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<PlaybackHistoryAlbum> getAlbumsWithCount(
List<AlbumSimple> 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<PlaybackHistoryArtist> getArtistsWithCount(Iterable<Artist> 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<PlaybackHistoryPlaylist> getPlaylistsWithCount(
List<HistoryTableData> 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);

View File

@ -40,8 +40,8 @@ class ServerConnectRoutes {
AudioPlayerNotifier get audioPlayerNotifier => AudioPlayerNotifier get audioPlayerNotifier =>
ref.read(audioPlayerProvider.notifier); ref.read(audioPlayerProvider.notifier);
PlaybackHistoryNotifier get historyNotifier => PlaybackHistoryActions get historyNotifier =>
ref.read(playbackHistoryProvider.notifier); ref.read(playbackHistoryActionsProvider);
Stream<String> get connectClientStream => Stream<String> get connectClientStream =>
_connectClientStreamController.stream; _connectClientStreamController.stream;