freezer/lib/ui/search.dart
Pato05 87c9733f51
add build script for linux
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
2024-02-19 00:49:32 +01:00

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