From 6816bdc1122d5caf0e6eed993ba8daf07d102113 Mon Sep 17 00:00:00 2001 From: Pato05 Date: Sun, 18 Feb 2024 16:05:35 +0100 Subject: [PATCH] implement playFromSearch and playFromUri for GAssistant change package name to deezer.android.app for the same reason (will probably be changed back if we can get freezer to work even without this "hack") implement topResult in search screen --- android/app/build.gradle | 6 +- android/app/src/main/AndroidManifest.xml | 7 +- devtools_options.yaml | 1 + lib/api/deezer.dart | 22 +- lib/api/definitions.dart | 25 +- lib/api/player/audio_handler.dart | 127 +++- lib/api/player/player_helper.dart | 20 +- lib/main.dart | 8 +- lib/ui/search.dart | 703 +++++++++++++++-------- 9 files changed, 642 insertions(+), 277 deletions(-) create mode 100644 devtools_options.yaml diff --git a/android/app/build.gradle b/android/app/build.gradle index a795daa..fcf44ca 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -40,8 +40,8 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "f.f.freezer" + // Workaround to make Google assistant work with Freezer + applicationId "deezer.android.app" // "f.f.freezer" minSdkVersion 21 targetSdkVersion 33 versionCode flutterVersionCode.toInteger() @@ -64,7 +64,7 @@ android { minifyEnabled true } debug { - applicationIdSuffix ".debug" + // applicationIdSuffix ".debug" shrinkResources false minifyEnabled false } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index faf4f38..822ad31 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -40,7 +40,7 @@ android:label="Freezer" android:requestLegacyExternalStorage="true" android:usesCleartextTraffic="true" - tools:targetApi="m"> + android:targetSandboxVersion="2"> - + + + + diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..7e7e7f6 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index b920bef..7511ed8 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -235,23 +235,28 @@ class DeezerAPI { } //URL/Link parser - Future parseLink(String url) async { - Uri uri = Uri.parse(url); - //https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID + Future parseLink(Uri uri) async { + //https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID[/radio?autoplay=true] if (uri.host == 'www.deezer.com' || uri.host == 'deezer.com') { if (uri.pathSegments.length < 2) return null; - DeezerLinkType? type = DeezerLinkResponse.typeFromString( - uri.pathSegments[uri.pathSegments.length - 2]); + if (uri.pathSegments[uri.pathSegments.length - 1] == 'radio') { + return DeezerLinkResponse( + type: DeezerLinkResponse.typeFromString( + uri.pathSegments[uri.pathSegments.length - 3]), + id: uri.pathSegments[uri.pathSegments.length - 2]); + } return DeezerLinkResponse( - type: type, id: uri.pathSegments[uri.pathSegments.length - 1]); + type: DeezerLinkResponse.typeFromString( + uri.pathSegments[uri.pathSegments.length - 2]), + id: uri.pathSegments[uri.pathSegments.length - 1]); } //Share URL if (uri.host == 'deezer.page.link' || uri.host == 'www.deezer.page.link') { - http.BaseRequest request = http.Request('HEAD', Uri.parse(url)); + http.BaseRequest request = http.Request('HEAD', uri); request.followRedirects = false; http.StreamedResponse response = await request.send(); String newUrl = response.headers['location']!; - return parseLink(newUrl); + return parseLink(Uri.parse(newUrl)); } //Spotify if (uri.host == 'open.spotify.com') { @@ -333,6 +338,7 @@ class DeezerAPI { Future search(String? query) async { Map data = await callApi('deezer.pageSearch', params: {'nb': 128, 'query': query, 'start': 0}); + print(data['results']['TOP_RESULT']); return SearchResults.fromPrivateJson(data['results']); } diff --git a/lib/api/definitions.dart b/lib/api/definitions.dart index 122a80d..cbee587 100644 --- a/lib/api/definitions.dart +++ b/lib/api/definitions.dart @@ -22,6 +22,22 @@ part 'definitions.g.dart'; abstract class DeezerMediaItem { String? get id; + + static DeezerMediaItem? fromDeezerObject(Map data) { + switch (data['__TYPE__']!) { + case 'song': + return Track.fromPrivateJson(data); + case 'artist': + return Artist.fromPrivateJson(data); + case 'playlist': + return Playlist.fromPrivateJson(data); + case 'album': + return Album.fromPrivateJson(data); + default: + print('UNKNOWN MEDIA ITEM TYPE ${data['__TYPE__']}'); + return null; + } + } } @HiveType(typeId: 5) @@ -662,6 +678,7 @@ class DeezerImageDetails extends ImageDetails { } class SearchResults { + List? topResult; List? tracks; List? albums; List? artists; @@ -670,7 +687,8 @@ class SearchResults { List? episodes; SearchResults( - {this.tracks, + {this.topResult, + this.tracks, this.albums, this.artists, this.playlists, @@ -689,6 +707,11 @@ class SearchResults { factory SearchResults.fromPrivateJson(Map json) => SearchResults( + topResult: (json['TOP_RESULT'] as List?) + ?.map( + (e) => DeezerMediaItem.fromDeezerObject(e as Map)) + .whereType() + .toList(growable: false), tracks: json['TRACK']['data'] .map((dynamic data) => Track.fromPrivateJson(data)) .toList(), diff --git a/lib/api/player/audio_handler.dart b/lib/api/player/audio_handler.dart index 8897fc0..d889c9c 100644 --- a/lib/api/player/audio_handler.dart +++ b/lib/api/player/audio_handler.dart @@ -492,15 +492,15 @@ class AudioPlayerTask extends BaseAudioHandler { return const MediaControl( androidIcon: 'drawable/ic_heart', label: 'unfavorite', - action: MediaAction.custom); + action: MediaAction.custom, + customAction: CustomMediaAction(name: 'favorite')); } return const MediaControl( - androidIcon: 'drawable/ic_heart_outline', - label: 'favourite', - action: MediaAction - .custom, // this acts as favourite/unfavourite as it's not already used. - ); + androidIcon: 'drawable/ic_heart_outline', + label: 'favourite', + action: MediaAction.custom, + customAction: CustomMediaAction(name: 'favorite')); } //Update state on all clients @@ -535,7 +535,6 @@ class AudioPlayerTask extends BaseAudioHandler { systemActions: !currentMediaItemIsShow ? { MediaAction.seek, - MediaAction.seekBackward, if (queue.hasValue && _queueIndex != queue.value.length - 1) MediaAction.skipToNext, if (_queueIndex != 0) MediaAction.skipToPrevious, @@ -552,6 +551,12 @@ class AudioPlayerTask extends BaseAudioHandler { bufferedPosition: _player.bufferedPosition, speed: _player.speed, queueIndex: _queueIndex, + androidCompactActionIndices: + !currentMediaItemIsShow ? const [1, 2, 3] : const [0, 1, 2], + repeatMode: _repeatMode, + shuffleMode: _originalQueue == null + ? AudioServiceShuffleMode.none + : AudioServiceShuffleMode.all, )); } @@ -863,6 +868,114 @@ class AudioPlayerTask extends BaseAudioHandler { await skipToQueueItem(queue.value.indexWhere((item) => item.id == mediaId)); } + @override + Future> search(String query, [Map? extras]) { + _logger.fine('search($query, $extras)'); + return Future.value([]); + } + + @override + Future playFromSearch(String query, + [Map? extras]) async { + _logger.fine('playFromSearch($query, $extras)'); + + // play from Freezer + + final res = await _deezerAPI.search(query); + print(res); + if (res.topResult != null && res.topResult!.isNotEmpty) { + _logger.fine('playing from top result: ${jsonEncode(res.topResult![0])}'); + final top = res.topResult![0]; + switch (top) { + case final Track track: + unawaited(playerHelper.playSearchMix(track.id, track.title!)); + break; + case final Artist artist: + final fullArtist = await _deezerAPI.artist(artist.id); + unawaited(playerHelper.playFromTopTracks( + fullArtist.topTracks!, null, fullArtist)); + break; + case final Album album: + final fullAlbum = await _deezerAPI.album(album.id); + unawaited(playerHelper.playFromAlbum(fullAlbum)); + break; + case final Playlist playlist: + final fullPlaylist = await _deezerAPI.playlist(playlist.id); + unawaited(playerHelper.playFromPlaylist(fullPlaylist)); + break; + } + } else if (res.tracks != null && res.tracks!.isNotEmpty) { + _logger.fine('playing from track mix: ${jsonEncode(res.tracks![0])}'); + unawaited( + playerHelper.playSearchMix(res.tracks![0].id, res.tracks![0].title!)); + } else if (res.albums != null && res.albums!.isNotEmpty) { + _logger.fine('playing from album: ${jsonEncode(res.albums![0])}'); + unawaited(playerHelper + .playFromAlbum(await _deezerAPI.album(res.albums![0].id))); + } else if (res.artists != null && res.artists!.isNotEmpty) { + _logger.fine('playing from artist top: ${jsonEncode(res.artists![0])}'); + final artist = await _deezerAPI.artist(res.artists![0].id); + unawaited( + playerHelper.playFromTopTracks(artist.topTracks!, null, artist)); + } else if (res.shows != null && res.shows!.isNotEmpty) { + _logger.fine('playing from show: ${jsonEncode(res.shows![0])}'); + final episodes = await _deezerAPI.allShowEpisodes(res.shows![0].id); + unawaited(playerHelper.playShowEpisode(res.shows![0], episodes!)); + } + } + + @override + Future playFromUri(Uri uri, [Map? extras]) async { + _logger.fine('playFromUri($uri, $extras)'); + if (uri.host != 'www.deezer.com' && uri.host != 'deezer.com') { + return; + } + + // parse common urls + if (uri.path.startsWith('/user/me/') && uri.pathSegments.length == 3) { + String stlId = uri.pathSegments[2]; + if ([ + 'new-releases', + 'inspired-by-1', + 'inspired-by-2', + 'inspired-by-3', + 'inspired-by-4', + 'discovery', + 'flow' + ].contains(stlId)) { + await playerHelper.playFromSmartTrackList(SmartTrackList(id: stlId)); + return; + } + } + + final parsed = await _deezerAPI.parseLink(uri); + if (parsed == null) return; + + switch (parsed.type!) { + case DeezerLinkType.TRACK: + final track = await _deezerAPI.track(parsed.id!); + _logger.fine('playing from track mix: ${jsonEncode(track)}'); + unawaited(playerHelper.playSearchMix(track.id, track.title!)); + break; + case DeezerLinkType.ALBUM: + final album = await _deezerAPI.album(parsed.id!); + _logger.fine('playing from album: ${album.title}'); + unawaited(playerHelper.playFromAlbum(album)); + break; + case DeezerLinkType.ARTIST: + final artist = await _deezerAPI.artist(parsed.id!); + _logger.fine('playing from artist top: ${artist.name!}'); + unawaited( + playerHelper.playFromTopTracks(artist.topTracks!, null, artist)); + break; + case DeezerLinkType.PLAYLIST: + final fullPlaylist = await _deezerAPI.playlist(parsed.id!); + _logger.fine('playing from playlist: ${fullPlaylist.title!}'); + unawaited(playerHelper.playFromPlaylist(fullPlaylist)); + break; + } + } + @override Future getMediaItem(String mediaId) async => queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId); diff --git a/lib/api/player/player_helper.dart b/lib/api/player/player_helper.dart index 6f8e754..9007eca 100644 --- a/lib/api/player/player_helper.dart +++ b/lib/api/player/player_helper.dart @@ -78,7 +78,12 @@ class PlayerHelper { androidNotificationChannelDescription: 'Freezer', androidNotificationChannelName: 'Freezer', androidNotificationIcon: 'drawable/ic_logo', + androidNotificationChannelId: 'f.f.freezer.audio', preloadArtwork: true, + androidBrowsableRootExtras: { + "android.media.browse.SEARCH_SUPPORTED": + true, // support showing search button on Android Auto as well as alternative search results on the player screen after voice search + }, ), cacheManager: cacheManager, ); @@ -197,13 +202,18 @@ class PlayerHelper { } //Play track from album - Future playFromAlbum(Album album, [String? trackId]) async { + Future playFromAlbum(Album album, [String? trackId]) async { + if (album.tracks!.length == 1) { + // play mix based on track + await playSearchMixDeferred(album.tracks![0]); + return; + } await playFromTrackList(album.tracks!, trackId, QueueSource(id: album.id, text: album.title, source: 'album')); } //Play mix by track - Future playMix(String trackId, String trackTitle) async { + Future playMix(String trackId, String trackTitle) async { List tracks = await deezerAPI.playMix(trackId); await playFromTrackList( tracks, @@ -244,8 +254,8 @@ class PlayerHelper { } //Play from artist top tracks - Future playFromTopTracks( - List tracks, String trackId, Artist artist) async { + Future playFromTopTracks( + List tracks, String? trackId, Artist artist) async { await playFromTrackList( tracks, trackId, @@ -253,7 +263,7 @@ class PlayerHelper { id: artist.id, text: 'Top ${artist.name}', source: 'topTracks')); } - Future playFromPlaylist(Playlist playlist, [String? trackId]) async { + Future playFromPlaylist(Playlist playlist, [String? trackId]) async { await playFromTrackList(playlist.tracks!, trackId, QueueSource(id: playlist.id, text: playlist.title, source: 'playlist')); } diff --git a/lib/main.dart b/lib/main.dart index dc03867..195c21a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -100,8 +100,10 @@ void main() async { try { cache = await Cache.load(); settings = await Settings.load(); - settings.save(); + await settings.save(); } catch (e) { + // ignore: avoid_print + print(e); // ignore: avoid_print print( 'Cannot load cache or settings box. Maybe another instance of the app is running?'); @@ -126,7 +128,7 @@ void main() async { '${record.level.name}: ${record.time}: [${record.loggerName}] ${record.message}'); }); - if (kDebugMode) { + if (kDebugMode || true) { Logger.root.level = Level.ALL; } @@ -470,12 +472,14 @@ class MainScreenState extends State //Listen to URLs _urlLinkStream = linkStream.listen((String? link) { if (link == null) return; + _logger.fine('received link: $link'); if (!context.mounted) return; openScreenByURL(context, link); }, onError: (err) {}); //Get initial link on cold start try { String? link = await getInitialLink(); + _logger.fine('initial link: $link'); if (!context.mounted) return; if (link != null && link.length > 4) openScreenByURL(context, link); diff --git a/lib/ui/search.dart b/lib/ui/search.dart index 293ee62..e46fc47 100644 --- a/lib/ui/search.dart +++ b/lib/ui/search.dart @@ -22,7 +22,7 @@ import '../api/definitions.dart'; import 'error.dart'; FutureOr openScreenByURL(BuildContext context, String url) async { - DeezerLinkResponse? res = await deezerAPI.parseLink(url); + DeezerLinkResponse? res = await deezerAPI.parseLink(Uri.parse(url)); if (res == null) return; switch (res.type) { @@ -435,271 +435,476 @@ class SearchBrowseCard extends StatelessWidget { } } -class SearchResultsScreen extends StatelessWidget { +class SearchResultsScreen extends StatefulWidget { final String? query; final bool? offline; const SearchResultsScreen(this.query, {super.key, this.offline}); + @override + State createState() => _SearchResultsScreenState(); +} + +class _SearchResultsScreenState extends State { + final _tracksKey = GlobalKey(); + final _albumsKey = GlobalKey(); + final _artistsKey = GlobalKey(); + final _playlistsKey = GlobalKey(); + final _showsKey = GlobalKey(); + final _episodesKey = GlobalKey(); + + SearchResults? _results; + Object? _error; + Future _search() async { - if (offline ?? false) { - return await downloadManager.search(query); + try { + final SearchResults results; + if (widget.offline ?? false) { + results = await downloadManager.search(widget.query); + } else { + results = await deezerAPI.search(widget.query); + } + setState(() { + _results = results; + }); + } catch (e) { + setState(() { + _error = e; + }); } - return await deezerAPI.search(query); + } + + Widget buildFromDeezerItem(DeezerMediaItem item) { + switch (item) { + case final Track track: + return const SizedBox.square(dimension: 128.0, child: Placeholder()); + // return TrackTile.fromTrack( + // track, + // onTap: () => playerHelper.playSearchMixDeferred(track), + // onSecondary: (details) => + // MenuSheet(context).defaultTrackMenu(track, details: details), + // ); + case final Album album: + return AlbumCard( + album, + onTap: () => Navigator.of(context) + .pushRoute(builder: (context) => AlbumDetails(album)), + onSecondary: (details) => + MenuSheet(context).defaultAlbumMenu(album, details: details), + ); + case final Playlist playlist: + return PlaylistCardTile( + playlist, + onTap: () => Navigator.of(context) + .pushRoute(builder: (context) => PlaylistDetails(playlist)), + onSecondary: (details) => MenuSheet(context) + .defaultPlaylistMenu(playlist, details: details), + ); + case final Artist artist: + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ArtistTile( + artist, + onTap: () => Navigator.of(context) + .pushRoute(builder: (ctx) => ArtistDetails(artist)), + onSecondary: (details) => + MenuSheet(context).defaultArtistMenu(artist, details: details), + ), + ); + default: + throw Exception(); + } + } + + @override + void initState() { + _search(); + super.initState(); } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(query!)), - body: FutureBuilder( - future: _search(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } - if (snapshot.hasError) return const ErrorScreen(); - - SearchResults results = snapshot.data; - - if (results.empty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.warning, - size: 64.sp, - ), - Text('No results!'.i18n) - ], - ), - ); - } - - return ListView( - children: [ - if (results.tracks != null && results.tracks!.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 4.0), - child: Text( - 'Tracks'.i18n, - textAlign: TextAlign.left, - style: const TextStyle( - fontSize: 20.0, fontWeight: FontWeight.bold), + appBar: AppBar( + title: Text(widget.query!), + bottom: PreferredSize( + preferredSize: Size.fromHeight(_results == null ? 0.0 : 50.0), + child: _results == null + ? const SizedBox.shrink() + : Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 8.0), + child: + ListView(scrollDirection: Axis.horizontal, children: [ + if (_results!.tracks != null && + _results!.tracks!.isNotEmpty) ...[ + ActionChip( + elevation: 1.0, + label: Text('Tracks'.i18n), + onPressed: () => Scrollable.ensureVisible( + _tracksKey.currentContext!, + duration: const Duration(milliseconds: 500))), + const SizedBox(width: 8.0), + ], + if (_results!.albums != null && + _results!.albums!.isNotEmpty) ...[ + ActionChip( + elevation: 1.0, + label: Text('Albums'.i18n), + onPressed: () => Scrollable.ensureVisible( + _albumsKey.currentContext!, + duration: const Duration(milliseconds: 500))), + const SizedBox(width: 8.0), + ], + if (_results!.artists != null && + _results!.artists!.isNotEmpty) ...[ + ActionChip( + elevation: 1.0, + label: Text('Artists'.i18n), + onPressed: () => Scrollable.ensureVisible( + _artistsKey.currentContext!, + duration: const Duration(milliseconds: 500))), + const SizedBox(width: 8.0), + ], + if (_results!.playlists != null && + _results!.playlists!.isNotEmpty) ...[ + ActionChip( + elevation: 1.0, + label: Text('Playlists'.i18n), + onPressed: () => Scrollable.ensureVisible( + _playlistsKey.currentContext!, + duration: const Duration(milliseconds: 500))), + const SizedBox(width: 8.0), + ], + if (_results!.shows != null && + _results!.shows!.isNotEmpty) ...[ + ActionChip( + elevation: 1.0, + label: Text('Shows'.i18n), + onPressed: () => Scrollable.ensureVisible( + _showsKey.currentContext!, + duration: const Duration(milliseconds: 500))), + const SizedBox(width: 8.0), + ], + if (_results!.episodes != null && + _results!.episodes!.isNotEmpty) ...[ + ActionChip( + elevation: 1.0, + label: Text('Episodes'.i18n), + onPressed: () => Scrollable.ensureVisible( + _episodesKey.currentContext!, + duration: const Duration(milliseconds: 500))), + const SizedBox(width: 8.0), + ], + ]), ), ), - for (final track in results.tracks! - .getRange(0, min(results.tracks!.length, 3))) - TrackTile.fromTrack(track, onTap: () { - cache.addToSearchHistory(track); - playerHelper.playSearchMixDeferred(track); - }, onSecondary: (details) { - MenuSheet m = MenuSheet(context); - m.defaultTrackMenu(track, details: details); - }), - ListTile( - title: Text('Show all tracks'.i18n), - onTap: () { - Navigator.of(context).pushRoute( - builder: (context) => - TrackListScreen(results.tracks, null)); - }, - ), - const FreezerDivider(), - ], - if (results.albums != null && results.albums!.isNotEmpty) ...[ - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, vertical: 4.0), - child: Text( - 'Albums'.i18n, - textAlign: TextAlign.left, - style: const TextStyle( - fontSize: 20.0, fontWeight: FontWeight.bold), - ), - ), - for (final album in results.albums! - .getRange(0, min(results.albums!.length, 3))) - AlbumTile(album, onSecondary: (details) { - MenuSheet m = MenuSheet(context); - m.defaultAlbumMenu(album, details: details); - }, onTap: () { - cache.addToSearchHistory(album); - Navigator.of(context) - .pushRoute(builder: (context) => AlbumDetails(album)); - }), - ListTile( - title: Text('Show all albums'.i18n), - onTap: () { - Navigator.of(context).pushRoute( - builder: (context) => - AlbumListScreen(results.albums)); - }, - ), - const FreezerDivider() - ], - if (results.artists != null && results.artists!.isNotEmpty) ...[ - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 4.0, horizontal: 16.0), - child: Text( - 'Artists'.i18n, - textAlign: TextAlign.left, - style: const TextStyle( - fontSize: 20.0, fontWeight: FontWeight.bold), - ), - ), - const SizedBox(height: 4.0), - SizedBox( - height: 136.0, - child: ListView.builder( - primary: false, - scrollDirection: Axis.horizontal, - itemCount: results.artists!.length, - itemBuilder: (context, index) { - final artist = results.artists![index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: ArtistTile( - artist, - onTap: () { - cache.addToSearchHistory(artist); - Navigator.of(context).pushRoute( - builder: (context) => ArtistDetails(artist)); - }, - onSecondary: (details) { - MenuSheet m = MenuSheet(context); - m.defaultArtistMenu(artist, details: details); - }, + ), + ), + body: _error != null + ? ErrorScreen(message: _error.toString()) + : _results == null + ? const Center(child: CircularProgressIndicator()) + : Builder( + builder: (context) { + final results = _results!; + if (results.empty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning, + size: 64.sp, + ), + Text('No results!'.i18n) + ], ), ); - }, - ), - ), - const FreezerDivider() - ], - if (results.playlists != null && - results.playlists!.isNotEmpty) ...[ - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 4.0, horizontal: 16.0), - child: Text( - 'Playlists'.i18n, - textAlign: TextAlign.left, - style: const TextStyle( - fontSize: 20.0, fontWeight: FontWeight.bold), - ), - ), - for (final playlist in results.playlists! - .getRange(0, min(results.playlists!.length, 3))) - PlaylistTile( - playlist, - onTap: () { - cache.addToSearchHistory(playlist); - Navigator.of(context).pushRoute( - builder: (context) => PlaylistDetails(playlist)); - }, - onSecondary: (details) { - MenuSheet m = MenuSheet(context); - m.defaultPlaylistMenu(playlist, details: details); - }, - ), - ListTile( - title: Text('Show all playlists'.i18n), - onTap: () { - Navigator.of(context).pushRoute( - builder: (context) => - SearchResultPlaylists(results.playlists)); - }, - ), - const FreezerDivider(), - ], - if (results.shows != null && results.shows!.isNotEmpty) ...[ - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 4.0, horizontal: 16.0), - child: Text( - 'Shows'.i18n, - textAlign: TextAlign.left, - style: const TextStyle( - fontSize: 20.0, fontWeight: FontWeight.bold), - ), - ), - for (final show in results.shows! - .getRange(0, min(results.shows!.length, 3))) - ShowTile( - show, - onTap: () async { - Navigator.of(context) - .pushRoute(builder: (context) => ShowScreen(show)); - }, - ), - ListTile( - title: Text('Show all shows'.i18n), - onTap: () { - Navigator.of(context).pushRoute( - builder: (context) => ShowListScreen(results.shows)); - }, - ), - const FreezerDivider() - ], - if (results.episodes != null && - results.episodes!.isNotEmpty) ...[ - const SizedBox(height: 8.0), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 4.0, horizontal: 16.0), - child: Text( - 'Episodes'.i18n, - textAlign: TextAlign.left, - style: const TextStyle( - fontSize: 20.0, fontWeight: FontWeight.bold), - ), - ), - for (final episode in results.episodes! - .getRange(0, min(results.episodes!.length, 3))) - ShowEpisodeTile( - episode, - trailing: IconButton( - icon: Icon( - Icons.more_vert, - semanticLabel: "Options".i18n, + } + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (results.topResult != null && + results.topResult!.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 4.0), + child: Text( + 'Top results'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: results.topResult! + .map(buildFromDeezerItem) + .toList(growable: false), + ), + ), + ), + ], + if (results.tracks != null && + results.tracks!.isNotEmpty) ...[ + Padding( + key: _tracksKey, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 4.0), + child: Text( + 'Tracks'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold), + ), + ), + for (final track in results.tracks! + .getRange(0, min(results.tracks!.length, 3))) + TrackTile.fromTrack(track, onTap: () { + cache.addToSearchHistory(track); + playerHelper.playSearchMixDeferred(track); + }, onSecondary: (details) { + MenuSheet m = MenuSheet(context); + m.defaultTrackMenu(track, details: details); + }), + ListTile( + title: Text('Show all tracks'.i18n), + onTap: () { + Navigator.of(context).pushRoute( + builder: (context) => TrackListScreen( + results.tracks, null)); + }, + ), + const FreezerDivider(), + ], + if (results.albums != null && + results.albums!.isNotEmpty) ...[ + const SizedBox(height: 8.0), + Padding( + key: _albumsKey, + padding: const EdgeInsets.symmetric( + horizontal: 16.0, vertical: 4.0), + child: Text( + 'Albums'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold), + ), + ), + for (final album in results.albums! + .getRange(0, min(results.albums!.length, 3))) + AlbumTile(album, onSecondary: (details) { + MenuSheet m = MenuSheet(context); + m.defaultAlbumMenu(album, details: details); + }, onTap: () { + cache.addToSearchHistory(album); + Navigator.of(context).pushRoute( + builder: (context) => + AlbumDetails(album)); + }), + ListTile( + title: Text('Show all albums'.i18n), + onTap: () { + Navigator.of(context).pushRoute( + builder: (context) => + AlbumListScreen(results.albums)); + }, + ), + const FreezerDivider() + ], + if (results.artists != null && + results.artists!.isNotEmpty) ...[ + const SizedBox(height: 8.0), + Padding( + key: _artistsKey, + padding: const EdgeInsets.symmetric( + vertical: 4.0, horizontal: 16.0), + child: Text( + 'Artists'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 4.0), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: SizedBox( + height: 136.0, + child: ListView.builder( + primary: false, + scrollDirection: Axis.horizontal, + itemCount: results.artists!.length, + itemBuilder: (context, index) { + final artist = results.artists![index]; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0), + child: ArtistTile( + artist, + onTap: () { + cache.addToSearchHistory(artist); + Navigator.of(context).pushRoute( + builder: (context) => + ArtistDetails(artist)); + }, + onSecondary: (details) { + MenuSheet m = MenuSheet(context); + m.defaultArtistMenu(artist, + details: details); + }, + ), + ); + }, + ), + ), + ), + const FreezerDivider() + ], + if (results.playlists != null && + results.playlists!.isNotEmpty) ...[ + const SizedBox(height: 8.0), + Padding( + key: _playlistsKey, + padding: const EdgeInsets.symmetric( + vertical: 4.0, horizontal: 16.0), + child: Text( + 'Playlists'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold), + ), + ), + for (final playlist in results.playlists! + .getRange( + 0, min(results.playlists!.length, 3))) + PlaylistTile( + playlist, + onTap: () { + cache.addToSearchHistory(playlist); + Navigator.of(context).pushRoute( + builder: (context) => + PlaylistDetails(playlist)); + }, + onSecondary: (details) { + MenuSheet m = MenuSheet(context); + m.defaultPlaylistMenu(playlist, + details: details); + }, + ), + ListTile( + title: Text('Show all playlists'.i18n), + onTap: () { + Navigator.of(context).pushRoute( + builder: (context) => + SearchResultPlaylists( + results.playlists)); + }, + ), + const FreezerDivider(), + ], + if (results.shows != null && + results.shows!.isNotEmpty) ...[ + const SizedBox(height: 8.0), + Padding( + key: _showsKey, + padding: const EdgeInsets.symmetric( + vertical: 4.0, horizontal: 16.0), + child: Text( + 'Shows'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold), + ), + ), + for (final show in results.shows! + .getRange(0, min(results.shows!.length, 3))) + ShowTile( + show, + onTap: () async { + Navigator.of(context).pushRoute( + builder: (context) => ShowScreen(show)); + }, + ), + ListTile( + title: Text('Show all shows'.i18n), + onTap: () { + Navigator.of(context).pushRoute( + builder: (context) => + ShowListScreen(results.shows)); + }, + ), + const FreezerDivider() + ], + if (results.episodes != null && + results.episodes!.isNotEmpty) ...[ + const SizedBox(height: 8.0), + Padding( + key: _episodesKey, + padding: const EdgeInsets.symmetric( + vertical: 4.0, horizontal: 16.0), + child: Text( + 'Episodes'.i18n, + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold), + ), + ), + for (final episode in results.episodes!.getRange( + 0, min(results.episodes!.length, 3))) + ShowEpisodeTile( + episode, + trailing: IconButton( + icon: Icon( + Icons.more_vert, + semanticLabel: "Options".i18n, + ), + onPressed: () { + MenuSheet m = MenuSheet(context); + m.defaultShowEpisodeMenu( + episode.show!, episode); + }, + ), + onTap: () async { + //Load entire show, then play + List episodes = + (await deezerAPI.allShowEpisodes( + episode.show!.id))!; + await playerHelper.playShowEpisode( + episode.show!, episodes, + index: episodes.indexWhere( + (ep) => episode.id == ep.id)); + }, + ), + ListTile( + title: Text('Show all episodes'.i18n), + onTap: () { + Navigator.of(context).pushRoute( + builder: (context) => EpisodeListScreen( + results.episodes)); + }) + ] + ], ), - onPressed: () { - MenuSheet m = MenuSheet(context); - m.defaultShowEpisodeMenu(episode.show!, episode); - }, - ), - onTap: () async { - //Load entire show, then play - List episodes = (await deezerAPI - .allShowEpisodes(episode.show!.id))!; - await playerHelper.playShowEpisode( - episode.show!, episodes, - index: episodes - .indexWhere((ep) => episode.id == ep.id)); - }, - ), - ListTile( - title: Text('Show all episodes'.i18n), - onTap: () { - Navigator.of(context).pushRoute( - builder: (context) => - EpisodeListScreen(results.episodes)); - }) - ] - ], - ); - }, - )); + ); + }, + )); } }