mobile: Update dependencies and adapt to changes

This commit is contained in:
Elias Projahn 2022-05-07 19:43:55 +02:00
parent b14dcd67f2
commit 8752ac81dd
15 changed files with 326 additions and 826 deletions

View file

@ -122,10 +122,12 @@ abstract class MusicusPlayback {
///
/// Requires [playlist] to be up to date.
void updateCurrentTrack(int index) {
currentIndex.add(index);
if (index != null) {
currentIndex.add(index);
if (playlist.value != null && index >= 0 && index < playlist.value.length) {
currentTrack.add(playlist.value[index]);
if (playlist.value != null && index >= 0 && index < playlist.value.length) {
currentTrack.add(playlist.value[index]);
}
}
}
}

View file

@ -198,3 +198,8 @@ SELECT *
FROM tracks
WHERE recording = :id
ORDER BY "index";
tracksById:
SELECT *
FROM tracks
WHERE id = :id;

View file

@ -26,23 +26,27 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
compileSdkVersion 29
compileSdkVersion flutter.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
lintOptions {
disable 'InvalidPackage'
}
defaultConfig {
applicationId "de.johrpan.musicus"
minSdkVersion 21
targetSdkVersion 29
minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
@ -50,8 +54,6 @@ android {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug
// See https://github.com/ryanheise/audio_service/blob/master/README.md#android-setup
shrinkResources false
}
}
}
@ -62,7 +64,4 @@ flutter {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
}

View file

@ -1,18 +1,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="de.johrpan.musicus">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- TODO: Actually manage obtaining this permission -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application
android:name="io.flutter.app.FlutterApplication"
android:name="${applicationName}"
android:label="Musicus"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@ -25,13 +31,21 @@
</intent-filter>
</activity>
<service android:name="com.ryanheise.audioservice.AudioService">
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>

View file

@ -1,240 +1,12 @@
package de.johrpan.musicus
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.provider.DocumentsContract
import androidx.annotation.NonNull
import android.content.Context
import com.ryanheise.audioservice.AudioServicePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant
class Document(private val id: String, private val name: String, private val parentId: String?, private val isDirectory: Boolean) {
fun toMap(): Map<String, Any?> {
return mapOf(
"id" to id,
"name" to name,
"parentId" to parentId,
"isDirectory" to isDirectory
)
}
}
class MainActivity : FlutterActivity() {
private val CHANNEL = "de.johrpan.musicus/platform"
private val AODT_REQUEST = 0
private var aodtResult: MethodChannel.Result? = null
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
GeneratedPluginRegistrant.registerWith(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
if (call.method == "openTree") {
aodtResult = result
// We will get the result within onActivityResult
openTree()
} else if (call.method == "getChildren") {
val treeUri = Uri.parse(call.argument<String>("treeUri"))
val parentId = call.argument<String>("parentId")
val children = getChildren(treeUri, parentId)
result.success(children.map { it.toMap() })
} else if (call.method == "readFile") {
val treeUri = Uri.parse(call.argument<String>("treeUri"))
val id = call.argument<String>("id")!!
result.success(readFile(treeUri, id))
} else if (call.method == "getUriByName") {
val treeUri = Uri.parse(call.argument<String>("treeUri"))
val parentId = call.argument<String>("parentId")!!
val fileName = call.argument<String>("fileName")!!
result.success(getUriByName(treeUri, parentId, fileName).toString())
} else if (call.method == "readFileByName") {
val treeUri = Uri.parse(call.argument<String>("treeUri"))
val parentId = call.argument<String>("parentId")!!
val fileName = call.argument<String>("fileName")!!
result.success(readFileByName(treeUri, parentId, fileName))
} else if (call.method == "writeFileByName") {
val treeUri = Uri.parse(call.argument<String>("treeUri"))
val parentId = call.argument<String>("parentId")!!
val fileName = call.argument<String>("fileName")!!
val content = call.argument<String>("content")!!
writeFileByName(treeUri, parentId, fileName, content)
result.success(null)
} else {
result.notImplemented()
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == AODT_REQUEST) {
if (resultCode == Activity.RESULT_OK && data?.data != null) {
// Drop all old URIs
contentResolver.persistedUriPermissions.forEach {
contentResolver.releasePersistableUriPermission(it.uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
// We already checked for null
val uri = data.data!!
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
aodtResult?.success(uri.toString())
} else {
aodtResult?.success(null)
}
}
}
/**
* Open a document tree using the storage access framework
*
* The result is handled within [onActivityResult]
*/
private fun openTree() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
startActivityForResult(intent, AODT_REQUEST)
}
/**
* List children of a directory
*
* @param treeUri The treeUri from the ACTION_OPEN_DOCUMENT_TREE request
* @param parentId Document ID of the parent directory or null for the top level directory
* @return List of directories and files within the directory
*/
private fun getChildren(treeUri: Uri, parentId: String?): List<Document> {
val realParentId = parentId ?: DocumentsContract.getTreeDocumentId(treeUri)
val children = ArrayList<Document>()
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, realParentId)
val cursor = contentResolver.query(
childrenUri,
arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE),
null, null, null)
if (cursor != null) {
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val name = cursor.getString(1)
val isDirectory = cursor.getString(2) == DocumentsContract.Document.MIME_TYPE_DIR
// Use parentId here to let the consumer know that we are at the top level.
children.add(Document(id, name, parentId, isDirectory))
}
cursor.close()
}
return children
}
/**
* Look for a file by name
*
* @param treeUri The treeUri from the ACTION_OPEN_DOCUMENT_TREE request
* @param parentId The directory in which the file is searched for
* @param fileName Name of the file
* @return The URI of the file or null
*/
private fun getUriByName(treeUri: Uri, parentId: String, fileName: String): Uri? {
var uri: Uri? = null
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentId)
val projection = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME)
// The file system provider doesn't support a select clause.
val cursor = contentResolver.query(childrenUri, projection, null, null, null)
if (cursor != null) {
while (cursor.moveToNext()) {
val id = cursor.getString(0)
val name = cursor.getString(1)
if (name == fileName) {
uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
break
}
}
cursor.close()
}
return uri
}
/**
* Read content of a file
*
* @param treeUri The URI from ACTION_OPEN_DOCUMENT_TREE
* @param id The document ID of the file
* @return File content or null
*/
private fun readFile(treeUri: Uri, id: String): String? {
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, id)
// TODO: Handle errors.
val input = contentResolver.openInputStream(uri)!!
val result = input.reader().readText()
input.close()
return result
}
/**
* Read content of a file by name
*
* @param treeUri The URI from ACTION_OPEN_DOCUMENT_TREE
* @param parentId Document ID of the parent directory
* @param fileName Name of the file
* @return File content or null
*/
private fun readFileByName(treeUri: Uri, parentId: String, fileName: String): String? {
var uri = getUriByName(treeUri, parentId, fileName)
return if (uri != null) {
// TODO: Handle errors.
val input = contentResolver.openInputStream(uri)!!
val result = input.reader().readText()
input.close()
return result
} else {
null
}
}
/**
* Write to file by name
*
* The file will always have the MIME type application/json.
*
* @param treeUri The URI from ACTION_OPEN_DOCUMENT_TREE
* @param parentId Document ID of the parent directory
* @param fileName Name of the file
* @param content Content to write
* @return File content or null
*/
private fun writeFileByName(treeUri: Uri, parentId: String, fileName: String, content: String) {
var uri = getUriByName(treeUri, parentId, fileName);
if (uri == null) {
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentId)
uri = DocumentsContract.createDocument(contentResolver, parentUri, "application/json", fileName)
}
// TODO: Handle errors.
val output = contentResolver.openOutputStream(uri!!)!!;
val writer = output.writer()
writer.write(content)
writer.close()
output.close()
override fun provideFlutterEngine(context: Context): FlutterEngine {
return AudioServicePlugin.getFlutterEngine(context);
}
}

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/*" />

View file

@ -1,12 +1,12 @@
buildscript {
ext.kotlin_version = '1.3.50'
ext.kotlin_version = '1.6.10'
repositories {
google()
jcenter()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0'
classpath 'com.android.tools.build:gradle:7.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@ -14,7 +14,7 @@ buildscript {
allprojects {
repositories {
google()
jcenter()
mavenCentral()
}
}

View file

@ -1,4 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
android.enableR8=true
android.useAndroidX=true
android.enableJetifier=true

View file

@ -1,15 +1,11 @@
include ':app'
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
def properties = new Properties()
def plugins = new Properties()
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
if (pluginsFile.exists()) {
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
}
assert localPropertiesFile.exists()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
plugins.each { name, path ->
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
include ":$name"
project(":$name").projectDir = pluginDirectory
}
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"

View file

@ -1,11 +1,9 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/widgets.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as pp;
import 'settings.dart';
import 'platform.dart';
import 'playback.dart';
Future<void> main() async {
@ -14,12 +12,11 @@ Future<void> main() async {
final dir = await pp.getApplicationDocumentsDirectory();
final dbPath = p.join(dir.path, 'db.sqlite');
runApp(AudioServiceWidget(
child: MusicusApp(
runApp(
MusicusApp(
dbPath: dbPath,
settingsStorage: SettingsStorage(),
platform: MusicusAndroidPlatform(),
playback: Playback(),
playback: MusicusMobilePlayback(),
),
));
);
}

View file

@ -1,76 +0,0 @@
import 'package:flutter/services.dart';
import 'package:musicus_common/musicus_common.dart';
class MusicusAndroidPlatform extends MusicusPlatform {
static const _platform = MethodChannel('de.johrpan.musicus/platform');
@override
Future<String> chooseBasePath() async {
return await _platform.invokeMethod<String>('openTree');
}
@override
Future<List<Document>> getChildren(String parentId) async {
final List<Map<dynamic, dynamic>> childrenJson =
await _platform.invokeListMethod(
'getChildren',
{
'treeUri': basePath,
'parentId': parentId,
},
);
return childrenJson
.map((childJson) => Document.fromJson(childJson))
.toList();
}
@override
Future<String> getIdentifier(String parentId, String fileName) async {
return await _platform.invokeMethod(
'getUriByName',
{
'treeUri': basePath,
'parentId': parentId,
'fileName': fileName,
},
);
}
@override
Future<String> readDocument(String id) async {
return await _platform.invokeMethod(
'readFile',
{
'treeUri': basePath,
'id': id,
},
);
}
@override
Future<String> readDocumentByName(String parentId, String fileName) async {
return await _platform.invokeMethod(
'readFileByName',
{
'treeUri': basePath,
'parentId': parentId,
'fileName': fileName,
},
);
}
@override
Future<void> writeDocumentByName(
String parentId, String fileName, String contents) async {
await _platform.invokeMethod(
'writeFileByName',
{
'treeUri': basePath,
'parentId': parentId,
'fileName': fileName,
'content': contents,
},
);
}
}

View file

@ -1,535 +1,317 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'dart:ui';
import 'package:audio_service/audio_service.dart';
import 'package:moor/isolate.dart';
import 'package:musicus_client/musicus_client.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:musicus_player/musicus_player.dart';
import 'package:path/path.dart' as p;
const _portName = 'playbackService';
class MusicusMobilePlayback extends MusicusPlayback {
AudioHandler audioHandler;
MusicusLibrary library;
/// Entrypoint for the playback service.
void _playbackServiceEntrypoint() {
AudioServiceBackground.run(() => _PlaybackService());
}
@override
Future<void> setup(MusicusLibrary musicusLibrary) async {
library = musicusLibrary;
class Playback extends MusicusPlayback {
StreamSubscription _playbackServiceStateSubscription;
/// Start playback service.
Future<void> _start() async {
if (!AudioService.running) {
await AudioService.start(
backgroundTaskEntrypoint: _playbackServiceEntrypoint,
audioHandler = await AudioService.init(
builder: () => MusicusAudioHandler(musicusLibrary),
config: AudioServiceConfig(
androidNotificationChannelId: 'de.johrpan.musicus.channel.audio',
androidNotificationChannelName: 'Musicus playback',
androidNotificationChannelDescription:
'Keeps Musicus playing in the background',
androidNotificationIcon: 'drawable/ic_notification',
);
),
);
active.add(true);
}
listen();
}
@override
Future<void> setup() async {
if (_playbackServiceStateSubscription != null) {
_playbackServiceStateSubscription.cancel();
}
// We will receive updated state information from the playback service,
// which runs in its own isolate, through this port.
final receivePort = ReceivePort();
receivePort.asBroadcastStream(
onListen: (subscription) {
_playbackServiceStateSubscription = subscription;
},
).listen((msg) {
// If state is null, the background audio service has stopped.
if (msg == null) {
dispose();
} else {
if (!active.value) {
active.add(true);
}
if (msg is _StatusMessage) {
playing.add(msg.playing);
} else if (msg is _PositionMessage) {
updatePosition(msg.positionMs);
} else if (msg is _TrackMessage) {
updateCurrentTrack(msg.currentTrack);
updateDuration(msg.positionMs, msg.durationMs);
} else if (msg is _PlaylistMessage) {
playlist.add(msg.playlist);
updateCurrentTrack(msg.currentTrack);
updateDuration(msg.positionMs, msg.durationMs);
}
Future<void> listen() async {
audioHandler.customEvent.listen((event) {
if (event != null && event is PlaylistEvent) {
playlist.add(event.playlist);
}
});
IsolateNameServer.removePortNameMapping(_portName);
IsolateNameServer.registerPortWithName(receivePort.sendPort, _portName);
audioHandler.playbackState.listen((event) {
if (event != null) {
playing.add(event.playing);
updatePosition(event.position);
updateCurrentTrack(event.queueIndex);
}
});
if (AudioService.running) {
active.add(true);
audioHandler.mediaItem.listen((event) {
if (event != null) {
updateDuration(event.duration);
}
});
// Instruct the background service to send its current state. This will
// by handled in the listeners, that were already set in the constructor.
AudioService.customAction('sendState');
}
await audioHandler.customAction('sendState');
}
@override
Future<void> addTracks(List<InternalTrack> tracks) async {
if (!AudioService.running) {
await _start();
}
await AudioService.customAction('addTracks', jsonEncode(tracks));
}
@override
Future<void> removeTrack(int index) async {
if (AudioService.running) {
await AudioService.customAction('removeTrack', index);
}
Future<void> addTracks(List<String> tracks) async {
await audioHandler.customAction('addTracks', {'tracks': tracks});
active.add(true);
}
@override
Future<void> playPause() async {
if (active.value) {
if (playing.value) {
await AudioService.pause();
} else {
await AudioService.play();
}
if (playing.value) {
await audioHandler.pause();
} else {
await audioHandler.play();
}
}
@override
Future<void> removeTrack(int index) async {
await audioHandler.customAction('removeTrack', {'index': index});
}
@override
Future<void> seekTo(double pos) async {
if (active.value && pos >= 0.0 && pos <= 1.0) {
final durationMs = duration.value.inMilliseconds;
await AudioService.seekTo((pos * durationMs).floor());
}
}
@override
Future<void> skipToPrevious() async {
if (AudioService.running) {
await AudioService.skipToPrevious();
}
}
@override
Future<void> skipToNext() async {
if (AudioService.running) {
await AudioService.skipToNext();
if (pos >= 0.0 && pos <= 1.0) {
final durationMs = audioHandler.mediaItem.value.duration.inMilliseconds;
await audioHandler
.seek(Duration(milliseconds: (pos * durationMs).floor()));
}
}
@override
Future<void> skipTo(int index) async {
if (AudioService.running) {
await AudioService.customAction('skipTo', index);
await audioHandler.skipToQueueItem(index);
}
@override
Future<void> skipToNext() async {
await audioHandler.skipToNext();
}
@override
Future<void> skipToPrevious() async {
await audioHandler.skipToPrevious();
}
}
class MusicusAudioHandler extends BaseAudioHandler {
final MusicusLibrary library;
MusicusPlayer player;
List<String> playlist = [];
int currentTrack = -1;
int durationMs = 1000;
bool playing = false;
MusicusAudioHandler(this.library) {
player = MusicusPlayer(onComplete: () async {
if (currentTrack < playlist.length - 1) {
await skipToNext();
} else {
playing = false;
await sendState();
}
});
}
@override
Future<void> play() async {
await player.play();
playing = true;
await sendState();
keepSendingPosition();
}
Future<void> pause() async {
await player.pause();
playing = false;
await sendState();
}
Future<void> stop() async {
playlist.clear();
await player.stop();
super.stop();
}
Future<void> seek(Duration position) async {
await player.seekTo(position.inMilliseconds);
await sendState();
}
@override
Future<void> skipToPrevious() async {
if (currentTrack > 0 && currentTrack < playlist.length) {
await skipToQueueItem(currentTrack - 1);
}
}
@override
void dispose() {
super.dispose();
_playbackServiceStateSubscription.cancel();
Future<void> skipToNext() async {
if (currentTrack >= 0 && currentTrack < playlist.length - 1) {
await skipToQueueItem(currentTrack + 1);
}
}
}
/// A message from the playback service to the UI.
abstract class _Message {}
@override
Future<void> skipToQueueItem(int index) async {
if (index >= 0 && index < playlist.length) {
currentTrack = index;
final track = await library.db.tracksById(playlist[index]).getSingle();
durationMs = await player.setUri(p.join(library.basePath, track.path));
/// Playback status update.
class _StatusMessage extends _Message {
/// Whether the player is playing (or paused).
final bool playing;
await sendState();
await sendMediaItem();
}
}
/// Playback position in milliseconds.
final int positionMs;
@override
Future<void> customAction(String name, [Map<String, dynamic> extras]) async {
if (name == 'sendState') {
await sendPlaylist();
await sendMediaItem();
await sendState();
} else if (name == 'addTracks') {
await addTracks(extras['tracks']);
} else if (name == 'removeTrack') {
await removeTrack(extras['index']);
}
}
_StatusMessage({
this.playing,
this.positionMs,
});
}
Future<void> addTracks(List<String> tracks) async {
if (tracks != null && tracks.isNotEmpty) {
final wasEmpty = playlist.isEmpty;
/// The playback position has changed.
///
/// This could be due to seeking or because time progressed.
class _PositionMessage extends _Message {
/// Playback position in milliseconds.
final int positionMs;
playlist.addAll(tracks);
await sendPlaylist();
_PositionMessage({
this.positionMs,
});
}
/// The current track has changed.
///
/// This also notifies about the playback position, as the old position could be
/// behind the new duration.
class _TrackMessage extends _Message {
/// Index of the new track within the playlist.
final int currentTrack;
/// Duration of the new track in milliseconds.
final int durationMs;
/// Playback position in milliseconds.
final int positionMs;
_TrackMessage({
this.currentTrack,
this.durationMs,
this.positionMs,
});
}
/// The playlist was changed.
///
/// This also notifies about the current track, as the old index could be out of
/// range in the new playlist.
class _PlaylistMessage extends _Message {
/// The new playlist.
final List<InternalTrack> playlist;
/// The current track.
final int currentTrack;
/// Duration of the current track in milliseconds.
final int durationMs;
/// Playback position in milliseconds.
final int positionMs;
_PlaylistMessage({
this.playlist,
this.currentTrack,
this.durationMs,
this.positionMs,
});
}
class _PlaybackService extends BackgroundAudioTask {
/// The interval between playback position updates in milliseconds.
static const positionUpdateInterval = 250;
static const playControl = MediaControl(
androidIcon: 'drawable/ic_play',
label: 'Play',
action: MediaAction.play,
);
static const pauseControl = MediaControl(
androidIcon: 'drawable/ic_pause',
label: 'Pause',
action: MediaAction.pause,
);
static const stopControl = MediaControl(
androidIcon: 'drawable/ic_stop',
label: 'Stop',
action: MediaAction.stop,
);
final _completer = Completer();
final _loading = Completer();
final List<InternalTrack> _playlist = [];
MusicusClientDatabase db;
MusicusPlayer _player;
int _currentTrack = 0;
bool _playing = false;
int _durationMs = 1000;
_PlaybackService() {
_player = MusicusPlayer(onComplete: () async {
if (_currentTrack < _playlist.length - 1) {
await _setCurrentTrack(_currentTrack + 1);
_sendTrack();
if (wasEmpty) {
await skipToQueueItem(0);
await play();
} else {
_playing = false;
_sendStatus();
_setState();
await sendState();
}
});
_load();
}
}
/// Initialize database.
Future<void> _load() async {
final moorPort = IsolateNameServer.lookupPortByName('moor');
final moorIsolate = MoorIsolate.fromConnectPort(moorPort);
db = MusicusClientDatabase.connect(connection: await moorIsolate.connect());
_loading.complete();
Future<void> removeTrack(int index) async {
if (index >= 0 && index < playlist.length) {
playlist.removeAt(index);
if (playlist.isNotEmpty) {
if (currentTrack == index) {
await skipToQueueItem(index);
} else if (currentTrack > index) {
currentTrack--;
}
}
await sendPlaylist();
await sendState();
}
}
/// Update the audio service status for the system.
Future<void> _setState() async {
final positionMs = await _player.getPosition() ?? 0;
final updateTime = DateTime.now().millisecondsSinceEpoch;
Future<void> sendPlaylist() async {
customEvent.add(PlaylistEvent(playlist));
}
AudioServiceBackground.setState(
controls:
_playing ? [pauseControl, stopControl] : [playControl, stopControl],
basicState:
_playing ? BasicPlaybackState.playing : BasicPlaybackState.paused,
position: positionMs,
updateTime: updateTime,
);
Future<void> sendState() async {
List<MediaControl> controls = [];
Set<MediaAction> actions = {};
if (_playlist.isNotEmpty) {
await _loading.future;
if (playlist.isNotEmpty) {
if (currentTrack < 0 || currentTrack >= playlist.length) {
currentTrack = 0;
}
final track = _playlist[_currentTrack];
final recordingInfo = await db.getRecording(track.track.recordingId);
final workInfo = await db.getWork(recordingInfo.recording.work);
if (currentTrack > 0) {
controls.add(MediaControl.skipToPrevious);
}
final title = workInfo.composers
.map((p) => '${p.firstName} ${p.lastName}')
.join(', ');
if (playing) {
controls.add(MediaControl.pause);
} else {
controls.add(MediaControl.play);
}
final subtitleBuffer = StringBuffer(workInfo.work.title);
if (currentTrack < playlist.length - 1) {
controls.add(MediaControl.skipToNext);
}
final partIds = track.track.partIds;
if (partIds.isNotEmpty) {
subtitleBuffer.write(': ');
actions.add(MediaAction.seek);
} else {
currentTrack = -1;
}
final section = workInfo.sections.lastWhere(
(s) => s.beforePartIndex <= partIds[0],
orElse: () => null,
);
playbackState.add(PlaybackState(
processingState: AudioProcessingState.ready,
playing: playing,
controls: controls,
systemActions: actions,
updatePosition: Duration(milliseconds: await player.getPosition()),
queueIndex: currentTrack,
));
}
if (section != null) {
subtitleBuffer.write(section.title);
Future<void> sendMediaItem() async {
if (currentTrack >= 0 && currentTrack < playlist.length) {
final track =
await library.db.tracksById(playlist[currentTrack]).getSingle();
final recording =
await library.db.recordingById(track.recording).getSingle();
final workInfo = await library.db.getWork(recording.work);
final partIds = track.workParts
.split(',')
.where((p) => p.isNotEmpty)
.map((p) => int.parse(p))
.toList();
String title;
String subtitle;
if (workInfo != null) {
title = '${workInfo.composer.firstName} ${workInfo.composer.lastName}';
final subtitleBuffer = StringBuffer(workInfo.work.title);
if (partIds.isNotEmpty) {
subtitleBuffer.write(': ');
subtitleBuffer
.write(partIds.map((i) => workInfo.parts[i].title).join(', '));
}
subtitleBuffer
.write(partIds.map((i) => workInfo.parts[i].part.title).join(', '));
subtitle = subtitleBuffer.toString();
} else {
title = '...';
subtitle = '...';
}
final subtitle = subtitleBuffer.toString();
AudioServiceBackground.setMediaItem(MediaItem(
id: track.identifier,
album: subtitle,
title: title,
displayTitle: title,
displaySubtitle: subtitle,
mediaItem.add(MediaItem(
id: track.id,
title: subtitle,
album: title,
duration: Duration(milliseconds: durationMs),
));
}
}
/// Send a message to the UI.
void _sendMsg(_Message msg) {
final sendPort = IsolateNameServer.lookupPortByName(_portName);
sendPort?.send(msg);
}
/// Notify the UI about the current playback status.
Future<void> _sendStatus() async {
_sendMsg(_StatusMessage(
playing: _playing,
positionMs: await _player.getPosition(),
));
}
/// Notify the UI about the current playback position.
Future<void> _sendPosition() async {
_sendMsg(_PositionMessage(
positionMs: await _player.getPosition(),
));
}
/// Notify the UI about the current track.
Future<void> _sendTrack() async {
_sendMsg(_TrackMessage(
currentTrack: _currentTrack,
durationMs: _durationMs,
positionMs: await _player.getPosition(),
));
}
/// Notify the UI about the current playlist.
Future<void> _sendPlaylist() async {
_sendMsg(_PlaylistMessage(
playlist: _playlist,
currentTrack: _currentTrack,
durationMs: _durationMs,
positionMs: await _player.getPosition(),
));
}
/// Notify the UI of the new playback position periodically.
Future<void> _updatePosition() async {
while (_playing) {
_sendPosition();
await Future.delayed(
const Duration(milliseconds: positionUpdateInterval));
Future<void> keepSendingPosition() async {
while (playing) {
sendState();
await Future.delayed(const Duration(seconds: 1));
}
}
/// Set the current track, update the player and notify the system.
Future<void> _setCurrentTrack(int index) async {
_currentTrack = index;
_durationMs = await _player.setUri(_playlist[_currentTrack].identifier);
_setState();
}
/// Add [tracks] to the playlist.
Future<void> _addTracks(List<InternalTrack> tracks) async {
final play = _playlist.isEmpty;
_playlist.addAll(tracks);
if (play) {
await _setCurrentTrack(0);
}
_sendPlaylist();
}
/// Remove the track at [index] from the playlist.
///
/// If it was the current track, the next track will be played.
Future<void> _removeTrack(int index) async {
if (index >= 0 && index < _playlist.length) {
_playlist.removeAt(index);
if (_playlist.isEmpty) {
onStop();
} else {
if (_currentTrack == index) {
await _setCurrentTrack(index);
} else if (_currentTrack > index) {
_currentTrack--;
}
_sendPlaylist();
}
}
}
/// Jump to the beginning of the track with the index [index].
Future<void> _skipTo(int index) async {
if (index >= 0 && index < _playlist.length) {
await _setCurrentTrack(index);
_sendTrack();
}
}
@override
Future<void> onStart() async {
_setState();
await _completer.future;
}
@override
Future<void> onCustomAction(String name, dynamic arguments) async {
super.onCustomAction(name, arguments);
// addTracks expects a List<Map<String, dynamic>> as its argument.
// skipTo and removeTrack expect an integer as their argument.
if (name == 'addTracks') {
final tracksJson = jsonDecode(arguments);
final List<InternalTrack> tracks = List.castFrom(
tracksJson.map((j) => InternalTrack.fromJson(j)).toList());
_addTracks(tracks);
} else if (name == 'removeTrack') {
final index = arguments as int;
_removeTrack(index);
} else if (name == 'skipTo') {
final index = arguments as int;
_skipTo(index);
} else if (name == 'sendState') {
_sendPlaylist();
_sendStatus();
}
}
@override
void onPlay() {
super.onPlay();
_player.play();
_playing = true;
_sendStatus();
_updatePosition();
_setState();
}
@override
void onPause() {
super.onPause();
_player.pause();
_playing = false;
_sendStatus();
_setState();
}
@override
Future<void> onSeekTo(int position) async {
super.onSeekTo(position);
await _player.seekTo(position);
_sendPosition();
_setState();
}
@override
Future<void> onSkipToNext() async {
super.onSkipToNext();
if (_playlist.length > 1 && _currentTrack < _playlist.length - 1) {
await _setCurrentTrack(_currentTrack + 1);
_sendTrack();
}
}
@override
Future<void> onSkipToPrevious() async {
super.onSkipToPrevious();
// If more than five seconds of the current track have been played, go back
// to its beginning, else, switch to the previous track.
if (await _player.getPosition() > 5000) {
await _setCurrentTrack(_currentTrack);
_sendTrack();
} else if (_playlist.length > 1 && _currentTrack > 0) {
await _setCurrentTrack(_currentTrack - 1);
_sendTrack();
}
}
@override
void onStop() {
_player.stop();
AudioServiceBackground.setState(
controls: [],
basicState: BasicPlaybackState.stopped,
);
_sendMsg(null);
// This will end onStart.
_completer.complete();
}
}
class PlaylistEvent {
final List<String> playlist;
PlaylistEvent(this.playlist);
}

View file

@ -1,24 +1,28 @@
name: musicus
version: 0.1.0
description: The classical music player and organizer.
author: Elias Projahn <johrpan@gmail.com>
homepage: https://musicus.org
repository: https://github.com/johrpan/musicus
publish_to: none
environment:
sdk: ">=2.3.0 <3.0.0"
dependencies:
audio_service:
audio_service: ^0.18.4
drift: ^1.0.0
flutter:
sdk: flutter
musicus_common:
path: ../common
musicus_database:
path: ../database
musicus_player:
path: ../player
path:
path_provider:
shared_preferences:
sqlite3_flutter_libs: ^0.5.0
flutter:
uses-material-design: true

View file

@ -2,14 +2,14 @@ group 'de.johrpan.musicus_player'
version '1.0-SNAPSHOT'
buildscript {
ext.kotlin_version = '1.3.50'
ext.kotlin_version = '1.6.10'
repositories {
google()
jcenter()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.5.0'
classpath 'com.android.tools.build:gradle:4.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@ -17,7 +17,7 @@ buildscript {
rootProject.allprojects {
repositories {
google()
jcenter()
mavenCentral()
}
}
@ -25,17 +25,24 @@ apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion 28
compileSdkVersion 31
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
minSdkVersion 16
}
lintOptions {
disable 'InvalidPackage'
}
}
dependencies {

View file

@ -1,4 +0,0 @@
org.gradle.jvmargs=-Xmx1536M
android.enableR8=true
android.useAndroidX=true
android.enableJetifier=true