From 8752ac81dd5533ab79eb0a03c49cc1c1034bfa70 Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Sat, 7 May 2022 19:43:55 +0200 Subject: [PATCH] mobile: Update dependencies and adapt to changes --- common/lib/src/playback.dart | 8 +- database/lib/src/database.drift | 5 + mobile/android/app/build.gradle | 25 +- .../android/app/src/main/AndroidManifest.xml | 24 +- .../kotlin/de/johrpan/musicus/MainActivity.kt | 236 +----- mobile/android/app/src/main/res/raw/keep.xml | 3 + mobile/android/build.gradle | 8 +- mobile/android/gradle.properties | 1 - mobile/android/settings.gradle | 18 +- mobile/lib/main.dart | 11 +- mobile/lib/platform.dart | 76 -- mobile/lib/playback.dart | 702 ++++++------------ mobile/pubspec.yaml | 8 +- player/android/build.gradle | 23 +- player/android/gradle.properties | 4 - 15 files changed, 326 insertions(+), 826 deletions(-) create mode 100644 mobile/android/app/src/main/res/raw/keep.xml delete mode 100644 mobile/lib/platform.dart delete mode 100644 player/android/gradle.properties diff --git a/common/lib/src/playback.dart b/common/lib/src/playback.dart index bf7f3f8..2e29cc2 100644 --- a/common/lib/src/playback.dart +++ b/common/lib/src/playback.dart @@ -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]); + } } } } diff --git a/database/lib/src/database.drift b/database/lib/src/database.drift index 4c32376..a9e3499 100644 --- a/database/lib/src/database.drift +++ b/database/lib/src/database.drift @@ -198,3 +198,8 @@ SELECT * FROM tracks WHERE recording = :id ORDER BY "index"; + +tracksById: +SELECT * +FROM tracks +WHERE id = :id; diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 5909b5d..0a1fa8b 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -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' } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index fd2f5d3..b06074d 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,18 +1,24 @@ - + + + - + + - + - + + diff --git a/mobile/android/app/src/main/kotlin/de/johrpan/musicus/MainActivity.kt b/mobile/android/app/src/main/kotlin/de/johrpan/musicus/MainActivity.kt index 9907f7c..06917ea 100644 --- a/mobile/android/app/src/main/kotlin/de/johrpan/musicus/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/de/johrpan/musicus/MainActivity.kt @@ -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 { - 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("treeUri")) - val parentId = call.argument("parentId") - val children = getChildren(treeUri, parentId) - result.success(children.map { it.toMap() }) - } else if (call.method == "readFile") { - val treeUri = Uri.parse(call.argument("treeUri")) - val id = call.argument("id")!! - result.success(readFile(treeUri, id)) - } else if (call.method == "getUriByName") { - val treeUri = Uri.parse(call.argument("treeUri")) - val parentId = call.argument("parentId")!! - val fileName = call.argument("fileName")!! - result.success(getUriByName(treeUri, parentId, fileName).toString()) - } else if (call.method == "readFileByName") { - val treeUri = Uri.parse(call.argument("treeUri")) - val parentId = call.argument("parentId")!! - val fileName = call.argument("fileName")!! - result.success(readFileByName(treeUri, parentId, fileName)) - } else if (call.method == "writeFileByName") { - val treeUri = Uri.parse(call.argument("treeUri")) - val parentId = call.argument("parentId")!! - val fileName = call.argument("fileName")!! - val content = call.argument("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 { - val realParentId = parentId ?: DocumentsContract.getTreeDocumentId(treeUri) - val children = ArrayList() - 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); } } diff --git a/mobile/android/app/src/main/res/raw/keep.xml b/mobile/android/app/src/main/res/raw/keep.xml new file mode 100644 index 0000000..18c2c32 --- /dev/null +++ b/mobile/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,3 @@ + + diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 3100ad2..e76b2d5 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -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() } } diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index 38c8d45..94adc3a 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx1536M -android.enableR8=true android.useAndroidX=true android.enableJetifier=true diff --git a/mobile/android/settings.gradle b/mobile/android/settings.gradle index 5a2f14f..44e62bc 100644 --- a/mobile/android/settings.gradle +++ b/mobile/android/settings.gradle @@ -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" diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 3a729e1..a2b2690 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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 main() async { @@ -14,12 +12,11 @@ Future 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(), ), - )); + ); } diff --git a/mobile/lib/platform.dart b/mobile/lib/platform.dart deleted file mode 100644 index 4ea0d79..0000000 --- a/mobile/lib/platform.dart +++ /dev/null @@ -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 chooseBasePath() async { - return await _platform.invokeMethod('openTree'); - } - - @override - Future> getChildren(String parentId) async { - final List> childrenJson = - await _platform.invokeListMethod( - 'getChildren', - { - 'treeUri': basePath, - 'parentId': parentId, - }, - ); - - return childrenJson - .map((childJson) => Document.fromJson(childJson)) - .toList(); - } - - @override - Future getIdentifier(String parentId, String fileName) async { - return await _platform.invokeMethod( - 'getUriByName', - { - 'treeUri': basePath, - 'parentId': parentId, - 'fileName': fileName, - }, - ); - } - - @override - Future readDocument(String id) async { - return await _platform.invokeMethod( - 'readFile', - { - 'treeUri': basePath, - 'id': id, - }, - ); - } - - @override - Future readDocumentByName(String parentId, String fileName) async { - return await _platform.invokeMethod( - 'readFileByName', - { - 'treeUri': basePath, - 'parentId': parentId, - 'fileName': fileName, - }, - ); - } - - @override - Future writeDocumentByName( - String parentId, String fileName, String contents) async { - await _platform.invokeMethod( - 'writeFileByName', - { - 'treeUri': basePath, - 'parentId': parentId, - 'fileName': fileName, - 'content': contents, - }, - ); - } -} diff --git a/mobile/lib/playback.dart b/mobile/lib/playback.dart index 0b72720..bc99bff 100644 --- a/mobile/lib/playback.dart +++ b/mobile/lib/playback.dart @@ -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 setup(MusicusLibrary musicusLibrary) async { + library = musicusLibrary; -class Playback extends MusicusPlayback { - StreamSubscription _playbackServiceStateSubscription; - - /// Start playback service. - Future _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 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 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 addTracks(List tracks) async { - if (!AudioService.running) { - await _start(); - } - - await AudioService.customAction('addTracks', jsonEncode(tracks)); - } - - @override - Future removeTrack(int index) async { - if (AudioService.running) { - await AudioService.customAction('removeTrack', index); - } + Future addTracks(List tracks) async { + await audioHandler.customAction('addTracks', {'tracks': tracks}); + active.add(true); } @override Future 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 removeTrack(int index) async { + await audioHandler.customAction('removeTrack', {'index': index}); + } + @override Future 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 skipToPrevious() async { - if (AudioService.running) { - await AudioService.skipToPrevious(); - } - } - - @override - Future 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 skipTo(int index) async { - if (AudioService.running) { - await AudioService.customAction('skipTo', index); + await audioHandler.skipToQueueItem(index); + } + + @override + Future skipToNext() async { + await audioHandler.skipToNext(); + } + + @override + Future skipToPrevious() async { + await audioHandler.skipToPrevious(); + } +} + +class MusicusAudioHandler extends BaseAudioHandler { + final MusicusLibrary library; + + MusicusPlayer player; + + List 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 play() async { + await player.play(); + playing = true; + await sendState(); + keepSendingPosition(); + } + + Future pause() async { + await player.pause(); + playing = false; + await sendState(); + } + + Future stop() async { + playlist.clear(); + await player.stop(); + + super.stop(); + } + + Future seek(Duration position) async { + await player.seekTo(position.inMilliseconds); + await sendState(); + } + + @override + Future skipToPrevious() async { + if (currentTrack > 0 && currentTrack < playlist.length) { + await skipToQueueItem(currentTrack - 1); } } @override - void dispose() { - super.dispose(); - _playbackServiceStateSubscription.cancel(); + Future 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 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 customAction(String name, [Map 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 addTracks(List 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 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 _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 _load() async { - final moorPort = IsolateNameServer.lookupPortByName('moor'); - final moorIsolate = MoorIsolate.fromConnectPort(moorPort); - db = MusicusClientDatabase.connect(connection: await moorIsolate.connect()); - _loading.complete(); + Future 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 _setState() async { - final positionMs = await _player.getPosition() ?? 0; - final updateTime = DateTime.now().millisecondsSinceEpoch; + Future 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 sendState() async { + List controls = []; + Set 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 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 _sendStatus() async { - _sendMsg(_StatusMessage( - playing: _playing, - positionMs: await _player.getPosition(), - )); - } - - /// Notify the UI about the current playback position. - Future _sendPosition() async { - _sendMsg(_PositionMessage( - positionMs: await _player.getPosition(), - )); - } - - /// Notify the UI about the current track. - Future _sendTrack() async { - _sendMsg(_TrackMessage( - currentTrack: _currentTrack, - durationMs: _durationMs, - positionMs: await _player.getPosition(), - )); - } - - /// Notify the UI about the current playlist. - Future _sendPlaylist() async { - _sendMsg(_PlaylistMessage( - playlist: _playlist, - currentTrack: _currentTrack, - durationMs: _durationMs, - positionMs: await _player.getPosition(), - )); - } - /// Notify the UI of the new playback position periodically. - Future _updatePosition() async { - while (_playing) { - _sendPosition(); - await Future.delayed( - const Duration(milliseconds: positionUpdateInterval)); + Future keepSendingPosition() async { + while (playing) { + sendState(); + await Future.delayed(const Duration(seconds: 1)); } } - - /// Set the current track, update the player and notify the system. - Future _setCurrentTrack(int index) async { - _currentTrack = index; - _durationMs = await _player.setUri(_playlist[_currentTrack].identifier); - _setState(); - } - - /// Add [tracks] to the playlist. - Future _addTracks(List 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 _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 _skipTo(int index) async { - if (index >= 0 && index < _playlist.length) { - await _setCurrentTrack(index); - _sendTrack(); - } - } - - @override - Future onStart() async { - _setState(); - await _completer.future; - } - - @override - Future onCustomAction(String name, dynamic arguments) async { - super.onCustomAction(name, arguments); - - // addTracks expects a List> as its argument. - // skipTo and removeTrack expect an integer as their argument. - if (name == 'addTracks') { - final tracksJson = jsonDecode(arguments); - final List 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 onSeekTo(int position) async { - super.onSeekTo(position); - - await _player.seekTo(position); - - _sendPosition(); - _setState(); - } - - @override - Future onSkipToNext() async { - super.onSkipToNext(); - - if (_playlist.length > 1 && _currentTrack < _playlist.length - 1) { - await _setCurrentTrack(_currentTrack + 1); - _sendTrack(); - } - } - - @override - Future 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 playlist; + PlaylistEvent(this.playlist); } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e27fb15..b820544 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,24 +1,28 @@ name: musicus version: 0.1.0 description: The classical music player and organizer. -author: Elias Projahn 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 diff --git a/player/android/build.gradle b/player/android/build.gradle index c0d0bf1..8241713 100644 --- a/player/android/build.gradle +++ b/player/android/build.gradle @@ -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 { diff --git a/player/android/gradle.properties b/player/android/gradle.properties deleted file mode 100644 index 38c8d45..0000000 --- a/player/android/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx1536M -android.enableR8=true -android.useAndroidX=true -android.enableJetifier=true