diff --git a/lib/controller/backup_controller.dart b/lib/controller/backup_controller.dart index 3fffc887..c2934146 100644 --- a/lib/controller/backup_controller.dart +++ b/lib/controller/backup_controller.dart @@ -5,6 +5,7 @@ import 'package:flutter_archive/flutter_archive.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; +import 'package:namida/controller/history_controller.dart'; import 'package:namida/controller/indexer_controller.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/playlist_controller.dart'; @@ -16,6 +17,7 @@ import 'package:namida/core/extensions.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/main.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 BackupController { @@ -235,13 +237,15 @@ class BackupController { QueueController.inst.prepareAllQueuesFile(); - PlaylistController.inst.prepareAllPlaylists(); VideoController.inst.initialize(); + PlaylistController.inst.prepareAllPlaylists(); + HistoryController.inst.prepareHistoryFile(); await PlaylistController.inst.prepareDefaultPlaylistsFile(); // await QueueController.inst.prepareLatestQueue(); YoutubePlaylistController.inst.prepareAllPlaylists(); + YoutubeHistoryController.inst.prepareHistoryFile(); await YoutubePlaylistController.inst.prepareDefaultPlaylistsFile(); YoutubeController.inst.fillBackupInfoMap(); // for history videos info. } diff --git a/lib/controller/edit_delete_controller.dart b/lib/controller/edit_delete_controller.dart index cc51a6e2..74220234 100644 --- a/lib/controller/edit_delete_controller.dart +++ b/lib/controller/edit_delete_controller.dart @@ -83,36 +83,77 @@ class EditDeleteController { } } + Future updateTrackPathInEveryPartOfNamidaBulk(Map oldNewPath) async { + final newtrlist = await Indexer.inst.convertPathToTrack(oldNewPath.values); + if (newtrlist.isEmpty) return; + final oldNewMap = {for (final on in oldNewPath.entries) Track(on.key): Track(on.value)}; + + // -- Player Queue + Player.inst.replaceAllTracksInQueueBulk(oldNewMap); // no need to await + + // -- History + final daysToSave = []; + final allHistory = HistoryController.inst.historyMap.value.entries.toList(); + + final oldNewTrack = oldNewMap; + for (final oldNewTrack in oldNewTrack.entries) { + allHistory.loop((entry, index) { + final day = entry.key; + final trs = entry.value; + trs.replaceWhere( + (e) => e.track == oldNewTrack.key.track, + (old) => TrackWithDate( + dateAdded: old.dateAdded, + track: oldNewTrack.value, + source: old.source, + ), + onMatch: () => daysToSave.add(day), + ); + }); + } + await Future.wait([ + HistoryController.inst.saveHistoryToStorage(daysToSave).then((value) => HistoryController.inst.updateMostPlayedPlaylist()), + QueueController.inst.replaceTrackInAllQueues(oldNewTrack), // -- Queues + PlaylistController.inst.replaceTrackInAllPlaylistsBulk(oldNewTrack), // -- Playlists + ]); + // -- Selected Tracks + if (SelectedTracksController.inst.selectedTracks.isNotEmpty) { + for (final oldNewTrack in oldNewTrack.entries) { + SelectedTracksController.inst.replaceThisTrack(oldNewTrack.key, oldNewTrack.value); + } + } + } + Future updateTrackPathInEveryPartOfNamida(Track oldTrack, String newPath) async { final newtrlist = await Indexer.inst.convertPathToTrack([newPath]); if (newtrlist.isEmpty) return; final newTrack = newtrlist.first; await Future.wait([ - // --- Queues --- - QueueController.inst.replaceTrackInAllQueues(oldTrack, newTrack), - - // --- Player Queue --- - Player.inst.replaceAllTracksInQueue(oldTrack, newTrack), - - // --- Playlists & Favourites--- - PlaylistController.inst.replaceTrackInAllPlaylists(oldTrack, newTrack), - - // --- History--- - HistoryController.inst.replaceAllTracksInsideHistory(oldTrack, newTrack), + QueueController.inst.replaceTrackInAllQueues({oldTrack: newTrack}), // Queues + Player.inst.replaceAllTracksInQueueBulk({oldTrack: newTrack}), // Player Queue + PlaylistController.inst.replaceTrackInAllPlaylists(oldTrack, newTrack), // Playlists & Favourites + HistoryController.inst.replaceAllTracksInsideHistory(oldTrack, newTrack), // History ]); // --- Selected Tracks --- - SelectedTracksController.inst.replaceThisTrack(oldTrack, newTrack); + if (SelectedTracksController.inst.selectedTracks.isNotEmpty) { + SelectedTracksController.inst.replaceThisTrack(oldTrack, newTrack); + } } Future updateDirectoryInEveryPartOfNamida(String oldDir, String newDir, {Iterable? forThesePathsOnly, bool ensureNewFileExists = false}) async { settings.save(directoriesToScan: [newDir]); + final pathSeparator = Platform.pathSeparator; + if (!oldDir.endsWith(pathSeparator)) oldDir += pathSeparator; + if (!newDir.endsWith(pathSeparator)) newDir += pathSeparator; await Future.wait([ PlaylistController.inst.replaceTracksDirectory(oldDir, newDir, forThesePathsOnly: forThesePathsOnly, ensureNewFileExists: ensureNewFileExists), QueueController.inst.replaceTracksDirectoryInQueues(oldDir, newDir, forThesePathsOnly: forThesePathsOnly, ensureNewFileExists: ensureNewFileExists), Player.inst.replaceTracksDirectoryInQueue(oldDir, newDir, forThesePathsOnly: forThesePathsOnly, ensureNewFileExists: ensureNewFileExists), HistoryController.inst.replaceTracksDirectoryInHistory(oldDir, newDir, forThesePathsOnly: forThesePathsOnly, ensureNewFileExists: ensureNewFileExists), ]); - SelectedTracksController.inst.replaceTrackDirectory(oldDir, newDir, forThesePathsOnly: forThesePathsOnly, ensureNewFileExists: ensureNewFileExists); + if (SelectedTracksController.inst.selectedTracks.isNotEmpty) { + SelectedTracksController.inst.replaceTrackDirectory(oldDir, newDir, forThesePathsOnly: forThesePathsOnly, ensureNewFileExists: ensureNewFileExists); + } } } diff --git a/lib/controller/generators_controller.dart b/lib/controller/generators_controller.dart index f12c107e..64c76f57 100644 --- a/lib/controller/generators_controller.dart +++ b/lib/controller/generators_controller.dart @@ -14,7 +14,7 @@ class NamidaGenerator extends NamidaGeneratorBase { @override HistoryManager get historyController => HistoryController.inst; - Set getHighMatcheFilesFromFilename(Iterable files, String filename) { + static Iterable getHighMatcheFilesFromFilename(Iterable files, String filename) { return files.where( (element) { final trackFilename = filename; @@ -26,7 +26,7 @@ class NamidaGenerator extends NamidaGeneratorBase { final matching2 = fileSystemFilenameCleaned.contains(trackTitle.split('(').first) && fileSystemFilenameCleaned.contains(trackArtist); return matching1 || matching2; }, - ).toSet(); + ); } Iterable getRandomTracks({Track? exclude, int? min, int? max}) { diff --git a/lib/controller/indexer_controller.dart b/lib/controller/indexer_controller.dart index f5fa2d1f..0e70c64f 100644 --- a/lib/controller/indexer_controller.dart +++ b/lib/controller/indexer_controller.dart @@ -665,17 +665,43 @@ class Indexer { Future> convertPathToTrack(Iterable tracksPathPre) async { final List finalTracks = []; - final tracksPath = tracksPathPre.toList(); + final tracksToExtract = []; + + final orderLookup = {}; + int index = 0; + for (final path in tracksPathPre) { + final trInLib = path.toTrackOrNull(); + if (trInLib != null) { + finalTracks.add(trInLib); + } else { + tracksToExtract.add(path); + } + orderLookup[path] = index; + index++; + } - await tracksPath.loopFuture((tp, index) async { - final trako = await tp.toTrackExtOrExtract(); - if (trako != null) finalTracks.add(trako.toTrack()); - }); + if (tracksToExtract.isNotEmpty) { + TrackExtended? extractFunction(FAudioModel item) => _convertTagToTrack( + trackPath: item.tags.path, + trackInfo: item, + tryExtractingFromFilename: true, + onMinDurTrigger: () => null, + onMinSizeTrigger: () => null, + onError: (_) => null, + ); + + final stream = await FAudioTaggerController.inst.extractMetadataAsStream(paths: tracksToExtract); + await for (final item in stream) { + finalTracks.add(Track(item.tags.path)); + final trext = extractFunction(item); + if (trext != null) _addTrackToLists(trext, true, item.tags.artwork); + } + } _addTheseTracksToAlbumGenreArtistEtc(finalTracks); await _sortAndSaveTracks(); - finalTracks.sortBy((e) => tracksPath.indexOf(e.path)); + finalTracks.sortBy((e) => orderLookup[e.path] ?? 0); return finalTracks; } diff --git a/lib/controller/player_controller.dart b/lib/controller/player_controller.dart index 2a0e5f19..dadda193 100644 --- a/lib/controller/player_controller.dart +++ b/lib/controller/player_controller.dart @@ -337,6 +337,10 @@ class Player { await _audioHandler.replaceAllItemsInQueue(oldTrack, newTrack); } + Future replaceAllTracksInQueueBulk(Map oldNewTrack) async { + await _audioHandler.replaceAllItemsInQueueBulk(oldNewTrack); + } + Future replaceTracksDirectoryInQueue(String oldDir, String newDir, {Iterable? forThesePathsOnly, bool ensureNewFileExists = false}) async { String getNewPath(String old) => old.replaceFirst(oldDir, newDir); if (currentQueue.isNotEmpty) { diff --git a/lib/controller/playlist_controller.dart b/lib/controller/playlist_controller.dart index b1a555bb..3da694c5 100644 --- a/lib/controller/playlist_controller.dart +++ b/lib/controller/playlist_controller.dart @@ -10,7 +10,6 @@ import 'package:playlist_manager/playlist_manager.dart'; import 'package:namida/class/track.dart'; import 'package:namida/controller/generators_controller.dart'; -import 'package:namida/controller/history_controller.dart'; import 'package:namida/controller/indexer_controller.dart'; import 'package:namida/controller/navigator_controller.dart'; import 'package:namida/controller/search_sort_controller.dart'; @@ -225,6 +224,23 @@ class PlaylistController extends PlaylistManager { ); } + Future replaceTrackInAllPlaylistsBulk(Map oldNewTrack) async { + final fnList = >[]; + for (final entry in oldNewTrack.entries) { + fnList.add( + MapEntry( + (e) => e.track == entry.key, + (old) => TrackWithDate( + dateAdded: old.dateAdded, + track: entry.value, + source: old.source, + ), + ), + ); + } + await replaceTheseTracksInPlaylistsBulk(fnList); + } + /// Returns number of generated tracks. int generateRandomPlaylist() { final rt = NamidaGenerator.inst.getRandomTracks(); @@ -271,6 +287,9 @@ class PlaylistController extends PlaylistManager { return listy; } + final _m3uPlaylistsCompleter = Completer(); + Future get waitForM3UPlaylistsLoad => _m3uPlaylistsCompleter.future; + Future prepareM3UPlaylists({Set forPaths = const {}}) async { final allAvailableDirectories = await Indexer.inst.getAvailableDirectories(strictNoMedia: false); @@ -304,6 +323,7 @@ class PlaylistController extends PlaylistManager { PlaylistController.inst.addNewPlaylist(plName, tracks: trs, m3uPath: m3uPath, creationDate: creationDate); } _pathsM3ULookup = infoMap; + if (!_m3uPlaylistsCompleter.isCompleted) _m3uPlaylistsCompleter.complete(true); } /// saves each track m3u info for writing back @@ -518,12 +538,6 @@ class PlaylistController extends PlaylistManager { return await _prepareFavouritesFile.thready(favouritePlaylistPath); } - @override - Future prepareDefaultPlaylistsFile() async { - HistoryController.inst.prepareHistoryFile(); - await super.prepareDefaultPlaylistsFile(); - } - static Future _prepareFavouritesFile(String path) async { try { final response = File(path).readAsJsonSync(); diff --git a/lib/controller/queue_controller.dart b/lib/controller/queue_controller.dart index 2b5b61a2..8e1511f0 100644 --- a/lib/controller/queue_controller.dart +++ b/lib/controller/queue_controller.dart @@ -155,15 +155,17 @@ class QueueController { } } - Future replaceTrackInAllQueues(Track oldTrack, Track newTrack) async { + Future replaceTrackInAllQueues(Map oldNewTrack) async { final queuesToSave = []; queuesMap.value.entries.toList().loop((entry, index) { final q = entry.value; - q.tracks.replaceItems( - oldTrack, - newTrack, - onMatch: () => queuesToSave.add(q), - ); + for (final e in oldNewTrack.entries) { + q.tracks.replaceItems( + e.key, + e.value, + onMatch: () => queuesToSave.add(q), + ); + } }); for (final q in queuesToSave) { _updateMap(q); diff --git a/lib/core/dimensions.dart b/lib/core/dimensions.dart index 14838ff0..4dad4851 100644 --- a/lib/core/dimensions.dart +++ b/lib/core/dimensions.dart @@ -26,6 +26,7 @@ class Dimensions { route == RouteType.SETTINGS_page || // bcz no search route == RouteType.SETTINGS_subpage || // bcz no search route == RouteType.YOUTUBE_PLAYLIST_DOWNLOAD_SUBPAGE || // bcz has fab + route == RouteType.SUBPAGE_INDEXER_UPDATE_MISSING_TRACKS || // bcz has fab ((fab == FABType.shuffle || fab == FABType.play) && SelectedTracksController.inst.currentAllTracks.isEmpty) || (settings.selectedLibraryTab.value == LibraryTab.tracks && LibraryTab.tracks.isBarVisible == false); return shouldHide; diff --git a/lib/core/enums.dart b/lib/core/enums.dart index 0f5afca4..0ab023c6 100644 --- a/lib/core/enums.dart +++ b/lib/core/enums.dart @@ -199,6 +199,7 @@ enum RouteType { SUBPAGE_historyTracks, SUBPAGE_mostPlayedTracks, SUBPAGE_queueTracks, + SUBPAGE_INDEXER_UPDATE_MISSING_TRACKS, // ----- Subpages ----- SETTINGS_page, diff --git a/lib/core/extensions.dart b/lib/core/extensions.dart index a58bd270..60aa3a33 100644 --- a/lib/core/extensions.dart +++ b/lib/core/extensions.dart @@ -450,7 +450,9 @@ extension ThreadOpener on ComputeCallback { /// Executes function on a separate thread using compute(). /// Must be `static` or `global` function. Future thready(M parameter) async { - WidgetsFlutterBinding.ensureInitialized(); + try { + WidgetsFlutterBinding.ensureInitialized(); + } catch (_) {} return await compute(this, parameter); } } diff --git a/lib/core/namida_converter_ext.dart b/lib/core/namida_converter_ext.dart index 52f2dc52..26d69a11 100644 --- a/lib/core/namida_converter_ext.dart +++ b/lib/core/namida_converter_ext.dart @@ -50,6 +50,7 @@ import 'package:namida/ui/pages/settings_page.dart'; import 'package:namida/ui/pages/subpages/album_tracks_subpage.dart'; import 'package:namida/ui/pages/subpages/artist_tracks_subpage.dart'; import 'package:namida/ui/pages/subpages/genre_tracks_subpage.dart'; +import 'package:namida/ui/pages/subpages/indexer_missing_tracks_subpage.dart'; import 'package:namida/ui/pages/subpages/playlist_tracks_subpage.dart'; import 'package:namida/ui/pages/subpages/queue_tracks_subpage.dart'; import 'package:namida/ui/pages/tracks_page.dart'; @@ -749,6 +750,9 @@ extension WidgetsPagess on Widget { route = RouteType.SUBPAGE_queueTracks; name = (this as QueueTracksPage).queue.date.toString(); break; + case IndexerMissingTracksSubpage: + route = RouteType.SUBPAGE_INDEXER_UPDATE_MISSING_TRACKS; + break; // ----- Search Results ----- case AlbumSearchResultsPage: diff --git a/lib/core/translations/keys.dart b/lib/core/translations/keys.dart index 402773c0..636a0164 100644 --- a/lib/core/translations/keys.dart +++ b/lib/core/translations/keys.dart @@ -358,6 +358,7 @@ abstract class LanguageKeys { String get MINIPLAYER_CUSTOMIZATION => _getKey('MINIPLAYER_CUSTOMIZATION'); String get MINUTES => _getKey('MINUTES'); String get MISSING_ENTRIES => _getKey('MISSING_ENTRIES'); + String get MISSING_TRACKS => _getKey('MISSING_TRACKS'); String get MIXES => _getKey('MIXES'); String get MONTH => _getKey('MONTH'); String get MONTHS => _getKey('MONTHS'); diff --git a/lib/main.dart b/lib/main.dart index 6ab22cd2..a9c07759 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'package:namida/controller/backup_controller.dart'; import 'package:namida/controller/connectivity.dart'; import 'package:namida/controller/current_color.dart'; import 'package:namida/controller/folders_controller.dart'; +import 'package:namida/controller/history_controller.dart'; import 'package:namida/controller/indexer_controller.dart'; import 'package:namida/controller/namida_channel.dart'; import 'package:namida/controller/navigator_controller.dart'; @@ -43,6 +44,7 @@ import 'package:namida/packages/scroll_physics_modified.dart'; import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/video_widget.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'; import 'package:namida/youtube/controller/youtube_subscriptions_controller.dart'; @@ -121,6 +123,8 @@ void main() async { FlutterNativeSplash.remove(); + HistoryController.inst.prepareHistoryFile(); + YoutubeHistoryController.inst.prepareHistoryFile(); await Future.wait([ PlaylistController.inst.prepareDefaultPlaylistsFile(), if (!shouldShowOnBoarding) QueueController.inst.prepareLatestQueue(), diff --git a/lib/ui/dialogs/general_popup_dialog.dart b/lib/ui/dialogs/general_popup_dialog.dart index 8f0e3f7b..45b9bf66 100644 --- a/lib/ui/dialogs/general_popup_dialog.dart +++ b/lib/ui/dialogs/general_popup_dialog.dart @@ -452,7 +452,7 @@ Future showGeneralPopupDialog( final paths = files.mapped((e) => e.path); paths.sortBy((e) => e); - final highMatchesFiles = NamidaGenerator.inst.getHighMatcheFilesFromFilename(paths, tracks.first.path.getFilename); + final highMatchesFiles = NamidaGenerator.getHighMatcheFilesFromFilename(paths, tracks.first.path.getFilename).toSet(); /// Searching final txtc = TextEditingController(); @@ -798,7 +798,7 @@ Future showGeneralPopupDialog( } /// firstly checks if a file exists in current library - final firstHighMatchesFiles = NamidaGenerator.inst.getHighMatcheFilesFromFilename(Indexer.inst.allAudioFiles, tracks.first.path.getFilename); + final firstHighMatchesFiles = NamidaGenerator.getHighMatcheFilesFromFilename(Indexer.inst.allAudioFiles, tracks.first.path.getFilename).toSet(); if (firstHighMatchesFiles.isNotEmpty) { await openDialog( CustomBlurryDialog( diff --git a/lib/ui/dialogs/track_advanced_dialog.dart b/lib/ui/dialogs/track_advanced_dialog.dart index 339d51cb..615c2aac 100644 --- a/lib/ui/dialogs/track_advanced_dialog.dart +++ b/lib/ui/dialogs/track_advanced_dialog.dart @@ -93,7 +93,7 @@ void showTrackAdvancedDialog({ ), // -- Updating directory path option, only for tracks whithin the same parent directory. - if (tracksUniqued.every((element) => element.track.path.startsWith(firstTracksDirectoryPath))) + if (!isSingle && tracksUniqued.every((element) => element.track.path.startsWith(firstTracksDirectoryPath))) UpdateDirectoryPathListTile( colorScheme: colorScheme, oldPath: firstTracksDirectoryPath, diff --git a/lib/ui/pages/subpages/indexer_missing_tracks_subpage.dart b/lib/ui/pages/subpages/indexer_missing_tracks_subpage.dart new file mode 100644 index 00000000..e00a7077 --- /dev/null +++ b/lib/ui/pages/subpages/indexer_missing_tracks_subpage.dart @@ -0,0 +1,506 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; +import 'package:get/get.dart'; + +import 'package:namida/base/pull_to_refresh.dart'; +import 'package:namida/class/track.dart'; +import 'package:namida/controller/current_color.dart'; +import 'package:namida/controller/edit_delete_controller.dart'; +import 'package:namida/controller/generators_controller.dart'; +import 'package:namida/controller/history_controller.dart'; +import 'package:namida/controller/indexer_controller.dart'; +import 'package:namida/controller/navigator_controller.dart'; +import 'package:namida/controller/playlist_controller.dart'; +import 'package:namida/core/dimensions.dart'; +import 'package:namida/core/extensions.dart'; +import 'package:namida/core/icon_fonts/broken_icons.dart'; +import 'package:namida/core/translations/language.dart'; +import 'package:namida/packages/three_arched_circle.dart'; +import 'package:namida/ui/widgets/custom_widgets.dart'; + +class IndexerMissingTracksSubpage extends StatefulWidget { + const IndexerMissingTracksSubpage({super.key}); + + @override + State createState() => _IndexerMissingTracksSubpageState(); +} + +class _IndexerMissingTracksSubpageState extends State with TickerProviderStateMixin, PullToRefreshMixin { + var _missingTracksPaths = []; + final _missingTracksSuggestions = {}.obs; + final _selectedTracksToUpdate = {}.obs; + + bool _isLoading = false; + final _loadingCount = 0.obs; + final _loadingCountTotalSteps = 5; + final _loadingCountLookup = { + 1: 'Preparing files', + 2: 'Collecting playlist tracks', + 3: 'Filling history tracks', + 4: 'Filling library tracks', + 5: 'Filling playlist tracks', + }; + + bool _isUpdatingPaths = false; + + late final ScrollController _scrollController; + + Isolate? _isolate; + ReceivePort? _resultPort; + ReceivePort? _portLoadingProgress; + + @override + void initState() { + _scrollController = ScrollController(); + Future.delayed(Duration.zero, _fetchMissingTracks); + super.initState(); + } + + @override + void dispose() { + _missingTracksSuggestions.close(); + _selectedTracksToUpdate.close(); + _scrollController.dispose(); + _loadingCount.close(); + _stopPreviousIsolates(); + super.dispose(); + } + + void _stopPreviousIsolates() { + try { + _isolate?.kill(priority: Isolate.immediate); + _resultPort?.close(); + _resultPort?.close(); + _isolate = null; + _resultPort = null; + _resultPort = null; + } catch (_) {} + } + + void _resetMaps() { + _missingTracksPaths = []; + _missingTracksSuggestions.value = {}; + _selectedTracksToUpdate.value = {}; + } + + Future _fetchMissingTracks() async { + if (_isLoading) return; + setState(() => _isLoading = true); + _stopPreviousIsolates(); + + _loadingCount.value = 1; + + late final Set audioFiles; + await Future.wait([ + Indexer.inst.getAudioFiles().then((value) => audioFiles = value), + HistoryController.inst.waitForHistoryAndMostPlayedLoad, + PlaylistController.inst.waitForPlaylistsLoad, + PlaylistController.inst.waitForFavouritePlaylistLoad, + PlaylistController.inst.waitForM3UPlaylistsLoad, + ]); + + _loadingCount.value = 2; + + _resultPort = ReceivePort(); + _portLoadingProgress = ReceivePort(); + + try { + StreamSubscription? portLoadingProgressSub; + portLoadingProgressSub = _portLoadingProgress!.listen((stepCount) { + _loadingCount.value = (stepCount as int); + }); + + final indicesProgressMap = {}; + final allTracks = {}; + indicesProgressMap[allTracks.length] = 3; + + // -- history first to be sorted & has no duplicates + for (final track in HistoryController.inst.topTracksMapListens.keys) { + allTracks[track.path] = true; + } + + indicesProgressMap[allTracks.length] = 4; + for (final track in Indexer.inst.allTracksMappedByPath.keys) { + allTracks[track.path] ??= true; + } + + indicesProgressMap[allTracks.length] = 5; + for (final tracks in PlaylistController.inst.playlistsMap.values.map((e) => e.tracks)) { + tracks.loop((e, _) { + allTracks[e.track.path] ??= true; + }); + } + + final params = (audioFiles, allTracks, indicesProgressMap, _portLoadingProgress!.sendPort, _resultPort!.sendPort); + _isolate = await Isolate.spawn(_fetchMissingTracksIsolate, params); + final res = await _resultPort!.first as (List, Map); + + _missingTracksPaths = res.$1; + _missingTracksSuggestions.value = res.$2; + + _portLoadingProgress?.close(); + portLoadingProgressSub.cancel(); + } catch (_) {} + + setState(() => _isLoading = false); + } + + static void _fetchMissingTracksIsolate((Set, Map, Map, SendPort, SendPort) params) { + final allAudioFiles = params.$1; + final allTracks = params.$2; + final indicesProgressMap = params.$3; + final progressPort = params.$4; + + String? getSuggestion(String path) { + final all = NamidaGenerator.getHighMatcheFilesFromFilename(allAudioFiles, path.getFilename); + for (final p in all) { + if (File(p).existsSync()) return p; + } + return null; + } + + final missingTracksPaths = []; + final existingTracksLookup = {}; + final missingTracksSuggestions = {}; + + // ignore: no_leading_underscores_for_local_identifiers + void _onAdd(String path) { + final exists = File(path).existsSync(); + if (!exists) missingTracksPaths.add(path); + existingTracksLookup[path] = exists; + missingTracksSuggestions[path] = getSuggestion(path); + } + + int index = 0; + for (final path in allTracks.keys) { + _onAdd(path); + final progress = indicesProgressMap[index]; + if (progress != null) progressPort.send(progress); + index++; + } + + params.$5.send((missingTracksPaths, missingTracksSuggestions)); + } + + void _pickNewPathFor(String path) async { + final file = await FilePicker.platform.pickFiles().then((value) => value?.files.firstOrNull); + final newContentUri = file?.identifier; + if (newContentUri != null) { + final finalPath = await FlutterSharingIntent.instance.getRealPath(newContentUri) ?? file?.path; + _missingTracksSuggestions[path] = finalPath; + if (finalPath != null) _selectedTracksToUpdate[path] = true; + } + } + + Future _onUpdating() async { + if (_selectedTracksToUpdate.isEmpty) return; + setState(() => _isUpdatingPaths = true); + try { + final newPaths = {}; + for (final e in _selectedTracksToUpdate.entries) { + if (e.value) { + final sugg = _missingTracksSuggestions[e.key]; + if (sugg != null) newPaths[e.key] = sugg; + } + } + await EditDeleteController.inst.updateTrackPathInEveryPartOfNamidaBulk(newPaths); + snackyy(title: lang.NOTE, message: "${lang.DONE}: ${newPaths.length.displayTrackKeyword}", top: false); + } catch (e) { + snackyy(title: lang.ERROR, message: '$e', top: false, isError: true); + } + + for (final k in _selectedTracksToUpdate.keys) { + _missingTracksPaths.remove(k); + _missingTracksSuggestions.remove(k); + } + _selectedTracksToUpdate.clear(); + setState(() => _isUpdatingPaths = false); + } + + @override + Widget build(BuildContext context) { + final textTheme = context.textTheme; + final cardColor = context.theme.cardColor; + final borderColor = context.theme.colorScheme.secondary.withOpacity(0.6); + + return BackgroundWrapper( + child: Stack( + children: [ + _isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + ThreeArchedCircle( + size: 56.0, + color: context.theme.colorScheme.secondary, + ), + Obx( + () => Text( + "${_loadingCount.value}/$_loadingCountTotalSteps", + style: context.textTheme.displayMedium, + textAlign: TextAlign.center, + ), + ), + ], + ), + const SizedBox(height: 12.0), + Obx( + () { + final stepName = _loadingCountLookup[_loadingCount.value] ?? ""; + return Text( + "$stepName...", + style: context.textTheme.displayMedium, + textAlign: TextAlign.center, + ); + }, + ), + ], + ), + ) + : Listener( + onPointerMove: (event) { + onPointerMove(_scrollController, event); + }, + onPointerUp: (event) { + onRefresh(() async { + _resetMaps(); + return await _fetchMissingTracks(); + }); + }, + onPointerCancel: (event) => onVerticalDragFinish(), + child: NamidaScrollbar( + controller: _scrollController, + child: AnimatedEnabled( + enabled: !_isUpdatingPaths, + child: ListView.builder( + controller: _scrollController, + padding: EdgeInsets.only(bottom: Dimensions.inst.globalBottomPaddingEffective + 56.0 + 4.0), + itemCount: _missingTracksPaths.length, + itemBuilder: (context, index) { + final path = _missingTracksPaths[index]; + return Padding( + padding: const EdgeInsets.all(3.0), + child: Obx( + () { + final suggestion = _missingTracksSuggestions[path]; + final isSelected = _selectedTracksToUpdate[path] == true; + final leftColor = context.theme.colorScheme.secondary.withOpacity(0.3); + return NamidaInkWell( + animationDurationMS: 300, + borderRadius: 12.0, + bgColor: cardColor, + decoration: BoxDecoration( + border: isSelected + ? Border.all( + width: 1.5, + color: borderColor, + ) + : null, + ), + onTap: () { + if (_missingTracksSuggestions[path] == null) return; + final wasUpadting = (_selectedTracksToUpdate[path] ?? false); + if (wasUpadting) { + _selectedTracksToUpdate.remove(path); + } else { + _selectedTracksToUpdate[path] = true; + } + }, + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + children: [ + const SizedBox(width: 12.0), + DecoratedBox( + decoration: BoxDecoration( + color: leftColor, + borderRadius: BorderRadius.circular(6.0.multipliedRadius), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3.0, vertical: 2.0), + child: Text( + '${HistoryController.inst.topTracksMapListens[Track(path)]?.length ?? 0}', + style: textTheme.displaySmall, + ), + ), + ), + const SizedBox(width: 12.0), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + path, + style: textTheme.displayMedium, + ), + if (suggestion != null) + Text( + " --> $suggestion", + style: textTheme.displaySmall, + ) + ], + ), + ), + const SizedBox(width: 12.0), + Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + _missingTracksSuggestions[path] == null + ? const SizedBox() + : NamidaCheckMark( + size: 18.0, + active: isSelected, + ), + const SizedBox(width: 8.0), + NamidaIconButton( + horizontalPadding: 0.0, + icon: Broken.repeat_circle, + onPressed: () => _pickNewPathFor(path), + ), + ], + ), + const SizedBox(width: 12.0), + ], + ), + ), + Positioned( + top: 0, + right: 0, + child: NamidaBlurryContainer( + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(6.0.multipliedRadius)), + padding: const EdgeInsets.only(top: 2.0, right: 8.0, left: 6.0, bottom: 2.0), + child: Text( + "${index + 1}", + style: context.textTheme.displaySmall, + ), + ), + ) + ], + ), + ); + }, + ), + ); + }, + ), + ), + ), + ), + pullToRefreshWidget, + Positioned( + bottom: Dimensions.inst.globalBottomPaddingTotal, + right: 12.0, + child: _isUpdatingPaths + ? DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0.multipliedRadius), + color: context.theme.cardColor, + boxShadow: [ + BoxShadow( + color: context.theme.shadowColor, + blurRadius: 6.0, + offset: const Offset(0, 4.0), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: ThreeArchedCircle( + size: 36.0, + color: context.theme.colorScheme.secondary, + ), + ), + ) + : Row( + children: [ + Obx( + () { + final allSelected = + _selectedTracksToUpdate.isNotEmpty && _missingTracksPaths.every((e) => _missingTracksSuggestions[e] == null || _selectedTracksToUpdate[e] == true); + return FloatingActionButton.small( + tooltip: lang.SELECT_ALL, + backgroundColor: allSelected ? CurrentColor.inst.color.withOpacity(1.0) : context.theme.disabledColor.withOpacity(1.0), + child: Icon( + allSelected ? Broken.tick_square : Broken.task_square, + color: Colors.white.withOpacity(0.8), + ), + onPressed: () { + final allSelected = _selectedTracksToUpdate.isNotEmpty && + _missingTracksPaths.every((e) => _missingTracksSuggestions[e] == null || _selectedTracksToUpdate[e] == true); + if (allSelected) { + _selectedTracksToUpdate.clear(); + } else { + _missingTracksPaths.loop((e, index) { + if (_missingTracksSuggestions[e] != null) _selectedTracksToUpdate[e] = true; + }); + } + }, + ); + }, + ), + const SizedBox(width: 8.0), + Obx( + () { + final totalLength = _selectedTracksToUpdate.length; + return FloatingActionButton.extended( + backgroundColor: (totalLength <= 0 ? context.theme.disabledColor : CurrentColor.inst.color).withOpacity(1.0), + extendedPadding: const EdgeInsets.symmetric(horizontal: 12.0), + onPressed: () async { + NamidaNavigator.inst.navigateDialog( + dialog: CustomBlurryDialog( + isWarning: true, + normalTitleStyle: true, + bodyText: "${lang.UPDATE} ${_selectedTracksToUpdate.length.displayTrackKeyword}?", + actions: [ + const CancelButton(), + const SizedBox(width: 8.0), + NamidaButton( + text: lang.UPDATE.toUpperCase(), + onPressed: () async { + NamidaNavigator.inst.closeDialog(); + _onUpdating(); + }, + ) + ], + ), + ); + }, + label: Row(children: [ + Icon( + Broken.pen_add, + size: 20.0, + color: Colors.white.withOpacity(0.8), + ), + const SizedBox(width: 12.0), + Text( + "${lang.UPDATE} ($totalLength)", + style: context.textTheme.displayMedium?.copyWith( + color: Colors.white.withOpacity(0.8), + ), + ), + ]), + ); + }, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/widgets/settings/indexer_settings.dart b/lib/ui/widgets/settings/indexer_settings.dart index a78c135a..cbb9bbab 100644 --- a/lib/ui/widgets/settings/indexer_settings.dart +++ b/lib/ui/widgets/settings/indexer_settings.dart @@ -1,8 +1,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; - import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:namida/base/setting_subpage_provider.dart'; @@ -17,8 +16,9 @@ import 'package:namida/core/extensions.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/ui/widgets/custom_widgets.dart'; +import 'package:namida/ui/pages/subpages/indexer_missing_tracks_subpage.dart'; import 'package:namida/ui/widgets/circular_percentages.dart'; +import 'package:namida/ui/widgets/custom_widgets.dart'; import 'package:namida/ui/widgets/settings/extra_settings.dart'; import 'package:namida/ui/widgets/settings_card.dart'; @@ -34,6 +34,7 @@ enum _IndexerSettingsKeys { minimumTrackDur, useMediaStore, refreshOnStartup, + missingTracks, reindex, refreshLibrary, foldersToScan, @@ -59,6 +60,7 @@ class IndexerSettings extends SettingSubpageProvider { _IndexerSettingsKeys.minimumTrackDur: [lang.MIN_FILE_DURATION], _IndexerSettingsKeys.useMediaStore: [lang.USE_MEDIA_STORE, lang.USE_MEDIA_STORE_SUBTITLE], _IndexerSettingsKeys.refreshOnStartup: [lang.REFRESH_ON_STARTUP], + _IndexerSettingsKeys.missingTracks: [lang.MISSING_TRACKS], _IndexerSettingsKeys.reindex: [lang.RE_INDEX, lang.RE_INDEX_SUBTITLE], _IndexerSettingsKeys.refreshLibrary: [lang.REFRESH_LIBRARY, lang.REFRESH_LIBRARY_SUBTITLE], _IndexerSettingsKeys.foldersToScan: [lang.LIST_OF_FOLDERS], @@ -572,6 +574,16 @@ class IndexerSettings extends SettingSubpageProvider { ), ), ), + getItemWrapper( + key: _IndexerSettingsKeys.missingTracks, + child: CustomListTile( + bgColor: getBgColor(_IndexerSettingsKeys.missingTracks), + icon: Broken.location_cross, + title: lang.MISSING_TRACKS, + trailing: const Icon(Broken.arrow_right_3), + onTap: () => NamidaNavigator.inst.navigateTo(const IndexerMissingTracksSubpage()), + ), + ), getItemWrapper( key: _IndexerSettingsKeys.reindex, child: CustomListTile( diff --git a/lib/youtube/controller/youtube_playlist_controller.dart b/lib/youtube/controller/youtube_playlist_controller.dart index 2771b2d2..1e1b9831 100644 --- a/lib/youtube/controller/youtube_playlist_controller.dart +++ b/lib/youtube/controller/youtube_playlist_controller.dart @@ -14,7 +14,6 @@ import 'package:namida/core/enums.dart'; import 'package:namida/core/extensions.dart'; import 'package:namida/core/translations/language.dart'; import 'package:namida/youtube/class/youtube_id.dart'; -import 'package:namida/youtube/controller/youtube_history_controller.dart'; typedef YoutubePlaylist = GeneralPlaylist; @@ -130,12 +129,6 @@ class YoutubePlaylistController extends PlaylistManager { Future prepareAllPlaylists() async => await super.prepareAllPlaylistsFile(); - @override - Future prepareDefaultPlaylistsFile() async { - YoutubeHistoryController.inst.prepareHistoryFile(); - await super.prepareDefaultPlaylistsFile(); - } - @override String get EMPTY_NAME => lang.PLEASE_ENTER_A_NAME; diff --git a/pubspec.yaml b/pubspec.yaml index 3c37286a..3c778ff8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,12 +17,16 @@ dependency_overrides: git: url: https://github.com/MSOB7YY/just_audio path: just_audio/ - ref: d87b70991f1a7973aeb5224c22a9784e5d3a2da6 + ref: 267e146029eeee4c3376d41f75d6da0ae4ad5960 just_audio_platform_interface: git: url: https://github.com/MSOB7YY/just_audio path: just_audio_platform_interface/ - ref: d87b70991f1a7973aeb5224c22a9784e5d3a2da6 + ref: 267e146029eeee4c3376d41f75d6da0ae4ad5960 + queue_manager: + git: + url: https://github.com/namidaco/queue_manager + ref: f279d07854f5dc701245c2ef31b2c4eb5bed7495 archive: ^3.3.8 intl: ^0.18.0 device_info_plus: ^9.0.2 @@ -62,7 +66,7 @@ dependencies: playlist_manager: git: url: https://github.com/namidaco/playlist_manager - ref: 4c22343d4dddce59958f2fb9d3b31ad9cd4bcc0e + ref: 842f31410c98b0699918ee277622a49382784825 history_manager: git: url: https://github.com/namidaco/history_manager @@ -87,7 +91,7 @@ dependencies: git: url: https://github.com/MSOB7YY/just_audio path: just_audio/ - ref: d87b70991f1a7973aeb5224c22a9784e5d3a2da6 + ref: 267e146029eeee4c3376d41f75d6da0ae4ad5960 audio_service: git: url: https://github.com/MSOB7YY/audio_service @@ -97,7 +101,7 @@ dependencies: basic_audio_handler: git: url: https://github.com/namidaco/basic_audio_handler - ref: 8d652d1fa177aed0a5e98cce759b8a9f3c1a2315 + ref: dda60b18745652f72fd1a2ce287be0bf6501a53a on_audio_query: git: url: https://github.com/MSOB7YY/on_audio_query