Complete rewrite of local_music_fetcher_impl including faster import and multithreading (#130)

* changes in file scanning
- progress indicator
- ManageExternalStorage permission
- fewer database calls

* filter allowed extensions and blocked paths earlier

* seperated fetching songs into 3 different for loops. This is messy for now but needs to be done in order to enable multithreading

* fix lint

* this does not work but I need it on my laptop

* Working multithreaded import

* fixes for multithreaded import

* multithread color generation

* Changes to the getBackgroundColor algorythm and a fun debugging trick that makes the album covers look hard

* final tweaks to color generation & remove debugging code

* remove vscode generated launch.json

* remove old background color code

* small changes and fixes in local_music_fetcher

* add quotes around env var in workflow

* change location for keystore in actions

---------

Co-authored-by: Moritz Weber <moritz.weber@posteo.de>
Co-authored-by: FriederHannenheim <frieder12.fml@pm.me>
This commit is contained in:
Frieder Hannenheim 2023-08-23 17:51:23 +02:00 committed by GitHub
parent 1034a88cb5
commit 65c5506e02
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 460 additions and 196 deletions

View file

@ -26,8 +26,8 @@ jobs:
with:
java-version: '12.x'
distribution: 'zulu'
- run: echo $KEYSTORE_GITHUB | base64 -d > android/github.jks
- run: echo $KEY_PROPERTIES_GITHUB | base64 -d > android/key.properties
- run: echo "$KEYSTORE_GITHUB" | base64 -d > android/app/github.jks
- run: echo "$KEY_PROPERTIES_GITHUB" | base64 -d > android/key.properties
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.10.5'

View file

@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

View file

@ -11,6 +11,8 @@ import '../entities/smart_list.dart';
import '../entities/song.dart';
abstract class MusicDataInfoRepository {
ValueStream<int?> get numFileStream;
ValueStream<int?> get progressStream;
Stream<Map<String, Song>> get songUpdateStream;
Stream<List<String>> get songRemovalStream;

View file

@ -27,9 +27,9 @@ Future<void> main() async {
));
// Fimber.plantTree(DebugTree());
MetadataGod.initialize();
await setupGetIt();
MetadataGod.initialize();
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());

View file

@ -10,6 +10,7 @@ import '../../state/import_store.dart';
import '../../state/music_data_store.dart';
import '../../state/settings_store.dart';
import '../../theming.dart';
import '../../utils.dart';
import '../../widgets/settings_section.dart';
class InitLibPage extends StatelessWidget {
@ -38,21 +39,23 @@ class InitLibPage extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Observer(builder: (_) {
if (musicDataStore.isUpdatingDatabase) {
return const LinearProgressIndicator();
}
return Container();
}),
Observer(builder: (_) {
final int artistCount = musicDataStore.artistStream.value?.length ?? 0;
final int albumCount = musicDataStore.albumStream.value?.length ?? 0;
final int songCount = musicDataStore.songStream.value?.length ?? 0;
return ListTile(
title: Text(L10n.of(context)!.yourLibrary),
subtitle: Text(
L10n.of(context)!.artistsAlbumsSongs(artistCount, albumCount, songCount),
),
subtitle: musicDataStore.isUpdatingDatabase
? LinearProgressIndicator(
value: getProgressOrNull(
musicDataStore.progressStream.value,
musicDataStore.numFileStream.value,
),
backgroundColor: Colors.white10,
)
: Text(
L10n.of(context)!.artistsAlbumsSongs(artistCount, albumCount, songCount),
),
trailing: Observer(
builder: (context) {
final folders = settingsStore.libraryFoldersStream.value;
@ -60,7 +63,7 @@ class InitLibPage extends StatelessWidget {
if (!importStore.scanned)
return ElevatedButton(
child: Text(L10n.of(context)!.scan),
onPressed: isNotActive
onPressed: isNotActive || musicDataStore.isUpdatingDatabase
? null
: () => musicDataStore
.updateDatabase()
@ -68,7 +71,9 @@ class InitLibPage extends StatelessWidget {
);
return OutlinedButton(
child: Text(L10n.of(context)!.scan),
onPressed: isNotActive ? null : () => musicDataStore.updateDatabase(),
onPressed: isNotActive || musicDataStore.isUpdatingDatabase
? null
: () => musicDataStore.updateDatabase(),
);
},
),
@ -238,7 +243,9 @@ class InitLibPage extends StatelessWidget {
final importBlockedFiles = importStore.blockedFiles;
final blockedFiles = settingsStore.blockedFilesStream.value;
if (importBlockedFiles == null || blockedFiles == null || importBlockedFiles.isEmpty) return Container();
if (importBlockedFiles == null ||
blockedFiles == null ||
importBlockedFiles.isEmpty) return Container();
return Column(
mainAxisSize: MainAxisSize.min,

View file

@ -12,6 +12,7 @@ import '../state/music_data_store.dart';
import '../state/navigation_store.dart';
import '../state/settings_store.dart';
import '../theming.dart';
import '../utils.dart';
import '../widgets/settings_section.dart';
import 'blocked_files_page.dart';
import 'export_page.dart';
@ -46,6 +47,14 @@ class SettingsPage extends StatelessWidget {
ListTile(
title: Text(L10n.of(context)!.updateLibrary),
subtitle: Observer(builder: (_) {
if (musicDataStore.isUpdatingDatabase) {
return LinearProgressIndicator(
value: getProgressOrNull(
musicDataStore.progressStream.value,
musicDataStore.numFileStream.value,
),
);
}
final int artistCount = musicDataStore.artistStream.value?.length ?? 0;
final int albumCount = musicDataStore.albumStream.value?.length ?? 0;
final int songCount = musicDataStore.songStream.value?.length ?? 0;
@ -54,15 +63,6 @@ class SettingsPage extends StatelessWidget {
);
}),
onTap: () => musicDataStore.updateDatabase(),
trailing: Observer(builder: (_) {
if (musicDataStore.isUpdatingDatabase) {
return const CircularProgressIndicator();
}
return Container(
height: 0,
width: 0,
);
}),
),
const Divider(),
ListTile(

View file

@ -57,6 +57,14 @@ abstract class _MusicDataStore with Store {
@observable
bool isUpdatingDatabase = false;
@observable
late ObservableStream<int?> numFileStream =
_musicDataRepository.numFileStream.asObservable(initialValue: null);
@observable
late ObservableStream<int?> progressStream =
_musicDataRepository.progressStream.asObservable(initialValue: null);
@observable
late ObservableStream<Album?> albumOfDay = _musicDataRepository.albumOfDayStream.asObservable();

View file

@ -143,3 +143,8 @@ Widget createPlayableCover(Playable playable, double size) {
);
}
}
double? getProgressOrNull(int? numerator, int? denominator) {
if (numerator == null || denominator == null) return null;
return numerator / denominator;
}

View file

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../domain/entities/song.dart';
import '../../system/utils.dart';
import '../state/audio_store.dart';
import '../theming.dart';
import '../utils.dart';
@ -57,7 +56,7 @@ class _AlbumBackgroundState extends State<AlbumBackground> {
Future<void> _setBackgroundWidget(Song? song) async {
if (song == null) return;
final Color color =
song.color ?? await getBackgroundColor(getAlbumImage(song.albumArtPath)) ?? DARK3;
song.color ?? DARK3;
setState(() {
_backgroundWidget = Container(

View file

@ -1,3 +1,7 @@
import 'package:rxdart/rxdart.dart';
abstract class LocalMusicFetcher {
ValueStream<int?> get fileNumStream;
ValueStream<int?> get progressStream;
Future<Map<String, List>> getLocalMusic();
}

View file

@ -1,23 +1,28 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:async_task/async_task.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_fimber/flutter_fimber.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart';
import 'package:image/image.dart' as img;
import 'package:metadata_god/metadata_god.dart';
import 'package:mucke/system/models/default_values.dart';
import 'package:mucke/system/utils.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:rxdart/rxdart.dart';
import '../models/album_model.dart';
import '../models/artist_model.dart';
import '../models/default_values.dart';
import '../models/song_model.dart';
import '../utils.dart';
import 'local_music_fetcher.dart';
import 'music_data_source_contract.dart';
import 'settings_data_source.dart';
class LocalMusicFetcherImpl implements LocalMusicFetcher {
LocalMusicFetcherImpl(this._settingsDataSource, this._musicDataSource);
@ -26,204 +31,324 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
final SettingsDataSource _settingsDataSource;
final MusicDataSource _musicDataSource;
@override
ValueStream<int?> get fileNumStream => _fileNumSubject.stream;
@override
ValueStream<int?> get progressStream => _progressSubject.stream;
final BehaviorSubject<int?> _fileNumSubject = BehaviorSubject<int?>();
final BehaviorSubject<int?> _progressSubject = BehaviorSubject<int?>();
@override
Future<Map<String, List>> getLocalMusic() async {
// FIXME: it seems that songs currently loaded in queue are not updated
_fileNumSubject.add(null);
_progressSubject.add(null);
int scanCount = 0;
final musicDirectories = await _settingsDataSource.libraryFoldersStream.first;
final musicDirectories =
await _settingsDataSource.libraryFoldersStream.first;
final libDirs = musicDirectories.map((e) => Directory(e));
final extString = await _settingsDataSource.fileExtensionsStream.first;
final allowedExtensions = getExtensionSet(extString);
final blockedPaths = await _musicDataSource.blockedFilesStream.first;
final hasStorageAccess = await Permission.storage.isGranted;
if(!hasStorageAccess) {
if (!hasStorageAccess) {
await Permission.storage.request();
if (!await Permission.storage.isGranted) {
return {};
}
}
final List<File> songFiles = [];
for (final libDir in libDirs) {
final List<File> files = await Directory(libDir.path)
.list(recursive: true, followLinks: false)
.where((item) => FileSystemEntity.isFileSync(item.path))
.asyncMap((item) => File(item.path)).toList();
final List<File> files = await getSongFilesInDirectory(libDir.path, allowedExtensions, blockedPaths);
songFiles.addAll(files);
}
_log.d('Found ${songFiles.length} songs');
final List<SongModel> songs = [];
final List<AlbumModel> albums = [];
final Map<String, int> albumIdMap = {};
final Map<int, String> albumArtMap = {};
/// album id, background color
final Map<int, Color?> colorMap = {};
final Set<ArtistModel> artistSet = {};
final albumsInDb = (await _musicDataSource.albumStream.first)
..sort((a, b) => a.id.compareTo(b.id));
final albumsInDb = await getSortedAlbums();
int newAlbumId = albumsInDb.isNotEmpty ? albumsInDb.last.id + 1 : 0;
_log.d('New albums start with id: $newAlbumId');
final artistsInDb = (await _musicDataSource.artistStream.first)
..sort((a, b) => a.id.compareTo(b.id));
final artistsInDb = await getSortedArtists();
int newArtistId = artistsInDb.isNotEmpty ? artistsInDb.last.id + 1 : 0;
_log.d('New artists start with id: $newArtistId');
final Directory dir = await getApplicationSupportDirectory();
for (final songFile in songFiles.toSet()) {
final String extension = p.extension(songFile.path).toLowerCase().substring(1);
if (!allowedExtensions.contains(extension)) continue;
if (blockedPaths.contains(songFile.path)) continue;
_log.d('Checking song: ${songFile.path}');
final List<File> songFilesToCheck = await getSongFilesToCheck(songFiles);
_fileNumSubject.add(songFilesToCheck.length);
final existingSongFiles = songFiles.where((element) => !songFilesToCheck.contains(element)).toList();
final structs = await mapSongsAlreadyScanned(existingSongFiles, albumsInDb, artistsInDb);
var songs = structs['songs'] as List<SongModel>;
final albums = structs['albums'] as List<AlbumModel>;
final artists = structs['artists'] as Set<ArtistModel>;
final albumIdMap = structs['albumIdMap'] as Map<String, int>;
final albumArtMap = structs['albumArtMap'] as Map<int, String>;
final songsToCheck = await getMetadataForFiles(songFilesToCheck);
for (final (songFile, songData) in songsToCheck) {
_log.i('Scanning Song ${songFile.path}');
_progressSubject.add(++scanCount);
// changed includes the creation time
// => also update, when the file was created later (and wasn't really changed)
// this is used as a workaround because android
// doesn't seem to return the correct modification time
final lastModified = songFile.lastModifiedSync();
final song = await _musicDataSource.getSongByPath(songFile.path);
int? albumId;
String albumString;
Color? color;
if (song != null) {
if (!lastModified.isAfter(song.lastModified)) {
// file hasn't changed -> use existing songmodel
final albumArtist = songData.albumArtist ?? songData.artist;
final albumString = '${songData.album}___${albumArtist}___${songData.year}';
final album = albumsInDb.singleWhere((a) => a.id == song.albumId);
albumString = '${album.title}___${album.artist}__${album.pubYear}';
if (albumIdMap.containsKey(albumString)) {
final albumId = albumIdMap[albumString]!;
// TODO: Potential improvement. If albumArtMap does not contain an entry for this album yet
// we could test if the current song has a picture. The way it is now if the first SongFile in an album
// scanned has no picture, the whole album has no Picture.
if (!albumIdMap.containsKey(albumString)) {
albumIdMap[albumString] = album.id;
if (album.albumArtPath != null) {
albumArtMap[album.id] = album.albumArtPath!;
if (album.color == null) {
// we have an album cover, but no color -> try to get one
color = await getBackgroundColor(FileImage(File(album.albumArtPath!)));
colorMap[album.id] = color;
} else {
colorMap[album.id] = album.color;
}
}
albums.add(album.copyWith(color: color));
final artist = artistsInDb.singleWhere((a) => a.name == album.artist);
artistSet.add(artist);
} else {
// we already encountered the album (at least by albumString)
// make sure the id is consistent
if (album.id != albumIdMap[albumString]) {
songs.add(
song.copyWith(
albumId: albumIdMap[albumString],
color: colorMap[albumIdMap[albumString]],
),
);
continue;
}
}
songs.add(song.copyWith(color: colorMap[album.id]));
continue;
} else {
// read new info but keep albumId
albumId = song.albumId;
}
}
final Metadata songData;
try {
songData = await MetadataGod.readMetadata(file: songFile.path);
} on FfiException {
songs.add(
SongModel.fromMetadata(
path: songFile.path,
songData: songData,
albumId: albumId,
albumArtPath: albumArtMap[albumId],
lastModified: lastModified,
),
);
continue;
}
// completely new song -> new album ids should start after existing ones
// this is new information
// is the album ID still correct or do we find another album with the same properties?
final String albumArtist = songData.albumArtist ?? '';
albumString = '${songData.album}___${albumArtist}__${songData.year}';
final albumId = await getAlbumId(newAlbumId, songData.album, albumArtist, songData.year);
albumIdMap[albumString] = albumId;
newAlbumId = max(newAlbumId, albumId + 1);
String? albumArtPath;
if (!albumIdMap.containsKey(albumString)) {
// we haven't seen an album with these properties in the files yet, but there might be an entry in the database
// in this case, we should use the corresponding ID
albumId ??= await _musicDataSource.getAlbumId(
songData.album,
albumArtist,
songData.year,
) ??
newAlbumId++;
albumIdMap[albumString] = albumId;
final albumArt = songData.picture;
final albumArt = songData.picture;
if (albumArt != null) {
albumArtPath = '${dir.path}/$albumId';
final file = File(albumArtPath);
file.writeAsBytesSync(albumArt.data);
albumArtMap[albumId] = albumArtPath;
color = await getBackgroundColor(FileImage(file));
colorMap[albumId] = color;
}
final String songArtist = songData.artist ?? '';
final String artistName =
albumArtist != '' ? albumArtist : (songArtist != '' ? songArtist : DEF_ARTIST);
final artist = artistsInDb.firstWhereOrNull((a) => a.name == artistName);
if (artist != null) {
artistSet.add(artist);
} else if (artistSet.firstWhereOrNull((a) => a.name == artistName) == null) {
// artist is also not in the set already
artistSet.add(ArtistModel(name: artistName, id: newArtistId++));
}
albums.add(
AlbumModel.fromMetadata(
albumId: albumId,
songData: songData,
albumArtPath: albumArtPath,
color: color,
),
);
} else {
// an album with the same properties is already stored in the list
// use it's ID regardless of the old one stored in the songModel
albumId = albumIdMap[albumString]!;
albumArtPath = albumArtMap[albumId];
color = colorMap[albumId];
if (albumArt != null) {
albumArtMap[albumId] = await cacheAlbumArt(albumArt, albumId);
}
final String? songArtist = songData.artist;
final String artistName =
albumArtist ?? (songArtist ?? DEF_ARTIST);
final artist = artistsInDb.firstWhereOrNull((a) => a.name == artistName);
if (artist != null)
artists.add(artist);
if (artists.none((a) => a.name == artistName)) {
// artist is also not in the set already
artists.add(ArtistModel(name: artistName, id: newArtistId++));
}
albums.add(
AlbumModel.fromMetadata(
songData: songData,
albumId: albumId,
albumArtPath: albumArtMap[albumId],
)
);
songs.add(
SongModel.fromMetadata(
path: songFile.path,
songData: songData,
albumId: albumId,
albumArtPath: albumArtPath,
color: color,
lastModified: lastModified,
),
albumArtPath: albumArtMap[albumId],
)
);
}
final albumAccentTasks = albums
.where((element) => element.color == null && element.albumArtPath != null)
.map((e) => AccentGenerator(e.id, File(e.albumArtPath!)));
final asyncExecutor = AsyncExecutor(
sequential: false,
parallelism: max(Platform.numberOfProcessors - 1, 1),
taskTypeRegister: accentGeneratorTypeRegister,
);
asyncExecutor.logger.enabled = true;
final executions = asyncExecutor.executeAll(albumAccentTasks);
_fileNumSubject.add(executions.length);
scanCount = 0;
for (final execution in executions) {
_progressSubject.add(++scanCount);
final (albumId, color) = await execution;
if (color == null) {
_log.w('failed getting color for albumId $albumId');
continue;
}
final i = albums.indexWhere((element) => element.id == albumId);
albums[i] = albums[i].copyWith(color: color);
songs = songs.map((song) {
if (song.albumId == albumId)
return song.copyWith(color: color);
return song;
})
.toList();
}
asyncExecutor.close();
return {
'SONGS': songs,
'ALBUMS': albums,
'ARTISTS': artistSet.toList(),
'ARTISTS': artists.toList(),
};
}
Future<List<File>> getSongFilesInDirectory(
String path,
Set<String> allowedExtensions,
Set<String> blockedPaths
) async {
return Directory(path)
.list(recursive: true, followLinks: false)
.where((item) => FileSystemEntity.isFileSync(item.path))
.where((item) => !blockedPaths.contains(item.path))
.where((item) {
final extension = p.extension(item.path).toLowerCase().substring(1);
return allowedExtensions.contains(extension);
})
.asyncMap((item) => File(item.path))
.toList();
}
// Returns a list of all new song files and files that have changed since they where last imported
Future<List<File>> getSongFilesToCheck(List<File> songFiles) async {
final List<File> songFilesToCheck = [];
for (final songFile in songFiles) {
final lastModified = songFile.lastModifiedSync();
final song = await _musicDataSource.getSongByPath(songFile.path);
if (song == null || lastModified.isAfter(song.lastModified))
songFilesToCheck.add(songFile);
}
return songFilesToCheck;
}
Future<List<ArtistModel>> getSortedArtists() async {
return (await _musicDataSource.artistStream.first)
..sort((a, b) => a.id.compareTo(b.id));
}
Future<List<AlbumModel>> getSortedAlbums() async {
return (await _musicDataSource.albumStream.first)
..sort((a, b) => a.id.compareTo(b.id));
}
Future<int> getAlbumId(int newAlbumId, String? album, String? albumArtist, int? year) async {
return await _musicDataSource.getAlbumId(album, albumArtist, year) ?? newAlbumId++;
}
Future<String> cacheAlbumArt(Picture albumArt, int albumId) async {
final Directory dir = await getApplicationSupportDirectory();
final albumArtPath = '${dir.path}/$albumId';
final file = File(albumArtPath);
file.writeAsBytesSync(albumArt.data);
return albumArtPath;
}
// Maps all the songs that where scanned previously, and their Albums and Artists to the new data structures
Future<Map<String, dynamic>> mapSongsAlreadyScanned(
List<File> songFiles,
List<AlbumModel> albumsInDb,
List<ArtistModel> artistsInDb
) async {
final List<SongModel> songs = [];
final List<AlbumModel> albums = [];
final Set<ArtistModel> artists = {};
final Map<String, int> albumIdMap = {};
final Map<int, String> albumArtMap = {};
/// album id, background color
final Map<int, Color?> colorMap = {};
for (final songFile in songFiles) {
final song = (await _musicDataSource.getSongByPath(songFile.path))!;
final album = albumsInDb.singleWhere((a) => a.id == song.albumId);
final albumString = '${album.title}___${album.artist}__${album.pubYear}';
if (albumIdMap.containsKey(albumString)) {
// we already encountered the album (at least by albumString)
// make sure the id is consistent
if (album.id != albumIdMap[albumString])
songs.add(
song.copyWith(
albumId: albumIdMap[albumString],
color: colorMap[albumIdMap[albumString]],
),
);
else
songs.add(song.copyWith(color: colorMap[album.id]));
} else {
albumIdMap[albumString] = album.id;
if (album.albumArtPath != null) {
albumArtMap[album.id] = album.albumArtPath!;
if (album.color != null)
colorMap[album.id] = album.color;
}
albums.add(album);
final artist = artistsInDb.singleWhere((a) => a.name == album.artist);
artists.add(artist);
songs.add(song.copyWith(color: colorMap[album.id]));
}
}
return {
'songs': songs,
'albums': albums,
'artists': artists,
'albumIdMap': albumIdMap,
'albumArtMap': albumArtMap
};
}
Future<List<(File, Metadata)>> getMetadataForFiles(
List<File> filesToCheck) async {
final List<(File, Metadata)> songsMetadata = [];
final tasks = filesToCheck.map((e) => MetadataLoader(e));
final asyncExecutor = AsyncExecutor(
sequential: false,
parallelism: max(Platform.numberOfProcessors - 1, 1),
taskTypeRegister: metadataLoaderTypeRegister,
);
asyncExecutor.logger.enabled = true;
final executions = asyncExecutor.executeAll(tasks);
await Future.wait(executions);
for (final execution in executions) {
songsMetadata.add(await execution);
}
asyncExecutor.close();
return songsMetadata;
}
Set<String> getExtensionSet(String extString) {
List<String> extensions = extString.toLowerCase().split(',');
extensions = extensions.map((e) => e.trim()).toList();
@ -235,7 +360,8 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
final List<File> files = [];
if (FileSystemEntity.isDirectorySync(path)) {
final dir = Directory(path);
await for (final entity in dir.list(recursive: true, followLinks: false)) {
await for (final entity
in dir.list(recursive: true, followLinks: false)) {
files.addAll(await getAllFilesRecursively(entity.path));
}
} else if (FileSystemEntity.isFileSync(path)) {
@ -243,4 +369,70 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
}
return files;
}
}
List<AsyncTask> metadataLoaderTypeRegister() => [MetadataLoader(File(''))];
class MetadataLoader extends AsyncTask<File, (File, Metadata)> {
MetadataLoader(this.file);
final File file;
@override
AsyncTask<File, (File, Metadata)> instantiate(File parameters,
[Map<String, SharedData>? sharedData]) {
MetadataGod.initialize();
return MetadataLoader(parameters);
}
@override
File parameters() {
return file;
}
@override
FutureOr<(File, Metadata)> run() async {
return (file, await MetadataGod.readMetadata(file: file.path));
}
}
List<AsyncTask> accentGeneratorTypeRegister() => [AccentGenerator(0, File(''))];
class AccentGenerator extends AsyncTask<(int, File), (int, Color?)> {
AccentGenerator(this.albumId, this.pictureFile);
final File pictureFile;
final int albumId;
@override
AsyncTask<(int, File), (int, Color?)> instantiate((int, File) parameters, [Map<String, SharedData>? sharedData]) {
return AccentGenerator(parameters.$1, parameters.$2);
}
@override
(int, File) parameters() {
return (albumId, pictureFile);
}
@override
FutureOr<(int, Color?)> run() async {
final image = await _loadImage(pictureFile);
if (image == null)
return (albumId, null);
return (albumId, getBackgroundColor(image));
}
Future<img.Image?> _loadImage(File file) async {
final data = await file.readAsBytes();
img.Image? image;
try {
image = img.decodeImage(data);
} catch(e) {
return null;
}
return image;
}
}

View file

@ -572,4 +572,10 @@ class MusicDataRepositoryImpl implements MusicDataRepository {
DateTime _day(DateTime dateTime) {
return DateTime(dateTime.year, dateTime.month, dateTime.day);
}
@override
ValueStream<int?> get numFileStream => _localMusicFetcher.fileNumStream;
@override
ValueStream<int?> get progressStream => _localMusicFetcher.progressStream;
}

View file

@ -1,6 +1,8 @@
import 'package:collection/collection.dart';
import 'dart:collection';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:image/image.dart' as img;
int? parseYear(String? yearString) {
if (yearString == null || yearString == '') {
@ -14,20 +16,33 @@ int? parseYear(String? yearString) {
}
}
Future<Color?> getBackgroundColor(ImageProvider image) async {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
image,
targets: PaletteTarget.baseTargets,
);
final colors = <Color?>[
paletteGenerator.vibrantColor?.color,
paletteGenerator.lightVibrantColor?.color,
paletteGenerator.mutedColor?.color,
paletteGenerator.darkVibrantColor?.color,
paletteGenerator.lightMutedColor?.color,
paletteGenerator.dominantColor?.color
];
/// Try to find an appropriate background color for the image
Color getBackgroundColor(img.Image image) {
image = img.quantize(image,
numberOfColors: 16, method: img.QuantizeMethod.octree);
image = image.convert(format: img.Format.uint8, numChannels: 4);
final data = image.getBytes(order: img.ChannelOrder.rgba);
final counts = HashMap<Color, int>();
for (var i = 0; i < data.length; i += 4) {
final argb = Color.fromARGB(data[i + 3], data[i], data[i+1], data[i+2]);
counts[argb] = (counts[argb] ?? 0) + 1;
}
return colors.firstWhereOrNull((c) => c != null);
final sortedColors = counts.keys.toList()..sort(
(a, b) =>
colorWeight(b, counts[b]!)
.compareTo(colorWeight(a, counts[a]!))
);
return sortedColors.first;
}
/// This function weighs colors and gives them a rating.
/// The higher the rating the better it works as an accent color
/// It prefers colors that are contained a lot in the image.
/// Colors that are really colorful (i.e. have a lot of saturation)
/// are weighted more than grayscale colors and colors that are too light
/// or too dark are weighted down.
num colorWeight(Color color, int count) {
final hslColor = HSLColor.fromColor(color);
return count * pow(hslColor.saturation, 2) * (0.55 - (hslColor.lightness - 0.55).abs());
}

View file

@ -49,6 +49,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.11.0"
async_extension:
dependency: transitive
description:
name: async_extension
sha256: "016034c2245ea4903ba0b5bdf8649330d7f8cdfa2f2a7e65ae613b5a6f6757df"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
async_task:
dependency: "direct main"
description:
name: async_task
sha256: "4c3b1b4f4ccce1555d10d31a74136e099e7db7a0c1c4852fc53116cb00970508"
url: "https://pub.dev"
source: hosted
version: "1.0.20"
audio_service:
dependency: "direct main"
description:
@ -485,6 +501,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.2"
image:
dependency: "direct main"
description:
name: image
sha256: a72242c9a0ffb65d03de1b7113bc4e189686fc07c7147b8b41811d0dd0e0d9bf
url: "https://pub.dev"
source: hosted
version: "4.0.17"
intl:
dependency: "direct main"
description:
@ -662,14 +686,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.3"
palette_generator:
dependency: "direct main"
description:
name: palette_generator
sha256: "0e3cd6974e10b1434dcf4cf779efddb80e2696585e273a2dbede6af52f94568d"
url: "https://pub.dev"
source: hosted
version: "0.3.3+2"
path:
dependency: "direct main"
description:
@ -1211,6 +1227,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
xml:
dependency: transitive
description:
name: xml
sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5"
url: "https://pub.dev"
source: hosted
version: "6.2.2"
yaml:
dependency: transitive
description:

View file

@ -9,6 +9,7 @@ environment:
flutter: ">=3.7.0"
dependencies:
async_task: ^1.0.20
audio_service: ^0.18.7 # MIT
audio_session: ^0.1.5 # MIT
collection: ^1.17.1 # BSD 3
@ -26,6 +27,7 @@ dependencies:
flutter_mobx: ^2.0.0 # MIT
flutter_speed_dial: ^7.0.0 # MIT
get_it: ^7.1.3 # MIT
image: ^4.0.17
intl: any
just_audio: ^0.9.18 # MIT
metadata_god:
@ -37,7 +39,6 @@ dependencies:
optimization_battery: ^0.0.7 # MIT
package_info_plus: ^4.0.1
page_indicator_plus: ^1.0.3 # MIT
palette_generator: ^0.3.3+2 # BSD 3
path: ^1.8.0 # BSD 3
path_provider: ^2.0.2 # BSD 3
permission_handler: ^10.2.0 # MIT