diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index b395303..4db468c 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -58,7 +58,7 @@ class App extends StatelessWidget { leading: const Icon(Icons.folder_open), title: Text('Choose path'), onTap: () { - backend.chooseMusicLibraryUri(); + backend.settings.chooseMusicLibraryUri(); }, ), ], diff --git a/mobile/lib/backend.dart b/mobile/lib/backend.dart index 1991447..a5af253 100644 --- a/mobile/lib/backend.dart +++ b/mobile/lib/backend.dart @@ -1,7 +1,6 @@ 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'; @@ -10,11 +9,10 @@ import 'package:musicus_client/musicus_client.dart'; import 'package:musicus_database/musicus_database.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart' as pp; -import 'package:rxdart/rxdart.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'music_library.dart'; import 'player.dart'; +import 'settings.dart'; // The following code was taken from // https://moor.simonbinder.eu/docs/advanced-features/isolates/ and just @@ -82,22 +80,15 @@ class Backend extends StatefulWidget { } class BackendState extends State { - static const defaultUrl = 'https://musicus.johrpan.de/api'; - static const _platform = MethodChannel('de.johrpan.musicus/platform'); - final player = Player(); + final settings = Settings(); BackendStatus status = BackendStatus.loading; Database db; - - final musicusServerUrl = BehaviorSubject(); MusicusClient client; - - String musicLibraryUri; MusicLibrary ml; MoorIsolate _moorIsolate; - SharedPreferences _shPref; @override void initState() { @@ -119,28 +110,26 @@ class BackendState extends State { player.setup(); db = Database.connect(dbConnection); - _shPref = await SharedPreferences.getInstance(); - var url = _shPref.getString('musicusServerUrl'); + await settings.load(); - if (url == null) { - url = defaultUrl; - await _shPref.setString('musicusServerUrl', url); - } - musicusServerUrl.add(url); - client = MusicusClient(url); + _updateMusicLibrary(settings.musicLibraryUri.value); + settings.musicLibraryUri.listen((uri) { + _updateMusicLibrary(uri); + }); - musicLibraryUri = _shPref.getString('musicLibraryUri'); - - _loadMusicLibrary(); + _updateClient(settings.server.value); + settings.server.listen((serverSettings) { + _updateClient(serverSettings); + }); } - Future _loadMusicLibrary() async { - if (musicLibraryUri == null) { + Future _updateMusicLibrary(String uri) async { + if (uri == null) { setState(() { status = BackendStatus.setup; }); } else { - ml = MusicLibrary(musicLibraryUri); + ml = MusicLibrary(uri); await ml.load(); setState(() { status = BackendStatus.ready; @@ -148,32 +137,12 @@ class BackendState extends State { } } - Future chooseMusicLibraryUri() async { - final uri = await _platform.invokeMethod('openTree'); - - if (uri != null) { - musicLibraryUri = uri; - await _shPref.setString('musicLibraryUri', uri); - setState(() { - status = BackendStatus.loading; - }); - await _loadMusicLibrary(); - } - } - - Future setMusicusServer(String serverUrl) async { - final url = serverUrl.isNotEmpty ? serverUrl : defaultUrl; - await _shPref.setString('musicusServerUrl', url); - - if (client != null) { - client.dispose(); - } - - if (url != null) { - client = MusicusClient(url); - } - - musicusServerUrl.add(url); + Future _updateClient(ServerSettings serverSettings) async { + client = MusicusClient( + host: serverSettings.host, + port: serverSettings.port, + basePath: serverSettings.basePath, + ); } @override diff --git a/mobile/lib/screens/home.dart b/mobile/lib/screens/home.dart index 07799c8..9cc04ae 100644 --- a/mobile/lib/screens/home.dart +++ b/mobile/lib/screens/home.dart @@ -49,8 +49,8 @@ class HomeScreen extends StatelessWidget { ), ], ), - body: StreamBuilder>( - stream: backend.db.allPersons().watch(), + body: FutureBuilder>( + future: backend.db.getPersons(), builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( diff --git a/mobile/lib/screens/person.dart b/mobile/lib/screens/person.dart index 0abf05c..8431045 100644 --- a/mobile/lib/screens/person.dart +++ b/mobile/lib/screens/person.dart @@ -37,14 +37,15 @@ class PersonScreen extends StatelessWidget { ), ], ), - body: StreamBuilder>( - stream: backend.db.worksByComposer(person.id).watch(), + body: FutureBuilder>( + future: backend.db.getWorks(person.id), builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( itemCount: snapshot.data.length, itemBuilder: (context, index) { - final work = snapshot.data[index]; + final work = snapshot.data[index].work; + return ListTile( title: Text(work.title), onTap: () async { diff --git a/mobile/lib/screens/server_settings.dart b/mobile/lib/screens/server_settings.dart new file mode 100644 index 0000000..a6dcc6e --- /dev/null +++ b/mobile/lib/screens/server_settings.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../backend.dart'; +import '../settings.dart'; + +class ServerSettingsScreen extends StatefulWidget { + @override + _ServerSettingsScreenState createState() => _ServerSettingsScreenState(); +} + +class _ServerSettingsScreenState extends State { + final hostController = TextEditingController(); + final portController = TextEditingController(); + final basePathController = TextEditingController(); + + BackendState backend; + StreamSubscription serverSubscription; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + backend = Backend.of(context); + + if (serverSubscription != null) { + serverSubscription.cancel(); + } + + _settingsChanged(backend.settings.server.value); + serverSubscription = backend.settings.server.listen((settings) { + _settingsChanged(settings); + }); + } + + void _settingsChanged(ServerSettings settings) { + hostController.text = settings.host; + portController.text = settings.port.toString(); + basePathController.text = settings.basePath; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Server settings'), + actions: [ + IconButton( + icon: const Icon(Icons.restore), + tooltip: 'Reset to default', + onPressed: () { + backend.settings.resetServerSettings(); + }, + ), + FlatButton( + onPressed: () async { + await backend.settings.setServerSettings(ServerSettings( + host: hostController.text, + port: int.parse(portController.text), + basePath: basePathController.text, + )); + + Navigator.pop(context); + }, + child: Text('DONE'), + ), + ], + ), + body: ListView( + children: [ + 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: basePathController, + decoration: InputDecoration( + labelText: 'API path', + ), + ), + ), + ], + ), + ); + } + + @override + void dispose() { + super.dispose(); + serverSubscription.cancel(); + } +} diff --git a/mobile/lib/screens/settings.dart b/mobile/lib/screens/settings.dart index 956be15..01248c4 100644 --- a/mobile/lib/screens/settings.dart +++ b/mobile/lib/screens/settings.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import '../backend.dart'; +import '../settings.dart'; + +import 'server_settings.dart'; class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { final backend = Backend.of(context); + final settings = backend.settings; return Scaffold( appBar: AppBar( @@ -13,60 +17,42 @@ class SettingsScreen extends StatelessWidget { ), body: ListView( children: [ - ListTile( - leading: Icon(Icons.library_music), - title: Text('Music library path'), - subtitle: Text(backend.musicLibraryUri), - onTap: () { - backend.chooseMusicLibraryUri(); - }, - ), StreamBuilder( - stream: backend.musicusServerUrl, - builder: (context, snapshot) { - return ListTile( - leading: Icon(Icons.router), - title: Text('Musicus server'), - subtitle: Text(snapshot.data ?? 'Set server URL'), - onTap: () { - showDialog( - context: context, - builder: (context) { - final controller = TextEditingController(); + stream: settings.musicLibraryUri, + builder: (context, snapshot) { + return ListTile( + title: Text('Music library path'), + subtitle: Text(snapshot.data ?? 'Choose folder'), + isThreeLine: snapshot.hasData, + onTap: () { + settings.chooseMusicLibraryUri(); + }, + ); + }), + StreamBuilder( + stream: settings.server, + builder: (context, snapshot) { + final s = snapshot.data; - if (snapshot.data != null) { - controller.text = snapshot.data; - } + return ListTile( + title: Text('Musicus server'), + subtitle: Text( + s != null ? '${s.host}:${s.port}${s.basePath}' : '...'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final ServerSettings result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ServerSettingsScreen(), + ), + ); - return AlertDialog( - title: Text('Musicus server'), - content: TextField( - controller: controller, - decoration: InputDecoration( - labelText: 'Server URL', - ), - ), - actions: [ - FlatButton( - onPressed: () { - backend.setMusicusServer(controller.text); - Navigator.pop(context); - }, - child: Text('SET'), - ), - FlatButton( - onPressed: () { - Navigator.pop(context); - }, - child: Text('CANCEL'), - ), - ], - ); - }); - }, - ); - } - ), + if (result != null) { + settings.setServerSettings(result); + } + }, + ); + }), ], ), ); diff --git a/mobile/lib/screens/work.dart b/mobile/lib/screens/work.dart index 46d1d69..179fb22 100644 --- a/mobile/lib/screens/work.dart +++ b/mobile/lib/screens/work.dart @@ -36,33 +36,25 @@ class WorkScreen extends StatelessWidget { ), ], ), - body: StreamBuilder>( - stream: backend.db.recordingsByWork(workInfo.work.id).watch(), + body: FutureBuilder>( + future: backend.db.getRecordings(workInfo.work.id), builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( itemCount: snapshot.data.length, itemBuilder: (context, index) { - final recording = snapshot.data[index]; + final recordingInfo = snapshot.data[index]; + final recording = recordingInfo.recording; return ListTile( - title: FutureBuilder( - future: backend.db.getRecordingInfo(recording), - builder: (context, snapshot) { - if (snapshot.hasData) { - return PerformancesText( - performanceInfos: snapshot.data.performances, - ); - } else { - return Text('...'); - } - } + title: PerformancesText( + performanceInfos: recordingInfo.performances, ), onTap: () async { final tracks = backend.ml.tracks[recording.id]; tracks.sort( (t1, t2) => t1.track.index.compareTo(t2.track.index)); - + backend.player.addTracks(backend.ml.tracks[recording.id]); }, ); diff --git a/mobile/lib/selectors/files.dart b/mobile/lib/selectors/files.dart index 79a673b..2e96b0a 100644 --- a/mobile/lib/selectors/files.dart +++ b/mobile/lib/selectors/files.dart @@ -131,7 +131,8 @@ class _FilesSelectorState extends State { }); final newChildren = await Platform.getChildren( - backend.musicLibraryUri, history.isNotEmpty ? history.last.id : null); + backend.settings.musicLibraryUri.value, + history.isNotEmpty ? history.last.id : null); newChildren.sort((d1, d2) { if (d1.isDirectory != d2.isDirectory) { diff --git a/mobile/lib/settings.dart b/mobile/lib/settings.dart new file mode 100644 index 0000000..a4e1c84 --- /dev/null +++ b/mobile/lib/settings.dart @@ -0,0 +1,102 @@ +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Settings concerning the Musicus server to connect to. +/// +/// We don't support setting a scheme here, because there may be password being +/// submitted in the future, so we default to HTTPS. +class ServerSettings { + static const defaultHost = 'musicus.johrpan.de'; + static const defaultPort = 1833; + static const defaultBasePath = '/api'; + + /// Host to connect to, e.g. 'musicus.johrpan.de'; + final String host; + + /// Port to connect to. + final int port; + + /// Path to the API. + /// + /// This should be null, if the API is at the root of the host. + final String basePath; + + ServerSettings({ + @required this.host, + @required this.port, + @required this.basePath, + }); +} + +/// Manager for all settings that are persisted. +class Settings { + static const defaultHost = 'musicus.johrpan.de'; + static const defaultPort = 443; + static const defaultBasePath = '/api'; + + static const _platform = MethodChannel('de.johrpan.musicus/platform'); + + /// The tree storage access framework tree URI of the music library. + final musicLibraryUri = BehaviorSubject(); + + /// Musicus server to connect to. + final server = BehaviorSubject(); + + SharedPreferences _shPref; + + /// Initialize the settings. + Future load() async { + _shPref = await SharedPreferences.getInstance(); + + final uri = _shPref.getString('musicLibraryUri'); + if (uri != null) { + musicLibraryUri.add(uri); + } + + final host = _shPref.getString('serverHost') ?? defaultHost; + final port = _shPref.getInt('serverPort') ?? defaultPort; + final basePath = _shPref.getString('serverBasePath') ?? defaultBasePath; + + server.add(ServerSettings( + host: host, + port: port, + basePath: basePath, + )); + } + + /// Open the system picker to select a new music library URI. + Future chooseMusicLibraryUri() async { + final uri = await _platform.invokeMethod('openTree'); + + if (uri != null) { + musicLibraryUri.add(uri); + await _shPref.setString('musicLibraryUri', uri); + } + } + + /// Change the Musicus server settings. + Future setServerSettings(ServerSettings settings) async { + await _shPref.setString('serverHost', settings.host); + await _shPref.setInt('serverPort', settings.port); + await _shPref.setString('serverBasePath', settings.basePath); + + server.add(settings); + } + + /// Reset the server settings to their defaults. + Future resetServerSettings() async { + await setServerSettings(ServerSettings( + host: defaultHost, + port: defaultPort, + basePath: defaultBasePath, + )); + } + + /// Tidy up. + void dispose() { + musicLibraryUri.close(); + server.close(); + } +} diff --git a/mobile/lib/widgets/works_by_composer.dart b/mobile/lib/widgets/works_by_composer.dart deleted file mode 100644 index ba4a799..0000000 --- a/mobile/lib/widgets/works_by_composer.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:musicus_database/musicus_database.dart'; - -import '../backend.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>( - 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(); - } - }, - ); - } -} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6edaa7d..c6b8981 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: audio_service: flutter: sdk: flutter + meta: moor: moor_ffi: musicus_client: