This commit is contained in:
austinried
2023-04-28 09:24:51 +09:00
parent 35b037f66c
commit f0f812e66a
402 changed files with 34368 additions and 62769 deletions

View File

@@ -0,0 +1,704 @@
import 'dart:async';
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pool/pool.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:rxdart/rxdart.dart';
import 'package:synchronized/synchronized.dart';
import '../cache/image_cache.dart';
import '../database/database.dart';
import '../models/music.dart';
import '../models/query.dart';
import '../models/support.dart';
import '../sources/music_source.dart';
import '../state/settings.dart';
import 'cache_service.dart';
import 'settings_service.dart';
part 'audio_service.g.dart';
class QueueSourceItem {
final MediaItem mediaItem;
final UriAudioSource audioSource;
final QueueData queueData;
const QueueSourceItem({
required this.mediaItem,
required this.audioSource,
required this.queueData,
});
}
class QueueSlice {
final QueueSourceItem? prev;
final QueueSourceItem? current;
final QueueSourceItem? next;
const QueueSlice(this.prev, this.current, this.next);
}
@Riverpod(keepAlive: true)
FutureOr<AudioControl> audioControlInit(AudioControlInitRef ref) async {
final imageCache = ref.watch(imageCacheProvider);
return AudioService.init(
builder: () => AudioControl(AudioPlayer(), ref),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.subtracks2.channel.audio',
androidNotificationChannelName: 'Music playback',
androidNotificationIcon: 'drawable/ic_stat_name',
),
cacheManager: imageCache,
cacheKeyResolver: (mediaItem) =>
mediaItem.data.artCache?.thumbnailArtCacheKey ?? '',
);
}
@Riverpod(keepAlive: true)
AudioControl audioControl(AudioControlRef ref) {
return ref.watch(audioControlInitProvider).requireValue;
}
class AudioControl extends BaseAudioHandler with QueueHandler, SeekHandler {
static const radioLength = 10;
Stream<Duration> get position => _player.positionStream;
BehaviorSubject<QueueMode> queueMode = BehaviorSubject.seeded(QueueMode.user);
BehaviorSubject<List<int>?> shuffleIndicies = BehaviorSubject.seeded(null);
BehaviorSubject<AudioServiceRepeatMode> repeatMode =
BehaviorSubject.seeded(AudioServiceRepeatMode.none);
final Ref _ref;
final AudioPlayer _player;
int? _queueLength;
int? _previousCurrentTrackIndex;
final _syncPool = Pool(1);
final _playLock = Lock();
final _currentIndexIgnore = <int?>[null, 0];
final ConcatenatingAudioSource _audioSource =
ConcatenatingAudioSource(children: []);
SubtracksDatabase get _db => _ref.read(databaseProvider);
CacheService get _cache => _ref.read(cacheServiceProvider);
MusicSource get _source => _ref.read(musicSourceProvider);
int get _sourceId => _ref.read(sourceIdProvider);
AudioControl(this._player, this._ref) {
_player.playbackEventStream.listen((PlaybackEvent event) {
final playing = _player.playing;
playbackState.add(playbackState.value.copyWith(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
MediaControl.skipToNext,
],
systemActions: const {
MediaAction.seek,
},
androidCompactActionIndices: const [0, 1, 3],
processingState: const {
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[_player.processingState]!,
playing: playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
queueIndex: event.currentIndex,
));
});
shuffleIndicies.listen((value) {
playbackState.add(playbackState.value.copyWith(
shuffleMode: value != null
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none,
));
});
repeatMode.listen((value) {
playbackState.add(playbackState.value.copyWith(repeatMode: value));
});
_player.processingStateStream.listen((event) async {
if (event == ProcessingState.completed) {
if (_audioSource.length > 0) {
yell('completed');
await stop();
await seek(Duration.zero);
}
}
});
_ref.listen(sourceIdProvider, (_, __) async {
await _clearAudioSource(true);
await _db.clearQueue();
});
_ref.listen(maxBitrateProvider, (prev, next) async {
if (prev?.valueOrNull != next.valueOrNull) {
await _resyncQueue(true);
}
});
_ref.listen(
settingsServiceProvider.select((value) => value.app.streamFormat),
(prev, next) async {
await _resyncQueue(true);
});
_player.durationStream.listen((duration) {
if (mediaItem.valueOrNull == null) return;
final index = queue.valueOrNull?.indexOf(mediaItem.value!);
final updated = mediaItem.value!.copyWith(duration: duration);
mediaItem.add(updated);
if (index != null) {
queue.add(queue.value..replaceRange(index, index + 1, [updated]));
}
});
_player.currentIndexStream.listen((index) async {
if (_currentIndexIgnore.contains(index)) {
_currentIndexIgnore.remove(index);
return;
}
if (index == null || index >= _audioSource.sequence.length) return;
final queueIndex = _audioSource.sequence[index].tag;
if (queueIndex != null) {
await _db.setCurrentTrack(queueIndex);
}
});
_db.currentTrackIndex().watchSingleOrNull().listen((index) {
// distict() except for when in loop one mode
if (repeatMode.value != AudioServiceRepeatMode.one &&
_previousCurrentTrackIndex == index) {
return;
}
_previousCurrentTrackIndex = index;
_syncPool.withResource(() => _syncQueue(index));
});
}
Future<void> init() async {
await _player.setAudioSource(_audioSource, preload: false);
final last = await _db.getLastAudioState().getSingleOrNull();
if (last == null) return;
_queueLength = await _db.queueLength().getSingleOrNull();
final repeat = {
RepeatMode.none: AudioServiceRepeatMode.none,
RepeatMode.all: AudioServiceRepeatMode.all,
RepeatMode.one: AudioServiceRepeatMode.one,
}[last.repeat]!;
repeatMode.add(repeat);
await repeatMode.firstWhere((e) => e == repeat);
queueMode.add(last.queueMode);
await queueMode.firstWhere((e) => e == last.queueMode);
if (last.shuffleIndicies != null) {
final indicies = last.shuffleIndicies!.unlock;
shuffleIndicies.add(indicies);
await shuffleIndicies.firstWhere((e) => e == indicies);
}
final startIndex = await _db.currentTrackIndex().getSingleOrNull();
if (startIndex != null && _queueLength != null) {
await _preparePlayer(startIndex);
}
}
Future<void> playSongs({
QueueMode mode = QueueMode.user,
required QueueContextType context,
String? contextId,
required ListQuery query,
required FutureOr<Iterable<Song>> Function(ListQuery query) getSongs,
int? startIndex,
bool? shuffle,
}) async {
if (!_playLock.locked) {
return _playLock.synchronized(
() => _playSongs(
mode: mode,
context: context,
contextId: contextId,
query: query,
getSongs: getSongs,
startIndex: startIndex,
shuffle: shuffle,
),
);
}
}
Future<void> playRadio({
required QueueContextType context,
String? contextId,
ListQuery query = const ListQuery(),
required FutureOr<Iterable<Song>> Function(ListQuery query) getSongs,
}) async {
await playSongs(
mode: QueueMode.radio,
context: QueueContextType.library,
contextId: contextId,
query: query.copyWith(
sort: SortBy(
column: 'SIN(songs.ROWID + ${Random().nextInt(10000)})',
),
),
getSongs: getSongs,
startIndex: 0,
);
}
Future<void> _playSongs({
QueueMode mode = QueueMode.user,
required QueueContextType context,
String? contextId,
required ListQuery query,
required FutureOr<Iterable<Song>> Function(ListQuery query) getSongs,
int? startIndex,
bool? shuffle,
}) async {
shuffle = shuffle ?? shuffleIndicies.valueOrNull != null;
queueMode.add(mode);
if (mode == QueueMode.radio) {
if (repeatMode.value != AudioServiceRepeatMode.none) {
await _loop(AudioServiceRepeatMode.none);
}
if (shuffleIndicies.value != null) {
await _shuffle(unshuffle: true);
}
}
await _clearAudioSource();
const limit = 500;
_queueLength = 0;
if ((startIndex == null || startIndex >= limit) && shuffle) {
startIndex = Random().nextInt(limit);
} else {
startIndex ??= 0;
}
// clear the queue and load the initial songs only up to the startIndex
await _db.transaction(() async {
await _db.clearQueue();
while (_queueLength! <= startIndex!) {
final songs = await getSongs(query.copyWith(
page: Pagination(limit: limit, offset: _queueLength!),
));
await _loadQueueSongs(songs, _queueLength!, context, contextId);
if (songs.length < limit) {
break;
}
}
});
// if there are less songs than the limit and we're shuffling,
// choose a new random startIndex
if (startIndex >= _queueLength!) {
startIndex = Random().nextInt(_queueLength!);
}
await _preparePlayer(startIndex, shuffle);
await _db.setCurrentTrack(startIndex);
play();
const maxLength = 10000;
// no need to do extra loading if we've already loaded everything
if (_queueLength! < limit) return;
while (true) {
final songs = await getSongs(query.copyWith(
page: Pagination(limit: limit, offset: _queueLength!),
));
await _loadQueueSongs(songs, _queueLength!, context, contextId);
if (songs.length < limit || _queueLength! >= maxLength) {
break;
}
}
}
Future<void> _loadQueueSongs(
Iterable<Song> songs,
int total,
QueueContextType context,
String? contextId,
) async {
await _db.insertQueue(songs.mapIndexed(
(i, song) => QueueCompanion.insert(
index: Value(i + (_queueLength ?? 0)),
sourceId: song.sourceId,
id: song.id,
context: context,
contextId: Value(contextId),
),
));
_queueLength = (_queueLength ?? 0) + songs.length;
if (shuffleIndicies.valueOrNull != null) {
await _generateShuffleIndicies(startIndex: _player.currentIndex);
}
}
Future<void> _preparePlayer(int startIndex, [bool? shuffle]) async {
if (shuffle == true) {
await _shuffle(startIndex: startIndex);
} else if (shuffle == false) {
await _shuffle(unshuffle: true);
}
final slice = await _getQueueSlice(startIndex);
if (slice == null) {
throw StateError('Could not get queue slice!');
}
final list =
[slice.prev, slice.current, slice.next].whereNotNull().toList();
mediaItem.add(slice.current!.mediaItem);
queue.add(list.map((e) => e.mediaItem).toList());
yell('addAll');
await _audioSource.addAll(list.map((e) => e.audioSource).toList());
await _player.seek(Duration.zero, index: list.indexOf(slice.current!));
}
Future<void> _syncQueue(int? index) async {
if (index == null || _queueLength == null) return;
final slice = await _getQueueSlice(index);
if (slice == null || slice.current == null) return;
mediaItem.add(slice.current!.mediaItem);
queue.add(
[slice.prev, slice.current, slice.next]
.map((e) => e?.mediaItem)
.whereNotNull()
.toList(),
);
final sourceIndex = _player.currentIndex;
final sourceNeedsNext = sourceIndex == _audioSource.length - 1;
final sourceNeedsPrev = sourceIndex == 0;
if (sourceNeedsNext && slice.next != null) {
yell('add');
await _audioSource.add(slice.next!.audioSource);
}
if (sourceNeedsPrev && slice.prev != null) {
await _insertFirstAudioSource(slice.prev!.audioSource);
}
}
Future<void> _loop(AudioServiceRepeatMode mode) async {
repeatMode.add(mode);
await repeatMode.firstWhere((e) => e == mode);
await _resyncQueue();
}
Future<void> _generateShuffleIndicies({
bool unshuffle = false,
int? startIndex,
}) async {
final indicies = unshuffle
? null
: (List.generate(_queueLength!, (i) => i + 1)
..insert(0, 0)
..removeLast()
..shuffle());
if (indicies != null && startIndex != null) {
indicies.removeAt(indicies.indexOf(startIndex));
indicies.insert(0, startIndex);
}
shuffleIndicies.add(indicies);
await shuffleIndicies.firstWhere((e) => e == indicies);
}
Future<void> _shuffle({bool unshuffle = false, int? startIndex}) async {
await _generateShuffleIndicies(
unshuffle: unshuffle,
startIndex: startIndex,
);
await _resyncQueue();
}
Future<void> _resyncQueue([bool reloadCurrent = false]) async {
return _syncPool.withResource(() async {
final currentSource =
_player.sequenceState?.currentSource as UriAudioSource?;
if (currentSource == null) return;
final currentSourceIndex = _player.sequence!.indexOf(currentSource);
await _pruneAudioSources(currentSourceIndex);
if (reloadCurrent && !currentSource.uri.isScheme('file')) {
final position = _player.position;
final item = (await _getQueueItems([currentSource.tag]))[0];
await _audioSource.clear();
await _audioSource.add(item.audioSource);
await seek(position);
}
await _syncQueue(currentSource.tag);
});
}
int _realIndex(int index) {
if (shuffleIndicies.valueOrNull == null) {
return index;
}
if (index < 0 || index >= shuffleIndicies.value!.length) {
return -1;
}
return shuffleIndicies.value![index];
}
int _effectiveIndex(int index) {
if (shuffleIndicies.valueOrNull == null) {
return index;
}
return shuffleIndicies.value!.indexOf(index);
}
Future<void> _insertFirstAudioSource(AudioSource source) {
yell('insert');
final wait = _audioSource.insert(0, source);
_currentIndexIgnore.add(1);
return wait;
}
Future<void> _pruneAudioSources(int keepIndex) async {
if (keepIndex > 0) {
yell('removeRange 0');
final wait = _audioSource.removeRange(0, keepIndex);
_currentIndexIgnore.add(0);
await wait;
}
if (_audioSource.length > 1) {
yell('removeRange 1');
await _audioSource.removeRange(1, _audioSource.length);
}
}
Future<void> _clearAudioSource([bool clearMetadata = false]) async {
// await _player.stop();
yell('_clearAudioSource');
await _audioSource.clear();
if (clearMetadata) {
mediaItem.add(null);
queue.add([]);
queueTitle.add('');
}
}
Future<QueueSlice?> _getQueueSlice(int index) async {
if (_queueLength == null) {
return null;
}
final effectiveIndex = _effectiveIndex(index);
int nextIndex;
int prevIndex;
if (repeatMode.value == AudioServiceRepeatMode.none) {
nextIndex = _realIndex(effectiveIndex + 1);
prevIndex = _realIndex(effectiveIndex - 1);
} else if (repeatMode.value == AudioServiceRepeatMode.one) {
nextIndex = index;
prevIndex = index;
} else {
nextIndex = _realIndex(
effectiveIndex + 1 >= _queueLength! ? 0 : effectiveIndex + 1,
);
prevIndex = _realIndex(
effectiveIndex - 1 < 0 ? _queueLength! - 1 : effectiveIndex - 1,
);
}
final slice = await _getQueueItems([prevIndex, index, nextIndex]);
final current = slice.firstWhereOrNull(
(e) => e.queueData.index == index,
);
final next = slice.firstWhereOrNull((e) => e.queueData.index == nextIndex);
final prev = slice.firstWhereOrNull((e) => e.queueData.index == prevIndex);
return QueueSlice(prev, current, next);
}
Future<List<QueueSourceItem>> _getQueueItems(List<int> indexes) async {
final slice = await _db.queueInIndicies(indexes).get();
final songs =
await _db.songsInIds(_sourceId, slice.map((e) => e.id).toList()).get();
final songMap = {for (var song in songs) song.id: song};
final albumIds = songs.map((e) => e.albumId).whereNotNull().toSet();
final albums = await _db.albumsInIds(_sourceId, albumIds.toList()).get();
final albumArtMap = {
for (var album in albums) album.id: _mapArtCache(album)
};
final queueItems = slice.map(
(item) => _mapSong(
songMap[item.id]!,
MediaItemData(
sourceId: item.sourceId,
contextType: item.context,
contextId: item.contextId,
artCache: albumArtMap[songMap[item.id]!.albumId],
),
item,
),
);
return queueItems.toList();
}
QueueSourceItem _mapSong(Song song, MediaItemData data, QueueData queueData) {
final item = MediaItem(
id: song.id,
title: song.title,
artist: song.artist,
album: song.album,
duration: song.duration,
artUri: data.artCache?.thumbnailArtUri ?? _cache.placeholderThumbImageUri,
extras: {},
);
item.data = data;
return QueueSourceItem(
mediaItem: item,
audioSource: song.downloadFilePath != null
? AudioSource.file(song.downloadFilePath!, tag: queueData.index)
: AudioSource.uri(_source.streamUri(song.id), tag: queueData.index),
queueData: queueData,
);
}
MediaItemArtCache _mapArtCache(Album album) {
final full = _cache.albumArt(album, thumbnail: false);
final thumbnail = _cache.albumArt(album, thumbnail: true);
return MediaItemArtCache(
fullArtUri: full.uri,
fullArtCacheKey: full.cacheKey,
thumbnailArtUri: thumbnail.uri,
thumbnailArtCacheKey: thumbnail.cacheKey,
);
}
///
/// AudioHandler
///
@override
Future<void> play() async {
await _player.play();
}
@override
Future<void> pause() async {
await _player.pause();
}
@override
Future<void> stop() async {
await _player.stop();
}
@override
Future<void> seek(Duration position) async {
await _player.seek(position);
}
@override
Future<void> skipToNext() => _player.seekToNext();
@override
Future<void> skipToPrevious() => _player.seekToPrevious();
@override
Future<void> skipToQueueItem(int index) async {
if (_player.effectiveIndices == null || _player.effectiveIndices!.isEmpty) {
return;
}
index = _player.effectiveIndices![index];
if (index < 0 || index >= queue.value.length) {
return;
}
await _player.seek(Duration.zero, index: index);
}
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
if (queueMode.value == QueueMode.radio) {
switch (repeatMode) {
case AudioServiceRepeatMode.all:
case AudioServiceRepeatMode.group:
case AudioServiceRepeatMode.one:
return _loop(AudioServiceRepeatMode.one);
default:
return _loop(AudioServiceRepeatMode.none);
}
}
return _loop(repeatMode);
}
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
if (queueMode.value == QueueMode.radio) {
return;
}
switch (shuffleMode) {
case AudioServiceShuffleMode.all:
case AudioServiceShuffleMode.group:
return _shuffle(startIndex: _player.sequenceState?.currentSource?.tag);
case AudioServiceShuffleMode.none:
return _shuffle(unshuffle: true);
}
}
}
void yell(String msg) {
print('===================================================================<');
print(msg);
print('===================================================================>');
}

View File

@@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audio_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$audioControlInitHash() => r'ecde06a9f4f7be5ca28e1f5f3c1f3e7fb2ce8dc5';
/// See also [audioControlInit].
@ProviderFor(audioControlInit)
final audioControlInitProvider = FutureProvider<AudioControl>.internal(
audioControlInit,
name: r'audioControlInitProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$audioControlInitHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef AudioControlInitRef = FutureProviderRef<AudioControl>;
String _$audioControlHash() => r'ea50108f29366182238a5e68d6cdcd1d874e4ba2';
/// See also [audioControl].
@ProviderFor(audioControl)
final audioControlProvider = Provider<AudioControl>.internal(
audioControl,
name: r'audioControlProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$audioControlHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef AudioControlRef = ProviderRef<AudioControl>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -0,0 +1,112 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../cache/image_cache.dart';
import '../models/music.dart';
import '../models/support.dart';
import '../sources/music_source.dart';
import '../state/init.dart';
import '../state/settings.dart';
part 'cache_service.g.dart';
String _cacheKey(
String type,
int sourceId,
String albumId, [
bool thumbnail = false,
]) {
return '$type${thumbnail ? 'Thumb' : ''}-$sourceId-$albumId';
}
String albumArtCacheKey(
int sourceId,
String albumId,
bool thumbnail,
) {
return _cacheKey('albumArt', sourceId, albumId, thumbnail);
}
String playlistArtCacheKey(
int sourceId,
String albumId,
bool thumbnail,
) {
return _cacheKey('playlistArt', sourceId, albumId, thumbnail);
}
String artistArtCacheKey(
int sourceId,
String albumId,
bool thumbnail,
) {
return _cacheKey('artistArt', sourceId, albumId, thumbnail);
}
@Riverpod(keepAlive: true)
CacheService cacheService(CacheServiceRef ref) {
final imageCache = ref.watch(imageCacheProvider);
final source = ref.watch(musicSourceProvider);
final placeholderImageUri =
ref.watch(placeholderImageUriProvider).requireValue;
final placeholderThumbImageUri =
ref.watch(placeholderThumbImageUriProvider).requireValue;
return CacheService(
imageCache: imageCache,
source: source,
placeholderImageUri: placeholderImageUri,
placeholderThumbImageUri: placeholderThumbImageUri,
);
}
class CacheService {
final CacheManager imageCache;
final MusicSource source;
final Uri placeholderImageUri;
final Uri placeholderThumbImageUri;
CacheService({
required this.imageCache,
required this.source,
required this.placeholderImageUri,
required this.placeholderThumbImageUri,
});
UriCacheInfo albumArt(Album album, {bool thumbnail = true}) {
final id = album.coverArt ?? album.id;
return UriCacheInfo(
uri: source.coverArtUri(id, thumbnail: thumbnail),
cacheKey: albumArtCacheKey(source.id, album.id, thumbnail),
cacheManager: imageCache,
);
}
UriCacheInfo playlistArt(Playlist playlist, {bool thumbnail = true}) {
final id = playlist.coverArt ?? playlist.id;
return UriCacheInfo(
uri: source.coverArtUri(id),
cacheKey: playlistArtCacheKey(source.id, playlist.id, thumbnail),
cacheManager: imageCache);
}
UriCacheInfo placeholder({bool thumbnail = true}) {
final uri = thumbnail ? placeholderThumbImageUri : placeholderImageUri;
return UriCacheInfo(
uri: uri,
cacheKey: uri.toString(),
cacheManager: imageCache,
);
}
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true}) {
return source.artistArtUri(artistId, thumbnail: thumbnail);
}
CacheInfo artistArtCacheInfo(String artistId, {bool thumbnail = true}) {
return CacheInfo(
cacheKey: artistArtCacheKey(source.id, artistId, thumbnail),
cacheManager: imageCache,
);
}
}

View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cache_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$cacheServiceHash() => r'5e83011fbdfc5a962d43e3311b666dde2c455e24';
/// See also [cacheService].
@ProviderFor(cacheService)
final cacheServiceProvider = Provider<CacheService>.internal(
cacheService,
name: r'cacheServiceProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$cacheServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef CacheServiceRef = ProviderRef<CacheService>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -0,0 +1,589 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_downloader/flutter_downloader.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:mime/mime.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../http/client.dart';
import '../models/music.dart';
import '../models/query.dart';
import '../models/support.dart';
import '../state/music.dart';
import '../state/settings.dart';
import 'cache_service.dart';
part 'download_service.freezed.dart';
part 'download_service.g.dart';
@freezed
class DownloadState with _$DownloadState {
const factory DownloadState({
@Default(IListConst([])) IList<Download> downloads,
@Default(IListConst([])) IList<SourceId> deletes,
@Default(IListConst([])) IList<SourceId> listDownloads,
@Default(IListConst([])) IList<SourceId> listCancels,
required String saveDir,
}) = _DownloadState;
}
@freezed
class Download with _$Download {
const Download._();
const factory Download({
required String taskId,
required DownloadTaskStatus status,
required int progress,
required String url,
required String? filename,
required String savedDir,
required int timeCreated,
required bool allowCellular,
}) = _Download;
factory Download.fromTask(DownloadTask task) {
return Download(
taskId: task.taskId,
status: task.status,
progress: task.progress,
url: task.url,
filename: task.filename,
savedDir: task.savedDir,
timeCreated: task.timeCreated,
allowCellular: task.allowCellular,
);
}
DownloadTask toTask() {
return DownloadTask(
taskId: taskId,
status: status,
progress: progress,
url: url,
filename: filename,
savedDir: savedDir,
timeCreated: timeCreated,
allowCellular: allowCellular,
);
}
}
enum SongDownloadState {
none,
inProgress,
completed,
}
@Riverpod(keepAlive: true)
class DownloadService extends _$DownloadService {
static final ReceivePort _port = ReceivePort();
@override
DownloadState build() {
return const DownloadState(saveDir: '');
}
Future<void> init() async {
await FlutterDownloader.initialize(
// debug: true,
ignoreSsl: true,
);
state = state.copyWith(
saveDir: path.join(
(await getApplicationDocumentsDirectory()).path,
'downloads',
),
);
_bindBackgroundIsolate();
await _syncDownloadTasks();
FlutterDownloader.registerCallback(downloadCallback, step: 1);
}
Future<void> downloadAlbum(Album album) async {
return _downloadList(album, () async {
final cache = ref.read(cacheServiceProvider);
await Future.wait([
_cacheArtistArt(album.sourceId, album.artistId, false),
_cacheArtistArt(album.sourceId, album.artistId, true),
_cacheImage(cache.albumArt(album, thumbnail: false)),
_cacheImage(cache.albumArt(album, thumbnail: true)),
]);
if (_isCanceled(album)) return;
final songs = await _albumSongs(album, SongDownloadState.none);
if (_isCanceled(album)) return;
for (var song in songs) {
await _downloadSong(song);
if (_isCanceled(album)) return;
}
});
}
Future<void> downloadPlaylist(Playlist playlist) async {
return _downloadList(playlist, () async {
final songs = await _playlistSongs(playlist, SongDownloadState.none);
if (_isCanceled(playlist)) return;
final albumIds = songs.map((e) => e.albumId).whereNotNull().toSet();
final albums =
await ref.read(albumsInIdsProvider(albumIds.toIList()).future);
final artistIds = albums.map((e) => e.artistId).whereNotNull().toSet();
final cache = ref.read(cacheServiceProvider);
await Future.wait([
_cacheImage(cache.playlistArt(playlist, thumbnail: true)),
_cacheImage(cache.playlistArt(playlist, thumbnail: false)),
...albums.map((a) => _cacheImage(cache.albumArt(a, thumbnail: true))),
...albums.map((a) => _cacheImage(cache.albumArt(a, thumbnail: false))),
...artistIds.map(
(artistId) => _cacheArtistArt(playlist.sourceId, artistId, true),
),
...artistIds.map(
(artistId) => _cacheArtistArt(playlist.sourceId, artistId, false),
),
]);
if (_isCanceled(playlist)) return;
for (var song in songs) {
await _downloadSong(song);
if (_isCanceled(playlist)) return;
}
});
}
Future<void> cancelAlbum(Album album) async {
return _cancelList(album, () async {
final songs = await _albumSongs(album, SongDownloadState.inProgress);
for (var song in songs) {
try {
await FlutterDownloader.cancel(taskId: song.downloadTaskId!);
} catch (e) {
//
}
}
});
}
Future<void> cancelPlaylist(Playlist playlist) async {
return _cancelList(playlist, () async {
final songs =
await _playlistSongs(playlist, SongDownloadState.inProgress);
for (var song in songs) {
await FlutterDownloader.cancel(taskId: song.downloadTaskId!);
}
});
}
Future<void> deleteAlbum(Album album) async {
return _deleteList(album, () async {
final db = ref.read(databaseProvider);
final songs = await _albumSongs(album, SongDownloadState.completed);
for (var song in songs) {
await _tryDeleteFile(song.downloadFilePath!);
await db.deleteSongDownloadFile(song.sourceId, song.id);
}
});
}
Future<void> deletePlaylist(Playlist playlist) async {
return _deleteList(playlist, () async {
final db = ref.read(databaseProvider);
final songs = await _playlistSongs(playlist, SongDownloadState.completed);
for (var song in songs) {
if (await _tryDeleteFile(song.downloadFilePath!)) {
await db.deleteSongDownloadFile(song.sourceId, song.id);
}
}
});
}
Future<void> deleteAll(int sourceId) async {
final db = ref.read(databaseProvider);
final albumIds = await db.albumIdsWithDownloaded(sourceId).get();
for (var id in albumIds) {
await deleteAlbum(await (db.albumById(sourceId, id)).getSingle());
}
}
Future<void> _downloadList(
SourceIdentifiable list,
Future<void> Function() callback,
) async {
final sourceId = SourceId.from(list);
if (state.listDownloads.contains(sourceId)) {
return;
}
state = state.copyWith(listDownloads: state.listDownloads.add(sourceId));
try {
await callback();
} finally {
state = state.copyWith(
listDownloads: state.listDownloads.remove(sourceId),
);
}
}
Future<void> _cancelList(
SourceIdentifiable list,
Future<void> Function() callback,
) async {
final sourceId = SourceId.from(list);
if (state.listCancels.contains(sourceId)) return;
state = state.copyWith(
listCancels: state.listCancels.add(sourceId),
);
if (state.listDownloads.contains(sourceId)) {
var tries = 0;
while (tries < 60) {
await Future.delayed(const Duration(seconds: 1));
if (!state.listDownloads.contains(sourceId)) {
break;
}
}
}
try {
await callback();
} finally {
state = state.copyWith(
listCancels: state.listCancels.remove(sourceId),
);
}
}
Future<void> _deleteList(
SourceIdentifiable list,
Future<void> Function() callback,
) async {
final sourceId = SourceId.from(list);
if (state.deletes.contains(sourceId)) {
return;
}
state = state.copyWith(deletes: state.deletes.add(sourceId));
try {
await callback();
} finally {
state = state.copyWith(deletes: state.deletes.remove(sourceId));
}
}
Future<void> _downloadSong(Song song) async {
if (song.downloadFilePath != null || song.downloadTaskId != null) {
return;
}
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final http = ref.read(httpClientProvider);
final uri = source.downloadUri(song.id);
final head = await http.head(uri);
final contentType = head.headers['content-type'];
if (contentType == null) {
throw StateError('Bad HTTP response from HEAD during download');
}
final mime = contentType.split(';').first.toLowerCase();
if (!mime.startsWith('audio') && !mime.startsWith('application')) {
throw StateError('Download error: MIME-type $mime is not audio');
}
String? ext = extensionFromMime(mime);
if (ext == mime) {
ext = null;
}
final saveDir = Directory(
path.join(state.saveDir, song.sourceId.toString()),
);
await saveDir.create(recursive: true);
final taskId = await FlutterDownloader.enqueue(
url: source.downloadUri(song.id).toString(),
savedDir: saveDir.path,
fileName: ext != null ? '${song.id}.$ext' : song.id,
headers: subtracksHeaders,
openFileFromNotification: false,
showNotification: false,
);
await db.updateSongDownloadTask(taskId, song.sourceId, song.id);
}
Future<void> _cacheImage(UriCacheInfo cache) async {
final cachedFile = await cache.cacheManager.getFileFromCache(
cache.cacheKey,
ignoreMemCache: true,
);
if (cachedFile == null) {
try {
await cache.cacheManager.getSingleFile(
cache.uri.toString(),
key: cache.cacheKey,
);
} catch (_) {}
}
}
Future<void> _cacheArtistArt(
int sourceId,
String? artistId,
bool thumbnail,
) async {
if (artistId == null) {
return;
}
final cache = ref.read(cacheServiceProvider);
try {
final uri = await cache.artistArtUri(artistId, thumbnail: thumbnail);
if (uri == null) {
return;
}
await _cacheImage(UriCacheInfo(
uri: uri,
cacheKey:
cache.artistArtCacheInfo(artistId, thumbnail: thumbnail).cacheKey,
cacheManager: cache.imageCache,
));
} catch (_) {}
}
bool _isCanceled(SourceIdentifiable item) {
return state.listCancels.contains(SourceId.from(item));
}
List<FilterWith> _downloadFilters(SongDownloadState state) {
switch (state) {
case SongDownloadState.none:
return [
const FilterWith.isNull(column: 'download_task_id'),
const FilterWith.isNull(column: 'download_file_path'),
];
case SongDownloadState.completed:
return [
const FilterWith.isNull(column: 'download_file_path', invert: true),
];
case SongDownloadState.inProgress:
return [
const FilterWith.isNull(column: 'download_task_id', invert: true),
];
}
}
Future<List<Song>> _albumSongs(
Album album,
SongDownloadState state,
) {
return ref
.read(databaseProvider)
.albumSongsList(
SourceId.from(album),
ListQuery(
sort: const SortBy(column: 'disc, track'),
filters: _downloadFilters(state).lock,
),
)
.get();
}
Future<List<Song>> _playlistSongs(
Playlist playlist,
SongDownloadState state,
) {
return ref
.read(databaseProvider)
.playlistSongsList(
SourceId.from(playlist),
ListQuery(
sort: const SortBy(column: 'playlist_songs.position'),
filters: _downloadFilters(state).lock,
),
)
.get();
}
Future<void> _syncDownloadTasks() async {
final tasks = await FlutterDownloader.loadTasks() ?? [];
final downloads = tasks.map((e) => Download.fromTask(e)).toIList();
state = state.copyWith(downloads: downloads);
final db = ref.read(databaseProvider);
final songs = await db.songsWithDownloadTasks().get();
await _deleteTasksNotIn(songs.map((e) => e.downloadTaskId!).toList());
final deleteTaskStatus = [
DownloadTaskStatus.canceled,
DownloadTaskStatus.failed,
DownloadTaskStatus.undefined,
];
for (var song in songs) {
final download = downloads.firstWhereOrNull(
(t) => t.taskId == song.downloadTaskId,
);
if (download == null) {
await db.clearSongDownloadTaskBySong(song.sourceId, song.id);
continue;
}
if (deleteTaskStatus.anyIs(download.status)) {
await _clearFailedDownload(download);
} else if (download.status == DownloadTaskStatus.complete) {
await _completeDownload(download);
}
}
}
Future<bool> _tryDeleteFile(String filePath) async {
try {
final file = File(filePath);
await file.delete();
return true;
} catch (_) {
return false;
}
}
Future<void> _deleteTasksNotIn(List<String> taskIds) async {
if (taskIds.isEmpty) {
return;
}
await FlutterDownloader.loadTasksWithRawQuery(
query: 'DELETE FROM task WHERE task_id NOT IN '
'(${taskIds.map((e) => "'$e'").join(',')})',
);
}
Future<void> _deleteTask(String taskId) async {
await FlutterDownloader.loadTasksWithRawQuery(
query: 'DELETE FROM task WHERE task_id = \'$taskId\'',
);
}
Future<DownloadTask?> _getTask(String taskId) async {
return (await FlutterDownloader.loadTasksWithRawQuery(
query: 'SELECT * FROM task WHERE task_id = \'$taskId\'',
))
?.firstOrNull;
}
Future<void> _completeDownload(Download download) async {
final db = ref.read(databaseProvider);
await db.completeSongDownload(
path.join(download.savedDir, download.filename),
download.taskId,
);
await _deleteTask(download.taskId);
state = state.copyWith(
downloads: state.downloads.removeWhere(
(d) => d.taskId == download.taskId,
),
);
}
Future<void> _clearFailedDownload(Download download) async {
final db = ref.read(databaseProvider);
await db.clearSongDownloadTask(download.taskId);
await _deleteTask(download.taskId);
await _tryDeleteFile(path.join(download.savedDir, download.filename));
state = state.copyWith(
downloads: state.downloads.removeWhere(
(d) => d.taskId == download.taskId,
),
);
}
void _bindBackgroundIsolate([retry = 0]) {
final isSuccess = IsolateNameServer.registerPortWithName(
_port.sendPort,
'downloader_send_port',
);
if (!isSuccess && retry < 100) {
_unbindBackgroundIsolate();
_bindBackgroundIsolate(retry + 1);
return;
} else if (retry >= 100) {
throw StateError('Could not bind background isolate for downloads');
}
_port.asyncMap((dynamic data) async {
final taskId = (data as List<dynamic>)[0] as String;
final status = DownloadTaskStatus(data[1] as int);
final progress = data[2] as int;
var download = state.downloads.firstWhereOrNull(
(task) => task.taskId == taskId,
);
if (download == null) {
final task = await _getTask(taskId);
if (task == null) {
return;
}
download = Download.fromTask(task);
}
download = download.copyWith(status: status, progress: progress);
state = state.copyWith(
downloads: state.downloads.replaceFirstWhere(
(d) => d.taskId == taskId,
(d) => download!,
addIfNotFound: true,
),
);
final deleteTaskStatus = [
DownloadTaskStatus.canceled,
DownloadTaskStatus.failed,
DownloadTaskStatus.undefined,
];
if (status == DownloadTaskStatus.complete) {
await _completeDownload(download);
} else if (deleteTaskStatus.anyIs(status)) {
await _clearFailedDownload(download);
}
}).listen((_) {});
}
void _unbindBackgroundIsolate() {
IsolateNameServer.removePortNameMapping('downloader_send_port');
}
@pragma('vm:entry-point')
static void downloadCallback(
String id,
DownloadTaskStatus status,
int progress,
) {
IsolateNameServer.lookupPortByName('downloader_send_port')?.send(
[id, status.value, progress],
);
}
}

View File

@@ -0,0 +1,495 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'download_service.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$DownloadState {
IList<Download> get downloads => throw _privateConstructorUsedError;
IList<SourceId> get deletes => throw _privateConstructorUsedError;
IList<SourceId> get listDownloads => throw _privateConstructorUsedError;
IList<SourceId> get listCancels => throw _privateConstructorUsedError;
String get saveDir => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$DownloadStateCopyWith<DownloadState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DownloadStateCopyWith<$Res> {
factory $DownloadStateCopyWith(
DownloadState value, $Res Function(DownloadState) then) =
_$DownloadStateCopyWithImpl<$Res, DownloadState>;
@useResult
$Res call(
{IList<Download> downloads,
IList<SourceId> deletes,
IList<SourceId> listDownloads,
IList<SourceId> listCancels,
String saveDir});
}
/// @nodoc
class _$DownloadStateCopyWithImpl<$Res, $Val extends DownloadState>
implements $DownloadStateCopyWith<$Res> {
_$DownloadStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? downloads = null,
Object? deletes = null,
Object? listDownloads = null,
Object? listCancels = null,
Object? saveDir = null,
}) {
return _then(_value.copyWith(
downloads: null == downloads
? _value.downloads
: downloads // ignore: cast_nullable_to_non_nullable
as IList<Download>,
deletes: null == deletes
? _value.deletes
: deletes // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
listDownloads: null == listDownloads
? _value.listDownloads
: listDownloads // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
listCancels: null == listCancels
? _value.listCancels
: listCancels // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
saveDir: null == saveDir
? _value.saveDir
: saveDir // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$_DownloadStateCopyWith<$Res>
implements $DownloadStateCopyWith<$Res> {
factory _$$_DownloadStateCopyWith(
_$_DownloadState value, $Res Function(_$_DownloadState) then) =
__$$_DownloadStateCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{IList<Download> downloads,
IList<SourceId> deletes,
IList<SourceId> listDownloads,
IList<SourceId> listCancels,
String saveDir});
}
/// @nodoc
class __$$_DownloadStateCopyWithImpl<$Res>
extends _$DownloadStateCopyWithImpl<$Res, _$_DownloadState>
implements _$$_DownloadStateCopyWith<$Res> {
__$$_DownloadStateCopyWithImpl(
_$_DownloadState _value, $Res Function(_$_DownloadState) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? downloads = null,
Object? deletes = null,
Object? listDownloads = null,
Object? listCancels = null,
Object? saveDir = null,
}) {
return _then(_$_DownloadState(
downloads: null == downloads
? _value.downloads
: downloads // ignore: cast_nullable_to_non_nullable
as IList<Download>,
deletes: null == deletes
? _value.deletes
: deletes // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
listDownloads: null == listDownloads
? _value.listDownloads
: listDownloads // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
listCancels: null == listCancels
? _value.listCancels
: listCancels // ignore: cast_nullable_to_non_nullable
as IList<SourceId>,
saveDir: null == saveDir
? _value.saveDir
: saveDir // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
class _$_DownloadState implements _DownloadState {
const _$_DownloadState(
{this.downloads = const IListConst([]),
this.deletes = const IListConst([]),
this.listDownloads = const IListConst([]),
this.listCancels = const IListConst([]),
required this.saveDir});
@override
@JsonKey()
final IList<Download> downloads;
@override
@JsonKey()
final IList<SourceId> deletes;
@override
@JsonKey()
final IList<SourceId> listDownloads;
@override
@JsonKey()
final IList<SourceId> listCancels;
@override
final String saveDir;
@override
String toString() {
return 'DownloadState(downloads: $downloads, deletes: $deletes, listDownloads: $listDownloads, listCancels: $listCancels, saveDir: $saveDir)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_DownloadState &&
const DeepCollectionEquality().equals(other.downloads, downloads) &&
const DeepCollectionEquality().equals(other.deletes, deletes) &&
const DeepCollectionEquality()
.equals(other.listDownloads, listDownloads) &&
const DeepCollectionEquality()
.equals(other.listCancels, listCancels) &&
(identical(other.saveDir, saveDir) || other.saveDir == saveDir));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(downloads),
const DeepCollectionEquality().hash(deletes),
const DeepCollectionEquality().hash(listDownloads),
const DeepCollectionEquality().hash(listCancels),
saveDir);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_DownloadStateCopyWith<_$_DownloadState> get copyWith =>
__$$_DownloadStateCopyWithImpl<_$_DownloadState>(this, _$identity);
}
abstract class _DownloadState implements DownloadState {
const factory _DownloadState(
{final IList<Download> downloads,
final IList<SourceId> deletes,
final IList<SourceId> listDownloads,
final IList<SourceId> listCancels,
required final String saveDir}) = _$_DownloadState;
@override
IList<Download> get downloads;
@override
IList<SourceId> get deletes;
@override
IList<SourceId> get listDownloads;
@override
IList<SourceId> get listCancels;
@override
String get saveDir;
@override
@JsonKey(ignore: true)
_$$_DownloadStateCopyWith<_$_DownloadState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$Download {
String get taskId => throw _privateConstructorUsedError;
DownloadTaskStatus get status => throw _privateConstructorUsedError;
int get progress => throw _privateConstructorUsedError;
String get url => throw _privateConstructorUsedError;
String? get filename => throw _privateConstructorUsedError;
String get savedDir => throw _privateConstructorUsedError;
int get timeCreated => throw _privateConstructorUsedError;
bool get allowCellular => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$DownloadCopyWith<Download> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DownloadCopyWith<$Res> {
factory $DownloadCopyWith(Download value, $Res Function(Download) then) =
_$DownloadCopyWithImpl<$Res, Download>;
@useResult
$Res call(
{String taskId,
DownloadTaskStatus status,
int progress,
String url,
String? filename,
String savedDir,
int timeCreated,
bool allowCellular});
}
/// @nodoc
class _$DownloadCopyWithImpl<$Res, $Val extends Download>
implements $DownloadCopyWith<$Res> {
_$DownloadCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? taskId = null,
Object? status = null,
Object? progress = null,
Object? url = null,
Object? filename = freezed,
Object? savedDir = null,
Object? timeCreated = null,
Object? allowCellular = null,
}) {
return _then(_value.copyWith(
taskId: null == taskId
? _value.taskId
: taskId // ignore: cast_nullable_to_non_nullable
as String,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as DownloadTaskStatus,
progress: null == progress
? _value.progress
: progress // ignore: cast_nullable_to_non_nullable
as int,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
filename: freezed == filename
? _value.filename
: filename // ignore: cast_nullable_to_non_nullable
as String?,
savedDir: null == savedDir
? _value.savedDir
: savedDir // ignore: cast_nullable_to_non_nullable
as String,
timeCreated: null == timeCreated
? _value.timeCreated
: timeCreated // ignore: cast_nullable_to_non_nullable
as int,
allowCellular: null == allowCellular
? _value.allowCellular
: allowCellular // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
/// @nodoc
abstract class _$$_DownloadCopyWith<$Res> implements $DownloadCopyWith<$Res> {
factory _$$_DownloadCopyWith(
_$_Download value, $Res Function(_$_Download) then) =
__$$_DownloadCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String taskId,
DownloadTaskStatus status,
int progress,
String url,
String? filename,
String savedDir,
int timeCreated,
bool allowCellular});
}
/// @nodoc
class __$$_DownloadCopyWithImpl<$Res>
extends _$DownloadCopyWithImpl<$Res, _$_Download>
implements _$$_DownloadCopyWith<$Res> {
__$$_DownloadCopyWithImpl(
_$_Download _value, $Res Function(_$_Download) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? taskId = null,
Object? status = null,
Object? progress = null,
Object? url = null,
Object? filename = freezed,
Object? savedDir = null,
Object? timeCreated = null,
Object? allowCellular = null,
}) {
return _then(_$_Download(
taskId: null == taskId
? _value.taskId
: taskId // ignore: cast_nullable_to_non_nullable
as String,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as DownloadTaskStatus,
progress: null == progress
? _value.progress
: progress // ignore: cast_nullable_to_non_nullable
as int,
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
filename: freezed == filename
? _value.filename
: filename // ignore: cast_nullable_to_non_nullable
as String?,
savedDir: null == savedDir
? _value.savedDir
: savedDir // ignore: cast_nullable_to_non_nullable
as String,
timeCreated: null == timeCreated
? _value.timeCreated
: timeCreated // ignore: cast_nullable_to_non_nullable
as int,
allowCellular: null == allowCellular
? _value.allowCellular
: allowCellular // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
class _$_Download extends _Download {
const _$_Download(
{required this.taskId,
required this.status,
required this.progress,
required this.url,
required this.filename,
required this.savedDir,
required this.timeCreated,
required this.allowCellular})
: super._();
@override
final String taskId;
@override
final DownloadTaskStatus status;
@override
final int progress;
@override
final String url;
@override
final String? filename;
@override
final String savedDir;
@override
final int timeCreated;
@override
final bool allowCellular;
@override
String toString() {
return 'Download(taskId: $taskId, status: $status, progress: $progress, url: $url, filename: $filename, savedDir: $savedDir, timeCreated: $timeCreated, allowCellular: $allowCellular)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$_Download &&
(identical(other.taskId, taskId) || other.taskId == taskId) &&
(identical(other.status, status) || other.status == status) &&
(identical(other.progress, progress) ||
other.progress == progress) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.filename, filename) ||
other.filename == filename) &&
(identical(other.savedDir, savedDir) ||
other.savedDir == savedDir) &&
(identical(other.timeCreated, timeCreated) ||
other.timeCreated == timeCreated) &&
(identical(other.allowCellular, allowCellular) ||
other.allowCellular == allowCellular));
}
@override
int get hashCode => Object.hash(runtimeType, taskId, status, progress, url,
filename, savedDir, timeCreated, allowCellular);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$_DownloadCopyWith<_$_Download> get copyWith =>
__$$_DownloadCopyWithImpl<_$_Download>(this, _$identity);
}
abstract class _Download extends Download {
const factory _Download(
{required final String taskId,
required final DownloadTaskStatus status,
required final int progress,
required final String url,
required final String? filename,
required final String savedDir,
required final int timeCreated,
required final bool allowCellular}) = _$_Download;
const _Download._() : super._();
@override
String get taskId;
@override
DownloadTaskStatus get status;
@override
int get progress;
@override
String get url;
@override
String? get filename;
@override
String get savedDir;
@override
int get timeCreated;
@override
bool get allowCellular;
@override
@JsonKey(ignore: true)
_$$_DownloadCopyWith<_$_Download> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'download_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$downloadServiceHash() => r'92e963b5c070f4d1edb0cd81899b16393c2b9a70';
/// See also [DownloadService].
@ProviderFor(DownloadService)
final downloadServiceProvider =
NotifierProvider<DownloadService, DownloadState>.internal(
DownloadService.new,
name: r'downloadServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$downloadServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$DownloadService = Notifier<DownloadState>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -0,0 +1,123 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../http/client.dart';
import '../models/settings.dart';
import '../sources/subsonic/client.dart';
import '../state/init.dart';
import 'download_service.dart';
part 'settings_service.g.dart';
@Riverpod(keepAlive: true)
class SettingsService extends _$SettingsService {
SubtracksDatabase get _db => ref.read(databaseProvider);
@override
Settings build() {
return const Settings();
}
Future<void> init() async {
final sources = await _db.allSubsonicSources().get();
final settings = await _db.getAppSettings().getSingleOrNull();
state = Settings(
sources: sources
.sorted((a, b) => a.createdAt.compareTo(b.createdAt))
.toIList(),
activeSource: sources.singleWhereOrNull((e) => e.isActive == true),
app: settings ?? const AppSettings(),
);
}
Future<void> createSource(
SourcesCompanion source,
SubsonicSourcesCompanion subsonic,
) async {
final client = SubsonicClient(
SubsonicSettings(
id: 1,
name: source.name.value,
address: source.address.value,
features: IList(),
username: subsonic.username.value,
password: subsonic.password.value,
useTokenAuth: true,
isActive: true,
createdAt: DateTime.now(),
),
ref.read(httpClientProvider),
);
final features = IList([
if (await client.testFeature(SubsonicFeature.emptyQuerySearch))
SubsonicFeature.emptyQuerySearch,
]);
await _db.createSource(
source,
subsonic.copyWith(features: Value(features)),
);
await init();
}
Future<void> updateSource(SubsonicSettings source) async {
await _db.updateSource(source);
await init();
}
Future<void> deleteSource(int sourceId) async {
await ref.read(downloadServiceProvider.notifier).deleteAll(sourceId);
await _db.deleteSource(sourceId);
await init();
}
Future<void> setActiveSource(int id) async {
await _db.setActiveSource(id);
await init();
}
Future<void> addTestSource(String prefix) async {
final env = ref.read(envProvider).requireValue;
await createSource(
SourcesCompanion.insert(
name: env['${prefix}_SERVER_NAME']!,
address: Uri.parse(env['${prefix}_SERVER_URL']!),
),
SubsonicSourcesCompanion.insert(
features: IList(),
username: env['${prefix}_SERVER_USERNAME']!,
password: env['${prefix}_SERVER_PASSWORD']!,
useTokenAuth: const Value(true),
),
);
await init();
}
Future<void> setMaxBitrateWifi(int bitrate) async {
await _db.updateSettings(
state.app.copyWith(maxBitrateWifi: bitrate).toCompanion(),
);
await init();
}
Future<void> setMaxBitrateMobile(int bitrate) async {
await _db.updateSettings(
state.app.copyWith(maxBitrateMobile: bitrate).toCompanion(),
);
await init();
}
Future<void> setStreamFormat(String? streamFormat) async {
await _db.updateSettings(
state.app.copyWith(streamFormat: streamFormat).toCompanion(),
);
await init();
}
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$settingsServiceHash() => r'85f2bd5eedc3f791fe03a6707748bc95277c6aaf';
/// See also [SettingsService].
@ProviderFor(SettingsService)
final settingsServiceProvider =
NotifierProvider<SettingsService, Settings>.internal(
SettingsService.new,
name: r'settingsServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$settingsServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SettingsService = Notifier<Settings>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions

View File

@@ -0,0 +1,105 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../database/database.dart';
import '../state/settings.dart';
part 'sync_service.g.dart';
@Riverpod(keepAlive: true)
class SyncService extends _$SyncService {
@override
DateTime build() {
return DateTime.now();
}
Future<void> syncAll() async {
final db = ref.read(databaseProvider);
await db.transaction(() async {
await Future.wait([
db.transaction(_syncAllArtists),
db.transaction(_syncAllAlbums),
db.transaction(_syncAllPlaylists),
db.transaction(_syncAllSongs),
]);
});
state = DateTime.now();
}
Future<void> _syncAllArtists() async {
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final ids = <String>[];
await for (var artists in source.allArtists()) {
ids.addAll(artists.map((e) => e.id.value));
await db.saveArtists(artists);
}
await db.deleteArtistsNotIn(source.id, ids);
}
Future<void> _syncAllAlbums() async {
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final ids = <String>[];
await for (var albums in source.allAlbums()) {
ids.addAll(albums.map((e) => e.id.value));
await db.saveAlbums(albums);
}
await db.deleteAlbumsNotIn(source.id, ids);
}
Future<void> _syncAllPlaylists() async {
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final ids = <String>[];
await for (var playlists in source.allPlaylists()) {
ids.addAll(playlists.map((e) => e.playist.id.value));
await db.savePlaylists(playlists);
}
await db.deletePlaylistsNotIn(source.id, ids);
}
Future<void> _syncAllSongs() async {
final source = ref.read(musicSourceProvider);
final db = ref.read(databaseProvider);
final ids = <String>[];
await for (var songs in source.allSongs()) {
ids.addAll(songs.map((e) => e.id.value));
await db.saveSongs(songs);
}
await db.deleteSongsNotIn(source.id, ids);
}
// Future<void> syncArtist(String id) async {
// final source = ref.read(musicSourceProvider);
// final db = ref.read(databaseProvider);
// final artist = await source.artist(id);
// await saveArtist(db, artist);
// }
// Future<void> syncAlbum(String id) async {
// final source = ref.read(musicSourceProvider);
// final db = ref.read(databaseProvider);
// final album = await source.album(id);
// await saveAlbum(db, album);
// }
// Future<void> syncPlaylist(String id) async {
// final source = ref.read(musicSourceProvider);
// final db = ref.read(databaseProvider);
// final playlist = await source.playlist(id);
// await savePlaylist(db, playlist);
// }
}

View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync_service.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$syncServiceHash() => r'2b8da374c3143bc56f17115440d57bc70468a17e';
/// See also [SyncService].
@ProviderFor(SyncService)
final syncServiceProvider = NotifierProvider<SyncService, DateTime>.internal(
SyncService.new,
name: r'syncServiceProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$syncServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SyncService = Notifier<DateTime>;
// ignore_for_file: unnecessary_raw_strings, subtype_of_sealed_class, invalid_use_of_internal_member, do_not_use_environment, prefer_const_constructors, public_member_api_docs, avoid_private_typedef_functions