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)); - }) - ] - ], - ); - }, - )); + ); + }, + )); } }