mirror of
https://github.com/johrpan/musicus_mobile.git
synced 2025-10-26 18:57:25 +01:00
Move reusable code from mobile to common
This will be useful for a future desktop application.
This commit is contained in:
parent
6e1255f26e
commit
711b19c998
40 changed files with 813 additions and 581 deletions
221
common/lib/src/backend.dart
Normal file
221
common/lib/src/backend.dart
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:moor/isolate.dart';
|
||||
import 'package:moor/moor.dart';
|
||||
import 'package:moor_ffi/moor_ffi.dart';
|
||||
import 'package:musicus_client/musicus_client.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import 'library.dart';
|
||||
import 'platform.dart';
|
||||
import 'playback.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
/// Current status of the backend.
|
||||
enum MusicusBackendStatus {
|
||||
/// The backend is loading.
|
||||
///
|
||||
/// It is not allowed to call any methods on the backend in this state.
|
||||
loading,
|
||||
|
||||
/// Required settings are missing.
|
||||
///
|
||||
/// Currently this only includes the music library path. It is not allowed to
|
||||
/// call any methods on the backend in this state.
|
||||
setup,
|
||||
|
||||
/// The backend is ready to be used.
|
||||
///
|
||||
/// This is the only state, in which it is allowed to call methods on the
|
||||
/// backend.
|
||||
ready,
|
||||
}
|
||||
|
||||
/// Meta widget holding all backend ressources for Musicus.
|
||||
///
|
||||
/// This widget is intended to sit near the top of the widget tree. Widgets
|
||||
/// below it can get the current backend state using the static [of] method.
|
||||
/// The backend is intended to be used exactly once and live until the UI is
|
||||
/// exited. Because of that, consuming widgets don't need to care about a
|
||||
/// change of the backend state object.
|
||||
///
|
||||
/// The backend maintains a Musicus database within a Moor isolate. The connect
|
||||
/// port will be registered as 'moor' in the [IsolateNameServer].
|
||||
class MusicusBackend extends StatefulWidget {
|
||||
/// Path to the database file.
|
||||
final String dbPath;
|
||||
|
||||
/// An object to persist the settings.
|
||||
final MusicusSettingsStorage settingsStorage;
|
||||
|
||||
/// An object handling playback.
|
||||
final MusicusPlayback playback;
|
||||
|
||||
/// An object handling platform dependent functionality.
|
||||
final MusicusPlatform platform;
|
||||
|
||||
/// The first child below the backend widget.
|
||||
///
|
||||
/// This widget should keep track of the current backend status and block
|
||||
/// other widgets from accessing the backend until its status is set to
|
||||
/// [MusicusBackendStatus.ready].
|
||||
final Widget child;
|
||||
|
||||
MusicusBackend({
|
||||
@required this.dbPath,
|
||||
@required this.settingsStorage,
|
||||
@required this.playback,
|
||||
@required this.platform,
|
||||
@required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
MusicusBackendState createState() => MusicusBackendState();
|
||||
|
||||
static MusicusBackendState of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_InheritedBackend>().state;
|
||||
}
|
||||
|
||||
class MusicusBackendState extends State<MusicusBackend> {
|
||||
/// Starts the Moor isolate.
|
||||
///
|
||||
/// It will create a database connection for [request.path] and will send the
|
||||
/// Moor send port through [request.sendPort].
|
||||
static void _moorIsolateEntrypoint(_IsolateStartRequest request) {
|
||||
final executor = VmDatabase(File(request.path));
|
||||
final moorIsolate =
|
||||
MoorIsolate.inCurrent(() => DatabaseConnection.fromExecutor(executor));
|
||||
request.sendPort.send(moorIsolate.connectPort);
|
||||
}
|
||||
|
||||
/// The current backend status.
|
||||
///
|
||||
/// If this is not [MusicusBackendStatus.ready], the [child] widget should
|
||||
/// prevent all access to the backend.
|
||||
MusicusBackendStatus status = MusicusBackendStatus.loading;
|
||||
|
||||
Database db;
|
||||
MusicusPlayback playback;
|
||||
MusicusSettings settings;
|
||||
MusicusClient client;
|
||||
MusicusPlatform platform;
|
||||
MusicusLibrary library;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
/// Initialize resources.
|
||||
Future<void> _load() async {
|
||||
SendPort moorPort = IsolateNameServer.lookupPortByName('moor');
|
||||
if (moorPort == null) {
|
||||
final receivePort = ReceivePort();
|
||||
await Isolate.spawn(_moorIsolateEntrypoint,
|
||||
_IsolateStartRequest(receivePort.sendPort, widget.dbPath));
|
||||
moorPort = await receivePort.first;
|
||||
IsolateNameServer.registerPortWithName(moorPort, 'moor');
|
||||
}
|
||||
|
||||
final moorIsolate = MoorIsolate.fromConnectPort(moorPort);
|
||||
db = Database.connect(await moorIsolate.connect());
|
||||
|
||||
playback = widget.playback;
|
||||
await playback.setup();
|
||||
|
||||
settings = MusicusSettings(widget.settingsStorage);
|
||||
await settings.load();
|
||||
|
||||
settings.musicLibraryPath.listen((path) {
|
||||
setState(() {
|
||||
status = MusicusBackendStatus.loading;
|
||||
});
|
||||
_updateMusicLibrary(path);
|
||||
});
|
||||
|
||||
settings.server.listen((serverSettings) {
|
||||
_updateClient(serverSettings);
|
||||
});
|
||||
|
||||
_updateClient(settings.server.value);
|
||||
|
||||
final path = settings.musicLibraryPath.value;
|
||||
|
||||
platform = widget.platform;
|
||||
platform.setBasePath(path);
|
||||
|
||||
// This will change the status for us.
|
||||
_updateMusicLibrary(path);
|
||||
}
|
||||
|
||||
/// Create a music library according to [path].
|
||||
Future<void> _updateMusicLibrary(String path) async {
|
||||
if (path == null) {
|
||||
setState(() {
|
||||
status = MusicusBackendStatus.setup;
|
||||
});
|
||||
} else {
|
||||
platform.setBasePath(path);
|
||||
library = MusicusLibrary(path, platform);
|
||||
await library.load();
|
||||
setState(() {
|
||||
status = MusicusBackendStatus.ready;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new client based on [settings].
|
||||
void _updateClient(MusicusServerSettings settings) {
|
||||
client?.dispose();
|
||||
client = MusicusClient(
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
basePath: settings.apiPath,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _InheritedBackend(
|
||||
child: widget.child,
|
||||
state: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
settings.dispose();
|
||||
|
||||
/// We don't stop the Moor isolate, because it can be used elsewhere.
|
||||
db.close();
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundles arguments for the moor isolate.
|
||||
class _IsolateStartRequest {
|
||||
final SendPort sendPort;
|
||||
final String path;
|
||||
|
||||
_IsolateStartRequest(this.sendPort, this.path);
|
||||
}
|
||||
|
||||
/// Helper widget passing the current backend state down the widget tree.
|
||||
class _InheritedBackend extends InheritedWidget {
|
||||
final Widget child;
|
||||
final MusicusBackendState state;
|
||||
|
||||
_InheritedBackend({
|
||||
@required this.child,
|
||||
@required this.state,
|
||||
}) : super(child: child);
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_InheritedBackend old) => true;
|
||||
}
|
||||
96
common/lib/src/editors/ensemble.dart
Normal file
96
common/lib/src/editors/ensemble.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
|
||||
class EnsembleEditor extends StatefulWidget {
|
||||
final Ensemble ensemble;
|
||||
|
||||
EnsembleEditor({
|
||||
this.ensemble,
|
||||
});
|
||||
|
||||
@override
|
||||
_EnsembleEditorState createState() => _EnsembleEditorState();
|
||||
}
|
||||
|
||||
class _EnsembleEditorState extends State<EnsembleEditor> {
|
||||
final nameController = TextEditingController();
|
||||
|
||||
bool uploading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.ensemble != null) {
|
||||
nameController.text = widget.ensemble.name;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Ensemble'),
|
||||
actions: <Widget>[
|
||||
uploading
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
uploading = true;
|
||||
});
|
||||
|
||||
final ensemble = Ensemble(
|
||||
id: widget.ensemble?.id ?? generateId(),
|
||||
name: nameController.text,
|
||||
);
|
||||
|
||||
final success = await backend.client.putEnsemble(ensemble);
|
||||
|
||||
setState(() {
|
||||
uploading = false;
|
||||
});
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(context, ensemble);
|
||||
} else {
|
||||
Scaffold.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Failed to upload'),
|
||||
));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
common/lib/src/editors/instrument.dart
Normal file
97
common/lib/src/editors/instrument.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
|
||||
class InstrumentEditor extends StatefulWidget {
|
||||
final Instrument instrument;
|
||||
|
||||
InstrumentEditor({
|
||||
this.instrument,
|
||||
});
|
||||
|
||||
@override
|
||||
_InstrumentEditorState createState() => _InstrumentEditorState();
|
||||
}
|
||||
|
||||
class _InstrumentEditorState extends State<InstrumentEditor> {
|
||||
final nameController = TextEditingController();
|
||||
|
||||
bool uploading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.instrument != null) {
|
||||
nameController.text = widget.instrument.name;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Instrument/Role'),
|
||||
actions: <Widget>[
|
||||
uploading
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
uploading = true;
|
||||
});
|
||||
|
||||
final instrument = Instrument(
|
||||
id: widget.instrument?.id ?? generateId(),
|
||||
name: nameController.text,
|
||||
);
|
||||
|
||||
final success =
|
||||
await backend.client.putInstrument(instrument);
|
||||
|
||||
setState(() {
|
||||
uploading = false;
|
||||
});
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(context, instrument);
|
||||
} else {
|
||||
Scaffold.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Failed to upload'),
|
||||
));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
131
common/lib/src/editors/performance.dart
Normal file
131
common/lib/src/editors/performance.dart
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../selectors/ensemble.dart';
|
||||
import '../selectors/instruments.dart';
|
||||
import '../selectors/person.dart';
|
||||
|
||||
class PerformanceEditor extends StatefulWidget {
|
||||
final PerformanceInfo performanceInfo;
|
||||
|
||||
PerformanceEditor({
|
||||
this.performanceInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
_PerformanceEditorState createState() => _PerformanceEditorState();
|
||||
}
|
||||
|
||||
class _PerformanceEditorState extends State<PerformanceEditor> {
|
||||
Person person;
|
||||
Ensemble ensemble;
|
||||
Instrument role;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.performanceInfo != null) {
|
||||
person = widget.performanceInfo.person;
|
||||
ensemble = widget.performanceInfo.ensemble;
|
||||
role = widget.performanceInfo.role;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Edit performer'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
PerformanceInfo(
|
||||
person: person,
|
||||
ensemble: ensemble,
|
||||
role: role,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Person'),
|
||||
subtitle: Text(person != null
|
||||
? '${person.firstName} ${person.lastName}'
|
||||
: 'Select person'),
|
||||
onTap: () async {
|
||||
final Person newPerson = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonsSelector(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (newPerson != null) {
|
||||
setState(() {
|
||||
person = newPerson;
|
||||
ensemble = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Ensemble'),
|
||||
subtitle: Text(ensemble?.name ?? 'Select ensemble'),
|
||||
onTap: () async {
|
||||
final Ensemble newEnsemble = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EnsembleSelector(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (newEnsemble != null) {
|
||||
setState(() {
|
||||
ensemble = newEnsemble;
|
||||
person = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Role'),
|
||||
subtitle: Text(role?.name ?? 'Select instrument/role'),
|
||||
trailing: role != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
role = null;
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
onTap: () async {
|
||||
final Instrument newRole = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InstrumentsSelector(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (newRole != null) {
|
||||
setState(() {
|
||||
role = newRole;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
common/lib/src/editors/person.dart
Normal file
108
common/lib/src/editors/person.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.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();
|
||||
|
||||
bool uploading = false;
|
||||
|
||||
@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 = MusicusBackend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Person'),
|
||||
actions: <Widget>[
|
||||
uploading
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
uploading = true;
|
||||
});
|
||||
|
||||
final person = Person(
|
||||
id: widget.person?.id ?? generateId(),
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
);
|
||||
|
||||
final success = await backend.client.putPerson(person);
|
||||
|
||||
setState(() {
|
||||
uploading = false;
|
||||
});
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(context, person);
|
||||
} else {
|
||||
Scaffold.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Failed to upload'),
|
||||
));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
215
common/lib/src/editors/recording.dart
Normal file
215
common/lib/src/editors/recording.dart
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../editors/performance.dart';
|
||||
import '../selectors/recording.dart';
|
||||
import '../selectors/work.dart';
|
||||
|
||||
/// Screen for editing a recording.
|
||||
///
|
||||
/// If the user has finished editing, the result will be returned using the
|
||||
/// navigator as a [RecordingSelectorResult] object.
|
||||
class RecordingEditor extends StatefulWidget {
|
||||
/// The recording to edit.
|
||||
///
|
||||
/// If this is null, a new recording will be created.
|
||||
final RecordingInfo recordingInfo;
|
||||
|
||||
RecordingEditor({
|
||||
this.recordingInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
_RecordingEditorState createState() => _RecordingEditorState();
|
||||
}
|
||||
|
||||
class _RecordingEditorState extends State<RecordingEditor> {
|
||||
final commentController = TextEditingController();
|
||||
|
||||
bool uploading = false;
|
||||
WorkInfo workInfo;
|
||||
List<PerformanceInfo> performanceInfos = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.recordingInfo != null) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
() async {
|
||||
workInfo = await backend.db.getWork(widget.recordingInfo.recording.id);
|
||||
performanceInfos = List.from(widget.recordingInfo.performances);
|
||||
}();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
Future<void> selectWork() async {
|
||||
final WorkInfo newWorkInfo = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkSelector(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (newWorkInfo != null) {
|
||||
setState(() {
|
||||
workInfo = newWorkInfo;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final List<Widget> performanceTiles = [];
|
||||
for (var i = 0; i < performanceInfos.length; i++) {
|
||||
final p = performanceInfos[i];
|
||||
|
||||
performanceTiles.add(ListTile(
|
||||
title: Text(p.person != null
|
||||
? '${p.person.firstName} ${p.person.lastName}'
|
||||
: p.ensemble.name),
|
||||
subtitle: p.role != null ? Text(p.role.name) : null,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
performanceInfos.remove(p);
|
||||
});
|
||||
},
|
||||
),
|
||||
onTap: () async {
|
||||
final PerformanceInfo performanceInfo = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PerformanceEditor(
|
||||
performanceInfo: p,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (performanceInfo != null) {
|
||||
setState(() {
|
||||
performanceInfos[i] = performanceInfo;
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Recording'),
|
||||
actions: <Widget>[
|
||||
uploading
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
uploading = true;
|
||||
});
|
||||
|
||||
final recordingInfo = RecordingInfo(
|
||||
recording: Recording(
|
||||
id: widget?.recordingInfo?.recording?.id ??
|
||||
generateId(),
|
||||
work: workInfo.work.id,
|
||||
comment: commentController.text,
|
||||
),
|
||||
performances: performanceInfos,
|
||||
);
|
||||
|
||||
final success =
|
||||
await backend.client.putRecording(recordingInfo);
|
||||
|
||||
setState(() {
|
||||
uploading = false;
|
||||
});
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(
|
||||
context,
|
||||
RecordingSelectorResult(
|
||||
workInfo: workInfo,
|
||||
recordingInfo: recordingInfo,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Scaffold.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Failed to upload'),
|
||||
));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
workInfo != null
|
||||
? ListTile(
|
||||
title: Text(workInfo.work.title),
|
||||
subtitle: Text(workInfo.composers
|
||||
.map((p) => '${p.firstName} ${p.lastName}')
|
||||
.join(', ')),
|
||||
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 PerformanceInfo model = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PerformanceEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (model != null) {
|
||||
setState(() {
|
||||
performanceInfos.add(model);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
...performanceTiles,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
163
common/lib/src/editors/tracks.dart
Normal file
163
common/lib/src/editors/tracks.dart
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../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> {
|
||||
MusicusBackendState backend;
|
||||
WorkInfo workInfo;
|
||||
RecordingInfo recordingInfo;
|
||||
String parentId;
|
||||
List<TrackModel> trackModels = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
backend = MusicusBackend.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: recordingInfo.recording.id,
|
||||
index: i,
|
||||
partIds: [trackModel.workPartIndex],
|
||||
));
|
||||
}
|
||||
|
||||
// We need to copy all information associated with this track we
|
||||
// got by asking the server to our local database. For now, we
|
||||
// will just override everything that we already had previously.
|
||||
backend.db.updateWork(workInfo);
|
||||
backend.db.updateRecording(recordingInfo);
|
||||
|
||||
backend.library.addTracks(parentId, tracks);
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ReorderableListView(
|
||||
header: Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: recordingInfo != null
|
||||
? RecordingTile(
|
||||
workInfo: workInfo,
|
||||
recordingInfo: recordingInfo,
|
||||
)
|
||||
: 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 (recordingInfo != 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 RecordingSelectorResult result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RecordingSelector(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
workInfo = result.workInfo;
|
||||
recordingInfo = result.recordingInfo;
|
||||
});
|
||||
|
||||
updateAutoParts();
|
||||
}
|
||||
}
|
||||
|
||||
/// Automatically associate the tracks with work parts.
|
||||
Future<void> updateAutoParts() async {
|
||||
setState(() {
|
||||
for (var i = 0; i < trackModels.length; i++) {
|
||||
if (i >= workInfo.parts.length) {
|
||||
trackModels[i].workPartIndex = null;
|
||||
trackModels[i].workPartTitle = null;
|
||||
} else {
|
||||
trackModels[i].workPartIndex = workInfo.parts[i].work.partIndex;
|
||||
trackModels[i].workPartTitle = workInfo.parts[i].work.title;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
371
common/lib/src/editors/work.dart
Normal file
371
common/lib/src/editors/work.dart
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen for editing a work.
|
||||
///
|
||||
/// If the user is finished editing, the result will be returned as a [WorkInfo]
|
||||
/// object.
|
||||
class WorkEditor extends StatefulWidget {
|
||||
/// The work to edit.
|
||||
///
|
||||
/// If this is null, a new work will be created.
|
||||
final WorkInfo workInfo;
|
||||
|
||||
WorkEditor({
|
||||
this.workInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
_WorkEditorState createState() => _WorkEditorState();
|
||||
}
|
||||
|
||||
class _WorkEditorState extends State<WorkEditor> {
|
||||
final titleController = TextEditingController();
|
||||
|
||||
bool uploading = false;
|
||||
Person composer;
|
||||
List<Instrument> instruments = [];
|
||||
List<PartData> parts = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.workInfo != null) {
|
||||
titleController.text = widget.workInfo.work.title;
|
||||
// TODO: Theoretically this includes the composers of all parts.
|
||||
composer = widget.workInfo.composers.first;
|
||||
instruments = List.from(widget.workInfo.instruments);
|
||||
|
||||
for (final partInfo in widget.workInfo.parts) {
|
||||
parts.add(PartData(
|
||||
title: partInfo.work.title,
|
||||
composer: partInfo.composer,
|
||||
instruments: List.from(partInfo.instruments),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(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>[
|
||||
uploading
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
uploading = true;
|
||||
});
|
||||
|
||||
final workId = widget?.workInfo?.work?.id ?? generateId();
|
||||
|
||||
List<PartInfo> partInfos = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
final part = parts[i];
|
||||
partInfos.add(PartInfo(
|
||||
work: Work(
|
||||
id: generateId(),
|
||||
title: part.titleController.text,
|
||||
composer: part.composer?.id,
|
||||
partOf: workId,
|
||||
partIndex: i,
|
||||
),
|
||||
instruments: part.instruments,
|
||||
composer: part.composer,
|
||||
));
|
||||
}
|
||||
|
||||
final workInfo = WorkInfo(
|
||||
work: Work(
|
||||
id: workId,
|
||||
title: titleController.text,
|
||||
composer: composer?.id,
|
||||
),
|
||||
instruments: instruments,
|
||||
// TODO: Theoretically, this should include all composers
|
||||
// from the parts.
|
||||
composers: [composer],
|
||||
parts: partInfos,
|
||||
);
|
||||
|
||||
final success = await backend.client.putWork(workInfo);
|
||||
|
||||
setState(() {
|
||||
uploading = false;
|
||||
});
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(context, workInfo);
|
||||
} else {
|
||||
Scaffold.of(context).showSnackBar(SnackBar(
|
||||
content: Text('Failed to upload'),
|
||||
));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
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());
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
187
common/lib/src/library.dart
Normal file
187
common/lib/src/library.dart
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'platform.dart';
|
||||
|
||||
/// Bundles a [Track] with information on how to find the corresponding file.
|
||||
class InternalTrack {
|
||||
/// The represented track.
|
||||
final Track track;
|
||||
|
||||
/// A string identifying the track for playback.
|
||||
///
|
||||
/// This will be the result of calling the platform objects getIdentifier()
|
||||
/// function with the file name of the track.
|
||||
final String identifier;
|
||||
|
||||
InternalTrack({
|
||||
this.track,
|
||||
this.identifier,
|
||||
});
|
||||
|
||||
factory InternalTrack.fromJson(Map<String, dynamic> json) => InternalTrack(
|
||||
track: Track.fromJson(json['track']),
|
||||
identifier: json['identifier'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'track': track.toJson(),
|
||||
'identifier': identifier,
|
||||
};
|
||||
}
|
||||
|
||||
/// 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 MusicusLibrary {
|
||||
/// String representing the music library base path.
|
||||
final String basePath;
|
||||
|
||||
/// Access to platform dependent functionality.
|
||||
final MusicusPlatform platform;
|
||||
|
||||
/// 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 = {};
|
||||
|
||||
MusicusLibrary(this.basePath, this.platform);
|
||||
|
||||
/// 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(parentId);
|
||||
|
||||
for (final child in children) {
|
||||
if (child.isDirectory) {
|
||||
recurse(child.id);
|
||||
} else if (child.name == 'musicus.json') {
|
||||
final content = await platform.readDocument(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.readDocumentByName(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.writeDocumentByName(
|
||||
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,
|
||||
identifier: await platform.getIdentifier(parentId, track.fileName),
|
||||
);
|
||||
|
||||
if (tracks.containsKey(track.recordingId)) {
|
||||
tracks[track.recordingId].add(iTrack);
|
||||
} else {
|
||||
tracks[track.recordingId] = [iTrack];
|
||||
}
|
||||
}
|
||||
}
|
||||
76
common/lib/src/platform.dart
Normal file
76
common/lib/src/platform.dart
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/// Object representing a document in Storage Access Framework terms.
|
||||
class Document {
|
||||
/// Unique ID for the document.
|
||||
///
|
||||
/// The platform implementation thould be able to get the content of the
|
||||
/// document based on this value.
|
||||
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;
|
||||
|
||||
Document({
|
||||
this.id,
|
||||
this.name,
|
||||
this.parent,
|
||||
this.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'];
|
||||
}
|
||||
|
||||
/// Platform dependent code for the Musicus backend.
|
||||
abstract class MusicusPlatform {
|
||||
/// An identifier for the root directory of the music library.
|
||||
///
|
||||
/// This will be the string, that is stored as musicLibraryPath in the
|
||||
/// settings object.
|
||||
String basePath;
|
||||
|
||||
MusicusPlatform();
|
||||
|
||||
/// This will be called, when the music library path was changed.
|
||||
void setBasePath(String path) {
|
||||
basePath = path;
|
||||
}
|
||||
|
||||
/// Get all documents in a directory.
|
||||
///
|
||||
/// [parentId] will be the ID of the directory document. If [parentId] is
|
||||
/// null, the children of the root directory will be returned.
|
||||
Future<List<Document>> getChildren(String parentId);
|
||||
|
||||
/// Read the contents of a document by ID.
|
||||
Future<String> readDocument(String id);
|
||||
|
||||
/// Read from a document by name.
|
||||
///
|
||||
/// [parentId] is the document ID of the parent directory.
|
||||
Future<String> readDocumentByName(String parentId, String fileName);
|
||||
|
||||
/// Get a string identifying a document.
|
||||
///
|
||||
/// [parentId] is the document ID of the parent directory. The return value
|
||||
/// should be a string, that the playback object can use to find and play the
|
||||
/// file. It will be included in [InternalTrack] objects by the music
|
||||
/// library.
|
||||
Future<String> getIdentifier(String parentId, String fileName);
|
||||
|
||||
/// Write to a document by name.
|
||||
///
|
||||
/// [parentId] is the document ID of the parent directory.
|
||||
Future<void> writeDocumentByName(
|
||||
String parentId, String fileName, String contents);
|
||||
}
|
||||
96
common/lib/src/playback.dart
Normal file
96
common/lib/src/playback.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:meta/meta.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import 'library.dart';
|
||||
|
||||
/// Base class for Musicus playback.
|
||||
abstract class MusicusPlayback {
|
||||
/// 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);
|
||||
|
||||
/// Initialize the player.
|
||||
///
|
||||
/// This will be called after the database was initialized.
|
||||
Future<void> setup();
|
||||
|
||||
/// Add a list of tracks to the players playlist.
|
||||
Future<void> addTracks(List<InternalTrack> tracks);
|
||||
|
||||
/// Remove the track at [index] from the playlist.
|
||||
Future<void> removeTrack(int index);
|
||||
|
||||
/// Toggle whether the player is playing or paused.
|
||||
Future<void> playPause();
|
||||
|
||||
/// Seek to [pos], which is a value between (and including) zero and one.
|
||||
Future<void> seekTo(double pos);
|
||||
|
||||
/// Skip to the previous track in the playlist.
|
||||
Future<void> skipToPrevious();
|
||||
|
||||
/// Play the next track in the playlist.
|
||||
Future<void> skipToNext();
|
||||
|
||||
/// Switch to the track with the index [index] in the playlist.
|
||||
Future<void> skipTo(int index);
|
||||
|
||||
/// Set all values to their default.
|
||||
void reset() {
|
||||
active.add(false);
|
||||
playlist.add([]);
|
||||
currentTrack.add(null);
|
||||
playing.add(false);
|
||||
position.add(const Duration());
|
||||
duration.add(const Duration(seconds: 1));
|
||||
normalizedPosition.add(0.0);
|
||||
}
|
||||
|
||||
/// Tidy up.
|
||||
@mustCallSuper
|
||||
void dispose() {
|
||||
active.close();
|
||||
playlist.close();
|
||||
currentIndex.close();
|
||||
currentTrack.close();
|
||||
playing.close();
|
||||
position.close();
|
||||
duration.close();
|
||||
normalizedPosition.close();
|
||||
}
|
||||
}
|
||||
41
common/lib/src/selectors/ensemble.dart
Normal file
41
common/lib/src/selectors/ensemble.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../editors/ensemble.dart';
|
||||
import '../widgets/lists.dart';
|
||||
|
||||
/// A screen to select an ensemble.
|
||||
///
|
||||
/// If the user has selected one, it will be returned as an [Ensemble] object
|
||||
/// using the navigator.
|
||||
class EnsembleSelector extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select ensemble'),
|
||||
),
|
||||
body: EnsemblesList(
|
||||
onSelected: (ensemble) {
|
||||
Navigator.pop(context, ensemble);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final Ensemble ensemble = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EnsembleEditor(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (ensemble != null) {
|
||||
Navigator.pop(context, ensemble);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
common/lib/src/selectors/files.dart
Normal file
162
common/lib/src/selectors/files.dart
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../platform.dart';
|
||||
|
||||
/// Result of the user's interaction with the files selector.
|
||||
///
|
||||
/// This will be given back when popping the navigator.
|
||||
class FilesSelectorResult {
|
||||
/// Document ID of the parent directory of the selected files.
|
||||
///
|
||||
/// This will be null, if they are in the toplevel directory.
|
||||
final String parentId;
|
||||
|
||||
/// Selected files.
|
||||
final Set<Document> selection;
|
||||
|
||||
FilesSelectorResult(this.parentId, this.selection);
|
||||
}
|
||||
|
||||
class FilesSelector extends StatefulWidget {
|
||||
@override
|
||||
_FilesSelectorState createState() => _FilesSelectorState();
|
||||
}
|
||||
|
||||
class _FilesSelectorState extends State<FilesSelector> {
|
||||
MusicusBackendState backend;
|
||||
List<Document> history = [];
|
||||
List<Document> children = [];
|
||||
Set<Document> selection = {};
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = MusicusBackend.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 backend.platform
|
||||
.getChildren(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
136
common/lib/src/selectors/instruments.dart
Normal file
136
common/lib/src/selectors/instruments.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../editors/instrument.dart';
|
||||
import '../widgets/lists.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> {
|
||||
final _list = GlobalKey<PagedListViewState<Instrument>>();
|
||||
|
||||
Set<Instrument> selection = {};
|
||||
String _search;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.selection != null) {
|
||||
selection = widget.selection.toSet();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.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: Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
onChanged: (text) {
|
||||
setState(() {
|
||||
_search = text;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: 'Search by name...',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PagedListView<Instrument>(
|
||||
key: _list,
|
||||
search: _search,
|
||||
fetch: (page, search) async {
|
||||
return await backend.client.getInstruments(page, search);
|
||||
},
|
||||
builder: (context, instrument) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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);
|
||||
});
|
||||
|
||||
// We need to rebuild the list view, because we added an item.
|
||||
_list.currentState.update();
|
||||
} else {
|
||||
Navigator.pop(context, instrument);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
common/lib/src/selectors/person.dart
Normal file
41
common/lib/src/selectors/person.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../editors/person.dart';
|
||||
import '../widgets/lists.dart';
|
||||
|
||||
/// A screen to select a person.
|
||||
///
|
||||
/// If the user has selected a person, it will be returned as a [Person] object
|
||||
/// using the navigator.
|
||||
class PersonsSelector extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select person'),
|
||||
),
|
||||
body: PersonsList(
|
||||
onSelected: (person) {
|
||||
Navigator.pop(context, person);
|
||||
},
|
||||
),
|
||||
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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
90
common/lib/src/selectors/recording.dart
Normal file
90
common/lib/src/selectors/recording.dart
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../editors/recording.dart';
|
||||
import '../widgets/lists.dart';
|
||||
|
||||
class RecordingSelectorResult {
|
||||
final WorkInfo workInfo;
|
||||
final RecordingInfo recordingInfo;
|
||||
|
||||
RecordingSelectorResult({
|
||||
this.workInfo,
|
||||
this.recordingInfo,
|
||||
});
|
||||
}
|
||||
|
||||
/// A screen to select a recording.
|
||||
///
|
||||
/// If the user has selected a recording, a [RecordingSelectorResult] containing
|
||||
/// the selected recording and the recorded work will be returned using the
|
||||
/// navigator.
|
||||
class RecordingSelector extends StatefulWidget {
|
||||
@override
|
||||
_RecordingSelectorState createState() => _RecordingSelectorState();
|
||||
}
|
||||
|
||||
class _RecordingSelectorState extends State<RecordingSelector> {
|
||||
Person person;
|
||||
WorkInfo workInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body;
|
||||
|
||||
if (person == null) {
|
||||
body = PersonsList(
|
||||
onSelected: (newPerson) {
|
||||
setState(() {
|
||||
person = newPerson;
|
||||
});
|
||||
},
|
||||
);
|
||||
} else if (workInfo == null) {
|
||||
body = WorksList(
|
||||
personId: person.id,
|
||||
onSelected: (newWorkInfo) {
|
||||
setState(() {
|
||||
workInfo = newWorkInfo;
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
body = RecordingsList(
|
||||
workId: workInfo.work.id,
|
||||
onSelected: (recordingInfo) {
|
||||
Navigator.pop(
|
||||
context,
|
||||
RecordingSelectorResult(
|
||||
workInfo: workInfo,
|
||||
recordingInfo: recordingInfo,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select recording'),
|
||||
),
|
||||
body: body,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final RecordingSelectorResult result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RecordingEditor(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
Navigator.pop(context, result);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
65
common/lib/src/selectors/work.dart
Normal file
65
common/lib/src/selectors/work.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../editors/work.dart';
|
||||
import '../widgets/lists.dart';
|
||||
|
||||
/// A screen to select a work.
|
||||
///
|
||||
/// If the user has selected a work, a [WorkInfo] will be returned
|
||||
/// using the navigator.
|
||||
class WorkSelector extends StatefulWidget {
|
||||
@override
|
||||
_WorkSelectorState createState() => _WorkSelectorState();
|
||||
}
|
||||
|
||||
class _WorkSelectorState extends State<WorkSelector> {
|
||||
Person person;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body;
|
||||
|
||||
if (person == null) {
|
||||
body = PersonsList(
|
||||
onSelected: (newPerson) {
|
||||
setState(() {
|
||||
person = newPerson;
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
body = WorksList(
|
||||
personId: person.id,
|
||||
onSelected: (workInfo) {
|
||||
setState(() {
|
||||
Navigator.pop(context, workInfo);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select work'),
|
||||
),
|
||||
body: body,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final WorkInfo workInfo = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkEditor(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (workInfo != null) {
|
||||
Navigator.pop(context, workInfo);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
112
common/lib/src/settings.dart
Normal file
112
common/lib/src/settings.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import 'package:meta/meta.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// Interface for persisting settings.
|
||||
///
|
||||
/// The methods should return null, if there is no value associated with the
|
||||
/// provided key.
|
||||
abstract class MusicusSettingsStorage {
|
||||
Future<void> load();
|
||||
Future<int> getInt(String key);
|
||||
Future<String> getString(String key);
|
||||
Future<void> setInt(String key, int value);
|
||||
Future<void> setString(String key, String value);
|
||||
}
|
||||
|
||||
/// Settings concerning the Musicus server to connect to.
|
||||
///
|
||||
/// We don't support setting a scheme here, because there may be password being
|
||||
/// submitted in the future, so we default to HTTPS.
|
||||
class MusicusServerSettings {
|
||||
/// Host to connect to, e.g. 'musicus.johrpan.de';
|
||||
final String host;
|
||||
|
||||
/// Port to connect to.
|
||||
final int port;
|
||||
|
||||
/// Path to the API.
|
||||
///
|
||||
/// This can be either null or empty, if the API is at the root of the host.
|
||||
final String apiPath;
|
||||
|
||||
MusicusServerSettings({
|
||||
@required this.host,
|
||||
@required this.port,
|
||||
@required this.apiPath,
|
||||
});
|
||||
}
|
||||
|
||||
/// Manager for all settings that are persisted.
|
||||
class MusicusSettings {
|
||||
static const defaultHost = 'musicus.johrpan.de';
|
||||
static const defaultPort = 443;
|
||||
static const defaultApiPath = '/api';
|
||||
|
||||
/// The storage method to use.
|
||||
final MusicusSettingsStorage storage;
|
||||
|
||||
/// A identifier for the base path of the music library.
|
||||
///
|
||||
/// This could be a file path on destop systems or a tree URI in terms of the
|
||||
/// Android storage access framework.
|
||||
final musicLibraryPath = BehaviorSubject<String>();
|
||||
|
||||
/// Musicus server to connect to.
|
||||
final server = BehaviorSubject<MusicusServerSettings>();
|
||||
|
||||
/// Create a settings instance.
|
||||
MusicusSettings(this.storage);
|
||||
|
||||
/// Initialize the settings.
|
||||
Future<void> load() async {
|
||||
await storage.load();
|
||||
|
||||
final path = await storage.getString('musicLibraryPath');
|
||||
if (path != null) {
|
||||
musicLibraryPath.add(path);
|
||||
}
|
||||
|
||||
final host = await storage.getString('serverHost') ?? defaultHost;
|
||||
final port = await storage.getInt('serverPort') ?? defaultPort;
|
||||
final apiPath = await storage.getString('serverApiPath') ?? defaultApiPath;
|
||||
|
||||
server.add(MusicusServerSettings(
|
||||
host: host,
|
||||
port: port,
|
||||
apiPath: apiPath,
|
||||
));
|
||||
}
|
||||
|
||||
/// Set a new music library path.
|
||||
///
|
||||
/// This will persist the new value and update the stream.
|
||||
Future<void> setMusicLibraryPath(String path) async {
|
||||
await storage.setString('musicLibraryPath', path);
|
||||
musicLibraryPath.add(path);
|
||||
}
|
||||
|
||||
/// Update the server settings.
|
||||
///
|
||||
/// This will persist the new values and update the stream.
|
||||
Future<void> setServer(MusicusServerSettings serverSettings) async {
|
||||
await storage.setString('serverHost', serverSettings.host);
|
||||
await storage.setInt('serverPort', serverSettings.port);
|
||||
await storage.setString('severApiPath', serverSettings.apiPath);
|
||||
server.add(serverSettings);
|
||||
}
|
||||
|
||||
/// Reset the server settings to their defaults.
|
||||
Future<void> resetServer() async {
|
||||
await setServer(MusicusServerSettings(
|
||||
host: defaultHost,
|
||||
port: defaultPort,
|
||||
apiPath: defaultApiPath,
|
||||
));
|
||||
}
|
||||
|
||||
/// Tidy up.
|
||||
void dispose() {
|
||||
musicLibraryPath.close();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
367
common/lib/src/widgets/lists.dart
Normal file
367
common/lib/src/widgets/lists.dart
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../widgets/texts.dart';
|
||||
|
||||
/// A list view supporting pagination and searching.
|
||||
///
|
||||
/// The [fetch] function will be called, when the user has scrolled to the end
|
||||
/// of the list. If you recreate this widget with a new [search] parameter, it
|
||||
/// will update, but it will NOT correctly react to a changed [fetch] method.
|
||||
/// You can call update() on the corresponding state object to manually refresh
|
||||
/// the contents.
|
||||
class PagedListView<T> extends StatefulWidget {
|
||||
/// A search string.
|
||||
///
|
||||
/// This will be provided when calling [fetch].
|
||||
final String search;
|
||||
|
||||
/// Callback for fetching a page of entities.
|
||||
///
|
||||
/// This has to tolerate abitrary high page numbers.
|
||||
final Future<List<T>> Function(int page, String search) fetch;
|
||||
|
||||
/// Build function to be called for each entity.
|
||||
final Widget Function(BuildContext context, T entity) builder;
|
||||
|
||||
PagedListView({
|
||||
Key key,
|
||||
this.search,
|
||||
@required this.fetch,
|
||||
@required this.builder,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
PagedListViewState<T> createState() => PagedListViewState<T>();
|
||||
}
|
||||
|
||||
class PagedListViewState<T> extends State<PagedListView<T>> {
|
||||
final _scrollController = ScrollController();
|
||||
final _entities = <T>[];
|
||||
|
||||
bool loading = true;
|
||||
|
||||
/// The last parameters of _fetch().
|
||||
int _page;
|
||||
String _search;
|
||||
|
||||
/// Whether the last fetch() call returned no results.
|
||||
bool _end = false;
|
||||
|
||||
/// Fetch new entities.
|
||||
///
|
||||
/// If the function was called again with other parameters, while it was
|
||||
/// running, it will discard the result.
|
||||
Future<void> _fetch(int page, String search) async {
|
||||
if (page != _page || search != _search) {
|
||||
_page = page;
|
||||
_search = search;
|
||||
|
||||
setState(() {
|
||||
loading = true;
|
||||
});
|
||||
|
||||
final newEntities = await widget.fetch(page, search);
|
||||
|
||||
if (mounted && search == _search) {
|
||||
setState(() {
|
||||
if (newEntities.isNotEmpty) {
|
||||
_entities.addAll(newEntities);
|
||||
} else {
|
||||
_end = true;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.pixels >
|
||||
_scrollController.position.maxScrollExtent - 64.0 &&
|
||||
!loading &&
|
||||
!_end) {
|
||||
_fetch(_page + 1, widget.search);
|
||||
}
|
||||
});
|
||||
|
||||
_fetch(0, widget.search);
|
||||
}
|
||||
|
||||
/// Update the content manually.
|
||||
///
|
||||
/// This will reset the current page to zero and call the provided fetch()
|
||||
/// method.
|
||||
void update() {
|
||||
setState(() {
|
||||
_entities.clear();
|
||||
});
|
||||
|
||||
_page = null;
|
||||
_fetch(0, widget.search);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PagedListView<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.search != widget.search) {
|
||||
// We don't nedd to call setState() because the framework will always call
|
||||
// build() after this.
|
||||
_entities.clear();
|
||||
_page = null;
|
||||
_fetch(0, widget.search);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: _entities.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < _entities.length) {
|
||||
return widget.builder(context, _entities[index]);
|
||||
} else {
|
||||
return SizedBox(
|
||||
height: 64.0,
|
||||
child: Center(
|
||||
child: loading ? CircularProgressIndicator() : Container(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of persons.
|
||||
class PersonsList extends StatefulWidget {
|
||||
/// Called, when the user has selected a person.
|
||||
final void Function(Person person) onSelected;
|
||||
|
||||
PersonsList({
|
||||
@required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
_PersonsListState createState() => _PersonsListState();
|
||||
}
|
||||
|
||||
class _PersonsListState extends State<PersonsList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
onChanged: (text) {
|
||||
setState(() {
|
||||
_search = text;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: 'Search by last name...',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PagedListView<Person>(
|
||||
search: _search,
|
||||
fetch: (page, search) async {
|
||||
return await backend.client.getPersons(page, search);
|
||||
},
|
||||
builder: (context, person) => ListTile(
|
||||
title: Text('${person.lastName}, ${person.firstName}'),
|
||||
onTap: () {
|
||||
widget.onSelected(person);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of ensembles.
|
||||
class EnsemblesList extends StatefulWidget {
|
||||
/// Called, when the user has selected an ensemble.
|
||||
final void Function(Ensemble ensemble) onSelected;
|
||||
|
||||
EnsemblesList({
|
||||
@required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
_EnsemblesListState createState() => _EnsemblesListState();
|
||||
}
|
||||
|
||||
class _EnsemblesListState extends State<EnsemblesList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
onChanged: (text) {
|
||||
setState(() {
|
||||
_search = text;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: 'Search by name...',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PagedListView<Ensemble>(
|
||||
search: _search,
|
||||
fetch: (page, search) async {
|
||||
return await backend.client.getEnsembles(page, search);
|
||||
},
|
||||
builder: (context, ensemble) => ListTile(
|
||||
title: Text(ensemble.name),
|
||||
onTap: () {
|
||||
widget.onSelected(ensemble);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of works by one composer.
|
||||
class WorksList extends StatefulWidget {
|
||||
/// The ID of the composer.
|
||||
final int personId;
|
||||
|
||||
/// Called, when the user has selected a work.
|
||||
final void Function(WorkInfo workInfo) onSelected;
|
||||
|
||||
WorksList({
|
||||
this.personId,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
_WorksListState createState() => _WorksListState();
|
||||
}
|
||||
|
||||
class _WorksListState extends State<WorksList> {
|
||||
String _search;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 4.0,
|
||||
),
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
onChanged: (text) {
|
||||
setState(() {
|
||||
_search = text;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: 'Search by title...',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PagedListView<WorkInfo>(
|
||||
search: _search,
|
||||
fetch: (page, search) async {
|
||||
return await backend.client
|
||||
.getWorks(widget.personId, page, search);
|
||||
},
|
||||
builder: (context, workInfo) => ListTile(
|
||||
title: Text(workInfo.work.title),
|
||||
onTap: () {
|
||||
widget.onSelected(workInfo);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of recordings of a work.
|
||||
class RecordingsList extends StatelessWidget {
|
||||
/// The ID of the work.
|
||||
final int workId;
|
||||
|
||||
/// Called, when the user has selected a recording.
|
||||
final void Function(RecordingInfo recordingInfo) onSelected;
|
||||
|
||||
RecordingsList({
|
||||
this.workId,
|
||||
this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.of(context);
|
||||
|
||||
return PagedListView<RecordingInfo>(
|
||||
fetch: (page, _) async {
|
||||
return await backend.client.getRecordings(workId, page);
|
||||
},
|
||||
builder: (context, recordingInfo) => ListTile(
|
||||
title: PerformancesText(
|
||||
performanceInfos: recordingInfo.performances,
|
||||
),
|
||||
onTap: () {
|
||||
if (onSelected != null) {
|
||||
onSelected(recordingInfo);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
49
common/lib/src/widgets/recording_tile.dart
Normal file
49
common/lib/src/widgets/recording_tile.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import 'texts.dart';
|
||||
|
||||
class RecordingTile extends StatelessWidget {
|
||||
final WorkInfo workInfo;
|
||||
final RecordingInfo recordingInfo;
|
||||
|
||||
RecordingTile({
|
||||
this.workInfo,
|
||||
this.recordingInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
DefaultTextStyle(
|
||||
style: textTheme.subtitle1,
|
||||
child: Text(workInfo.composers
|
||||
.map((p) => '${p.firstName} ${p.lastName}')
|
||||
.join(', ')),
|
||||
),
|
||||
DefaultTextStyle(
|
||||
style: textTheme.headline6,
|
||||
child: Text(workInfo.work.title),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
DefaultTextStyle(
|
||||
style: textTheme.bodyText1,
|
||||
child: PerformancesText(
|
||||
performanceInfos: recordingInfo.performances,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
73
common/lib/src/widgets/texts.dart
Normal file
73
common/lib/src/widgets/texts.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:musicus_database/musicus_database.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
|
||||
/// A widget showing information on a list of performances.
|
||||
class PerformancesText extends StatelessWidget {
|
||||
/// The information to show.
|
||||
final List<PerformanceInfo> performanceInfos;
|
||||
|
||||
PerformancesText({
|
||||
this.performanceInfos,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<String> performanceTexts = [];
|
||||
|
||||
for (final p in performanceInfos) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (p.person != null) {
|
||||
buffer.write('${p.person.firstName} ${p.person.lastName}');
|
||||
} else if (p.ensemble != null) {
|
||||
buffer.write(p.ensemble.name);
|
||||
} else {
|
||||
buffer.write('Unknown');
|
||||
}
|
||||
|
||||
if (p.role != null) {
|
||||
buffer.write(' (${p.role.name})');
|
||||
}
|
||||
|
||||
performanceTexts.add(buffer.toString());
|
||||
}
|
||||
|
||||
return Text(performanceTexts.join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
class WorkText extends StatelessWidget {
|
||||
final int workId;
|
||||
|
||||
WorkText(this.workId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = MusicusBackend.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 = MusicusBackend.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(', ')
|
||||
: '...'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue