332 lines
9.7 KiB
Dart
332 lines
9.7 KiB
Dart
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<List<SCMusicFolderMode>> scanMp3Folders({
|
|
List<SCMusicMode> addedSongs = const [],
|
|
}) async {
|
|
if (Platform.isIOS) {
|
|
return _pickIosMp3Files(addedSongs: addedSongs);
|
|
}
|
|
|
|
final hasPermission = await SCPermissionUtils.checkAudioPermission();
|
|
if (!hasPermission) {
|
|
return const <SCMusicFolderMode>[];
|
|
}
|
|
|
|
final List<SongModel> songs;
|
|
try {
|
|
songs = await _audioQuery.querySongs(
|
|
sortType: SongSortType.TITLE,
|
|
orderType: OrderType.ASC_OR_SMALLER,
|
|
uriType: UriType.EXTERNAL,
|
|
ignoreCase: true,
|
|
);
|
|
} catch (_) {
|
|
return const <SCMusicFolderMode>[];
|
|
}
|
|
|
|
final addedIds = addedSongs.map((item) => item.id).toSet();
|
|
final addedPaths = addedSongs.map((item) => item.localPath).toSet();
|
|
final byFolder = <String, List<SCMusicMode>>{};
|
|
final seen = <String>{};
|
|
|
|
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, () => <SCMusicMode>[]).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<SCMusicMode>.unmodifiable(folderSongs),
|
|
addedCount: addedCount,
|
|
);
|
|
}).toList()
|
|
..sort(
|
|
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
|
);
|
|
|
|
return List<SCMusicFolderMode>.unmodifiable(folders);
|
|
}
|
|
|
|
Future<List<SCMusicFolderMode>> _pickIosMp3Files({
|
|
required List<SCMusicMode> 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 <SCMusicFolderMode>[];
|
|
}
|
|
if (result == null || result.files.isEmpty) {
|
|
return const <SCMusicFolderMode>[];
|
|
}
|
|
|
|
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 = <SCMusicMode>[];
|
|
final seen = <String>{};
|
|
|
|
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 <SCMusicFolderMode>[];
|
|
}
|
|
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>[
|
|
SCMusicFolderMode(
|
|
id: 'ios_file_picker',
|
|
name: 'Selected Music',
|
|
path: 'Files',
|
|
songs: List<SCMusicMode>.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<Directory> _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<String> _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<int> _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<String?> 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;
|
|
}
|
|
}
|
|
}
|