Playback server

This commit is contained in:
2024-08-27 01:49:05 +08:00
parent 84d66fbc4b
commit 031cab75e0
28 changed files with 2634 additions and 12 deletions

View 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);
}
}

View 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;
}
}

View 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,
);
}
}

View 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();
}
}
}