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(url); if (res == null) return; switch (res.type) { case DeezerLinkType.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 DeezerLinkType.ALBUM: Album a = await deezerAPI.album(res.id); return Navigator.of(context) .push(MaterialPageRoute(builder: (context) => AlbumDetails(a))); case DeezerLinkType.ARTIST: Artist a = await deezerAPI.artist(res.id); return Navigator.of(context) .push(MaterialPageRoute(builder: (context) => ArtistDetails(a))); case DeezerLinkType.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 createState() => _SearchScreenState(); } class _SearchScreenState extends State { bool _offline = false; late final _controller = TextEditingController(text: widget.defaultText); final _suggestions = ListNotifier([]); final _showingSuggestions = ValueNotifier(false); final _loading = ValueNotifier(false); CancelToken? _searchCancelToken; Timer? _searchTimer; final _focus = FocusNode(); final _textFieldFocusNode = FocusNode(); Future _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 _loadSuggestions() async { if (_controller.text.isEmpty || _controller.text.length < 2 || _controller.text.startsWith('http')) return; _loading.value = true; _searchCancelToken?.cancel(); //Load final List? 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( 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( valueListenable: _loading, builder: (context, loading, _) => loading ? const LinearProgressIndicator() : const SizedBox()), ) ], )), body: FocusScope( child: ListView( children: [ ListTile( title: Text('Offline search'.i18n), leading: const Icon(Icons.offline_pin), trailing: Switch( value: _offline, onChanged: (v) { setState(() => _offline = !_offline); }, ), ), const FreezerDivider(), ValueListenableBuilder( 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() .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>( 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 StatelessWidget { final String? query; final bool? offline; const SearchResultsScreen(this.query, {super.key, this.offline}); Future _search() async { if (offline ?? false) { return await downloadManager.search(query); } return await deezerAPI.search(query); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(query!)), body: FutureBuilder( future: _search(), builder: (BuildContext context, AsyncSnapshot snapshot) { if (!snapshot.hasData) { return const Center( child: CircularProgressIndicator(), ); } if (snapshot.hasError) return const ErrorScreen(); SearchResults results = snapshot.data; if (results.empty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.warning, size: 64.sp, ), Text('No results!'.i18n) ], ), ); } return ListView( children: [ if (results.tracks != null && results.tracks!.isNotEmpty) ...[ Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 4.0), child: Text( 'Tracks'.i18n, textAlign: TextAlign.left, style: const TextStyle( fontSize: 20.0, fontWeight: FontWeight.bold), ), ), 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); }, ), ); }, ), ), 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, ), onPressed: () { MenuSheet m = MenuSheet(context); m.defaultShowEpisodeMenu(episode.show!, episode); }, ), onTap: () async { //Load entire show, then play List episodes = (await deezerAPI .allShowEpisodes(episode.show!.id))!; await playerHelper.playShowEpisode( episode.show!, episodes, index: episodes .indexWhere((ep) => episode.id == ep.id)); }, ), ListTile( title: Text('Show all episodes'.i18n), onTap: () { Navigator.of(context).pushRoute( builder: (context) => EpisodeListScreen(results.episodes)); }) ] ], ); }, )); } } //List all tracks class TrackListScreen extends StatelessWidget { final QueueSource? queueSource; final List? tracks; const TrackListScreen(this.tracks, this.queueSource, {super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Tracks'.i18n)), body: 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? albums; const AlbumListScreen(this.albums, {super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Albums'.i18n)), body: 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 SearchResultPlaylists extends StatelessWidget { final List? playlists; const SearchResultPlaylists(this.playlists, {super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Playlists'.i18n)), body: 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? shows; const ShowListScreen(this.shows, {super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Shows'.i18n)), body: 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? episodes; const EpisodeListScreen(this.episodes, {super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Episodes'.i18n)), body: 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 episodes = (await deezerAPI.allShowEpisodes(e.show!.id))!; await playerHelper.playShowEpisode(e.show!, episodes, index: episodes.indexWhere((ep) => e.id == ep.id)); }, ); }, )); } }