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:
parent
14ff02b65c
commit
6816bdc112
|
@ -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
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
android:label="Freezer"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="m">
|
||||
android:targetSandboxVersion="2">
|
||||
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
|
@ -88,9 +88,12 @@
|
|||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.APP_MUSIC"/>
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
</intent-filter>
|
||||
|
||||
|
||||
<!-- App links -->
|
||||
|
|
1
devtools_options.yaml
Normal file
1
devtools_options.yaml
Normal file
|
@ -0,0 +1 @@
|
|||
extensions:
|
|
@ -235,23 +235,28 @@ class DeezerAPI {
|
|||
}
|
||||
|
||||
//URL/Link parser
|
||||
Future<DeezerLinkResponse?> parseLink(String url) async {
|
||||
Uri uri = Uri.parse(url);
|
||||
//https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID
|
||||
Future<DeezerLinkResponse?> 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<SearchResults> search(String? query) async {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',
|
||||
params: {'nb': 128, 'query': query, 'start': 0});
|
||||
print(data['results']['TOP_RESULT']);
|
||||
return SearchResults.fromPrivateJson(data['results']);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<DeezerMediaItem>? topResult;
|
||||
List<Track>? tracks;
|
||||
List<Album>? albums;
|
||||
List<Artist>? artists;
|
||||
|
@ -670,7 +687,8 @@ class SearchResults {
|
|||
List<ShowEpisode>? episodes;
|
||||
|
||||
SearchResults(
|
||||
{this.tracks,
|
||||
{this.topResult,
|
||||
this.tracks,
|
||||
this.albums,
|
||||
this.artists,
|
||||
this.playlists,
|
||||
|
@ -689,6 +707,11 @@ class SearchResults {
|
|||
|
||||
factory SearchResults.fromPrivateJson(Map<dynamic, dynamic> json) =>
|
||||
SearchResults(
|
||||
topResult: (json['TOP_RESULT'] as List?)
|
||||
?.map<DeezerMediaItem?>(
|
||||
(e) => DeezerMediaItem.fromDeezerObject(e as Map))
|
||||
.whereType<DeezerMediaItem>()
|
||||
.toList(growable: false),
|
||||
tracks: json['TRACK']['data']
|
||||
.map<Track>((dynamic data) => Track.fromPrivateJson(data))
|
||||
.toList(),
|
||||
|
|
|
@ -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<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
|
||||
Future<MediaItem?> getMediaItem(String mediaId) async =>
|
||||
queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId);
|
||||
|
|
|
@ -78,7 +78,12 @@ class PlayerHelper {
|
|||
androidNotificationChannelDescription: 'Freezer',
|
||||
androidNotificationChannelName: 'Freezer',
|
||||
androidNotificationIcon: 'drawable/ic_logo',
|
||||
androidNotificationChannelId: 'f.f.freezer.audio',
|
||||
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,
|
||||
);
|
||||
|
@ -197,13 +202,18 @@ class PlayerHelper {
|
|||
}
|
||||
|
||||
//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,
|
||||
QueueSource(id: album.id, text: album.title, source: 'album'));
|
||||
}
|
||||
|
||||
//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);
|
||||
await playFromTrackList(
|
||||
tracks,
|
||||
|
@ -244,8 +254,8 @@ class PlayerHelper {
|
|||
}
|
||||
|
||||
//Play from artist top tracks
|
||||
Future playFromTopTracks(
|
||||
List<Track> tracks, String trackId, Artist artist) async {
|
||||
Future<void> playFromTopTracks(
|
||||
List<Track> 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<void> playFromPlaylist(Playlist playlist, [String? trackId]) async {
|
||||
await playFromTrackList(playlist.tracks!, trackId,
|
||||
QueueSource(id: playlist.id, text: playlist.title, source: 'playlist'));
|
||||
}
|
||||
|
|
|
@ -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<MainScreen>
|
|||
//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);
|
||||
|
|
|
@ -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<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 {
|
||||
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: <Widget>[
|
||||
Icon(
|
||||
Icons.warning,
|
||||
size: 64.sp,
|
||||
),
|
||||
Text('No results!'.i18n)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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<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));
|
||||
})
|
||||
]
|
||||
],
|
||||
);
|
||||
},
|
||||
));
|
||||
);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue