Rename top level directories

This commit is contained in:
Elias Projahn 2020-04-24 21:55:17 +02:00
parent 256bda1b72
commit 674ce19414
90 changed files with 0 additions and 0 deletions

31
mobile/.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,8 @@
gradle-wrapper.jar
/.gradle
/gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java

View 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'
}

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View file

@ -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()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

View file

@ -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>

View file

@ -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>

View file

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<color name="background">#303030</color>
</resources>

View 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>

View 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>

View 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
}

View file

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

View 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
View file

@ -0,0 +1,6 @@
targets:
$default:
builders:
moor_generator:
options:
generate_connect_constructor: true

Binary file not shown.

Binary file not shown.

Binary file not shown.

141
mobile/lib/app.dart Normal file
View 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
View 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
View 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
View 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;

View 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',
),
),
),
],
),
);
}
}

View 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',
),
),
),
],
),
);
}
}

View 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',
),
),
),
],
),
);
}
}

View 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);
});
},
),
),
],
),
);
}
}

View 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;
}
}
});
}
}

View 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
View 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(),
),
));
}

View 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
View 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
View 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();
}
}

View 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();
}
},
),
);
}
}

View 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();
}
},
),
);
}
}

View 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');
}
}

View 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();
},
),
],
),
);
}
}

View 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();
}
},
),
);
}
}

View 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;
}
}
}

View 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);
}
}
},
),
);
}
}

View 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);
},
),
],
),
);
},
),
);
}
}

View 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);
}
},
),
);
}
}

View 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()),
);
}
}

View 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);
}
},
),
);
}
}

View 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();
}
}

View 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(),
),
),
),
);
}
}

View 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),
),
],
),
),
);
}
}

View 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(', ')
: '...'),
);
}
}

View 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
View 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