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:
parent
9eb71b3b76
commit
52df779dd1
12 changed files with 279 additions and 158 deletions
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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": {},
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
|
@ -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();
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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() {}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue