From dfea195ec178de733717cfe3226cede7521ee2d3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 28 Jun 2023 16:29:23 +0600 Subject: [PATCH] feat: search alternative track source --- lib/components/player/player_actions.dart | 3 - .../player/sibling_tracks_sheet.dart | 240 ++++++++++++++---- lib/hooks/use_debounce.dart | 17 ++ lib/models/spotube_track.dart | 22 +- .../proxy_playlist_provider.dart | 3 +- 5 files changed, 217 insertions(+), 68 deletions(-) create mode 100644 lib/hooks/use_debounce.dart diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 577d5a833..0d17c263c 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -116,9 +116,6 @@ class PlayerActions extends HookConsumerWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * .5, - ), builder: (context) { return SiblingTracksSheet(floating: floatingQueue); }, diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 09d454d7a..36cee3056 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -4,12 +4,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; +import 'package:spotify/spotify.dart' hide Offset; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/hooks/use_debounce.dart'; import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/provider/piped_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/service_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; class SiblingTracksSheet extends HookConsumerWidget { final bool floating; @@ -23,6 +31,51 @@ class SiblingTracksSheet extends HookConsumerWidget { final theme = Theme.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final preferencesSearchMode = + ref.watch(userPreferencesProvider.select((value) => value.searchMode)); + final pipedClient = ref.watch(pipedClientProvider); + + final isSearching = useState(false); + final searchMode = useState(preferencesSearchMode); + + final title = ServiceUtils.getTitle( + playlist.activeTrack?.name ?? "", + artists: + playlist.activeTrack?.artists?.map((e) => e.name!).toList() ?? [], + onlyCleanArtist: true, + ).trim(); + + final defaultSearchTerm = + "$title - ${TypeConversionUtils.artists_X_String(playlist.activeTrack?.artists ?? [])}"; + final searchController = useTextEditingController( + text: defaultSearchTerm, + ); + + final searchTerm = useDebounce( + useValueListenable(searchController).text, + ); + + final searchRequest = useMemoized(() async { + if (searchTerm.trim().isEmpty) { + return []; + } + + return pipedClient + .search( + searchTerm.trim(), + switch (searchMode.value) { + SearchMode.youtube => PipedFilter.video, + SearchMode.youtubeMusic => PipedFilter.musicSongs, + }, + ) + .then( + (result) => + result.items.whereType().toList(), + ); + }, [ + searchTerm, + searchMode.value, + ]); final siblings = playlist.isFetching == false ? (playlist.activeTrack as SpotubeTrack).siblings @@ -43,66 +96,149 @@ class SiblingTracksSheet extends HookConsumerWidget { return null; }, [playlist.activeTrack]); + final itemBuilder = useCallback((PipedSearchItemStream video) { + return ListTile( + title: Text(video.title), + leading: Padding( + padding: const EdgeInsets.all(8.0), + child: UniversalImage( + path: video.thumbnail, + height: 60, + width: 60, + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + trailing: Text( + PrimitiveUtils.toReadableDuration(video.duration), + ), + subtitle: Text(video.uploaderName), + enabled: playlist.isFetching != true, + selected: playlist.isFetching != true && + video.id == (playlist.activeTrack as SpotubeTrack).ytTrack.id, + selectedTileColor: theme.popupMenuTheme.color, + onTap: () { + if (playlist.isFetching == false && + video.id != (playlist.activeTrack as SpotubeTrack).ytTrack.id) { + playlistNotifier.swapSibling(video); + Navigator.of(context).pop(); + } + }, + ); + }, [ + playlist.isFetching, + playlist.activeTrack, + siblings, + ]); + + var mediaQuery = MediaQuery.of(context); return BackdropFilter( filter: ImageFilter.blur( sigmaX: 12.0, sigmaY: 12.0, ), - child: Container( - margin: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - borderRadius: borderRadius, - color: theme.scaffoldBackgroundColor.withOpacity(.3), - ), - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: AppBar( - centerTitle: true, - title: Text( - context.l10n.alternative_track_sources, - style: theme.textTheme.headlineSmall, - ), - automaticallyImplyLeading: false, - backgroundColor: Colors.transparent, - toolbarOpacity: 0, + child: AnimatedSize( + duration: const Duration(milliseconds: 300), + child: Container( + height: isSearching.value && mediaQuery.smAndDown + ? mediaQuery.size.height + : mediaQuery.size.height * .6, + margin: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + borderRadius: borderRadius, + color: theme.scaffoldBackgroundColor.withOpacity(.3), ), - body: Container( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: ListView.builder( - itemCount: siblings.length, - itemBuilder: (context, index) { - final video = siblings[index]; - return ListTile( - title: Text(video.title), - leading: Padding( - padding: const EdgeInsets.all(8.0), - child: UniversalImage( - path: video.thumbnail, - height: 60, - width: 60, - ), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: !isSearching.value + ? Text( + context.l10n.alternative_track_sources, + style: theme.textTheme.headlineSmall, + ) + : TextField( + autofocus: true, + controller: searchController, + decoration: InputDecoration( + hintText: context.l10n.search, + hintStyle: theme.textTheme.headlineSmall, + border: InputBorder.none, + ), + style: theme.textTheme.headlineSmall, + ), + ), + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + actions: [ + if (!isSearching.value) + IconButton( + icon: const Icon(SpotubeIcons.search, size: 18), + onPressed: () { + isSearching.value = true; + }, + ) + else ...[ + PopupMenuButton( + icon: const Icon(SpotubeIcons.filter, size: 18), + onSelected: (SearchMode mode) { + searchMode.value = mode; + }, + initialValue: searchMode.value, + itemBuilder: (context) => SearchMode.values + .map( + (e) => PopupMenuItem( + value: e, + child: Text(e.label), + ), + ) + .toList(), ), - trailing: Text( - PrimitiveUtils.toReadableDuration(video.duration), + IconButton( + icon: const Icon(SpotubeIcons.close, size: 18), + onPressed: () { + isSearching.value = false; + }, ), - subtitle: Text(video.uploaderName), - enabled: playlist.isFetching != true, - selected: playlist.isFetching != true && - video.id == - (playlist.activeTrack as SpotubeTrack).ytTrack.id, - selectedTileColor: theme.popupMenuTheme.color, - onTap: () async { - if (playlist.isFetching == false && - video.id != - (playlist.activeTrack as SpotubeTrack).ytTrack.id) { - await playlistNotifier.swapSibling(video); - } - }, - ); - }, + ] + ], + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: switch (isSearching.value) { + false => ListView.builder( + itemCount: siblings.length, + itemBuilder: (context, index) => + itemBuilder(siblings[index]), + ), + true => FutureBuilder( + future: searchRequest, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text(snapshot.error.toString()), + ); + } else if (!snapshot.hasData) { + return const Center( + child: CircularProgressIndicator()); + } + + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) => + itemBuilder(snapshot.data![index]), + ); + }, + ), + }, + ), ), ), ), diff --git a/lib/hooks/use_debounce.dart b/lib/hooks/use_debounce.dart new file mode 100644 index 000000000..5eb859a16 --- /dev/null +++ b/lib/hooks/use_debounce.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:flutter_hooks/flutter_hooks.dart'; + +T useDebounce( + T value, [ + Duration delay = const Duration(milliseconds: 500), +]) { + final state = useState(value); + + useEffect(() { + final timer = Timer(delay, () => state.value = value); + return timer.cancel; + }, [value, delay]); + + return state.value; +} diff --git a/lib/models/spotube_track.dart b/lib/models/spotube_track.dart index 4b21dafb7..ab37d5b73 100644 --- a/lib/models/spotube_track.dart +++ b/lib/models/spotube_track.dart @@ -1,14 +1,11 @@ import 'dart:async'; -import 'dart:convert'; -import 'package:catcher/catcher.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:http/http.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/artist_simple.dart'; -import 'package:spotube/models/logger.dart'; import 'package:spotube/models/matched_track.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; @@ -177,7 +174,8 @@ class SpotubeTrack extends Track { UserPreferences preferences, PipedClient client, ) async { - if (siblings.none((element) => element.id == video.id)) return null; + // sibling tracks that were manually searched and swapped + final isStepSibling = siblings.none((element) => element.id == video.id); final ytVideo = await client.streams(video.id); @@ -185,13 +183,15 @@ class SpotubeTrack extends Track { final ytUri = ytStream.url; - await MatchedTrack.box.put( - id!, - MatchedTrack( - youtubeId: video.id, - spotifyId: id!, - ), - ); + if (!isStepSibling) { + await MatchedTrack.box.put( + id!, + MatchedTrack( + youtubeId: video.id, + spotifyId: id!, + ), + ); + } if (preferences.predownload && video.duration < const Duration(minutes: 15)) { diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 33720be7d..89137f776 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:catcher/catcher.dart'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:http/http.dart'; @@ -406,7 +405,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier Future swapSibling(PipedSearchItem video) async { if (state.activeTrack is SpotubeTrack && video is PipedSearchItemStream) { - populateSibling(); + await populateSibling(); final newTrack = await (state.activeTrack as SpotubeTrack) .swappedCopy(video, preferences, pipedClient); if (newTrack == null) return;