✨ Playback server
This commit is contained in:
84
lib/services/audio_services/audio_services.dart
Executable file
84
lib/services/audio_services/audio_services.dart
Executable file
@ -0,0 +1,84 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/services/audio_services/image.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/services/audio_services/mobile_audio_service.dart';
|
||||
import 'package:rhythm_box/services/audio_services/windows_audio_service.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
|
||||
class AudioServices with WidgetsBindingObserver {
|
||||
final MobileAudioService? mobile;
|
||||
final WindowsAudioService? smtc;
|
||||
|
||||
AudioServices(this.mobile, this.smtc) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
static Future<AudioServices> create() async {
|
||||
final mobile =
|
||||
PlatformInfo.isMobile || PlatformInfo.isMacOS || PlatformInfo.isLinux
|
||||
? await AudioService.init(
|
||||
builder: () => MobileAudioService(),
|
||||
config: AudioServiceConfig(
|
||||
androidNotificationChannelId: PlatformInfo.isLinux
|
||||
? 'RhythmBox'
|
||||
: 'dev.solsynth.rhythmBox',
|
||||
androidNotificationChannelName: 'RhythmBox',
|
||||
androidNotificationOngoing: false,
|
||||
androidNotificationIcon: "drawable/ic_launcher_monochrome",
|
||||
androidStopForegroundOnPause: false,
|
||||
androidNotificationChannelDescription: "RhythmBox Music",
|
||||
),
|
||||
)
|
||||
: null;
|
||||
final smtc = PlatformInfo.isWindows ? WindowsAudioService() : null;
|
||||
|
||||
return AudioServices(mobile, smtc);
|
||||
}
|
||||
|
||||
Future<void> addTrack(Track track) async {
|
||||
await smtc?.addTrack(track);
|
||||
mobile?.addItem(MediaItem(
|
||||
id: track.id!,
|
||||
album: track.album?.name ?? "",
|
||||
title: track.name!,
|
||||
artist: (track.artists)?.asString() ?? "",
|
||||
duration: track is SourcedTrack
|
||||
? track.sourceInfo.duration
|
||||
: Duration(milliseconds: track.durationMs ?? 0),
|
||||
artUri: Uri.parse(
|
||||
(track.album?.images).asUrlString(
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
),
|
||||
playable: true,
|
||||
));
|
||||
}
|
||||
|
||||
void activateSession() {
|
||||
mobile?.session?.setActive(true);
|
||||
}
|
||||
|
||||
void deactivateSession() {
|
||||
mobile?.session?.setActive(false);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.detached:
|
||||
deactivateSession();
|
||||
mobile?.stop();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
smtc?.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
}
|
34
lib/services/audio_services/image.dart
Normal file
34
lib/services/audio_services/image.dart
Normal file
@ -0,0 +1,34 @@
|
||||
import 'package:rhythm_box/services/primitive.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/collections/assets.gen.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
enum ImagePlaceholder {
|
||||
albumArt,
|
||||
artist,
|
||||
collection,
|
||||
online,
|
||||
}
|
||||
|
||||
extension SpotifyImageExtensions on List<Image>? {
|
||||
String asUrlString({
|
||||
int index = 1,
|
||||
required ImagePlaceholder placeholder,
|
||||
}) {
|
||||
final String placeholderUrl = {
|
||||
ImagePlaceholder.albumArt: Assets.albumPlaceholder.path,
|
||||
ImagePlaceholder.artist: Assets.userPlaceholder.path,
|
||||
ImagePlaceholder.collection: Assets.placeholder.path,
|
||||
ImagePlaceholder.online:
|
||||
"https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png",
|
||||
}[placeholder]!;
|
||||
|
||||
final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!));
|
||||
|
||||
return sortedImage != null && sortedImage.isNotEmpty
|
||||
? sortedImage[
|
||||
index > sortedImage.length - 1 ? sortedImage.length - 1 : index]
|
||||
.url!
|
||||
: placeholderUrl;
|
||||
}
|
||||
}
|
153
lib/services/audio_services/mobile_audio_service.dart
Executable file
153
lib/services/audio_services/mobile_audio_service.dart
Executable file
@ -0,0 +1,153 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:rhythm_box/services/audio_player/state.dart';
|
||||
|
||||
class MobileAudioService extends BaseAudioHandler {
|
||||
AudioSession? session;
|
||||
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
||||
AudioPlayerState get playlist => Get.find<AudioPlayerProvider>().state.value;
|
||||
|
||||
MobileAudioService() {
|
||||
AudioSession.instance.then((s) {
|
||||
session = s;
|
||||
session?.configure(const AudioSessionConfiguration.music());
|
||||
|
||||
bool wasPausedByBeginEvent = false;
|
||||
|
||||
s.interruptionEventStream.listen((event) async {
|
||||
if (event.begin) {
|
||||
switch (event.type) {
|
||||
case AudioInterruptionType.duck:
|
||||
await audioPlayer.setVolume(0.5);
|
||||
break;
|
||||
case AudioInterruptionType.pause:
|
||||
case AudioInterruptionType.unknown:
|
||||
{
|
||||
wasPausedByBeginEvent = audioPlayer.isPlaying;
|
||||
await audioPlayer.pause();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch (event.type) {
|
||||
case AudioInterruptionType.duck:
|
||||
await audioPlayer.setVolume(1.0);
|
||||
break;
|
||||
case AudioInterruptionType.pause when wasPausedByBeginEvent:
|
||||
case AudioInterruptionType.unknown when wasPausedByBeginEvent:
|
||||
await audioPlayer.resume();
|
||||
wasPausedByBeginEvent = false;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
s.becomingNoisyEventStream.listen((_) {
|
||||
audioPlayer.pause();
|
||||
});
|
||||
});
|
||||
audioPlayer.playerStateStream.listen((state) async {
|
||||
playbackState.add(await _transformEvent());
|
||||
});
|
||||
|
||||
audioPlayer.positionStream.listen((pos) async {
|
||||
playbackState.add(await _transformEvent());
|
||||
});
|
||||
audioPlayer.bufferedPositionStream.listen((pos) async {
|
||||
playbackState.add(await _transformEvent());
|
||||
});
|
||||
}
|
||||
|
||||
void addItem(MediaItem item) {
|
||||
session?.setActive(true);
|
||||
mediaItem.add(item);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() => audioPlayer.resume();
|
||||
|
||||
@override
|
||||
Future<void> pause() => audioPlayer.pause();
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) => audioPlayer.seek(position);
|
||||
|
||||
@override
|
||||
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
||||
await super.setShuffleMode(shuffleMode);
|
||||
|
||||
audioPlayer.setShuffle(shuffleMode == AudioServiceShuffleMode.all);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
||||
super.setRepeatMode(repeatMode);
|
||||
audioPlayer.setLoopMode(switch (repeatMode) {
|
||||
AudioServiceRepeatMode.all ||
|
||||
AudioServiceRepeatMode.group =>
|
||||
PlaylistMode.loop,
|
||||
AudioServiceRepeatMode.one => PlaylistMode.single,
|
||||
_ => PlaylistMode.none,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
await Get.find<AudioPlayerProvider>().stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToNext() async {
|
||||
await audioPlayer.skipToNext();
|
||||
await super.skipToNext();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToPrevious() async {
|
||||
await audioPlayer.skipToPrevious();
|
||||
await super.skipToPrevious();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onTaskRemoved() async {
|
||||
await Get.find<AudioPlayerProvider>().stop();
|
||||
return super.onTaskRemoved();
|
||||
}
|
||||
|
||||
Future<PlaybackState> _transformEvent() async {
|
||||
return PlaybackState(
|
||||
controls: [
|
||||
MediaControl.skipToPrevious,
|
||||
audioPlayer.isPlaying ? MediaControl.pause : MediaControl.play,
|
||||
MediaControl.skipToNext,
|
||||
MediaControl.stop,
|
||||
],
|
||||
systemActions: {
|
||||
MediaAction.seek,
|
||||
},
|
||||
androidCompactActionIndices: const [0, 1, 2],
|
||||
playing: audioPlayer.isPlaying,
|
||||
updatePosition: audioPlayer.position,
|
||||
bufferedPosition: audioPlayer.bufferedPosition,
|
||||
shuffleMode: audioPlayer.isShuffled == true
|
||||
? AudioServiceShuffleMode.all
|
||||
: AudioServiceShuffleMode.none,
|
||||
repeatMode: switch (audioPlayer.loopMode) {
|
||||
PlaylistMode.loop => AudioServiceRepeatMode.all,
|
||||
PlaylistMode.single => AudioServiceRepeatMode.one,
|
||||
_ => AudioServiceRepeatMode.none,
|
||||
},
|
||||
processingState: audioPlayer.isBuffering
|
||||
? AudioProcessingState.loading
|
||||
: AudioProcessingState.ready,
|
||||
);
|
||||
}
|
||||
}
|
101
lib/services/audio_services/windows_audio_service.dart
Executable file
101
lib/services/audio_services/windows_audio_service.dart
Executable file
@ -0,0 +1,101 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/services/audio_services/image.dart';
|
||||
import 'package:smtc_windows/smtc_windows.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/audio_player/playback_state.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
|
||||
class WindowsAudioService {
|
||||
final SMTCWindows smtc;
|
||||
|
||||
final subscriptions = <StreamSubscription>[];
|
||||
|
||||
WindowsAudioService() : smtc = SMTCWindows(enabled: false) {
|
||||
smtc.setPlaybackStatus(PlaybackStatus.Stopped);
|
||||
final buttonStream = smtc.buttonPressStream.listen((event) {
|
||||
switch (event) {
|
||||
case PressedButton.play:
|
||||
audioPlayer.resume();
|
||||
break;
|
||||
case PressedButton.pause:
|
||||
audioPlayer.pause();
|
||||
break;
|
||||
case PressedButton.next:
|
||||
audioPlayer.skipToNext();
|
||||
break;
|
||||
case PressedButton.previous:
|
||||
audioPlayer.skipToPrevious();
|
||||
break;
|
||||
case PressedButton.stop:
|
||||
Get.find<AudioPlayerProvider>().stop();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
final playerStateStream =
|
||||
audioPlayer.playerStateStream.listen((state) async {
|
||||
switch (state) {
|
||||
case AudioPlaybackState.playing:
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.Playing);
|
||||
break;
|
||||
case AudioPlaybackState.paused:
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.Paused);
|
||||
break;
|
||||
case AudioPlaybackState.stopped:
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.Stopped);
|
||||
break;
|
||||
case AudioPlaybackState.completed:
|
||||
await smtc.setPlaybackStatus(PlaybackStatus.Changing);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
final positionStream = audioPlayer.positionStream.listen((pos) async {
|
||||
await smtc.setPosition(pos);
|
||||
});
|
||||
|
||||
final durationStream = audioPlayer.durationStream.listen((duration) async {
|
||||
await smtc.setEndTime(duration);
|
||||
});
|
||||
|
||||
subscriptions.addAll([
|
||||
buttonStream,
|
||||
playerStateStream,
|
||||
positionStream,
|
||||
durationStream,
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> addTrack(Track track) async {
|
||||
if (!smtc.enabled) {
|
||||
await smtc.enableSmtc();
|
||||
}
|
||||
await smtc.updateMetadata(
|
||||
MusicMetadata(
|
||||
title: track.name!,
|
||||
albumArtist: track.artists?.firstOrNull?.name ?? "Unknown",
|
||||
artist: track.artists?.asString() ?? "Unknown",
|
||||
album: track.album?.name ?? "Unknown",
|
||||
thumbnail: (track.album?.images).asUrlString(
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
smtc.disableSmtc();
|
||||
smtc.dispose();
|
||||
for (var element in subscriptions) {
|
||||
element.cancel();
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user