diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e6a4294..472520ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "Buildless", "danceability", "instrumentalness", "Mpris", diff --git a/analysis_options.yaml b/analysis_options.yaml index 748fc015..4ba476e0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -25,6 +25,7 @@ linter: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule file_names: false + avoid_renaming_method_parameters: false # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart new file mode 100644 index 00000000..03773404 --- /dev/null +++ b/lib/provider/spotify/lyrics/synced.dart @@ -0,0 +1,56 @@ +part of '../spotify.dart'; + +class SyncedLyricsNotifier extends FamilyAsyncNotifier + with Persistence { + SyncedLyricsNotifier() { + load(); + } + + @override + FutureOr build(track) async { + final spotify = ref.watch(spotifyProvider); + if (track == null) { + throw "No track currently"; + } + final token = await spotify.getCredentials(); + final res = await http.get( + Uri.parse( + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", + ), + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", + "App-platform": "WebPlayer", + "authorization": "Bearer ${token.accessToken}" + }); + + if (res.statusCode != 200) { + throw Exception("Unable to find lyrics"); + } + final linesRaw = Map.castFrom( + jsonDecode(res.body), + )["lyrics"]?["lines"] as List?; + + final lines = linesRaw?.map((line) { + return LyricSlice( + time: Duration(milliseconds: int.parse(line["startTimeMs"])), + text: line["words"] as String, + ); + }).toList() ?? + []; + + return SubtitleSimple( + lyrics: lines, + name: track.name!, + uri: res.request!.url, + rating: 100, + ); + } + + @override + FutureOr fromJson(Map json) => + SubtitleSimple.fromJson(json.castKeyDeep()); + + @override + Map toJson(SubtitleSimple data) => data.toJson(); +} diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index 471594bd..f43fed46 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -1,14 +1,22 @@ library spotify; +import 'dart:async'; +import 'dart:convert'; + import 'package:collection/collection.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; import 'package:spotify/spotify.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // ignore: depend_on_referenced_packages, implementation_imports import 'package:riverpod/src/async_notifier.dart'; +import 'package:spotube/extensions/map.dart'; +import 'package:spotube/models/lyrics.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:http/http.dart' as http; part 'album/favorite.dart'; part 'album/tracks.dart'; @@ -25,6 +33,9 @@ part 'category/genres.dart'; part 'category/categories.dart'; part 'category/playlists.dart'; +part 'lyrics/synced.dart'; + part 'utils/mixin.dart'; part 'utils/state.dart'; part 'utils/provider.dart'; +part 'utils/persistence.dart'; diff --git a/lib/provider/spotify/utils/persistence.dart b/lib/provider/spotify/utils/persistence.dart new file mode 100644 index 00000000..14d3c940 --- /dev/null +++ b/lib/provider/spotify/utils/persistence.dart @@ -0,0 +1,40 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin Persistence on BuildlessAsyncNotifier { + LazyBox get store => Hive.lazyBox("spotube_cache"); + + FutureOr fromJson(Map json); + Map toJson(T data); + + FutureOr onInit() {} + + Future load() async { + final json = await store.get(runtimeType.toString()); + if (json != null || + (json is Map && json.entries.isNotEmpty) || + (json is List && json.isNotEmpty)) { + state = AsyncData( + await fromJson( + PersistedStateNotifier.castNestedJson(json), + ), + ); + } + + await onInit(); + } + + Future save() async { + await store.put( + runtimeType.toString(), + state.value == null ? null : toJson(state.value as T), + ); + } + + @override + set state(AsyncValue value) { + if (state == value) return; + super.state = value; + save(); + } +} diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart index 60f7b96e..9416a340 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/persisted_state_notifier.dart @@ -126,7 +126,7 @@ abstract class PersistedStateNotifier extends StateNotifier { } } - Map castNestedJson(Map map) { + static Map castNestedJson(Map map) { return Map.castFrom( map.map((key, value) { if (value is Map) {