mirror of
				https://github.com/johrpan/musicus_mobile.git
				synced 2025-10-26 02:37:25 +01:00 
			
		
		
		
	mobile: Update dependencies and adapt to changes
This commit is contained in:
		
							parent
							
								
									b14dcd67f2
								
							
						
					
					
						commit
						8752ac81dd
					
				
					 15 changed files with 326 additions and 826 deletions
				
			
		|  | @ -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]); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -198,3 +198,8 @@ SELECT * | |||
| FROM tracks | ||||
| WHERE recording = :id | ||||
| ORDER BY "index"; | ||||
| 
 | ||||
| tracksById: | ||||
| SELECT * | ||||
| FROM tracks | ||||
| WHERE id = :id; | ||||
|  |  | |||
|  | @ -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' | ||||
| } | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
| } | ||||
|  |  | |||
							
								
								
									
										3
									
								
								mobile/android/app/src/main/res/raw/keep.xml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								mobile/android/app/src/main/res/raw/keep.xml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,3 @@ | |||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <resources xmlns:tools="http://schemas.android.com/tools" | ||||
|   tools:keep="@drawable/*" /> | ||||
|  | @ -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() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,3 @@ | |||
| org.gradle.jvmargs=-Xmx1536M | ||||
| android.enableR8=true | ||||
| android.useAndroidX=true | ||||
| android.enableJetifier=true | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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(), | ||||
|     ), | ||||
|   )); | ||||
|   ); | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | @ -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); | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -1,4 +0,0 @@ | |||
| org.gradle.jvmargs=-Xmx1536M | ||||
| android.enableR8=true | ||||
| android.useAndroidX=true | ||||
| android.enableJetifier=true | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue