import 'dart:convert'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:just_audio/just_audio.dart'; import 'package:on_audio_query/on_audio_query.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:yumi/shared/data_sources/models/sc_music_folder_mode.dart'; import 'package:yumi/shared/data_sources/models/sc_music_mode.dart'; import 'package:yumi/shared/tools/sc_permission_utils.dart'; class LocalMusicScanner { LocalMusicScanner({OnAudioQuery? audioQuery}) : _audioQuery = audioQuery ?? OnAudioQuery(); final OnAudioQuery _audioQuery; Future> scanMp3Folders({ List addedSongs = const [], }) async { if (Platform.isIOS) { return _pickIosMp3Files(addedSongs: addedSongs); } final hasPermission = await SCPermissionUtils.checkAudioPermission(); if (!hasPermission) { return const []; } final List songs; try { songs = await _audioQuery.querySongs( sortType: SongSortType.TITLE, orderType: OrderType.ASC_OR_SMALLER, uriType: UriType.EXTERNAL, ignoreCase: true, ); } catch (_) { return const []; } final addedIds = addedSongs.map((item) => item.id).toSet(); final addedPaths = addedSongs.map((item) => item.localPath).toSet(); final byFolder = >{}; final seen = {}; for (final song in songs) { if (!_isMp3(song)) { continue; } final localPath = _songString(song, "_data").trim(); if (localPath.isEmpty || !_fileExists(localPath)) { continue; } final id = _songString(song, "_id"); final key = id.isNotEmpty ? id : localPath; if (!seen.add(key)) { continue; } final folderPath = p.dirname(localPath); final fileName = _safeFileName(song, localPath); final title = _safeTitle(song, fileName); final model = SCMusicMode( id: key, title: title, artist: _songString(song, "artist"), durationMs: _songInt(song, "duration"), localPath: localPath, contentUri: _songString(song, "_uri"), folderPath: folderPath, fileName: fileName, size: _songInt(song, "_size"), addedAt: DateTime.now().millisecondsSinceEpoch, ); byFolder.putIfAbsent(folderPath, () => []).add(model); } final folders = byFolder.entries.map((entry) { final path = entry.key; final folderSongs = entry.value..sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); final addedCount = folderSongs.where((song) { return addedIds.contains(song.id) || addedPaths.contains(song.localPath); }).length; return SCMusicFolderMode( id: path, name: p.basename(path).isEmpty ? path : p.basename(path), path: path, songs: List.unmodifiable(folderSongs), addedCount: addedCount, ); }).toList() ..sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); return List.unmodifiable(folders); } Future> _pickIosMp3Files({ required List addedSongs, }) async { final FilePickerResult? result; try { result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: const ['mp3'], allowMultiple: true, withData: false, withReadStream: false, ); } catch (_) { return const []; } if (result == null || result.files.isEmpty) { return const []; } final addedIds = addedSongs.map((item) => item.id).toSet(); final addedPaths = addedSongs.map((item) => item.localPath).toSet(); final storageDir = await _iosRoomMusicDirectory(); final player = AudioPlayer(); final songs = []; final seen = {}; try { for (final file in result.files) { final sourcePath = (file.path ?? '').trim(); if (sourcePath.isEmpty || !_isMp3FileName(file.name) || !_fileExists(sourcePath)) { continue; } final sourceId = _firstNonEmpty([ file.identifier, '${file.name}:${file.size}', sourcePath, ]); if (sourceId.isEmpty || !seen.add(sourceId)) { continue; } final storedPath = await _copyIosPickedFile( sourcePath: sourcePath, fileName: file.name, sourceId: sourceId, storageDir: storageDir, ); final durationMs = await _readLocalMp3DurationMs(player, storedPath); final storedFile = File(storedPath); final safeFileName = p.basename(storedPath); songs.add( SCMusicMode( id: sourceId, title: p.basenameWithoutExtension(file.name).trim(), artist: '', durationMs: durationMs, localPath: storedPath, contentUri: '', folderPath: storageDir.path, fileName: safeFileName, size: storedFile.existsSync() ? storedFile.lengthSync() : file.size, addedAt: DateTime.now().millisecondsSinceEpoch, ), ); } } finally { await player.dispose(); } if (songs.isEmpty) { return const []; } songs.sort( (a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()), ); final addedCount = songs.where((song) { return addedIds.contains(song.id) || addedPaths.contains(song.localPath); }).length; return [ SCMusicFolderMode( id: 'ios_file_picker', name: 'Selected Music', path: 'Files', songs: List.unmodifiable(songs), addedCount: addedCount, ), ]; } bool _isMp3(SongModel song) { final extension = _songString( song, "file_extension", ).toLowerCase().replaceFirst(".", ""); if (extension == "mp3") { return true; } return _songString(song, "_display_name").toLowerCase().endsWith(".mp3") || _songString(song, "_data").toLowerCase().endsWith(".mp3"); } bool _isMp3FileName(String fileName) { return fileName.toLowerCase().endsWith('.mp3'); } Future _iosRoomMusicDirectory() async { final supportDir = await getApplicationSupportDirectory(); final directory = Directory(p.join(supportDir.path, 'room_music')); if (!directory.existsSync()) { await directory.create(recursive: true); } return directory; } Future _copyIosPickedFile({ required String sourcePath, required String fileName, required String sourceId, required Directory storageDir, }) async { if (p.isWithin(storageDir.path, sourcePath)) { return sourcePath; } final sourceFile = File(sourcePath); final targetFile = File( p.join(storageDir.path, _iosStoredFileName(fileName, sourceId)), ); if (targetFile.existsSync() && targetFile.lengthSync() == sourceFile.lengthSync()) { return targetFile.path; } await sourceFile.copy(targetFile.path); return targetFile.path; } String _iosStoredFileName(String fileName, String sourceId) { final baseName = p.basenameWithoutExtension(fileName).trim(); final safeBaseName = baseName .replaceAll(RegExp(r'[^A-Za-z0-9._ -]+'), '_') .replaceAll(RegExp(r'\s+'), ' ') .trim(); final token = base64Url.encode(utf8.encode(sourceId)).replaceAll('=', ''); final suffix = token.length > 12 ? token.substring(0, 12) : token; return '${safeBaseName.isEmpty ? 'music' : safeBaseName}_$suffix.mp3'; } Future _readLocalMp3DurationMs(AudioPlayer player, String path) async { try { final duration = await player.setFilePath(path); await player.stop(); return duration?.inMilliseconds ?? 0; } catch (_) { return 0; } } String _firstNonEmpty(List values) { for (final value in values) { final text = value?.trim() ?? ''; if (text.isNotEmpty) { return text; } } return ''; } bool _fileExists(String path) { try { return File(path).existsSync(); } catch (_) { return false; } } String _safeFileName(SongModel song, String localPath) { final displayName = _songString(song, "_display_name").trim(); if (displayName.isNotEmpty) { return displayName; } return p.basename(localPath); } String _safeTitle(SongModel song, String fileName) { final title = _songString(song, "title").trim(); if (title.isNotEmpty) { return title; } final displayNameWOExt = _songString(song, "_display_name_wo_ext").trim(); if (displayNameWOExt.isNotEmpty) { return displayNameWOExt; } return p.basenameWithoutExtension(fileName); } String _songString(SongModel song, String key) { final value = _songValue(song, key); if (value == null) { return ""; } return value.toString(); } int _songInt(SongModel song, String key) { final value = _songValue(song, key); if (value is int) { return value; } if (value is num) { return value.toInt(); } return int.tryParse(value?.toString() ?? "") ?? 0; } Object? _songValue(SongModel song, String key) { try { return song.getMap[key]; } catch (_) { return null; } } }