mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 15:02:42 +01:00
v2
This commit is contained in:
704
lib/services/audio_service.dart
Normal file
704
lib/services/audio_service.dart
Normal 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('===================================================================>');
|
||||
}
|
||||
38
lib/services/audio_service.g.dart
Normal file
38
lib/services/audio_service.g.dart
Normal 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
|
||||
112
lib/services/cache_service.dart
Normal file
112
lib/services/cache_service.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/services/cache_service.g.dart
Normal file
23
lib/services/cache_service.g.dart
Normal 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
|
||||
589
lib/services/download_service.dart
Normal file
589
lib/services/download_service.dart
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
495
lib/services/download_service.freezed.dart
Normal file
495
lib/services/download_service.freezed.dart
Normal 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;
|
||||
}
|
||||
25
lib/services/download_service.g.dart
Normal file
25
lib/services/download_service.g.dart
Normal 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
|
||||
123
lib/services/settings_service.dart
Normal file
123
lib/services/settings_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
25
lib/services/settings_service.g.dart
Normal file
25
lib/services/settings_service.g.dart
Normal 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
|
||||
105
lib/services/sync_service.dart
Normal file
105
lib/services/sync_service.dart
Normal 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);
|
||||
// }
|
||||
}
|
||||
23
lib/services/sync_service.g.dart
Normal file
23
lib/services/sync_service.g.dart
Normal 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
|
||||
Reference in New Issue
Block a user