refactor: synced lyric cache to use drift db

This commit is contained in:
Kingkor Roy Tirtho 2024-06-30 11:01:40 +06:00
parent 08ac29c979
commit 1cfd377c29
14 changed files with 401 additions and 268 deletions

View File

@ -131,7 +131,9 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
); );
} }
} finally { } finally {
isLoading.value = false; if (context.mounted) {
isLoading.value = false;
}
} }
} }

View File

@ -33,7 +33,6 @@ import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart';
import 'package:spotube/themes/theme.dart'; import 'package:spotube/themes/theme.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:system_theme/system_theme.dart'; import 'package:system_theme/system_theme.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -85,10 +84,6 @@ Future<void> main(List<String> rawArgs) async {
Hive.init(hiveCacheDir); Hive.init(hiveCacheDir);
await PersistedStateNotifier.initializeBoxes(
path: hiveCacheDir,
);
if (kIsDesktop) { if (kIsDesktop) {
await localNotifier.setup(appName: "Spotube"); await localNotifier.setup(appName: "Spotube");
await WindowManagerTools.initialize(); await WindowManagerTools.initialize();

View File

@ -9,6 +9,7 @@ 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' hide Playlist; import 'package:spotify/spotify.dart' hide Playlist;
import 'package:spotube/models/lyrics.dart';
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';
@ -28,12 +29,14 @@ 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 'tables/history.dart';
part 'tables/lyrics.dart';
part 'typeconverters/color.dart'; part 'typeconverters/color.dart';
part 'typeconverters/locale.dart'; part 'typeconverters/locale.dart';
part 'typeconverters/string_list.dart'; part 'typeconverters/string_list.dart';
part 'typeconverters/encrypted_text.dart'; part 'typeconverters/encrypted_text.dart';
part 'typeconverters/map.dart'; part 'typeconverters/map.dart';
part 'typeconverters/subtitle.dart';
@DriftDatabase( @DriftDatabase(
tables: [ tables: [
@ -47,6 +50,7 @@ part 'typeconverters/map.dart';
PlaylistTable, PlaylistTable,
PlaylistMediaTable, PlaylistMediaTable,
HistoryTable, HistoryTable,
LyricsTable,
], ],
) )
class AppDatabase extends _$AppDatabase { class AppDatabase extends _$AppDatabase {

View File

@ -3709,6 +3709,218 @@ class HistoryTableCompanion extends UpdateCompanion<HistoryTableData> {
} }
} }
class $LyricsTableTable extends LyricsTable
with TableInfo<$LyricsTableTable, LyricsTableData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
$LyricsTableTable(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 _trackIdMeta =
const VerificationMeta('trackId');
@override
late final GeneratedColumn<String> trackId = GeneratedColumn<String>(
'track_id', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
static const VerificationMeta _dataMeta = const VerificationMeta('data');
@override
late final GeneratedColumnWithTypeConverter<SubtitleSimple, String> data =
GeneratedColumn<String>('data', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<SubtitleSimple>($LyricsTableTable.$converterdata);
@override
List<GeneratedColumn> get $columns => [id, trackId, data];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'lyrics_table';
@override
VerificationContext validateIntegrity(Insertable<LyricsTableData> 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('track_id')) {
context.handle(_trackIdMeta,
trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta));
} else if (isInserting) {
context.missing(_trackIdMeta);
}
context.handle(_dataMeta, const VerificationResult.success());
return context;
}
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
LyricsTableData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return LyricsTableData(
id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!,
trackId: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}track_id'])!,
data: $LyricsTableTable.$converterdata.fromSql(attachedDatabase
.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}data'])!),
);
}
@override
$LyricsTableTable createAlias(String alias) {
return $LyricsTableTable(attachedDatabase, alias);
}
static TypeConverter<SubtitleSimple, String> $converterdata =
SubtitleTypeConverter();
}
class LyricsTableData extends DataClass implements Insertable<LyricsTableData> {
final int id;
final String trackId;
final SubtitleSimple data;
const LyricsTableData(
{required this.id, required this.trackId, required this.data});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<int>(id);
map['track_id'] = Variable<String>(trackId);
{
map['data'] =
Variable<String>($LyricsTableTable.$converterdata.toSql(data));
}
return map;
}
LyricsTableCompanion toCompanion(bool nullToAbsent) {
return LyricsTableCompanion(
id: Value(id),
trackId: Value(trackId),
data: Value(data),
);
}
factory LyricsTableData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return LyricsTableData(
id: serializer.fromJson<int>(json['id']),
trackId: serializer.fromJson<String>(json['trackId']),
data: serializer.fromJson<SubtitleSimple>(json['data']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<int>(id),
'trackId': serializer.toJson<String>(trackId),
'data': serializer.toJson<SubtitleSimple>(data),
};
}
LyricsTableData copyWith({int? id, String? trackId, SubtitleSimple? data}) =>
LyricsTableData(
id: id ?? this.id,
trackId: trackId ?? this.trackId,
data: data ?? this.data,
);
@override
String toString() {
return (StringBuffer('LyricsTableData(')
..write('id: $id, ')
..write('trackId: $trackId, ')
..write('data: $data')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, trackId, data);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is LyricsTableData &&
other.id == this.id &&
other.trackId == this.trackId &&
other.data == this.data);
}
class LyricsTableCompanion extends UpdateCompanion<LyricsTableData> {
final Value<int> id;
final Value<String> trackId;
final Value<SubtitleSimple> data;
const LyricsTableCompanion({
this.id = const Value.absent(),
this.trackId = const Value.absent(),
this.data = const Value.absent(),
});
LyricsTableCompanion.insert({
this.id = const Value.absent(),
required String trackId,
required SubtitleSimple data,
}) : trackId = Value(trackId),
data = Value(data);
static Insertable<LyricsTableData> custom({
Expression<int>? id,
Expression<String>? trackId,
Expression<String>? data,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
if (trackId != null) 'track_id': trackId,
if (data != null) 'data': data,
});
}
LyricsTableCompanion copyWith(
{Value<int>? id, Value<String>? trackId, Value<SubtitleSimple>? data}) {
return LyricsTableCompanion(
id: id ?? this.id,
trackId: trackId ?? this.trackId,
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 (trackId.present) {
map['track_id'] = Variable<String>(trackId.value);
}
if (data.present) {
map['data'] =
Variable<String>($LyricsTableTable.$converterdata.toSql(data.value));
}
return map;
}
@override
String toString() {
return (StringBuffer('LyricsTableCompanion(')
..write('id: $id, ')
..write('trackId: $trackId, ')
..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);
@ -3728,6 +3940,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $PlaylistMediaTableTable playlistMediaTable = late final $PlaylistMediaTableTable playlistMediaTable =
$PlaylistMediaTableTable(this); $PlaylistMediaTableTable(this);
late final $HistoryTableTable historyTable = $HistoryTableTable(this); late final $HistoryTableTable historyTable = $HistoryTableTable(this);
late final $LyricsTableTable lyricsTable = $LyricsTableTable(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',
@ -3747,6 +3960,7 @@ abstract class _$AppDatabase extends GeneratedDatabase {
playlistTable, playlistTable,
playlistMediaTable, playlistMediaTable,
historyTable, historyTable,
lyricsTable,
uniqueBlacklist, uniqueBlacklist,
uniqTrackMatch uniqTrackMatch
]; ];
@ -5492,6 +5706,113 @@ class $$HistoryTableTableOrderingComposer
ColumnOrderings(column, joinBuilders: joinBuilders)); ColumnOrderings(column, joinBuilders: joinBuilders));
} }
typedef $$LyricsTableTableInsertCompanionBuilder = LyricsTableCompanion
Function({
Value<int> id,
required String trackId,
required SubtitleSimple data,
});
typedef $$LyricsTableTableUpdateCompanionBuilder = LyricsTableCompanion
Function({
Value<int> id,
Value<String> trackId,
Value<SubtitleSimple> data,
});
class $$LyricsTableTableTableManager extends RootTableManager<
_$AppDatabase,
$LyricsTableTable,
LyricsTableData,
$$LyricsTableTableFilterComposer,
$$LyricsTableTableOrderingComposer,
$$LyricsTableTableProcessedTableManager,
$$LyricsTableTableInsertCompanionBuilder,
$$LyricsTableTableUpdateCompanionBuilder> {
$$LyricsTableTableTableManager(_$AppDatabase db, $LyricsTableTable table)
: super(TableManagerState(
db: db,
table: table,
filteringComposer:
$$LyricsTableTableFilterComposer(ComposerState(db, table)),
orderingComposer:
$$LyricsTableTableOrderingComposer(ComposerState(db, table)),
getChildManagerBuilder: (p) =>
$$LyricsTableTableProcessedTableManager(p),
getUpdateCompanionBuilder: ({
Value<int> id = const Value.absent(),
Value<String> trackId = const Value.absent(),
Value<SubtitleSimple> data = const Value.absent(),
}) =>
LyricsTableCompanion(
id: id,
trackId: trackId,
data: data,
),
getInsertCompanionBuilder: ({
Value<int> id = const Value.absent(),
required String trackId,
required SubtitleSimple data,
}) =>
LyricsTableCompanion.insert(
id: id,
trackId: trackId,
data: data,
),
));
}
class $$LyricsTableTableProcessedTableManager extends ProcessedTableManager<
_$AppDatabase,
$LyricsTableTable,
LyricsTableData,
$$LyricsTableTableFilterComposer,
$$LyricsTableTableOrderingComposer,
$$LyricsTableTableProcessedTableManager,
$$LyricsTableTableInsertCompanionBuilder,
$$LyricsTableTableUpdateCompanionBuilder> {
$$LyricsTableTableProcessedTableManager(super.$state);
}
class $$LyricsTableTableFilterComposer
extends FilterComposer<_$AppDatabase, $LyricsTableTable> {
$$LyricsTableTableFilterComposer(super.$state);
ColumnFilters<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<String> get trackId => $state.composableBuilder(
column: $state.table.trackId,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<SubtitleSimple, SubtitleSimple, String>
get data => $state.composableBuilder(
column: $state.table.data,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
column,
joinBuilders: joinBuilders));
}
class $$LyricsTableTableOrderingComposer
extends OrderingComposer<_$AppDatabase, $LyricsTableTable> {
$$LyricsTableTableOrderingComposer(super.$state);
ColumnOrderings<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get trackId => $state.composableBuilder(
column: $state.table.trackId,
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);
@ -5515,4 +5836,6 @@ class _$AppDatabaseManager {
$$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable); $$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable);
$$HistoryTableTableTableManager get historyTable => $$HistoryTableTableTableManager get historyTable =>
$$HistoryTableTableTableManager(_db, _db.historyTable); $$HistoryTableTableTableManager(_db, _db.historyTable);
$$LyricsTableTableTableManager get lyricsTable =>
$$LyricsTableTableTableManager(_db, _db.lyricsTable);
} }

View File

@ -0,0 +1,8 @@
part of '../database.dart';
class LyricsTable extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get trackId => text()();
TextColumn get data => text().map(SubtitleTypeConverter())();
}

View File

@ -0,0 +1,13 @@
part of '../database.dart';
class SubtitleTypeConverter extends TypeConverter<SubtitleSimple, String> {
@override
SubtitleSimple fromSql(String fromDb) {
return SubtitleSimple.fromJson(jsonDecode(fromDb));
}
@override
String toSql(SubtitleSimple value) {
return jsonEncode(value.toJson());
}
}

View File

@ -5,7 +5,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/player_queue.dart';
import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart';
@ -19,7 +18,6 @@ import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/routes/connect.dart'; import 'package:spotube/provider/server/routes/connect.dart';
import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/services/connectivity_adapter.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -41,13 +39,6 @@ class RootApp extends HookConsumerWidget {
useEffect(() { useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
ServiceUtils.checkForUpdates(context, ref); ServiceUtils.checkForUpdates(context, ref);
final sharedPreferences = await SharedPreferences.getInstance();
if (sharedPreferences.getBool(kIsUsingEncryption) == false &&
context.mounted) {
await PersistedStateNotifier.showNoEncryptionDialog(context);
}
}); });
final subscriptions = [ final subscriptions = [

View File

@ -1,11 +1,6 @@
part of '../spotify.dart'; part of '../spotify.dart';
class SyncedLyricsNotifier extends FamilyAsyncNotifier<SubtitleSimple, Track?> class SyncedLyricsNotifier extends FamilyAsyncNotifier<SubtitleSimple, Track?> {
with Persistence<SubtitleSimple> {
SyncedLyricsNotifier() {
load();
}
Track get _track => arg!; Track get _track => arg!;
Future<SubtitleSimple> getSpotifyLyrics(String? token) async { Future<SubtitleSimple> getSpotifyLyrics(String? token) async {
@ -128,12 +123,25 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier<SubtitleSimple, Track?>
@override @override
FutureOr<SubtitleSimple> build(track) async { FutureOr<SubtitleSimple> build(track) async {
try { try {
final database = ref.watch(databaseProvider);
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
if (track == null) { if (track == null) {
throw "No track currently"; throw "No track currently";
} }
final cachedLyrics = await (database.select(database.lyricsTable)
..where((tbl) => tbl.trackId.equals(track.id!)))
.map((row) => row.data)
.getSingleOrNull();
SubtitleSimple? lyrics = cachedLyrics;
final token = await spotify.getCredentials(); final token = await spotify.getCredentials();
SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken);
if (lyrics == null || lyrics.lyrics.isEmpty) {
lyrics = await getSpotifyLyrics(token.accessToken);
}
if (lyrics.lyrics.isEmpty || lyrics.lyrics.length <= 5) { if (lyrics.lyrics.isEmpty || lyrics.lyrics.length <= 5) {
lyrics = await getLRCLibLyrics(); lyrics = await getLRCLibLyrics();
@ -143,19 +151,21 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier<SubtitleSimple, Track?>
throw Exception("Unable to find lyrics"); throw Exception("Unable to find lyrics");
} }
if (cachedLyrics == null || cachedLyrics.lyrics.isEmpty) {
await database.into(database.lyricsTable).insertOnConflictUpdate(
LyricsTableCompanion.insert(
trackId: track.id!,
data: lyrics,
),
);
}
return lyrics; return lyrics;
} catch (e, stackTrace) { } catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace); AppLogger.reportError(e, stackTrace);
rethrow; rethrow;
} }
} }
@override
FutureOr<SubtitleSimple> fromJson(Map<String, dynamic> json) =>
SubtitleSimple.fromJson(json.castKeyDeep<String>());
@override
Map<String, dynamic> toJson(SubtitleSimple data) => data.toJson();
} }
final syncedLyricsDelayProvider = StateProvider<int>((ref) => 0); final syncedLyricsDelayProvider = StateProvider<int>((ref) => 0);

View File

@ -1,10 +1,6 @@
part of '../spotify.dart'; part of '../spotify.dart';
class LikedTracksNotifier extends AsyncNotifier<List<Track>> with Persistence { class LikedTracksNotifier extends AsyncNotifier<List<Track>> {
LikedTracksNotifier() {
load();
}
@override @override
FutureOr<List<Track>> build() async { FutureOr<List<Track>> build() async {
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
@ -29,18 +25,6 @@ class LikedTracksNotifier extends AsyncNotifier<List<Track>> with Persistence {
} }
}); });
} }
@override
FutureOr<List<Track>> fromJson(Map<String, dynamic> json) {
return (json['tracks'] as List).map((e) => Track.fromJson(e)).toList();
}
@override
Map<String, dynamic> toJson(List<Track> data) {
return {
'tracks': data.map((e) => e.toJson()).toList(),
};
}
} }
final likedTracksProvider = final likedTracksProvider =

View File

@ -2,6 +2,9 @@ library spotify;
import 'dart:async'; import 'dart:async';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/spotify/utils/json_cast.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
@ -15,7 +18,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
// ignore: depend_on_referenced_packages, implementation_imports // ignore: depend_on_referenced_packages, implementation_imports
import 'package:riverpod/src/async_notifier.dart'; import 'package:riverpod/src/async_notifier.dart';
import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/map.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/lyrics.dart'; import 'package:spotube/models/lyrics.dart';
import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart';
@ -25,7 +27,6 @@ import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/dio/dio.dart';
import 'package:spotube/services/wikipedia/wikipedia.dart'; import 'package:spotube/services/wikipedia/wikipedia.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:wikipedia_api/wikipedia_api.dart'; import 'package:wikipedia_api/wikipedia_api.dart';

View File

@ -0,0 +1,21 @@
Map<String, dynamic> castNestedJson(Map map) {
return Map.castFrom<dynamic, dynamic, String, dynamic>(
map.map((key, value) {
if (value is Map) {
return MapEntry(
key,
castNestedJson(value),
);
} else if (value is Iterable) {
return MapEntry(
key,
value.map((e) {
if (e is Map) return castNestedJson(e);
return e;
}).toList(),
);
}
return MapEntry(key, value);
}),
);
}

View File

@ -16,7 +16,7 @@ mixin Persistence<T> on BuildlessAsyncNotifier<T> {
(json is List && json.isNotEmpty)) { (json is List && json.isNotEmpty)) {
state = AsyncData( state = AsyncData(
await fromJson( await fromJson(
PersistedStateNotifier.castNestedJson(json), castNestedJson(json),
), ),
); );
} }

View File

@ -1,55 +0,0 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
abstract class PersistedChangeNotifier extends ChangeNotifier {
late SharedPreferences _localStorage;
PersistedChangeNotifier() {
SharedPreferences.getInstance().then((value) => _localStorage = value).then(
(_) async {
final persistedMap = (await toMap())
.entries
.toList()
.fold<Map<String, dynamic>>({}, (acc, entry) {
if (entry.value != null) {
if (entry.value is bool) {
acc[entry.key] = _localStorage.getBool(entry.key);
} else if (entry.value is int) {
acc[entry.key] = _localStorage.getInt(entry.key);
} else if (entry.value is double) {
acc[entry.key] = _localStorage.getDouble(entry.key);
} else if (entry.value is String) {
acc[entry.key] = _localStorage.getString(entry.key);
}
} else {
acc[entry.key] = _localStorage.get(entry.key);
}
return acc;
});
await loadFromLocal(persistedMap);
notifyListeners();
},
);
}
FutureOr<void> loadFromLocal(Map<String, dynamic> map);
FutureOr<Map<String, dynamic>> toMap();
Future<void> updatePersistence({bool clearNullEntries = false}) async {
for (final entry in (await toMap()).entries) {
if (entry.value is bool) {
await _localStorage.setBool(entry.key, entry.value);
} else if (entry.value is int) {
await _localStorage.setInt(entry.key, entry.value);
} else if (entry.value is double) {
await _localStorage.setDouble(entry.key, entry.value);
} else if (entry.value is String) {
await _localStorage.setString(entry.key, entry.value);
} else if (entry.value == null && clearNullEntries) {
_localStorage.remove(entry.key);
}
}
}
}

View File

@ -1,164 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive/hive.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/dialogs/prompt_dialog.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/primitive_utils.dart';
const secureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
);
const kKeyBoxName = "spotube_box_name";
const kNoEncryptionWarningShownKey = "showedNoEncryptionWarning";
const kIsUsingEncryption = "isUsingEncryption";
String getBoxKey(String boxName) => "spotube_box_$boxName";
abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
final String cacheKey;
final bool encrypted;
FutureOr<void> onInit() {}
PersistedStateNotifier(
super.state,
this.cacheKey, {
this.encrypted = false,
}) {
_load().then((_) => onInit());
}
static late LazyBox _box;
static late LazyBox _encryptedBox;
static Future<void> showNoEncryptionDialog(BuildContext context) async {
final localStorage = await SharedPreferences.getInstance();
final wasShownAlready =
localStorage.getBool(kNoEncryptionWarningShownKey) == true;
if (wasShownAlready || !context.mounted) {
return;
}
await showPromptDialog(
context: context,
title: context.l10n.failed_to_encrypt,
message: context.l10n.encryption_failed_warning,
cancelText: null,
);
await localStorage.setBool(kNoEncryptionWarningShownKey, true);
}
static Future<String?> read(String key) async {
final localStorage = await SharedPreferences.getInstance();
if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) {
return localStorage.getString(key);
}
try {
await localStorage.setBool(kIsUsingEncryption, true);
return await secureStorage.read(key: key);
} catch (e) {
await localStorage.setBool(kIsUsingEncryption, false);
return localStorage.getString(key);
}
}
static Future<void> write(String key, String value) async {
final localStorage = await SharedPreferences.getInstance();
if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) {
await localStorage.setString(key, value);
return;
}
try {
await localStorage.setBool(kIsUsingEncryption, true);
await secureStorage.write(key: key, value: value);
} catch (e) {
await localStorage.setBool(kIsUsingEncryption, false);
await localStorage.setString(key, value);
}
}
static Future<void> initializeBoxes({required String? path}) async {
String? boxName = await read(kKeyBoxName);
if (boxName == null) {
boxName = "spotube-${PrimitiveUtils.uuid.v4()}";
await write(kKeyBoxName, boxName);
}
String? encryptionKey = await read(getBoxKey(boxName));
if (encryptionKey == null) {
encryptionKey = base64Url.encode(Hive.generateSecureKey());
await write(getBoxKey(boxName), encryptionKey);
}
_encryptedBox = await Hive.openLazyBox(
boxName,
encryptionCipher: HiveAesCipher(base64Url.decode(encryptionKey)),
);
_box = await Hive.openLazyBox(
"spotube_cache",
path: path,
);
}
LazyBox get box => encrypted ? _encryptedBox : _box;
Future<void> _load() async {
final json = await box.get(cacheKey);
if (json != null ||
(json is Map && json.entries.isNotEmpty) ||
(json is List && json.isNotEmpty)) {
state = await fromJson(castNestedJson(json));
}
}
static Map<String, dynamic> castNestedJson(Map map) {
return Map.castFrom<dynamic, dynamic, String, dynamic>(
map.map((key, value) {
if (value is Map) {
return MapEntry(
key,
castNestedJson(value),
);
} else if (value is Iterable) {
return MapEntry(
key,
value.map((e) {
if (e is Map) return castNestedJson(e);
return e;
}).toList(),
);
}
return MapEntry(key, value);
}),
);
}
void save() async {
await box.put(cacheKey, toJson());
}
FutureOr<T> fromJson(Map<String, dynamic> json);
Map<String, dynamic> toJson();
@override
set state(T value) {
if (state == value) return;
super.state = value;
save();
}
}