tweaks for library scan:

- added optional permission to manage all files
- speed improvements to color selection
- prepared fallback for missing covers
This commit is contained in:
Moritz Weber 2023-08-24 16:30:07 -04:00
parent 9eb71b3b76
commit 52df779dd1
12 changed files with 279 additions and 158 deletions

View file

@ -5,6 +5,7 @@ abstract class SettingsInfoRepository {
ValueStream<String> get fileExtensionsStream;
ValueStream<bool> get playAlbumsInOrderStream;
ValueStream<int> get listenedPercentageStream;
ValueStream<bool> get manageExternalStorageGranted;
}
abstract class SettingsRepository extends SettingsInfoRepository {
@ -13,4 +14,5 @@ abstract class SettingsRepository extends SettingsInfoRepository {
Future<void> setFileExtension(String extensions);
Future<void> setPlayAlbumsInOrder(bool playInOrder);
Future<void> setListenedPercentage(int percentage);
Future<void> setManageExternalStorageGranted(bool granted);
}

View file

@ -376,8 +376,8 @@
"@dataExportFailed": {},
"yourPlaylists": "Your Playlists",
"@yourPlaylists": {},
"batteryOptimization": "Battery Optimization",
"@batteryOptimization": {},
"systemSettings": "System Settings",
"@systemSettings": {},
"batteryExplanation": "Starting with Android 12, the battery optimization causes an error with the notification after losing the audio focus, for example when receiving a call. Disabling the optimization for mucke solves this issue.",
"@batteryExplanation": {},
"openBattery": "Open battery settings",
@ -386,6 +386,9 @@
"@disableBattery": {},
"disabledBattery": "Battery optimization is disabled.",
"@disabledBattery": {},
"manageExternalExplanation": "Granting this permission can improve the speed of library scans significantly. It does not change the behavior of the app otherwise.",
"grantManagePermission": "Grant permission to manage all files.",
"managePermissionSubtitle": "Revoking the permission will result in a restart of the app.",
"favorites": "Favorites",
"favoritesDesc": "Contains all the songs that you like.",
"@favorites": {},

View file

@ -1,22 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/localizations.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:get_it/get_it.dart';
import 'package:optimization_battery/optimization_battery.dart';
import '../../state/import_store.dart';
import '../../state/settings_store.dart';
import '../../theming.dart';
import '../../widgets/info_card.dart';
class InitBatteryPage extends StatelessWidget {
const InitBatteryPage({Key? key, required this.importStore}) : super(key: key);
class InitSystemPage extends StatelessWidget {
const InitSystemPage({
Key? key,
required this.importStore,
}) : super(key: key);
final ImportStore importStore;
@override
Widget build(BuildContext context) {
final SettingsStore settingsStore = GetIt.I<SettingsStore>();
return Scaffold(
appBar: AppBar(
title: Text(
L10n.of(context)!.batteryOptimization,
L10n.of(context)!.systemSettings,
style: TEXT_HEADER,
),
centerTitle: true,
@ -29,7 +37,7 @@ class InitBatteryPage extends StatelessWidget {
text: L10n.of(context)!.batteryExplanation,
),
BatteryOptimizationsObserver(builder: (context, isIgnore) {
return ListTile(
return SwitchListTile(
title: Text(L10n.of(context)!.openBattery),
subtitle: Text(
(isIgnore != null && !isIgnore)
@ -37,12 +45,31 @@ class InitBatteryPage extends StatelessWidget {
: L10n.of(context)!.disabledBattery,
style: TEXT_SMALL_SUBTITLE,
),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () {
value: isIgnore != null && isIgnore,
onChanged: (_) {
OptimizationBattery.openBatteryOptimizationSettings();
},
);
}),
const Divider(
height: 16.0,
),
InfoCard(
text: L10n.of(context)!.manageExternalExplanation,
),
Observer(
builder: (context) => SwitchListTile(
value:
settingsStore.manageExternalStorageGranted.value ?? false,
onChanged: settingsStore.setManageExternalStorageGranted,
title: Text(L10n.of(context)!.grantManagePermission),
subtitle: Text(
L10n.of(context)!.managePermissionSubtitle,
style: TEXT_SMALL_SUBTITLE,
),
),
),
const SizedBox(height: 8.0),
],
),
),

View file

@ -7,10 +7,10 @@ import 'package:page_indicator_plus/page_indicator_plus.dart';
import '../../../domain/repositories/init_repository.dart';
import '../../state/import_store.dart';
import '../../theming.dart';
import 'init_battery_page.dart';
import 'init_lib_page.dart';
import 'init_meta_page.dart';
import 'init_smartlists.dart';
import 'init_system_page.dart';
class InitWorkflow extends StatefulWidget {
const InitWorkflow({super.key, required this.importStore});
@ -39,7 +39,7 @@ class _InitWorkflowState extends State<InitWorkflow> {
setState(() => pages.add(InitSmartlistsPage(importStore: widget.importStore)));
DeviceInfoPlugin().androidInfo.then((info) {
if (info.version.sdkInt > 30)
setState(() => pages.add(InitBatteryPage(importStore: widget.importStore)));
setState(() => pages.insert(0, InitSystemPage(importStore: widget.importStore)));
});
super.initState();

View file

@ -205,6 +205,20 @@ class SettingsPage extends StatelessWidget {
return Container();
},
),
const Divider(
height: 4.0,
),
Observer(
builder: (context) => SwitchListTile(
value: settingsStore.manageExternalStorageGranted.value ?? false,
onChanged: settingsStore.setManageExternalStorageGranted,
title: Text(L10n.of(context)!.grantManagePermission),
subtitle: Text(
L10n.of(context)!.managePermissionSubtitle,
style: TEXT_SMALL_SUBTITLE,
),
),
),
const SizedBox(height: 8.0),
],
),

View file

@ -113,6 +113,38 @@ mixin _$MusicDataStore on _MusicDataStore, Store {
});
}
late final _$numFileStreamAtom =
Atom(name: '_MusicDataStore.numFileStream', context: context);
@override
ObservableStream<int?> get numFileStream {
_$numFileStreamAtom.reportRead();
return super.numFileStream;
}
@override
set numFileStream(ObservableStream<int?> value) {
_$numFileStreamAtom.reportWrite(value, super.numFileStream, () {
super.numFileStream = value;
});
}
late final _$progressStreamAtom =
Atom(name: '_MusicDataStore.progressStream', context: context);
@override
ObservableStream<int?> get progressStream {
_$progressStreamAtom.reportRead();
return super.progressStream;
}
@override
set progressStream(ObservableStream<int?> value) {
_$progressStreamAtom.reportWrite(value, super.progressStream, () {
super.progressStream = value;
});
}
late final _$albumOfDayAtom =
Atom(name: '_MusicDataStore.albumOfDay', context: context);
@ -162,6 +194,8 @@ artistStream: ${artistStream},
playlistsStream: ${playlistsStream},
smartListsStream: ${smartListsStream},
isUpdatingDatabase: ${isUpdatingDatabase},
numFileStream: ${numFileStream},
progressStream: ${progressStream},
albumOfDay: ${albumOfDay},
artistOfDay: ${artistOfDay},
songListIsEmpty: ${songListIsEmpty}

View file

@ -46,6 +46,12 @@ abstract class _SettingsStore with Store {
initialValue: false,
);
@observable
late ObservableStream<bool> manageExternalStorageGranted =
_settingsRepository.manageExternalStorageGranted.asObservable(
initialValue: _settingsRepository.manageExternalStorageGranted.valueOrNull ?? false,
);
Future<void> addLibraryFolder(String? path) async {
await _settingsRepository.addLibraryFolder(path);
}
@ -74,5 +80,9 @@ abstract class _SettingsStore with Store {
await _settingsRepository.setListenedPercentage(percentage);
}
Future<void> setManageExternalStorageGranted(bool granted) async {
await _settingsRepository.setManageExternalStorageGranted(granted);
}
void dispose() {}
}

View file

@ -101,6 +101,23 @@ mixin _$SettingsStore on _SettingsStore, Store {
});
}
late final _$manageExternalStorageGrantedAtom = Atom(
name: '_SettingsStore.manageExternalStorageGranted', context: context);
@override
ObservableStream<bool> get manageExternalStorageGranted {
_$manageExternalStorageGrantedAtom.reportRead();
return super.manageExternalStorageGranted;
}
@override
set manageExternalStorageGranted(ObservableStream<bool> value) {
_$manageExternalStorageGrantedAtom
.reportWrite(value, super.manageExternalStorageGranted, () {
super.manageExternalStorageGranted = value;
});
}
@override
String toString() {
return '''
@ -109,6 +126,7 @@ fileExtensionsStream: ${fileExtensionsStream},
blockedFilesStream: ${blockedFilesStream},
listenedPercentageStream: ${listenedPercentageStream},
playAlbumsInOrderStream: ${playAlbumsInOrderStream},
manageExternalStorageGranted: ${manageExternalStorageGranted},
numBlockedFiles: ${numBlockedFiles}
''';
}

View file

@ -663,15 +663,12 @@ class $QueueEntriesTable extends QueueEntries
static const VerificationMeta _isAvailableMeta =
const VerificationMeta('isAvailable');
@override
late final GeneratedColumn<bool> isAvailable =
GeneratedColumn<bool>('is_available', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({
SqlDialect.sqlite: 'CHECK ("is_available" IN (0, 1))',
SqlDialect.mysql: '',
SqlDialect.postgres: '',
}));
late final GeneratedColumn<bool> isAvailable = GeneratedColumn<bool>(
'is_available', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("is_available" IN (0, 1))'));
@override
List<GeneratedColumn> get $columns =>
[index, path, originalIndex, type, isAvailable];
@ -957,15 +954,12 @@ class $AvailableSongEntriesTable extends AvailableSongEntries
static const VerificationMeta _isAvailableMeta =
const VerificationMeta('isAvailable');
@override
late final GeneratedColumn<bool> isAvailable =
GeneratedColumn<bool>('is_available', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({
SqlDialect.sqlite: 'CHECK ("is_available" IN (0, 1))',
SqlDialect.mysql: '',
SqlDialect.postgres: '',
}));
late final GeneratedColumn<bool> isAvailable = GeneratedColumn<bool>(
'is_available', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("is_available" IN (0, 1))'));
@override
List<GeneratedColumn> get $columns =>
[index, path, originalIndex, type, isAvailable];
@ -1324,16 +1318,13 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, DriftSong> {
static const VerificationMeta _presentMeta =
const VerificationMeta('present');
@override
late final GeneratedColumn<bool> present =
GeneratedColumn<bool>('present', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({
SqlDialect.sqlite: 'CHECK ("present" IN (0, 1))',
SqlDialect.mysql: '',
SqlDialect.postgres: '',
}),
defaultValue: const Constant(true));
late final GeneratedColumn<bool> present = GeneratedColumn<bool>(
'present', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("present" IN (0, 1))'),
defaultValue: const Constant(true));
static const VerificationMeta _timeAddedMeta =
const VerificationMeta('timeAdded');
@override
@ -1351,28 +1342,22 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, DriftSong> {
static const VerificationMeta _previousMeta =
const VerificationMeta('previous');
@override
late final GeneratedColumn<bool> previous =
GeneratedColumn<bool>('previous', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({
SqlDialect.sqlite: 'CHECK ("previous" IN (0, 1))',
SqlDialect.mysql: '',
SqlDialect.postgres: '',
}),
defaultValue: const Constant(false));
late final GeneratedColumn<bool> previous = GeneratedColumn<bool>(
'previous', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("previous" IN (0, 1))'),
defaultValue: const Constant(false));
static const VerificationMeta _nextMeta = const VerificationMeta('next');
@override
late final GeneratedColumn<bool> next =
GeneratedColumn<bool>('next', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({
SqlDialect.sqlite: 'CHECK ("next" IN (0, 1))',
SqlDialect.mysql: '',
SqlDialect.postgres: '',
}),
defaultValue: const Constant(false));
late final GeneratedColumn<bool> next = GeneratedColumn<bool>(
'next', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("next" IN (0, 1))'),
defaultValue: const Constant(false));
@override
List<GeneratedColumn> get $columns => [
title,
@ -2184,16 +2169,13 @@ class $SmartListsTable extends SmartLists
static const VerificationMeta _excludeArtistsMeta =
const VerificationMeta('excludeArtists');
@override
late final GeneratedColumn<bool> excludeArtists =
GeneratedColumn<bool>('exclude_artists', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintsDependsOnDialect({
SqlDialect.sqlite: 'CHECK ("exclude_artists" IN (0, 1))',
SqlDialect.mysql: '',
SqlDialect.postgres: '',
}),
defaultValue: const Constant(false));
late final GeneratedColumn<bool> excludeArtists = GeneratedColumn<bool>(
'exclude_artists', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("exclude_artists" IN (0, 1))'),
defaultValue: const Constant(false));
static const VerificationMeta _blockLevelMeta =
const VerificationMeta('blockLevel');
@override

View file

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
import 'package:async_task/async_task.dart';
import 'package:collection/collection.dart';
@ -8,8 +9,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_fimber/flutter_fimber.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';
@ -17,14 +16,20 @@ 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';
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png'];
class LocalMusicFetcherImpl implements LocalMusicFetcher {
LocalMusicFetcherImpl(this._settingsDataSource, this._musicDataSource);
LocalMusicFetcherImpl(
this._settingsDataSource,
this._musicDataSource,
);
static final _log = FimberLog('LocalMusicFetcher');
@ -46,8 +51,7 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
_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;
@ -64,7 +68,8 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
final List<File> songFiles = [];
for (final libDir in libDirs) {
final List<File> files = await getSongFilesInDirectory(libDir.path, allowedExtensions, blockedPaths);
final List<File> files =
await getSongFilesInDirectory(libDir.path, allowedExtensions, blockedPaths);
songFiles.addAll(files);
}
@ -78,11 +83,11 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
int newArtistId = artistsInDb.isNotEmpty ? artistsInDb.last.id + 1 : 0;
_log.d('New artists start with id: $newArtistId');
final List<File> songFilesToCheck = await getSongFilesToCheck(songFiles);
_fileNumSubject.add(songFilesToCheck.length);
final existingSongFiles = songFiles.where((element) => !songFilesToCheck.contains(element)).toList();
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>;
@ -120,45 +125,58 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
continue;
}
final albumId = await getAlbumId(newAlbumId, songData.album, albumArtist, songData.year);
final albumId = await getAlbumId(
newAlbumId,
songData.album,
albumArtist,
songData.year,
);
albumIdMap[albumString] = albumId;
newAlbumId = max(newAlbumId, albumId + 1);
final albumArt = songData.picture;
final Uint8List? albumArt = songData.picture?.data;
// fallback to get albumArt
// TODO: enable when everything else works as expected
// if (albumArt == null) {
// // get directory of song and look for image files
// final images = await songFile.parent
// .list(recursive: false, followLinks: false)
// .where((item) =>
// FileSystemEntity.isFileSync(item.path) &&
// IMAGE_EXTENSIONS.contains(
// p.extension(item.path).toLowerCase().substring(1)))
// .asyncMap((item) => File(item.path))
// .toList();
// if (images.isNotEmpty) albumArt = images.first.readAsBytesSync();
// }
if (albumArt != null) {
albumArtMap[albumId] = await cacheAlbumArt(albumArt, albumId);
}
final String? songArtist = songData.artist;
final String artistName =
albumArtist ?? (songArtist ?? DEF_ARTIST);
final String artistName = albumArtist ?? (songArtist ?? DEF_ARTIST);
final artist = artistsInDb.firstWhereOrNull((a) => a.name == artistName);
if (artist != null)
artists.add(artist);
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,
lastModified: lastModified,
albumArtPath: albumArtMap[albumId],
)
);
albums.add(AlbumModel.fromMetadata(
songData: songData,
albumId: albumId,
albumArtPath: albumArtMap[albumId],
));
songs.add(SongModel.fromMetadata(
path: songFile.path,
songData: songData,
albumId: albumId,
lastModified: lastModified,
albumArtPath: albumArtMap[albumId],
));
}
final albumAccentTasks = albums
@ -189,18 +207,14 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
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);
if (song.albumId == albumId) return song.copyWith(color: color);
return song;
})
.toList();
}).toList();
}
asyncExecutor.close();
return {
'SONGS': songs,
'ALBUMS': albums,
@ -209,17 +223,18 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
}
Future<List<File>> getSongFilesInDirectory(
String path,
Set<String> allowedExtensions,
Set<String> blockedPaths
) async {
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);
try {
final extension = p.extension(item.path).toLowerCase().substring(1);
return allowedExtensions.contains(extension);
} on RangeError {
return false;
}
})
.asyncMap((item) => File(item.path))
.toList();
@ -233,49 +248,43 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
final lastModified = songFile.lastModifiedSync();
final song = await _musicDataSource.getSongByPath(songFile.path);
if (song == null || lastModified.isAfter(song.lastModified))
songFilesToCheck.add(songFile);
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));
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));
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 {
Future<String> cacheAlbumArt(Uint8List albumArt, int albumId) async {
final Directory dir = await getApplicationSupportDirectory();
final albumArtPath = '${dir.path}/$albumId';
final file = File(albumArtPath);
file.writeAsBytesSync(albumArt.data);
file.writeAsBytesSync(albumArt);
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 {
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 = {};
@ -288,22 +297,21 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
if (albumIdMap.containsKey(albumString)) {
// we already encountered the album (at least by albumString)
// make sure the id is consistent
if (album.id != albumIdMap[albumString])
if (album.id != albumIdMap[albumString])
songs.add(
song.copyWith(
albumId: albumIdMap[albumString],
color: colorMap[albumIdMap[albumString]],
),
);
else
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;
if (album.color != null) colorMap[album.id] = album.color;
}
albums.add(album);
final artist = artistsInDb.singleWhere((a) => a.name == album.artist);
@ -313,17 +321,15 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
}
}
return {
'songs': songs,
'albums': albums,
'songs': songs,
'albums': albums,
'artists': artists,
'albumIdMap': albumIdMap,
'albumIdMap': albumIdMap,
'albumArtMap': albumArtMap
};
}
Future<List<(File, Metadata)>> getMetadataForFiles(
List<File> filesToCheck) async {
Future<List<(File, Metadata)>> getMetadataForFiles(List<File> filesToCheck) async {
final List<(File, Metadata)> songsMetadata = [];
final tasks = filesToCheck.map((e) => MetadataLoader(e));
@ -360,8 +366,7 @@ 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)) {
@ -369,7 +374,6 @@ class LocalMusicFetcherImpl implements LocalMusicFetcher {
}
return files;
}
}
List<AsyncTask> metadataLoaderTypeRegister() => [MetadataLoader(File(''))];
@ -400,17 +404,17 @@ class MetadataLoader extends AsyncTask<File, (File, Metadata)> {
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]) {
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);
@ -419,8 +423,7 @@ class AccentGenerator extends AsyncTask<(int, File), (int, Color?)> {
@override
FutureOr<(int, Color?)> run() async {
final image = await _loadImage(pictureFile);
if (image == null)
return (albumId, null);
if (image == null) return (albumId, null);
return (albumId, getBackgroundColor(image));
}
@ -429,10 +432,10 @@ class AccentGenerator extends AsyncTask<(int, File), (int, Color?)> {
img.Image? image;
try {
image = img.decodeImage(data);
} catch(e) {
} catch (e) {
return null;
}
return image;
}
}

View file

@ -1,3 +1,4 @@
import 'package:permission_handler/permission_handler.dart';
import 'package:rxdart/rxdart.dart';
import '../../domain/repositories/settings_repository.dart';
@ -5,6 +6,7 @@ import '../datasources/settings_data_source.dart';
class SettingsRepositoryImpl implements SettingsRepository {
SettingsRepositoryImpl(this._settingsDataSource) {
Permission.manageExternalStorage.isGranted.then(_manageExternalStorageGrantedSubject.add);
_settingsDataSource.fileExtensionsStream.listen(_fileExtensionsSubject.add);
_settingsDataSource.playAlbumsInOrderStream.listen(_playAlbumsInOrderSubject.add);
_settingsDataSource.listenedPercentageStream.listen(
@ -14,6 +16,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
final SettingsDataSource _settingsDataSource;
final BehaviorSubject<bool> _manageExternalStorageGrantedSubject = BehaviorSubject();
final BehaviorSubject<String> _fileExtensionsSubject = BehaviorSubject();
final BehaviorSubject<bool> _playAlbumsInOrderSubject = BehaviorSubject();
final BehaviorSubject<int> _listenedPercentageSubject = BehaviorSubject();
@ -33,6 +36,24 @@ class SettingsRepositoryImpl implements SettingsRepository {
await _settingsDataSource.removeLibraryFolder(path);
}
@override
ValueStream<bool> get manageExternalStorageGranted => _manageExternalStorageGrantedSubject.stream;
@override
Future<void> setManageExternalStorageGranted(bool granted) async {
if (granted) {
if (!await Permission.manageExternalStorage.isGranted) {
_manageExternalStorageGrantedSubject
.add(await Permission.manageExternalStorage.request().isGranted);
}
} else {
if (await Permission.manageExternalStorage.isGranted) {
await openAppSettings();
_manageExternalStorageGrantedSubject.add(await Permission.manageExternalStorage.isGranted);
}
}
}
@override
ValueStream<String> get fileExtensionsStream => _fileExtensionsSubject.stream;

View file

@ -18,22 +18,29 @@ int? parseYear(String? yearString) {
/// 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);
image = img.quantize(
img.copyResize(image, width: 64),
numberOfColors: 16,
method: img.QuantizeMethod.octree,
);
image = image.convert(format: img.Format.uint8, numChannels: 3);
final data = image.getBytes(order: img.ChannelOrder.rgb);
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]);
for (var i = 0; i < data.length; i += 3) {
final argb = Color.fromARGB(255, data[i], data[i + 1], data[i + 2]);
counts[argb] = (counts[argb] ?? 0) + 1;
}
final sortedColors = counts.keys.toList()..sort(
(a, b) =>
colorWeight(b, counts[b]!)
.compareTo(colorWeight(a, counts[a]!))
);
return sortedColors.first;
num maxWeight = -1;
Color maxColor = counts.keys.first;
for (final e in counts.entries) {
final weight = colorWeight(e.key, e.value);
if (weight > maxWeight) {
maxWeight = weight;
maxColor = e.key;
}
}
return maxColor;
}
/// This function weighs colors and gives them a rating.
@ -45,4 +52,4 @@ Color getBackgroundColor(img.Image image) {
num colorWeight(Color color, int count) {
final hslColor = HSLColor.fromColor(color);
return count * pow(hslColor.saturation, 2) * (0.55 - (hslColor.lightness - 0.55).abs());
}
}