Files
SpotiFLAC-Mobile/lib/services/music_player_runtime.dart
T

315 lines
12 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/network_metadata_service.dart';
import 'package:spotiflac_android/services/network_storage_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/playback_notification.dart';
import 'package:spotiflac_android/services/player_widget_service.dart';
import 'package:spotiflac_android/services/source_deletion_events.dart';
import 'package:spotiflac_android/services/system_volume_service.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MusicPlayerRuntime');
/// The process services used by a player. Tests can replace effects without
/// changing the queue, transport, interruption or automation algorithms.
class PlaybackDependencies {
const PlaybackDependencies({
this.createDeck = _createDeck,
this.audioSession = _getAudioSession,
this.readMetadata = PlatformBridge.readFileMetadata,
this.readNetworkMetadata = _readNetworkMetadata,
this.resolveNetworkSource = _resolveNetworkSource,
this.initializeHandler = _initializeAudioHandler,
AppStateDatabase? sessionDatabase,
LibraryDatabase? libraryDatabase,
ListeningRecorder? recorder,
SourceDeletionEvents? sourceDeletionEvents,
Stream<double>? volumeChanges,
bool? isIOS,
bool? isAndroid,
this.notificationChannel = const MethodChannel(
'com.zarz.spotiflac/playback_notification',
),
}) : _sessionDatabase = sessionDatabase,
_libraryDatabase = libraryDatabase,
_recorder = recorder,
_sourceDeletionEvents = sourceDeletionEvents,
_volumeChanges = volumeChanges,
_isIOS = isIOS,
_isAndroid = isAndroid;
final MusicPlaybackDeck Function(String playerId) createDeck;
final Future<AudioSession> Function() audioSession;
final Future<Map<String, dynamic>> Function(String, {String? displayName})
readMetadata;
final Future<Map<String, dynamic>> Function(String) readNetworkMetadata;
final Future<String> Function(String) resolveNetworkSource;
final Future<MusicPlayerHandler> Function(MusicPlayerHandler Function())
initializeHandler;
final MethodChannel notificationChannel;
final AppStateDatabase? _sessionDatabase;
final LibraryDatabase? _libraryDatabase;
final ListeningRecorder? _recorder;
final SourceDeletionEvents? _sourceDeletionEvents;
final Stream<double>? _volumeChanges;
final bool? _isIOS, _isAndroid;
AppStateDatabase get sessionDatabase =>
_sessionDatabase ?? AppStateDatabase.instance;
LibraryDatabase get libraryDatabase =>
_libraryDatabase ?? LibraryDatabase.instance;
ListeningRecorder get recorder => _recorder ?? listeningRecorder;
SourceDeletionEvents get sourceDeletionEvents =>
_sourceDeletionEvents ?? SourceDeletionEvents.instance;
Stream<double> get volumeChanges =>
_volumeChanges ?? SystemVolumeService.instance.changes;
bool get isIOS => _isIOS ?? Platform.isIOS;
bool get isAndroid => _isAndroid ?? Platform.isAndroid;
static MusicPlaybackDeck _createDeck(String playerId) =>
MusicPlaybackDeck(playerId: playerId);
static Future<AudioSession> _getAudioSession() => AudioSession.instance;
static Future<Map<String, dynamic>> _readNetworkMetadata(String source) =>
NetworkMetadataService.instance.read(source);
static Future<String> _resolveNetworkSource(String source) =>
NetworkStorageService.instance.resolve(source);
static Future<MusicPlayerHandler> _initializeAudioHandler(
MusicPlayerHandler Function() builder,
) => AudioService.init(
builder: builder,
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.zarz.spotiflac.playback',
androidNotificationChannelName: 'Playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
),
);
}
/// Owns a player lifetime and its latest immutable settings. Configuration is
/// available before lazy initialization and remains live while it is pending.
class MusicPlayerRuntime {
MusicPlayerRuntime({
AppSettings settings = const AppSettings(),
this.dependencies = const PlaybackDependencies(),
}) : _settings = settings;
final PlaybackDependencies dependencies;
AppSettings _settings;
MusicPlayerHandler? _handler;
void Function()? _unsubscribeSourceDeletion;
Future<MusicPlayerHandler>? _initFuture;
Future<void>? _restoreFuture;
final _handlers = StreamController<MusicPlayerHandler?>.broadcast(sync: true);
bool _initializing = false;
bool _disposed = false;
Future<void>? _disposeFuture;
String _unknownTitle = 'Unknown title', _unknownArtist = 'Unknown artist';
PlaybackNotification _notification = const PlaybackNotification();
Future<bool> Function(MediaItem)? _toggleFavorite;
Future<void> Function()? exclusiveAudioHook;
AppSettings get settings => _settings;
MusicPlayerHandler? get handler => _initializing ? null : _handler;
String get unknownTitle => _unknownTitle;
String get unknownArtist => _unknownArtist;
PlaybackNotification get notification => _notification;
void configure(AppSettings settings) {
final previous = _settings;
_settings = settings;
_handler?.applySettings(previous);
}
void updateStrings({
required String unknownTitle,
required String unknownArtist,
}) {
_unknownTitle = unknownTitle;
_unknownArtist = unknownArtist;
}
void configureNotification({
required PlaybackNotification presentation,
required Future<bool> Function(MediaItem) toggleFavorite,
}) {
_notification = presentation;
_toggleFavorite = toggleFavorite;
_handler?.refreshNotificationControls();
if (dependencies.isIOS) {
dependencies.notificationChannel.setMethodCallHandler((call) async {
if (call.method == 'favorite') {
await _handler?.customAction(PlaybackNotification.favoriteAction);
}
});
unawaited(_publishNotificationFavorite());
}
}
Future<bool?> toggleFavorite(MediaItem item) async =>
_toggleFavorite?.call(item);
void updateFavorite(MediaItem item, bool loved) {
_notification = _notification.withFavorite(item, loved);
_handler?.refreshNotificationControls();
if (dependencies.isIOS) unawaited(_publishNotificationFavorite());
}
Future<void> _publishNotificationFavorite() async {
try {
await dependencies.notificationChannel.invokeMethod<void>('update', {
'enabled': _notification.mornye && _notification.mediaId != null,
'loved': _notification.loved,
'label': _notification.favoriteActionLabel,
});
} on MissingPluginException {
// Tests do not install the iOS remote-command bridge.
} on PlatformException catch (error) {
_log.w('Could not update the system favorite action: ${error.code}');
}
}
void attach(MusicPlayerHandler handler) {
if (_disposed) throw StateError('This playback runtime was disposed');
final existing = _handler;
if (existing != null && !identical(existing, handler)) {
throw StateError('This playback runtime already owns a handler');
}
_handler = handler;
if (!identical(existing, handler)) {
_unsubscribeSourceDeletion = dependencies.sourceDeletionEvents.subscribe(
handler.onSourceDeleted,
);
}
if (!_initializing && !identical(existing, handler)) _handlers.add(handler);
}
void detach(MusicPlayerHandler handler) {
if (!identical(_handler, handler)) return;
_unsubscribeSourceDeletion?.call();
_unsubscribeSourceDeletion = null;
_handler = null;
_restoreFuture = null;
if (!_handlers.isClosed) _handlers.add(null);
}
Future<MusicPlayerHandler> initialize() {
if (_disposed) {
return Future.error(StateError('This playback runtime was disposed'));
}
final pending = _initFuture;
if (pending != null) return pending;
final existing = _handler;
if (existing != null) return Future.value(existing);
return _initFuture = _initialize();
}
Future<MusicPlayerHandler> _initialize() async {
_initializing = true;
MusicPlayerHandler? created;
try {
// Defer even a synchronous test initializer until _initFuture is stored.
await Future<void>.value();
final handler = await dependencies.initializeHandler(() {
if (_disposed) throw StateError('This playback runtime was disposed');
return created = MusicPlayerHandler(runtime: this);
});
if (_disposed || handler.isDisposed || !identical(_handler, handler)) {
throw StateError(
'Playback initialization returned an inactive handler',
);
}
_initializing = false;
_handlers.add(handler);
if (dependencies.isAndroid || dependencies.isIOS) {
PlayerWidgetService.instance.bind(handler);
DiscordPresenceService.instance.bind(handler);
}
return handler;
} catch (error, stack) {
_log.e('Failed to initialize playback', error, stack);
await created?.dispose();
rethrow;
} finally {
_initializing = false;
_initFuture = null;
}
}
Future<void> restoreSession() =>
_restoreFuture ??= restorePlaybackSession(this);
/// The default runtime lives for the app lifetime. Scoped runtimes close
/// subscriptions and reject new initialization when their owner disposes.
Future<void> dispose() => _disposeFuture ??= _dispose();
Future<void> _dispose() async {
_disposed = true;
_toggleFavorite = null;
exclusiveAudioHook = null;
await _handler?.dispose();
try {
await _initFuture;
} catch (_) {
// Initialization may have failed because its pending handler was closed.
}
await _handlers.close();
}
Stream<T> _events<T>(
Stream<T> Function(MusicPlayerHandler) select, {
List<T> initial = const [],
}) => Stream<T>.multi((listener) {
StreamSubscription<T>? events;
var revision = 0;
void selectHandler(MusicPlayerHandler? handler) {
final selectedRevision = ++revision;
unawaited(events?.cancel());
events = null;
if (handler == null) {
for (final value in initial) {
listener.addSync(value);
}
} else {
events = select(handler).listen((value) {
if (selectedRevision == revision) listener.addSync(value);
}, onError: listener.addErrorSync);
}
}
// Register first: UI listeners can cancel immediately while initialization
// is pending, and can follow a replacement handler after disposal.
final ready = _handlers.stream.listen(
selectHandler,
onDone: listener.closeSync,
);
selectHandler(handler);
listener.onCancel = () {
revision++;
unawaited(ready.cancel());
unawaited(events?.cancel());
};
});
Stream<MediaItem?> mediaItemEvents() =>
_events((handler) => handler.mediaItem, initial: [null]);
Stream<PlaybackState> playbackStateEvents() =>
_events((handler) => handler.playbackState);
Stream<List<MediaItem>> queueEvents() =>
_events((handler) => handler.queue, initial: [const []]);
}
final musicPlayerRuntime = MusicPlayerRuntime();