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. /// Requires [playlist] to be up to date.
void updateCurrentTrack(int index) { void updateCurrentTrack(int index) {
currentIndex.add(index); if (index != null) {
currentIndex.add(index);
if (playlist.value != null && index >= 0 && index < playlist.value.length) { if (playlist.value != null && index >= 0 && index < playlist.value.length) {
currentTrack.add(playlist.value[index]); currentTrack.add(playlist.value[index]);
}
} }
} }
} }

View file

@ -198,3 +198,8 @@ SELECT *
FROM tracks FROM tracks
WHERE recording = :id WHERE recording = :id
ORDER BY "index"; 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" apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
compileSdkVersion 29 compileSdkVersion flutter.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
sourceSets { sourceSets {
main.java.srcDirs += 'src/main/kotlin' main.java.srcDirs += 'src/main/kotlin'
} }
lintOptions {
disable 'InvalidPackage'
}
defaultConfig { defaultConfig {
applicationId "de.johrpan.musicus" applicationId "de.johrpan.musicus"
minSdkVersion 21 minSdkVersion flutter.minSdkVersion
targetSdkVersion 29 targetSdkVersion flutter.targetSdkVersion
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
@ -50,8 +54,6 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
// See https://github.com/ryanheise/audio_service/blob/master/README.md#android-setup
shrinkResources false
} }
} }
} }
@ -62,7 +64,4 @@ flutter {
dependencies { dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 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"> package="de.johrpan.musicus">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- TODO: Actually manage obtaining this permission -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application <application
android:name="io.flutter.app.FlutterApplication" android:name="${applicationName}"
android:label="Musicus" android:label="Musicus"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@ -25,13 +31,21 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name="com.ryanheise.audioservice.AudioService"> <service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter> <intent-filter>
<action android:name="android.media.browse.MediaBrowserService" /> <action android:name="android.media.browse.MediaBrowserService" />
</intent-filter> </intent-filter>
</service> </service>
<receiver android:name="androidx.media.session.MediaButtonReceiver" > <receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true"
tools:ignore="Instantiatable">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" /> <action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter> </intent-filter>

View file

@ -1,240 +1,12 @@
package de.johrpan.musicus package de.johrpan.musicus
import android.app.Activity import android.content.Context
import android.content.Intent import com.ryanheise.audioservice.AudioServicePlugin
import android.net.Uri
import android.provider.DocumentsContract
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine 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() { class MainActivity : FlutterActivity() {
private val CHANNEL = "de.johrpan.musicus/platform" override fun provideFlutterEngine(context: Context): FlutterEngine {
private val AODT_REQUEST = 0 return AudioServicePlugin.getFlutterEngine(context);
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()
} }
} }

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 { buildscript {
ext.kotlin_version = '1.3.50' ext.kotlin_version = '1.6.10'
repositories { repositories {
google() google()
jcenter() mavenCentral()
} }
dependencies { 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" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
@ -14,7 +14,7 @@ buildscript {
allprojects { allprojects {
repositories { repositories {
google() google()
jcenter() mavenCentral()
} }
} }

View file

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

View file

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

View file

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

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:async';
import 'dart:convert';
import 'dart:isolate';
import 'dart:ui';
import 'package:audio_service/audio_service.dart'; 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_common/musicus_common.dart';
import 'package:musicus_player/musicus_player.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. @override
void _playbackServiceEntrypoint() { Future<void> setup(MusicusLibrary musicusLibrary) async {
AudioServiceBackground.run(() => _PlaybackService()); library = musicusLibrary;
}
class Playback extends MusicusPlayback { audioHandler = await AudioService.init(
StreamSubscription _playbackServiceStateSubscription; builder: () => MusicusAudioHandler(musicusLibrary),
config: AudioServiceConfig(
/// Start playback service. androidNotificationChannelId: 'de.johrpan.musicus.channel.audio',
Future<void> _start() async {
if (!AudioService.running) {
await AudioService.start(
backgroundTaskEntrypoint: _playbackServiceEntrypoint,
androidNotificationChannelName: 'Musicus playback', androidNotificationChannelName: 'Musicus playback',
androidNotificationChannelDescription: androidNotificationChannelDescription:
'Keeps Musicus playing in the background', 'Keeps Musicus playing in the background',
androidNotificationIcon: 'drawable/ic_notification', androidNotificationIcon: 'drawable/ic_notification',
); ),
);
active.add(true); listen();
}
} }
@override Future<void> listen() async {
Future<void> setup() async { audioHandler.customEvent.listen((event) {
if (_playbackServiceStateSubscription != null) { if (event != null && event is PlaylistEvent) {
_playbackServiceStateSubscription.cancel(); playlist.add(event.playlist);
}
// 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);
}
} }
}); });
IsolateNameServer.removePortNameMapping(_portName); audioHandler.playbackState.listen((event) {
IsolateNameServer.registerPortWithName(receivePort.sendPort, _portName); if (event != null) {
playing.add(event.playing);
updatePosition(event.position);
updateCurrentTrack(event.queueIndex);
}
});
if (AudioService.running) { audioHandler.mediaItem.listen((event) {
active.add(true); if (event != null) {
updateDuration(event.duration);
}
});
// Instruct the background service to send its current state. This will await audioHandler.customAction('sendState');
// by handled in the listeners, that were already set in the constructor.
AudioService.customAction('sendState');
}
} }
@override @override
Future<void> addTracks(List<InternalTrack> tracks) async { Future<void> addTracks(List<String> tracks) async {
if (!AudioService.running) { await audioHandler.customAction('addTracks', {'tracks': tracks});
await _start(); active.add(true);
}
await AudioService.customAction('addTracks', jsonEncode(tracks));
}
@override
Future<void> removeTrack(int index) async {
if (AudioService.running) {
await AudioService.customAction('removeTrack', index);
}
} }
@override @override
Future<void> playPause() async { Future<void> playPause() async {
if (active.value) { if (playing.value) {
if (playing.value) { await audioHandler.pause();
await AudioService.pause(); } else {
} else { await audioHandler.play();
await AudioService.play();
}
} }
} }
@override
Future<void> removeTrack(int index) async {
await audioHandler.customAction('removeTrack', {'index': index});
}
@override @override
Future<void> seekTo(double pos) async { Future<void> seekTo(double pos) async {
if (active.value && pos >= 0.0 && pos <= 1.0) { if (pos >= 0.0 && pos <= 1.0) {
final durationMs = duration.value.inMilliseconds; final durationMs = audioHandler.mediaItem.value.duration.inMilliseconds;
await AudioService.seekTo((pos * durationMs).floor()); await audioHandler
} .seek(Duration(milliseconds: (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();
} }
} }
@override @override
Future<void> skipTo(int index) async { Future<void> skipTo(int index) async {
if (AudioService.running) { await audioHandler.skipToQueueItem(index);
await AudioService.customAction('skipTo', 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 @override
void dispose() { Future<void> skipToNext() async {
super.dispose(); if (currentTrack >= 0 && currentTrack < playlist.length - 1) {
_playbackServiceStateSubscription.cancel(); await skipToQueueItem(currentTrack + 1);
}
} }
}
/// A message from the playback service to the UI. @override
abstract class _Message {} 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. await sendState();
class _StatusMessage extends _Message { await sendMediaItem();
/// Whether the player is playing (or paused). }
final bool playing; }
/// Playback position in milliseconds. @override
final int positionMs; 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({ Future<void> addTracks(List<String> tracks) async {
this.playing, if (tracks != null && tracks.isNotEmpty) {
this.positionMs, final wasEmpty = playlist.isEmpty;
});
}
/// The playback position has changed. playlist.addAll(tracks);
/// await sendPlaylist();
/// This could be due to seeking or because time progressed.
class _PositionMessage extends _Message {
/// Playback position in milliseconds.
final int positionMs;
_PositionMessage({ if (wasEmpty) {
this.positionMs, await skipToQueueItem(0);
}); await play();
}
/// 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();
} else { } else {
_playing = false; await sendState();
_sendStatus();
_setState();
} }
}); }
_load();
} }
/// Initialize database. Future<void> removeTrack(int index) async {
Future<void> _load() async { if (index >= 0 && index < playlist.length) {
final moorPort = IsolateNameServer.lookupPortByName('moor'); playlist.removeAt(index);
final moorIsolate = MoorIsolate.fromConnectPort(moorPort);
db = MusicusClientDatabase.connect(connection: await moorIsolate.connect()); if (playlist.isNotEmpty) {
_loading.complete(); 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> sendPlaylist() async {
Future<void> _setState() async { customEvent.add(PlaylistEvent(playlist));
final positionMs = await _player.getPosition() ?? 0; }
final updateTime = DateTime.now().millisecondsSinceEpoch;
AudioServiceBackground.setState( Future<void> sendState() async {
controls: List<MediaControl> controls = [];
_playing ? [pauseControl, stopControl] : [playControl, stopControl], Set<MediaAction> actions = {};
basicState:
_playing ? BasicPlaybackState.playing : BasicPlaybackState.paused,
position: positionMs,
updateTime: updateTime,
);
if (_playlist.isNotEmpty) { if (playlist.isNotEmpty) {
await _loading.future; if (currentTrack < 0 || currentTrack >= playlist.length) {
currentTrack = 0;
}
final track = _playlist[_currentTrack]; if (currentTrack > 0) {
final recordingInfo = await db.getRecording(track.track.recordingId); controls.add(MediaControl.skipToPrevious);
final workInfo = await db.getWork(recordingInfo.recording.work); }
final title = workInfo.composers if (playing) {
.map((p) => '${p.firstName} ${p.lastName}') controls.add(MediaControl.pause);
.join(', '); } 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; actions.add(MediaAction.seek);
if (partIds.isNotEmpty) { } else {
subtitleBuffer.write(': '); currentTrack = -1;
}
final section = workInfo.sections.lastWhere( playbackState.add(PlaybackState(
(s) => s.beforePartIndex <= partIds[0], processingState: AudioProcessingState.ready,
orElse: () => null, playing: playing,
); controls: controls,
systemActions: actions,
updatePosition: Duration(milliseconds: await player.getPosition()),
queueIndex: currentTrack,
));
}
if (section != null) { Future<void> sendMediaItem() async {
subtitleBuffer.write(section.title); 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(': ');
subtitleBuffer
.write(partIds.map((i) => workInfo.parts[i].title).join(', '));
} }
subtitleBuffer subtitle = subtitleBuffer.toString();
.write(partIds.map((i) => workInfo.parts[i].part.title).join(', ')); } else {
title = '...';
subtitle = '...';
} }
final subtitle = subtitleBuffer.toString(); mediaItem.add(MediaItem(
id: track.id,
AudioServiceBackground.setMediaItem(MediaItem( title: subtitle,
id: track.identifier, album: title,
album: subtitle, duration: Duration(milliseconds: durationMs),
title: title,
displayTitle: title,
displaySubtitle: subtitle,
)); ));
} }
} }
/// 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. /// Notify the UI of the new playback position periodically.
Future<void> _updatePosition() async { Future<void> keepSendingPosition() async {
while (_playing) { while (playing) {
_sendPosition(); sendState();
await Future.delayed( await Future.delayed(const Duration(seconds: 1));
const Duration(milliseconds: positionUpdateInterval));
} }
} }
}
/// Set the current track, update the player and notify the system.
Future<void> _setCurrentTrack(int index) async { class PlaylistEvent {
_currentTrack = index; final List<String> playlist;
_durationMs = await _player.setUri(_playlist[_currentTrack].identifier); PlaylistEvent(this.playlist);
_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();
}
} }

View file

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

View file

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

View file

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