mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
315 lines
12 KiB
Dart
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();
|