From 18d4892a0daac72cc1c8adc11ed2355a91e80972 Mon Sep 17 00:00:00 2001 From: MSOB7YY Date: Thu, 25 Jan 2024 02:47:40 +0200 Subject: [PATCH] feat: youtube queue generators and some redesign --- lib/controller/generators_controller.dart | 141 +-- lib/controller/player_controller.dart | 24 +- lib/controller/playlist_controller.dart | 2 +- lib/controller/search_ports_provider.dart | 51 +- lib/core/functions.dart | 168 ++++ lib/core/namida_converter_ext.dart | 23 + lib/packages/miniplayer.dart | 869 +++++++----------- lib/ui/pages/home_page.dart | 6 +- lib/ui/widgets/custom_widgets.dart | 128 +++ .../controller/youtube_controller.dart | 15 +- .../youtube_local_search_controller.dart | 9 +- .../controller/yt_generators_controller.dart | 278 ++++++ lib/youtube/widgets/yt_queue_chip.dart | 170 +++- lib/youtube/yt_utils.dart | 4 +- 14 files changed, 1222 insertions(+), 666 deletions(-) create mode 100644 lib/youtube/controller/yt_generators_controller.dart diff --git a/lib/controller/generators_controller.dart b/lib/controller/generators_controller.dart index c8e72ef7..676431c7 100644 --- a/lib/controller/generators_controller.dart +++ b/lib/controller/generators_controller.dart @@ -1,14 +1,18 @@ +import 'package:history_manager/history_manager.dart'; + import 'package:namida/class/track.dart'; import 'package:namida/controller/history_controller.dart'; import 'package:namida/controller/indexer_controller.dart'; import 'package:namida/core/constants.dart'; import 'package:namida/core/extensions.dart'; -class NamidaGenerator { - static NamidaGenerator get inst => _instance; - static final NamidaGenerator _instance = NamidaGenerator._internal(); +class NamidaGenerator extends NamidaGeneratorBase { + static final NamidaGenerator inst = NamidaGenerator._internal(); NamidaGenerator._internal(); + @override + HistoryManager get historyController => HistoryController.inst; + Set getHighMatcheFilesFromFilename(Iterable files, String filename) { return files.where( (element) { @@ -24,67 +28,12 @@ class NamidaGenerator { ).toSet(); } - List getRandomTracks([int? min, int? max]) { - final trackslist = allTracksInLibrary; - final trackslistLength = trackslist.length; - - if (trackslist.length < 3) { - return []; - } - - /// ignore min and max if the value is more than the alltrackslist. - if (max != null && max > allTracksInLibrary.length) { - max = null; - min = null; - } - min ??= trackslistLength ~/ 12; - max ??= trackslistLength ~/ 8; - - // number of resulting tracks. - final int randomNumber = (max - min).getRandomNumberBelow(min); - - final Set randomList = {}; - for (int i = 0; i <= randomNumber; i++) { - randomList.add(trackslist[trackslistLength.getRandomNumberBelow()]); - } - return randomList.toList(); + Iterable getRandomTracks({Track? exclude, int? min, int? max}) { + return NamidaGeneratorBase.getRandomItems(allTracksInLibrary, exclude: exclude, min: min, max: max); } Iterable generateRecommendedTrack(Track track) { - final historytracks = HistoryController.inst.historyTracks.toList(); - if (historytracks.isEmpty) { - return []; - } - const length = 10; - final max = historytracks.length; - int clamped(int range) => range.clamp(0, max); - - final Map numberOfListensMap = {}; - - for (int i = 0; i <= historytracks.length - 1;) { - final t = historytracks[i]; - if (t.track == track) { - final heatTracks = historytracks.getRange(clamped(i - length), clamped(i + length)).toList(); - heatTracks.loop((e, index) { - numberOfListensMap.update(e.track, (value) => value + 1, ifAbsent: () => 1); - }); - // skip length since we already took 10 tracks. - i += length; - } else { - i++; - } - } - - numberOfListensMap.remove(track); - - final sortedByValueMap = numberOfListensMap.entries.toList(); - sortedByValueMap.sortByReverse((e) => e.value); - - return sortedByValueMap.map((e) => e.key); - } - - List generateTracksFromHistoryDates(DateTime? oldestDate, DateTime? newestDate, {bool removeDuplicates = true}) { - return HistoryController.inst.generateTracksFromHistoryDates(oldestDate, newestDate, removeDuplicates: removeDuplicates); + return super.generateRecommendedItemsFor(track, (current) => current.track); } /// [daysRange] means taking n days before [yearTimeStamp] & n days after [yearTimeStamp]. @@ -145,3 +94,73 @@ class NamidaGenerator { return finalTracks; } } + +abstract class NamidaGeneratorBase { + HistoryManager get historyController; + + /// Generated items listened to in a time range. + List generateItemsFromHistoryDates(DateTime? oldestDate, DateTime? newestDate, {bool removeDuplicates = true}) { + return historyController.generateTracksFromHistoryDates(oldestDate, newestDate, removeDuplicates: removeDuplicates); + } + + static Iterable getRandomItems(List list, {R? exclude, int? min, int? max}) { + final itemslist = list; + final itemslistLength = itemslist.length; + + if (itemslistLength <= 2) return []; + + /// ignore min and max if the value is more than the alltrackslist. + if (max != null && max > itemslist.length) { + max = null; + min = null; + } + min ??= itemslistLength ~/ 12; + max ??= itemslistLength ~/ 8; + + // number of resulting tracks. + final int randomNumber = (max - min).getRandomNumberBelow(min); + + final randomListMap = {}; + for (int i = 0; i <= randomNumber; i++) { + final item = list[itemslistLength.getRandomNumberBelow()]; + randomListMap[item] = true; + } + + if (exclude != null) randomListMap.remove(exclude); + + return randomListMap.keys; + } + + Iterable generateRecommendedItemsFor(E item, E Function(T current) itemToSub) { + final historytracks = historyController.historyTracks.toList(); + if (historytracks.isEmpty) return []; + + const length = 10; + final max = historytracks.length; + int clamped(int range) => range.clamp(0, max); + + final Map numberOfListensMap = {}; + + for (int i = 0; i <= historytracks.length - 1;) { + final t = historytracks[i]; + final subItem = itemToSub(t); + if (subItem == item) { + final heatTracks = historytracks.getRange(clamped(i - length), clamped(i + length)).toList(); + heatTracks.loop((e, index) { + numberOfListensMap.update(itemToSub(e), (value) => value + 1, ifAbsent: () => 1); + }); + // skip length since we already took 10 tracks. + i += length; + } else { + i++; + } + } + + numberOfListensMap.remove(item); + + final sortedByValueMap = numberOfListensMap.entries.toList(); + sortedByValueMap.sortByReverse((e) => e.value); + + return sortedByValueMap.map((e) => e.key); + } +} diff --git a/lib/controller/player_controller.dart b/lib/controller/player_controller.dart index ecc67d34..f71d68a9 100644 --- a/lib/controller/player_controller.dart +++ b/lib/controller/player_controller.dart @@ -232,10 +232,10 @@ class Player { bool showSnackBar = true, String? emptyTracksMessage, }) async { + final insertionDetails = insertionType?.toQueueInsertion(); + final shouldInsertNext = insertionDetails?.insertNext ?? insertNext; + final maxCount = insertionDetails?.numberOfTracks == 0 ? null : insertionDetails?.numberOfTracks; if (tracks.firstOrNull is Selectable) { - final insertionDetails = insertionType?.toQueueInsertion(); - final shouldInsertNext = insertionDetails?.insertNext ?? insertNext; - final maxCount = insertionDetails?.numberOfTracks == 0 ? null : insertionDetails?.numberOfTracks; final finalTracks = List.from(tracks.withLimit(maxCount)); insertionType?.shuffleOrSort(finalTracks); @@ -257,11 +257,25 @@ class Player { } return true; } else if (tracks.firstOrNull is YoutubeID) { + final finalVideos = List.from(tracks.withLimit(maxCount)); + insertionType?.shuffleOrSortYT(finalVideos); + + if (showSnackBar && finalVideos.isEmpty) { + snackyy(title: lang.NOTE, message: emptyTracksMessage ?? lang.NO_TRACKS_FOUND); + return false; + } await _audioHandler.addToQueue( - tracks, - insertNext: insertNext, + finalVideos, + insertNext: shouldInsertNext, insertAfterLatest: insertAfterLatest, ); + if (showSnackBar) { + final addins = shouldInsertNext ? lang.INSERTED : lang.ADDED; + snackyy( + icon: shouldInsertNext ? Broken.redo : Broken.add_circle, + message: '${addins.capitalizeFirst} ${finalVideos.length.displayVideoKeyword}', + ); + } return true; } diff --git a/lib/controller/playlist_controller.dart b/lib/controller/playlist_controller.dart index e0042489..34372ff5 100644 --- a/lib/controller/playlist_controller.dart +++ b/lib/controller/playlist_controller.dart @@ -109,7 +109,7 @@ class PlaylistController extends PlaylistManager { if (rt.isEmpty) return 0; final l = playlistsMap.keys.where((name) => name.startsWith(k_PLAYLIST_NAME_AUTO_GENERATED)).length; - addNewPlaylist('$k_PLAYLIST_NAME_AUTO_GENERATED ${l + 1}', tracks: rt); + addNewPlaylist('$k_PLAYLIST_NAME_AUTO_GENERATED ${l + 1}', tracks: rt.toList()); return rt.length; } diff --git a/lib/controller/search_ports_provider.dart b/lib/controller/search_ports_provider.dart index 096cba8f..856eac71 100644 --- a/lib/controller/search_ports_provider.dart +++ b/lib/controller/search_ports_provider.dart @@ -4,22 +4,15 @@ import 'dart:isolate'; import 'package:namida/core/enums.dart'; import 'package:namida/core/extensions.dart'; -typedef _PortsComm = ({ReceivePort items, Completer search}); - -class SearchPortsProvider { +class SearchPortsProvider with PortsProvider { static final SearchPortsProvider inst = SearchPortsProvider._internal(); SearchPortsProvider._internal(); - final _ports = {}; - - Future _disposePort(_PortsComm port) async { - port.items.close(); - (await port.search.future).send('dispose'); - } + final _ports = {}; void disposeAll() { for (final p in _ports.values) { - if (p != null) _disposePort(p); + if (p != null) disposePort(p); } _ports.clear(); } @@ -27,7 +20,7 @@ class SearchPortsProvider { Future closePorts(MediaType type) async { final port = _ports[type]; if (port != null) { - await _disposePort(port); + await disposePort(port); _ports[type] = null; } } @@ -38,13 +31,37 @@ class SearchPortsProvider { required Future Function(SendPort itemsSendPort) isolateFunction, bool force = false, }) async { - final portC = _ports[type]; - if (portC != null && !force) return await portC.search.future; + return await preparePortRaw( + portN: _ports[type], + onPortNull: () async { + await closePorts(type); + _ports[type] = (items: ReceivePort(), search: Completer()); + return _ports[type]!; + }, + onResult: onResult, + isolateFunction: isolateFunction, + ); + } +} - await closePorts(type); - _ports[type] = (items: ReceivePort(), search: Completer()); - final port = _ports[type]; - port!.items.listen((result) { +typedef PortsComm = ({ReceivePort items, Completer search}); +mixin PortsProvider { + Future disposePort(PortsComm port) async { + port.items.close(); + (await port.search.future).send('dispose'); + } + + Future preparePortRaw({ + required PortsComm? portN, + required Future Function() onPortNull, + required void Function(dynamic result) onResult, + required Future Function(SendPort itemsSendPort) isolateFunction, + bool force = false, + }) async { + if (portN != null && !force) return await portN.search.future; + + final port = await onPortNull(); + port.items.listen((result) { if (result is SendPort) { port.search.completeIfWasnt(result); } else { diff --git a/lib/core/functions.dart b/lib/core/functions.dart index ed5aa4fc..f3939f03 100644 --- a/lib/core/functions.dart +++ b/lib/core/functions.dart @@ -8,6 +8,7 @@ import 'package:history_manager/history_manager.dart'; import 'package:namida/class/folder.dart'; import 'package:namida/class/queue.dart'; +import 'package:namida/class/queue_insertion.dart'; import 'package:namida/class/track.dart'; import 'package:namida/controller/current_color.dart'; import 'package:namida/controller/folders_controller.dart'; @@ -579,3 +580,170 @@ Map> getFilesTypeIsolate(Map parameters) { 'pathsExcludedByNoMedia': excludedByNoMedia, }; } + +Future showAddItemsToQueueDialog({ + required BuildContext context, + required List Function( + Widget Function({ + required String title, + required String subtitle, + required IconData icon, + required QueueInsertionType insertionType, + required void Function(QueueInsertionType insertionType) onTap, + Widget? trailingRaw, + }) addTracksTile) + tiles, +}) async { + final shouldShowConfigureIcon = false.obs; + + void openQueueInsertionConfigure(QueueInsertionType insertionType, String title) async { + final qinsertion = insertionType.toQueueInsertion(); + final tracksNo = qinsertion.numberOfTracks.obs; + final insertN = qinsertion.insertNext.obs; + final sortBy = qinsertion.sortBy.obs; + final maxCount = 200.withMaximum(allTracksInLibrary.length); + await NamidaNavigator.inst.navigateDialog( + onDisposing: () { + tracksNo.close(); + insertN.close(); + sortBy.close(); + }, + dialog: CustomBlurryDialog( + title: lang.CONFIGURE, + actions: [ + const CancelButton(), + NamidaButton( + text: lang.SAVE, + onPressed: () { + settings.updateQueueInsertion( + insertionType, + QueueInsertion( + numberOfTracks: tracksNo.value, + insertNext: insertN.value, + sortBy: sortBy.value, + ), + ); + NamidaNavigator.inst.closeDialog(); + }, + ) + ], + child: Column( + children: [ + NamidaInkWell( + borderRadius: 10.0, + bgColor: context.theme.cardColor, + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: Text(title, style: context.textTheme.displayLarge), + ), + const SizedBox(height: 24.0), + CustomListTile( + icon: Broken.computing, + title: lang.NUMBER_OF_TRACKS, + subtitle: "${lang.UNLIMITED}-$maxCount", + trailing: Obx( + () => NamidaWheelSlider( + totalCount: maxCount, + initValue: tracksNo.value, + itemSize: 1, + squeeze: 0.3, + onValueChanged: (val) => tracksNo.value = val, + text: tracksNo.value == 0 ? lang.UNLIMITED : '${tracksNo.value}', + ), + ), + ), + Obx( + () => CustomSwitchListTile( + icon: Broken.next, + title: lang.PLAY_NEXT, + value: insertN.value, + onChanged: (isTrue) => insertN.value = !isTrue, + ), + ), + CustomListTile( + icon: Broken.sort, + title: lang.SORT_BY, + trailingRaw: ConstrainedBox( + constraints: BoxConstraints(minWidth: 0, maxWidth: context.width * 0.34), + child: FittedBox( + child: PopupMenuButton( + child: Obx( + () => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(sortBy.value.toIcon(), size: 18.0), + const SizedBox(width: 8.0), + Text(sortBy.value.toText()), + ], + ), + ), + itemBuilder: (context) { + return >[ + ...InsertionSortingType.values + .map( + (e) => PopupMenuItem( + value: e, + child: Row( + children: [ + Icon(e.toIcon(), size: 20.0), + const SizedBox(width: 8.0), + Text(e.toText()), + ], + ), + ), + ) + .toList() + ]; + }, + onSelected: (value) => sortBy.value = value, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget getAddTracksTile({ + required String title, + required String subtitle, + required IconData icon, + required QueueInsertionType insertionType, + required void Function(QueueInsertionType insertionType) onTap, + Widget? trailingRaw, + }) { + return CustomListTile( + title: title, + subtitle: subtitle, + icon: icon, + maxSubtitleLines: 22, + onTap: () => onTap(insertionType), + trailingRaw: trailingRaw ?? + Obx( + () => NamidaIconButton( + icon: Broken.setting_4, + onPressed: () => openQueueInsertionConfigure(insertionType, title), + ).animateEntrance( + showWhen: shouldShowConfigureIcon.value, + durationMS: 200, + ), + ), + ); + } + + await NamidaNavigator.inst.navigateDialog( + dialog: CustomBlurryDialog( + normalTitleStyle: true, + title: lang.NEW_TRACKS_ADD, + trailingWidgets: [ + NamidaIconButton( + icon: Broken.setting_3, + tooltip: lang.CONFIGURE, + onPressed: () => shouldShowConfigureIcon.value = !shouldShowConfigureIcon.value, + ), + ], + child: Column(children: tiles(getAddTracksTile)), + ), + ); +} diff --git a/lib/core/namida_converter_ext.dart b/lib/core/namida_converter_ext.dart index 468cd302..5d320eb1 100644 --- a/lib/core/namida_converter_ext.dart +++ b/lib/core/namida_converter_ext.dart @@ -707,6 +707,29 @@ extension QueueInsertionTypeToQI on QueueInsertionType { return tracks; } + + /// NOTE: Modifies the original list. + List shuffleOrSortYT(List videos) { + final sortBy = toQueueInsertion().sortBy; + + switch (sortBy) { + case InsertionSortingType.listenCount: + if (this == QueueInsertionType.algorithm) { + // already sorted by repeated times inside [NamidaGenerator.generateRecommendedTrack]. + } else { + videos.sortByReverse((e) => YoutubeHistoryController.inst.topTracksMapListens[e.id]?.length ?? 0); + } + // case InsertionSortingType.rating: + // tracks.sortByReverse((e) => e.track.stats.rating); + case InsertionSortingType.random: + videos.shuffle(); + + default: + null; + } + + return videos; + } } extension InsertionSortingTypeTextIcon on InsertionSortingType { diff --git a/lib/packages/miniplayer.dart b/lib/packages/miniplayer.dart index c3438722..ac1ef85c 100644 --- a/lib/packages/miniplayer.dart +++ b/lib/packages/miniplayer.dart @@ -5,7 +5,6 @@ import 'package:animated_background/animated_background.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:namida/class/queue_insertion.dart'; import 'package:namida/class/track.dart'; import 'package:namida/class/video.dart'; import 'package:namida/controller/connectivity.dart'; @@ -246,7 +245,16 @@ class _NamidaMiniPlayerState extends State { child: Padding( padding: const EdgeInsets.all(4.0).add(EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom)), child: FittedBox( - child: _queueUtilsRow(context), + child: QueueUtilsRow( + itemsKeyword: (number) => number.displayTrackKeyword, + onAddItemsTap: () => _addTracksButtonTap(context), + scrollQueueWidget: Obx( + () => NamidaButton( + onPressed: MiniPlayerController.inst.animateQueueToCurrentTrack, + icon: MiniPlayerController.inst.arrowIcon.value, + ), + ), + ), ), ), ), @@ -1287,581 +1295,340 @@ class _NamidaMiniPlayerState extends State { ); } - Widget _queueUtilsRow(BuildContext context) { - const tileHeight = 48.0; - const tileVPadding = 3.0; - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - SizedBox(width: context.width * 0.23), - const SizedBox(width: 6.0), - NamidaButton( - tooltip: lang.REMOVE_DUPLICATES, - icon: Broken.trash, - onPressed: () { - final removed = Player.inst.removeDuplicatesFromQueue(); - snackyy( - icon: Broken.filter_remove, - message: "${lang.REMOVED} ${removed.displayTrackKeyword}", - ); - }, - ), - const SizedBox(width: 6.0), - _addTracksButton(context), - const SizedBox(width: 6.0), - Obx( - () => NamidaButton( - onPressed: MiniPlayerController.inst.animateQueueToCurrentTrack, - icon: MiniPlayerController.inst.arrowIcon.value, + void _addTracksButtonTap(BuildContext context) { + final currentTrack = Player.inst.nowPlayingTrack; + showAddItemsToQueueDialog( + context: context, + tiles: (getAddTracksTile) { + return [ + getAddTracksTile( + title: lang.NEW_TRACKS_RANDOM, + subtitle: lang.NEW_TRACKS_RANDOM_SUBTITLE, + icon: Broken.format_circle, + insertionType: QueueInsertionType.random, + onTap: (insertionType) { + final config = insertionType.toQueueInsertion(); + final count = config.numberOfTracks; + final rt = NamidaGenerator.inst.getRandomTracks(exclude: currentTrack, min: count - 1, max: count); + Player.inst.addToQueue(rt, insertionType: insertionType, emptyTracksMessage: lang.NO_ENOUGH_TRACKS).closeDialog(); + }, ), - ), - const SizedBox(width: 6.0), - GestureDetector( - onLongPressStart: (details) async { - void saveSetting(bool shuffleAll) => settings.save(playerShuffleAllTracks: shuffleAll); - await showMenu( - context: context, - position: RelativeRect.fromLTRB( - details.globalPosition.dx, - details.globalPosition.dy - kQueueBottomRowHeight - (tileHeight + tileVPadding * 2) * 2, - details.globalPosition.dx, - details.globalPosition.dy, - ), - items: [ - ...[ - ( - lang.SHUFFLE_NEXT, - Broken.forward, - false, - ), - ( - lang.SHUFFLE_ALL, - Broken.task, - true, - ), - ].map( - (e) => PopupMenuItem( + getAddTracksTile( + title: lang.GENERATE_FROM_DATES, + subtitle: lang.GENERATE_FROM_DATES_SUBTITLE, + icon: Broken.calendar, + insertionType: QueueInsertionType.listenTimeRange, + onTap: (insertionType) { + NamidaNavigator.inst.closeDialog(); + final historyTracks = HistoryController.inst.historyTracks; + if (historyTracks.isEmpty) { + snackyy(title: lang.NOTE, message: lang.NO_TRACKS_IN_HISTORY); + return; + } + showCalendarDialog( + title: lang.GENERATE_FROM_DATES, + buttonText: lang.GENERATE, + useHistoryDates: true, + onGenerate: (dates) { + final tracks = NamidaGenerator.inst.generateItemsFromHistoryDates(dates.firstOrNull, dates.lastOrNull); + Player.inst + .addToQueue( + tracks, + insertionType: insertionType, + emptyTracksMessage: lang.NO_TRACKS_FOUND_BETWEEN_DATES, + ) + .closeDialog(); + }, + ); + }, + ), + getAddTracksTile( + title: lang.NEW_TRACKS_MOODS, + subtitle: lang.NEW_TRACKS_MOODS_SUBTITLE, + icon: Broken.emoji_happy, + insertionType: QueueInsertionType.mood, + onTap: (insertionType) async { + NamidaNavigator.inst.closeDialog(); + + // -- moods from playlists. + final allAvailableMoodsPlaylists = >{}; + for (final pl in PlaylistController.inst.playlistsMap.entries) { + pl.value.moods.loop((mood, _) { + allAvailableMoodsPlaylists.addAllNoDuplicatesForce(mood, pl.value.tracks.tracks); + }); + } + // -- moods from tracks. + final allAvailableMoodsTracks = >{}; + for (final tr in Indexer.inst.trackStatsMap.entries) { + tr.value.moods.loop((mood, _) { + allAvailableMoodsTracks.addNoDuplicatesForce(mood, tr.key); + }); + } + + // -- moods from track embedded tag + final library = allTracksInLibrary; + for (final tr in library) { + tr.moodList.loop((mood, _) { + allAvailableMoodsTracks.addNoDuplicatesForce(mood, tr); + }); + } + + if (allAvailableMoodsPlaylists.isEmpty && allAvailableMoodsTracks.isEmpty) { + snackyy(title: lang.ERROR, message: lang.NO_MOODS_AVAILABLE); + return; + } + + final playlistsAllMoods = allAvailableMoodsPlaylists.keys.toList(); + final tracksAllMoods = allAvailableMoodsTracks.keys.toList(); + + final selectedmoodsPlaylists = [].obs; + final selectedmoodsTracks = [].obs; + + List getListy({ + required String title, + required List moodsList, + required Map> allAvailableMoods, + required List selectedList, + }) { + return [ + SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(vertical: tileVPadding), - child: Obx( - () => SizedBox( - height: tileHeight, - child: ListTileWithCheckMark( - active: settings.playerShuffleAllTracks.value == e.$3, - leading: StackedIcon( - baseIcon: Broken.shuffle, - secondaryIcon: e.$2, - blurRadius: 8.0, - ), - title: e.$1, - onTap: () => saveSetting(e.$3), - ), + padding: const EdgeInsets.all(12.0), + child: Text("$title (${moodsList.length})", style: context.textTheme.displayMedium), + ), + ), + SliverToBoxAdapter( + child: Wrap( + children: [ + ...moodsList.map( + (m) { + final tracksCount = allAvailableMoods[m]?.length ?? 0; + return NamidaInkWell( + borderRadius: 6.0, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + margin: const EdgeInsets.all(2.0), + bgColor: context.theme.cardColor, + onTap: () => selectedList.addOrRemove(m), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "$m ($tracksCount)", + style: context.textTheme.displayMedium, + ), + const SizedBox(width: 8.0), + Obx( + () => NamidaCheckMark( + size: 12.0, + active: selectedList.contains(m), + ), + ), + ], + ), + ); + }, ), - ), + ], ), ), - ), - ], - ); - }, - child: NamidaButton( - text: lang.SHUFFLE, - icon: Broken.shuffle, - onPressed: () => Player.inst.shuffleTracks(settings.playerShuffleAllTracks.value), - ), - ), - const SizedBox(width: 8.0), - ], - ); - } + ]; + } - Widget _addTracksButton(BuildContext context) { - final currentTrack = Player.inst.nowPlayingTrack; - final shouldShowConfigureIcon = false.obs; - - void openQueueInsertionConfigure(QueueInsertionType insertionType, String title) async { - final qinsertion = insertionType.toQueueInsertion(); - final tracksNo = qinsertion.numberOfTracks.obs; - final insertN = qinsertion.insertNext.obs; - final sortBy = qinsertion.sortBy.obs; - final maxCount = 200.withMaximum(allTracksInLibrary.length); - await NamidaNavigator.inst.navigateDialog( - onDisposing: () { - tracksNo.close(); - insertN.close(); - sortBy.close(); - }, - dialog: CustomBlurryDialog( - title: lang.CONFIGURE, - actions: [ - const CancelButton(), - NamidaButton( - text: lang.SAVE, - onPressed: () { - settings.updateQueueInsertion( - insertionType, - QueueInsertion( - numberOfTracks: tracksNo.value, - insertNext: insertN.value, - sortBy: sortBy.value, - ), - ); - NamidaNavigator.inst.closeDialog(); - }, - ) - ], - child: Column( - children: [ - NamidaInkWell( - borderRadius: 10.0, - bgColor: context.theme.cardColor, - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - child: Text(title, style: context.textTheme.displayLarge), - ), - const SizedBox(height: 24.0), - CustomListTile( - icon: Broken.computing, - title: lang.NUMBER_OF_TRACKS, - subtitle: "${lang.UNLIMITED}-$maxCount", - trailing: Obx( - () => NamidaWheelSlider( - totalCount: maxCount, - initValue: tracksNo.value, - itemSize: 1, - squeeze: 0.3, - onValueChanged: (val) => tracksNo.value = val, - text: tracksNo.value == 0 ? lang.UNLIMITED : '${tracksNo.value}', - ), - ), - ), - Obx( - () => CustomSwitchListTile( - icon: Broken.next, - title: lang.PLAY_NEXT, - value: insertN.value, - onChanged: (isTrue) => insertN.value = !isTrue, - ), - ), - CustomListTile( - icon: Broken.sort, - title: lang.SORT_BY, - trailingRaw: ConstrainedBox( - constraints: BoxConstraints(minWidth: 0, maxWidth: context.width * 0.34), - child: FittedBox( - child: PopupMenuButton( - child: Obx( - () => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(sortBy.value.toIcon(), size: 18.0), - const SizedBox(width: 8.0), - Text(sortBy.value.toText()), - ], - ), - ), - itemBuilder: (context) { - return >[ - ...InsertionSortingType.values - .map( - (e) => PopupMenuItem( - value: e, - child: Row( - children: [ - Icon(e.toIcon(), size: 20.0), - const SizedBox(width: 8.0), - Text(e.toText()), - ], - ), - ), - ) - .toList() - ]; + await NamidaNavigator.inst.navigateDialog( + onDisposing: () { + selectedmoodsPlaylists.close(); + selectedmoodsTracks.close(); + }, + dialog: CustomBlurryDialog( + normalTitleStyle: true, + insetPadding: const EdgeInsets.symmetric(horizontal: 48.0), + title: lang.MOODS, + actions: [ + const CancelButton(), + NamidaButton( + text: lang.GENERATE, + onPressed: () { + final finalTracks = []; + selectedmoodsPlaylists.loop((m, _) { + finalTracks.addAll(allAvailableMoodsPlaylists[m] ?? []); + }); + selectedmoodsTracks.loop((m, _) { + finalTracks.addAll(allAvailableMoodsTracks[m] ?? []); + }); + Player.inst.addToQueue( + finalTracks.uniqued(), + insertionType: insertionType, + ); + NamidaNavigator.inst.closeDialog(); }, - onSelected: (value) => sortBy.value = value, + ), + ], + child: SizedBox( + height: context.height * 0.4, + width: context.width, + child: CustomScrollView( + slivers: [ + // -- Tracks moods (embedded & custom) + ...getListy( + title: lang.TRACKS, + moodsList: tracksAllMoods, + allAvailableMoods: allAvailableMoodsTracks, + selectedList: selectedmoodsTracks, + ), + // -- Playlist moods + ...getListy( + title: lang.PLAYLISTS, + moodsList: playlistsAllMoods, + allAvailableMoods: allAvailableMoodsPlaylists, + selectedList: selectedmoodsPlaylists, + ), + ], ), ), ), - ), - ], - ), - ), - ); - } - - Widget getAddTracksTile({ - required String title, - required String subtitle, - required IconData icon, - required QueueInsertionType insertionType, - required void Function(QueueInsertionType insertionType) onTap, - }) { - return CustomListTile( - title: title, - subtitle: subtitle, - icon: icon, - maxSubtitleLines: 22, - onTap: () => onTap(insertionType), - trailingRaw: Obx( - () => NamidaIconButton( - icon: Broken.setting_4, - onPressed: () => openQueueInsertionConfigure(insertionType, title), - ).animateEntrance( - showWhen: shouldShowConfigureIcon.value, - durationMS: 200, + ); + }, ), - ), - ); - } - - return NamidaButton( - tooltip: lang.NEW_TRACKS_ADD, - icon: Broken.add_circle, - onPressed: () async { - await NamidaNavigator.inst.navigateDialog( - dialog: CustomBlurryDialog( - normalTitleStyle: true, - title: lang.NEW_TRACKS_ADD, - trailingWidgets: [ - NamidaIconButton( - icon: Broken.setting_3, - tooltip: lang.CONFIGURE, - onPressed: () => shouldShowConfigureIcon.value = !shouldShowConfigureIcon.value, - ), - ], - child: Column( - children: [ - getAddTracksTile( - title: lang.NEW_TRACKS_RANDOM, - subtitle: lang.NEW_TRACKS_RANDOM_SUBTITLE, - icon: Broken.format_circle, - insertionType: QueueInsertionType.random, - onTap: (insertionType) { - final config = insertionType.toQueueInsertion(); - final count = config.numberOfTracks; - final rt = NamidaGenerator.inst.getRandomTracks(count - 1, count); - Player.inst.addToQueue(rt, insertionType: insertionType, emptyTracksMessage: lang.NO_ENOUGH_TRACKS).closeDialog(); - }, - ), - getAddTracksTile( - title: lang.GENERATE_FROM_DATES, - subtitle: lang.GENERATE_FROM_DATES_SUBTITLE, - icon: Broken.calendar, - insertionType: QueueInsertionType.listenTimeRange, - onTap: (insertionType) { - NamidaNavigator.inst.closeDialog(); - final historyTracks = HistoryController.inst.historyTracks; - if (historyTracks.isEmpty) { - snackyy(title: lang.NOTE, message: lang.NO_TRACKS_IN_HISTORY); - return; - } - showCalendarDialog( - title: lang.GENERATE_FROM_DATES, - buttonText: lang.GENERATE, - useHistoryDates: true, - onGenerate: (dates) { - final tracks = NamidaGenerator.inst.generateTracksFromHistoryDates(dates.firstOrNull, dates.lastOrNull); - Player.inst - .addToQueue( - tracks, - insertionType: insertionType, - emptyTracksMessage: lang.NO_TRACKS_FOUND_BETWEEN_DATES, - ) - .closeDialog(); + getAddTracksTile( + title: lang.NEW_TRACKS_RATINGS, + subtitle: lang.NEW_TRACKS_RATINGS_SUBTITLE, + icon: Broken.happyemoji, + insertionType: QueueInsertionType.rating, + onTap: (insertionType) async { + NamidaNavigator.inst.closeDialog(); + + final RxInt minRating = 80.obs; + final RxInt maxRating = 100.obs; + await NamidaNavigator.inst.navigateDialog( + onDisposing: () { + minRating.close(); + maxRating.close(); + }, + dialog: CustomBlurryDialog( + normalTitleStyle: true, + title: lang.NEW_TRACKS_RATINGS, + actions: [ + const CancelButton(), + NamidaButton( + text: lang.GENERATE, + onPressed: () { + if (minRating.value > maxRating.value) { + snackyy(title: lang.ERROR, message: lang.MIN_VALUE_CANT_BE_MORE_THAN_MAX); + return; + } + final tracks = NamidaGenerator.inst.generateTracksFromRatings( + minRating.value, + maxRating.value, + ); + Player.inst.addToQueue(tracks, insertionType: insertionType); + NamidaNavigator.inst.closeDialog(); }, - ); - }, - ), - getAddTracksTile( - title: lang.NEW_TRACKS_MOODS, - subtitle: lang.NEW_TRACKS_MOODS_SUBTITLE, - icon: Broken.emoji_happy, - insertionType: QueueInsertionType.mood, - onTap: (insertionType) async { - NamidaNavigator.inst.closeDialog(); - - // -- moods from playlists. - final allAvailableMoodsPlaylists = >{}; - for (final pl in PlaylistController.inst.playlistsMap.entries) { - pl.value.moods.loop((mood, _) { - allAvailableMoodsPlaylists.addAllNoDuplicatesForce(mood, pl.value.tracks.tracks); - }); - } - // -- moods from tracks. - final allAvailableMoodsTracks = >{}; - for (final tr in Indexer.inst.trackStatsMap.entries) { - tr.value.moods.loop((mood, _) { - allAvailableMoodsTracks.addNoDuplicatesForce(mood, tr.key); - }); - } - - // -- moods from track embedded tag - final library = allTracksInLibrary; - for (final tr in library) { - tr.moodList.loop((mood, _) { - allAvailableMoodsTracks.addNoDuplicatesForce(mood, tr); - }); - } - - if (allAvailableMoodsPlaylists.isEmpty && allAvailableMoodsTracks.isEmpty) { - snackyy(title: lang.ERROR, message: lang.NO_MOODS_AVAILABLE); - return; - } - - final playlistsAllMoods = allAvailableMoodsPlaylists.keys.toList(); - final tracksAllMoods = allAvailableMoodsTracks.keys.toList(); - - final selectedmoodsPlaylists = [].obs; - final selectedmoodsTracks = [].obs; - - List getListy({ - required String title, - required List moodsList, - required Map> allAvailableMoods, - required List selectedList, - }) { - return [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Text("$title (${moodsList.length})", style: context.textTheme.displayMedium), - ), - ), - SliverToBoxAdapter( - child: Wrap( + ), + ], + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( children: [ - ...moodsList.map( - (m) { - final tracksCount = allAvailableMoods[m]?.length ?? 0; - return NamidaInkWell( - borderRadius: 6.0, - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), - margin: const EdgeInsets.all(2.0), - bgColor: context.theme.cardColor, - onTap: () => selectedList.addOrRemove(m), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "$m ($tracksCount)", - style: context.textTheme.displayMedium, - ), - const SizedBox(width: 8.0), - Obx( - () => NamidaCheckMark( - size: 12.0, - active: selectedList.contains(m), - ), - ), - ], - ), - ); + Text(lang.MINIMUM), + const SizedBox(height: 24.0), + NamidaWheelSlider( + totalCount: 100, + initValue: minRating.value, + itemSize: 1, + squeeze: 0.3, + onValueChanged: (val) { + minRating.value = val; }, ), + const SizedBox(height: 2.0), + Obx( + () => Text( + '${minRating.value}%', + style: context.textTheme.displaySmall, + ), + ) ], ), - ), - ]; - } - - await NamidaNavigator.inst.navigateDialog( - onDisposing: () { - selectedmoodsPlaylists.close(); - selectedmoodsTracks.close(); - }, - dialog: CustomBlurryDialog( - normalTitleStyle: true, - insetPadding: const EdgeInsets.symmetric(horizontal: 48.0), - title: lang.MOODS, - actions: [ - const CancelButton(), - NamidaButton( - text: lang.GENERATE, - onPressed: () { - final finalTracks = []; - selectedmoodsPlaylists.loop((m, _) { - finalTracks.addAll(allAvailableMoodsPlaylists[m] ?? []); - }); - selectedmoodsTracks.loop((m, _) { - finalTracks.addAll(allAvailableMoodsTracks[m] ?? []); - }); - Player.inst.addToQueue( - finalTracks.uniqued(), - insertionType: insertionType, - ); - NamidaNavigator.inst.closeDialog(); - }, - ), - ], - child: SizedBox( - height: context.height * 0.4, - width: context.width, - child: CustomScrollView( - slivers: [ - // -- Tracks moods (embedded & custom) - ...getListy( - title: lang.TRACKS, - moodsList: tracksAllMoods, - allAvailableMoods: allAvailableMoodsTracks, - selectedList: selectedmoodsTracks, + Column( + children: [ + Text(lang.MAXIMUM), + const SizedBox(height: 24.0), + NamidaWheelSlider( + totalCount: 100, + initValue: maxRating.value, + itemSize: 1, + squeeze: 0.3, + onValueChanged: (val) { + maxRating.value = val; + }, ), - // -- Playlist moods - ...getListy( - title: lang.PLAYLISTS, - moodsList: playlistsAllMoods, - allAvailableMoods: allAvailableMoodsPlaylists, - selectedList: selectedmoodsPlaylists, + const SizedBox(height: 2.0), + Obx( + () => Text( + '${maxRating.value}%', + style: context.textTheme.displaySmall, + ), ), ], - ), - ), - ), - ); - }, - ), - getAddTracksTile( - title: lang.NEW_TRACKS_RATINGS, - subtitle: lang.NEW_TRACKS_RATINGS_SUBTITLE, - icon: Broken.happyemoji, - insertionType: QueueInsertionType.rating, - onTap: (insertionType) async { - NamidaNavigator.inst.closeDialog(); - - final RxInt minRating = 80.obs; - final RxInt maxRating = 100.obs; - await NamidaNavigator.inst.navigateDialog( - onDisposing: () { - minRating.close(); - maxRating.close(); - }, - dialog: CustomBlurryDialog( - normalTitleStyle: true, - title: lang.NEW_TRACKS_RATINGS, - actions: [ - const CancelButton(), - NamidaButton( - text: lang.GENERATE, - onPressed: () { - if (minRating.value > maxRating.value) { - snackyy(title: lang.ERROR, message: lang.MIN_VALUE_CANT_BE_MORE_THAN_MAX); - return; - } - final tracks = NamidaGenerator.inst.generateTracksFromRatings( - minRating.value, - maxRating.value, - ); - Player.inst.addToQueue(tracks, insertionType: insertionType); - NamidaNavigator.inst.closeDialog(); - }, - ), + ) ], - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - children: [ - Text(lang.MINIMUM), - const SizedBox(height: 24.0), - NamidaWheelSlider( - totalCount: 100, - initValue: minRating.value, - itemSize: 1, - squeeze: 0.3, - onValueChanged: (val) { - minRating.value = val; - }, - ), - const SizedBox(height: 2.0), - Obx( - () => Text( - '${minRating.value}%', - style: context.textTheme.displaySmall, - ), - ) - ], - ), - Column( - children: [ - Text(lang.MAXIMUM), - const SizedBox(height: 24.0), - NamidaWheelSlider( - totalCount: 100, - initValue: maxRating.value, - itemSize: 1, - squeeze: 0.3, - onValueChanged: (val) { - maxRating.value = val; - }, - ), - const SizedBox(height: 2.0), - Obx( - () => Text( - '${maxRating.value}%', - style: context.textTheme.displaySmall, - ), - ), - ], - ) - ], - ), - ], - ), ), - ); - }, - ), - const NamidaContainerDivider(margin: EdgeInsets.symmetric(vertical: 4.0)), - getAddTracksTile( - title: lang.NEW_TRACKS_SIMILARR_RELEASE_DATE, - subtitle: lang.NEW_TRACKS_SIMILARR_RELEASE_DATE_SUBTITLE.replaceFirst( - '_CURRENT_TRACK_', - currentTrack.title.addDQuotation(), - ), - icon: Broken.calendar_1, - insertionType: QueueInsertionType.sameReleaseDate, - onTap: (insertionType) { - final year = currentTrack.year; - if (year == 0) { - snackyy(title: lang.ERROR, message: lang.NEW_TRACKS_UNKNOWN_YEAR); - return; - } - final tracks = NamidaGenerator.inst.generateTracksFromSameEra(year, currentTrack: currentTrack); - Player.inst - .addToQueue( - tracks, - insertionType: insertionType, - emptyTracksMessage: lang.NO_TRACKS_FOUND_BETWEEN_DATES, - ) - .closeDialog(); - }, - ), - getAddTracksTile( - title: lang.NEW_TRACKS_RECOMMENDED, - subtitle: lang.NEW_TRACKS_RECOMMENDED_SUBTITLE.replaceFirst( - '_CURRENT_TRACK_', - currentTrack.title.addDQuotation(), + ], ), - icon: Broken.bezier, - insertionType: QueueInsertionType.algorithm, - onTap: (insertionType) { - final gentracks = NamidaGenerator.inst.generateRecommendedTrack(currentTrack); - - Player.inst - .addToQueue( - gentracks, - insertionType: insertionType, - insertNext: true, - emptyTracksMessage: lang.NO_TRACKS_IN_HISTORY, - ) - .closeDialog(); - }, ), - ], + ); + }, + ), + const NamidaContainerDivider(margin: EdgeInsets.symmetric(vertical: 4.0)), + getAddTracksTile( + title: lang.NEW_TRACKS_SIMILARR_RELEASE_DATE, + subtitle: lang.NEW_TRACKS_SIMILARR_RELEASE_DATE_SUBTITLE.replaceFirst( + '_CURRENT_TRACK_', + currentTrack.title.addDQuotation(), ), + icon: Broken.calendar_1, + insertionType: QueueInsertionType.sameReleaseDate, + onTap: (insertionType) { + final year = currentTrack.year; + if (year == 0) { + snackyy(title: lang.ERROR, message: lang.NEW_TRACKS_UNKNOWN_YEAR); + return; + } + final tracks = NamidaGenerator.inst.generateTracksFromSameEra(year, currentTrack: currentTrack); + Player.inst + .addToQueue( + tracks, + insertionType: insertionType, + emptyTracksMessage: lang.NO_TRACKS_FOUND_BETWEEN_DATES, + ) + .closeDialog(); + }, ), - ); - // shouldShowConfigureIcon.closeAfterDelay(); + getAddTracksTile( + title: lang.NEW_TRACKS_RECOMMENDED, + subtitle: lang.NEW_TRACKS_RECOMMENDED_SUBTITLE.replaceFirst( + '_CURRENT_TRACK_', + currentTrack.title.addDQuotation(), + ), + icon: Broken.bezier, + insertionType: QueueInsertionType.algorithm, + onTap: (insertionType) { + final gentracks = NamidaGenerator.inst.generateRecommendedTrack(currentTrack); + + Player.inst + .addToQueue( + gentracks, + insertionType: insertionType, + insertNext: true, + emptyTracksMessage: lang.NO_TRACKS_IN_HISTORY, + ) + .closeDialog(); + }, + ), + ]; }, ); } diff --git a/lib/ui/pages/home_page.dart b/lib/ui/pages/home_page.dart index eee6f725..5ca2e919 100644 --- a/lib/ui/pages/home_page.dart +++ b/lib/ui/pages/home_page.dart @@ -50,7 +50,7 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State with SingleTickerProviderStateMixin { - final _shimmerList = List.filled(20, null); + final _shimmerList = List.filled(20, null, growable: true); late bool _isLoading; final _recentlyAddedFull = []; @@ -107,7 +107,7 @@ class _HomePageState extends State with SingleTickerProviderStateMixin _recentlyAdded.addAll(alltracks.take(40)); // -- Recent Listens -- - _recentListened.addAllIfEmpty(NamidaGenerator.inst.generateTracksFromHistoryDates(DateTime(timeNow.year, timeNow.month, timeNow.day - 3), timeNow).take(40)); + _recentListened.addAllIfEmpty(NamidaGenerator.inst.generateItemsFromHistoryDates(DateTime(timeNow.year, timeNow.month, timeNow.day - 3), timeNow).take(40)); // -- Top Recents -- _topRecentListened.addAllIfEmpty( @@ -142,7 +142,7 @@ class _HomePageState extends State with SingleTickerProviderStateMixin // ==== Mixes ==== // -- Random -- - _randomTracks.addAllIfEmpty(NamidaGenerator.inst.getRandomTracks(24, 25)); + _randomTracks.addAllIfEmpty(NamidaGenerator.inst.getRandomTracks(min: 24, max: 25)); // -- favs -- final favs = List.from(PlaylistController.inst.favouritesPlaylist.value.tracks); diff --git a/lib/ui/widgets/custom_widgets.dart b/lib/ui/widgets/custom_widgets.dart index 37f88b02..37c71576 100644 --- a/lib/ui/widgets/custom_widgets.dart +++ b/lib/ui/widgets/custom_widgets.dart @@ -3356,3 +3356,131 @@ class _DummyElement extends Element { // ignore: must_call_super void performRebuild() {} } + +class AnimatedEnabled extends StatelessWidget { + final bool enabled; + final double disabledOpacity; + final int durationMS; + final Widget child; + + const AnimatedEnabled({ + super.key, + required this.enabled, + this.disabledOpacity = 0.6, + this.durationMS = 300, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: !enabled, + child: AnimatedOpacity( + opacity: enabled ? 1.0 : disabledOpacity, + duration: Duration(milliseconds: durationMS), + child: child, + ), + ); + } +} + +class QueueUtilsRow extends StatelessWidget { + final String Function(int number) itemsKeyword; + final void Function() onAddItemsTap; + final Widget scrollQueueWidget; + + const QueueUtilsRow({ + super.key, + required this.itemsKeyword, + required this.onAddItemsTap, + required this.scrollQueueWidget, + }); + + @override + Widget build(BuildContext context) { + const tileHeight = 48.0; + const tileVPadding = 3.0; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox(width: context.width * 0.23), + const SizedBox(width: 6.0), + NamidaButton( + tooltip: lang.REMOVE_DUPLICATES, + icon: Broken.trash, + onPressed: () { + final removed = Player.inst.removeDuplicatesFromQueue(); + snackyy( + icon: Broken.filter_remove, + message: "${lang.REMOVED} ${itemsKeyword(removed)}", + ); + }, + ), + const SizedBox(width: 6.0), + NamidaButton( + tooltip: lang.NEW_TRACKS_ADD, + icon: Broken.add_circle, + onPressed: () => onAddItemsTap(), + ), + const SizedBox(width: 6.0), + scrollQueueWidget, + const SizedBox(width: 6.0), + GestureDetector( + onLongPressStart: (details) async { + void saveSetting(bool shuffleAll) => settings.save(playerShuffleAllTracks: shuffleAll); + await showMenu( + context: context, + position: RelativeRect.fromLTRB( + details.globalPosition.dx, + details.globalPosition.dy - kQueueBottomRowHeight - (tileHeight + tileVPadding * 2) * 2, + details.globalPosition.dx, + details.globalPosition.dy, + ), + items: [ + ...[ + ( + lang.SHUFFLE_NEXT, + Broken.forward, + false, + ), + ( + lang.SHUFFLE_ALL, + Broken.task, + true, + ), + ].map( + (e) => PopupMenuItem( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: tileVPadding), + child: Obx( + () => SizedBox( + height: tileHeight, + child: ListTileWithCheckMark( + active: settings.playerShuffleAllTracks.value == e.$3, + leading: StackedIcon( + baseIcon: Broken.shuffle, + secondaryIcon: e.$2, + blurRadius: 8.0, + ), + title: e.$1, + onTap: () => saveSetting(e.$3), + ), + ), + ), + ), + ), + ), + ], + ); + }, + child: NamidaButton( + text: lang.SHUFFLE, + icon: Broken.shuffle, + onPressed: () => Player.inst.shuffleTracks(settings.playerShuffleAllTracks.value), + ), + ), + const SizedBox(width: 8.0), + ], + ); + } +} diff --git a/lib/youtube/controller/youtube_controller.dart b/lib/youtube/controller/youtube_controller.dart index 1a8a1dd5..7e48c5fe 100644 --- a/lib/youtube/controller/youtube_controller.dart +++ b/lib/youtube/controller/youtube_controller.dart @@ -284,17 +284,22 @@ class YoutubeController { } String? getVideoName(String id, {bool checkFromStorage = false}) { - return _getTemporarelyVideoInfo(id, checkFromStorage: checkFromStorage)?.name ?? - _getBackupVideoInfo(id)?.title ?? + return getTemporarelyStreamInfo(id)?.name ?? + _getBackupVideoInfo(id)?.title ?? // _fetchVideoDetailsFromCacheSync(id, checkFromStorage: checkFromStorage)?.name; } String? getVideoChannelName(String id, {bool checkFromStorage = false}) { - return _getTemporarelyVideoInfo(id, checkFromStorage: checkFromStorage)?.uploaderName ?? - _getBackupVideoInfo(id)?.channel ?? + return getTemporarelyStreamInfo(id)?.uploaderName ?? + _getBackupVideoInfo(id)?.channel ?? // _fetchVideoDetailsFromCacheSync(id, checkFromStorage: checkFromStorage)?.uploaderName; } + DateTime? getVideoReleaseDate(String id, {bool checkFromStorage = false}) { + return getTemporarelyStreamInfo(id)?.date ?? // + _fetchVideoDetailsFromCacheSync(id, checkFromStorage: checkFromStorage)?.date; + } + VideoInfo? _getTemporarelyVideoInfo(String id, {bool checkFromStorage = false}) { final r = getTemporarelyStreamInfo(id, checkFromStorage: checkFromStorage); return r == null ? null : VideoInfo.fromStreamInfoItem(r); @@ -390,7 +395,7 @@ class YoutubeController { } Future fetchRelatedVideos(String id) async { - currentRelatedVideos.value = List.filled(20, null); + currentRelatedVideos.value = List.filled(20, null, growable: true); final items = await NewPipeExtractorDart.videos.getRelatedStreams(id.toYTUrl()); _fillTempVideoInfoMap(items.whereType()); items.loop((p, index) { diff --git a/lib/youtube/controller/youtube_local_search_controller.dart b/lib/youtube/controller/youtube_local_search_controller.dart index 607042db..51889b01 100644 --- a/lib/youtube/controller/youtube_local_search_controller.dart +++ b/lib/youtube/controller/youtube_local_search_controller.dart @@ -23,6 +23,7 @@ class YTLocalSearchController { YTLocalSearchController._internal(); final isLoadingLookupLists = false.obs; + Completer? fillingCompleter; bool enableFuzzySearch = true; @@ -62,11 +63,11 @@ class YTLocalSearchController { Future initializeLookupMap({required final void Function() onSearchDone}) async { isLoadingLookupLists.value = true; - final fillingCompleter = Completer(); + fillingCompleter = Completer(); await _YTLocalSearchPortsProvider.inst.preparePorts( onResult: (result) { if (result is bool) { - fillingCompleter.complete(); + fillingCompleter.completeIfWasnt(); return; } result as List; @@ -86,7 +87,7 @@ class YTLocalSearchController { await Isolate.spawn(_prepareResourcesAndSearch, params); }, ); - await fillingCompleter.future; + await fillingCompleter?.future; isLoadingLookupLists.value = false; } @@ -309,6 +310,8 @@ class YTLocalSearchController { } void cleanResources() { + fillingCompleter.completeIfWasnt(); + fillingCompleter = null; _YTLocalSearchPortsProvider.inst.closePorts(); searchResults.clear(); scrollController?.dispose(); diff --git a/lib/youtube/controller/yt_generators_controller.dart b/lib/youtube/controller/yt_generators_controller.dart new file mode 100644 index 00000000..f0eadd25 --- /dev/null +++ b/lib/youtube/controller/yt_generators_controller.dart @@ -0,0 +1,278 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:history_manager/history_manager.dart'; +import 'package:newpipeextractor_dart/newpipeextractor_dart.dart'; +import 'package:newpipeextractor_dart/utils/stringChecker.dart'; + +import 'package:namida/class/video.dart'; +import 'package:namida/controller/generators_controller.dart'; +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/controller/search_ports_provider.dart'; +import 'package:namida/core/constants.dart'; +import 'package:namida/core/extensions.dart'; +import 'package:namida/youtube/class/youtube_id.dart'; +import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/youtube_playlist_controller.dart'; + +class NamidaYTGenerator extends NamidaGeneratorBase with PortsProvider { + static final NamidaYTGenerator inst = NamidaYTGenerator._internal(); + NamidaYTGenerator._internal(); + + late final isPreparingResources = false.obs; + Completer? fillingCompleter; + + late final _operationsCompleter = <_GenerateOperation, Completer>>{}; + + @override + HistoryManager get historyController => YoutubeHistoryController.inst; + + Iterable generateRecommendedVideos(YoutubeID video) { + final strings = super.generateRecommendedItemsFor(video.id, (current) => current.id); + return strings.map((e) => YoutubeID(id: e, playlistID: null)); + } + + Future> getRandomVideos({String? exclude, int? min, int? max}) async { + const type = _GenerateOperation.randomItems; + final p = {'type': type, 'exclude': exclude, 'min': min, 'max': max}; + final ids = await _onOperationExecution(type: type, parameters: p); + return ids.map((e) => YoutubeID(id: e, playlistID: null)); + } + + Future> generateVideoFromSameEra(String videoId, {int daysRange = 30, String? videoToRemove}) async { + const type = _GenerateOperation.sameReleaseDate; + final date = YoutubeController.inst.getVideoReleaseDate(videoId, checkFromStorage: true) ?? + await NewPipeExtractorDart.videos.getInfo('https://www.youtube.com/watch?v=$videoId').then((value) => value?.date); + final p = {'type': type, 'id': videoId, 'date': date, 'daysRange': daysRange, 'videoToRemove': videoToRemove}; + final ids = await _onOperationExecution(type: type, parameters: p); + return ids.map((e) => YoutubeID(id: e, playlistID: null)); + } + + Future> _onOperationExecution({required _GenerateOperation type, required Map parameters}) async { + _operationsCompleter[type]?.completeIfWasnt([]); + _operationsCompleter[type] = Completer(); + + (await _portComm?.search.future)?.send(parameters); + + return await _operationsCompleter[type]?.future ?? []; + } + + PortsComm? _portComm; + + void cleanResources() async { + fillingCompleter.completeIfWasnt(); + fillingCompleter = null; + if (_portComm != null) { + await disposePort(_portComm!); + _portComm = null; + } + isPreparingResources.value = false; + } + + Future prepareResources() async { + if (isPreparingResources.value || fillingCompleter?.isCompleted == true) return; + + isPreparingResources.value = true; + fillingCompleter = Completer(); + await preparePortRaw( + portN: _portComm, + onPortNull: () async { + if (_portComm != null) await disposePort(_portComm!); + _portComm = (items: ReceivePort(), search: Completer()); + return _portComm!; + }, + onResult: (result) { + if (result is bool) { + fillingCompleter.completeIfWasnt(); + return; + } + if (result is Exception) { + snackyy(message: result.toString(), isError: true); + return; + } + result as Map; + final type = result['type'] as _GenerateOperation; + final videos = result['videos'] as Iterable; + _operationsCompleter[type]?.completeIfWasnt(videos); + }, + isolateFunction: (itemsSendPort) async { + final playlists = {for (final pl in YoutubePlaylistController.inst.playlistsMap.values) pl.name: pl.tracks}; + final params = { + 'tempStreamInfo': YoutubeController.inst.tempVideoInfosFromStreams, + 'dirStreamInfo': AppDirs.YT_METADATA_TEMP, + 'dirVideoInfo': AppDirs.YT_METADATA, + 'tempBackupYTVH': YoutubeController.inst.tempBackupVideoInfo, + 'mostplayedPlaylist': YoutubeHistoryController.inst.topTracksMapListens.keys, + 'favouritesPlaylist': YoutubePlaylistController.inst.favouritesPlaylist.value.tracks, + 'playlists': playlists, + 'sendPort': itemsSendPort, + 'token': RootIsolateToken.instance!, + }; + await Isolate.spawn(_prepareResourcesAndListen, params); + }, + ); + await fillingCompleter?.future; + isPreparingResources.value = false; + } + + static void _prepareResourcesAndListen(Map params) async { + final tempStreamInfo = params['tempStreamInfo'] as Map; + final dirStreamInfo = params['dirStreamInfo'] as String; + final dirVideoInfo = params['dirVideoInfo'] as String; + final tempBackupYTVH = params['tempBackupYTVH'] as Map; + final mostplayedPlaylist = params['mostplayedPlaylist'] as Iterable; + final favouritesPlaylist = params['favouritesPlaylist'] as List; + final playlists = params['playlists'] as Map>; + final sendPort = params['sendPort'] as SendPort; + final token = params['token'] as RootIsolateToken; + final recievePort = ReceivePort(); + + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + + sendPort.send(recievePort.sendPort); + + final releaseDateMap = {}; + final allIds = []; + final allIdsAdded = {}; + + // -- start listening + recievePort.listen((p) async { + if (p is String && p == 'dispose') { + recievePort.close(); + releaseDateMap.clear(); + allIds.clear(); + allIdsAdded.clear(); + return; + } + p as Map; + final type = p['type'] as _GenerateOperation; + + switch (type) { + case _GenerateOperation.sameReleaseDate: + final id = p['id'] as String; + final date = p['date'] as DateTime?; + final dateReleased = date ?? releaseDateMap[id]; + if (dateReleased == null) { + sendPort.send(Exception('Unknown video release date')); + return; + } + final results = []; + final daysRange = p['daysRange'] as int; + final videoToRemove = p['videoToRemove'] as String?; + allIds.loop((id, _) { + final dt = releaseDateMap[id]; + if (dt != null && (dt.difference(dateReleased).inDays).abs() <= daysRange) { + results.add(id); + } + }); + if (videoToRemove != null) results.remove(videoToRemove); + sendPort.send({'videos': results, 'type': type}); + break; + + case _GenerateOperation.randomItems: + final exclude = p['exclude'] as String?; + final min = p['min'] as int?; + final max = p['max'] as int?; + final randomItems = NamidaGeneratorBase.getRandomItems(allIds, exclude: exclude, min: min, max: max); + sendPort.send({'videos': randomItems, 'type': type}); + break; + } + }); + // -- end listening + + // -- start filling info + final start = DateTime.now(); + + for (final id in tempStreamInfo.keys) { + allIds.add(id); + allIdsAdded[id] = true; + releaseDateMap[id] = tempStreamInfo[id]?.date; + } + + final completer1 = Completer(); + final completer2 = Completer(); + + Directory(dirStreamInfo).listAllIsolate().then((value) { + value.loop((file, _) { + try { + final res = (file as File).readAsJsonSync(); + if (res != null) { + final id = res['id']; + if (id != null && releaseDateMap[id] == null) { + allIds.add(id); + allIdsAdded[id] = true; + releaseDateMap[id] = (res['date'] as String?)?.getDateTimeFromMSSEString(); + } + } + } catch (_) {} + }); + completer1.complete(); + }); + Directory(dirVideoInfo).listAllIsolate().then((value) { + value.loop((file, _) { + try { + final res = (file as File).readAsJsonSync(); + if (res != null) { + final id = res['id']; + if (id != null && releaseDateMap[id] == null) { + allIds.add(id); + allIdsAdded[id] = true; + releaseDateMap[id] = (res['date'] as String?)?.getDateTimeFromMSSEString(); + } + } + } catch (_) {} + }); + completer2.complete(); + }); + + await completer1.future; + await completer2.future; + + for (final id in tempBackupYTVH.keys) { + if (releaseDateMap[id] == null) { + allIds.add(id); + allIdsAdded[id] = true; + // releaseDateMap[id] = null; + } + } + // -- filling from playlists + for (final id in mostplayedPlaylist) { + if (allIdsAdded[id] == null) { + allIds.add(id); + } + } + favouritesPlaylist.loop((v, _) { + final id = v.id; + if (allIdsAdded[id] == null) { + allIds.add(id); + } + }); + + for (final pl in playlists.values) { + pl.loop((v, index) { + final id = v.id; + if (allIdsAdded[id] == null) { + allIds.add(id); + } + }); + } + // -- end filling from playlists + sendPort.send(true); // finished filling + + final durationTaken = start.difference(DateTime.now()); + printo('Initialized 4 Lists in $durationTaken'); + printo('''NamidaYTGenerators: ids from cached data ${allIds.length} + ids from playlists ${allIds.length} + '''); + // -- end filling info + } +} + +enum _GenerateOperation { + randomItems, + sameReleaseDate, +} diff --git a/lib/youtube/widgets/yt_queue_chip.dart b/lib/youtube/widgets/yt_queue_chip.dart index 2b4290d3..cc57818f 100644 --- a/lib/youtube/widgets/yt_queue_chip.dart +++ b/lib/youtube/widgets/yt_queue_chip.dart @@ -7,12 +7,16 @@ import 'package:namida/controller/player_controller.dart'; import 'package:namida/core/dimensions.dart'; import 'package:namida/core/enums.dart'; import 'package:namida/core/extensions.dart'; +import 'package:namida/core/functions.dart'; import 'package:namida/core/icon_fonts/broken_icons.dart'; import 'package:namida/core/namida_converter_ext.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/packages/scroll_physics_modified.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; +import 'package:namida/ui/widgets/settings/extra_settings.dart'; import 'package:namida/youtube/controller/youtube_controller.dart'; +import 'package:namida/youtube/controller/youtube_history_controller.dart'; +import 'package:namida/youtube/controller/yt_generators_controller.dart'; import 'package:namida/youtube/functions/add_to_playlist_sheet.dart'; import 'package:namida/youtube/pages/yt_playlist_download_subpage.dart'; import 'package:namida/youtube/widgets/yt_history_video_card.dart'; @@ -108,6 +112,7 @@ class YTMiniplayerQueueChipState extends State with Ticke void _animateBigToSmall() { _animate(0, 1); YoutubeController.inst.startDimTimer(); + NamidaYTGenerator.inst.cleanResources(); NamidaNavigator.inst.isQueueSheetOpen = false; } @@ -190,6 +195,12 @@ class YTMiniplayerQueueChipState extends State with Ticke child: Row( mainAxisSize: MainAxisSize.min, children: [ + Icon( + Broken.airdrop, + size: 24.0, + color: context.theme.iconTheme.color?.withOpacity(0.65), + ), + const SizedBox(width: 6.0), Expanded( child: Obx( () { @@ -207,7 +218,7 @@ class YTMiniplayerQueueChipState extends State with Ticke "${Player.inst.currentIndex + 1}/${Player.inst.currentQueueYoutube.length}", style: context.textTheme.displaySmall?.copyWith(fontWeight: FontWeight.w600), ), - const SizedBox(height: 2.0), + // const SizedBox(height: 2.0), if (nextItemName != null && nextItemName != '') Text( "${lang.NEXT}: $nextItemName", @@ -221,7 +232,7 @@ class YTMiniplayerQueueChipState extends State with Ticke ), ), const SizedBox(width: 6.0), - const Icon(Broken.arrow_up_3) + const Icon(Broken.arrow_up_3, size: 22.0) ], ), ), @@ -312,14 +323,6 @@ class YTMiniplayerQueueChipState extends State with Ticke }, ), const SizedBox(width: 6.0), - _ActionItem( - tooltip: lang.SHUFFLE, - icon: Broken.shuffle, - onTap: () { - Player.inst.playOrPause(0, Player.inst.currentQueueYoutube, QueueSource.others, shuffle: true); - }, - ), - const SizedBox(width: 6.0), _ActionItem( icon: Broken.import, tooltip: lang.DOWNLOAD, @@ -333,14 +336,6 @@ class YTMiniplayerQueueChipState extends State with Ticke ); }, ), - const SizedBox(width: 6.0), - Obx( - () => _ActionItem( - tooltip: '', - icon: _arrowIcon.value, - onTap: _animateQueueToCurrentTrack, - ), - ), const SizedBox(width: 4.0), NamidaIconButton( iconColor: context.defaultIconColor().withOpacity(0.95), @@ -405,6 +400,28 @@ class YTMiniplayerQueueChipState extends State with Ticke }, ), ), + ColoredBox( + color: context.theme.scaffoldBackgroundColor, + child: SizedBox( + width: context.width, + height: kQueueBottomRowHeight + MediaQuery.paddingOf(context).bottom, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: FittedBox( + child: QueueUtilsRow( + itemsKeyword: (number) => number.displayVideoKeyword, + onAddItemsTap: () => _onAddVideoTap(), + scrollQueueWidget: Obx( + () => NamidaButton( + onPressed: _animateQueueToCurrentTrack, + icon: _arrowIcon.value, + ), + ), + ), + ), + ), + ), + ), ], ), ), @@ -432,6 +449,123 @@ class YTMiniplayerQueueChipState extends State with Ticke ], ); } + + void _onAddVideoTap() { + final currentVideo = Player.inst.nowPlayingVideoID; + if (currentVideo == null) return; + final currentVideoId = currentVideo.id; + final currentVideoName = YoutubeController.inst.getVideoName(currentVideoId) ?? currentVideoId; + + NamidaYTGenerator.inst.prepareResources(); + showAddItemsToQueueDialog( + context: context, + tiles: (getAddTracksTile) { + return [ + Obx( + () { + final isLoading = NamidaYTGenerator.inst.isPreparingResources.value; + return AnimatedEnabled( + enabled: !isLoading, + child: getAddTracksTile( + title: lang.NEW_TRACKS_RANDOM, + subtitle: lang.NEW_TRACKS_RANDOM_SUBTITLE, + icon: Broken.format_circle, + insertionType: QueueInsertionType.random, + onTap: (insertionType) async { + final config = insertionType.toQueueInsertion(); + final count = config.numberOfTracks; + final rt = await NamidaYTGenerator.inst.getRandomVideos(exclude: currentVideoId, min: count - 1, max: count); + Player.inst.addToQueue(rt, insertionType: insertionType, emptyTracksMessage: lang.NO_ENOUGH_TRACKS).closeDialog(); + }, + trailingRaw: isLoading ? const LoadingIndicator() : null, + ), + ); + }, + ), + getAddTracksTile( + title: lang.GENERATE_FROM_DATES, + subtitle: lang.GENERATE_FROM_DATES_SUBTITLE, + icon: Broken.calendar, + insertionType: QueueInsertionType.listenTimeRange, + onTap: (insertionType) { + NamidaNavigator.inst.closeDialog(); + final historyTracks = YoutubeHistoryController.inst.historyTracks; + if (historyTracks.isEmpty) { + snackyy(title: lang.NOTE, message: lang.NO_TRACKS_IN_HISTORY); + return; + } + showCalendarDialog( + title: lang.GENERATE_FROM_DATES, + buttonText: lang.GENERATE, + useHistoryDates: true, + historyController: YoutubeHistoryController.inst, + onGenerate: (dates) { + final videos = NamidaYTGenerator.inst.generateItemsFromHistoryDates(dates.firstOrNull, dates.lastOrNull); + Player.inst + .addToQueue( + videos, + insertionType: insertionType, + emptyTracksMessage: lang.NO_TRACKS_FOUND_BETWEEN_DATES, + ) + .closeDialog(); + }, + ); + }, + ), + const NamidaContainerDivider(margin: EdgeInsets.symmetric(vertical: 4.0)), + Obx( + () { + final isLoading = NamidaYTGenerator.inst.isPreparingResources.value; + return AnimatedEnabled( + enabled: !isLoading, + child: getAddTracksTile( + title: lang.NEW_TRACKS_SIMILARR_RELEASE_DATE, + subtitle: lang.NEW_TRACKS_SIMILARR_RELEASE_DATE_SUBTITLE.replaceFirst( + '_CURRENT_TRACK_', + currentVideoName.addDQuotation(), + ), + icon: Broken.calendar_1, + insertionType: QueueInsertionType.sameReleaseDate, + onTap: (insertionType) async { + final videos = await NamidaYTGenerator.inst.generateVideoFromSameEra(currentVideoId, videoToRemove: currentVideoId); + Player.inst + .addToQueue( + videos, + insertionType: insertionType, + emptyTracksMessage: lang.NO_TRACKS_FOUND_BETWEEN_DATES, + ) + .closeDialog(); + }, + trailingRaw: isLoading ? const LoadingIndicator() : null, + ), + ); + }, + ), + getAddTracksTile( + title: lang.NEW_TRACKS_RECOMMENDED, + subtitle: lang.NEW_TRACKS_RECOMMENDED_SUBTITLE.replaceFirst( + '_CURRENT_TRACK_', + currentVideoName.addDQuotation(), + ), + icon: Broken.bezier, + insertionType: QueueInsertionType.algorithm, + onTap: (insertionType) { + final genvideos = NamidaYTGenerator.inst.generateRecommendedVideos(currentVideo); + + Player.inst + .addToQueue( + genvideos, + insertionType: insertionType, + insertNext: true, + emptyTracksMessage: lang.NO_TRACKS_IN_HISTORY, + ) + .closeDialog(); + }, + ), + ]; + }, + ); + } } class _ActionItem extends StatelessWidget { diff --git a/lib/youtube/yt_utils.dart b/lib/youtube/yt_utils.dart index e39216ce..12dbff31 100644 --- a/lib/youtube/yt_utils.dart +++ b/lib/youtube/yt_utils.dart @@ -186,14 +186,14 @@ class YTUtils { icon: Broken.next, title: lang.PLAY_NEXT, onTap: () { - Player.inst.addToQueue([YoutubeID(id: videoId, playlistID: playlistID)], insertNext: true); + Player.inst.addToQueue([YoutubeID(id: videoId, playlistID: playlistID)], insertNext: true, showSnackBar: false); }, ), NamidaPopupItem( icon: Broken.play_cricle, title: lang.PLAY_LAST, onTap: () { - Player.inst.addToQueue([YoutubeID(id: videoId, playlistID: playlistID)], insertNext: false); + Player.inst.addToQueue([YoutubeID(id: videoId, playlistID: playlistID)], insertNext: false, showSnackBar: false); }, ), if (playlistName != '' && videoYTID != null)