Move more code from mobile to common

This commit is contained in:
Elias Projahn 2020-07-18 11:54:49 +02:00
parent 2e4f69a178
commit 5312bad52d
28 changed files with 258 additions and 215 deletions

25
common/assets/about.md Normal file
View file

@ -0,0 +1,25 @@
# Introduction
Musicus is a classical music player and organizer.
# Contact
Please contact me [via e-mail](mailto:johrpan@gmail.com?subject=Musicus), if
you have any questions or need help. I'm also open to ideas for the future of
Musicus! Musicus is free and open source software. You can study the source
code and contribute to it on [GitHub](https://github.com/johrpan/musicus).
# License
© 20192020 Elias Projahn
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, either version 3 of the License, or (at your option) any
later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the
[GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html)
for more details.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,23 +1,4 @@
export 'src/editors/ensemble.dart';
export 'src/editors/instrument.dart';
export 'src/editors/performance.dart';
export 'src/editors/person.dart';
export 'src/editors/recording.dart';
export 'src/editors/tracks.dart';
export 'src/editors/work.dart';
export 'src/selectors/ensemble.dart';
export 'src/selectors/files.dart';
export 'src/selectors/instruments.dart';
export 'src/selectors/person.dart';
export 'src/selectors/recording.dart';
export 'src/selectors/work.dart';
export 'src/widgets/lists.dart';
export 'src/widgets/recording_tile.dart';
export 'src/widgets/texts.dart';
export 'src/backend.dart';
export 'src/app.dart';
export 'src/library.dart';
export 'src/platform.dart';
export 'src/playback.dart';

188
common/lib/src/app.dart Normal file
View file

@ -0,0 +1,188 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'backend.dart';
import 'screens/home.dart';
import 'settings.dart';
import 'platform.dart';
import 'playback.dart';
import 'widgets/player_bar.dart';
/// The classical music player and organizer.
///
/// This widget is the cross platform abstraction for a whole Musicus app. The
/// properties should be implemented seperately for each platform.
class MusicusApp extends StatelessWidget {
/// Path to the database file.
final String dbPath;
/// An object to persist the settings.
final MusicusSettingsStorage settingsStorage;
/// An object handling playback.
final MusicusPlayback playback;
/// An object handling platform dependent functionality.
final MusicusPlatform platform;
MusicusApp({
@required this.dbPath,
@required this.settingsStorage,
@required this.playback,
@required this.platform,
});
@override
Widget build(BuildContext context) {
return MusicusBackend(
dbPath: dbPath,
settingsStorage: settingsStorage,
playback: playback,
platform: platform,
child: Builder(
builder: (context) {
final backend = MusicusBackend.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,
),
snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey[800],
contentTextStyle: TextStyle(
color: Colors.white,
),
behavior: SnackBarBehavior.floating,
),
fontFamily: 'Libertinus Sans',
),
home: Builder(
builder: (context) {
if (backend.status == MusicusBackendStatus.loading) {
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
);
} else if (backend.status == MusicusBackendStatus.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: () async {
final uri = await platform.chooseBasePath();
if (uri != null) {
backend.settings.setMusicLibraryPath(uri);
}
},
),
],
),
);
} else {
return Content();
}
},
),
);
},
),
);
}
}
class Content extends StatefulWidget {
@override
_ContentState createState() => _ContentState();
}
class _ContentState extends State<Content> with SingleTickerProviderStateMixin {
final nestedNavigator = GlobalKey<NavigatorState>();
AnimationController playerBarAnimation;
MusicusBackendState backend;
StreamSubscription<bool> playerActiveSubscription;
@override
void initState() {
super.initState();
playerBarAnimation = AnimationController(
vsync: this,
duration: Duration(milliseconds: 300),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
backend = MusicusBackend.of(context);
playerBarAnimation.value = backend.playback.active.value ? 1.0 : 0.0;
if (playerActiveSubscription != null) {
playerActiveSubscription.cancel();
}
playerActiveSubscription = backend.playback.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();
}
}

14
common/lib/src/icons.dart Normal file
View file

@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
/// Custom icons.
///
/// This was generated using https://fluttericon.com/.
class MusicusIcons {
MusicusIcons._();
static const _kFontFam = 'Musicus Icons';
static const _kFontPkg = 'musicus_common';
static const IconData musicus =
IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg);
}

View file

@ -46,6 +46,12 @@ abstract class MusicusPlatform {
basePath = path;
}
/// Choose a root level directory for the music library.
///
/// This should return a string representation of the chosen directory
/// suitable for storage as [basePath].
Future<String> chooseBasePath();
/// Get all documents in a directory.
///
/// [parentId] will be the ID of the directory document. If [parentId] is

View file

@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:url_launcher/url_launcher.dart' as url;
class AboutScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final textTheme = theme.textTheme;
return Scaffold(
appBar: AppBar(
title: Text('About'),
),
body: FutureBuilder<String>(
future:
rootBundle.loadString('packages/musicus_common/assets/about.md'),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Markdown(
data: snapshot.data,
styleSheet: MarkdownStyleSheet(
h1: textTheme.headline6.copyWith(
height: 2.0,
),
a: textTheme.bodyText1.copyWith(
color: theme.accentColor,
decoration: TextDecoration.underline,
),
),
onTapLink: (link) => url.launch(link),
);
} else {
return Container();
}
},
),
);
}
}

View file

@ -0,0 +1,265 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:musicus_client/musicus_client.dart';
import '../backend.dart';
import 'delete_account.dart';
import 'email.dart';
import 'password.dart';
import 'register.dart';
class AccountSettingsScreen extends StatefulWidget {
@override
_AccountSettingsScreenState createState() => _AccountSettingsScreenState();
}
class _AccountSettingsScreenState extends State<AccountSettingsScreen> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
MusicusBackendState _backend;
StreamSubscription<MusicusAccountCredentials> _accountSubscription;
bool _loading = false;
bool _loggedIn = false;
String _username;
String _email;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_backend = MusicusBackend.of(context);
final credentials = _backend.settings.account.value;
if (credentials != null) {
_setCredentials(credentials);
_getDetails();
}
_accountSubscription = _backend.settings.account.listen((credentials) {
_setCredentials(credentials);
});
}
Future<void> _setCredentials(MusicusAccountCredentials credentials) async {
if (mounted) {
if (credentials != null) {
setState(() {
_loggedIn = true;
_username = credentials.username;
});
} else {
setState(() {
_loggedIn = false;
});
}
}
}
Future<void> _getDetails() async {
setState(() {
_email = null;
});
final email = (await _backend.client.getAccountDetails()).email;
if (mounted) {
setState(() {
_email = email;
});
}
}
@override
Widget build(BuildContext context) {
List<Widget> children;
if (_loggedIn) {
children = [
Material(
elevation: 2.0,
child: ListTile(
title: Text('Logged in as: $_username'),
),
),
ListTile(
title: Text('E-mail address'),
subtitle: Text(
_email != null ? _email.isNotEmpty ? _email : 'Not set' : '...'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EmailScreen(
email: _email,
),
),
);
_getDetails();
},
),
ListTile(
title: Text('Change password'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PasswordScreen(),
),
);
},
),
ListTile(
title: Text('Delete this account'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DeleteAccountScreen(),
),
);
},
),
ListTile(
title: Text('Logout'),
onTap: () async {
await _backend.settings.clearAccount();
Navigator.pop(context);
},
),
];
} else {
children = [
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: 8.0,
),
child: Text(
'Enter your Musicus account credentials:',
style: Theme.of(context).textTheme.subtitle1,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: TextField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'User name',
),
),
),
SizedBox(
height: 16.0,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
),
),
),
SizedBox(
height: 32.0,
),
ListTile(
title: Text('Create a new account'),
onTap: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => RegisterScreen(
username: _usernameController.text,
password: _passwordController.text,
),
),
);
},
),
];
}
return Scaffold(
appBar: AppBar(
title: Text('Musicus account'),
actions: <Widget>[
Builder(
builder: (context) {
if (_loggedIn) {
return Container();
} else if (_loading) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
);
} else {
return FlatButton(
onPressed: () async {
setState(() {
_loading = true;
});
final credentials = MusicusAccountCredentials(
username: _usernameController.text,
password: _passwordController.text,
);
_backend.client.credentials = credentials;
try {
await _backend.client.login();
await _backend.settings.setAccount(credentials);
Navigator.pop(context);
} on MusicusLoginFailedException {
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('Login failed'),
),
);
}
setState(() {
_loading = false;
});
},
child: Text('LOGIN'),
);
}
},
),
],
),
body: ListView(
children: children,
),
);
}
@override
void dispose() {
super.dispose();
_accountSubscription.cancel();
}
}

View file

@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import '../backend.dart';
class DeleteAccountScreen extends StatefulWidget {
@override
_DeleteAccountScreenState createState() => _DeleteAccountScreenState();
}
class _DeleteAccountScreenState extends State<DeleteAccountScreen> {
final _passwordController = TextEditingController();
bool _loading = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Delete account'),
actions: <Widget>[
Builder(
builder: (context) {
if (_loading) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
);
} else {
return FlatButton(
onPressed: () async {
final backend = MusicusBackend.of(context);
if (_passwordController.text ==
backend.settings.account.value.password) {
setState(() {
_loading = true;
});
await backend.client.deleteAccount();
await backend.settings.clearAccount();
setState(() {
_loading = false;
});
Navigator.pop(context);
} else {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('Wrong password'),
));
}
},
child: Text('DELETE'),
);
}
},
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: 16.0,
right: 16.0,
top: 16.0,
bottom: 8.0,
),
child: Text(
'If you really want to delete your account, enter your password '
'below.',
style: Theme.of(context).textTheme.subtitle1,
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import '../backend.dart';
class EmailScreen extends StatefulWidget {
final String email;
EmailScreen({
this.email,
});
@override
_EmailScreenState createState() => _EmailScreenState();
}
class _EmailScreenState extends State<EmailScreen> {
final _emailController = TextEditingController();
bool _loading = false;
@override
void initState() {
super.initState();
if (widget.email != null) {
_emailController.text = widget.email;
}
}
Future<void> _setEmail(String email) async {
setState(() {
_loading = true;
});
final backend = MusicusBackend.of(context);
await backend.client.updateAccount(
newEmail: email,
);
setState(() {
_loading = false;
});
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('E-mail address'),
actions: <Widget>[
Builder(
builder: (context) {
if (_loading) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
);
} else {
return FlatButton(
onPressed: () {
_setEmail(_emailController.text);
},
child: Text('DONE'),
);
}
},
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'E-mail',
),
),
),
ListTile(
title: Text('Delete E-mail address'),
onTap: () {
_setEmail('');
},
),
],
),
);
}
}

View file

@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:musicus_client/musicus_client.dart';
import '../backend.dart';
import '../editors/person.dart';
import '../editors/tracks.dart';
import '../icons.dart';
import '../widgets/lists.dart';
import 'about.dart';
import 'person.dart';
import 'settings.dart';
class HomeScreen extends StatefulWidget {
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
String _search;
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
leading: Icon(
MusicusIcons.musicus,
color: Colors.amber,
),
title: TextField(
autofocus: true,
onChanged: (text) {
setState(() {
_search = text;
});
},
decoration: InputDecoration.collapsed(
hintText: 'Composers',
),
),
actions: <Widget>[
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
value: 1,
child: Text('Add tracks'),
),
PopupMenuItem(
value: 2,
child: Text('Settings'),
),
PopupMenuItem(
value: 3,
child: Text('About'),
),
],
onSelected: (selected) {
if (selected == 1) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TracksEditor(),
fullscreenDialog: true,
),
);
} else if (selected == 2) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SettingsScreen(),
),
);
} else if (selected == 3) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AboutScreen(),
),
);
}
},
),
],
),
body: PagedListView<Person>(
search: _search,
fetch: (page, search) async {
return await backend.db.getPersons(page, search);
},
builder: (context, person) => ListTile(
title: Text('${person.lastName}, ${person.firstName}'),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PersonScreen(
person: person,
),
),
),
onLongPress: () {
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
children: <Widget>[
ListTile(
title: Text('Edit person'),
onTap: () async {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => PersonEditor(
person: person,
),
fullscreenDialog: true,
),
);
},
),
],
);
},
);
},
),
),
);
}
}

View file

@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:musicus_client/musicus_client.dart';
import '../backend.dart';
class PasswordScreen extends StatefulWidget {
@override
_PasswordScreenState createState() => _PasswordScreenState();
}
class _PasswordScreenState extends State<PasswordScreen> {
final _oldPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
final _repeatController = TextEditingController();
bool _loading = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Change password'),
actions: <Widget>[
Builder(
builder: (context) {
if (_loading) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
);
} else {
return FlatButton(
onPressed: () async {
final backend = MusicusBackend.of(context);
final password = _newPasswordController.text;
if (_oldPasswordController.text ==
backend.settings.account.value.password &&
password.isNotEmpty &&
password == _repeatController.text) {
setState(() {
_loading = true;
});
await backend.client.updateAccount(
newPassword: password,
);
await backend.settings
.setAccount(MusicusAccountCredentials(
username: backend.settings.account.value.username,
password: password,
));
setState(() {
_loading = false;
});
Navigator.pop(context);
} else {
Scaffold.of(context).showSnackBar(SnackBar(
content: Text('Invalid inputs'),
));
}
},
child: Text('DONE'),
);
}
},
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _oldPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Old password',
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _newPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'New password',
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _repeatController,
obscureText: true,
decoration: InputDecoration(
labelText: 'New password (repeat)',
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:musicus_client/musicus_client.dart';
import '../backend.dart';
import '../editors/work.dart';
import '../widgets/lists.dart';
import 'work.dart';
class PersonScreen extends StatefulWidget {
final Person person;
PersonScreen({
this.person,
});
@override
_PersonScreenState createState() => _PersonScreenState();
}
class _PersonScreenState extends State<PersonScreen> {
String _search;
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
title: TextField(
autofocus: true,
onChanged: (text) {
setState(() {
_search = text;
});
},
decoration: InputDecoration.collapsed(
hintText:
'Works by ${widget.person.firstName} ${widget.person.lastName}',
),
),
),
body: PagedListView<WorkInfo>(
search: _search,
fetch: (page, search) async {
return await backend.db.getWorks(widget.person.id, page, search);
},
builder: (context, workInfo) => ListTile(
title: Text(workInfo.work.title),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WorkScreen(
workInfo: workInfo,
),
),
),
onLongPress: () {
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
children: <Widget>[
ListTile(
title: Text('Edit work'),
onTap: () async {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => WorkEditor(
workInfo: workInfo,
),
fullscreenDialog: true,
),
);
},
),
],
);
},
);
},
),
),
);
}
}

View file

@ -0,0 +1,320 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:musicus_client/musicus_client.dart';
import '../backend.dart';
import '../library.dart';
import '../widgets/play_pause_button.dart';
import '../widgets/recording_tile.dart';
class ProgramScreen extends StatefulWidget {
@override
_ProgramScreenState createState() => _ProgramScreenState();
}
class _ProgramScreenState extends State<ProgramScreen> {
MusicusBackendState backend;
StreamSubscription<bool> playerActiveSubscription;
StreamSubscription<List<InternalTrack>> playlistSubscription;
List<Widget> widgets = [];
StreamSubscription<double> positionSubscription;
double position = 0.0;
bool seeking = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
backend = MusicusBackend.of(context);
if (playerActiveSubscription != null) {
playerActiveSubscription.cancel();
}
// Close the program screen, if the player is no longer active.
playerActiveSubscription = backend.playback.active.listen((active) {
if (!active) {
Navigator.pop(context);
}
});
if (playlistSubscription != null) {
playlistSubscription.cancel();
}
playlistSubscription = backend.playback.playlist.listen((playlist) {
updateProgram(playlist);
});
if (positionSubscription != null) {
positionSubscription.cancel();
}
positionSubscription = backend.playback.normalizedPosition.listen((pos) {
if (!seeking) {
setState(() {
position = pos;
});
}
});
}
/// Go through the tracks of [playlist] and preprocess them for displaying.
Future<void> updateProgram(List<InternalTrack> playlist) async {
List<Widget> newWidgets = [];
// The following variables exist to adapt the resulting ProgramItem to its
// predecessor.
// If the previous recording was the same, we won't need to include the
// recording data again.
int lastRecordingId;
// If the previous work was the same, we won't need to retrieve its parts
// from the database again.
int lastWorkId;
// This will contain information on the last new work.
WorkInfo workInfo;
// The index of the last displayed section.
int lastSectionIndex;
for (var i = 0; i < playlist.length; i++) {
// The widgets displayed for this track.
List<Widget> children = [];
final track = playlist[i];
final recordingId = track.track.recordingId;
final partIds = track.track.partIds;
// If the recording is the same, the work will also be the same, so
// workInfo doesn't have to be updated either.
if (recordingId != lastRecordingId) {
lastRecordingId = recordingId;
final recordingInfo = await backend.db.getRecording(recordingId);
if (recordingInfo.recording.work != lastWorkId) {
lastWorkId = recordingInfo.recording.work;
workInfo = await backend.db.getWork(lastWorkId);
lastSectionIndex = null;
}
children.addAll([
RecordingTile(
workInfo: workInfo,
recordingInfo: recordingInfo,
),
SizedBox(
height: 8.0,
),
]);
}
for (final partId in partIds) {
final partInfo = workInfo.parts[partId];
final sectionIndex = workInfo.sections
.lastIndexWhere((s) => s.beforePartIndex <= partId);
if (sectionIndex != lastSectionIndex && sectionIndex >= 0) {
lastSectionIndex = sectionIndex;
children.add(Padding(
padding: const EdgeInsets.only(
bottom: 8.0,
),
child: Text(workInfo.sections[sectionIndex].title),
));
}
children.add(Padding(
padding: const EdgeInsets.only(
left: 8.0,
),
child: Text(
partInfo.part.title,
style: TextStyle(
fontStyle: FontStyle.italic,
),
),
));
}
newWidgets.add(Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
));
}
// Check, whether we are still a part of the widget tree, because this
// function might take some time.
if (mounted) {
setState(() {
widgets = newWidgets;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.pop(context),
),
title: Text('Program'),
),
body: StreamBuilder<int>(
stream: backend.playback.currentIndex,
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: widgets.length,
itemBuilder: (context, index) {
return InkWell(
child: Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(4.0),
child: index == snapshot.data
? const Icon(Icons.play_arrow)
: SizedBox(
width: 24.0,
height: 24.0,
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: widgets[index],
),
),
],
),
onTap: () {
backend.playback.skipTo(index);
},
onLongPress: () {
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
children: <Widget>[
ListTile(
title: Text('Remove from playlist'),
onTap: () {
backend.playback.removeTrack(index);
Navigator.pop(context);
},
),
],
);
});
},
);
},
);
} else {
return Container();
}
},
),
bottomNavigationBar: BottomAppBar(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Slider(
value: position,
onChangeStart: (_) {
seeking = true;
},
onChangeEnd: (pos) {
seeking = false;
backend.playback.seekTo(pos);
},
onChanged: (pos) {
setState(() {
position = pos;
});
},
),
Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(left: 24.0),
child: StreamBuilder<Duration>(
stream: backend.playback.position,
builder: (context, snapshot) {
if (snapshot.hasData) {
return DurationText(snapshot.data);
} else {
return Container();
}
},
),
),
Spacer(),
IconButton(
icon: const Icon(Icons.skip_previous),
onPressed: () {
backend.playback.skipToPrevious();
},
),
PlayPauseButton(),
IconButton(
icon: const Icon(Icons.skip_next),
onPressed: () {
backend.playback.skipToNext();
},
),
Spacer(),
Padding(
padding: const EdgeInsets.only(right: 20.0),
child: StreamBuilder<Duration>(
stream: backend.playback.duration,
builder: (context, snapshot) {
if (snapshot.hasData) {
return DurationText(snapshot.data);
} else {
return Container();
}
},
),
),
],
),
],
),
),
);
}
@override
void dispose() {
super.dispose();
playerActiveSubscription.cancel();
playlistSubscription.cancel();
positionSubscription.cancel();
}
}
class DurationText extends StatelessWidget {
final Duration duration;
DurationText(this.duration);
@override
Widget build(BuildContext context) {
final minutes = duration.inMinutes;
final seconds = (duration - Duration(minutes: minutes)).inSeconds;
final secondsString = seconds >= 10 ? seconds.toString() : '0$seconds';
return Text('$minutes:$secondsString');
}
}

View file

@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:musicus_client/musicus_client.dart';
import '../backend.dart';
/// A screen for creating a new Musicus account.
class RegisterScreen extends StatefulWidget {
final String username;
final String password;
RegisterScreen({
this.username,
this.password,
});
@override
_RegisterScreenState createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final nameController = TextEditingController();
final emailController = TextEditingController();
final passwordController = TextEditingController();
final repeatController = TextEditingController();
bool _loading = false;
@override
void initState() {
super.initState();
if (widget.username != null) {
nameController.text = widget.username;
}
if (widget.password != null) {
passwordController.text = widget.password;
}
}
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
title: Text('Create account'),
actions: <Widget>[
Builder(
builder: (context) {
if (!_loading) {
return FlatButton(
onPressed: () async {
if (_verify()) {
setState(() {
_loading = true;
});
final success = await backend.client.registerAccount(
username: nameController.text,
email: emailController.text,
password: passwordController.text,
);
setState(() {
_loading = false;
});
if (success) {
await backend.settings
.setAccount(MusicusAccountCredentials(
username: nameController.text,
password: passwordController.text,
));
Navigator.pop(context);
} else {
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('Failed to create account'),
),
);
}
} else {
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('Invalid inputs'),
),
);
}
},
child: Text('REGISTER'),
);
} else {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
),
),
),
);
}
},
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'User name',
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: emailController,
decoration: InputDecoration(
labelText: 'E-mail address (optional)',
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: repeatController,
obscureText: true,
decoration: InputDecoration(
labelText: 'Password (repeat)',
),
),
),
],
),
);
}
/// Check whether all requirements are met.
bool _verify() {
return nameController.text.isNotEmpty &&
passwordController.text.isNotEmpty &&
passwordController.text == repeatController.text;
}
}

View file

@ -0,0 +1,110 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:musicus_common/musicus_common.dart';
import '../backend.dart';
class ServerSettingsScreen extends StatefulWidget {
@override
_ServerSettingsScreenState createState() => _ServerSettingsScreenState();
}
class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
final hostController = TextEditingController();
final portController = TextEditingController();
final apiPathController = TextEditingController();
MusicusBackendState backend;
StreamSubscription<MusicusServerSettings> serverSubscription;
@override
void didChangeDependencies() {
super.didChangeDependencies();
backend = MusicusBackend.of(context);
if (serverSubscription != null) {
serverSubscription.cancel();
}
_settingsChanged(backend.settings.server.value);
serverSubscription = backend.settings.server.listen((settings) {
_settingsChanged(settings);
});
}
void _settingsChanged(MusicusServerSettings settings) {
hostController.text = settings.host;
portController.text = settings.port.toString();
apiPathController.text = settings.apiPath;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Server settings'),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.restore),
tooltip: 'Reset to default',
onPressed: () {
backend.settings.resetServer();
},
),
FlatButton(
onPressed: () async {
await backend.settings.setServer(MusicusServerSettings(
host: hostController.text,
port: int.parse(portController.text),
apiPath: apiPathController.text,
));
Navigator.pop(context);
},
child: Text('DONE'),
),
],
),
body: ListView(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: hostController,
decoration: InputDecoration(
labelText: 'Host',
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: portController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Port',
),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: apiPathController,
decoration: InputDecoration(
labelText: 'API path',
),
),
),
],
),
);
}
@override
void dispose() {
super.dispose();
serverSubscription.cancel();
}
}

View file

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:musicus_client/musicus_client.dart';
import 'package:musicus_common/musicus_common.dart';
import '../backend.dart';
import 'account_settings.dart';
import 'server_settings.dart';
class SettingsScreen extends StatelessWidget {
static const _platform = MethodChannel('de.johrpan.musicus/platform');
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
final settings = backend.settings;
return Scaffold(
appBar: AppBar(
title: Text('Settings'),
),
body: ListView(
children: <Widget>[
StreamBuilder<String>(
stream: settings.musicLibraryPath,
builder: (context, snapshot) {
return ListTile(
title: Text('Music library path'),
subtitle: Text(snapshot.data ?? 'Choose folder'),
isThreeLine: snapshot.hasData,
onTap: () async {
final uri = await backend.platform.chooseBasePath();
if (uri != null) {
settings.setMusicLibraryPath(uri);
}
},
);
},
),
StreamBuilder<MusicusServerSettings>(
stream: settings.server,
builder: (context, snapshot) {
final s = snapshot.data;
return ListTile(
title: Text('Musicus server'),
subtitle:
Text(s != null ? '${s.host}:${s.port}${s.apiPath}' : '...'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
final MusicusServerSettings result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ServerSettingsScreen(),
),
);
if (result != null) {
settings.setServer(result);
}
},
);
},
),
StreamBuilder<MusicusAccountCredentials>(
stream: settings.account,
builder: (context, snapshot) {
final credentials = snapshot.data;
return ListTile(
title: Text('Account settings'),
subtitle: Text(
credentials != null ? credentials.username : 'No account'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AccountSettingsScreen(),
),
);
},
);
},
),
],
),
);
}
}

View file

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:musicus_client/musicus_client.dart';
import '../backend.dart';
import '../editors/recording.dart';
import '../widgets/lists.dart';
import '../widgets/texts.dart';
class WorkScreen extends StatelessWidget {
final WorkInfo workInfo;
WorkScreen({
this.workInfo,
});
@override
Widget build(BuildContext context) {
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(
title: Text(workInfo.work.title),
),
body: PagedListView<RecordingInfo>(
fetch: (page, _) async {
return await backend.db.getRecordings(workInfo.work.id, page);
},
builder: (context, recordingInfo) {
final recordingId = recordingInfo.recording.id;
return ListTile(
title: PerformancesText(
performanceInfos: recordingInfo.performances,
),
onTap: () {
final tracks = backend.library.tracks[recordingId];
tracks.sort((t1, t2) => t1.track.index.compareTo(t2.track.index));
backend.playback.addTracks(tracks);
},
onLongPress: () {
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
children: <Widget>[
ListTile(
title: Text('Edit recording'),
onTap: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => RecordingEditor(
recordingInfo: recordingInfo,
),
fullscreenDialog: true,
),
);
},
),
],
);
},
);
},
);
},
),
);
}
}

View file

@ -0,0 +1,59 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../backend.dart';
class PlayPauseButton extends StatefulWidget {
@override
_PlayPauseButtonState createState() => _PlayPauseButtonState();
}
class _PlayPauseButtonState extends State<PlayPauseButton>
with SingleTickerProviderStateMixin {
AnimationController playPauseAnimation;
MusicusBackendState backend;
StreamSubscription<bool> playingSubscription;
@override
void initState() {
super.initState();
playPauseAnimation = AnimationController(
vsync: this,
duration: Duration(milliseconds: 300),
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
backend = MusicusBackend.of(context);
playPauseAnimation.value = backend.playback.playing.value ? 1.0 : 0.0;
if (playingSubscription != null) {
playingSubscription.cancel();
}
playingSubscription = backend.playback.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.playback.playPause,
);
}
@override
void dispose() {
super.dispose();
playingSubscription.cancel();
}
}

View file

@ -0,0 +1,135 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:musicus_client/musicus_client.dart';
import '../backend.dart';
import '../library.dart';
import '../screens/program.dart';
import 'play_pause_button.dart';
class PlayerBar extends StatefulWidget {
@override
_PlayerBarState createState() => _PlayerBarState();
}
class _PlayerBarState extends State<PlayerBar> {
MusicusBackendState _backend;
StreamSubscription<InternalTrack> _currentTrackSubscribtion;
WorkInfo _workInfo;
List<int> _partIds;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_backend = MusicusBackend.of(context);
_currentTrackSubscribtion?.cancel();
_currentTrackSubscribtion = _backend.playback.currentTrack.listen((track) {
if (track != null) {
_setTrack(track.track);
}
});
}
Future<void> _setTrack(Track track) async {
final recording =
await _backend.db.recordingById(track.recordingId).getSingle();
final workInfo = await _backend.db.getWork(recording.work);
final partIds = track.partIds;
if (mounted) {
setState(() {
_workInfo = workInfo;
_partIds = partIds;
});
}
}
@override
Widget build(BuildContext context) {
String title;
String subtitle;
if (_workInfo != null) {
title = _workInfo.composers
.map((p) => '${p.firstName} ${p.lastName}')
.join(', ');
final subtitleBuffer = StringBuffer(_workInfo.work.title);
if (_partIds.isNotEmpty) {
subtitleBuffer.write(': ');
final section = _workInfo.sections.lastWhere(
(s) => s.beforePartIndex <= _partIds[0],
orElse: () => null,
);
if (section != null) {
subtitleBuffer.write(section.title);
subtitleBuffer.write(': ');
}
subtitleBuffer.write(
_partIds.map((i) => _workInfo.parts[i].part.title).join(', '));
}
subtitle = subtitleBuffer.toString();
} else {
title = '...';
subtitle = '...';
}
return BottomAppBar(
child: InkWell(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
StreamBuilder(
stream: _backend.playback.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>[
DefaultTextStyle.merge(
style: TextStyle(fontWeight: FontWeight.bold),
child: Text(title),
),
Text(subtitle),
],
),
),
PlayPauseButton(),
],
),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProgramScreen(),
),
),
),
);
}
@override
void dispose() {
super.dispose();
_currentTrackSubscribtion?.cancel();
}
}

View file

@ -8,9 +8,27 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_markdown:
meta:
moor:
moor_ffi:
musicus_client:
path: ../client
rxdart:
rxdart:
url_launcher:
flutter:
uses-material-design: true
assets:
- assets/about.md
fonts:
- family: Libertinus Sans
fonts:
- asset: fonts/libertinussans_regular.otf
- asset: fonts/libertinussans_bold.otf
weight: 700
- asset: fonts/libertinussans_italic.otf
style: italic
- family: Musicus Icons
fonts:
- asset: fonts/musicus_icons.ttf