Pato05
87c9733f51
fix audio service stop on android getTrack backend improvements get new track token when expired move shuffle button into LibraryPlaylists as FAB move favoriteButton next to track title move lyrics button on top of album art search: fix chips, and remove checkbox when selected
1063 lines
44 KiB
Dart
1063 lines
44 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:freezer/api/cache.dart';
|
|
import 'package:freezer/api/download.dart';
|
|
import 'package:freezer/api/player/audio_handler.dart';
|
|
import 'package:freezer/icons.dart';
|
|
import 'package:freezer/notifiers/list_notifier.dart';
|
|
import 'package:freezer/ui/details_screens.dart';
|
|
import 'package:freezer/ui/elements.dart';
|
|
import 'package:freezer/ui/menu.dart';
|
|
import 'package:freezer/translations.i18n.dart';
|
|
|
|
import 'tiles.dart';
|
|
import '../api/deezer.dart';
|
|
import '../api/definitions.dart';
|
|
import 'error.dart';
|
|
|
|
FutureOr openScreenByURL(BuildContext context, String url) async {
|
|
DeezerLinkResponse? res = await deezerAPI.parseLink(Uri.parse(url));
|
|
if (res == null) return;
|
|
|
|
switch (res.type) {
|
|
case DeezerMediaType.track:
|
|
Track t = await deezerAPI.track(res.id!);
|
|
MenuSheet(context).defaultTrackMenu(t, optionsTop: [
|
|
MenuSheetOption(Text('Play'.i18n),
|
|
icon: const Icon(Icons.play_arrow),
|
|
onTap: () => playerHelper.playSearchMixDeferred(t)),
|
|
]);
|
|
break;
|
|
case DeezerMediaType.album:
|
|
Album a = await deezerAPI.album(res.id);
|
|
return Navigator.of(context)
|
|
.push(MaterialPageRoute(builder: (context) => AlbumDetails(a)));
|
|
case DeezerMediaType.artist:
|
|
Artist a = await deezerAPI.artist(res.id);
|
|
return Navigator.of(context)
|
|
.push(MaterialPageRoute(builder: (context) => ArtistDetails(a)));
|
|
case DeezerMediaType.playlist:
|
|
Playlist p = await deezerAPI.playlist(res.id);
|
|
if (p.tracks == null || p.tracks!.isEmpty) {
|
|
ScaffoldMessenger.of(context)
|
|
.snack('The playlist is empty or private.'.i18n);
|
|
return;
|
|
}
|
|
return Navigator.of(context)
|
|
.push(MaterialPageRoute(builder: (context) => PlaylistDetails(p)));
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
class SearchScreen extends StatefulWidget {
|
|
final String? defaultText;
|
|
const SearchScreen({this.defaultText, Key? key}) : super(key: key);
|
|
@override
|
|
State<SearchScreen> createState() => _SearchScreenState();
|
|
}
|
|
|
|
class _SearchScreenState extends State<SearchScreen> {
|
|
bool _offline = false;
|
|
late final _controller = TextEditingController(text: widget.defaultText);
|
|
final _suggestions = ListNotifier<String>([]);
|
|
final _showingSuggestions = ValueNotifier(false);
|
|
final _loading = ValueNotifier(false);
|
|
CancelToken? _searchCancelToken;
|
|
Timer? _searchTimer;
|
|
final _focus = FocusNode();
|
|
final _textFieldFocusNode = FocusNode();
|
|
|
|
Future<void> _submit() async {
|
|
// dismiss keyboard
|
|
_textFieldFocusNode.unfocus();
|
|
|
|
if (_controller.text.isEmpty) return;
|
|
|
|
//URL
|
|
if (_controller.text.startsWith('http')) {
|
|
_loading.value = true;
|
|
try {
|
|
final f = openScreenByURL(context, _controller.text);
|
|
if (f is Future) {
|
|
f.whenComplete(() => _textFieldFocusNode.requestFocus());
|
|
}
|
|
} catch (e) {}
|
|
_loading.value = false;
|
|
return;
|
|
}
|
|
|
|
Navigator.of(context)
|
|
.pushRoute(
|
|
builder: (context) => SearchResultsScreen(
|
|
_controller.text,
|
|
offline: _offline,
|
|
))
|
|
.whenComplete(() => _textFieldFocusNode.requestFocus());
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
//Check for connectivity and enable offline mode
|
|
Connectivity().checkConnectivity().then((res) {
|
|
if (res == ConnectivityResult.none) {
|
|
setState(() {
|
|
_offline = true;
|
|
});
|
|
}
|
|
});
|
|
_suggestions.addListener(
|
|
() => _showingSuggestions.value = _suggestions.value.isNotEmpty);
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_focus.dispose();
|
|
_textFieldFocusNode.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
//Load search suggestions
|
|
Future<void> _loadSuggestions() async {
|
|
if (_controller.text.isEmpty ||
|
|
_controller.text.length < 2 ||
|
|
_controller.text.startsWith('http')) return;
|
|
_loading.value = true;
|
|
_searchCancelToken?.cancel();
|
|
|
|
//Load
|
|
final List<String>? suggestions;
|
|
try {
|
|
_searchCancelToken = CancelToken();
|
|
suggestions = await deezerAPI.searchSuggestions(_controller.text,
|
|
cancelToken: _searchCancelToken);
|
|
} on DioException catch (e) {
|
|
if (e.type != DioExceptionType.cancel) rethrow;
|
|
return;
|
|
} catch (e) {
|
|
print(e);
|
|
return;
|
|
}
|
|
|
|
_loading.value = false;
|
|
if (suggestions != null) _suggestions.value = suggestions;
|
|
}
|
|
|
|
Widget _removeHistoryItemWidget(int index) {
|
|
return IconButton(
|
|
icon: Icon(
|
|
Icons.close,
|
|
semanticLabel: "Remove".i18n,
|
|
),
|
|
onPressed: () async {
|
|
cache.searchHistory.removeAt(index);
|
|
setState(() {});
|
|
await cache.save();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SafeArea(
|
|
child: Scaffold(
|
|
appBar: PreferredSize(
|
|
preferredSize: const Size.fromHeight(76.0),
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ListTile(
|
|
focusColor: Theme.of(context).listTileTheme.tileColor ??
|
|
Theme.of(context).colorScheme.background,
|
|
mouseCursor: MaterialStateMouseCursor.textable,
|
|
leading: Navigator.canPop(context)
|
|
? IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => Navigator.pop(context))
|
|
: const Icon(Icons.search),
|
|
trailing: ValueListenableBuilder<TextEditingValue>(
|
|
valueListenable: _controller,
|
|
builder: (context, value, child) =>
|
|
value.text.isEmpty ? const SizedBox() : child!,
|
|
child: IconButton(
|
|
icon: Icon(
|
|
Icons.clear,
|
|
semanticLabel: "Clear".i18n,
|
|
),
|
|
onPressed: () {
|
|
_suggestions.clear();
|
|
_controller.clear();
|
|
})),
|
|
title: RawKeyboardListener(
|
|
focusNode: _focus,
|
|
onKey: (event) {
|
|
// For Android TV: quit search textfield
|
|
if (event is RawKeyUpEvent) {
|
|
LogicalKeyboardKey key = event.data.logicalKey;
|
|
if (key == LogicalKeyboardKey.arrowDown) {
|
|
_textFieldFocusNode.unfocus();
|
|
}
|
|
}
|
|
},
|
|
child: TextField(
|
|
onChanged: (query) {
|
|
if (query.isEmpty) {
|
|
_suggestions.clear();
|
|
} else {
|
|
_searchTimer ??=
|
|
Timer(const Duration(milliseconds: 1), () {
|
|
_searchTimer = null;
|
|
_loadSuggestions();
|
|
});
|
|
}
|
|
},
|
|
focusNode: _textFieldFocusNode,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search or paste URL'.i18n,
|
|
border: InputBorder.none,
|
|
),
|
|
controller: _controller,
|
|
onSubmitted: (String s) => _submit(),
|
|
)),
|
|
onTap: () => _textFieldFocusNode.requestFocus()),
|
|
),
|
|
SizedBox(
|
|
height: 3.0,
|
|
child: ValueListenableBuilder<bool>(
|
|
valueListenable: _loading,
|
|
builder: (context, loading, _) => loading
|
|
? const LinearProgressIndicator()
|
|
: const SizedBox()),
|
|
)
|
|
],
|
|
)),
|
|
body: FocusScope(
|
|
child: ListView(
|
|
children: <Widget>[
|
|
ListTile(
|
|
title: Text('Offline search'.i18n),
|
|
leading: const Icon(Icons.offline_pin),
|
|
trailing: Switch(
|
|
value: _offline,
|
|
onChanged: (v) {
|
|
setState(() => _offline = !_offline);
|
|
},
|
|
),
|
|
),
|
|
const FreezerDivider(),
|
|
ValueListenableBuilder<bool>(
|
|
valueListenable: _showingSuggestions,
|
|
builder: (context, showingSuggestions, child) =>
|
|
showingSuggestions ? const SizedBox() : child!,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 8.0, horizontal: 16.0),
|
|
child: Text(
|
|
'Quick access'.i18n,
|
|
style: const TextStyle(
|
|
fontSize: 20.0, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
SearchBrowseCard(
|
|
color: const Color(0xff11b192),
|
|
text: 'Flow'.i18n,
|
|
icon: const Icon(FreezerIcons.waves),
|
|
onTap: () async {
|
|
await playerHelper.playFromSmartTrackList(
|
|
SmartTrackList(id: 'flow'));
|
|
},
|
|
),
|
|
if (cache.searchHistory.isNotEmpty) ...[
|
|
const FreezerDivider(),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 8.0, horizontal: 16.0),
|
|
child: Text(
|
|
'History'.i18n,
|
|
style: const TextStyle(
|
|
fontSize: 20.0, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
...List.generate(min(cache.searchHistory.length, 10),
|
|
(int i) {
|
|
switch (cache.searchHistory[i]) {
|
|
case final Track data:
|
|
return TrackTile.fromTrack(
|
|
data,
|
|
onTap: () {
|
|
final queue = cache.searchHistory
|
|
.whereType<Track>()
|
|
.toList();
|
|
playerHelper.playFromTrackList(
|
|
queue,
|
|
data.id,
|
|
QueueSource(
|
|
text: 'Search history'.i18n,
|
|
source: 'searchhistory',
|
|
id: 'searchhistory'));
|
|
},
|
|
onSecondary: (details) => MenuSheet(context)
|
|
.defaultTrackMenu(data, details: details),
|
|
trailing: _removeHistoryItemWidget(i),
|
|
);
|
|
case final Album data:
|
|
return AlbumTile(
|
|
data,
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) => AlbumDetails(data));
|
|
},
|
|
onSecondary: (details) => MenuSheet(context)
|
|
.defaultAlbumMenu(data, details: details),
|
|
trailing: _removeHistoryItemWidget(i),
|
|
);
|
|
case final Artist data:
|
|
return ArtistHorizontalTile(
|
|
data,
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) =>
|
|
ArtistDetails(data));
|
|
},
|
|
onHold: () =>
|
|
MenuSheet(context).defaultArtistMenu(data),
|
|
trailing: _removeHistoryItemWidget(i),
|
|
);
|
|
case final Playlist data:
|
|
return PlaylistTile(
|
|
data,
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) =>
|
|
PlaylistDetails(data));
|
|
},
|
|
onSecondary: (details) => MenuSheet(context)
|
|
.defaultPlaylistMenu(data,
|
|
details: details),
|
|
trailing: _removeHistoryItemWidget(i),
|
|
);
|
|
default:
|
|
return const SizedBox();
|
|
}
|
|
}),
|
|
if (cache.searchHistory.isNotEmpty)
|
|
ListTile(
|
|
title: Text('Clear search history'.i18n),
|
|
leading: const Icon(Icons.clear_all),
|
|
onTap: () {
|
|
cache.searchHistory.clear();
|
|
cache.save();
|
|
setState(() {});
|
|
},
|
|
),
|
|
],
|
|
])),
|
|
|
|
//Suggestions
|
|
ValueListenableBuilder<List<String>>(
|
|
valueListenable: _suggestions,
|
|
builder: (context, suggestions, _) => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: List.generate(
|
|
suggestions.length,
|
|
(index) => ListTile(
|
|
title: Text(suggestions[index]),
|
|
leading: const Icon(Icons.search),
|
|
onTap: () {
|
|
setState(() =>
|
|
_controller.text = suggestions[index]);
|
|
_submit();
|
|
},
|
|
)))),
|
|
],
|
|
)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class SearchBrowseCard extends StatelessWidget {
|
|
final Color color;
|
|
final Widget? icon;
|
|
final Function onTap;
|
|
final String text;
|
|
const SearchBrowseCard(
|
|
{super.key,
|
|
required this.color,
|
|
required this.onTap,
|
|
required this.text,
|
|
this.icon});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Card(
|
|
color: color,
|
|
child: InkWell(
|
|
onTap: onTap as void Function()?,
|
|
child: SizedBox(
|
|
width: MediaQuery.of(context).size.width / 2 - 32,
|
|
height: 75,
|
|
child: Center(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (icon != null) icon!,
|
|
if (icon != null) Container(width: 8.0),
|
|
Text(
|
|
text,
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 18.0,
|
|
fontWeight: FontWeight.bold,
|
|
color: (color.computeLuminance() > 0.5)
|
|
? Colors.black
|
|
: Colors.white),
|
|
),
|
|
],
|
|
)),
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
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> {
|
|
SearchResults? _results;
|
|
Object? _error;
|
|
DeezerMediaType? _page;
|
|
|
|
Future _search() async {
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
|
|
Widget buildListFor(DeezerMediaType page) {
|
|
switch (page) {
|
|
case DeezerMediaType.track:
|
|
return TrackListScreen(_results!.tracks, null);
|
|
case DeezerMediaType.album:
|
|
return AlbumListScreen(_results!.albums);
|
|
case DeezerMediaType.playlist:
|
|
return PlaylistListScreen(_results!.playlists);
|
|
case DeezerMediaType.episode:
|
|
return EpisodeListScreen(_results!.episodes);
|
|
case DeezerMediaType.show:
|
|
return ShowListScreen(_results!.shows);
|
|
|
|
case DeezerMediaType.artist:
|
|
return const Placeholder();
|
|
}
|
|
}
|
|
|
|
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(widget.query!),
|
|
bottom: PreferredSize(
|
|
preferredSize: Size.fromHeight(_results == null ? 0.0 : 50.0),
|
|
child: _results == null
|
|
? const SizedBox.shrink()
|
|
: ChipTheme(
|
|
data: const ChipThemeData(
|
|
elevation: 1.0, showCheckmark: false),
|
|
child: 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) ...[
|
|
FilterChip(
|
|
label: Text('Tracks'.i18n),
|
|
selected: _page == DeezerMediaType.track,
|
|
onSelected: (selected) => setState(() =>
|
|
_page = selected
|
|
? DeezerMediaType.track
|
|
: null)),
|
|
const SizedBox(width: 8.0),
|
|
],
|
|
if (_results!.albums != null &&
|
|
_results!.albums!.isNotEmpty) ...[
|
|
FilterChip(
|
|
label: Text('Albums'.i18n),
|
|
selected: _page == DeezerMediaType.album,
|
|
onSelected: (selected) => setState(() =>
|
|
_page = selected
|
|
? DeezerMediaType.album
|
|
: null)),
|
|
const SizedBox(width: 8.0),
|
|
],
|
|
// if (_results!.artists != null &&
|
|
// _results!.artists!.isNotEmpty) ...[
|
|
// FilterChip(
|
|
// elevation: 1.0,
|
|
// label: Text('Artists'.i18n),
|
|
// selected: _page == DeezerMediaType.artist,
|
|
// onSelected: (selected) => setState(() => _page =
|
|
// selected ? DeezerMediaType.artist : null)),
|
|
// const SizedBox(width: 8.0),
|
|
// ],
|
|
if (_results!.playlists != null &&
|
|
_results!.playlists!.isNotEmpty) ...[
|
|
FilterChip(
|
|
label: Text('Playlists'.i18n),
|
|
selected: _page == DeezerMediaType.playlist,
|
|
onSelected: (selected) => setState(() =>
|
|
_page = selected
|
|
? DeezerMediaType.playlist
|
|
: null)),
|
|
const SizedBox(width: 8.0),
|
|
],
|
|
if (_results!.shows != null &&
|
|
_results!.shows!.isNotEmpty) ...[
|
|
FilterChip(
|
|
label: Text('Shows'.i18n),
|
|
selected: _page == DeezerMediaType.show,
|
|
onSelected: (selected) => setState(() =>
|
|
_page = selected
|
|
? DeezerMediaType.show
|
|
: null)),
|
|
const SizedBox(width: 8.0),
|
|
],
|
|
if (_results!.episodes != null &&
|
|
_results!.episodes!.isNotEmpty) ...[
|
|
FilterChip(
|
|
label: Text('Episodes'.i18n),
|
|
selected: _page == DeezerMediaType.episode,
|
|
onSelected: (selected) => setState(() =>
|
|
_page = selected
|
|
? DeezerMediaType.episode
|
|
: null)),
|
|
const SizedBox(width: 8.0),
|
|
],
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
body: _error != null
|
|
? ErrorScreen(message: _error.toString())
|
|
: _results == null
|
|
? const Center(child: CircularProgressIndicator())
|
|
: PopScope(
|
|
canPop: _page == null,
|
|
onPopInvoked: (didPop) {
|
|
if (_page != null) {
|
|
setState(() => _page = null);
|
|
}
|
|
},
|
|
child: 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)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_page != null) {
|
|
return buildListFor(_page!);
|
|
}
|
|
|
|
return ListView(
|
|
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(
|
|
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: () => setState(
|
|
() => _page = DeezerMediaType.track),
|
|
),
|
|
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: () => setState(
|
|
() => _page = DeezerMediaType.album),
|
|
),
|
|
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),
|
|
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(
|
|
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: () => setState(
|
|
() => _page = DeezerMediaType.playlist),
|
|
),
|
|
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: () => setState(
|
|
() => _page = DeezerMediaType.show),
|
|
),
|
|
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,
|
|
),
|
|
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: () => setState(
|
|
() => _page = DeezerMediaType.episode),
|
|
)
|
|
]
|
|
],
|
|
);
|
|
},
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
//List all tracks
|
|
class TrackListScreen extends StatelessWidget {
|
|
final QueueSource? queueSource;
|
|
final List<Track>? tracks;
|
|
|
|
const TrackListScreen(this.tracks, this.queueSource, {super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView.builder(
|
|
itemCount: tracks!.length,
|
|
itemBuilder: (BuildContext context, int i) {
|
|
Track t = tracks![i];
|
|
return TrackTile.fromTrack(
|
|
t,
|
|
onTap: () {
|
|
if (queueSource == null) {
|
|
playerHelper.playSearchMixDeferred(t);
|
|
return;
|
|
}
|
|
|
|
playerHelper.playFromTrackList(tracks!, t.id, queueSource!);
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultTrackMenu(t, details: details);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
//List all albums
|
|
class AlbumListScreen extends StatelessWidget {
|
|
final List<Album?>? albums;
|
|
const AlbumListScreen(this.albums, {super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView.builder(
|
|
itemCount: albums!.length,
|
|
itemBuilder: (context, i) {
|
|
Album? a = albums![i];
|
|
return AlbumTile(
|
|
a,
|
|
onTap: () {
|
|
Navigator.of(context)
|
|
.pushRoute(builder: (context) => AlbumDetails(a));
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultAlbumMenu(a!, details: details);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class PlaylistListScreen extends StatelessWidget {
|
|
final List<Playlist?>? playlists;
|
|
const PlaylistListScreen(this.playlists, {super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView.builder(
|
|
itemCount: playlists!.length,
|
|
itemBuilder: (context, i) {
|
|
Playlist? p = playlists![i];
|
|
return PlaylistTile(
|
|
p,
|
|
onTap: () {
|
|
Navigator.of(context)
|
|
.pushRoute(builder: (context) => PlaylistDetails(p));
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultPlaylistMenu(p!, details: details);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class ShowListScreen extends StatelessWidget {
|
|
final List<Show>? shows;
|
|
const ShowListScreen(this.shows, {super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView.builder(
|
|
itemCount: shows!.length,
|
|
itemBuilder: (context, i) {
|
|
Show s = shows![i];
|
|
return ShowTile(
|
|
s,
|
|
onTap: () {
|
|
Navigator.of(context)
|
|
.push(MaterialPageRoute(builder: (context) => ShowScreen(s)));
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class EpisodeListScreen extends StatelessWidget {
|
|
final List<ShowEpisode>? episodes;
|
|
const EpisodeListScreen(this.episodes, {super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ListView.builder(
|
|
itemCount: episodes!.length,
|
|
itemBuilder: (context, i) {
|
|
ShowEpisode e = episodes![i];
|
|
return ShowEpisodeTile(
|
|
e,
|
|
trailing: IconButton(
|
|
icon: Icon(
|
|
Icons.more_vert,
|
|
semanticLabel: "Options".i18n,
|
|
),
|
|
onPressed: () {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultShowEpisodeMenu(e.show!, e);
|
|
},
|
|
),
|
|
onTap: () async {
|
|
//Load entire show, then play
|
|
List<ShowEpisode> episodes =
|
|
(await deezerAPI.allShowEpisodes(e.show!.id))!;
|
|
await playerHelper.playShowEpisode(e.show!, episodes,
|
|
index: episodes.indexWhere((ep) => e.id == ep.id));
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|