Add music library

This commit is contained in:
Elias Projahn 2020-04-18 23:41:08 +02:00
parent 9da8f8891b
commit b1994d1067
6 changed files with 368 additions and 21 deletions

View file

@ -40,6 +40,22 @@ class MainActivity : FlutterActivity() {
val parentId = call.argument<String>("parentId") val parentId = call.argument<String>("parentId")
val children = getChildren(treeUri, parentId) val children = getChildren(treeUri, parentId)
result.success(children.map { it.toMap() }) 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 == "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 { } else {
result.notImplemented() result.notImplemented()
} }
@ -114,4 +130,104 @@ class MainActivity : FlutterActivity() {
return children 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!!)!!;
output.writer().write(content)
output.close()
}
} }

View file

@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart' as pp;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'database.dart'; import 'database.dart';
import 'music_library.dart';
import 'player.dart'; import 'player.dart';
// The following code was taken from // The following code was taken from
@ -86,6 +87,7 @@ class BackendState extends State<Backend> {
BackendStatus status = BackendStatus.loading; BackendStatus status = BackendStatus.loading;
Database db; Database db;
String musicLibraryUri; String musicLibraryUri;
MusicLibrary ml;
MoorIsolate _moorIsolate; MoorIsolate _moorIsolate;
SharedPreferences _shPref; SharedPreferences _shPref;
@ -122,6 +124,8 @@ class BackendState extends State<Backend> {
status = BackendStatus.setup; status = BackendStatus.setup;
}); });
} else { } else {
ml = MusicLibrary(musicLibraryUri);
await ml.load();
setState(() { setState(() {
status = BackendStatus.ready; status = BackendStatus.ready;
}); });

View file

@ -2,15 +2,15 @@ import 'package:flutter/material.dart';
import '../backend.dart'; import '../backend.dart';
import '../database.dart'; import '../database.dart';
import '../music_library.dart';
import '../selectors/files.dart'; import '../selectors/files.dart';
import '../selectors/recording.dart'; import '../selectors/recording.dart';
import '../widgets/recording_tile.dart'; import '../widgets/recording_tile.dart';
// TODO: Update for storage access framework.
class TrackModel { class TrackModel {
String path; String fileName;
TrackModel(this.path); TrackModel(this.fileName);
} }
class TracksEditor extends StatefulWidget { class TracksEditor extends StatefulWidget {
@ -20,7 +20,8 @@ class TracksEditor extends StatefulWidget {
class _TracksEditorState extends State<TracksEditor> { class _TracksEditorState extends State<TracksEditor> {
int recordingId; int recordingId;
List<TrackModel> tracks = []; String parentId;
List<TrackModel> trackModels = [];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -33,7 +34,22 @@ class _TracksEditorState extends State<TracksEditor> {
FlatButton( FlatButton(
child: Text('DONE'), child: Text('DONE'),
onPressed: () async { onPressed: () async {
// TODO: Save tracks. 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: [],
));
}
backend.ml.addTracks(parentId, tracks);
Navigator.pop(context);
}, },
), ),
], ],
@ -63,22 +79,27 @@ class _TracksEditorState extends State<TracksEditor> {
); );
if (result != null) { if (result != null) {
// TODO: Add tracks. setState(() {
parentId = result.parentId;
for (final document in result.selection) {
trackModels.add(TrackModel(document.name));
}
});
} }
}, },
), ),
), ),
], ],
), ),
children: tracks children: trackModels
.map((t) => ListTile( .map((t) => ListTile(
key: Key(t.hashCode.toString()), key: Key(t.hashCode.toString()),
title: Text(t.path), title: Text(t.fileName),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
onPressed: () { onPressed: () {
setState(() { setState(() {
tracks.remove(t); trackModels.remove(t);
}); });
}, },
), ),
@ -86,9 +107,9 @@ class _TracksEditorState extends State<TracksEditor> {
.toList(), .toList(),
onReorder: (i1, i2) { onReorder: (i1, i2) {
setState(() { setState(() {
final track = tracks.removeAt(i1); final track = trackModels.removeAt(i1);
final newIndex = i2 > i1 ? i2 - 1 : i2; final newIndex = i2 > i1 ? i2 - 1 : i2;
tracks.insert(newIndex, track); trackModels.insert(newIndex, track);
}); });
}, },
), ),

155
lib/music_library.dart Normal file
View file

@ -0,0 +1,155 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'platform.dart';
/// 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.
final Map<int, List<Track>> 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) {
if (tracks.containsKey(track.recordingId)) {
tracks[track.recordingId].add(track);
} else {
tracks[track.recordingId] = [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) {
musicusFile.tracks.add(track);
if (tracks.containsKey(track.recordingId)) {
tracks[track.recordingId].add(track);
} else {
tracks[track.recordingId] = [track];
}
}
await Platform.writeFileByName(
treeUri, parentId, 'musicus.json', jsonEncode(musicusFile.toJson()));
}
}

View file

@ -47,4 +47,51 @@ class Platform {
.map((childJson) => Document.fromJson(childJson)) .map((childJson) => Document.fromJson(childJson))
.toList(); .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,
},
);
}
/// 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,
},
);
}
} }

View file

@ -4,18 +4,18 @@ import '../backend.dart';
import '../platform.dart'; import '../platform.dart';
/// Result of the user's interaction with the files selector. /// Result of the user's interaction with the files selector.
/// ///
/// This will be given back when popping the navigator. /// This will be given back when popping the navigator.
class FilesSelectorResult { class FilesSelectorResult {
/// Document ID of the parent directory of the selected files. /// Document ID of the parent directory of the selected files.
/// ///
/// This will be null, if they are in the toplevel directory. /// This will be null, if they are in the toplevel directory.
final String parentId; final String parentId;
/// Document IDs of the selected files. /// Selected files.
final List<String> trackIds; final Set<Document> selection;
FilesSelectorResult(this.parentId, this.trackIds); FilesSelectorResult(this.parentId, this.selection);
} }
class FilesSelector extends StatefulWidget { class FilesSelector extends StatefulWidget {
@ -27,7 +27,7 @@ class _FilesSelectorState extends State<FilesSelector> {
BackendState backend; BackendState backend;
List<Document> history = []; List<Document> history = [];
List<Document> children = []; List<Document> children = [];
Set<String> selectedIds = {}; Set<Document> selection = {};
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@ -57,7 +57,7 @@ class _FilesSelectorState extends State<FilesSelector> {
context, context,
FilesSelectorResult( FilesSelectorResult(
history.isNotEmpty ? history.last.id : null, history.isNotEmpty ? history.last.id : null,
selectedIds.toList(), selection,
), ),
); );
}, },
@ -99,13 +99,13 @@ class _FilesSelectorState extends State<FilesSelector> {
controlAffinity: ListTileControlAffinity.trailing, controlAffinity: ListTileControlAffinity.trailing,
secondary: const Icon(Icons.insert_drive_file), secondary: const Icon(Icons.insert_drive_file),
title: Text(document.name), title: Text(document.name),
value: selectedIds.contains(document.id), value: selection.contains(document),
onChanged: (selected) { onChanged: (selected) {
setState(() { setState(() {
if (selected) { if (selected) {
selectedIds.add(document.id); selection.add(document);
} else { } else {
selectedIds.remove(document.id); selection.remove(document);
} }
}); });
}, },
@ -124,6 +124,10 @@ class _FilesSelectorState extends State<FilesSelector> {
Future<void> loadChildren() async { Future<void> loadChildren() async {
setState(() { setState(() {
children = []; 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( final newChildren = await Platform.getChildren(