Rename top level directories
31
mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# VS Code related
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/*.g.dart
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
pubspec.lock
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
8
mobile/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
68
mobile/android/app/build.gradle
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
compileSdkVersion 29
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
disable 'InvalidPackage'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "de.johrpan.musicus"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 29
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
7
mobile/android/app/src/debug/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="de.johrpan.musicus">
|
||||
<!-- Flutter needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
43
mobile/android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="de.johrpan.musicus">
|
||||
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:name="io.flutter.app.FlutterApplication"
|
||||
android:label="Musicus"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service android:name="com.ryanheise.audioservice.AudioService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
BIN
mobile/android/app/src/main/ic_launcher-web.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
|
|
@ -0,0 +1,240 @@
|
|||
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 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()
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 674 B |
BIN
mobile/android/app/src/main/res/drawable-hdpi/ic_pause.png
Normal file
|
After Width: | Height: | Size: 140 B |
BIN
mobile/android/app/src/main/res/drawable-hdpi/ic_play.png
Normal file
|
After Width: | Height: | Size: 272 B |
BIN
mobile/android/app/src/main/res/drawable-hdpi/ic_stop.png
Normal file
|
After Width: | Height: | Size: 102 B |
|
After Width: | Height: | Size: 485 B |
BIN
mobile/android/app/src/main/res/drawable-mdpi/ic_pause.png
Normal file
|
After Width: | Height: | Size: 108 B |
BIN
mobile/android/app/src/main/res/drawable-mdpi/ic_play.png
Normal file
|
After Width: | Height: | Size: 159 B |
BIN
mobile/android/app/src/main/res/drawable-mdpi/ic_stop.png
Normal file
|
After Width: | Height: | Size: 92 B |
|
After Width: | Height: | Size: 1 KiB |
BIN
mobile/android/app/src/main/res/drawable-xhdpi/ic_pause.png
Normal file
|
After Width: | Height: | Size: 162 B |
BIN
mobile/android/app/src/main/res/drawable-xhdpi/ic_play.png
Normal file
|
After Width: | Height: | Size: 288 B |
BIN
mobile/android/app/src/main/res/drawable-xhdpi/ic_stop.png
Normal file
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 1.4 KiB |
BIN
mobile/android/app/src/main/res/drawable-xxhdpi/ic_pause.png
Normal file
|
After Width: | Height: | Size: 202 B |
BIN
mobile/android/app/src/main/res/drawable-xxhdpi/ic_play.png
Normal file
|
After Width: | Height: | Size: 547 B |
BIN
mobile/android/app/src/main/res/drawable-xxhdpi/ic_stop.png
Normal file
|
After Width: | Height: | Size: 196 B |
|
After Width: | Height: | Size: 2.3 KiB |
BIN
mobile/android/app/src/main/res/drawable-xxxhdpi/ic_pause.png
Normal file
|
After Width: | Height: | Size: 244 B |
BIN
mobile/android/app/src/main/res/drawable-xxxhdpi/ic_play.png
Normal file
|
After Width: | Height: | Size: 488 B |
BIN
mobile/android/app/src/main/res/drawable-xxxhdpi/ic_stop.png
Normal file
|
After Width: | Height: | Size: 244 B |
|
|
@ -0,0 +1,11 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<path
|
||||
android:pathData="m45.993,37.589c-1.147,6.587 -2.628,14.34 -4.415,23.149 -1.222,-1.066 -2.952,-1.735 -4.877,-1.735 -3.701,0 -6.701,2.462 -6.701,5.498s3,5.498 6.701,5.498 6.101,-1.669 6.701,-5.498c0.858,-6.02 1.602,-10.357 2.94,-18.602h0.195c2.249,4.725 4.562,9.879 6.941,15.456l2.43,-4.965c-2.647,-5.62 -5.452,-11.86 -8.432,-18.802zM68.7,37.589c-4.441,9.091 -9.305,19.012 -13.563,27.716 0.495,1.106 0.494,1.094 1.631,1.863 3.214,-7.358 6.516,-14.404 9.904,-21.137l0.175,0.022c1.149,8.652 1.97,15.734 2.464,21.246 0.451,-0.087 1.185,-0.131 2.203,-0.131 1.207,0 2.036,0.043 2.486,0.131 -1.629,-9.772 -3.023,-19.675 -4.187,-29.709z"
|
||||
android:strokeWidth=".80618"
|
||||
android:fillColor="#ffc107" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 2 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
BIN
mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
4
mobile/android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<resources>
|
||||
<color name="background">#303030</color>
|
||||
</resources>
|
||||
6
mobile/android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/background</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
mobile/android/app/src/profile/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="de.johrpan.musicus">
|
||||
<!-- Flutter needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
31
mobile/android/build.gradle
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
buildscript {
|
||||
ext.kotlin_version = '1.3.50'
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.5.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
4
mobile/android/gradle.properties
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.enableR8=true
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
15
mobile/android/settings.gradle
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
include ':app'
|
||||
|
||||
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
|
||||
|
||||
def plugins = new Properties()
|
||||
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
|
||||
if (pluginsFile.exists()) {
|
||||
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
|
||||
}
|
||||
|
||||
plugins.each { name, path ->
|
||||
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
|
||||
include ":$name"
|
||||
project(":$name").projectDir = pluginDirectory
|
||||
}
|
||||
6
mobile/build.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
targets:
|
||||
$default:
|
||||
builders:
|
||||
moor_generator:
|
||||
options:
|
||||
generate_connect_constructor: true
|
||||
BIN
mobile/fonts/libertinussans_bold.otf
Normal file
BIN
mobile/fonts/libertinussans_italic.otf
Normal file
BIN
mobile/fonts/libertinussans_regular.otf
Normal file
141
mobile/lib/app.dart
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'backend.dart';
|
||||
import 'screens/home.dart';
|
||||
import 'widgets/player_bar.dart';
|
||||
|
||||
class App extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Musicus',
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
accentColor: Colors.amber,
|
||||
textSelectionColor: Colors.grey[600],
|
||||
cursorColor: Colors.amber,
|
||||
textSelectionHandleColor: Colors.amber,
|
||||
toggleableActiveColor: Colors.amber,
|
||||
// Added for sliders and FABs. Not everything seems to obey this.
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: Colors.amber,
|
||||
secondary: Colors.amber,
|
||||
),
|
||||
fontFamily: 'Libertinus Sans',
|
||||
),
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
if (backend.status == BackendStatus.loading) {
|
||||
return Material(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
);
|
||||
} else if (backend.status == BackendStatus.setup) {
|
||||
return Material(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Choose the base path for\nyour music library.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_open),
|
||||
title: Text('Choose path'),
|
||||
onTap: () {
|
||||
backend.chooseMusicLibraryUri();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Content();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Content extends StatefulWidget {
|
||||
@override
|
||||
_ContentState createState() => _ContentState();
|
||||
}
|
||||
|
||||
class _ContentState extends State<Content> with SingleTickerProviderStateMixin {
|
||||
final nestedNavigator = GlobalKey<NavigatorState>();
|
||||
|
||||
AnimationController playerBarAnimation;
|
||||
BackendState backend;
|
||||
StreamSubscription<bool> playerActiveSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
playerBarAnimation = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
playerBarAnimation.value = backend.player.active.value ? 1.0 : 0.0;
|
||||
|
||||
if (playerActiveSubscription != null) {
|
||||
playerActiveSubscription.cancel();
|
||||
}
|
||||
|
||||
playerActiveSubscription = backend.player.active.listen((active) =>
|
||||
active ? playerBarAnimation.forward() : playerBarAnimation.reverse());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The nested Navigator is for every screen from which the player bar at
|
||||
// the bottom should be accessible. The WillPopScope widget intercepts
|
||||
// taps on the system back button and redirects them to the nested
|
||||
// navigator.
|
||||
return WillPopScope(
|
||||
onWillPop: () async => !(await nestedNavigator.currentState.maybePop()),
|
||||
child: Scaffold(
|
||||
body: Navigator(
|
||||
key: nestedNavigator,
|
||||
onGenerateRoute: (settings) => settings.name == '/'
|
||||
? MaterialPageRoute(
|
||||
builder: (context) => HomeScreen(),
|
||||
)
|
||||
: null,
|
||||
initialRoute: '/',
|
||||
),
|
||||
bottomNavigationBar: SizeTransition(
|
||||
sizeFactor: CurvedAnimation(
|
||||
curve: Curves.easeOut,
|
||||
parent: playerBarAnimation,
|
||||
),
|
||||
axisAlignment: -1.0,
|
||||
child: PlayerBar(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
playerActiveSubscription.cancel();
|
||||
}
|
||||
}
|
||||
166
mobile/lib/backend.dart
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:moor/isolate.dart';
|
||||
import 'package:moor/moor.dart';
|
||||
import 'package:moor_ffi/moor_ffi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart' as pp;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'database.dart';
|
||||
import 'music_library.dart';
|
||||
import 'player.dart';
|
||||
|
||||
// The following code was taken from
|
||||
// https://moor.simonbinder.eu/docs/advanced-features/isolates/ and just
|
||||
// slightly modified.
|
||||
|
||||
Future<MoorIsolate> _createMoorIsolate() async {
|
||||
// This method is called from the main isolate. Since we can't use
|
||||
// getApplicationDocumentsDirectory on a background isolate, we calculate
|
||||
// the database path in the foreground isolate and then inform the
|
||||
// background isolate about the path.
|
||||
final dir = await pp.getApplicationDocumentsDirectory();
|
||||
final path = p.join(dir.path, 'db.sqlite');
|
||||
final receivePort = ReceivePort();
|
||||
|
||||
await Isolate.spawn(
|
||||
_startBackground,
|
||||
_IsolateStartRequest(receivePort.sendPort, path),
|
||||
);
|
||||
|
||||
// _startBackground will send the MoorIsolate to this ReceivePort.
|
||||
return (await receivePort.first as MoorIsolate);
|
||||
}
|
||||
|
||||
void _startBackground(_IsolateStartRequest request) {
|
||||
// This is the entrypoint from the background isolate! Let's create
|
||||
// the database from the path we received.
|
||||
final executor = VmDatabase(File(request.targetPath));
|
||||
// We're using MoorIsolate.inCurrent here as this method already runs on a
|
||||
// background isolate. If we used MoorIsolate.spawn, a third isolate would be
|
||||
// started which is not what we want!
|
||||
final moorIsolate = MoorIsolate.inCurrent(
|
||||
() => DatabaseConnection.fromExecutor(executor),
|
||||
);
|
||||
// Inform the starting isolate about this, so that it can call .connect().
|
||||
request.sendMoorIsolate.send(moorIsolate);
|
||||
}
|
||||
|
||||
// Used to bundle the SendPort and the target path, since isolate entrypoint
|
||||
// functions can only take one parameter.
|
||||
class _IsolateStartRequest {
|
||||
final SendPort sendMoorIsolate;
|
||||
final String targetPath;
|
||||
|
||||
_IsolateStartRequest(this.sendMoorIsolate, this.targetPath);
|
||||
}
|
||||
|
||||
enum BackendStatus {
|
||||
loading,
|
||||
setup,
|
||||
ready,
|
||||
}
|
||||
|
||||
class Backend extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
Backend({
|
||||
@required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
BackendState createState() => BackendState();
|
||||
|
||||
static BackendState of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_InheritedBackend>().state;
|
||||
}
|
||||
|
||||
class BackendState extends State<Backend> {
|
||||
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
||||
|
||||
final player = Player();
|
||||
|
||||
BackendStatus status = BackendStatus.loading;
|
||||
Database db;
|
||||
String musicLibraryUri;
|
||||
MusicLibrary ml;
|
||||
|
||||
MoorIsolate _moorIsolate;
|
||||
SharedPreferences _shPref;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _InheritedBackend(
|
||||
child: widget.child,
|
||||
state: this,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
_moorIsolate = await _createMoorIsolate();
|
||||
final dbConnection = await _moorIsolate.connect();
|
||||
player.setup();
|
||||
db = Database.connect(dbConnection);
|
||||
|
||||
_shPref = await SharedPreferences.getInstance();
|
||||
musicLibraryUri = _shPref.getString('musicLibraryUri');
|
||||
|
||||
_loadMusicLibrary();
|
||||
}
|
||||
|
||||
Future<void> _loadMusicLibrary() async {
|
||||
if (musicLibraryUri == null) {
|
||||
setState(() {
|
||||
status = BackendStatus.setup;
|
||||
});
|
||||
} else {
|
||||
ml = MusicLibrary(musicLibraryUri);
|
||||
await ml.load();
|
||||
setState(() {
|
||||
status = BackendStatus.ready;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> chooseMusicLibraryUri() async {
|
||||
final uri = await _platform.invokeMethod<String>('openTree');
|
||||
|
||||
if (uri != null) {
|
||||
musicLibraryUri = uri;
|
||||
await _shPref.setString('musicLibraryUri', uri);
|
||||
setState(() {
|
||||
status = BackendStatus.loading;
|
||||
});
|
||||
await _loadMusicLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_moorIsolate.shutdownAll();
|
||||
}
|
||||
}
|
||||
|
||||
class _InheritedBackend extends InheritedWidget {
|
||||
final Widget child;
|
||||
final BackendState state;
|
||||
|
||||
_InheritedBackend({
|
||||
@required this.child,
|
||||
@required this.state,
|
||||
}) : super(child: child);
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_InheritedBackend old) => true;
|
||||
}
|
||||
102
mobile/lib/database.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:moor/moor.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
final _random = Random(DateTime.now().millisecondsSinceEpoch);
|
||||
int generateId() => _random.nextInt(0xFFFFFFFF);
|
||||
|
||||
class WorkModel {
|
||||
final Work work;
|
||||
final List<int> instrumentIds;
|
||||
|
||||
WorkModel({
|
||||
@required this.work,
|
||||
@required this.instrumentIds,
|
||||
});
|
||||
}
|
||||
|
||||
class PerformanceModel {
|
||||
final Person person;
|
||||
final Ensemble ensemble;
|
||||
final Instrument role;
|
||||
|
||||
PerformanceModel({
|
||||
this.person,
|
||||
this.ensemble,
|
||||
this.role,
|
||||
});
|
||||
}
|
||||
|
||||
@UseMoor(
|
||||
include: {
|
||||
'database.moor',
|
||||
},
|
||||
)
|
||||
class Database extends _$Database {
|
||||
Database.connect(DatabaseConnection connection) : super.connect(connection);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
beforeOpen: (details) async {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> updatePerson(Person person) async {
|
||||
await into(persons).insert(person, orReplace: true);
|
||||
}
|
||||
|
||||
Future<void> updateInstrument(Instrument instrument) async {
|
||||
await into(instruments).insert(instrument, orReplace: true);
|
||||
}
|
||||
|
||||
Future<void> updateWork(WorkModel model, List<WorkModel> parts) async {
|
||||
await transaction(() async {
|
||||
final workId = model.work.id;
|
||||
await (delete(works)..where((w) => w.id.equals(workId))).go();
|
||||
await (delete(works)..where((w) => w.partOf.equals(workId))).go();
|
||||
|
||||
Future<void> insertWork(WorkModel model) async {
|
||||
await into(works).insert(model.work);
|
||||
await batch((b) => b.insertAll(
|
||||
instrumentations,
|
||||
model.instrumentIds
|
||||
.map((id) =>
|
||||
Instrumentation(work: model.work.id, instrument: id))
|
||||
.toList()));
|
||||
}
|
||||
|
||||
await insertWork(model);
|
||||
for (final part in parts) {
|
||||
await insertWork(part);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateEnsemble(Ensemble ensemble) async {
|
||||
await into(ensembles).insert(ensemble, orReplace: true);
|
||||
}
|
||||
|
||||
Future<void> updateRecording(
|
||||
Recording recording, List<PerformanceModel> models) async {
|
||||
await transaction(() async {
|
||||
await (delete(performances)
|
||||
..where((p) => p.recording.equals(recording.id)))
|
||||
.go();
|
||||
await into(recordings).insert(recording, orReplace: true);
|
||||
for (final model in models) {
|
||||
await into(performances).insert(Performance(
|
||||
recording: recording.id,
|
||||
person: model.person?.id,
|
||||
ensemble: model.ensemble?.id,
|
||||
role: model.role?.id,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
91
mobile/lib/database.moor
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
CREATE TABLE persons (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- This represents real instruments as well as other roles that can be played
|
||||
-- in a recording.
|
||||
CREATE TABLE instruments (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE works (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
composer INTEGER REFERENCES persons(id),
|
||||
title TEXT NOT NULL,
|
||||
part_of INTEGER REFERENCES works(id) ON DELETE CASCADE,
|
||||
part_index INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE instrumentations (
|
||||
work INTEGER NOT NULL REFERENCES works(id) ON DELETE CASCADE,
|
||||
instrument INTEGER NOT NULL REFERENCES instruments(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE ensembles (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE recordings (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
work INTEGER REFERENCES works(id),
|
||||
comment TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE performances (
|
||||
recording INTEGER NOT NULL REFERENCES recordings(id) ON DELETE CASCADE,
|
||||
person INTEGER REFERENCES persons(id) ON DELETE CASCADE,
|
||||
ensemble INTEGER REFERENCES ensembles(id) ON DELETE CASCADE,
|
||||
role INTEGER REFERENCES instruments(id)
|
||||
);
|
||||
|
||||
allPersons:
|
||||
SELECT * FROM persons ORDER BY last_name;
|
||||
|
||||
personById:
|
||||
SELECT * FROM persons WHERE id = :id LIMIT 1;
|
||||
|
||||
allInstruments:
|
||||
SELECT * FROM instruments ORDER BY name;
|
||||
|
||||
instrumentById:
|
||||
SELECT * FROM instruments WHERE id = :id LIMIT 1;
|
||||
|
||||
workById:
|
||||
SELECT * FROM works WHERE id = :id LIMIT 1;
|
||||
|
||||
workParts:
|
||||
SELECT * FROM works WHERE part_of = :id ORDER BY part_index;
|
||||
|
||||
-- TODO: Maybe optimize.
|
||||
worksByComposer:
|
||||
SELECT DISTINCT A.* FROM works A LEFT JOIN works B ON A.id = B.part_of
|
||||
WHERE A.part_of IS NULL AND A.composer = :id OR B.composer = :id;
|
||||
|
||||
composersByWork:
|
||||
SELECT DISTINCT persons.* FROM persons
|
||||
JOIN works ON works.composer = persons.id
|
||||
WHERE works.id = :id OR works.part_of = :id;
|
||||
|
||||
instrumentsByWork:
|
||||
SELECT instruments.* FROM instrumentations
|
||||
JOIN instruments ON instrumentations.instrument=instruments.id
|
||||
WHERE instrumentations.work = :workId;
|
||||
|
||||
allEnsembles:
|
||||
SELECT * FROM ensembles ORDER BY name;
|
||||
|
||||
ensembleById:
|
||||
SELECT * FROM ensembles WHERE id = :id LIMIT 1;
|
||||
|
||||
recordingById:
|
||||
SELECT * FROM recordings WHERE id = :id;
|
||||
|
||||
recordingsByWork:
|
||||
SELECT * FROM recordings WHERE work = :id;
|
||||
|
||||
performancesByRecording:
|
||||
SELECT * FROM performances WHERE recording = :id;
|
||||
66
mobile/lib/editors/ensemble.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class EnsembleEditor extends StatefulWidget {
|
||||
final Ensemble ensemble;
|
||||
|
||||
EnsembleEditor({
|
||||
this.ensemble,
|
||||
});
|
||||
|
||||
@override
|
||||
_EnsembleEditorState createState() => _EnsembleEditorState();
|
||||
}
|
||||
|
||||
class _EnsembleEditorState extends State<EnsembleEditor> {
|
||||
final nameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.ensemble != null) {
|
||||
nameController.text = widget.ensemble.name;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Ensemble'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final ensemble = Ensemble(
|
||||
id: widget.ensemble?.id ?? generateId(),
|
||||
name: nameController.text,
|
||||
);
|
||||
|
||||
await backend.db.updateEnsemble(ensemble);
|
||||
Navigator.pop(context, ensemble);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
mobile/lib/editors/instrument.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class InstrumentEditor extends StatefulWidget {
|
||||
final Instrument instrument;
|
||||
|
||||
InstrumentEditor({
|
||||
this.instrument,
|
||||
});
|
||||
|
||||
@override
|
||||
_InstrumentEditorState createState() => _InstrumentEditorState();
|
||||
}
|
||||
|
||||
class _InstrumentEditorState extends State<InstrumentEditor> {
|
||||
final nameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.instrument != null) {
|
||||
nameController.text = widget.instrument.name;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Instrument/Role'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final instrument = Instrument(
|
||||
id: widget.instrument?.id ?? generateId(),
|
||||
name: nameController.text,
|
||||
);
|
||||
|
||||
await backend.db.updateInstrument(instrument);
|
||||
Navigator.pop(context, instrument);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
mobile/lib/editors/person.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class PersonEditor extends StatefulWidget {
|
||||
final Person person;
|
||||
|
||||
PersonEditor({
|
||||
this.person,
|
||||
});
|
||||
|
||||
@override
|
||||
_PersonEditorState createState() => _PersonEditorState();
|
||||
}
|
||||
|
||||
class _PersonEditorState extends State<PersonEditor> {
|
||||
final firstNameController = TextEditingController();
|
||||
final lastNameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.person != null) {
|
||||
firstNameController.text = widget.person.firstName;
|
||||
lastNameController.text = widget.person.lastName;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Person'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final person = Person(
|
||||
id: widget.person?.id ?? generateId(),
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
);
|
||||
|
||||
await backend.db.updatePerson(person);
|
||||
Navigator.pop(context, person);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: firstNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'First name',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: lastNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Last name',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
mobile/lib/editors/recording.dart
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../selectors/performer.dart';
|
||||
import '../selectors/work.dart';
|
||||
import '../widgets/texts.dart';
|
||||
|
||||
class RecordingEditor extends StatefulWidget {
|
||||
final Recording recording;
|
||||
|
||||
RecordingEditor({
|
||||
this.recording,
|
||||
});
|
||||
|
||||
@override
|
||||
_RecordingEditorState createState() => _RecordingEditorState();
|
||||
}
|
||||
|
||||
class _RecordingEditorState extends State<RecordingEditor> {
|
||||
final commentController = TextEditingController();
|
||||
|
||||
Work work;
|
||||
List<PerformanceModel> performances = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.recording != null) {
|
||||
// TODO: Initialize.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
Future<void> selectWork() async {
|
||||
final Work newWork = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkSelector(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (newWork != null) {
|
||||
setState(() {
|
||||
work = newWork;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Recording'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final recording = Recording(
|
||||
id: widget.recording?.id ?? generateId(),
|
||||
work: work.id,
|
||||
comment: commentController.text,
|
||||
);
|
||||
|
||||
await backend.db.updateRecording(recording, performances);
|
||||
Navigator.pop(context, recording);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
work != null
|
||||
? ListTile(
|
||||
title: WorkText(work.id),
|
||||
subtitle: ComposersText(work.id),
|
||||
onTap: selectWork,
|
||||
)
|
||||
: ListTile(
|
||||
title: Text('Work'),
|
||||
subtitle: Text('Select work'),
|
||||
onTap: selectWork,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
top: 0.0,
|
||||
bottom: 16.0,
|
||||
),
|
||||
child: TextField(
|
||||
controller: commentController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Comment',
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Performers'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final PerformanceModel model = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PerformerSelector(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (model != null) {
|
||||
setState(() {
|
||||
performances.add(model);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
for (final performance in performances)
|
||||
ListTile(
|
||||
title: Text(performance.person != null
|
||||
? '${performance.person.firstName} ${performance.person.lastName}'
|
||||
: performance.ensemble.name),
|
||||
subtitle:
|
||||
performance.role != null ? Text(performance.role.name) : null,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
performances.remove(performance);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
158
mobile/lib/editors/tracks.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../music_library.dart';
|
||||
import '../selectors/files.dart';
|
||||
import '../selectors/recording.dart';
|
||||
import '../widgets/recording_tile.dart';
|
||||
|
||||
class TrackModel {
|
||||
int workPartIndex;
|
||||
String workPartTitle;
|
||||
String fileName;
|
||||
|
||||
TrackModel(this.fileName);
|
||||
}
|
||||
|
||||
class TracksEditor extends StatefulWidget {
|
||||
@override
|
||||
_TracksEditorState createState() => _TracksEditorState();
|
||||
}
|
||||
|
||||
class _TracksEditorState extends State<TracksEditor> {
|
||||
BackendState backend;
|
||||
int recordingId;
|
||||
String parentId;
|
||||
List<TrackModel> trackModels = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Tracks'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final List<Track> tracks = [];
|
||||
|
||||
for (var i = 0; i < trackModels.length; i++) {
|
||||
final trackModel = trackModels[i];
|
||||
|
||||
tracks.add(Track(
|
||||
fileName: trackModel.fileName,
|
||||
recordingId: recordingId,
|
||||
index: i,
|
||||
partIds: [trackModel.workPartIndex],
|
||||
));
|
||||
}
|
||||
|
||||
backend.ml.addTracks(parentId, tracks);
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ReorderableListView(
|
||||
header: Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: recordingId != null
|
||||
? RecordingTile(
|
||||
recordingId: recordingId,
|
||||
)
|
||||
: Text('Select recording'),
|
||||
onTap: selectRecording,
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Files'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () async {
|
||||
final FilesSelectorResult result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FilesSelector(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
final List<TrackModel> newTrackModels = [];
|
||||
|
||||
for (final document in result.selection) {
|
||||
newTrackModels.add(TrackModel(document.name));
|
||||
}
|
||||
|
||||
setState(() {
|
||||
parentId = result.parentId;
|
||||
trackModels = newTrackModels;
|
||||
});
|
||||
|
||||
if (recordingId != null) {
|
||||
updateAutoParts();
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: trackModels
|
||||
.map((t) => ListTile(
|
||||
key: Key(t.hashCode.toString()),
|
||||
leading: const Icon(Icons.drag_handle),
|
||||
title: Text(t.workPartTitle ?? 'Set work part'),
|
||||
subtitle: Text(t.fileName),
|
||||
))
|
||||
.toList(),
|
||||
onReorder: (i1, i2) {
|
||||
setState(() {
|
||||
final track = trackModels.removeAt(i1);
|
||||
final newIndex = i2 > i1 ? i2 - 1 : i2;
|
||||
trackModels.insert(newIndex, track);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> selectRecording() async {
|
||||
final Recording recording = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RecordingsSelector(),
|
||||
),
|
||||
);
|
||||
|
||||
if (recording != null) {
|
||||
setState(() {
|
||||
recordingId = recording.id;
|
||||
});
|
||||
|
||||
updateAutoParts();
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatically associate the tracks with work parts.
|
||||
Future<void> updateAutoParts() async {
|
||||
final recording = await backend.db.recordingById(recordingId).getSingle();
|
||||
final workId = recording.work;
|
||||
final workParts = await backend.db.workParts(workId).get();
|
||||
|
||||
setState(() {
|
||||
for (var i = 0; i < trackModels.length; i++) {
|
||||
if (i >= workParts.length) {
|
||||
trackModels[i].workPartIndex = null;
|
||||
trackModels[i].workPartTitle = null;
|
||||
} else {
|
||||
trackModels[i].workPartIndex = workParts[i].partIndex;
|
||||
trackModels[i].workPartTitle = workParts[i].title;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
378
mobile/lib/editors/work.dart
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../selectors/instruments.dart';
|
||||
import '../selectors/person.dart';
|
||||
|
||||
class PartData {
|
||||
final titleController = TextEditingController();
|
||||
|
||||
Person composer;
|
||||
List<Instrument> instruments;
|
||||
|
||||
PartData({
|
||||
String title,
|
||||
this.composer,
|
||||
this.instruments = const [],
|
||||
}) {
|
||||
titleController.text = title ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
class WorkProperties extends StatelessWidget {
|
||||
final TextEditingController titleController;
|
||||
final Person composer;
|
||||
final List<Instrument> instruments;
|
||||
final void Function(Person) onComposerChanged;
|
||||
final void Function(List<Instrument>) onInstrumentsChanged;
|
||||
|
||||
WorkProperties({
|
||||
@required this.titleController,
|
||||
@required this.composer,
|
||||
@required this.instruments,
|
||||
@required this.onComposerChanged,
|
||||
@required this.onInstrumentsChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: titleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Title',
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Composer'),
|
||||
subtitle: Text(composer != null
|
||||
? '${composer.firstName} ${composer.lastName}'
|
||||
: 'Select composer'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
onComposerChanged(null);
|
||||
},
|
||||
),
|
||||
onTap: () async {
|
||||
final Person person = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonsSelector(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (person != null) {
|
||||
onComposerChanged(person);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Instruments'),
|
||||
subtitle: Text(instruments.isNotEmpty
|
||||
? instruments.map((i) => i.name).join(', ')
|
||||
: 'Select instruments'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
onInstrumentsChanged([]);
|
||||
}),
|
||||
onTap: () async {
|
||||
final List<Instrument> selection = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InstrumentsSelector(
|
||||
multiple: true,
|
||||
selection: instruments,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (selection != null) {
|
||||
onInstrumentsChanged(selection);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PartTile extends StatefulWidget {
|
||||
final PartData part;
|
||||
final void Function() onMore;
|
||||
final void Function() onDelete;
|
||||
|
||||
PartTile({
|
||||
Key key,
|
||||
@required this.part,
|
||||
@required this.onMore,
|
||||
@required this.onDelete,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_PartTileState createState() => _PartTileState();
|
||||
}
|
||||
|
||||
class _PartTileState extends State<PartTile> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 8.0),
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.part.titleController,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Part title',
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
onPressed: widget.onMore,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: widget.onDelete,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkEditor extends StatefulWidget {
|
||||
final Work work;
|
||||
|
||||
WorkEditor({
|
||||
this.work,
|
||||
});
|
||||
|
||||
@override
|
||||
_WorkEditorState createState() => _WorkEditorState();
|
||||
}
|
||||
|
||||
class _WorkEditorState extends State<WorkEditor> {
|
||||
final titleController = TextEditingController();
|
||||
|
||||
BackendState backend;
|
||||
|
||||
String title = '';
|
||||
Person composer;
|
||||
List<Instrument> instruments = [];
|
||||
List<PartData> parts = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.work != null) {
|
||||
titleController.text = widget.work.title;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
|
||||
if (widget.work != null) {
|
||||
if (widget.work.composer != null) {
|
||||
() async {
|
||||
final person =
|
||||
await backend.db.personById(widget.work.composer).getSingle();
|
||||
|
||||
// We don't want to override a newly selected composer.
|
||||
if (composer == null) {
|
||||
setState(() {
|
||||
composer = person;
|
||||
});
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
() async {
|
||||
final selection =
|
||||
await backend.db.instrumentsByWork(widget.work.id).get();
|
||||
|
||||
// We don't want to override already selected instruments.
|
||||
if (instruments.isEmpty) {
|
||||
setState(() {
|
||||
instruments = selection;
|
||||
});
|
||||
}
|
||||
}();
|
||||
|
||||
() async {
|
||||
final dbParts = await backend.db.workParts(widget.work.id).get();
|
||||
for (final dbPart in dbParts) {
|
||||
final partInstruments =
|
||||
await backend.db.instrumentsByWork(dbPart.id).get();
|
||||
|
||||
Person partComposer;
|
||||
|
||||
if (dbPart.composer != null) {
|
||||
partComposer =
|
||||
await backend.db.personById(dbPart.composer).getSingle();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
parts.add(PartData(
|
||||
title: dbPart.title,
|
||||
composer: partComposer,
|
||||
instruments: partInstruments,
|
||||
));
|
||||
});
|
||||
}
|
||||
}();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> partTiles = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
final part = parts[i];
|
||||
|
||||
partTiles.add(PartTile(
|
||||
key: Key(part.hashCode.toString()),
|
||||
part: part,
|
||||
onMore: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) => Dialog(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: <Widget>[
|
||||
WorkProperties(
|
||||
titleController: part.titleController,
|
||||
composer: part.composer,
|
||||
instruments: part.instruments,
|
||||
onComposerChanged: (composer) {
|
||||
setState(() {
|
||||
part.composer = composer;
|
||||
});
|
||||
},
|
||||
onInstrumentsChanged: (instruments) {
|
||||
setState(() {
|
||||
part.instruments = instruments;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onDelete: () {
|
||||
setState(() {
|
||||
parts.removeAt(i);
|
||||
});
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Work'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final workId = widget.work?.id ?? generateId();
|
||||
|
||||
final model = WorkModel(
|
||||
work: Work(
|
||||
id: workId,
|
||||
title: titleController.text,
|
||||
composer: composer?.id,
|
||||
),
|
||||
instrumentIds: instruments.map((i) => i.id).toList(),
|
||||
);
|
||||
|
||||
final List<WorkModel> partModels = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
final part = parts[i];
|
||||
partModels.add(WorkModel(
|
||||
work: Work(
|
||||
id: generateId(),
|
||||
title: part.titleController.text,
|
||||
composer: part.composer?.id,
|
||||
partOf: workId,
|
||||
partIndex: i,
|
||||
),
|
||||
instrumentIds: part.instruments.map((i) => i.id).toList(),
|
||||
));
|
||||
}
|
||||
|
||||
await backend.db.updateWork(model, partModels);
|
||||
Navigator.pop(context, model.work);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ReorderableListView(
|
||||
header: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
WorkProperties(
|
||||
titleController: titleController,
|
||||
composer: composer,
|
||||
instruments: instruments,
|
||||
onComposerChanged: (newComposer) {
|
||||
setState(() {
|
||||
composer = newComposer;
|
||||
});
|
||||
},
|
||||
onInstrumentsChanged: (newInstruments) {
|
||||
setState(() {
|
||||
instruments = newInstruments;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (parts.length > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Text(
|
||||
'Parts',
|
||||
style: Theme.of(context).textTheme.subtitle1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: partTiles,
|
||||
onReorder: (i1, i2) {
|
||||
setState(() {
|
||||
final part = parts.removeAt(i1);
|
||||
final newIndex = i2 > i1 ? i2 - 1 : i2;
|
||||
|
||||
parts.insert(newIndex, part);
|
||||
});
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('Add part'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
parts.add(PartData());
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
mobile/lib/main.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'backend.dart';
|
||||
|
||||
void main() {
|
||||
runApp(AudioServiceWidget(
|
||||
child: Backend(
|
||||
child: App(),
|
||||
),
|
||||
));
|
||||
}
|
||||
189
mobile/lib/music_library.dart
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'platform.dart';
|
||||
|
||||
/// Bundles a [Track] with the URI of the audio file it represents.
|
||||
///
|
||||
/// The uri shouldn't be stored on disk, but will be used at runtime.
|
||||
class InternalTrack {
|
||||
/// The represented track.
|
||||
final Track track;
|
||||
|
||||
/// The URI of the represented audio file as retrieved from the SAF.
|
||||
final String uri;
|
||||
|
||||
InternalTrack({
|
||||
this.track,
|
||||
this.uri,
|
||||
});
|
||||
|
||||
factory InternalTrack.fromJson(Map<String, dynamic> json) => InternalTrack(
|
||||
track: Track.fromJson(json['track']),
|
||||
uri: json['uri'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'track': track.toJson(),
|
||||
'uri': uri,
|
||||
};
|
||||
}
|
||||
|
||||
/// Description of a concrete audio file.
|
||||
///
|
||||
/// This gets stored in the folder of the audio file and links the audio file
|
||||
/// to a recording in the database.
|
||||
class Track {
|
||||
/// The name of the file that contains the track's audio.
|
||||
///
|
||||
/// This corresponds to a document ID in terms of the Android Storage Access
|
||||
/// Framework.
|
||||
final String fileName;
|
||||
|
||||
/// Index within the list of tracks for the corresponding recording.
|
||||
final int index;
|
||||
|
||||
/// Of which recording this track is a part of.
|
||||
final int recordingId;
|
||||
|
||||
/// Which work parts of the recorded work are contained in this track.
|
||||
final List<int> partIds;
|
||||
|
||||
Track({
|
||||
this.fileName,
|
||||
this.index,
|
||||
this.recordingId,
|
||||
this.partIds,
|
||||
});
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => Track(
|
||||
fileName: json['fileName'],
|
||||
index: json['index'],
|
||||
recordingId: json['recording'],
|
||||
partIds: List.from(json['parts']),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'fileName': fileName,
|
||||
'index': index,
|
||||
'recording': recordingId,
|
||||
'parts': partIds,
|
||||
};
|
||||
}
|
||||
|
||||
/// Representation of all tracked audio files in one folder.
|
||||
class MusicusFile {
|
||||
/// Current version of the Musicus file format.
|
||||
///
|
||||
/// If incompatible changes are made, this will be increased by one.
|
||||
static const currentVersion = 0;
|
||||
|
||||
/// Musicus file format version in use.
|
||||
///
|
||||
/// This will be used in the future, if incompatible changes are made.
|
||||
final int version;
|
||||
|
||||
/// List of [Track] objects.
|
||||
final List<Track> tracks;
|
||||
|
||||
MusicusFile({
|
||||
this.version = currentVersion,
|
||||
List<Track> tracks,
|
||||
}) : tracks = tracks ?? [];
|
||||
|
||||
factory MusicusFile.fromJson(Map<String, dynamic> json) => MusicusFile(
|
||||
version: json['version'],
|
||||
tracks: json['tracks']
|
||||
.map<Track>((trackJson) => Track.fromJson(trackJson))
|
||||
.toList(growable: true),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'version': version,
|
||||
'tracks': tracks.map((t) => t.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Manager for all available tracks and their representation on disk.
|
||||
class MusicLibrary {
|
||||
static const platform = MethodChannel('de.johrpan.musicus/platform');
|
||||
|
||||
/// URI of the music library folder.
|
||||
///
|
||||
/// This is a tree URI in the terms of the Android Storage Access Framework.
|
||||
final String treeUri;
|
||||
|
||||
/// Map of all available tracks by recording ID.
|
||||
///
|
||||
/// These are [InternalTrack] objects to store the URI of the corresponding
|
||||
/// audio file alongside the real [Track] object.
|
||||
final Map<int, List<InternalTrack>> tracks = {};
|
||||
|
||||
MusicLibrary(this.treeUri);
|
||||
|
||||
/// Load all available tracks.
|
||||
///
|
||||
/// This recursively searches through the whole music library, reads the
|
||||
/// content of all files called musicus.json and stores all track information
|
||||
/// that it found.
|
||||
Future<void> load() async {
|
||||
// TODO: Consider capping the recursion somewhere.
|
||||
Future<void> recurse([String parentId]) async {
|
||||
final children = await Platform.getChildren(treeUri, parentId);
|
||||
|
||||
for (final child in children) {
|
||||
if (child.isDirectory) {
|
||||
recurse(child.id);
|
||||
} else if (child.name == 'musicus.json') {
|
||||
final content = await Platform.readFile(treeUri, child.id);
|
||||
final musicusFile = MusicusFile.fromJson(jsonDecode(content));
|
||||
for (final track in musicusFile.tracks) {
|
||||
_indexTrack(parentId, track);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await recurse();
|
||||
}
|
||||
|
||||
/// Add a list of new tracks to the music library.
|
||||
///
|
||||
/// They are stored in this instance and on disk in the directory denoted by
|
||||
/// [parentId].
|
||||
Future<void> addTracks(String parentId, List<Track> newTracks) async {
|
||||
MusicusFile musicusFile;
|
||||
|
||||
final oldContent =
|
||||
await Platform.readFileByName(treeUri, parentId, 'musicus.json');
|
||||
|
||||
if (oldContent != null) {
|
||||
musicusFile = MusicusFile.fromJson(jsonDecode(oldContent));
|
||||
} else {
|
||||
musicusFile = MusicusFile();
|
||||
}
|
||||
|
||||
for (final track in newTracks) {
|
||||
_indexTrack(parentId, track);
|
||||
musicusFile.tracks.add(track);
|
||||
}
|
||||
|
||||
await Platform.writeFileByName(
|
||||
treeUri, parentId, 'musicus.json', jsonEncode(musicusFile.toJson()));
|
||||
}
|
||||
|
||||
/// Add a track to the map of available tracks.
|
||||
Future<void> _indexTrack(String parentId, Track track) async {
|
||||
final iTrack = InternalTrack(
|
||||
track: track,
|
||||
uri: await Platform.getUriByName(treeUri, parentId, track.fileName),
|
||||
);
|
||||
|
||||
if (tracks.containsKey(track.recordingId)) {
|
||||
tracks[track.recordingId].add(iTrack);
|
||||
} else {
|
||||
tracks[track.recordingId] = [iTrack];
|
||||
}
|
||||
}
|
||||
}
|
||||
113
mobile/lib/platform.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Object representing a document in Storage Access Framework terms.
|
||||
class Document {
|
||||
/// Unique document ID given by the SAF.
|
||||
final String id;
|
||||
|
||||
/// Name of the document (i.e. file name).
|
||||
final String name;
|
||||
|
||||
/// Document ID of the parent document.
|
||||
final String parent;
|
||||
|
||||
/// Whether this document represents a directory.
|
||||
final bool isDirectory;
|
||||
|
||||
// Use Map<dynamic, dynamic> here, as we get casting errors otherwise. This
|
||||
// won't be typesafe anyway.
|
||||
Document.fromJson(Map<dynamic, dynamic> json)
|
||||
: id = json['id'],
|
||||
name = json['name'],
|
||||
parent = json['parent'],
|
||||
isDirectory = json['isDirectory'];
|
||||
}
|
||||
|
||||
/// Collection of methods that are implemented platform dependent.
|
||||
class Platform {
|
||||
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
||||
|
||||
/// Get child documents.
|
||||
///
|
||||
/// [treeId] is the base URI as requested from the SAF.
|
||||
/// [parentId] is the document ID of the parent. If this is null, the children
|
||||
/// of the tree base will be returned.
|
||||
static Future<List<Document>> getChildren(
|
||||
String treeUri, String parentId) async {
|
||||
final List<Map<dynamic, dynamic>> childrenJson =
|
||||
await _platform.invokeListMethod(
|
||||
'getChildren',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'parentId': parentId,
|
||||
},
|
||||
);
|
||||
|
||||
return childrenJson
|
||||
.map((childJson) => Document.fromJson(childJson))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Read contents of file.
|
||||
///
|
||||
/// [treeId] is the base URI from the SAF, [id] is the document ID of the
|
||||
/// file.
|
||||
static Future<String> readFile(String treeUri, String id) async {
|
||||
return await _platform.invokeMethod(
|
||||
'readFile',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'id': id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Get document URI by file name
|
||||
///
|
||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
||||
/// the parent directory.
|
||||
static Future<String> getUriByName(
|
||||
String treeUri, String parentId, String fileName) async {
|
||||
return await _platform.invokeMethod(
|
||||
'getUriByName',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'parentId': parentId,
|
||||
'fileName': fileName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Read contents of file by name
|
||||
///
|
||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
||||
/// the parent directory.
|
||||
static Future<String> readFileByName(
|
||||
String treeUri, String parentId, String fileName) async {
|
||||
return await _platform.invokeMethod(
|
||||
'readFileByName',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'parentId': parentId,
|
||||
'fileName': fileName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Write to file by name
|
||||
///
|
||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
||||
/// the parent directory.
|
||||
static Future<void> writeFileByName(
|
||||
String treeUri, String parentId, String fileName, String content) async {
|
||||
await _platform.invokeMethod(
|
||||
'writeFileByName',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'parentId': parentId,
|
||||
'fileName': fileName,
|
||||
'content': content,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
544
mobile/lib/player.dart
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:musicus_player/musicus_player.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import 'music_library.dart';
|
||||
|
||||
const _portName = 'playbackService';
|
||||
|
||||
/// Entrypoint for the playback service.
|
||||
void _playbackServiceEntrypoint() {
|
||||
AudioServiceBackground.run(() => _PlaybackService());
|
||||
}
|
||||
|
||||
class Player {
|
||||
/// Whether the player is active.
|
||||
///
|
||||
/// This means, that there is at least one item in the queue and the playback
|
||||
/// service is ready to play.
|
||||
final active = BehaviorSubject.seeded(false);
|
||||
|
||||
/// The current playlist.
|
||||
///
|
||||
/// If the player is not active, this will be an empty list.
|
||||
final playlist = BehaviorSubject.seeded(<InternalTrack>[]);
|
||||
|
||||
/// Index of the currently played (or paused) track within the playlist.
|
||||
///
|
||||
/// This will be zero, if the player is not active!
|
||||
final currentIndex = BehaviorSubject.seeded(0);
|
||||
|
||||
/// The currently played track.
|
||||
///
|
||||
/// This will be null, if there is no current track.
|
||||
final currentTrack = BehaviorSubject<InternalTrack>.seeded(null);
|
||||
|
||||
/// Whether we are currently playing or not.
|
||||
///
|
||||
/// This will be false, if the player is not active.
|
||||
final playing = BehaviorSubject.seeded(false);
|
||||
|
||||
/// Current playback position.
|
||||
///
|
||||
/// If the player is not active, this will default to zero.
|
||||
final position = BehaviorSubject.seeded(const Duration());
|
||||
|
||||
/// Duration of the current track.
|
||||
///
|
||||
/// If the player is not active, the duration will default to 1 s.
|
||||
final duration = BehaviorSubject.seeded(const Duration(seconds: 1));
|
||||
|
||||
/// Playback position normalized to the range from zero to one.
|
||||
final normalizedPosition = BehaviorSubject.seeded(0.0);
|
||||
|
||||
StreamSubscription _playbackServiceStateSubscription;
|
||||
|
||||
/// Set everything to its default because the playback service was stopped.
|
||||
void _stop() {
|
||||
active.add(false);
|
||||
playlist.add([]);
|
||||
currentIndex.add(0);
|
||||
playing.add(false);
|
||||
position.add(const Duration());
|
||||
duration.add(const Duration(seconds: 1));
|
||||
normalizedPosition.add(0.0);
|
||||
}
|
||||
|
||||
/// Start playback service.
|
||||
Future<void> start() async {
|
||||
if (!AudioService.running) {
|
||||
await AudioService.start(
|
||||
backgroundTaskEntrypoint: _playbackServiceEntrypoint,
|
||||
androidNotificationChannelName: 'Musicus playback',
|
||||
androidNotificationChannelDescription:
|
||||
'Keeps Musicus playing in the background',
|
||||
androidNotificationIcon: 'drawable/ic_notification',
|
||||
);
|
||||
|
||||
active.add(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update [position] and [normalizedPosition].
|
||||
///
|
||||
/// Requires [duration] to be up to date
|
||||
void _updatePosition(int positionMs) {
|
||||
position.add(Duration(milliseconds: positionMs));
|
||||
normalizedPosition.add(positionMs / duration.value.inMilliseconds);
|
||||
}
|
||||
|
||||
/// Update [position], [duration] and [normalizedPosition].
|
||||
void _updateDuration(int positionMs, int durationMs) {
|
||||
position.add(Duration(milliseconds: positionMs));
|
||||
duration.add(Duration(milliseconds: durationMs));
|
||||
normalizedPosition.add(positionMs / durationMs);
|
||||
}
|
||||
|
||||
/// Update [currentIndex] and [currentTrack].
|
||||
///
|
||||
/// Requires [playlist] to be up to date.
|
||||
void _updateCurrentTrack(int index) {
|
||||
currentIndex.add(index);
|
||||
currentTrack.add(playlist.value[index]);
|
||||
}
|
||||
|
||||
/// Connect listeners and initialize streams.
|
||||
void setup() {
|
||||
if (_playbackServiceStateSubscription == null) {
|
||||
// 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) {
|
||||
_stop();
|
||||
} else {
|
||||
if (msg is _StatusMessage) {
|
||||
playing.add(msg.playing);
|
||||
} else if (msg is _PositionMessage) {
|
||||
_updatePosition(msg.positionMs);
|
||||
} else if (msg is _TrackMessage) {
|
||||
_updateCurrentTrack(msg.currentTrack);
|
||||
_updateDuration(msg.positionMs, msg.durationMs);
|
||||
} else if (msg is _PlaylistMessage) {
|
||||
playlist.add(msg.playlist);
|
||||
_updateCurrentTrack(msg.currentTrack);
|
||||
_updateDuration(msg.positionMs, msg.durationMs);
|
||||
}
|
||||
}
|
||||
});
|
||||
IsolateNameServer.registerPortWithName(receivePort.sendPort, _portName);
|
||||
}
|
||||
|
||||
if (AudioService.running) {
|
||||
active.add(true);
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle whether the player is playing or paused.
|
||||
///
|
||||
/// If the player is not active, this will do nothing.
|
||||
Future<void> playPause() async {
|
||||
if (active.value) {
|
||||
if (playing.value) {
|
||||
await AudioService.pause();
|
||||
} else {
|
||||
await AudioService.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a list of tracks to the players playlist.
|
||||
Future<void> addTracks(List<InternalTrack> tracks) async {
|
||||
if (!AudioService.running) {
|
||||
await start();
|
||||
}
|
||||
|
||||
await AudioService.customAction('addTracks', jsonEncode(tracks));
|
||||
}
|
||||
|
||||
/// Seek to [pos], which is a value between (and including) zero and one.
|
||||
///
|
||||
/// If the player is not active or an invalid value is provided, this will do
|
||||
/// nothing.
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/// Play the previous track in the playlist.
|
||||
///
|
||||
/// If the player is not active or there is no previous track, this will do
|
||||
/// nothing.
|
||||
Future<void> skipToNext() async {
|
||||
if (AudioService.running) {
|
||||
await AudioService.skipToNext();
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip to the next track in the playlist.
|
||||
///
|
||||
/// If the player is not active or there is no next track, this will do
|
||||
/// nothing. If more than five seconds of the current track have been played,
|
||||
/// this will go back to its beginning instead.
|
||||
Future<void> skipToPrevious() async {
|
||||
if (AudioService.running) {
|
||||
await AudioService.skipToPrevious();
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch to the track with the index [index] in the playlist.
|
||||
Future<void> skipTo(int index) async {
|
||||
if (AudioService.running) {
|
||||
await AudioService.customAction('skipTo', index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tidy up.
|
||||
void dispose() {
|
||||
_playbackServiceStateSubscription.cancel();
|
||||
active.close();
|
||||
playlist.close();
|
||||
currentIndex.close();
|
||||
currentTrack.close();
|
||||
playing.close();
|
||||
position.close();
|
||||
duration.close();
|
||||
normalizedPosition.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// A message from the playback service to the UI.
|
||||
abstract class _Message {}
|
||||
|
||||
/// Playback status update.
|
||||
class _StatusMessage extends _Message {
|
||||
/// Whether the player is playing (or paused).
|
||||
final bool playing;
|
||||
|
||||
/// Playback position in milliseconds.
|
||||
final int positionMs;
|
||||
|
||||
_StatusMessage({
|
||||
this.playing,
|
||||
this.positionMs,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
_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,
|
||||
);
|
||||
|
||||
static const dummyMediaItem = MediaItem(
|
||||
id: 'dummy',
|
||||
album: 'Johannes Brahms',
|
||||
title: 'Symphony No. 1 in C minor, Op. 68: 1. Un poco sostenuto — Allegro',
|
||||
duration: 10000,
|
||||
);
|
||||
|
||||
final _completer = Completer();
|
||||
final List<InternalTrack> _playlist = [];
|
||||
|
||||
MusicusPlayer _player;
|
||||
int _currentTrack = 0;
|
||||
bool _playing = false;
|
||||
int _durationMs = 1000;
|
||||
|
||||
_PlaybackService() {
|
||||
_player = MusicusPlayer(onComplete: () async {
|
||||
if (_currentTrack < _playlist.length - 1) {
|
||||
await _setCurrentTrack(_currentTrack + 1);
|
||||
_sendTrack();
|
||||
} else {
|
||||
_playing = false;
|
||||
_sendStatus();
|
||||
_setState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Update the audio service status for the system.
|
||||
Future<void> _setState() async {
|
||||
final positionMs = await _player.getPosition() ?? 0;
|
||||
final updateTime = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
AudioServiceBackground.setState(
|
||||
controls:
|
||||
_playing ? [pauseControl, stopControl] : [playControl, stopControl],
|
||||
basicState:
|
||||
_playing ? BasicPlaybackState.playing : BasicPlaybackState.paused,
|
||||
position: positionMs,
|
||||
updateTime: updateTime,
|
||||
);
|
||||
|
||||
AudioServiceBackground.setMediaItem(dummyMediaItem);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: positionUpdateInterval));
|
||||
_sendPosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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].uri);
|
||||
_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();
|
||||
}
|
||||
|
||||
/// 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
|
||||
void onCustomAction(String name, dynamic arguments) {
|
||||
super.onCustomAction(name, arguments);
|
||||
|
||||
// addTracks expects a List<Map<String, dynamic>> as its argument.
|
||||
// skipTo expects an integer as its argument.
|
||||
if (name == 'addTracks') {
|
||||
final tracksJson = jsonDecode(arguments);
|
||||
final List<InternalTrack> tracks = List.castFrom(
|
||||
tracksJson.map((j) => InternalTrack.fromJson(j)).toList());
|
||||
|
||||
_addTracks(tracks);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
87
mobile/lib/screens/home.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/tracks.dart';
|
||||
|
||||
import 'person.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Musicus'),
|
||||
actions: <Widget>[
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 0,
|
||||
child: Text('Start player'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 1,
|
||||
child: Text('Add tracks'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 2,
|
||||
child: Text('Settings'),
|
||||
),
|
||||
],
|
||||
onSelected: (selected) {
|
||||
if (selected == 0) {
|
||||
backend.player.start();
|
||||
} else if (selected == 1) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TracksEditor(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
} else if (selected == 2) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SettingsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// For debugging purposes
|
||||
body: StreamBuilder<List<Person>>(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: Text('${person.lastName}, ${person.firstName}'),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonScreen(
|
||||
person: person,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
mobile/lib/screens/person.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/person.dart';
|
||||
|
||||
import 'work.dart';
|
||||
|
||||
class PersonScreen extends StatelessWidget {
|
||||
final Person person;
|
||||
|
||||
PersonScreen({
|
||||
this.person,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${person.firstName} ${person.lastName}'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonEditor(
|
||||
person: person,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<List<Work>>(
|
||||
stream: backend.db.worksByComposer(person.id).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final work = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: Text(work.title),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkScreen(
|
||||
work: work,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
339
mobile/lib/screens/program.dart
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../music_library.dart';
|
||||
import '../widgets/play_pause_button.dart';
|
||||
import '../widgets/recording_tile.dart';
|
||||
|
||||
/// Data class to bundle information from the database on one track.
|
||||
class ProgramItem {
|
||||
/// ID of the recording.
|
||||
///
|
||||
/// We don't need the real recording, as the [RecordingTile] widget handles
|
||||
/// that for us. If the recording is the same one, as the one from the
|
||||
/// previous track, this will be null.
|
||||
final int recordingId;
|
||||
|
||||
/// List of work parts contained in this track.
|
||||
///
|
||||
/// This will include the parts linked in the track as well as all parents of
|
||||
/// them, if there are gaps between them (i.e. some parts are missing).
|
||||
final List<Work> workParts;
|
||||
|
||||
ProgramItem({
|
||||
this.recordingId,
|
||||
this.workParts,
|
||||
});
|
||||
}
|
||||
|
||||
/// Widget displaying a [ProgramItem].
|
||||
class ProgramTile extends StatelessWidget {
|
||||
final ProgramItem item;
|
||||
final bool isPlaying;
|
||||
|
||||
ProgramTile({
|
||||
this.item,
|
||||
this.isPlaying,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: isPlaying
|
||||
? const Icon(Icons.play_arrow)
|
||||
: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
if (item.recordingId != null) ...[
|
||||
RecordingTile(
|
||||
recordingId: item.recordingId,
|
||||
),
|
||||
SizedBox(
|
||||
height: 8.0,
|
||||
),
|
||||
],
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
for (final part in item.workParts)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8.0,
|
||||
),
|
||||
child: Text(
|
||||
part.title,
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ProgramScreen extends StatefulWidget {
|
||||
@override
|
||||
_ProgramScreenState createState() => _ProgramScreenState();
|
||||
}
|
||||
|
||||
class _ProgramScreenState extends State<ProgramScreen> {
|
||||
BackendState backend;
|
||||
|
||||
StreamSubscription<bool> playerActiveSubscription;
|
||||
|
||||
StreamSubscription<List<InternalTrack>> playlistSubscription;
|
||||
List<ProgramItem> items = [];
|
||||
|
||||
StreamSubscription<double> positionSubscription;
|
||||
double position = 0.0;
|
||||
bool seeking = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
|
||||
if (playerActiveSubscription != null) {
|
||||
playerActiveSubscription.cancel();
|
||||
}
|
||||
|
||||
// Close the program screen, if the player is no longer active.
|
||||
playerActiveSubscription = backend.player.active.listen((active) {
|
||||
if (!active) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
});
|
||||
|
||||
if (playlistSubscription != null) {
|
||||
playlistSubscription.cancel();
|
||||
}
|
||||
|
||||
playlistSubscription = backend.player.playlist.listen((playlist) {
|
||||
updateProgram(playlist);
|
||||
});
|
||||
|
||||
if (positionSubscription != null) {
|
||||
positionSubscription.cancel();
|
||||
}
|
||||
|
||||
positionSubscription = backend.player.normalizedPosition.listen((pos) {
|
||||
if (!seeking) {
|
||||
setState(() {
|
||||
position = pos;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Go through the tracks of [playlist] and preprocess them for displaying.
|
||||
Future<void> updateProgram(List<InternalTrack> playlist) async {
|
||||
List<ProgramItem> newItems = [];
|
||||
|
||||
// The following variables exist to adapt the resulting ProgramItem to its
|
||||
// predecessor.
|
||||
|
||||
// If the previous recording was the same, we won't need to include the
|
||||
// recording data again.
|
||||
int lastRecordingId;
|
||||
|
||||
// If the previous work was the same, we won't need to retrieve its parts
|
||||
// from the database again.
|
||||
int lastWorkId;
|
||||
|
||||
// This will always contain the parts of the current work.
|
||||
List<Work> workParts = [];
|
||||
|
||||
for (var i = 0; i < playlist.length; i++) {
|
||||
// The data that will be stored in the resulting ProgramItem.
|
||||
int newRecordingId;
|
||||
List<Work> newWorkParts = [];
|
||||
|
||||
final track = playlist[i];
|
||||
final recordingId = track.track.recordingId;
|
||||
final partIds = track.track.partIds;
|
||||
|
||||
// newRecordingId will be null, if the recording ID is the same. This
|
||||
// also means, that the work is the same, so workParts doesn't have to
|
||||
// be updated either.
|
||||
if (recordingId != lastRecordingId) {
|
||||
lastRecordingId = recordingId;
|
||||
newRecordingId = recordingId;
|
||||
|
||||
final recording =
|
||||
await backend.db.recordingById(recordingId).getSingle();
|
||||
|
||||
if (recording.work != lastWorkId) {
|
||||
workParts = await backend.db.workParts(recording.work).get();
|
||||
}
|
||||
|
||||
lastWorkId = recording.work;
|
||||
}
|
||||
|
||||
for (final partId in partIds) {
|
||||
newWorkParts.add(workParts[partId]);
|
||||
}
|
||||
|
||||
newItems.add(ProgramItem(
|
||||
recordingId: newRecordingId,
|
||||
workParts: newWorkParts,
|
||||
));
|
||||
}
|
||||
|
||||
// Check, whether we are still a part of the widget tree, because this
|
||||
// function might take some time.
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
items = newItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_down),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text('Program'),
|
||||
),
|
||||
body: StreamBuilder<int>(
|
||||
stream: backend.player.currentIndex,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
return InkWell(
|
||||
child: ProgramTile(
|
||||
item: items[index],
|
||||
isPlaying: index == snapshot?.data,
|
||||
),
|
||||
onTap: () {
|
||||
backend.player.skipTo(index);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Slider(
|
||||
value: position,
|
||||
onChangeStart: (_) {
|
||||
seeking = true;
|
||||
},
|
||||
onChangeEnd: (pos) {
|
||||
seeking = false;
|
||||
backend.player.seekTo(pos);
|
||||
},
|
||||
onChanged: (pos) {
|
||||
setState(() {
|
||||
position = pos;
|
||||
});
|
||||
},
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24.0),
|
||||
child: StreamBuilder<Duration>(
|
||||
stream: backend.player.position,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return DurationText(snapshot.data);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: () {
|
||||
backend.player.skipToPrevious();
|
||||
},
|
||||
),
|
||||
PlayPauseButton(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: () {
|
||||
backend.player.skipToNext();
|
||||
},
|
||||
),
|
||||
Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 20.0),
|
||||
child: StreamBuilder<Duration>(
|
||||
stream: backend.player.duration,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return DurationText(snapshot.data);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
playerActiveSubscription.cancel();
|
||||
playlistSubscription.cancel();
|
||||
positionSubscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class DurationText extends StatelessWidget {
|
||||
final Duration duration;
|
||||
|
||||
DurationText(this.duration);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = (duration - Duration(minutes: minutes)).inSeconds;
|
||||
|
||||
final secondsString = seconds >= 10 ? seconds.toString() : '0$seconds';
|
||||
|
||||
return Text('$minutes:$secondsString');
|
||||
}
|
||||
}
|
||||
28
mobile/lib/screens/settings.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: Icon(Icons.library_music),
|
||||
title: Text('Music library path'),
|
||||
subtitle: Text(backend.musicLibraryUri),
|
||||
onTap: () {
|
||||
backend.chooseMusicLibraryUri();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
mobile/lib/screens/work.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/work.dart';
|
||||
import '../widgets/texts.dart';
|
||||
|
||||
class WorkScreen extends StatelessWidget {
|
||||
final Work work;
|
||||
|
||||
WorkScreen({
|
||||
this.work,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(work.title),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkEditor(
|
||||
work: work,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<List<Recording>>(
|
||||
stream: backend.db.recordingsByWork(work.id).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recording = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: PerformancesText(recording.id),
|
||||
onTap: () async {
|
||||
final tracks = backend.ml.tracks[recording.id];
|
||||
tracks.sort(
|
||||
(t1, t2) => t1.track.index.compareTo(t2.track.index));
|
||||
|
||||
backend.player.addTracks(backend.ml.tracks[recording.id]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
mobile/lib/selectors/files.dart
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../platform.dart';
|
||||
|
||||
/// Result of the user's interaction with the files selector.
|
||||
///
|
||||
/// This will be given back when popping the navigator.
|
||||
class FilesSelectorResult {
|
||||
/// Document ID of the parent directory of the selected files.
|
||||
///
|
||||
/// This will be null, if they are in the toplevel directory.
|
||||
final String parentId;
|
||||
|
||||
/// Selected files.
|
||||
final Set<Document> selection;
|
||||
|
||||
FilesSelectorResult(this.parentId, this.selection);
|
||||
}
|
||||
|
||||
class FilesSelector extends StatefulWidget {
|
||||
@override
|
||||
_FilesSelectorState createState() => _FilesSelectorState();
|
||||
}
|
||||
|
||||
class _FilesSelectorState extends State<FilesSelector> {
|
||||
BackendState backend;
|
||||
List<Document> history = [];
|
||||
List<Document> children = [];
|
||||
Set<Document> selection = {};
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
loadChildren();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Choose files'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () {
|
||||
Navigator.pop(
|
||||
context,
|
||||
FilesSelectorResult(
|
||||
history.isNotEmpty ? history.last.id : null,
|
||||
selection,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
onPressed: history.isNotEmpty ? up : null,
|
||||
),
|
||||
title: Text(
|
||||
history.isNotEmpty ? history.last.name : 'Music library'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: children.length,
|
||||
itemBuilder: (context, index) {
|
||||
final document = children[index];
|
||||
|
||||
if (document.isDirectory) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.folder),
|
||||
title: Text(document.name),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
history.add(document);
|
||||
});
|
||||
loadChildren();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return CheckboxListTile(
|
||||
controlAffinity: ListTileControlAffinity.trailing,
|
||||
secondary: const Icon(Icons.insert_drive_file),
|
||||
title: Text(document.name),
|
||||
value: selection.contains(document),
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
selection.add(document);
|
||||
} else {
|
||||
selection.remove(document);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onWillPop: () => Future.value(up()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadChildren() async {
|
||||
setState(() {
|
||||
children = [];
|
||||
|
||||
// We reset the selection here, because the user should not be able to
|
||||
// select files from multiple directories for now.
|
||||
selection = {};
|
||||
});
|
||||
|
||||
final newChildren = await Platform.getChildren(
|
||||
backend.musicLibraryUri, history.isNotEmpty ? history.last.id : null);
|
||||
|
||||
newChildren.sort((d1, d2) {
|
||||
if (d1.isDirectory != d2.isDirectory) {
|
||||
return d1.isDirectory ? -1 : 1;
|
||||
} else {
|
||||
return d1.name.compareTo(d2.name);
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
children = newChildren;
|
||||
});
|
||||
}
|
||||
|
||||
bool up() {
|
||||
if (history.isNotEmpty) {
|
||||
setState(() {
|
||||
history.removeLast();
|
||||
});
|
||||
|
||||
loadChildren();
|
||||
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
108
mobile/lib/selectors/instruments.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/instrument.dart';
|
||||
|
||||
class InstrumentsSelector extends StatefulWidget {
|
||||
final bool multiple;
|
||||
final List<Instrument> selection;
|
||||
|
||||
InstrumentsSelector({
|
||||
this.multiple = false,
|
||||
this.selection,
|
||||
});
|
||||
|
||||
@override
|
||||
_InstrumentsSelectorState createState() => _InstrumentsSelectorState();
|
||||
}
|
||||
|
||||
class _InstrumentsSelectorState extends State<InstrumentsSelector> {
|
||||
Set<Instrument> selection = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.selection != null) {
|
||||
selection = widget.selection.toSet();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.multiple ? 'Select instruments/roles' : 'Select instrument/role'),
|
||||
actions: widget.multiple
|
||||
? <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () => Navigator.pop(context, selection.toList()),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
body: StreamBuilder(
|
||||
stream: backend.db.allInstruments().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final instrument = snapshot.data[index];
|
||||
|
||||
if (widget.multiple) {
|
||||
return CheckboxListTile(
|
||||
title: Text(instrument.name),
|
||||
value: selection.contains(instrument),
|
||||
checkColor: Colors.black,
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
selection.add(instrument);
|
||||
} else {
|
||||
selection.remove(instrument);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return ListTile(
|
||||
title: Text(instrument.name),
|
||||
onTap: () => Navigator.pop(context, instrument),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final Instrument instrument = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InstrumentEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (instrument != null) {
|
||||
if (widget.multiple) {
|
||||
setState(() {
|
||||
selection.add(instrument);
|
||||
});
|
||||
} else {
|
||||
Navigator.pop(context, instrument);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
200
mobile/lib/selectors/performer.dart
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/ensemble.dart';
|
||||
import '../editors/person.dart';
|
||||
|
||||
import 'instruments.dart';
|
||||
|
||||
class PerformerSelector extends StatefulWidget {
|
||||
@override
|
||||
_PerformerSelectorState createState() => _PerformerSelectorState();
|
||||
}
|
||||
|
||||
class _Selection {
|
||||
final bool isPerson;
|
||||
final Person person;
|
||||
final Ensemble ensemble;
|
||||
|
||||
_Selection.person(this.person)
|
||||
: isPerson = true,
|
||||
ensemble = null;
|
||||
|
||||
_Selection.ensemble(this.ensemble)
|
||||
: isPerson = false,
|
||||
person = null;
|
||||
}
|
||||
|
||||
class _PerformerSelectorState extends State<PerformerSelector> {
|
||||
Instrument role;
|
||||
_Selection selection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select performer'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
PerformanceModel(
|
||||
person: selection?.person,
|
||||
ensemble: selection?.ensemble,
|
||||
role: role,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
title: Text('Instrument/Role'),
|
||||
subtitle:
|
||||
Text(role != null ? role.name : 'Select instrument/role'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
role = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
onTap: () async {
|
||||
final Instrument newRole = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InstrumentsSelector(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (newRole != null) {
|
||||
setState(() {
|
||||
role = newRole;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
StreamBuilder<List<Person>>(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data.isNotEmpty) {
|
||||
return ExpansionTile(
|
||||
initiallyExpanded: true,
|
||||
title: Text('Persons'),
|
||||
children: snapshot.data
|
||||
.map((person) => RadioListTile<Person>(
|
||||
title: Text(
|
||||
'${person.lastName}, ${person.firstName}'),
|
||||
value: person,
|
||||
groupValue: selection?.person,
|
||||
onChanged: (person) {
|
||||
setState(() {
|
||||
selection = _Selection.person(person);
|
||||
});
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
StreamBuilder<List<Ensemble>>(
|
||||
stream: backend.db.allEnsembles().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data.isNotEmpty) {
|
||||
return ExpansionTile(
|
||||
initiallyExpanded: true,
|
||||
title: Text('Ensembles'),
|
||||
children: snapshot.data
|
||||
.map((ensemble) => RadioListTile<Ensemble>(
|
||||
title: Text(ensemble.name),
|
||||
value: ensemble,
|
||||
groupValue: selection?.ensemble,
|
||||
onChanged: (ensemble) {
|
||||
setState(() {
|
||||
selection = _Selection.ensemble(ensemble);
|
||||
});
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: Text('Add person'),
|
||||
onTap: () async {
|
||||
final Person person = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (person != null) {
|
||||
setState(() {
|
||||
selection = _Selection.person(person);
|
||||
});
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: Text('Add ensemble'),
|
||||
onTap: () async {
|
||||
final Ensemble ensemble = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EnsembleEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (ensemble != null) {
|
||||
setState(() {
|
||||
selection = _Selection.ensemble(ensemble);
|
||||
});
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
mobile/lib/selectors/person.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/person.dart';
|
||||
|
||||
class PersonsSelector extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select person'),
|
||||
),
|
||||
body: StreamBuilder(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = snapshot.data[index];
|
||||
|
||||
return ListTile(
|
||||
title: Text('${person.lastName}, ${person.firstName}'),
|
||||
onTap: () => Navigator.pop(context, person),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final Person person = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (person != null) {
|
||||
Navigator.pop(context, person);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
211
mobile/lib/selectors/recording.dart
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/recording.dart';
|
||||
import '../widgets/texts.dart';
|
||||
import '../widgets/works_by_composer.dart';
|
||||
|
||||
class PersonList extends StatelessWidget {
|
||||
final void Function(int personId) onSelect;
|
||||
|
||||
PersonList({
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
title: Text('Composers'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Person>>(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: Text('${person.lastName}, ${person.firstName}'),
|
||||
onTap: () => onSelect(person.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkList extends StatelessWidget {
|
||||
final int composerId;
|
||||
final void Function(int workId) onSelect;
|
||||
|
||||
WorkList({
|
||||
this.composerId,
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: PersonText(composerId),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: WorksByComposer(
|
||||
personId: composerId,
|
||||
onTap: (selectedWork) => onSelect(selectedWork.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingList extends StatelessWidget {
|
||||
final int workId;
|
||||
final void Function(Recording recording) onSelect;
|
||||
|
||||
RecordingList({
|
||||
this.workId,
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: WorkText(workId),
|
||||
subtitle: ComposersText(workId),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Recording>>(
|
||||
stream: backend.db.recordingsByWork(workId).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recording = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: PerformancesText(recording.id),
|
||||
onTap: () => onSelect(recording),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingsSelector extends StatefulWidget {
|
||||
@override
|
||||
_RecordingsSelectorState createState() => _RecordingsSelectorState();
|
||||
}
|
||||
|
||||
class _RecordingsSelectorState extends State<RecordingsSelector> {
|
||||
final nestedNavigator = GlobalKey<NavigatorState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This exists to circumvent the nested navigator when selecting a
|
||||
// recording.
|
||||
void popUpperNavigator(Recording recording) {
|
||||
Navigator.pop(context, recording);
|
||||
}
|
||||
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text('Select recording'),
|
||||
),
|
||||
body: Navigator(
|
||||
key: nestedNavigator,
|
||||
onGenerateRoute: (settings) => settings.name == '/'
|
||||
? MaterialPageRoute(
|
||||
builder: (context) => PersonList(
|
||||
onSelect: (personId) => nestedNavigator.currentState.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkList(
|
||||
composerId: personId,
|
||||
onSelect: (workId) =>
|
||||
nestedNavigator.currentState.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RecordingList(
|
||||
workId: workId,
|
||||
onSelect: (recording) =>
|
||||
popUpperNavigator(recording),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
initialRoute: '/',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final recording = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RecordingEditor(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (recording != null) {
|
||||
Navigator.pop(context, recording);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onWillPop: () async => !(await nestedNavigator.currentState.maybePop()),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
mobile/lib/selectors/work.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/work.dart';
|
||||
|
||||
// TODO: Lazy load works and/or optimize queries.
|
||||
class WorkSelector extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select work'),
|
||||
),
|
||||
body: StreamBuilder<List<Person>>(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = snapshot.data[index];
|
||||
final title = Text('${person.lastName}, ${person.firstName}');
|
||||
return StreamBuilder<List<Work>>(
|
||||
stream: backend.db.worksByComposer(person.id).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data.isNotEmpty) {
|
||||
return ExpansionTile(
|
||||
title: title,
|
||||
children: <Widget>[
|
||||
for (final work in snapshot.data)
|
||||
ListTile(
|
||||
title: Text(work.title),
|
||||
onTap: () => Navigator.pop(context, work),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return ListTile(
|
||||
title: title,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final Work work = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (work != null) {
|
||||
Navigator.pop(context, work);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
mobile/lib/widgets/play_pause_button.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
|
||||
class PlayPauseButton extends StatefulWidget {
|
||||
@override
|
||||
_PlayPauseButtonState createState() => _PlayPauseButtonState();
|
||||
}
|
||||
|
||||
class _PlayPauseButtonState extends State<PlayPauseButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
AnimationController playPauseAnimation;
|
||||
BackendState backend;
|
||||
StreamSubscription<bool> playingSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
playPauseAnimation = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
playPauseAnimation.value = backend.player.playing.value ? 1.0 : 0.0;
|
||||
|
||||
if (playingSubscription != null) {
|
||||
playingSubscription.cancel();
|
||||
}
|
||||
|
||||
playingSubscription = backend.player.playing.listen((playing) =>
|
||||
playing ? playPauseAnimation.forward() : playPauseAnimation.reverse());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: AnimatedIcon(
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: playPauseAnimation,
|
||||
),
|
||||
onPressed: backend.player.playPause,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
playingSubscription.cancel();
|
||||
}
|
||||
}
|
||||
83
mobile/lib/widgets/player_bar.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus/database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../music_library.dart';
|
||||
import '../screens/program.dart';
|
||||
|
||||
import 'play_pause_button.dart';
|
||||
import 'texts.dart';
|
||||
|
||||
class PlayerBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return BottomAppBar(
|
||||
child: InkWell(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
StreamBuilder(
|
||||
stream: backend.player.normalizedPosition,
|
||||
builder: (context, snapshot) => LinearProgressIndicator(
|
||||
value: snapshot.data,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Icon(Icons.keyboard_arrow_up),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<InternalTrack>(
|
||||
stream: backend.player.currentTrack,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.data != null) {
|
||||
final recordingId = snapshot.data.track.recordingId;
|
||||
|
||||
return FutureBuilder<Recording>(
|
||||
future:
|
||||
backend.db.recordingById(recordingId).getSingle(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final workId = snapshot.data.work;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
DefaultTextStyle.merge(
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.bold),
|
||||
child: ComposersText(workId),
|
||||
),
|
||||
WorkText(workId),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
PlayPauseButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProgramScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
mobile/lib/widgets/recording_tile.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
import 'texts.dart';
|
||||
|
||||
class RecordingTile extends StatelessWidget {
|
||||
final int recordingId;
|
||||
|
||||
RecordingTile({
|
||||
this.recordingId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return StreamBuilder<Recording>(
|
||||
stream: backend.db.recordingById(recordingId).watchSingle(),
|
||||
builder: (context, snapshot) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (snapshot.hasData) ...[
|
||||
DefaultTextStyle(
|
||||
style: textTheme.subtitle1,
|
||||
child: ComposersText(snapshot.data.work),
|
||||
),
|
||||
DefaultTextStyle(
|
||||
style: textTheme.headline6,
|
||||
child: WorkText(snapshot.data.work),
|
||||
),
|
||||
],
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
DefaultTextStyle(
|
||||
style: textTheme.bodyText1,
|
||||
child: PerformancesText(recordingId),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
mobile/lib/widgets/texts.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class EnsembleText extends StatelessWidget {
|
||||
final int ensembleId;
|
||||
|
||||
EnsembleText(this.ensembleId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<Ensemble>(
|
||||
stream: backend.db.ensembleById(ensembleId).watchSingle(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(snapshot.data.name);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PersonText extends StatelessWidget {
|
||||
final int personId;
|
||||
|
||||
PersonText(this.personId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<Person>(
|
||||
stream: backend.db.personById(personId).watchSingle(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text('${snapshot.data.firstName} ${snapshot.data.lastName}');
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PerformancesText extends StatefulWidget {
|
||||
final int recordingId;
|
||||
|
||||
PerformancesText(this.recordingId);
|
||||
|
||||
@override
|
||||
_PerformancesTextState createState() => _PerformancesTextState();
|
||||
}
|
||||
|
||||
class _PerformancesTextState extends State<PerformancesText> {
|
||||
BackendState backend;
|
||||
StreamSubscription<List<Performance>> performancesSubscription;
|
||||
String text = '...';
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
performancesSubscription?.cancel();
|
||||
backend = Backend.of(context);
|
||||
|
||||
performancesSubscription = backend.db
|
||||
.performancesByRecording(widget.recordingId)
|
||||
.watch()
|
||||
.listen((performances) async {
|
||||
final List<String> texts = [];
|
||||
|
||||
for (final performance in performances) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (performance.person != null) {
|
||||
final person =
|
||||
await backend.db.personById(performance.person).getSingle();
|
||||
buffer.write('${person.firstName} ${person.lastName}');
|
||||
} else if (performance.ensemble != null) {
|
||||
final ensemble =
|
||||
await backend.db.ensembleById(performance.ensemble).getSingle();
|
||||
buffer.write(ensemble.name);
|
||||
} else {
|
||||
buffer.write('Unknown');
|
||||
}
|
||||
|
||||
if (performance.role != null) {
|
||||
final role =
|
||||
await backend.db.instrumentById(performance.role).getSingle();
|
||||
buffer.write(' (${role.name})');
|
||||
}
|
||||
|
||||
texts.add(buffer.toString());
|
||||
}
|
||||
|
||||
setState(() {
|
||||
text = texts.join(', ');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(text);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
performancesSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class WorkText extends StatelessWidget {
|
||||
final int workId;
|
||||
|
||||
WorkText(this.workId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<Work>(
|
||||
stream: backend.db.workById(workId).watchSingle(),
|
||||
builder: (context, snapshot) => Text(snapshot.data?.title ?? '...'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ComposersText extends StatelessWidget {
|
||||
final int workId;
|
||||
|
||||
ComposersText(this.workId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<List<Person>>(
|
||||
stream: backend.db.composersByWork(workId).watch(),
|
||||
builder: (context, snapshot) => Text(snapshot.hasData
|
||||
? snapshot.data.map((p) => '${p.firstName} ${p.lastName}').join(', ')
|
||||
: '...'),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
mobile/lib/widgets/works_by_composer.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class WorksByComposer extends StatelessWidget {
|
||||
final int personId;
|
||||
final void Function(Work work) onTap;
|
||||
|
||||
WorksByComposer({
|
||||
this.personId,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<List<Work>>(
|
||||
stream: backend.db.worksByComposer(personId).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final work = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: Text(work.title),
|
||||
onTap: () => onTap(work),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
37
mobile/pubspec.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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
|
||||
|
||||
environment:
|
||||
sdk: ">=2.3.0 <3.0.0"
|
||||
|
||||
dependencies:
|
||||
audio_service:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
moor:
|
||||
moor_ffi:
|
||||
musicus_player:
|
||||
path: ../musicus_player
|
||||
path:
|
||||
path_provider:
|
||||
rxdart:
|
||||
shared_preferences:
|
||||
|
||||
dev_dependencies:
|
||||
build_runner:
|
||||
moor_generator:
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
fonts:
|
||||
- family: Libertinus Sans
|
||||
fonts:
|
||||
- asset: fonts/libertinussans_regular.otf
|
||||
- asset: fonts/libertinussans_bold.otf
|
||||
weight: 700
|
||||
- asset: fonts/libertinussans_italic.otf
|
||||
style: italic
|
||||