yumi-flutter/lib/services/music/local_music_scanner.dart
2026-06-15 16:21:05 +08:00

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;
}
}
}