mirror of
https://github.com/johrpan/musicus_mobile.git
synced 2025-10-26 18:57:25 +01:00
Move musicus package to seperate directory
This commit is contained in:
parent
5216c7d359
commit
c8e831c461
81 changed files with 0 additions and 0 deletions
141
musicus/lib/app.dart
Normal file
141
musicus/lib/app.dart
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'backend.dart';
|
||||
import 'screens/home.dart';
|
||||
import 'widgets/player_bar.dart';
|
||||
|
||||
class App extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Musicus',
|
||||
theme: ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
accentColor: Colors.amber,
|
||||
textSelectionColor: Colors.grey[600],
|
||||
cursorColor: Colors.amber,
|
||||
textSelectionHandleColor: Colors.amber,
|
||||
toggleableActiveColor: Colors.amber,
|
||||
// Added for sliders and FABs. Not everything seems to obey this.
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: Colors.amber,
|
||||
secondary: Colors.amber,
|
||||
),
|
||||
fontFamily: 'Libertinus Sans',
|
||||
),
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
if (backend.status == BackendStatus.loading) {
|
||||
return Material(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
);
|
||||
} else if (backend.status == BackendStatus.setup) {
|
||||
return Material(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Choose the base path for\nyour music library.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headline6,
|
||||
),
|
||||
SizedBox(
|
||||
height: 16.0,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.folder_open),
|
||||
title: Text('Choose path'),
|
||||
onTap: () {
|
||||
backend.chooseMusicLibraryUri();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Content();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Content extends StatefulWidget {
|
||||
@override
|
||||
_ContentState createState() => _ContentState();
|
||||
}
|
||||
|
||||
class _ContentState extends State<Content> with SingleTickerProviderStateMixin {
|
||||
final nestedNavigator = GlobalKey<NavigatorState>();
|
||||
|
||||
AnimationController playerBarAnimation;
|
||||
BackendState backend;
|
||||
StreamSubscription<bool> playerActiveSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
playerBarAnimation = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
playerBarAnimation.value = backend.player.active.value ? 1.0 : 0.0;
|
||||
|
||||
if (playerActiveSubscription != null) {
|
||||
playerActiveSubscription.cancel();
|
||||
}
|
||||
|
||||
playerActiveSubscription = backend.player.active.listen((active) =>
|
||||
active ? playerBarAnimation.forward() : playerBarAnimation.reverse());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The nested Navigator is for every screen from which the player bar at
|
||||
// the bottom should be accessible. The WillPopScope widget intercepts
|
||||
// taps on the system back button and redirects them to the nested
|
||||
// navigator.
|
||||
return WillPopScope(
|
||||
onWillPop: () async => !(await nestedNavigator.currentState.maybePop()),
|
||||
child: Scaffold(
|
||||
body: Navigator(
|
||||
key: nestedNavigator,
|
||||
onGenerateRoute: (settings) => settings.name == '/'
|
||||
? MaterialPageRoute(
|
||||
builder: (context) => HomeScreen(),
|
||||
)
|
||||
: null,
|
||||
initialRoute: '/',
|
||||
),
|
||||
bottomNavigationBar: SizeTransition(
|
||||
sizeFactor: CurvedAnimation(
|
||||
curve: Curves.easeOut,
|
||||
parent: playerBarAnimation,
|
||||
),
|
||||
axisAlignment: -1.0,
|
||||
child: PlayerBar(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
playerActiveSubscription.cancel();
|
||||
}
|
||||
}
|
||||
166
musicus/lib/backend.dart
Normal file
166
musicus/lib/backend.dart
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:moor/isolate.dart';
|
||||
import 'package:moor/moor.dart';
|
||||
import 'package:moor_ffi/moor_ffi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart' as pp;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'database.dart';
|
||||
import 'music_library.dart';
|
||||
import 'player.dart';
|
||||
|
||||
// The following code was taken from
|
||||
// https://moor.simonbinder.eu/docs/advanced-features/isolates/ and just
|
||||
// slightly modified.
|
||||
|
||||
Future<MoorIsolate> _createMoorIsolate() async {
|
||||
// This method is called from the main isolate. Since we can't use
|
||||
// getApplicationDocumentsDirectory on a background isolate, we calculate
|
||||
// the database path in the foreground isolate and then inform the
|
||||
// background isolate about the path.
|
||||
final dir = await pp.getApplicationDocumentsDirectory();
|
||||
final path = p.join(dir.path, 'db.sqlite');
|
||||
final receivePort = ReceivePort();
|
||||
|
||||
await Isolate.spawn(
|
||||
_startBackground,
|
||||
_IsolateStartRequest(receivePort.sendPort, path),
|
||||
);
|
||||
|
||||
// _startBackground will send the MoorIsolate to this ReceivePort.
|
||||
return (await receivePort.first as MoorIsolate);
|
||||
}
|
||||
|
||||
void _startBackground(_IsolateStartRequest request) {
|
||||
// This is the entrypoint from the background isolate! Let's create
|
||||
// the database from the path we received.
|
||||
final executor = VmDatabase(File(request.targetPath));
|
||||
// We're using MoorIsolate.inCurrent here as this method already runs on a
|
||||
// background isolate. If we used MoorIsolate.spawn, a third isolate would be
|
||||
// started which is not what we want!
|
||||
final moorIsolate = MoorIsolate.inCurrent(
|
||||
() => DatabaseConnection.fromExecutor(executor),
|
||||
);
|
||||
// Inform the starting isolate about this, so that it can call .connect().
|
||||
request.sendMoorIsolate.send(moorIsolate);
|
||||
}
|
||||
|
||||
// Used to bundle the SendPort and the target path, since isolate entrypoint
|
||||
// functions can only take one parameter.
|
||||
class _IsolateStartRequest {
|
||||
final SendPort sendMoorIsolate;
|
||||
final String targetPath;
|
||||
|
||||
_IsolateStartRequest(this.sendMoorIsolate, this.targetPath);
|
||||
}
|
||||
|
||||
enum BackendStatus {
|
||||
loading,
|
||||
setup,
|
||||
ready,
|
||||
}
|
||||
|
||||
class Backend extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
Backend({
|
||||
@required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
BackendState createState() => BackendState();
|
||||
|
||||
static BackendState of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_InheritedBackend>().state;
|
||||
}
|
||||
|
||||
class BackendState extends State<Backend> {
|
||||
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
||||
|
||||
final player = Player();
|
||||
|
||||
BackendStatus status = BackendStatus.loading;
|
||||
Database db;
|
||||
String musicLibraryUri;
|
||||
MusicLibrary ml;
|
||||
|
||||
MoorIsolate _moorIsolate;
|
||||
SharedPreferences _shPref;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _InheritedBackend(
|
||||
child: widget.child,
|
||||
state: this,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
_moorIsolate = await _createMoorIsolate();
|
||||
final dbConnection = await _moorIsolate.connect();
|
||||
player.setup();
|
||||
db = Database.connect(dbConnection);
|
||||
|
||||
_shPref = await SharedPreferences.getInstance();
|
||||
musicLibraryUri = _shPref.getString('musicLibraryUri');
|
||||
|
||||
_loadMusicLibrary();
|
||||
}
|
||||
|
||||
Future<void> _loadMusicLibrary() async {
|
||||
if (musicLibraryUri == null) {
|
||||
setState(() {
|
||||
status = BackendStatus.setup;
|
||||
});
|
||||
} else {
|
||||
ml = MusicLibrary(musicLibraryUri);
|
||||
await ml.load();
|
||||
setState(() {
|
||||
status = BackendStatus.ready;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> chooseMusicLibraryUri() async {
|
||||
final uri = await _platform.invokeMethod<String>('openTree');
|
||||
|
||||
if (uri != null) {
|
||||
musicLibraryUri = uri;
|
||||
await _shPref.setString('musicLibraryUri', uri);
|
||||
setState(() {
|
||||
status = BackendStatus.loading;
|
||||
});
|
||||
await _loadMusicLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_moorIsolate.shutdownAll();
|
||||
}
|
||||
}
|
||||
|
||||
class _InheritedBackend extends InheritedWidget {
|
||||
final Widget child;
|
||||
final BackendState state;
|
||||
|
||||
_InheritedBackend({
|
||||
@required this.child,
|
||||
@required this.state,
|
||||
}) : super(child: child);
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_InheritedBackend old) => true;
|
||||
}
|
||||
102
musicus/lib/database.dart
Normal file
102
musicus/lib/database.dart
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:moor/moor.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
final _random = Random(DateTime.now().millisecondsSinceEpoch);
|
||||
int generateId() => _random.nextInt(0xFFFFFFFF);
|
||||
|
||||
class WorkModel {
|
||||
final Work work;
|
||||
final List<int> instrumentIds;
|
||||
|
||||
WorkModel({
|
||||
@required this.work,
|
||||
@required this.instrumentIds,
|
||||
});
|
||||
}
|
||||
|
||||
class PerformanceModel {
|
||||
final Person person;
|
||||
final Ensemble ensemble;
|
||||
final Instrument role;
|
||||
|
||||
PerformanceModel({
|
||||
this.person,
|
||||
this.ensemble,
|
||||
this.role,
|
||||
});
|
||||
}
|
||||
|
||||
@UseMoor(
|
||||
include: {
|
||||
'database.moor',
|
||||
},
|
||||
)
|
||||
class Database extends _$Database {
|
||||
Database.connect(DatabaseConnection connection) : super.connect(connection);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
beforeOpen: (details) async {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> updatePerson(Person person) async {
|
||||
await into(persons).insert(person, orReplace: true);
|
||||
}
|
||||
|
||||
Future<void> updateInstrument(Instrument instrument) async {
|
||||
await into(instruments).insert(instrument, orReplace: true);
|
||||
}
|
||||
|
||||
Future<void> updateWork(WorkModel model, List<WorkModel> parts) async {
|
||||
await transaction(() async {
|
||||
final workId = model.work.id;
|
||||
await (delete(works)..where((w) => w.id.equals(workId))).go();
|
||||
await (delete(works)..where((w) => w.partOf.equals(workId))).go();
|
||||
|
||||
Future<void> insertWork(WorkModel model) async {
|
||||
await into(works).insert(model.work);
|
||||
await batch((b) => b.insertAll(
|
||||
instrumentations,
|
||||
model.instrumentIds
|
||||
.map((id) =>
|
||||
Instrumentation(work: model.work.id, instrument: id))
|
||||
.toList()));
|
||||
}
|
||||
|
||||
await insertWork(model);
|
||||
for (final part in parts) {
|
||||
await insertWork(part);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateEnsemble(Ensemble ensemble) async {
|
||||
await into(ensembles).insert(ensemble, orReplace: true);
|
||||
}
|
||||
|
||||
Future<void> updateRecording(
|
||||
Recording recording, List<PerformanceModel> models) async {
|
||||
await transaction(() async {
|
||||
await (delete(performances)
|
||||
..where((p) => p.recording.equals(recording.id)))
|
||||
.go();
|
||||
await into(recordings).insert(recording, orReplace: true);
|
||||
for (final model in models) {
|
||||
await into(performances).insert(Performance(
|
||||
recording: recording.id,
|
||||
person: model.person?.id,
|
||||
ensemble: model.ensemble?.id,
|
||||
role: model.role?.id,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
92
musicus/lib/database.moor
Normal file
92
musicus/lib/database.moor
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
CREATE TABLE persons (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- This represents real instruments as well as other roles that can be played
|
||||
-- in a recording.
|
||||
CREATE TABLE instruments (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE works (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
composer INTEGER REFERENCES persons(id),
|
||||
title TEXT NOT NULL,
|
||||
part_of INTEGER REFERENCES works(id) ON DELETE CASCADE,
|
||||
part_index INTEGER,
|
||||
part_level INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE instrumentations (
|
||||
work INTEGER NOT NULL REFERENCES works(id) ON DELETE CASCADE,
|
||||
instrument INTEGER NOT NULL REFERENCES instruments(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE ensembles (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE recordings (
|
||||
id INTEGER NOT NULL PRIMARY KEY,
|
||||
work INTEGER REFERENCES works(id),
|
||||
comment TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE performances (
|
||||
recording INTEGER NOT NULL REFERENCES recordings(id) ON DELETE CASCADE,
|
||||
person INTEGER REFERENCES persons(id) ON DELETE CASCADE,
|
||||
ensemble INTEGER REFERENCES ensembles(id) ON DELETE CASCADE,
|
||||
role INTEGER REFERENCES instruments(id)
|
||||
);
|
||||
|
||||
allPersons:
|
||||
SELECT * FROM persons ORDER BY last_name;
|
||||
|
||||
personById:
|
||||
SELECT * FROM persons WHERE id = :id LIMIT 1;
|
||||
|
||||
allInstruments:
|
||||
SELECT * FROM instruments ORDER BY name;
|
||||
|
||||
instrumentById:
|
||||
SELECT * FROM instruments WHERE id = :id LIMIT 1;
|
||||
|
||||
workById:
|
||||
SELECT * FROM works WHERE id = :id LIMIT 1;
|
||||
|
||||
workParts:
|
||||
SELECT * FROM works WHERE part_of = :id ORDER BY part_index;
|
||||
|
||||
-- TODO: Maybe optimize.
|
||||
worksByComposer:
|
||||
SELECT DISTINCT A.* FROM works A LEFT JOIN works B ON A.id = B.part_of
|
||||
WHERE A.part_of IS NULL AND A.composer = :id OR B.composer = :id;
|
||||
|
||||
composersByWork:
|
||||
SELECT DISTINCT persons.* FROM persons
|
||||
JOIN works ON works.composer = persons.id
|
||||
WHERE works.id = :id OR works.part_of = :id;
|
||||
|
||||
instrumentsByWork:
|
||||
SELECT instruments.* FROM instrumentations
|
||||
JOIN instruments ON instrumentations.instrument=instruments.id
|
||||
WHERE instrumentations.work = :workId;
|
||||
|
||||
allEnsembles:
|
||||
SELECT * FROM ensembles ORDER BY name;
|
||||
|
||||
ensembleById:
|
||||
SELECT * FROM ensembles WHERE id = :id LIMIT 1;
|
||||
|
||||
recordingById:
|
||||
SELECT * FROM recordings WHERE id = :id;
|
||||
|
||||
recordingsByWork:
|
||||
SELECT * FROM recordings WHERE work = :id;
|
||||
|
||||
performancesByRecording:
|
||||
SELECT * FROM performances WHERE recording = :id;
|
||||
66
musicus/lib/editors/ensemble.dart
Normal file
66
musicus/lib/editors/ensemble.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class EnsembleEditor extends StatefulWidget {
|
||||
final Ensemble ensemble;
|
||||
|
||||
EnsembleEditor({
|
||||
this.ensemble,
|
||||
});
|
||||
|
||||
@override
|
||||
_EnsembleEditorState createState() => _EnsembleEditorState();
|
||||
}
|
||||
|
||||
class _EnsembleEditorState extends State<EnsembleEditor> {
|
||||
final nameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.ensemble != null) {
|
||||
nameController.text = widget.ensemble.name;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Ensemble'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final ensemble = Ensemble(
|
||||
id: widget.ensemble?.id ?? generateId(),
|
||||
name: nameController.text,
|
||||
);
|
||||
|
||||
await backend.db.updateEnsemble(ensemble);
|
||||
Navigator.pop(context, ensemble);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
66
musicus/lib/editors/instrument.dart
Normal file
66
musicus/lib/editors/instrument.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class InstrumentEditor extends StatefulWidget {
|
||||
final Instrument instrument;
|
||||
|
||||
InstrumentEditor({
|
||||
this.instrument,
|
||||
});
|
||||
|
||||
@override
|
||||
_InstrumentEditorState createState() => _InstrumentEditorState();
|
||||
}
|
||||
|
||||
class _InstrumentEditorState extends State<InstrumentEditor> {
|
||||
final nameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.instrument != null) {
|
||||
nameController.text = widget.instrument.name;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Instrument/Role'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final instrument = Instrument(
|
||||
id: widget.instrument?.id ?? generateId(),
|
||||
name: nameController.text,
|
||||
);
|
||||
|
||||
await backend.db.updateInstrument(instrument);
|
||||
Navigator.pop(context, instrument);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
musicus/lib/editors/person.dart
Normal file
78
musicus/lib/editors/person.dart
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class PersonEditor extends StatefulWidget {
|
||||
final Person person;
|
||||
|
||||
PersonEditor({
|
||||
this.person,
|
||||
});
|
||||
|
||||
@override
|
||||
_PersonEditorState createState() => _PersonEditorState();
|
||||
}
|
||||
|
||||
class _PersonEditorState extends State<PersonEditor> {
|
||||
final firstNameController = TextEditingController();
|
||||
final lastNameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.person != null) {
|
||||
firstNameController.text = widget.person.firstName;
|
||||
lastNameController.text = widget.person.lastName;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Person'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final person = Person(
|
||||
id: widget.person?.id ?? generateId(),
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
);
|
||||
|
||||
await backend.db.updatePerson(person);
|
||||
Navigator.pop(context, person);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: firstNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'First name',
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: lastNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Last name',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
musicus/lib/editors/recording.dart
Normal file
140
musicus/lib/editors/recording.dart
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../selectors/performer.dart';
|
||||
import '../selectors/work.dart';
|
||||
import '../widgets/texts.dart';
|
||||
|
||||
class RecordingEditor extends StatefulWidget {
|
||||
final Recording recording;
|
||||
|
||||
RecordingEditor({
|
||||
this.recording,
|
||||
});
|
||||
|
||||
@override
|
||||
_RecordingEditorState createState() => _RecordingEditorState();
|
||||
}
|
||||
|
||||
class _RecordingEditorState extends State<RecordingEditor> {
|
||||
final commentController = TextEditingController();
|
||||
|
||||
Work work;
|
||||
List<PerformanceModel> performances = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.recording != null) {
|
||||
// TODO: Initialize.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
Future<void> selectWork() async {
|
||||
final Work newWork = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkSelector(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (newWork != null) {
|
||||
setState(() {
|
||||
work = newWork;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Recording'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final recording = Recording(
|
||||
id: widget.recording?.id ?? generateId(),
|
||||
work: work.id,
|
||||
comment: commentController.text,
|
||||
);
|
||||
|
||||
await backend.db.updateRecording(recording, performances);
|
||||
Navigator.pop(context, recording);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
work != null
|
||||
? ListTile(
|
||||
title: WorkText(work.id),
|
||||
subtitle: ComposersText(work.id),
|
||||
onTap: selectWork,
|
||||
)
|
||||
: ListTile(
|
||||
title: Text('Work'),
|
||||
subtitle: Text('Select work'),
|
||||
onTap: selectWork,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0,
|
||||
right: 16.0,
|
||||
top: 0.0,
|
||||
bottom: 16.0,
|
||||
),
|
||||
child: TextField(
|
||||
controller: commentController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Comment',
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Performers'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final PerformanceModel model = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PerformerSelector(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (model != null) {
|
||||
setState(() {
|
||||
performances.add(model);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
for (final performance in performances)
|
||||
ListTile(
|
||||
title: Text(performance.person != null
|
||||
? '${performance.person.firstName} ${performance.person.lastName}'
|
||||
: performance.ensemble.name),
|
||||
subtitle:
|
||||
performance.role != null ? Text(performance.role.name) : null,
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
performances.remove(performance);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
133
musicus/lib/editors/tracks.dart
Normal file
133
musicus/lib/editors/tracks.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../music_library.dart';
|
||||
import '../selectors/files.dart';
|
||||
import '../selectors/recording.dart';
|
||||
import '../widgets/recording_tile.dart';
|
||||
|
||||
class TrackModel {
|
||||
String fileName;
|
||||
|
||||
TrackModel(this.fileName);
|
||||
}
|
||||
|
||||
class TracksEditor extends StatefulWidget {
|
||||
@override
|
||||
_TracksEditorState createState() => _TracksEditorState();
|
||||
}
|
||||
|
||||
class _TracksEditorState extends State<TracksEditor> {
|
||||
int recordingId;
|
||||
String parentId;
|
||||
List<TrackModel> trackModels = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Tracks'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final List<Track> tracks = [];
|
||||
|
||||
for (var i = 0; i < trackModels.length; i++) {
|
||||
final trackModel = trackModels[i];
|
||||
|
||||
tracks.add(Track(
|
||||
fileName: trackModel.fileName,
|
||||
recordingId: recordingId,
|
||||
index: i,
|
||||
partIds: [],
|
||||
));
|
||||
}
|
||||
|
||||
backend.ml.addTracks(parentId, tracks);
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ReorderableListView(
|
||||
header: Column(
|
||||
children: <Widget>[
|
||||
recordingId != null
|
||||
? RecordingTile(
|
||||
recordingId: recordingId,
|
||||
onTap: selectRecording,
|
||||
)
|
||||
: ListTile(
|
||||
title: Text('Select recording'),
|
||||
onTap: selectRecording,
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Files'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final FilesSelectorResult result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FilesSelector(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
parentId = result.parentId;
|
||||
for (final document in result.selection) {
|
||||
trackModels.add(TrackModel(document.name));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: trackModels
|
||||
.map((t) => ListTile(
|
||||
key: Key(t.hashCode.toString()),
|
||||
title: Text(t.fileName),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
trackModels.remove(t);
|
||||
});
|
||||
},
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
onReorder: (i1, i2) {
|
||||
setState(() {
|
||||
final track = trackModels.removeAt(i1);
|
||||
final newIndex = i2 > i1 ? i2 - 1 : i2;
|
||||
trackModels.insert(newIndex, track);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectRecording() async {
|
||||
final Recording recording = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RecordingsSelector(),
|
||||
),
|
||||
);
|
||||
|
||||
if (recording != null) {
|
||||
setState(() {
|
||||
recordingId = recording.id;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
456
musicus/lib/editors/work.dart
Normal file
456
musicus/lib/editors/work.dart
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../selectors/instruments.dart';
|
||||
import '../selectors/person.dart';
|
||||
|
||||
class PartData {
|
||||
final titleController = TextEditingController();
|
||||
|
||||
int level;
|
||||
Person composer;
|
||||
List<Instrument> instruments;
|
||||
|
||||
PartData({
|
||||
String title,
|
||||
this.level = 0,
|
||||
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() onAdd;
|
||||
final void Function() onDelete;
|
||||
final void Function(int levels) onMove;
|
||||
|
||||
PartTile({
|
||||
Key key,
|
||||
@required this.part,
|
||||
@required this.onMore,
|
||||
@required this.onAdd,
|
||||
@required this.onDelete,
|
||||
@required this.onMove,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_PartTileState createState() => _PartTileState();
|
||||
}
|
||||
|
||||
class _PartTileState extends State<PartTile> {
|
||||
static const unit = 16.0;
|
||||
static const iconShrink = 4.0;
|
||||
|
||||
double dragStart;
|
||||
double dragDelta = 0.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final padding = widget.part.level * unit + dragDelta;
|
||||
final iconSize = 24 - widget.part.level * iconShrink;
|
||||
|
||||
return GestureDetector(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: padding > 0.0 ? padding : 0.0),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, right: 8.0),
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: widget.part.titleController,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Part title',
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_horiz),
|
||||
iconSize: iconSize,
|
||||
onPressed: widget.onMore,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
iconSize: iconSize,
|
||||
onPressed: widget.onAdd,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
iconSize: iconSize,
|
||||
onPressed: widget.onDelete,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onHorizontalDragStart: (details) {
|
||||
dragStart = details.localPosition.dx;
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
setState(() {
|
||||
dragDelta = details.localPosition.dx - dragStart;
|
||||
});
|
||||
},
|
||||
onHorizontalDragEnd: (details) {
|
||||
if (dragDelta.abs() >= unit) {
|
||||
widget.onMove((dragDelta / unit).round());
|
||||
}
|
||||
setState(() {
|
||||
dragDelta = 0.0;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkEditor extends StatefulWidget {
|
||||
final Work work;
|
||||
|
||||
WorkEditor({
|
||||
this.work,
|
||||
});
|
||||
|
||||
@override
|
||||
_WorkEditorState createState() => _WorkEditorState();
|
||||
}
|
||||
|
||||
class _WorkEditorState extends State<WorkEditor> {
|
||||
final titleController = TextEditingController();
|
||||
|
||||
BackendState backend;
|
||||
|
||||
String title = '';
|
||||
Person composer;
|
||||
List<Instrument> instruments = [];
|
||||
List<PartData> parts = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.work != null) {
|
||||
titleController.text = widget.work.title;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
|
||||
if (widget.work != null) {
|
||||
if (widget.work.composer != null) {
|
||||
() async {
|
||||
final person =
|
||||
await backend.db.personById(widget.work.composer).getSingle();
|
||||
|
||||
// We don't want to override a newly selected composer.
|
||||
if (composer == null) {
|
||||
setState(() {
|
||||
composer = person;
|
||||
});
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
() async {
|
||||
final selection =
|
||||
await backend.db.instrumentsByWork(widget.work.id).get();
|
||||
|
||||
// We don't want to override already selected instruments.
|
||||
if (instruments.isEmpty) {
|
||||
setState(() {
|
||||
instruments = selection;
|
||||
});
|
||||
}
|
||||
}();
|
||||
|
||||
() async {
|
||||
final dbParts = await backend.db.workParts(widget.work.id).get();
|
||||
for (final dbPart in dbParts) {
|
||||
final partInstruments =
|
||||
await backend.db.instrumentsByWork(dbPart.id).get();
|
||||
|
||||
Person partComposer;
|
||||
|
||||
if (dbPart.composer != null) {
|
||||
partComposer =
|
||||
await backend.db.personById(dbPart.composer).getSingle();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
parts.add(PartData(
|
||||
title: dbPart.title,
|
||||
composer: partComposer,
|
||||
level: dbPart.partLevel,
|
||||
instruments: partInstruments,
|
||||
));
|
||||
});
|
||||
}
|
||||
}();
|
||||
}
|
||||
}
|
||||
|
||||
void cleanLevels() {
|
||||
var previousLevel = -1;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
final part = parts[i];
|
||||
if (part.level > previousLevel + 1) {
|
||||
part.level = previousLevel + 1;
|
||||
}
|
||||
previousLevel = part.level;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<Widget> partTiles = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
final part = parts[i];
|
||||
|
||||
partTiles.add(PartTile(
|
||||
key: Key(part.hashCode.toString()),
|
||||
part: part,
|
||||
onMore: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) => Dialog(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: <Widget>[
|
||||
WorkProperties(
|
||||
titleController: part.titleController,
|
||||
composer: part.composer,
|
||||
instruments: part.instruments,
|
||||
onComposerChanged: (composer) {
|
||||
setState(() {
|
||||
part.composer = composer;
|
||||
});
|
||||
},
|
||||
onInstrumentsChanged: (instruments) {
|
||||
setState(() {
|
||||
part.instruments = instruments;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAdd: () {
|
||||
setState(() {
|
||||
parts.insert(i + 1, PartData(level: part.level + 1));
|
||||
});
|
||||
},
|
||||
onDelete: () {
|
||||
setState(() {
|
||||
parts.removeAt(i);
|
||||
cleanLevels();
|
||||
});
|
||||
},
|
||||
onMove: (levels) {
|
||||
if (levels > 0 && i > 0 && parts[i - 1].level >= part.level) {
|
||||
setState(() {
|
||||
part.level++;
|
||||
});
|
||||
} else if (levels < 0) {
|
||||
final newLevel = part.level + levels;
|
||||
setState(() {
|
||||
part.level = newLevel > 0 ? newLevel : 0;
|
||||
cleanLevels();
|
||||
});
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Work'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () async {
|
||||
final workId = widget.work?.id ?? generateId();
|
||||
|
||||
final model = WorkModel(
|
||||
work: Work(
|
||||
id: workId,
|
||||
title: titleController.text,
|
||||
composer: composer?.id,
|
||||
),
|
||||
instrumentIds: instruments.map((i) => i.id).toList(),
|
||||
);
|
||||
|
||||
final List<WorkModel> partModels = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
final part = parts[i];
|
||||
partModels.add(WorkModel(
|
||||
work: Work(
|
||||
id: generateId(),
|
||||
title: part.titleController.text,
|
||||
composer: part.composer?.id,
|
||||
partOf: workId,
|
||||
partIndex: i,
|
||||
partLevel: part.level,
|
||||
),
|
||||
instrumentIds: part.instruments.map((i) => i.id).toList(),
|
||||
));
|
||||
}
|
||||
|
||||
await backend.db.updateWork(model, partModels);
|
||||
Navigator.pop(context, model.work);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ReorderableListView(
|
||||
header: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
WorkProperties(
|
||||
titleController: titleController,
|
||||
composer: composer,
|
||||
instruments: instruments,
|
||||
onComposerChanged: (newComposer) {
|
||||
setState(() {
|
||||
composer = newComposer;
|
||||
});
|
||||
},
|
||||
onInstrumentsChanged: (newInstruments) {
|
||||
setState(() {
|
||||
instruments = newInstruments;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (parts.length > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Text(
|
||||
'Parts',
|
||||
style: Theme.of(context).textTheme.subhead,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: partTiles,
|
||||
onReorder: (i1, i2) {
|
||||
setState(() {
|
||||
final part = parts.removeAt(i1);
|
||||
final newIndex = i2 > i1 ? i2 - 1 : i2;
|
||||
|
||||
parts.insert(newIndex, part);
|
||||
|
||||
cleanLevels();
|
||||
});
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('Add part'),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
parts.add(PartData(level: 0));
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
musicus/lib/main.dart
Normal file
13
musicus/lib/main.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'backend.dart';
|
||||
|
||||
void main() {
|
||||
runApp(AudioServiceWidget(
|
||||
child: Backend(
|
||||
child: App(),
|
||||
),
|
||||
));
|
||||
}
|
||||
165
musicus/lib/music_library.dart
Normal file
165
musicus/lib/music_library.dart
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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;
|
||||
|
||||
String uri;
|
||||
|
||||
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) {
|
||||
final uri =
|
||||
await Platform.getUriByName(treeUri, parentId, track.fileName);
|
||||
track.uri = uri;
|
||||
|
||||
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) {
|
||||
final uri =
|
||||
await Platform.getUriByName(treeUri, parentId, track.fileName);
|
||||
track.uri = uri;
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
113
musicus/lib/platform.dart
Normal file
113
musicus/lib/platform.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Object representing a document in Storage Access Framework terms.
|
||||
class Document {
|
||||
/// Unique document ID given by the SAF.
|
||||
final String id;
|
||||
|
||||
/// Name of the document (i.e. file name).
|
||||
final String name;
|
||||
|
||||
/// Document ID of the parent document.
|
||||
final String parent;
|
||||
|
||||
/// Whether this document represents a directory.
|
||||
final bool isDirectory;
|
||||
|
||||
// Use Map<dynamic, dynamic> here, as we get casting errors otherwise. This
|
||||
// won't be typesafe anyway.
|
||||
Document.fromJson(Map<dynamic, dynamic> json)
|
||||
: id = json['id'],
|
||||
name = json['name'],
|
||||
parent = json['parent'],
|
||||
isDirectory = json['isDirectory'];
|
||||
}
|
||||
|
||||
/// Collection of methods that are implemented platform dependent.
|
||||
class Platform {
|
||||
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
||||
|
||||
/// Get child documents.
|
||||
///
|
||||
/// [treeId] is the base URI as requested from the SAF.
|
||||
/// [parentId] is the document ID of the parent. If this is null, the children
|
||||
/// of the tree base will be returned.
|
||||
static Future<List<Document>> getChildren(
|
||||
String treeUri, String parentId) async {
|
||||
final List<Map<dynamic, dynamic>> childrenJson =
|
||||
await _platform.invokeListMethod(
|
||||
'getChildren',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'parentId': parentId,
|
||||
},
|
||||
);
|
||||
|
||||
return childrenJson
|
||||
.map((childJson) => Document.fromJson(childJson))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Read contents of file.
|
||||
///
|
||||
/// [treeId] is the base URI from the SAF, [id] is the document ID of the
|
||||
/// file.
|
||||
static Future<String> readFile(String treeUri, String id) async {
|
||||
return await _platform.invokeMethod(
|
||||
'readFile',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'id': id,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Get document URI by file name
|
||||
///
|
||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
||||
/// the parent directory.
|
||||
static Future<String> getUriByName(
|
||||
String treeUri, String parentId, String fileName) async {
|
||||
return await _platform.invokeMethod(
|
||||
'getUriByName',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'parentId': parentId,
|
||||
'fileName': fileName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Read contents of file by name
|
||||
///
|
||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
||||
/// the parent directory.
|
||||
static Future<String> readFileByName(
|
||||
String treeUri, String parentId, String fileName) async {
|
||||
return await _platform.invokeMethod(
|
||||
'readFileByName',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'parentId': parentId,
|
||||
'fileName': fileName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Write to file by name
|
||||
///
|
||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
||||
/// the parent directory.
|
||||
static Future<void> writeFileByName(
|
||||
String treeUri, String parentId, String fileName, String content) async {
|
||||
await _platform.invokeMethod(
|
||||
'writeFileByName',
|
||||
{
|
||||
'treeUri': treeUri,
|
||||
'parentId': parentId,
|
||||
'fileName': fileName,
|
||||
'content': content,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
249
musicus/lib/player.dart
Normal file
249
musicus/lib/player.dart
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
/// Entrypoint for the playback service.
|
||||
void _playbackServiceEntrypoint() {
|
||||
AudioServiceBackground.run(() => _PlaybackService());
|
||||
}
|
||||
|
||||
class Player {
|
||||
/// The interval between playback position updates in milliseconds.
|
||||
static const positionUpdateInterval = 250;
|
||||
|
||||
/// 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);
|
||||
|
||||
/// 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);
|
||||
|
||||
/// The current position in milliseconds.
|
||||
int _positionMs = 0;
|
||||
|
||||
StreamSubscription<PlaybackState> _stateStreamSubscription;
|
||||
StreamSubscription<MediaItem> _mediaItemStreamSubscription;
|
||||
|
||||
/// Update [position] and [normalizedPosition] according to [_positionMs].
|
||||
void _updatePosition() {
|
||||
position.add(Duration(milliseconds: _positionMs));
|
||||
normalizedPosition.add(_positionMs / duration.value.inMilliseconds);
|
||||
}
|
||||
|
||||
/// Set everything to its default because the playback service was stopped.
|
||||
void _stop() {
|
||||
active.add(false);
|
||||
playing.add(false);
|
||||
position.add(const Duration());
|
||||
duration.add(const Duration(seconds: 1));
|
||||
normalizedPosition.add(0.0);
|
||||
_positionMs = 0;
|
||||
_stateStreamSubscription.cancel();
|
||||
_mediaItemStreamSubscription.cancel();
|
||||
}
|
||||
|
||||
/// Start playback service.
|
||||
Future<void> start() async {
|
||||
if (!AudioService.running) {
|
||||
await AudioService.start(
|
||||
backgroundTaskEntrypoint: _playbackServiceEntrypoint,
|
||||
androidNotificationChannelName: 'Musicus playback',
|
||||
androidNotificationChannelDescription:
|
||||
'Keeps Musicus playing in the background',
|
||||
androidNotificationIcon: 'drawable/ic_notification',
|
||||
);
|
||||
|
||||
setup();
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect listeners and initialize streams.
|
||||
void setup() {
|
||||
if (AudioService.running) {
|
||||
active.add(true);
|
||||
|
||||
_stateStreamSubscription =
|
||||
AudioService.playbackStateStream.listen((playbackState) {
|
||||
if (playbackState != null) {
|
||||
if (playbackState.basicState == BasicPlaybackState.stopped) {
|
||||
_stop();
|
||||
} else {
|
||||
if (playbackState.basicState == BasicPlaybackState.playing) {
|
||||
playing.add(true);
|
||||
_play();
|
||||
} else {
|
||||
playing.add(false);
|
||||
}
|
||||
|
||||
_positionMs = playbackState.currentPosition;
|
||||
_updatePosition();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_mediaItemStreamSubscription =
|
||||
AudioService.currentMediaItemStream.listen((mediaItem) {
|
||||
if (mediaItem?.duration != null) {
|
||||
duration.add(Duration(milliseconds: mediaItem.duration));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle whether the player is playing or paused.
|
||||
///
|
||||
/// If the player is not active, this will do nothing.
|
||||
Future<void> playPause() async {
|
||||
if (active.value) {
|
||||
if (playing.value) {
|
||||
await AudioService.pause();
|
||||
} else {
|
||||
await AudioService.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regularly update [_positionMs] while playing.
|
||||
// TODO: Maybe find a better approach on handling this.
|
||||
Future<void> _play() async {
|
||||
while (playing.value) {
|
||||
await Future.delayed(Duration(milliseconds: positionUpdateInterval));
|
||||
_positionMs += positionUpdateInterval;
|
||||
_updatePosition();
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek to [pos], which is a value between (and including) zero and one.
|
||||
///
|
||||
/// If the player is not active or an invalid value is provided, this will do
|
||||
/// nothing.
|
||||
Future<void> seekTo(double pos) async {
|
||||
if (active.value && pos >= 0.0 && pos <= 1.0) {
|
||||
final durationMs = duration.value.inMilliseconds;
|
||||
await AudioService.seekTo((pos * durationMs).floor());
|
||||
}
|
||||
}
|
||||
|
||||
/// Tidy up.
|
||||
void dispose() {
|
||||
_stateStreamSubscription.cancel();
|
||||
_mediaItemStreamSubscription.cancel();
|
||||
|
||||
active.close();
|
||||
playing.close();
|
||||
position.close();
|
||||
duration.close();
|
||||
normalizedPosition.close();
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaybackService extends BackgroundAudioTask {
|
||||
static const playControl = MediaControl(
|
||||
androidIcon: 'drawable/ic_play',
|
||||
label: 'Play',
|
||||
action: MediaAction.play,
|
||||
);
|
||||
|
||||
static const pauseControl = MediaControl(
|
||||
androidIcon: 'drawable/ic_pause',
|
||||
label: 'Pause',
|
||||
action: MediaAction.pause,
|
||||
);
|
||||
|
||||
static const stopControl = MediaControl(
|
||||
androidIcon: 'drawable/ic_stop',
|
||||
label: 'Stop',
|
||||
action: MediaAction.stop,
|
||||
);
|
||||
|
||||
static const dummyMediaItem = MediaItem(
|
||||
id: 'dummy',
|
||||
album: 'Johannes Brahms',
|
||||
title: 'Symphony No. 1 in C minor, Op. 68: 1. Un poco sostenuto — Allegro',
|
||||
duration: 10000,
|
||||
);
|
||||
|
||||
final _completer = Completer();
|
||||
|
||||
int _position;
|
||||
int _updateTime;
|
||||
bool _playing = false;
|
||||
|
||||
void _setPosition(int position) {
|
||||
_position = position;
|
||||
_updateTime = DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
|
||||
void _setState() {
|
||||
AudioServiceBackground.setState(
|
||||
controls:
|
||||
_playing ? [pauseControl, stopControl] : [playControl, stopControl],
|
||||
basicState:
|
||||
_playing ? BasicPlaybackState.playing : BasicPlaybackState.paused,
|
||||
position: _position,
|
||||
updateTime: _updateTime,
|
||||
);
|
||||
|
||||
AudioServiceBackground.setMediaItem(dummyMediaItem);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onStart() async {
|
||||
_setPosition(0);
|
||||
_setState();
|
||||
await _completer.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void onPlay() {
|
||||
super.onPlay();
|
||||
|
||||
_playing = true;
|
||||
_setState();
|
||||
}
|
||||
|
||||
@override
|
||||
void onPause() {
|
||||
super.onPause();
|
||||
|
||||
_playing = false;
|
||||
_setState();
|
||||
}
|
||||
|
||||
@override
|
||||
void onSeekTo(int position) {
|
||||
super.onSeekTo(position);
|
||||
|
||||
_setPosition(position);
|
||||
_setState();
|
||||
}
|
||||
|
||||
@override
|
||||
void onStop() {
|
||||
AudioServiceBackground.setState(
|
||||
controls: [],
|
||||
basicState: BasicPlaybackState.stopped,
|
||||
);
|
||||
|
||||
// This will end onStart.
|
||||
_completer.complete();
|
||||
}
|
||||
}
|
||||
87
musicus/lib/screens/home.dart
Normal file
87
musicus/lib/screens/home.dart
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/tracks.dart';
|
||||
|
||||
import 'person.dart';
|
||||
import 'settings.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Musicus'),
|
||||
actions: <Widget>[
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 0,
|
||||
child: Text('Start player'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 1,
|
||||
child: Text('Add tracks'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 2,
|
||||
child: Text('Settings'),
|
||||
),
|
||||
],
|
||||
onSelected: (selected) {
|
||||
if (selected == 0) {
|
||||
backend.player.start();
|
||||
} else if (selected == 1) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TracksEditor(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
} else if (selected == 2) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SettingsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// For debugging purposes
|
||||
body: StreamBuilder<List<Person>>(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: Text('${person.lastName}, ${person.firstName}'),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonScreen(
|
||||
person: person,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
musicus/lib/screens/person.dart
Normal file
68
musicus/lib/screens/person.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/person.dart';
|
||||
|
||||
import 'work.dart';
|
||||
|
||||
class PersonScreen extends StatelessWidget {
|
||||
final Person person;
|
||||
|
||||
PersonScreen({
|
||||
this.person,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('${person.firstName} ${person.lastName}'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonEditor(
|
||||
person: person,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<List<Work>>(
|
||||
stream: backend.db.worksByComposer(person.id).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final work = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: Text(work.title),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkScreen(
|
||||
work: work,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
135
musicus/lib/screens/program.dart
Normal file
135
musicus/lib/screens/program.dart
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../widgets/play_pause_button.dart';
|
||||
|
||||
class ProgramScreen extends StatefulWidget {
|
||||
@override
|
||||
_ProgramScreenState createState() => _ProgramScreenState();
|
||||
}
|
||||
|
||||
class _ProgramScreenState extends State<ProgramScreen> {
|
||||
BackendState backend;
|
||||
StreamSubscription<double> positionSubscription;
|
||||
double position = 0.0;
|
||||
bool seeking = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
|
||||
if (positionSubscription != null) {
|
||||
positionSubscription.cancel();
|
||||
}
|
||||
|
||||
positionSubscription = backend.player.normalizedPosition.listen((pos) {
|
||||
if (!seeking) {
|
||||
setState(() {
|
||||
position = pos;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.keyboard_arrow_down),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text('Program'),
|
||||
),
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Slider(
|
||||
value: position,
|
||||
onChangeStart: (_) {
|
||||
seeking = true;
|
||||
},
|
||||
onChangeEnd: (pos) {
|
||||
seeking = false;
|
||||
backend.player.seekTo(pos);
|
||||
},
|
||||
onChanged: (pos) {
|
||||
setState(() {
|
||||
position = pos;
|
||||
});
|
||||
},
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24.0),
|
||||
child: StreamBuilder<Duration>(
|
||||
stream: backend.player.position,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return DurationText(snapshot.data);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: () {},
|
||||
),
|
||||
PlayPauseButton(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: () {},
|
||||
),
|
||||
Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 20.0),
|
||||
child: StreamBuilder<Duration>(
|
||||
stream: backend.player.duration,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return DurationText(snapshot.data);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
positionSubscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class DurationText extends StatelessWidget {
|
||||
final Duration duration;
|
||||
|
||||
DurationText(this.duration);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final minutes = duration.inMinutes;
|
||||
final seconds = (duration - Duration(minutes: minutes)).inSeconds;
|
||||
|
||||
final secondsString = seconds >= 10 ? seconds.toString() : '0$seconds';
|
||||
|
||||
return Text('$minutes:$secondsString');
|
||||
}
|
||||
}
|
||||
28
musicus/lib/screens/settings.dart
Normal file
28
musicus/lib/screens/settings.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: Icon(Icons.library_music),
|
||||
title: Text('Music library path'),
|
||||
subtitle: Text(backend.musicLibraryUri),
|
||||
onTap: () {
|
||||
backend.chooseMusicLibraryUri();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
musicus/lib/screens/work.dart
Normal file
62
musicus/lib/screens/work.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/work.dart';
|
||||
import '../widgets/texts.dart';
|
||||
|
||||
class WorkScreen extends StatelessWidget {
|
||||
final Work work;
|
||||
|
||||
WorkScreen({
|
||||
this.work,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(work.title),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkEditor(
|
||||
work: work,
|
||||
),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: StreamBuilder<List<Recording>>(
|
||||
stream: backend.db.recordingsByWork(work.id).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recording = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: PerformancesText(recording.id),
|
||||
onTap: () async {
|
||||
// TODO: Play recording.
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
162
musicus/lib/selectors/files.dart
Normal file
162
musicus/lib/selectors/files.dart
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../platform.dart';
|
||||
|
||||
/// Result of the user's interaction with the files selector.
|
||||
///
|
||||
/// This will be given back when popping the navigator.
|
||||
class FilesSelectorResult {
|
||||
/// Document ID of the parent directory of the selected files.
|
||||
///
|
||||
/// This will be null, if they are in the toplevel directory.
|
||||
final String parentId;
|
||||
|
||||
/// Selected files.
|
||||
final Set<Document> selection;
|
||||
|
||||
FilesSelectorResult(this.parentId, this.selection);
|
||||
}
|
||||
|
||||
class FilesSelector extends StatefulWidget {
|
||||
@override
|
||||
_FilesSelectorState createState() => _FilesSelectorState();
|
||||
}
|
||||
|
||||
class _FilesSelectorState extends State<FilesSelector> {
|
||||
BackendState backend;
|
||||
List<Document> history = [];
|
||||
List<Document> children = [];
|
||||
Set<Document> selection = {};
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
loadChildren();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Choose files'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () {
|
||||
Navigator.pop(
|
||||
context,
|
||||
FilesSelectorResult(
|
||||
history.isNotEmpty ? history.last.id : null,
|
||||
selection,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
onPressed: history.isNotEmpty ? up : null,
|
||||
),
|
||||
title: Text(
|
||||
history.isNotEmpty ? history.last.name : 'Music library'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: children.length,
|
||||
itemBuilder: (context, index) {
|
||||
final document = children[index];
|
||||
|
||||
if (document.isDirectory) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.folder),
|
||||
title: Text(document.name),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
history.add(document);
|
||||
});
|
||||
loadChildren();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return CheckboxListTile(
|
||||
controlAffinity: ListTileControlAffinity.trailing,
|
||||
secondary: const Icon(Icons.insert_drive_file),
|
||||
title: Text(document.name),
|
||||
value: selection.contains(document),
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
selection.add(document);
|
||||
} else {
|
||||
selection.remove(document);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onWillPop: () => Future.value(up()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadChildren() async {
|
||||
setState(() {
|
||||
children = [];
|
||||
|
||||
// We reset the selection here, because the user should not be able to
|
||||
// select files from multiple directories for now.
|
||||
selection = {};
|
||||
});
|
||||
|
||||
final newChildren = await Platform.getChildren(
|
||||
backend.musicLibraryUri, history.isNotEmpty ? history.last.id : null);
|
||||
|
||||
newChildren.sort((d1, d2) {
|
||||
if (d1.isDirectory != d2.isDirectory) {
|
||||
return d1.isDirectory ? -1 : 1;
|
||||
} else {
|
||||
return d1.name.compareTo(d2.name);
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
children = newChildren;
|
||||
});
|
||||
}
|
||||
|
||||
bool up() {
|
||||
if (history.isNotEmpty) {
|
||||
setState(() {
|
||||
history.removeLast();
|
||||
});
|
||||
|
||||
loadChildren();
|
||||
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
108
musicus/lib/selectors/instruments.dart
Normal file
108
musicus/lib/selectors/instruments.dart
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/instrument.dart';
|
||||
|
||||
class InstrumentsSelector extends StatefulWidget {
|
||||
final bool multiple;
|
||||
final List<Instrument> selection;
|
||||
|
||||
InstrumentsSelector({
|
||||
this.multiple = false,
|
||||
this.selection,
|
||||
});
|
||||
|
||||
@override
|
||||
_InstrumentsSelectorState createState() => _InstrumentsSelectorState();
|
||||
}
|
||||
|
||||
class _InstrumentsSelectorState extends State<InstrumentsSelector> {
|
||||
Set<Instrument> selection = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.selection != null) {
|
||||
selection = widget.selection.toSet();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.multiple ? 'Select instruments/roles' : 'Select instrument/role'),
|
||||
actions: widget.multiple
|
||||
? <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () => Navigator.pop(context, selection.toList()),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
body: StreamBuilder(
|
||||
stream: backend.db.allInstruments().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final instrument = snapshot.data[index];
|
||||
|
||||
if (widget.multiple) {
|
||||
return CheckboxListTile(
|
||||
title: Text(instrument.name),
|
||||
value: selection.contains(instrument),
|
||||
checkColor: Colors.black,
|
||||
onChanged: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
selection.add(instrument);
|
||||
} else {
|
||||
selection.remove(instrument);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return ListTile(
|
||||
title: Text(instrument.name),
|
||||
onTap: () => Navigator.pop(context, instrument),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final Instrument instrument = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InstrumentEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (instrument != null) {
|
||||
if (widget.multiple) {
|
||||
setState(() {
|
||||
selection.add(instrument);
|
||||
});
|
||||
} else {
|
||||
Navigator.pop(context, instrument);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
200
musicus/lib/selectors/performer.dart
Normal file
200
musicus/lib/selectors/performer.dart
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/ensemble.dart';
|
||||
import '../editors/person.dart';
|
||||
|
||||
import 'instruments.dart';
|
||||
|
||||
class PerformerSelector extends StatefulWidget {
|
||||
@override
|
||||
_PerformerSelectorState createState() => _PerformerSelectorState();
|
||||
}
|
||||
|
||||
class _Selection {
|
||||
final bool isPerson;
|
||||
final Person person;
|
||||
final Ensemble ensemble;
|
||||
|
||||
_Selection.person(this.person)
|
||||
: isPerson = true,
|
||||
ensemble = null;
|
||||
|
||||
_Selection.ensemble(this.ensemble)
|
||||
: isPerson = false,
|
||||
person = null;
|
||||
}
|
||||
|
||||
class _PerformerSelectorState extends State<PerformerSelector> {
|
||||
Instrument role;
|
||||
_Selection selection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select performer'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('DONE'),
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
PerformanceModel(
|
||||
person: selection?.person,
|
||||
ensemble: selection?.ensemble,
|
||||
role: role,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
title: Text('Instrument/Role'),
|
||||
subtitle:
|
||||
Text(role != null ? role.name : 'Select instrument/role'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
role = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
onTap: () async {
|
||||
final Instrument newRole = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InstrumentsSelector(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (newRole != null) {
|
||||
setState(() {
|
||||
role = newRole;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
StreamBuilder<List<Person>>(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data.isNotEmpty) {
|
||||
return ExpansionTile(
|
||||
initiallyExpanded: true,
|
||||
title: Text('Persons'),
|
||||
children: snapshot.data
|
||||
.map((person) => RadioListTile<Person>(
|
||||
title: Text(
|
||||
'${person.lastName}, ${person.firstName}'),
|
||||
value: person,
|
||||
groupValue: selection?.person,
|
||||
onChanged: (person) {
|
||||
setState(() {
|
||||
selection = _Selection.person(person);
|
||||
});
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
StreamBuilder<List<Ensemble>>(
|
||||
stream: backend.db.allEnsembles().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data.isNotEmpty) {
|
||||
return ExpansionTile(
|
||||
initiallyExpanded: true,
|
||||
title: Text('Ensembles'),
|
||||
children: snapshot.data
|
||||
.map((ensemble) => RadioListTile<Ensemble>(
|
||||
title: Text(ensemble.name),
|
||||
value: ensemble,
|
||||
groupValue: selection?.ensemble,
|
||||
onChanged: (ensemble) {
|
||||
setState(() {
|
||||
selection = _Selection.ensemble(ensemble);
|
||||
});
|
||||
},
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: Text('Add person'),
|
||||
onTap: () async {
|
||||
final Person person = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (person != null) {
|
||||
setState(() {
|
||||
selection = _Selection.person(person);
|
||||
});
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add),
|
||||
title: Text('Add ensemble'),
|
||||
onTap: () async {
|
||||
final Ensemble ensemble = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => EnsembleEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (ensemble != null) {
|
||||
setState(() {
|
||||
selection = _Selection.ensemble(ensemble);
|
||||
});
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
musicus/lib/selectors/person.dart
Normal file
53
musicus/lib/selectors/person.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/person.dart';
|
||||
|
||||
class PersonsSelector extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select person'),
|
||||
),
|
||||
body: StreamBuilder(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = snapshot.data[index];
|
||||
|
||||
return ListTile(
|
||||
title: Text('${person.lastName}, ${person.firstName}'),
|
||||
onTap: () => Navigator.pop(context, person),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final Person person = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PersonEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (person != null) {
|
||||
Navigator.pop(context, person);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
211
musicus/lib/selectors/recording.dart
Normal file
211
musicus/lib/selectors/recording.dart
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/recording.dart';
|
||||
import '../widgets/texts.dart';
|
||||
import '../widgets/works_by_composer.dart';
|
||||
|
||||
class PersonList extends StatelessWidget {
|
||||
final void Function(int personId) onSelect;
|
||||
|
||||
PersonList({
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
title: Text('Composers'),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Person>>(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: Text('${person.lastName}, ${person.firstName}'),
|
||||
onTap: () => onSelect(person.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WorkList extends StatelessWidget {
|
||||
final int composerId;
|
||||
final void Function(int workId) onSelect;
|
||||
|
||||
WorkList({
|
||||
this.composerId,
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: PersonText(composerId),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: WorksByComposer(
|
||||
personId: composerId,
|
||||
onTap: (selectedWork) => onSelect(selectedWork.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingList extends StatelessWidget {
|
||||
final int workId;
|
||||
final void Function(Recording recording) onSelect;
|
||||
|
||||
RecordingList({
|
||||
this.workId,
|
||||
this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Material(
|
||||
elevation: 2.0,
|
||||
child: ListTile(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: WorkText(workId),
|
||||
subtitle: ComposersText(workId),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Recording>>(
|
||||
stream: backend.db.recordingsByWork(workId).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recording = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: PerformancesText(recording.id),
|
||||
onTap: () => onSelect(recording),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingsSelector extends StatefulWidget {
|
||||
@override
|
||||
_RecordingsSelectorState createState() => _RecordingsSelectorState();
|
||||
}
|
||||
|
||||
class _RecordingsSelectorState extends State<RecordingsSelector> {
|
||||
final nestedNavigator = GlobalKey<NavigatorState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This exists to circumvent the nested navigator when selecting a
|
||||
// recording.
|
||||
void popUpperNavigator(Recording recording) {
|
||||
Navigator.pop(context, recording);
|
||||
}
|
||||
|
||||
return WillPopScope(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text('Select recording'),
|
||||
),
|
||||
body: Navigator(
|
||||
key: nestedNavigator,
|
||||
onGenerateRoute: (settings) => settings.name == '/'
|
||||
? MaterialPageRoute(
|
||||
builder: (context) => PersonList(
|
||||
onSelect: (personId) => nestedNavigator.currentState.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkList(
|
||||
composerId: personId,
|
||||
onSelect: (workId) =>
|
||||
nestedNavigator.currentState.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RecordingList(
|
||||
workId: workId,
|
||||
onSelect: (recording) =>
|
||||
popUpperNavigator(recording),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
initialRoute: '/',
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final recording = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RecordingEditor(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (recording != null) {
|
||||
Navigator.pop(context, recording);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onWillPop: () async => !(await nestedNavigator.currentState.maybePop()),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
musicus/lib/selectors/work.dart
Normal file
71
musicus/lib/selectors/work.dart
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
import '../editors/work.dart';
|
||||
|
||||
// TODO: Lazy load works and/or optimize queries.
|
||||
class WorkSelector extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Select work'),
|
||||
),
|
||||
body: StreamBuilder<List<Person>>(
|
||||
stream: backend.db.allPersons().watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final person = snapshot.data[index];
|
||||
final title = Text('${person.lastName}, ${person.firstName}');
|
||||
return StreamBuilder<List<Work>>(
|
||||
stream: backend.db.worksByComposer(person.id).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data.isNotEmpty) {
|
||||
return ExpansionTile(
|
||||
title: title,
|
||||
children: <Widget>[
|
||||
for (final work in snapshot.data)
|
||||
ListTile(
|
||||
title: Text(work.title),
|
||||
onTap: () => Navigator.pop(context, work),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return ListTile(
|
||||
title: title,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
final Work work = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => WorkEditor(),
|
||||
fullscreenDialog: true,
|
||||
));
|
||||
|
||||
if (work != null) {
|
||||
Navigator.pop(context, work);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
59
musicus/lib/widgets/play_pause_button.dart
Normal file
59
musicus/lib/widgets/play_pause_button.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
|
||||
class PlayPauseButton extends StatefulWidget {
|
||||
@override
|
||||
_PlayPauseButtonState createState() => _PlayPauseButtonState();
|
||||
}
|
||||
|
||||
class _PlayPauseButtonState extends State<PlayPauseButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
AnimationController playPauseAnimation;
|
||||
BackendState backend;
|
||||
StreamSubscription<bool> playingSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
playPauseAnimation = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
backend = Backend.of(context);
|
||||
playPauseAnimation.value = backend.player.playing.value ? 1.0 : 0.0;
|
||||
|
||||
if (playingSubscription != null) {
|
||||
playingSubscription.cancel();
|
||||
}
|
||||
|
||||
playingSubscription = backend.player.playing.listen((playing) =>
|
||||
playing ? playPauseAnimation.forward() : playPauseAnimation.reverse());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: AnimatedIcon(
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: playPauseAnimation,
|
||||
),
|
||||
onPressed: backend.player.playPause,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
playingSubscription.cancel();
|
||||
}
|
||||
}
|
||||
56
musicus/lib/widgets/player_bar.dart
Normal file
56
musicus/lib/widgets/player_bar.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../screens/program.dart';
|
||||
|
||||
import 'play_pause_button.dart';
|
||||
|
||||
class PlayerBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return BottomAppBar(
|
||||
child: InkWell(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
StreamBuilder(
|
||||
stream: backend.player.normalizedPosition,
|
||||
builder: (context, snapshot) => LinearProgressIndicator(
|
||||
value: snapshot.data,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Icon(Icons.keyboard_arrow_up),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Composer',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text('Work: Movement'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PlayPauseButton(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProgramScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
musicus/lib/widgets/recording_tile.dart
Normal file
56
musicus/lib/widgets/recording_tile.dart
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
import 'texts.dart';
|
||||
|
||||
class RecordingTile extends StatelessWidget {
|
||||
final int recordingId;
|
||||
final void Function() onTap;
|
||||
|
||||
RecordingTile({
|
||||
this.recordingId,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return StreamBuilder<Recording>(
|
||||
stream: backend.db.recordingById(recordingId).watchSingle(),
|
||||
builder: (context, snapshot) => ListTile(
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
if (snapshot.hasData) ...[
|
||||
DefaultTextStyle(
|
||||
style: textTheme.subtitle1,
|
||||
child: ComposersText(snapshot.data.work),
|
||||
),
|
||||
DefaultTextStyle(
|
||||
style: textTheme.headline6,
|
||||
child: WorkText(snapshot.data.work),
|
||||
),
|
||||
],
|
||||
const SizedBox(
|
||||
height: 4.0,
|
||||
),
|
||||
DefaultTextStyle(
|
||||
style: textTheme.bodyText1,
|
||||
child: PerformancesText(recordingId),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
musicus/lib/widgets/texts.dart
Normal file
153
musicus/lib/widgets/texts.dart
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class EnsembleText extends StatelessWidget {
|
||||
final int ensembleId;
|
||||
|
||||
EnsembleText(this.ensembleId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<Ensemble>(
|
||||
stream: backend.db.ensembleById(ensembleId).watchSingle(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(snapshot.data.name);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PersonText extends StatelessWidget {
|
||||
final int personId;
|
||||
|
||||
PersonText(this.personId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<Person>(
|
||||
stream: backend.db.personById(personId).watchSingle(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text('${snapshot.data.firstName} ${snapshot.data.lastName}');
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PerformancesText extends StatefulWidget {
|
||||
final int recordingId;
|
||||
|
||||
PerformancesText(this.recordingId);
|
||||
|
||||
@override
|
||||
_PerformancesTextState createState() => _PerformancesTextState();
|
||||
}
|
||||
|
||||
class _PerformancesTextState extends State<PerformancesText> {
|
||||
BackendState backend;
|
||||
StreamSubscription<List<Performance>> performancesSubscription;
|
||||
String text = '...';
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
performancesSubscription?.cancel();
|
||||
backend = Backend.of(context);
|
||||
|
||||
performancesSubscription = backend.db
|
||||
.performancesByRecording(widget.recordingId)
|
||||
.watch()
|
||||
.listen((performances) async {
|
||||
final List<String> texts = [];
|
||||
|
||||
for (final performance in performances) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (performance.person != null) {
|
||||
final person =
|
||||
await backend.db.personById(performance.person).getSingle();
|
||||
buffer.write('${person.firstName} ${person.lastName}');
|
||||
} else if (performance.ensemble != null) {
|
||||
final ensemble =
|
||||
await backend.db.ensembleById(performance.ensemble).getSingle();
|
||||
buffer.write(ensemble.name);
|
||||
} else {
|
||||
buffer.write('Unknown');
|
||||
}
|
||||
|
||||
if (performance.role != null) {
|
||||
final role =
|
||||
await backend.db.instrumentById(performance.role).getSingle();
|
||||
buffer.write(' (${role.name})');
|
||||
}
|
||||
|
||||
texts.add(buffer.toString());
|
||||
}
|
||||
|
||||
setState(() {
|
||||
text = texts.join(', ');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(text);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
performancesSubscription?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
class WorkText extends StatelessWidget {
|
||||
final int workId;
|
||||
|
||||
WorkText(this.workId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<Work>(
|
||||
stream: backend.db.workById(workId).watchSingle(),
|
||||
builder: (context, snapshot) => Text(snapshot.data?.title ?? '...'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ComposersText extends StatelessWidget {
|
||||
final int workId;
|
||||
|
||||
ComposersText(this.workId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<List<Person>>(
|
||||
stream: backend.db.composersByWork(workId).watch(),
|
||||
builder: (context, snapshot) => Text(snapshot.hasData
|
||||
? snapshot.data.map((p) => '${p.firstName} ${p.lastName}').join(', ')
|
||||
: '...'),
|
||||
);
|
||||
}
|
||||
}
|
||||
39
musicus/lib/widgets/works_by_composer.dart
Normal file
39
musicus/lib/widgets/works_by_composer.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../backend.dart';
|
||||
import '../database.dart';
|
||||
|
||||
class WorksByComposer extends StatelessWidget {
|
||||
final int personId;
|
||||
final void Function(Work work) onTap;
|
||||
|
||||
WorksByComposer({
|
||||
this.personId,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backend = Backend.of(context);
|
||||
|
||||
return StreamBuilder<List<Work>>(
|
||||
stream: backend.db.worksByComposer(personId).watch(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final work = snapshot.data[index];
|
||||
return ListTile(
|
||||
title: Text(work.title),
|
||||
onTap: () => onTap(work),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue