mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
Auto-dispose media-item playback providers after their last listener leaves so scrolling does not retain every visited row subscription. Test disposal of 200 rows, active-row behavior and reattachment.
273 lines
8.6 KiB
Dart
273 lines
8.6 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/models/unified_library_item.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
|
import 'package:spotiflac_android/services/library_database.dart';
|
|
import 'package:spotiflac_android/services/music_player_service.dart';
|
|
|
|
final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
|
|
return musicPlayerMediaItemEvents();
|
|
});
|
|
|
|
/// Use the Library's identity for favorites, including restored queues and
|
|
/// playback started from a file path rather than a Library row ID.
|
|
final playerCollectionTrackProvider = FutureProvider.autoDispose
|
|
.family<Track, MediaItem>((ref, item) async {
|
|
final source = item.extras?['source']?.toString() ?? '';
|
|
if (source.isNotEmpty) {
|
|
final history = await ref
|
|
.read(downloadHistoryProvider.notifier)
|
|
.getByFilePathAsync(source);
|
|
if (history != null) {
|
|
return UnifiedLibraryItem.fromDownloadHistory(history).toTrack();
|
|
}
|
|
}
|
|
final database = LibraryDatabase.instance;
|
|
final row = await database.getById(item.id);
|
|
if (row != null) {
|
|
return UnifiedLibraryItem.fromLocalLibrary(
|
|
LocalLibraryItem.fromJson(row),
|
|
).toTrack();
|
|
}
|
|
if (source.isNotEmpty) {
|
|
final matches = await database.findByTrackAndArtist(
|
|
item.title,
|
|
item.artist ?? '',
|
|
);
|
|
for (final row in matches) {
|
|
final local = LocalLibraryItem.fromJson(row);
|
|
if (local.filePath == source) {
|
|
return UnifiedLibraryItem.fromLocalLibrary(local).toTrack();
|
|
}
|
|
}
|
|
}
|
|
return Track(
|
|
id: item.id,
|
|
name: item.title,
|
|
artistName: item.artist ?? '',
|
|
albumName: item.album ?? '',
|
|
coverUrl: item.artUri?.toString(),
|
|
duration: item.duration?.inSeconds ?? 0,
|
|
source: 'local',
|
|
);
|
|
});
|
|
|
|
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
|
|
return musicPlayerPlaybackStateEvents();
|
|
});
|
|
|
|
/// Small derived providers keep position ticks from rebuilding widgets that
|
|
/// only care about transport state (and vice versa).
|
|
final playbackPositionProvider = Provider<Duration>((ref) {
|
|
return ref.watch(
|
|
playbackStateProvider.select(
|
|
(state) => state.value?.position ?? Duration.zero,
|
|
),
|
|
);
|
|
});
|
|
|
|
final playbackPlayingProvider = Provider<bool>((ref) {
|
|
return ref.watch(
|
|
playbackStateProvider.select((state) => state.value?.playing ?? false),
|
|
);
|
|
});
|
|
|
|
final playbackLoadingProvider = Provider<bool>((ref) {
|
|
return ref.watch(
|
|
playbackStateProvider.select((state) {
|
|
final processingState = state.value?.processingState;
|
|
return processingState == AudioProcessingState.loading ||
|
|
processingState == AudioProcessingState.buffering;
|
|
}),
|
|
);
|
|
});
|
|
|
|
/// Transport state for one media item. Non-current Library cells only watch
|
|
/// the current media ID; the active cell additionally follows play/loading so
|
|
/// a transport toggle does not rebuild the entire Library grid.
|
|
/// Release each cell's subscription when it scrolls out of the Library.
|
|
final mediaItemPlaybackUiProvider = Provider.autoDispose
|
|
.family<({bool isCurrent, bool isPlaying, bool isLoading}), String>((
|
|
ref,
|
|
mediaId,
|
|
) {
|
|
final currentId = ref.watch(
|
|
currentMediaItemProvider.select((state) => state.value?.id),
|
|
);
|
|
if (mediaId.isEmpty || currentId != mediaId) {
|
|
return (isCurrent: false, isPlaying: false, isLoading: false);
|
|
}
|
|
return (
|
|
isCurrent: true,
|
|
isPlaying: ref.watch(playbackPlayingProvider),
|
|
isLoading: ref.watch(playbackLoadingProvider),
|
|
);
|
|
});
|
|
|
|
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
|
|
return musicPlayerQueueEvents();
|
|
});
|
|
|
|
class MusicPlayerController {
|
|
const MusicPlayerController();
|
|
|
|
MusicPlayerHandler? get _handler => musicPlayerHandler;
|
|
|
|
bool get isAvailable => _handler != null;
|
|
|
|
DateTime? get sleepTimerEndsAt => _handler?.sleepTimerEndsAt;
|
|
|
|
Future<MusicPlayerHandler?> ensureInitialized() async {
|
|
try {
|
|
return await initMusicPlayer();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> playAll(
|
|
List<PlayableMedia> items, {
|
|
int initialIndex = 0,
|
|
}) async {
|
|
final handler = await ensureInitialized();
|
|
await handler?.setQueueAndPlay(items, initialIndex: initialIndex);
|
|
}
|
|
|
|
Future<void> playSingle(PlayableMedia item) => playAll([item]);
|
|
|
|
Future<void> playHistory(
|
|
List<DownloadHistoryItem> items, {
|
|
int initialIndex = 0,
|
|
}) async {
|
|
final media = items
|
|
.where((i) => i.filePath.trim().isNotEmpty)
|
|
.map(playableFromHistory)
|
|
.toList();
|
|
if (media.isEmpty) return;
|
|
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
|
}
|
|
|
|
Future<void> playLocal(
|
|
List<LocalLibraryItem> items, {
|
|
int initialIndex = 0,
|
|
}) async {
|
|
final media = items
|
|
.where((i) => i.filePath.trim().isNotEmpty)
|
|
.map(playableFromLocal)
|
|
.toList();
|
|
if (media.isEmpty) return;
|
|
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
|
}
|
|
|
|
Future<void> play() async => _handler?.play();
|
|
Future<void> pause() async => _handler?.pause();
|
|
Future<void> stop() async => _handler?.stop();
|
|
Future<void> seek(Duration position) async => _handler?.seek(position);
|
|
Future<void> next() async => _handler?.skipToNext();
|
|
Future<void> previous() async => _handler?.skipToPrevious();
|
|
|
|
void setSleepTimer(Duration duration) => _handler?.setSleepTimer(duration);
|
|
|
|
void cancelSleepTimer() => _handler?.cancelSleepTimer();
|
|
|
|
Future<void> togglePlayPause(bool isPlaying) async {
|
|
final handler = _handler;
|
|
if (handler == null) return;
|
|
final processingState = handler.playbackState.value.processingState;
|
|
if (processingState == AudioProcessingState.loading ||
|
|
processingState == AudioProcessingState.buffering) {
|
|
return;
|
|
}
|
|
if (isPlaying) {
|
|
await handler.pause();
|
|
} else {
|
|
await handler.play();
|
|
}
|
|
}
|
|
|
|
Future<void> setShuffle(bool enabled) async {
|
|
await _handler?.setShuffleMode(
|
|
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
|
|
);
|
|
}
|
|
|
|
Future<void> setRepeatMode(AudioServiceRepeatMode mode) async =>
|
|
_handler?.setRepeatMode(mode);
|
|
|
|
Future<void> playNext(PlayableMedia item) async =>
|
|
(await ensureInitialized())?.enqueue(item, playNext: true);
|
|
|
|
Future<void> addToQueue(PlayableMedia item) async =>
|
|
(await ensureInitialized())?.enqueue(item);
|
|
|
|
Future<void> playNextHistory(DownloadHistoryItem item) async =>
|
|
playNext(playableFromHistory(item));
|
|
|
|
Future<void> addToQueueHistory(DownloadHistoryItem item) async =>
|
|
addToQueue(playableFromHistory(item));
|
|
|
|
Future<void> playNextLocal(LocalLibraryItem item) async =>
|
|
playNext(playableFromLocal(item));
|
|
|
|
Future<void> addToQueueLocal(LocalLibraryItem item) async =>
|
|
addToQueue(playableFromLocal(item));
|
|
|
|
Future<void> jumpTo(int index) async => _handler?.skipToQueueItem(index);
|
|
|
|
void moveQueueItem(int oldIndex, int newIndex) {
|
|
_handler?.moveQueueItem(oldIndex, newIndex);
|
|
}
|
|
}
|
|
|
|
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
|
|
(ref) => const MusicPlayerController(),
|
|
);
|
|
|
|
PlayableMedia playableFromHistory(DownloadHistoryItem item) {
|
|
return PlayableMedia(
|
|
id: item.id,
|
|
source: item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
artUri: (item.coverUrl != null && item.coverUrl!.trim().isNotEmpty)
|
|
? item.coverUrl
|
|
: null,
|
|
duration: (item.duration != null && item.duration! > 0)
|
|
? Duration(seconds: item.duration!)
|
|
: null,
|
|
bitDepth: item.bitDepth,
|
|
sampleRate: item.sampleRate,
|
|
bitrate: item.bitrate,
|
|
format: item.format,
|
|
explicit: item.explicit,
|
|
);
|
|
}
|
|
|
|
PlayableMedia playableFromLocal(LocalLibraryItem item) {
|
|
String? art;
|
|
final cover = item.coverPath;
|
|
if (cover != null && cover.trim().isNotEmpty) {
|
|
art = cover.startsWith('http') || cover.startsWith('content://')
|
|
? cover
|
|
: Uri.file(cover).toString();
|
|
}
|
|
return PlayableMedia(
|
|
id: item.id,
|
|
source: item.filePath,
|
|
title: item.trackName,
|
|
artist: item.artistName,
|
|
album: item.albumName,
|
|
artUri: art,
|
|
duration: (item.duration != null && item.duration! > 0)
|
|
? Duration(seconds: item.duration!)
|
|
: null,
|
|
bitDepth: item.bitDepth,
|
|
sampleRate: item.sampleRate,
|
|
bitrate: item.bitrate,
|
|
format: item.format,
|
|
explicit: item.explicit,
|
|
);
|
|
}
|