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
This commit is contained in:
Pato05 2024-02-18 16:05:35 +01:00
parent 14ff02b65c
commit 6816bdc112
No known key found for this signature in database
GPG Key ID: ED4C6F9C3D574FB6
9 changed files with 642 additions and 277 deletions

View File

@ -40,8 +40,8 @@ android {
} }
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // Workaround to make Google assistant work with Freezer
applicationId "f.f.freezer" applicationId "deezer.android.app" // "f.f.freezer"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 33 targetSdkVersion 33
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
@ -64,7 +64,7 @@ android {
minifyEnabled true minifyEnabled true
} }
debug { debug {
applicationIdSuffix ".debug" // applicationIdSuffix ".debug"
shrinkResources false shrinkResources false
minifyEnabled false minifyEnabled false
} }

View File

@ -40,7 +40,7 @@
android:label="Freezer" android:label="Freezer"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
tools:targetApi="m"> android:targetSandboxVersion="2">
<service <service
android:name=".DownloadService" android:name=".DownloadService"
@ -88,9 +88,12 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.APP_MUSIC"/>
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
</intent-filter>
<!-- App links --> <!-- App links -->

1
devtools_options.yaml Normal file
View File

@ -0,0 +1 @@
extensions:

View File

@ -235,23 +235,28 @@ class DeezerAPI {
} }
//URL/Link parser //URL/Link parser
Future<DeezerLinkResponse?> parseLink(String url) async { Future<DeezerLinkResponse?> parseLink(Uri uri) async {
Uri uri = Uri.parse(url); //https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID[/radio?autoplay=true]
//https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID
if (uri.host == 'www.deezer.com' || uri.host == 'deezer.com') { if (uri.host == 'www.deezer.com' || uri.host == 'deezer.com') {
if (uri.pathSegments.length < 2) return null; if (uri.pathSegments.length < 2) return null;
DeezerLinkType? type = DeezerLinkResponse.typeFromString( if (uri.pathSegments[uri.pathSegments.length - 1] == 'radio') {
uri.pathSegments[uri.pathSegments.length - 2]); return DeezerLinkResponse(
type: DeezerLinkResponse.typeFromString(
uri.pathSegments[uri.pathSegments.length - 3]),
id: uri.pathSegments[uri.pathSegments.length - 2]);
}
return DeezerLinkResponse( 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 //Share URL
if (uri.host == 'deezer.page.link' || uri.host == 'www.deezer.page.link') { 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; request.followRedirects = false;
http.StreamedResponse response = await request.send(); http.StreamedResponse response = await request.send();
String newUrl = response.headers['location']!; String newUrl = response.headers['location']!;
return parseLink(newUrl); return parseLink(Uri.parse(newUrl));
} }
//Spotify //Spotify
if (uri.host == 'open.spotify.com') { if (uri.host == 'open.spotify.com') {
@ -333,6 +338,7 @@ class DeezerAPI {
Future<SearchResults> search(String? query) async { Future<SearchResults> search(String? query) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch', Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',
params: {'nb': 128, 'query': query, 'start': 0}); params: {'nb': 128, 'query': query, 'start': 0});
print(data['results']['TOP_RESULT']);
return SearchResults.fromPrivateJson(data['results']); return SearchResults.fromPrivateJson(data['results']);
} }

View File

@ -22,6 +22,22 @@ part 'definitions.g.dart';
abstract class DeezerMediaItem { abstract class DeezerMediaItem {
String? get id; 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) @HiveType(typeId: 5)
@ -662,6 +678,7 @@ class DeezerImageDetails extends ImageDetails {
} }
class SearchResults { class SearchResults {
List<DeezerMediaItem>? topResult;
List<Track>? tracks; List<Track>? tracks;
List<Album>? albums; List<Album>? albums;
List<Artist>? artists; List<Artist>? artists;
@ -670,7 +687,8 @@ class SearchResults {
List<ShowEpisode>? episodes; List<ShowEpisode>? episodes;
SearchResults( SearchResults(
{this.tracks, {this.topResult,
this.tracks,
this.albums, this.albums,
this.artists, this.artists,
this.playlists, this.playlists,
@ -689,6 +707,11 @@ class SearchResults {
factory SearchResults.fromPrivateJson(Map<dynamic, dynamic> json) => factory SearchResults.fromPrivateJson(Map<dynamic, dynamic> json) =>
SearchResults( SearchResults(
topResult: (json['TOP_RESULT'] as List?)
?.map<DeezerMediaItem?>(
(e) => DeezerMediaItem.fromDeezerObject(e as Map))
.whereType<DeezerMediaItem>()
.toList(growable: false),
tracks: json['TRACK']['data'] tracks: json['TRACK']['data']
.map<Track>((dynamic data) => Track.fromPrivateJson(data)) .map<Track>((dynamic data) => Track.fromPrivateJson(data))
.toList(), .toList(),

View File

@ -492,15 +492,15 @@ class AudioPlayerTask extends BaseAudioHandler {
return const MediaControl( return const MediaControl(
androidIcon: 'drawable/ic_heart', androidIcon: 'drawable/ic_heart',
label: 'unfavorite', label: 'unfavorite',
action: MediaAction.custom); action: MediaAction.custom,
customAction: CustomMediaAction(name: 'favorite'));
} }
return const MediaControl( return const MediaControl(
androidIcon: 'drawable/ic_heart_outline', androidIcon: 'drawable/ic_heart_outline',
label: 'favourite', label: 'favourite',
action: MediaAction action: MediaAction.custom,
.custom, // this acts as favourite/unfavourite as it's not already used. customAction: CustomMediaAction(name: 'favorite'));
);
} }
//Update state on all clients //Update state on all clients
@ -535,7 +535,6 @@ class AudioPlayerTask extends BaseAudioHandler {
systemActions: !currentMediaItemIsShow systemActions: !currentMediaItemIsShow
? { ? {
MediaAction.seek, MediaAction.seek,
MediaAction.seekBackward,
if (queue.hasValue && _queueIndex != queue.value.length - 1) if (queue.hasValue && _queueIndex != queue.value.length - 1)
MediaAction.skipToNext, MediaAction.skipToNext,
if (_queueIndex != 0) MediaAction.skipToPrevious, if (_queueIndex != 0) MediaAction.skipToPrevious,
@ -552,6 +551,12 @@ class AudioPlayerTask extends BaseAudioHandler {
bufferedPosition: _player.bufferedPosition, bufferedPosition: _player.bufferedPosition,
speed: _player.speed, speed: _player.speed,
queueIndex: _queueIndex, 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)); await skipToQueueItem(queue.value.indexWhere((item) => item.id == mediaId));
} }
@override
Future<List<MediaItem>> search(String query, [Map<String, dynamic>? extras]) {
_logger.fine('search($query, $extras)');
return Future.value([]);
}
@override
Future<void> playFromSearch(String query,
[Map<String, dynamic>? extras]) async {
_logger.fine('playFromSearch($query, $extras)');
// play <query> 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<void> playFromUri(Uri uri, [Map<String, dynamic>? 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 @override
Future<MediaItem?> getMediaItem(String mediaId) async => Future<MediaItem?> getMediaItem(String mediaId) async =>
queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId); queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId);

View File

@ -78,7 +78,12 @@ class PlayerHelper {
androidNotificationChannelDescription: 'Freezer', androidNotificationChannelDescription: 'Freezer',
androidNotificationChannelName: 'Freezer', androidNotificationChannelName: 'Freezer',
androidNotificationIcon: 'drawable/ic_logo', androidNotificationIcon: 'drawable/ic_logo',
androidNotificationChannelId: 'f.f.freezer.audio',
preloadArtwork: true, preloadArtwork: true,
androidBrowsableRootExtras: <String, dynamic>{
"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, cacheManager: cacheManager,
); );
@ -197,13 +202,18 @@ class PlayerHelper {
} }
//Play track from album //Play track from album
Future playFromAlbum(Album album, [String? trackId]) async { Future<void> 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, await playFromTrackList(album.tracks!, trackId,
QueueSource(id: album.id, text: album.title, source: 'album')); QueueSource(id: album.id, text: album.title, source: 'album'));
} }
//Play mix by track //Play mix by track
Future playMix(String trackId, String trackTitle) async { Future<void> playMix(String trackId, String trackTitle) async {
List<Track> tracks = await deezerAPI.playMix(trackId); List<Track> tracks = await deezerAPI.playMix(trackId);
await playFromTrackList( await playFromTrackList(
tracks, tracks,
@ -244,8 +254,8 @@ class PlayerHelper {
} }
//Play from artist top tracks //Play from artist top tracks
Future playFromTopTracks( Future<void> playFromTopTracks(
List<Track> tracks, String trackId, Artist artist) async { List<Track> tracks, String? trackId, Artist artist) async {
await playFromTrackList( await playFromTrackList(
tracks, tracks,
trackId, trackId,
@ -253,7 +263,7 @@ class PlayerHelper {
id: artist.id, text: 'Top ${artist.name}', source: 'topTracks')); id: artist.id, text: 'Top ${artist.name}', source: 'topTracks'));
} }
Future playFromPlaylist(Playlist playlist, [String? trackId]) async { Future<void> playFromPlaylist(Playlist playlist, [String? trackId]) async {
await playFromTrackList(playlist.tracks!, trackId, await playFromTrackList(playlist.tracks!, trackId,
QueueSource(id: playlist.id, text: playlist.title, source: 'playlist')); QueueSource(id: playlist.id, text: playlist.title, source: 'playlist'));
} }

View File

@ -100,8 +100,10 @@ void main() async {
try { try {
cache = await Cache.load(); cache = await Cache.load();
settings = await Settings.load(); settings = await Settings.load();
settings.save(); await settings.save();
} catch (e) { } catch (e) {
// ignore: avoid_print
print(e);
// ignore: avoid_print // ignore: avoid_print
print( print(
'Cannot load cache or settings box. Maybe another instance of the app is running?'); '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}'); '${record.level.name}: ${record.time}: [${record.loggerName}] ${record.message}');
}); });
if (kDebugMode) { if (kDebugMode || true) {
Logger.root.level = Level.ALL; Logger.root.level = Level.ALL;
} }
@ -470,12 +472,14 @@ class MainScreenState extends State<MainScreen>
//Listen to URLs //Listen to URLs
_urlLinkStream = linkStream.listen((String? link) { _urlLinkStream = linkStream.listen((String? link) {
if (link == null) return; if (link == null) return;
_logger.fine('received link: $link');
if (!context.mounted) return; if (!context.mounted) return;
openScreenByURL(context, link); openScreenByURL(context, link);
}, onError: (err) {}); }, onError: (err) {});
//Get initial link on cold start //Get initial link on cold start
try { try {
String? link = await getInitialLink(); String? link = await getInitialLink();
_logger.fine('initial link: $link');
if (!context.mounted) return; if (!context.mounted) return;
if (link != null && link.length > 4) openScreenByURL(context, link); if (link != null && link.length > 4) openScreenByURL(context, link);

View File

@ -22,7 +22,7 @@ import '../api/definitions.dart';
import 'error.dart'; import 'error.dart';
FutureOr openScreenByURL(BuildContext context, String url) async { 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; if (res == null) return;
switch (res.type) { switch (res.type) {
@ -435,271 +435,476 @@ class SearchBrowseCard extends StatelessWidget {
} }
} }
class SearchResultsScreen extends StatelessWidget { class SearchResultsScreen extends StatefulWidget {
final String? query; final String? query;
final bool? offline; final bool? offline;
const SearchResultsScreen(this.query, {super.key, this.offline}); const SearchResultsScreen(this.query, {super.key, this.offline});
@override
State<SearchResultsScreen> createState() => _SearchResultsScreenState();
}
class _SearchResultsScreenState extends State<SearchResultsScreen> {
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 { Future _search() async {
if (offline ?? false) { try {
return await downloadManager.search(query); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(query!)), appBar: AppBar(
body: FutureBuilder( title: Text(widget.query!),
future: _search(), bottom: PreferredSize(
builder: (BuildContext context, AsyncSnapshot snapshot) { preferredSize: Size.fromHeight(_results == null ? 0.0 : 50.0),
if (!snapshot.hasData) { child: _results == null
return const Center( ? const SizedBox.shrink()
child: CircularProgressIndicator(), : Expanded(
); child: Padding(
} padding: const EdgeInsets.symmetric(
if (snapshot.hasError) return const ErrorScreen(); horizontal: 16.0, vertical: 8.0),
child:
SearchResults results = snapshot.data; ListView(scrollDirection: Axis.horizontal, children: [
if (_results!.tracks != null &&
if (results.empty) { _results!.tracks!.isNotEmpty) ...[
return Center( ActionChip(
child: Column( elevation: 1.0,
mainAxisSize: MainAxisSize.min, label: Text('Tracks'.i18n),
children: <Widget>[ onPressed: () => Scrollable.ensureVisible(
Icon( _tracksKey.currentContext!,
Icons.warning, duration: const Duration(milliseconds: 500))),
size: 64.sp, const SizedBox(width: 8.0),
), ],
Text('No results!'.i18n) if (_results!.albums != null &&
], _results!.albums!.isNotEmpty) ...[
), ActionChip(
); elevation: 1.0,
} label: Text('Albums'.i18n),
onPressed: () => Scrollable.ensureVisible(
return ListView( _albumsKey.currentContext!,
children: <Widget>[ duration: const Duration(milliseconds: 500))),
if (results.tracks != null && results.tracks!.isNotEmpty) ...[ const SizedBox(width: 8.0),
Padding( ],
padding: const EdgeInsets.symmetric( if (_results!.artists != null &&
horizontal: 16.0, vertical: 4.0), _results!.artists!.isNotEmpty) ...[
child: Text( ActionChip(
'Tracks'.i18n, elevation: 1.0,
textAlign: TextAlign.left, label: Text('Artists'.i18n),
style: const TextStyle( onPressed: () => Scrollable.ensureVisible(
fontSize: 20.0, fontWeight: FontWeight.bold), _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: () { body: _error != null
cache.addToSearchHistory(track); ? ErrorScreen(message: _error.toString())
playerHelper.playSearchMixDeferred(track); : _results == null
}, onSecondary: (details) { ? const Center(child: CircularProgressIndicator())
MenuSheet m = MenuSheet(context); : Builder(
m.defaultTrackMenu(track, details: details); builder: (context) {
}), final results = _results!;
ListTile( if (results.empty) {
title: Text('Show all tracks'.i18n), return Center(
onTap: () { child: Column(
Navigator.of(context).pushRoute( mainAxisSize: MainAxisSize.min,
builder: (context) => children: <Widget>[
TrackListScreen(results.tracks, null)); Icon(
}, Icons.warning,
), size: 64.sp,
const FreezerDivider(), ),
], Text('No results!'.i18n)
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);
},
), ),
); );
}, }
),
), return SingleChildScrollView(
const FreezerDivider() child: Column(
], crossAxisAlignment: CrossAxisAlignment.start,
if (results.playlists != null && children: <Widget>[
results.playlists!.isNotEmpty) ...[ if (results.topResult != null &&
const SizedBox(height: 8.0), results.topResult!.isNotEmpty) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 4.0, horizontal: 16.0), horizontal: 16.0, vertical: 4.0),
child: Text( child: Text(
'Playlists'.i18n, 'Top results'.i18n,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
fontSize: 20.0, fontWeight: FontWeight.bold), fontSize: 20.0,
), fontWeight: FontWeight.bold),
), ),
for (final playlist in results.playlists! ),
.getRange(0, min(results.playlists!.length, 3))) Padding(
PlaylistTile( padding: const EdgeInsets.symmetric(
playlist, horizontal: 16.0),
onTap: () { child: SingleChildScrollView(
cache.addToSearchHistory(playlist); scrollDirection: Axis.horizontal,
Navigator.of(context).pushRoute( child: Row(
builder: (context) => PlaylistDetails(playlist)); children: results.topResult!
}, .map(buildFromDeezerItem)
onSecondary: (details) { .toList(growable: false),
MenuSheet m = MenuSheet(context); ),
m.defaultPlaylistMenu(playlist, details: details); ),
}, ),
), ],
ListTile( if (results.tracks != null &&
title: Text('Show all playlists'.i18n), results.tracks!.isNotEmpty) ...[
onTap: () { Padding(
Navigator.of(context).pushRoute( key: _tracksKey,
builder: (context) => padding: const EdgeInsets.symmetric(
SearchResultPlaylists(results.playlists)); horizontal: 16.0, vertical: 4.0),
}, child: Text(
), 'Tracks'.i18n,
const FreezerDivider(), textAlign: TextAlign.left,
], style: const TextStyle(
if (results.shows != null && results.shows!.isNotEmpty) ...[ fontSize: 20.0,
const SizedBox(height: 8.0), fontWeight: FontWeight.bold),
Padding( ),
padding: const EdgeInsets.symmetric( ),
vertical: 4.0, horizontal: 16.0), for (final track in results.tracks!
child: Text( .getRange(0, min(results.tracks!.length, 3)))
'Shows'.i18n, TrackTile.fromTrack(track, onTap: () {
textAlign: TextAlign.left, cache.addToSearchHistory(track);
style: const TextStyle( playerHelper.playSearchMixDeferred(track);
fontSize: 20.0, fontWeight: FontWeight.bold), }, onSecondary: (details) {
), MenuSheet m = MenuSheet(context);
), m.defaultTrackMenu(track, details: details);
for (final show in results.shows! }),
.getRange(0, min(results.shows!.length, 3))) ListTile(
ShowTile( title: Text('Show all tracks'.i18n),
show, onTap: () {
onTap: () async { Navigator.of(context).pushRoute(
Navigator.of(context) builder: (context) => TrackListScreen(
.pushRoute(builder: (context) => ShowScreen(show)); results.tracks, null));
}, },
), ),
ListTile( const FreezerDivider(),
title: Text('Show all shows'.i18n), ],
onTap: () { if (results.albums != null &&
Navigator.of(context).pushRoute( results.albums!.isNotEmpty) ...[
builder: (context) => ShowListScreen(results.shows)); const SizedBox(height: 8.0),
}, Padding(
), key: _albumsKey,
const FreezerDivider() padding: const EdgeInsets.symmetric(
], horizontal: 16.0, vertical: 4.0),
if (results.episodes != null && child: Text(
results.episodes!.isNotEmpty) ...[ 'Albums'.i18n,
const SizedBox(height: 8.0), textAlign: TextAlign.left,
Padding( style: const TextStyle(
padding: const EdgeInsets.symmetric( fontSize: 20.0,
vertical: 4.0, horizontal: 16.0), fontWeight: FontWeight.bold),
child: Text( ),
'Episodes'.i18n, ),
textAlign: TextAlign.left, for (final album in results.albums!
style: const TextStyle( .getRange(0, min(results.albums!.length, 3)))
fontSize: 20.0, fontWeight: FontWeight.bold), AlbumTile(album, onSecondary: (details) {
), MenuSheet m = MenuSheet(context);
), m.defaultAlbumMenu(album, details: details);
for (final episode in results.episodes! }, onTap: () {
.getRange(0, min(results.episodes!.length, 3))) cache.addToSearchHistory(album);
ShowEpisodeTile( Navigator.of(context).pushRoute(
episode, builder: (context) =>
trailing: IconButton( AlbumDetails(album));
icon: Icon( }),
Icons.more_vert, ListTile(
semanticLabel: "Options".i18n, 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<ShowEpisode> 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<ShowEpisode> 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));
})
]
],
);
},
));
} }
} }