From faec2af8057c84c754e2391a4dfc0bd74ec6aefe Mon Sep 17 00:00:00 2001 From: Pato05 Date: Wed, 24 Jan 2024 18:55:25 +0100 Subject: [PATCH] when a song is played in search, now its mix gets played instead --- ios/Runner/Info.plist | 5 + lib/api/cache.dart | 164 ++++++++++++------------- lib/api/deezer.dart | 97 ++++++++++++--- lib/api/definitions.dart | 52 +++++--- lib/api/download.dart | 6 +- lib/api/download_manager/database.dart | 2 +- lib/api/player/audio_handler.dart | 10 +- lib/api/player/player_helper.dart | 53 ++++++-- lib/main.dart | 4 +- lib/ui/details_screens.dart | 7 +- lib/ui/external_link_route.dart | 51 ++++++++ lib/ui/home_screen.dart | 94 ++++++++------ lib/ui/library.dart | 27 +++- lib/ui/lyrics_screen.dart | 39 +++--- lib/ui/menu.dart | 8 +- lib/ui/player_bar.dart | 29 +++-- lib/ui/player_screen.dart | 27 ++-- lib/ui/search.dart | 68 +++++----- macos/Runner/Info.plist | 5 + 19 files changed, 492 insertions(+), 256 deletions(-) create mode 100644 lib/ui/external_link_route.dart diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 383d242..db406d0 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,5 +47,10 @@ UIApplicationSupportsIndirectInputEvents + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + diff --git a/lib/api/cache.dart b/lib/api/cache.dart index 76139ce..6ae5e8d 100644 --- a/lib/api/cache.dart +++ b/lib/api/cache.dart @@ -1,6 +1,6 @@ import 'package:freezer/api/definitions.dart'; +import 'package:freezer/api/paths.dart'; import 'package:hive_flutter/hive_flutter.dart'; -import 'package:json_annotation/json_annotation.dart'; import 'dart:async'; @@ -8,46 +8,82 @@ part 'cache.g.dart'; late Cache cache; +class CacheEntryAdapter extends TypeAdapter { + @override + final int typeId = 20; + + @override + CacheEntry read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CacheEntry( + fields[0], + updatedAt: fields[1] as DateTime?, + ); + } + + @override + void write(BinaryWriter writer, CacheEntry obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.value) + ..writeByte(1) + ..write(obj.updatedAt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CacheEntryAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class CacheEntry { + final T value; + final DateTime updatedAt; + + CacheEntry(this.value, {DateTime? updatedAt}) + : updatedAt = updatedAt ?? DateTime.now(); +} + //Cache for miscellaneous things @HiveType(typeId: 22) -@JsonSerializable() class Cache { - static Future> get _box => - Hive.openLazyBox('metacache'); + static Future> get _box async => + Hive.openLazyBox('metacache', path: await Paths.cacheDir()); //ID's of tracks that are in library @HiveField(0, defaultValue: []) - @JsonKey(defaultValue: []) List libraryTracks = []; //Track ID of logged track, to prevent duplicates @HiveField(1) - @JsonKey(includeToJson: false, includeFromJson: false) String? loggedTrackId; @HiveField(2) - @JsonKey(defaultValue: []) List history = []; //All sorting cached @HiveField(3) - @JsonKey(defaultValue: []) List sorts = []; //Sleep timer - @JsonKey(includeToJson: false, includeFromJson: false) DateTime? sleepTimerTime; - @JsonKey(includeToJson: false, includeFromJson: false) // ignore: cancel_subscriptions StreamSubscription? sleepTimer; //Search history @HiveField(4) - @JsonKey(name: 'searchHistory2', defaultValue: []) - List searchHistory = []; + List searchHistory = []; //If download threads warning was shown @HiveField(5) - @JsonKey(defaultValue: false) bool threadsWarning = false; //Last time update check @@ -62,9 +98,17 @@ class Cache { @HiveField(9, defaultValue: false) bool canStreamLossless = false; - @JsonKey(includeToJson: false, includeFromJson: false) bool wakelock = false; + @HiveField(10, defaultValue: null) + CacheEntry>? favoritePlaylists; + @HiveField(11, defaultValue: null) + CacheEntry>? favoriteArtists; + @HiveField(12, defaultValue: null) + CacheEntry>? favoriteAlbums; + @HiveField(13, defaultValue: null) + CacheEntry>? favoriteTracks; + Cache(); //Wrapper to test if track is favorite against cache @@ -74,27 +118,34 @@ class Cache { return libraryTracks.contains(t.id); } + /// Add [item] to the corresponding favorite* cached item + void addFavorite(DeezerMediaItem item) { + switch (item) { + case final Track track: + favoriteTracks?.value.add(track); + libraryTracks.add(track.id); + break; + case final Album album: + favoriteAlbums?.value[album.id!] = album; + break; + case final Playlist playlist: + favoritePlaylists?.value[playlist.id] = playlist; + break; + case final Artist artist: + favoriteArtists?.value[artist.id] = artist; + break; + } + } + //Add to history void addToSearchHistory(DeezerMediaItem item) async { // Remove duplicate - int i = searchHistory.indexWhere((e) => e.data.id == item.id); + int i = searchHistory.indexWhere((e) => e.id == item.id); if (i != -1) { searchHistory.removeAt(i); } - if (item is Track) { - searchHistory.add(SearchHistoryItem(item, SearchHistoryItemType.track)); - } - if (item is Album) { - searchHistory.add(SearchHistoryItem(item, SearchHistoryItemType.album)); - } - if (item is Artist) { - searchHistory.add(SearchHistoryItem(item, SearchHistoryItemType.artist)); - } - if (item is Playlist) { - searchHistory - .add(SearchHistoryItem(item, SearchHistoryItemType.playlist)); - } + searchHistory.add(item); await save(); } @@ -134,64 +185,9 @@ class Cache { await box.clear(); await box.put(0, this); } - - //JSON - factory Cache.fromJson(Map json) => _$CacheFromJson(json); - Map toJson() => _$CacheToJson(this); - - //Search History JSON - // static List _searchHistoryFromJson(List? json) { - // return (json ?? []) - // .map((i) => _searchHistoryItemFromJson(i)) - // .toList(); - // } - - // static SearchHistoryItem _searchHistoryItemFromJson( - // Map json) { - // SearchHistoryItemType type = SearchHistoryItemType.values[json['type']]; - // dynamic data; - // switch (type) { - // case SearchHistoryItemType.TRACK: - // data = Track.fromJson(json['data']); - // break; - // case SearchHistoryItemType.ALBUM: - // data = Album.fromJson(json['data']); - // break; - // case SearchHistoryItemType.ARTIST: - // data = Artist.fromJson(json['data']); - // break; - // case SearchHistoryItemType.PLAYLIST: - // data = Playlist.fromJson(json['data']); - // break; - // } - // return SearchHistoryItem(data, type); - // } -} - -@HiveType(typeId: 20) -@JsonSerializable() -class SearchHistoryItem { - @HiveField(0) - @JsonKey( - toJson: _searchHistoryItemTypeToJson, - fromJson: _searchHistoryItemTypeFromJson) - SearchHistoryItemType type; - @HiveField(1) - // TODO: make this type-safe - dynamic data; - - SearchHistoryItem(this.data, this.type); - - Map toJson() => _$SearchHistoryItemToJson(this); - factory SearchHistoryItem.fromJson(Map json) => - _$SearchHistoryItemFromJson(json); - - static int _searchHistoryItemTypeToJson(SearchHistoryItemType type) => - type.index; - static SearchHistoryItemType _searchHistoryItemTypeFromJson(int index) => - SearchHistoryItemType.values[index]; } +@deprecated @HiveType(typeId: 21) enum SearchHistoryItemType { @HiveField(0) diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index 65aefd9..18451f3 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:cookie_jar/cookie_jar.dart'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; @@ -44,8 +46,13 @@ class DeezerAPI { cookieJar.delete(Uri.https('www.deezer.com')); return; } - cookieJar - .saveFromResponse(Uri.https('www.deezer.com'), [Cookie('arl', arl)]); + cookieJar.saveFromResponse(Uri.https('www.deezer.com'), [ + Cookie('arl', arl) + ..domain = '.deezer.com' + ..httpOnly = true + ..sameSite = SameSite.none + ..secure = true + ]); } String? token; @@ -89,9 +96,24 @@ class DeezerAPI { dio.options.headers = headers; } + Future> callPipeApi( + String operationName, String query, Map variables, + {CancelToken? cancelToken}) async { + final res = await dio.post('https://pipe.deezer.com/api', + data: jsonEncode({ + 'operationName': operationName, + 'variables': variables, + 'query': query, + }), + cancelToken: cancelToken); + return res.data; + } + //Call private API Future> callApi(String method, - {Map? params, String? gatewayInput}) async { + {Map? params, + String? gatewayInput, + CancelToken? cancelToken}) async { //Post final res = await dio.post('https://www.deezer.com/ajax/gw-light.php', queryParameters: { @@ -102,7 +124,8 @@ class DeezerAPI { //Used for homepage if (gatewayInput != null) 'gateway_input': gatewayInput }, - data: jsonEncode(params)); + data: jsonEncode(params), + cancelToken: cancelToken); final body = res.data; // In case of error "Invalid CSRF token" retrieve new one and retry the same call @@ -310,6 +333,17 @@ class DeezerAPI { }).toList(growable: false); } + // TODO: Not working + Future<(String, DateTime)> getTrackToken(String trackId) async { + final data = await callPipeApi( + 'TrackMediaToken', + "query TrackMediaToken(\$trackId: String!) {\n track(trackId: \$trackId) {\n media {\n token {\n payload\n expiresAt\n __typename\n }\n __typename\n }\n __typename\n }\n}", + {'trackId': trackId}, + ); + + return data['data']['track']['media']['token']; + } + //Search Future search(String? query) async { Map data = await callApi('deezer.pageSearch', @@ -395,6 +429,9 @@ class DeezerAPI { await callApi('artist.addFavorite', params: {'ART_ID': id}); } + Future addFavoriteShow(String id) => + callApi('show.addFavorite', params: {'SHOW_ID': id}); + //Remove artist from favorites/library Future removeArtist(String? id) async { await callApi('artist.deleteFavorite', params: {'ART_ID': id}); @@ -430,7 +467,7 @@ class DeezerAPI { //Get users playlists Future> getPlaylists() async { Map data = await callApi('deezer.pageProfile', - params: {'nb': 100, 'tab': 'playlists', 'user_id': userId}); + params: {'nb': 2000, 'tab': 'playlists', 'user_id': userId}); return data['results']['TAB']['playlists']['data'] .map((json) => Playlist.fromPrivateJson(json, library: true)) .toList(); @@ -439,7 +476,7 @@ class DeezerAPI { //Get favorite albums Future> getAlbums() async { Map data = await callApi('deezer.pageProfile', - params: {'nb': 50, 'tab': 'albums', 'user_id': userId}); + params: {'nb': 2000, 'tab': 'albums', 'user_id': userId}); List albumList = data['results']['TAB']['albums']['data']; List albums = albumList .map((json) => Album.fromPrivateJson(json, library: true)) @@ -448,15 +485,18 @@ class DeezerAPI { } //Remove album from library - Future removeAlbum(String? id) async { + Future removeAlbum(String? id) async { await callApi('album.deleteFavorite', params: {'ALB_ID': id}); } //Remove track from favorites - Future removeFavorite(String id) async { + Future removeFavorite(String id) async { await callApi('favorite_song.remove', params: {'SNG_ID': id}); } + Future removeFavoriteShow(String id) => + callApi('show.deleteFavorite', params: {'SHOW_ID': id}); + //Get favorite artists Future?> getArtists() async { Map data = await callApi('deezer.pageProfile', @@ -467,11 +507,13 @@ class DeezerAPI { } //Get lyrics by track id - Future lyrics(String? trackId) async { - Map data = await callApi('song.getLyrics', params: {'sng_id': trackId}); + Future lyrics(String? trackId, {CancelToken? cancelToken}) async { + Map data = await callApi('song.getLyrics', + params: {'sng_id': trackId}, cancelToken: cancelToken); if (data['error'] != null && data['error'].length > 0) { - return Lyrics.error(); + throw Exception('Deezer reported error: ${data['error']}'); } + print(data); return Lyrics.fromPrivateJson(data['results']); } @@ -505,7 +547,8 @@ class DeezerAPI { 'show', 'smarttracklist', 'track', - 'user' + 'user', + 'external-link' ]; Map data = await callApi('page.get', gatewayInput: jsonEncode({ @@ -663,9 +706,10 @@ class DeezerAPI { .toList(); } - Future?> searchSuggestions(String? query) async { - Map data = - await callApi('search_getSuggestedQueries', params: {'QUERY': query}); + Future?> searchSuggestions(String? query, + {CancelToken? cancelToken}) async { + Map data = await callApi('search_getSuggestedQueries', + params: {'QUERY': query}, cancelToken: cancelToken); return (data['results']['SUGGESTION'] as List?) ?.map((s) => s['QUERY'] as String) .toList(); @@ -702,11 +746,24 @@ class DeezerAPI { } //Get similar tracks for track with id [trackId] - Future?> playMix(String? trackId) async { + Future> playMix(String? trackId) async { Map data = await callApi('song.getContextualTrackMix', params: { 'sng_ids': [trackId] }); - return data['results']['data'] + return data['results']['data']! + .map((t) => Track.fromPrivateJson(t)) + .toList(); + } + + Future> getSearchTrackMix(String trackId, + [bool? startWithInputTrack = true]) async { + Map data = await callApi('song.getSearchTrackMix', params: { + 'sng_id': trackId, + if (startWithInputTrack != null) + 'start_with_input_track': startWithInputTrack, + }); + + return data['results']['data']! .map((t) => Track.fromPrivateJson(t)) .toList(); } @@ -725,3 +782,9 @@ class DeezerAPI { .toList(); } } + +class PipeAPI { + PipeAPI._(); + + Future getTrackToken(String trackId) async {} +} diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart index 3686683..aaf06e2 100644 --- a/lib/api/definitions.dart +++ b/lib/api/definitions.dart @@ -92,7 +92,7 @@ class Track extends DeezerMediaItem { } //MediaItem - Future toMediaItem() async { + MediaItem toMediaItem() { return MediaItem( title: title!, album: album!.title!, @@ -641,7 +641,7 @@ class DeezerImageDetails extends ImageDetails { @override String get full => size(1000, 1000); @override - String get thumb => size(140, 140); + String get thumb => size(264, 264); String size(int width, int height, {int num = 80, String id = '000000', String format = 'jpg'}) => @@ -957,18 +957,18 @@ class HomePageSection { //JSON static HomePageSection? fromPrivateJson(Map json) { - final layout = { - 'horizontal-grid': HomePageSectionLayout.ROW, - 'filterable-grid': HomePageSectionLayout.ROW, - 'grid-preview-two': HomePageSectionLayout.ROW, - 'grid': HomePageSectionLayout.GRID + final layout = const { + 'horizontal-grid': HomePageSectionLayout.row, + 'filterable-grid': HomePageSectionLayout.row, + 'grid-preview-two': HomePageSectionLayout.row, + 'grid': HomePageSectionLayout.grid, + 'slideshow': HomePageSectionLayout.slideshow, }[json['layout'] ?? '']; if (layout == null) { _logger.warning('UNKNOWN LAYOUT: ${json['layout']}'); - _logger.warning('LAYOUT DATA:'); - _logger.warning(json); return null; } + _logger.fine('LAYOUT: $layout'); final items = []; for (var i in (json['items'] ?? [])) { HomePageItem? hpi = HomePageItem.fromPrivateJson(i); @@ -1020,7 +1020,7 @@ class HomePageItem { case 'channel': return HomePageItem( type: HomePageItemType.CHANNEL, - value: DeezerChannel.fromPrivateJson(json)); + value: DeezerChannel.fromPrivateJson(json, false)); case 'album': return HomePageItem( type: HomePageItemType.ALBUM, @@ -1029,6 +1029,10 @@ class HomePageItem { return HomePageItem( type: HomePageItemType.SHOW, value: Show.fromPrivateJson(json['data'])); + case 'external-link': + return HomePageItem( + type: HomePageItemType.EXTERNAL_LINK, + value: DeezerChannel.fromPrivateJson(json, true)); default: return null; } @@ -1060,6 +1064,10 @@ class HomePageItem { return HomePageItem( type: HomePageItemType.SHOW, value: Show.fromPrivateJson(json['value'])); + case 'EXTERNAL_LINK': + return HomePageItem( + type: HomePageItemType.EXTERNAL_LINK, + value: DeezerChannel.fromJson(json['value'])); default: throw Exception('Unexpected type $t for HomePageItem'); } @@ -1090,15 +1098,21 @@ class DeezerChannel { @HiveField(5, defaultValue: null) final DeezerImageDetails? picture; + @JsonKey(defaultValue: false) + @HiveField(6, defaultValue: false) + final bool isExternalLink; + const DeezerChannel( {this.id, this.title, this.backgroundColor = Colors.blue, this.target, this.logo, - this.picture}); + this.picture, + this.isExternalLink = false}); - factory DeezerChannel.fromPrivateJson(Map json) => + factory DeezerChannel.fromPrivateJson( + Map json, bool isExternalLink) => DeezerChannel( id: json['id'], title: json['title'], @@ -1112,7 +1126,8 @@ class DeezerChannel { : null, picture: json.containsKey('pictures') && json['pictures'].length > 0 ? DeezerImageDetails.fromPrivateJson(json['pictures'][0]) - : null); + : null, + isExternalLink: isExternalLink); factory DeezerChannel.fromJson(Map json) => _$DeezerChannelFromJson(json); @@ -1136,14 +1151,21 @@ enum HomePageItemType { ALBUM, @HiveField(5) SHOW, + + @HiveField(6) + EXTERNAL_LINK, } @HiveType(typeId: 3) enum HomePageSectionLayout { @HiveField(0) - ROW, + row, @HiveField(1) - GRID, + grid, + + /// ROW but bigger + @HiveField(2) + slideshow, } enum RepeatType { NONE, LIST, TRACK } diff --git a/lib/api/download.dart b/lib/api/download.dart index f872258..ccf90a3 100644 --- a/lib/api/download.dart +++ b/lib/api/download.dart @@ -338,15 +338,15 @@ class DownloadManager { } //Get all offline available tracks - Future> allOfflineTracks() async { + Future> allOfflineTracks() async { if (!isSupported) return []; List rawTracks = await db.query('Tracks', where: 'offline == 1', columns: ['id']); - List out = []; + List out = []; //Load track meta individually for (Map rawTrack in rawTracks as Iterable>) { - out.add(await getOfflineTrack(rawTrack['id'])); + out.add((await getOfflineTrack(rawTrack['id']))!); } return out; } diff --git a/lib/api/download_manager/database.dart b/lib/api/download_manager/database.dart index 124c7d8..5201f23 100644 --- a/lib/api/download_manager/database.dart +++ b/lib/api/download_manager/database.dart @@ -215,7 +215,7 @@ class DeezerImageDetails { } } -@collection +@embedded class Lyrics { late final String lyricsId; late final String writers; diff --git a/lib/api/player/audio_handler.dart b/lib/api/player/audio_handler.dart index 29a46f3..c13bd6b 100644 --- a/lib/api/player/audio_handler.dart +++ b/lib/api/player/audio_handler.dart @@ -893,11 +893,13 @@ class AudioPlayerTask extends BaseAudioHandler { case 'mix': tracks = await _deezerAPI.playMix(queueSource!.id); // Deduplicate tracks with the same id - List queueIds = queue.value.map((e) => e.id).toList(); - tracks?.removeWhere((track) => queueIds.contains(track.id)); + // List queueIds = queue.value.map((e) => e.id).toList(); + // tracks?.removeWhere((track) => queueIds.contains(track.id)); break; case 'smarttracklist': tracks = (await _deezerAPI.smartTrackList(queueSource!.id!)).tracks; + case 'searchMix': + tracks = await _deezerAPI.getSearchTrackMix(queueSource!.id!, null); default: return; // print(queueSource.toJson()); @@ -908,8 +910,8 @@ class AudioPlayerTask extends BaseAudioHandler { 'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})'); } - final mi = await Future.wait( - tracks.map>((t) => t.toMediaItem())); + final mi = + tracks.map((t) => t.toMediaItem()).toList(growable: false); await addQueueItems(mi); } diff --git a/lib/api/player/player_helper.dart b/lib/api/player/player_helper.dart index be1e718..d5382a9 100644 --- a/lib/api/player/player_helper.dart +++ b/lib/api/player/player_helper.dart @@ -185,12 +185,12 @@ class PlayerHelper { } //Replace queue, play specified track id - Future _loadQueuePlay(List queue, String? trackId) async { + Future _loadQueuePlay(List queue, int? index) async { await settings.updateAudioServiceQuality(); - await audioHandler.customAction('setIndex', { - 'index': trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId) - }); + if (index != null) { + await audioHandler.customAction('setIndex', {'index': index}); + } await audioHandler.updateQueue(queue); // if (queue[0].id != trackId) // await AudioService.skipToQueueItem(trackId); @@ -206,7 +206,7 @@ class PlayerHelper { //Play mix by track Future playMix(String trackId, String trackTitle) async { List tracks = (await deezerAPI.playMix(trackId))!; - playFromTrackList( + await playFromTrackList( tracks, tracks[0].id, QueueSource( @@ -215,6 +215,35 @@ class PlayerHelper { source: 'mix')); } + Future playSearchMixDeferred(Track track) async { + final playFuture = playFromTrackList( + [track], + null, + QueueSource( + id: track.id, + text: "${'Mix based on'.i18n} ${track.title}", + source: 'searchMix')); + List tracks = await deezerAPI.getSearchTrackMix(track.id, false); + // discard first track (if it is the searched track) + if (tracks[0].id == track.id) tracks.removeAt(0); + await playFuture; // avoid race conditions + + // update queue with mix + await audioHandler.addQueueItems( + tracks.map((e) => e.toMediaItem()).toList(growable: false)); + } + + Future playSearchMix(String trackId, String trackTitle) async { + List tracks = await deezerAPI.getSearchTrackMix(trackId, true); + await playFromTrackList( + tracks, + null, // we can avoid passing it, as the index is 0 + QueueSource( + id: trackId, + text: "${'Mix based on'.i18n} $trackTitle", + source: 'searchMix')); + } + //Play from artist top tracks Future playFromTopTracks( List tracks, String trackId, Artist artist) async { @@ -249,17 +278,17 @@ class PlayerHelper { } //Load tracks as queue, play track id, set queue source - Future playFromTrackList( - List tracks, String? trackId, QueueSource queueSource) async { - final queue = await Future.wait(tracks - .map>((track) => track!.toMediaItem()) - .toList()); + Future playFromTrackList( + List tracks, String? trackId, QueueSource queueSource) async { + final queue = + tracks.map((track) => track!.toMediaItem()).toList(); await setQueueSource(queueSource); - await _loadQueuePlay(queue, trackId); + await _loadQueuePlay( + queue, trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId)); } //Load smart track list as queue, start from beginning - Future playFromSmartTrackList(SmartTrackList stl) async { + Future playFromSmartTrackList(SmartTrackList stl) async { //Load from API if no tracks if (stl.tracks == null || stl.tracks!.isEmpty) { if (settings.offlineMode) { diff --git a/lib/main.dart b/lib/main.dart index c46d3ca..3c7eac7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -76,7 +76,7 @@ void main() async { ..registerAdapter(SortingAdapter()) ..registerAdapter(SortTypeAdapter()) ..registerAdapter(SortSourceTypesAdapter()) - ..registerAdapter(SearchHistoryItemAdapter()) + ..registerAdapter(CacheEntryAdapter()) ..registerAdapter(SearchHistoryItemTypeAdapter()) ..registerAdapter(CacheAdapter()) ..registerAdapter(ColorAdapter()) @@ -92,7 +92,7 @@ void main() async { ..registerAdapter(QueueSourceAdapter()) ..registerAdapter(HomePageAdapter()) ..registerAdapter(NavigationRailAppearanceAdapter()) - ..registerAdapter(HiveCacheObjectAdapter(typeId: 35)); + ..registerAdapter(HiveCacheObjectAdapter(typeId: 35)); // not working? Hive.init(await Paths.dataDirectory()); diff --git a/lib/ui/details_screens.dart b/lib/ui/details_screens.dart index db6bd69..b575e52 100644 --- a/lib/ui/details_screens.dart +++ b/lib/ui/details_screens.dart @@ -356,6 +356,7 @@ class _ArtistDetailsState extends State { maxHeight: MediaQuery.of(context).size.height / 3), child: Row( mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Flexible( child: ZoomableImage( @@ -368,7 +369,7 @@ class _ArtistDetailsState extends State { MediaQuery.of(context).size.width / 16, 60.0)), Expanded( child: Column( - mainAxisSize: MainAxisSize.max, + mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -847,6 +848,8 @@ class _PlaylistDetailsState extends State { setState(() { playlist = p; }); + // update cache + cache.favoritePlaylists?.value[playlist!.id] = p; //Load tracks _load(); }).catchError((e) { @@ -872,7 +875,7 @@ class _PlaylistDetailsState extends State { children: [ const SizedBox(height: 4.0), ConstrainedBox( - constraints: BoxConstraints.tight( + constraints: BoxConstraints.loose( Size.fromHeight(MediaQuery.of(context).size.height / 3)), child: Padding( padding: const EdgeInsets.symmetric( diff --git a/lib/ui/external_link_route.dart b/lib/ui/external_link_route.dart new file mode 100644 index 0000000..ad381c1 --- /dev/null +++ b/lib/ui/external_link_route.dart @@ -0,0 +1,51 @@ +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart' as fwv; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/settings.dart'; + +class ExternalLinkRoute extends StatelessWidget { + final String title; + final String target; + const ExternalLinkRoute( + {required this.title, required this.target, super.key}); + + Uri _resolveTarget(String target) { + print('target: $target'); + if (target == 'story/mdy' || target == '/story/mdy') { + // my deezer year redirect to iframe URL + return Uri.parse( + 'https://mydeezerstory.deezer.com/inapp?campaign=mdy&lang=${settings.deezerLanguage}'); + } + + // resolve relative to www.deezer.com + return Uri.https('www.deezer.com', '/us').resolve(target); + } + + Future> _resolveHeaders(Uri uri) async { + List cookies = await deezerAPI.cookieJar.loadForRequest(uri); + print(cookies); + return {'Cookie': cookies.join(';')}; + } + + @override + Widget build(BuildContext context) { + Uri uriTarget = _resolveTarget(target); + return Scaffold( + appBar: AppBar(title: Text(title)), + body: fwv.InAppWebView( + onWebViewCreated: (controller) async { + final uri = _resolveTarget(target); + final headers = await _resolveHeaders(uri); + controller.addWebMessageListener(fwv.WebMessageListener( + jsObjectName: 'jsObjectName', + onPostMessage: (message, sourceOrigin, isMainFrame, replyProxy) => + print('message: $message, sourceOrigin: $sourceOrigin'), + )); + controller.loadUrl( + urlRequest: fwv.URLRequest(url: uri, headers: headers)); + }, + ), + ); + } +} diff --git a/lib/ui/home_screen.dart b/lib/ui/home_screen.dart index 39b70a5..fa2ab78 100644 --- a/lib/ui/home_screen.dart +++ b/lib/ui/home_screen.dart @@ -5,6 +5,7 @@ import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/player/audio_handler.dart'; import 'package:freezer/main.dart'; import 'package:freezer/ui/error.dart'; +import 'package:freezer/ui/external_link_route.dart'; import 'package:freezer/ui/menu.dart'; import 'package:freezer/translations.i18n.dart'; import 'tiles.dart'; @@ -43,15 +44,26 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - child: Scaffold( - body: NestedScrollView( - floatHeaderSlivers: true, - headerSliverBuilder: (context, _) => [ - SliverPersistentHeader( - delegate: _SearchHeaderDelegate(), floating: true) - ], - body: const HomePageWidget(cacheable: true), + final actualScrollConfiguration = ScrollConfiguration.of(context); + return ScrollConfiguration( + behavior: actualScrollConfiguration.copyWith( + dragDevices: { + PointerDeviceKind.mouse, + PointerDeviceKind.stylus, + PointerDeviceKind.touch, + PointerDeviceKind.trackpad + }, + ), + child: SafeArea( + child: Scaffold( + body: NestedScrollView( + floatHeaderSlivers: true, + headerSliverBuilder: (context, _) => [ + SliverPersistentHeader( + delegate: _SearchHeaderDelegate(), floating: true) + ], + body: const HomePageWidget(cacheable: true), + ), ), ), ); @@ -189,9 +201,10 @@ class _HomePageWidgetState extends State { Widget getSectionChild(HomePageSection section) { switch (section.layout) { - case HomePageSectionLayout.GRID: + case HomePageSectionLayout.grid: return HomePageGridSection(section); - case HomePageSectionLayout.ROW: + case HomePageSectionLayout.slideshow: + case HomePageSectionLayout.row: default: return HomepageRowSection(section); } @@ -205,33 +218,22 @@ class _HomePageWidgetState extends State { sections = _homePage!.sections; } - final actualScrollConfiguration = ScrollConfiguration.of(context); - return ScrollConfiguration( - behavior: actualScrollConfiguration.copyWith( - dragDevices: { - PointerDeviceKind.mouse, - PointerDeviceKind.stylus, - PointerDeviceKind.touch, - PointerDeviceKind.trackpad - }, - ), - child: RefreshIndicator( - key: _indicatorKey, - onRefresh: _load, - child: _homePage == null - ? const SizedBox.expand(child: SingleChildScrollView()) - : ListView.builder( - itemBuilder: (context, index) { - return Padding( - padding: index == 0 - ? EdgeInsets.zero - : const EdgeInsets.only(top: 16.0), - child: getSectionChild(sections![index])); - }, - itemCount: sections!.length, - padding: const EdgeInsets.symmetric(horizontal: 8.0), - ), - ), + return RefreshIndicator( + key: _indicatorKey, + onRefresh: _load, + child: _homePage == null + ? const SizedBox.expand(child: SingleChildScrollView()) + : ListView.builder( + itemBuilder: (context, index) { + return Padding( + padding: index == 0 + ? EdgeInsets.zero + : const EdgeInsets.only(top: 16.0), + child: getSectionChild(sections![index])); + }, + itemCount: sections!.length, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + ), ); } } @@ -356,7 +358,8 @@ class HomePageGridSection extends StatelessWidget { class HomePageItemWidget extends StatelessWidget { final HomePageItem item; - const HomePageItemWidget(this.item, {super.key}); + final Size? itemSize; + const HomePageItemWidget(this.item, {super.key, this.itemSize}); @override Widget build(BuildContext context) { @@ -415,6 +418,19 @@ class HomePageItemWidget extends StatelessWidget { ); }, ); + case HomePageItemType.EXTERNAL_LINK: + return ChannelTile( + item.value, + onTap: () { + final channel = item.value as DeezerChannel; + Navigator.of(context).pushRoute( + builder: (context) => ExternalLinkRoute( + target: channel.target!, + title: channel.title ?? '', + ), + ); + }, + ); case HomePageItemType.SHOW: return ShowCard( item.value, diff --git a/lib/ui/library.dart b/lib/ui/library.dart index 7f5e5e4..0888547 100644 --- a/lib/ui/library.dart +++ b/lib/ui/library.dart @@ -237,7 +237,7 @@ class _LibraryTracksState extends State { bool _loadingTracks = false; final ScrollController _scrollController = ScrollController(); List tracks = []; - List allTracks = []; + List allTracks = []; int? trackCount; Sorting? _sort = Sorting(sourceType: SortSourceTypes.TRACKS); @@ -377,7 +377,7 @@ class _LibraryTracksState extends State { } Future _loadAllOffline() async { - List tracks = await downloadManager.allOfflineTracks(); + List tracks = await downloadManager.allOfflineTracks(); setState(() { allTracks = tracks; }); @@ -955,10 +955,33 @@ class _LibraryPlaylistsState extends State { } Future _load() async { + if (cache.favoritePlaylists != null) { + setState(() => _playlists = + cache.favoritePlaylists!.value.values.toList(growable: false)); + + if (DateTime.now().difference(cache.favoritePlaylists!.updatedAt) < + const Duration(hours: 1)) return; + } if (!settings.offlineMode) { try { final List playlists = await deezerAPI.getPlaylists(); setState(() => _playlists = playlists); + if (cache.favoritePlaylists == null) { + cache.favoritePlaylists = + CacheEntry({for (final p in playlists) p.id: p}); + } else { + // update non-destructively + final oldEntry = cache.favoritePlaylists!.value; + final newEntry = {}; + for (final playlist in playlists) { + if (oldEntry.containsKey(playlist.id)) { + newEntry[playlist.id] = oldEntry[playlist.id]!; + } else { + newEntry[playlist.id] = playlist; + } + } + } + await cache.save(); return; } catch (e) {} } diff --git a/lib/ui/lyrics_screen.dart b/lib/ui/lyrics_screen.dart index 8b133e8..ca9f0dc 100644 --- a/lib/ui/lyrics_screen.dart +++ b/lib/ui/lyrics_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:async/async.dart'; import 'package:audio_service/audio_service.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:freezer/api/deezer.dart'; @@ -48,13 +49,14 @@ class _LyricsWidgetState extends State { late StreamSubscription _mediaItemSub; late StreamSubscription _playbackStateSub; int? _currentIndex = -1; - Duration _nextPosition = Duration.zero; + Duration _nextOffset = Duration.zero; + Duration _currentOffset = Duration.zero; final ScrollController _controller = ScrollController(); final double height = 90; BoxConstraints? _widgetConstraints; Lyrics? _lyrics; bool _loading = true; - CancelableOperation? _lyricsCancelable; + CancelToken? _lyricsCancelToken; Object? _error; bool _freeScroll = false; @@ -63,7 +65,10 @@ class _LyricsWidgetState extends State { Future _loadForId(String trackId) async { // cancel current request, if applicable - await _lyricsCancelable?.cancel(); + _lyricsCancelToken?.cancel(); + _currentIndex = -1; + _currentOffset = Duration.zero; + _nextOffset = Duration.zero; //Fetch if (_loading == false && _lyrics != null) { @@ -75,10 +80,9 @@ class _LyricsWidgetState extends State { } try { - _lyricsCancelable = - CancelableOperation.fromFuture(deezerAPI.lyrics(trackId)); - final lyrics = await _lyricsCancelable!.valueOrCancellation(null); - if (lyrics == null) return; + _lyricsCancelToken = CancelToken(); + final lyrics = + await deezerAPI.lyrics(trackId, cancelToken: _lyricsCancelToken); _syncedLyrics = lyrics.sync; if (!mounted) return; setState(() { @@ -86,9 +90,10 @@ class _LyricsWidgetState extends State { _lyrics = lyrics; }); - _nextPosition = Duration.zero; SchedulerBinding.instance.addPostFrameCallback( (_) => _updatePosition(audioHandler.playbackState.value.position)); + } on DioException catch (e) { + if (e.type != DioExceptionType.cancel) rethrow; } catch (e) { if (!mounted) return; setState(() { @@ -126,21 +131,22 @@ class _LyricsWidgetState extends State { void _updatePosition(Duration position) { if (_loading) return; if (!_syncedLyrics) return; - if (position < _nextPosition) return; + if (position < _nextOffset && position > _currentOffset) return; _currentIndex = _lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position); - //Scroll to current lyric if (_currentIndex! < 0) return; - //Update current lyric index - if (_currentIndex! < _lyrics!.lyrics!.length) { - // update nextPosition - _nextPosition = _lyrics!.lyrics![_currentIndex! + 1].offset!; + + if (_currentIndex! < _lyrics!.lyrics!.length - 1) { + // update nextOffset + _nextOffset = _lyrics!.lyrics![_currentIndex! + 1].offset!; } else { // dummy position so that the before-hand condition always returns false - _nextPosition = const Duration(days: 69); + _nextOffset = const Duration(days: 69); } + _currentOffset = _lyrics!.lyrics![_currentIndex!].offset!; + setState(() => _currentIndex); if (_freeScroll) return; _scrollToLyric(); @@ -153,9 +159,6 @@ class _LyricsWidgetState extends State { // if (settings.lyricsVisualizer) playerHelper.startVisualizer(); _playbackStateSub = AudioService.position.listen(_updatePosition); }); - if (audioHandler.mediaItem.value != null) { - _loadForId(audioHandler.mediaItem.value!.id); - } /// Track change = ~exit~ reload lyrics _mediaItemSub = audioHandler.mediaItem.listen((mediaItem) { diff --git a/lib/ui/menu.dart b/lib/ui/menu.dart index ed7f8a4..39700a8 100644 --- a/lib/ui/menu.dart +++ b/lib/ui/menu.dart @@ -389,7 +389,13 @@ class MenuSheet { Text('Play mix'.i18n), icon: const Icon(Icons.online_prediction), onTap: () async { - playerHelper.playMix(track.id, track.title!); + // I couldn't find this API request within the Deezer app, but the + // same button uses the getSearchTrackMix API call, so let's use that + // instead. + + // playerHelper.playMix(track.id, track.title!); + + playerHelper.playSearchMix(track.id, track.title!); }, ); diff --git a/lib/ui/player_bar.dart b/lib/ui/player_bar.dart index d35c160..e0e1757 100644 --- a/lib/ui/player_bar.dart +++ b/lib/ui/player_bar.dart @@ -46,18 +46,24 @@ class PlayerBar extends StatelessWidget { initialData: audioHandler.mediaItem.valueOrNull, builder: (context, snapshot) { if (snapshot.data == null) { - return Material( - child: ListTile( - dense: true, - visualDensity: VisualDensity.standard, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 6.0), - leading: Image.asset( - 'assets/cover_thumb.jpg', - width: 48.0, - height: 48.0, + // lazy way to prevent dragging up + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragEnd: (_) {}, + onVerticalDragUpdate: (_) {}, + child: Material( + child: ListTile( + dense: true, + visualDensity: VisualDensity.standard, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 6.0), + leading: Image.asset( + 'assets/cover_thumb.jpg', + width: 48.0, + height: 48.0, + ), + title: Text('Nothing is currently playing'.i18n), ), - title: Text('Nothing is currently playing'.i18n), ), ); } @@ -72,6 +78,7 @@ class PlayerBar extends StatelessWidget { ? Hero(tag: currentMediaItem.id, child: image) : image; return Material( + type: MaterialType.transparency, child: ListTile( dense: true, tileColor: _backgroundColor, diff --git a/lib/ui/player_screen.dart b/lib/ui/player_screen.dart index fa3b5c0..a349e32 100644 --- a/lib/ui/player_screen.dart +++ b/lib/ui/player_screen.dart @@ -333,9 +333,11 @@ class PlayerScreenDesktop extends StatelessWidget { showQueueButton: false, ), ), - ConstrainedBox( - constraints: BoxConstraints.loose(const Size.square(500)), - child: const BigAlbumArt()), + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints.loose(const Size.square(500)), + child: const BigAlbumArt()), + ), Padding( padding: const EdgeInsets.symmetric(horizontal: 24.0), child: PlayerTextSubtext(textSize: 18.sp), @@ -435,7 +437,6 @@ class _FitOrScrollTextState extends State { ); textPainter.layout(maxWidth: constraints.maxWidth); - print(textPainter.didExceedMaxLines); return !(textPainter.didExceedMaxLines || textPainter.height > constraints.maxHeight || @@ -458,7 +459,7 @@ class _FitOrScrollTextState extends State { startPadding: 0.0, accelerationDuration: const Duration(seconds: 1), pauseAfterRound: const Duration(seconds: 2), - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, fadingEdgeEndFraction: 0.05, fadingEdgeStartFraction: 0.05, ); @@ -483,12 +484,15 @@ class PlayerTextSubtext extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - FitOrScrollText( - key: Key(currentMediaItem.displayTitle!), - text: currentMediaItem.displayTitle!, - maxLines: 1, - style: TextStyle( - fontSize: textSize, fontWeight: FontWeight.bold)), + SizedBox( + height: 1.5 * textSize, + child: FitOrScrollText( + key: Key(currentMediaItem.displayTitle!), + text: currentMediaItem.displayTitle!, + maxLines: 1, + style: TextStyle( + fontSize: textSize, fontWeight: FontWeight.bold)), + ), // child: currentMediaItem.displayTitle!.length >= 26 // ? Marquee( // key: Key(currentMediaItem.displayTitle!), @@ -511,7 +515,6 @@ class PlayerTextSubtext extends StatelessWidget { // style: TextStyle( // fontSize: textSize, fontWeight: FontWeight.bold), // )), - const SizedBox(height: 2.0), Text( currentMediaItem.displaySubtitle ?? '', maxLines: 1, diff --git a/lib/ui/search.dart b/lib/ui/search.dart index d25f70e..2782fd8 100644 --- a/lib/ui/search.dart +++ b/lib/ui/search.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -59,6 +60,7 @@ class _SearchScreenState extends State { final _suggestions = ListNotifier([]); final _showingSuggestions = ValueNotifier(false); final _loading = ValueNotifier(false); + CancelToken? _searchCancelToken; Timer? _searchTimer; final _focus = FocusNode(); final _textFieldFocusNode = FocusNode(); @@ -121,16 +123,24 @@ class _SearchScreenState extends State { _controller.text.length < 2 || _controller.text.startsWith('http')) return; _loading.value = true; + _searchCancelToken?.cancel(); + //Load - List? sugg; + final List? suggestions; try { - sugg = await deezerAPI.searchSuggestions(_controller.text); + _searchCancelToken = CancelToken(); + suggestions = await deezerAPI.searchSuggestions(_controller.text, + cancelToken: _searchCancelToken); + } on DioException catch (e) { + if (e.type != DioExceptionType.cancel) rethrow; + return; } catch (e) { print(e); + return; } - _loading.value = false; - if (sugg != null) _suggestions.value = sugg; + _loading.value = false; + if (suggestions != null) _suggestions.value = suggestions; } Widget _removeHistoryItemWidget(int index) { @@ -193,8 +203,8 @@ class _SearchScreenState extends State { if (query.isEmpty) { _suggestions.clear(); } else { - _searchTimer ??= Timer( - const Duration(milliseconds: 300), () { + _searchTimer ??= + Timer(const Duration(milliseconds: 1), () { _searchTimer = null; _loadSuggestions(); }); @@ -274,16 +284,13 @@ class _SearchScreenState extends State { ), ...List.generate(min(cache.searchHistory.length, 10), (int i) { - final data = cache.searchHistory[i].data; - switch (cache.searchHistory[i].type) { - case SearchHistoryItemType.track: + switch (cache.searchHistory[i]) { + case final Track data: return TrackTile.fromTrack( data, onTap: () { - List queue = cache.searchHistory - .where((h) => - h.type == SearchHistoryItemType.track) - .map((t) => t.data) + final queue = cache.searchHistory + .whereType() .toList(); playerHelper.playFromTrackList( queue, @@ -297,7 +304,7 @@ class _SearchScreenState extends State { .defaultTrackMenu(data, details: details), trailing: _removeHistoryItemWidget(i), ); - case SearchHistoryItemType.album: + case final Album data: return AlbumTile( data, onTap: () { @@ -308,7 +315,7 @@ class _SearchScreenState extends State { .defaultAlbumMenu(data, details: details), trailing: _removeHistoryItemWidget(i), ); - case SearchHistoryItemType.artist: + case final Artist data: return ArtistHorizontalTile( data, onTap: () { @@ -320,7 +327,7 @@ class _SearchScreenState extends State { MenuSheet(context).defaultArtistMenu(data), trailing: _removeHistoryItemWidget(i), ); - case SearchHistoryItemType.playlist: + case final Playlist data: return PlaylistTile( data, onTap: () { @@ -480,13 +487,7 @@ class SearchResultsScreen extends StatelessWidget { .getRange(0, min(results.tracks!.length, 3))) TrackTile.fromTrack(track, onTap: () { cache.addToSearchHistory(track); - playerHelper.playFromTrackList( - results.tracks!, - track.id, - QueueSource( - text: 'Search'.i18n, - id: query, - source: 'search')); + playerHelper.playSearchMixDeferred(track); }, onSecondary: (details) { MenuSheet m = MenuSheet(context); m.defaultTrackMenu(track, details: details); @@ -495,12 +496,8 @@ class SearchResultsScreen extends StatelessWidget { title: Text('Show all tracks'.i18n), onTap: () { Navigator.of(context).pushRoute( - builder: (context) => TrackListScreen( - results.tracks, - QueueSource( - id: query, - source: 'search', - text: 'Search'.i18n))); + builder: (context) => + TrackListScreen(results.tracks, null)); }, ), const FreezerDivider(), @@ -699,8 +696,8 @@ class SearchResultsScreen extends StatelessWidget { //List all tracks class TrackListScreen extends StatelessWidget { - final QueueSource queueSource; - final List? tracks; + final QueueSource? queueSource; + final List? tracks; const TrackListScreen(this.tracks, this.queueSource, {super.key}); @@ -711,11 +708,16 @@ class TrackListScreen extends StatelessWidget { body: ListView.builder( itemCount: tracks!.length, itemBuilder: (BuildContext context, int i) { - Track t = tracks![i]!; + Track t = tracks![i]; return TrackTile.fromTrack( t, onTap: () { - playerHelper.playFromTrackList(tracks!, t.id, queueSource); + if (queueSource == null) { + playerHelper.playSearchMixDeferred(t); + return; + } + + playerHelper.playFromTrackList(tracks!, t.id, queueSource!); }, onSecondary: (details) { MenuSheet m = MenuSheet(context); diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 3733c1a..d9dbf1e 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -28,5 +28,10 @@ MainMenu NSPrincipalClass NSApplication + NSAppTransportSecurity + + NSAllowsArbitraryLoads + +