feat(flutter/player): MinstrelAudioHandler + just_audio + audio_service
audio_service runs the handler in a background isolate; UI watches playbackState/mediaItem/queue streams through Riverpod. AndroidManifest gets the foreground-service media-playback permission; Info.plist gets UIBackgroundModes=audio. Bearer token attached to stream URLs via just_audio's per-source headers.
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<application
|
<application
|
||||||
android:label="minstrel"
|
android:label="minstrel"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
@@ -30,6 +34,21 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<service
|
||||||
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
|
android:foregroundServiceType="mediaPlayback"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<receiver
|
||||||
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
</dict>
|
</dict>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'player/audio_handler.dart';
|
||||||
|
import 'player/player_provider.dart';
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
runApp(const ProviderScope(child: MinstrelApp()));
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final handler = await AudioService.init(
|
||||||
|
builder: () => MinstrelAudioHandler(),
|
||||||
|
config: const AudioServiceConfig(
|
||||||
|
androidNotificationChannelId: 'com.fabledsword.minstrel.audio',
|
||||||
|
androidNotificationChannelName: 'Minstrel playback',
|
||||||
|
androidNotificationOngoing: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
runApp(ProviderScope(
|
||||||
|
overrides: [audioHandlerProvider.overrideWithValue(handler)],
|
||||||
|
child: const MinstrelApp(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
|
import '../models/track.dart';
|
||||||
|
|
||||||
|
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
||||||
|
MinstrelAudioHandler() {
|
||||||
|
_player.playbackEventStream.listen(_broadcastState);
|
||||||
|
}
|
||||||
|
|
||||||
|
final AudioPlayer _player = AudioPlayer();
|
||||||
|
String _baseUrl = '';
|
||||||
|
String? _token;
|
||||||
|
|
||||||
|
void configure({required String baseUrl, required String? token}) {
|
||||||
|
_baseUrl = baseUrl;
|
||||||
|
_token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||||
|
final items = tracks.map(_toMediaItem).toList();
|
||||||
|
queue.add(items);
|
||||||
|
if (items.isNotEmpty) {
|
||||||
|
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
||||||
|
final sources = tracks.map((t) {
|
||||||
|
final url = t.streamUrl.isNotEmpty
|
||||||
|
? (Uri.tryParse(t.streamUrl)?.hasScheme == true
|
||||||
|
? t.streamUrl
|
||||||
|
: '$_baseUrl${t.streamUrl}')
|
||||||
|
: '$_baseUrl/api/tracks/${t.id}/stream';
|
||||||
|
return AudioSource.uri(Uri.parse(url), headers: headers);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
await _player.setAudioSource(
|
||||||
|
ConcatenatingAudioSource(children: sources),
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artistName,
|
||||||
|
album: t.albumTitle,
|
||||||
|
duration: Duration(seconds: t.durationSec),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> play() => _player.play();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> pause() => _player.pause();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> seek(Duration p) => _player.seek(p);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> skipToNext() => _player.seekToNext();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> skipToPrevious() => _player.seekToPrevious();
|
||||||
|
|
||||||
|
void _broadcastState(PlaybackEvent event) {
|
||||||
|
final playing = _player.playing;
|
||||||
|
playbackState.add(PlaybackState(
|
||||||
|
controls: [
|
||||||
|
MediaControl.skipToPrevious,
|
||||||
|
if (playing) MediaControl.pause else MediaControl.play,
|
||||||
|
MediaControl.skipToNext,
|
||||||
|
],
|
||||||
|
systemActions: const {MediaAction.seek},
|
||||||
|
processingState: switch (_player.processingState) {
|
||||||
|
ProcessingState.idle => AudioProcessingState.idle,
|
||||||
|
ProcessingState.loading => AudioProcessingState.loading,
|
||||||
|
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||||
|
ProcessingState.ready => AudioProcessingState.ready,
|
||||||
|
ProcessingState.completed => AudioProcessingState.completed,
|
||||||
|
},
|
||||||
|
playing: playing,
|
||||||
|
updatePosition: _player.position,
|
||||||
|
bufferedPosition: _player.bufferedPosition,
|
||||||
|
speed: _player.speed,
|
||||||
|
queueIndex: event.currentIndex,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../auth/auth_provider.dart';
|
||||||
|
import '../models/track.dart';
|
||||||
|
import 'audio_handler.dart';
|
||||||
|
|
||||||
|
final audioHandlerProvider = Provider<MinstrelAudioHandler>((ref) {
|
||||||
|
throw UnimplementedError('overridden in main()');
|
||||||
|
});
|
||||||
|
|
||||||
|
final playbackStateProvider = StreamProvider<PlaybackState>(
|
||||||
|
(ref) => ref.watch(audioHandlerProvider).playbackState,
|
||||||
|
);
|
||||||
|
|
||||||
|
final mediaItemProvider = StreamProvider<MediaItem?>(
|
||||||
|
(ref) => ref.watch(audioHandlerProvider).mediaItem,
|
||||||
|
);
|
||||||
|
|
||||||
|
final queueProvider = StreamProvider<List<MediaItem>>(
|
||||||
|
(ref) => ref.watch(audioHandlerProvider).queue,
|
||||||
|
);
|
||||||
|
|
||||||
|
class PlayerActions {
|
||||||
|
PlayerActions(this._ref);
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Future<void> playTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
||||||
|
final url = await _ref.read(serverUrlProvider.future);
|
||||||
|
final token = await _ref.read(secureStorageProvider).read(key: 'session_token');
|
||||||
|
final h = _ref.read(audioHandlerProvider)..configure(baseUrl: url ?? '', token: token);
|
||||||
|
await h.setQueueFromTracks(tracks, initialIndex: initialIndex);
|
||||||
|
await h.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final playerActionsProvider = Provider<PlayerActions>((ref) => PlayerActions(ref));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:minstrel/player/audio_handler.dart';
|
||||||
|
import 'package:minstrel/player/player_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('audioHandlerProvider must be overridden — bare read throws', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
expect(() => container.read(audioHandlerProvider), throwsA(isA<UnimplementedError>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overridden audioHandlerProvider exposes playbackState stream', () {
|
||||||
|
final handler = MinstrelAudioHandler();
|
||||||
|
final container = ProviderContainer(overrides: [
|
||||||
|
audioHandlerProvider.overrideWithValue(handler),
|
||||||
|
]);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
expect(container.read(audioHandlerProvider), same(handler));
|
||||||
|
final sub = container.listen(playbackStateProvider, (_, __) {});
|
||||||
|
sub.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user