mirror of
https://github.com/johrpan/musicus_mobile.git
synced 2025-10-26 10:47:25 +01:00
Add music library
This commit is contained in:
parent
9da8f8891b
commit
b1994d1067
6 changed files with 368 additions and 21 deletions
|
|
@ -40,6 +40,22 @@ class MainActivity : FlutterActivity() {
|
|||
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 == "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()
|
||||
}
|
||||
|
|
@ -114,4 +130,104 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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
|
||||
|
|
@ -86,6 +87,7 @@ class BackendState extends State<Backend> {
|
|||
BackendStatus status = BackendStatus.loading;
|
||||
Database db;
|
||||
String musicLibraryUri;
|
||||
MusicLibrary ml;
|
||||
|
||||
MoorIsolate _moorIsolate;
|
||||
SharedPreferences _shPref;
|
||||
|
|
@ -122,6 +124,8 @@ class BackendState extends State<Backend> {
|
|||
status = BackendStatus.setup;
|
||||
});
|
||||
} else {
|
||||
ml = MusicLibrary(musicLibraryUri);
|
||||
await ml.load();
|
||||
setState(() {
|
||||
status = BackendStatus.ready;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ 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';
|
||||
|
||||
// TODO: Update for storage access framework.
|
||||
class TrackModel {
|
||||
String path;
|
||||
String fileName;
|
||||
|
||||
TrackModel(this.path);
|
||||
TrackModel(this.fileName);
|
||||
}
|
||||
|
||||
class TracksEditor extends StatefulWidget {
|
||||
|
|
@ -20,7 +20,8 @@ class TracksEditor extends StatefulWidget {
|
|||
|
||||
class _TracksEditorState extends State<TracksEditor> {
|
||||
int recordingId;
|
||||
List<TrackModel> tracks = [];
|
||||
String parentId;
|
||||
List<TrackModel> trackModels = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -33,7 +34,22 @@ class _TracksEditorState extends State<TracksEditor> {
|
|||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
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) {
|
||||
// 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(
|
||||
key: Key(t.hashCode.toString()),
|
||||
title: Text(t.path),
|
||||
title: Text(t.fileName),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
tracks.remove(t);
|
||||
trackModels.remove(t);
|
||||
});
|
||||
},
|
||||
),
|
||||
|
|
@ -86,9 +107,9 @@ class _TracksEditorState extends State<TracksEditor> {
|
|||
.toList(),
|
||||
onReorder: (i1, i2) {
|
||||
setState(() {
|
||||
final track = tracks.removeAt(i1);
|
||||
final track = trackModels.removeAt(i1);
|
||||
final newIndex = i2 > i1 ? i2 - 1 : i2;
|
||||
tracks.insert(newIndex, track);
|
||||
trackModels.insert(newIndex, track);
|
||||
});
|
||||
},
|
||||
),
|
||||
|
|
|
|||
155
lib/music_library.dart
Normal file
155
lib/music_library.dart
Normal 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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -47,4 +47,51 @@ class Platform {
|
|||
.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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ class FilesSelectorResult {
|
|||
/// This will be null, if they are in the toplevel directory.
|
||||
final String parentId;
|
||||
|
||||
/// Document IDs of the selected files.
|
||||
final List<String> trackIds;
|
||||
/// Selected files.
|
||||
final Set<Document> selection;
|
||||
|
||||
FilesSelectorResult(this.parentId, this.trackIds);
|
||||
FilesSelectorResult(this.parentId, this.selection);
|
||||
}
|
||||
|
||||
class FilesSelector extends StatefulWidget {
|
||||
|
|
@ -27,7 +27,7 @@ class _FilesSelectorState extends State<FilesSelector> {
|
|||
BackendState backend;
|
||||
List<Document> history = [];
|
||||
List<Document> children = [];
|
||||
Set<String> selectedIds = {};
|
||||
Set<Document> selection = {};
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
|
|
@ -57,7 +57,7 @@ class _FilesSelectorState extends State<FilesSelector> {
|
|||
context,
|
||||
FilesSelectorResult(
|
||||
history.isNotEmpty ? history.last.id : null,
|
||||
selectedIds.toList(),
|
||||
selection,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
@ -99,13 +99,13 @@ class _FilesSelectorState extends State<FilesSelector> {
|
|||
controlAffinity: ListTileControlAffinity.trailing,
|
||||
secondary: const Icon(Icons.insert_drive_file),
|
||||
title: Text(document.name),
|
||||
value: selectedIds.contains(document.id),
|
||||
value: selection.contains(document),
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
selectedIds.add(document.id);
|
||||
selection.add(document);
|
||||
} else {
|
||||
selectedIds.remove(document.id);
|
||||
selection.remove(document);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
@ -124,6 +124,10 @@ class _FilesSelectorState extends State<FilesSelector> {
|
|||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue