Move reusable code from mobile to common

This will be useful for a future desktop application.
This commit is contained in:
Elias Projahn 2020-05-04 21:49:44 +02:00
parent 6e1255f26e
commit 711b19c998
40 changed files with 813 additions and 581 deletions

View file

@ -12,17 +12,20 @@ depend on other ones. All packages are written in [Dart](https://dart.dev).
`database` A Database of classical music. This package will be used by all `database` A Database of classical music. This package will be used by all
standalone Musicus applications for storing classical music metadata. standalone Musicus applications for storing classical music metadata.
`server` A simple http server hosting a Musicus database. The server is
developed using the [Aqueduct framework](https://aqueduct.io).
`client` A client library for the Musicus server.
`common` Common building blocks for Musicus client apps. This includes shared
UI and backend code for the mobile app and the (future) desktop app.
`mobile` The Musicus mobile app. It is being developed using `mobile` The Musicus mobile app. It is being developed using
[Flutter toolkit](https://flutter.dev) and only runs on Android for now. [Flutter toolkit](https://flutter.dev) and only runs on Android for now.
`player` The simplest possible audio player plugin. This is used by the `player` The simplest possible audio player plugin. This is used by the
mobile app for playback. mobile app for playback.
`server` A simple http server hosting a Musicus database. The server is
developed using the [Aqueduct framework](https://aqueduct.io).
`client` A client library for the Musicus server.
## Hacking ## Hacking
Picking up Dart as a programming language and Flutter as an UI toolkit should Picking up Dart as a programming language and Flutter as an UI toolkit should

31
common/.gitignore vendored Normal file
View file

@ -0,0 +1,31 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VS Code related
.vscode/
# Flutter/Dart/Pub related
**/*.g.dart
**/doc/api/
.dart_tool/
pubspec.lock
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/

View file

@ -0,0 +1,24 @@
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/library.dart';
export 'src/platform.dart';
export 'src/playback.dart';
export 'src/settings.dart';

221
common/lib/src/backend.dart Normal file
View file

@ -0,0 +1,221 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:flutter/widgets.dart';
import 'package:moor/isolate.dart';
import 'package:moor/moor.dart';
import 'package:moor_ffi/moor_ffi.dart';
import 'package:musicus_client/musicus_client.dart';
import 'package:musicus_database/musicus_database.dart';
import 'library.dart';
import 'platform.dart';
import 'playback.dart';
import 'settings.dart';
/// Current status of the backend.
enum MusicusBackendStatus {
/// The backend is loading.
///
/// It is not allowed to call any methods on the backend in this state.
loading,
/// Required settings are missing.
///
/// Currently this only includes the music library path. It is not allowed to
/// call any methods on the backend in this state.
setup,
/// The backend is ready to be used.
///
/// This is the only state, in which it is allowed to call methods on the
/// backend.
ready,
}
/// Meta widget holding all backend ressources for Musicus.
///
/// This widget is intended to sit near the top of the widget tree. Widgets
/// below it can get the current backend state using the static [of] method.
/// The backend is intended to be used exactly once and live until the UI is
/// exited. Because of that, consuming widgets don't need to care about a
/// change of the backend state object.
///
/// The backend maintains a Musicus database within a Moor isolate. The connect
/// port will be registered as 'moor' in the [IsolateNameServer].
class MusicusBackend extends StatefulWidget {
/// Path to the database file.
final String dbPath;
/// An object to persist the settings.
final MusicusSettingsStorage settingsStorage;
/// An object handling playback.
final MusicusPlayback playback;
/// An object handling platform dependent functionality.
final MusicusPlatform platform;
/// The first child below the backend widget.
///
/// This widget should keep track of the current backend status and block
/// other widgets from accessing the backend until its status is set to
/// [MusicusBackendStatus.ready].
final Widget child;
MusicusBackend({
@required this.dbPath,
@required this.settingsStorage,
@required this.playback,
@required this.platform,
@required this.child,
});
@override
MusicusBackendState createState() => MusicusBackendState();
static MusicusBackendState of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_InheritedBackend>().state;
}
class MusicusBackendState extends State<MusicusBackend> {
/// Starts the Moor isolate.
///
/// It will create a database connection for [request.path] and will send the
/// Moor send port through [request.sendPort].
static void _moorIsolateEntrypoint(_IsolateStartRequest request) {
final executor = VmDatabase(File(request.path));
final moorIsolate =
MoorIsolate.inCurrent(() => DatabaseConnection.fromExecutor(executor));
request.sendPort.send(moorIsolate.connectPort);
}
/// The current backend status.
///
/// If this is not [MusicusBackendStatus.ready], the [child] widget should
/// prevent all access to the backend.
MusicusBackendStatus status = MusicusBackendStatus.loading;
Database db;
MusicusPlayback playback;
MusicusSettings settings;
MusicusClient client;
MusicusPlatform platform;
MusicusLibrary library;
@override
void initState() {
super.initState();
_load();
}
/// Initialize resources.
Future<void> _load() async {
SendPort moorPort = IsolateNameServer.lookupPortByName('moor');
if (moorPort == null) {
final receivePort = ReceivePort();
await Isolate.spawn(_moorIsolateEntrypoint,
_IsolateStartRequest(receivePort.sendPort, widget.dbPath));
moorPort = await receivePort.first;
IsolateNameServer.registerPortWithName(moorPort, 'moor');
}
final moorIsolate = MoorIsolate.fromConnectPort(moorPort);
db = Database.connect(await moorIsolate.connect());
playback = widget.playback;
await playback.setup();
settings = MusicusSettings(widget.settingsStorage);
await settings.load();
settings.musicLibraryPath.listen((path) {
setState(() {
status = MusicusBackendStatus.loading;
});
_updateMusicLibrary(path);
});
settings.server.listen((serverSettings) {
_updateClient(serverSettings);
});
_updateClient(settings.server.value);
final path = settings.musicLibraryPath.value;
platform = widget.platform;
platform.setBasePath(path);
// This will change the status for us.
_updateMusicLibrary(path);
}
/// Create a music library according to [path].
Future<void> _updateMusicLibrary(String path) async {
if (path == null) {
setState(() {
status = MusicusBackendStatus.setup;
});
} else {
platform.setBasePath(path);
library = MusicusLibrary(path, platform);
await library.load();
setState(() {
status = MusicusBackendStatus.ready;
});
}
}
/// Create a new client based on [settings].
void _updateClient(MusicusServerSettings settings) {
client?.dispose();
client = MusicusClient(
host: settings.host,
port: settings.port,
basePath: settings.apiPath,
);
}
@override
Widget build(BuildContext context) {
return _InheritedBackend(
child: widget.child,
state: this,
);
}
@override
void dispose() {
super.dispose();
settings.dispose();
/// We don't stop the Moor isolate, because it can be used elsewhere.
db.close();
client.dispose();
}
}
/// Bundles arguments for the moor isolate.
class _IsolateStartRequest {
final SendPort sendPort;
final String path;
_IsolateStartRequest(this.sendPort, this.path);
}
/// Helper widget passing the current backend state down the widget tree.
class _InheritedBackend extends InheritedWidget {
final Widget child;
final MusicusBackendState state;
_InheritedBackend({
@required this.child,
@required this.state,
}) : super(child: child);
@override
bool updateShouldNotify(_InheritedBackend old) => true;
}

View file

@ -30,7 +30,7 @@ class _EnsembleEditorState extends State<EnsembleEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View file

@ -30,7 +30,7 @@ class _InstrumentEditorState extends State<InstrumentEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View file

@ -32,7 +32,7 @@ class _PersonEditorState extends State<PersonEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View file

@ -36,7 +36,7 @@ class _RecordingEditorState extends State<RecordingEditor> {
super.initState(); super.initState();
if (widget.recordingInfo != null) { if (widget.recordingInfo != null) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
() async { () async {
workInfo = await backend.db.getWork(widget.recordingInfo.recording.id); workInfo = await backend.db.getWork(widget.recordingInfo.recording.id);
@ -47,7 +47,7 @@ class _RecordingEditorState extends State<RecordingEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
Future<void> selectWork() async { Future<void> selectWork() async {
final WorkInfo newWorkInfo = await Navigator.push( final WorkInfo newWorkInfo = await Navigator.push(

View file

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import '../backend.dart'; import '../backend.dart';
import '../music_library.dart'; import '../library.dart';
import '../selectors/files.dart'; import '../selectors/files.dart';
import '../selectors/recording.dart'; import '../selectors/recording.dart';
import '../widgets/recording_tile.dart'; import '../widgets/recording_tile.dart';
@ -21,7 +21,7 @@ class TracksEditor extends StatefulWidget {
} }
class _TracksEditorState extends State<TracksEditor> { class _TracksEditorState extends State<TracksEditor> {
BackendState backend; MusicusBackendState backend;
WorkInfo workInfo; WorkInfo workInfo;
RecordingInfo recordingInfo; RecordingInfo recordingInfo;
String parentId; String parentId;
@ -29,7 +29,7 @@ class _TracksEditorState extends State<TracksEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
backend = Backend.of(context); backend = MusicusBackend.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -57,7 +57,7 @@ class _TracksEditorState extends State<TracksEditor> {
backend.db.updateWork(workInfo); backend.db.updateWork(workInfo);
backend.db.updateRecording(recordingInfo); backend.db.updateRecording(recordingInfo);
backend.ml.addTracks(parentId, tracks); backend.library.addTracks(parentId, tracks);
Navigator.pop(context); Navigator.pop(context);
}, },

View file

@ -200,7 +200,7 @@ class _WorkEditorState extends State<WorkEditor> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
final List<Widget> partTiles = []; final List<Widget> partTiles = [];
for (var i = 0; i < parts.length; i++) { for (var i = 0; i < parts.length; i++) {

View file

@ -1,32 +1,31 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart';
import 'platform.dart'; import 'platform.dart';
/// Bundles a [Track] with the URI of the audio file it represents. /// Bundles a [Track] with information on how to find the corresponding file.
///
/// The uri shouldn't be stored on disk, but will be used at runtime.
class InternalTrack { class InternalTrack {
/// The represented track. /// The represented track.
final Track track; final Track track;
/// The URI of the represented audio file as retrieved from the SAF. /// A string identifying the track for playback.
final String uri; ///
/// This will be the result of calling the platform objects getIdentifier()
/// function with the file name of the track.
final String identifier;
InternalTrack({ InternalTrack({
this.track, this.track,
this.uri, this.identifier,
}); });
factory InternalTrack.fromJson(Map<String, dynamic> json) => InternalTrack( factory InternalTrack.fromJson(Map<String, dynamic> json) => InternalTrack(
track: Track.fromJson(json['track']), track: Track.fromJson(json['track']),
uri: json['uri'], identifier: json['identifier'],
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'track': track.toJson(), 'track': track.toJson(),
'uri': uri, 'identifier': identifier,
}; };
} }
@ -106,13 +105,12 @@ class MusicusFile {
} }
/// Manager for all available tracks and their representation on disk. /// Manager for all available tracks and their representation on disk.
class MusicLibrary { class MusicusLibrary {
static const platform = MethodChannel('de.johrpan.musicus/platform'); /// String representing the music library base path.
final String basePath;
/// URI of the music library folder. /// Access to platform dependent functionality.
/// final MusicusPlatform platform;
/// 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. /// Map of all available tracks by recording ID.
/// ///
@ -120,7 +118,7 @@ class MusicLibrary {
/// audio file alongside the real [Track] object. /// audio file alongside the real [Track] object.
final Map<int, List<InternalTrack>> tracks = {}; final Map<int, List<InternalTrack>> tracks = {};
MusicLibrary(this.treeUri); MusicusLibrary(this.basePath, this.platform);
/// Load all available tracks. /// Load all available tracks.
/// ///
@ -130,13 +128,13 @@ class MusicLibrary {
Future<void> load() async { Future<void> load() async {
// TODO: Consider capping the recursion somewhere. // TODO: Consider capping the recursion somewhere.
Future<void> recurse([String parentId]) async { Future<void> recurse([String parentId]) async {
final children = await Platform.getChildren(treeUri, parentId); final children = await platform.getChildren(parentId);
for (final child in children) { for (final child in children) {
if (child.isDirectory) { if (child.isDirectory) {
recurse(child.id); recurse(child.id);
} else if (child.name == 'musicus.json') { } else if (child.name == 'musicus.json') {
final content = await Platform.readFile(treeUri, child.id); final content = await platform.readDocument(child.id);
final musicusFile = MusicusFile.fromJson(jsonDecode(content)); final musicusFile = MusicusFile.fromJson(jsonDecode(content));
for (final track in musicusFile.tracks) { for (final track in musicusFile.tracks) {
_indexTrack(parentId, track); _indexTrack(parentId, track);
@ -156,7 +154,7 @@ class MusicLibrary {
MusicusFile musicusFile; MusicusFile musicusFile;
final oldContent = final oldContent =
await Platform.readFileByName(treeUri, parentId, 'musicus.json'); await platform.readDocumentByName(parentId, 'musicus.json');
if (oldContent != null) { if (oldContent != null) {
musicusFile = MusicusFile.fromJson(jsonDecode(oldContent)); musicusFile = MusicusFile.fromJson(jsonDecode(oldContent));
@ -169,15 +167,15 @@ class MusicLibrary {
musicusFile.tracks.add(track); musicusFile.tracks.add(track);
} }
await Platform.writeFileByName( await platform.writeDocumentByName(
treeUri, parentId, 'musicus.json', jsonEncode(musicusFile.toJson())); parentId, 'musicus.json', jsonEncode(musicusFile.toJson()));
} }
/// Add a track to the map of available tracks. /// Add a track to the map of available tracks.
Future<void> _indexTrack(String parentId, Track track) async { Future<void> _indexTrack(String parentId, Track track) async {
final iTrack = InternalTrack( final iTrack = InternalTrack(
track: track, track: track,
uri: await Platform.getUriByName(treeUri, parentId, track.fileName), identifier: await platform.getIdentifier(parentId, track.fileName),
); );
if (tracks.containsKey(track.recordingId)) { if (tracks.containsKey(track.recordingId)) {

View file

@ -0,0 +1,76 @@
/// Object representing a document in Storage Access Framework terms.
class Document {
/// Unique ID for the document.
///
/// The platform implementation thould be able to get the content of the
/// document based on this value.
final String id;
/// Name of the document (i.e. file name).
final String name;
/// Document ID of the parent document.
final String parent;
/// Whether this document represents a directory.
final bool isDirectory;
Document({
this.id,
this.name,
this.parent,
this.isDirectory,
});
// Use Map<dynamic, dynamic> here, as we get casting errors otherwise. This
// won't be typesafe anyway.
Document.fromJson(Map<dynamic, dynamic> json)
: id = json['id'],
name = json['name'],
parent = json['parent'],
isDirectory = json['isDirectory'];
}
/// Platform dependent code for the Musicus backend.
abstract class MusicusPlatform {
/// An identifier for the root directory of the music library.
///
/// This will be the string, that is stored as musicLibraryPath in the
/// settings object.
String basePath;
MusicusPlatform();
/// This will be called, when the music library path was changed.
void setBasePath(String path) {
basePath = path;
}
/// Get all documents in a directory.
///
/// [parentId] will be the ID of the directory document. If [parentId] is
/// null, the children of the root directory will be returned.
Future<List<Document>> getChildren(String parentId);
/// Read the contents of a document by ID.
Future<String> readDocument(String id);
/// Read from a document by name.
///
/// [parentId] is the document ID of the parent directory.
Future<String> readDocumentByName(String parentId, String fileName);
/// Get a string identifying a document.
///
/// [parentId] is the document ID of the parent directory. The return value
/// should be a string, that the playback object can use to find and play the
/// file. It will be included in [InternalTrack] objects by the music
/// library.
Future<String> getIdentifier(String parentId, String fileName);
/// Write to a document by name.
///
/// [parentId] is the document ID of the parent directory.
Future<void> writeDocumentByName(
String parentId, String fileName, String contents);
}

View file

@ -0,0 +1,96 @@
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'library.dart';
/// Base class for Musicus playback.
abstract class MusicusPlayback {
/// Whether the player is active.
///
/// This means, that there is at least one item in the queue and the playback
/// service is ready to play.
final active = BehaviorSubject.seeded(false);
/// The current playlist.
///
/// If the player is not active, this will be an empty list.
final playlist = BehaviorSubject.seeded(<InternalTrack>[]);
/// Index of the currently played (or paused) track within the playlist.
///
/// This will be zero, if the player is not active!
final currentIndex = BehaviorSubject.seeded(0);
/// The currently played track.
///
/// This will be null, if there is no current track.
final currentTrack = BehaviorSubject<InternalTrack>.seeded(null);
/// Whether we are currently playing or not.
///
/// This will be false, if the player is not active.
final playing = BehaviorSubject.seeded(false);
/// Current playback position.
///
/// If the player is not active, this will default to zero.
final position = BehaviorSubject.seeded(const Duration());
/// Duration of the current track.
///
/// If the player is not active, the duration will default to 1 s.
final duration = BehaviorSubject.seeded(const Duration(seconds: 1));
/// Playback position normalized to the range from zero to one.
final normalizedPosition = BehaviorSubject.seeded(0.0);
/// Initialize the player.
///
/// This will be called after the database was initialized.
Future<void> setup();
/// Add a list of tracks to the players playlist.
Future<void> addTracks(List<InternalTrack> tracks);
/// Remove the track at [index] from the playlist.
Future<void> removeTrack(int index);
/// Toggle whether the player is playing or paused.
Future<void> playPause();
/// Seek to [pos], which is a value between (and including) zero and one.
Future<void> seekTo(double pos);
/// Skip to the previous track in the playlist.
Future<void> skipToPrevious();
/// Play the next track in the playlist.
Future<void> skipToNext();
/// Switch to the track with the index [index] in the playlist.
Future<void> skipTo(int index);
/// Set all values to their default.
void reset() {
active.add(false);
playlist.add([]);
currentTrack.add(null);
playing.add(false);
position.add(const Duration());
duration.add(const Duration(seconds: 1));
normalizedPosition.add(0.0);
}
/// Tidy up.
@mustCallSuper
void dispose() {
active.close();
playlist.close();
currentIndex.close();
currentTrack.close();
playing.close();
position.close();
duration.close();
normalizedPosition.close();
}
}

View file

@ -24,7 +24,7 @@ class FilesSelector extends StatefulWidget {
} }
class _FilesSelectorState extends State<FilesSelector> { class _FilesSelectorState extends State<FilesSelector> {
BackendState backend; MusicusBackendState backend;
List<Document> history = []; List<Document> history = [];
List<Document> children = []; List<Document> children = [];
Set<Document> selection = {}; Set<Document> selection = {};
@ -33,7 +33,7 @@ class _FilesSelectorState extends State<FilesSelector> {
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
backend = Backend.of(context); backend = MusicusBackend.of(context);
loadChildren(); loadChildren();
} }
@ -130,9 +130,8 @@ class _FilesSelectorState extends State<FilesSelector> {
selection = {}; selection = {};
}); });
final newChildren = await Platform.getChildren( final newChildren = await backend.platform
backend.settings.musicLibraryUri.value, .getChildren(history.isNotEmpty ? history.last.id : null);
history.isNotEmpty ? history.last.id : null);
newChildren.sort((d1, d2) { newChildren.sort((d1, d2) {
if (d1.isDirectory != d2.isDirectory) { if (d1.isDirectory != d2.isDirectory) {

View file

@ -35,7 +35,7 @@ class _InstrumentsSelectorState extends State<InstrumentsSelector> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View file

@ -0,0 +1,112 @@
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
/// Interface for persisting settings.
///
/// The methods should return null, if there is no value associated with the
/// provided key.
abstract class MusicusSettingsStorage {
Future<void> load();
Future<int> getInt(String key);
Future<String> getString(String key);
Future<void> setInt(String key, int value);
Future<void> setString(String key, String value);
}
/// Settings concerning the Musicus server to connect to.
///
/// We don't support setting a scheme here, because there may be password being
/// submitted in the future, so we default to HTTPS.
class MusicusServerSettings {
/// Host to connect to, e.g. 'musicus.johrpan.de';
final String host;
/// Port to connect to.
final int port;
/// Path to the API.
///
/// This can be either null or empty, if the API is at the root of the host.
final String apiPath;
MusicusServerSettings({
@required this.host,
@required this.port,
@required this.apiPath,
});
}
/// Manager for all settings that are persisted.
class MusicusSettings {
static const defaultHost = 'musicus.johrpan.de';
static const defaultPort = 443;
static const defaultApiPath = '/api';
/// The storage method to use.
final MusicusSettingsStorage storage;
/// A identifier for the base path of the music library.
///
/// This could be a file path on destop systems or a tree URI in terms of the
/// Android storage access framework.
final musicLibraryPath = BehaviorSubject<String>();
/// Musicus server to connect to.
final server = BehaviorSubject<MusicusServerSettings>();
/// Create a settings instance.
MusicusSettings(this.storage);
/// Initialize the settings.
Future<void> load() async {
await storage.load();
final path = await storage.getString('musicLibraryPath');
if (path != null) {
musicLibraryPath.add(path);
}
final host = await storage.getString('serverHost') ?? defaultHost;
final port = await storage.getInt('serverPort') ?? defaultPort;
final apiPath = await storage.getString('serverApiPath') ?? defaultApiPath;
server.add(MusicusServerSettings(
host: host,
port: port,
apiPath: apiPath,
));
}
/// Set a new music library path.
///
/// This will persist the new value and update the stream.
Future<void> setMusicLibraryPath(String path) async {
await storage.setString('musicLibraryPath', path);
musicLibraryPath.add(path);
}
/// Update the server settings.
///
/// This will persist the new values and update the stream.
Future<void> setServer(MusicusServerSettings serverSettings) async {
await storage.setString('serverHost', serverSettings.host);
await storage.setInt('serverPort', serverSettings.port);
await storage.setString('severApiPath', serverSettings.apiPath);
server.add(serverSettings);
}
/// Reset the server settings to their defaults.
Future<void> resetServer() async {
await setServer(MusicusServerSettings(
host: defaultHost,
port: defaultPort,
apiPath: defaultApiPath,
));
}
/// Tidy up.
void dispose() {
musicLibraryPath.close();
server.close();
}
}

View file

@ -161,7 +161,7 @@ class _PersonsListState extends State<PersonsList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Column( return Column(
children: <Widget>[ children: <Widget>[
@ -222,7 +222,7 @@ class _EnsemblesListState extends State<EnsemblesList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Column( return Column(
children: <Widget>[ children: <Widget>[
@ -287,7 +287,7 @@ class _WorksListState extends State<WorksList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Column( return Column(
children: <Widget>[ children: <Widget>[
@ -346,7 +346,7 @@ class RecordingsList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return PagedListView<RecordingInfo>( return PagedListView<RecordingInfo>(
fetch: (page, _) async { fetch: (page, _) async {

View file

@ -45,7 +45,7 @@ class WorkText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return StreamBuilder<Work>( return StreamBuilder<Work>(
stream: backend.db.workById(workId).watchSingle(), stream: backend.db.workById(workId).watchSingle(),
@ -61,7 +61,7 @@ class ComposersText extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return StreamBuilder<List<Person>>( return StreamBuilder<List<Person>>(
stream: backend.db.composersByWork(workId).watch(), stream: backend.db.composersByWork(workId).watch(),

18
common/pubspec.yaml Normal file
View file

@ -0,0 +1,18 @@
name: musicus_common
version: 0.1.0
description: Common building blocks for Musicus client apps.
environment:
sdk: ">=2.3.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
meta:
moor:
moor_ffi:
musicus_client:
path: ../client
musicus_database:
path: ../database
rxdart:

View file

@ -1,15 +1,18 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:musicus_common/musicus_common.dart';
import 'backend.dart';
import 'screens/home.dart'; import 'screens/home.dart';
import 'widgets/player_bar.dart'; import 'widgets/player_bar.dart';
class App extends StatelessWidget { class App extends StatelessWidget {
static const _platform = MethodChannel('de.johrpan.musicus/platform');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return MaterialApp( return MaterialApp(
title: 'Musicus', title: 'Musicus',
@ -36,11 +39,11 @@ class App extends StatelessWidget {
), ),
home: Builder( home: Builder(
builder: (context) { builder: (context) {
if (backend.status == BackendStatus.loading) { if (backend.status == MusicusBackendStatus.loading) {
return Material( return Material(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
); );
} else if (backend.status == BackendStatus.setup) { } else if (backend.status == MusicusBackendStatus.setup) {
return Material( return Material(
color: Theme.of(context).scaffoldBackgroundColor, color: Theme.of(context).scaffoldBackgroundColor,
child: Column( child: Column(
@ -57,8 +60,13 @@ class App extends StatelessWidget {
ListTile( ListTile(
leading: const Icon(Icons.folder_open), leading: const Icon(Icons.folder_open),
title: Text('Choose path'), title: Text('Choose path'),
onTap: () { onTap: () async {
backend.settings.chooseMusicLibraryUri(); final uri =
await _platform.invokeMethod<String>('openTree');
if (uri != null) {
backend.settings.setMusicLibraryPath(uri);
}
}, },
), ),
], ],
@ -82,7 +90,7 @@ class _ContentState extends State<Content> with SingleTickerProviderStateMixin {
final nestedNavigator = GlobalKey<NavigatorState>(); final nestedNavigator = GlobalKey<NavigatorState>();
AnimationController playerBarAnimation; AnimationController playerBarAnimation;
BackendState backend; MusicusBackendState backend;
StreamSubscription<bool> playerActiveSubscription; StreamSubscription<bool> playerActiveSubscription;
@override @override
@ -99,14 +107,14 @@ class _ContentState extends State<Content> with SingleTickerProviderStateMixin {
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
backend = Backend.of(context); backend = MusicusBackend.of(context);
playerBarAnimation.value = backend.player.active.value ? 1.0 : 0.0; playerBarAnimation.value = backend.playback.active.value ? 1.0 : 0.0;
if (playerActiveSubscription != null) { if (playerActiveSubscription != null) {
playerActiveSubscription.cancel(); playerActiveSubscription.cancel();
} }
playerActiveSubscription = backend.player.active.listen((active) => playerActiveSubscription = backend.playback.active.listen((active) =>
active ? playerBarAnimation.forward() : playerBarAnimation.reverse()); active ? playerBarAnimation.forward() : playerBarAnimation.reverse());
} }

View file

@ -1,176 +0,0 @@
import 'dart:io';
import 'dart:isolate';
import 'dart:ui';
import 'package:flutter/widgets.dart';
import 'package:moor/isolate.dart';
import 'package:moor/moor.dart';
import 'package:moor_ffi/moor_ffi.dart';
import 'package:musicus_client/musicus_client.dart';
import 'package:musicus_database/musicus_database.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as pp;
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
// 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> {
final player = Player();
final settings = Settings();
BackendStatus status = BackendStatus.loading;
Database db;
MusicusClient client;
MusicLibrary ml;
@override
void initState() {
super.initState();
_load();
}
@override
Widget build(BuildContext context) {
return _InheritedBackend(
child: widget.child,
state: this,
);
}
Future<void> _load() async {
MoorIsolate moorIsolate;
final moorPort = IsolateNameServer.lookupPortByName('moorPort');
if (moorPort != null) {
moorIsolate = MoorIsolate.fromConnectPort(moorPort);
} else {
moorIsolate = await _createMoorIsolate();
IsolateNameServer.registerPortWithName(
moorIsolate.connectPort, 'moorPort');
}
final dbConnection = await moorIsolate.connect();
db = Database.connect(dbConnection);
player.setup();
await settings.load();
_updateMusicLibrary(settings.musicLibraryUri.value);
settings.musicLibraryUri.listen((uri) {
_updateMusicLibrary(uri);
});
_updateClient(settings.server.value);
settings.server.listen((serverSettings) {
_updateClient(serverSettings);
});
}
Future<void> _updateMusicLibrary(String uri) async {
if (uri == null) {
setState(() {
status = BackendStatus.setup;
});
} else {
ml = MusicLibrary(uri);
await ml.load();
setState(() {
status = BackendStatus.ready;
});
}
}
Future<void> _updateClient(ServerSettings serverSettings) async {
client = MusicusClient(
host: serverSettings.host,
port: serverSettings.port,
basePath: serverSettings.basePath,
);
}
@override
void dispose() {
super.dispose();
client.dispose();
}
}
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;
}

View file

@ -1,12 +1,26 @@
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart' as pp;
import 'app.dart'; import 'app.dart';
import 'backend.dart'; import 'settings.dart';
import 'platform.dart';
import 'playback.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final dir = await pp.getApplicationDocumentsDirectory();
final dbPath = p.join(dir.path, 'db.sqlite');
void main() {
runApp(AudioServiceWidget( runApp(AudioServiceWidget(
child: Backend( child: MusicusBackend(
dbPath: dbPath,
settingsStorage: SettingsStorage(),
platform: MusicusAndroidPlatform(),
playback: Playback(),
child: App(), child: App(),
), ),
)); ));

View file

@ -1,44 +1,16 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:musicus_common/musicus_common.dart';
/// Object representing a document in Storage Access Framework terms. class MusicusAndroidPlatform extends MusicusPlatform {
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'); static const _platform = MethodChannel('de.johrpan.musicus/platform');
/// Get child documents. @override
/// Future<List<Document>> getChildren(String parentId) async {
/// [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 = final List<Map<dynamic, dynamic>> childrenJson =
await _platform.invokeListMethod( await _platform.invokeListMethod(
'getChildren', 'getChildren',
{ {
'treeUri': treeUri, 'treeUri': basePath,
'parentId': parentId, 'parentId': parentId,
}, },
); );
@ -48,65 +20,51 @@ class Platform {
.toList(); .toList();
} }
/// Read contents of file. @override
/// Future<String> getIdentifier(String parentId, String fileName) async {
/// [treeId] is the base URI from the SAF, [id] is the document ID of the return await _platform.invokeMethod(
/// file. 'getUriByName',
static Future<String> readFile(String treeUri, String id) async { {
'treeUri': basePath,
'parentId': parentId,
'fileName': fileName,
},
);
}
@override
Future<String> readDocument(String id) async {
return await _platform.invokeMethod( return await _platform.invokeMethod(
'readFile', 'readFile',
{ {
'treeUri': treeUri, 'treeUri': basePath,
'id': id, 'id': id,
}, },
); );
} }
/// Get document URI by file name @override
/// Future<String> readDocumentByName(String parentId, String fileName) async {
/// [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( return await _platform.invokeMethod(
'readFileByName', 'readFileByName',
{ {
'treeUri': treeUri, 'treeUri': basePath,
'parentId': parentId, 'parentId': parentId,
'fileName': fileName, 'fileName': fileName,
}, },
); );
} }
/// Write to file by name @override
/// Future<void> writeDocumentByName(
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of String parentId, String fileName, String contents) async {
/// the parent directory.
static Future<void> writeFileByName(
String treeUri, String parentId, String fileName, String content) async {
await _platform.invokeMethod( await _platform.invokeMethod(
'writeFileByName', 'writeFileByName',
{ {
'treeUri': treeUri, 'treeUri': basePath,
'parentId': parentId, 'parentId': parentId,
'fileName': fileName, 'fileName': fileName,
'content': content, 'content': contents,
}, },
); );
} }

View file

@ -6,10 +6,8 @@ import 'dart:ui';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:moor/isolate.dart'; import 'package:moor/isolate.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:musicus_player/musicus_player.dart'; import 'package:musicus_player/musicus_player.dart';
import 'package:rxdart/rxdart.dart';
import 'music_library.dart';
const _portName = 'playbackService'; const _portName = 'playbackService';
@ -18,61 +16,11 @@ void _playbackServiceEntrypoint() {
AudioServiceBackground.run(() => _PlaybackService()); AudioServiceBackground.run(() => _PlaybackService());
} }
class Player { class Playback extends MusicusPlayback {
/// Whether the player is active.
///
/// This means, that there is at least one item in the queue and the playback
/// service is ready to play.
final active = BehaviorSubject.seeded(false);
/// The current playlist.
///
/// If the player is not active, this will be an empty list.
final playlist = BehaviorSubject.seeded(<InternalTrack>[]);
/// Index of the currently played (or paused) track within the playlist.
///
/// This will be zero, if the player is not active!
final currentIndex = BehaviorSubject.seeded(0);
/// The currently played track.
///
/// This will be null, if there is no current track.
final currentTrack = BehaviorSubject<InternalTrack>.seeded(null);
/// Whether we are currently playing or not.
///
/// This will be false, if the player is not active.
final playing = BehaviorSubject.seeded(false);
/// Current playback position.
///
/// If the player is not active, this will default to zero.
final position = BehaviorSubject.seeded(const Duration());
/// Duration of the current track.
///
/// If the player is not active, the duration will default to 1 s.
final duration = BehaviorSubject.seeded(const Duration(seconds: 1));
/// Playback position normalized to the range from zero to one.
final normalizedPosition = BehaviorSubject.seeded(0.0);
StreamSubscription _playbackServiceStateSubscription; StreamSubscription _playbackServiceStateSubscription;
/// Set everything to its default because the playback service was stopped.
void _stop() {
active.add(false);
playlist.add([]);
currentIndex.add(0);
playing.add(false);
position.add(const Duration());
duration.add(const Duration(seconds: 1));
normalizedPosition.add(0.0);
}
/// Start playback service. /// Start playback service.
Future<void> start() async { Future<void> _start() async {
if (!AudioService.running) { if (!AudioService.running) {
await AudioService.start( await AudioService.start(
backgroundTaskEntrypoint: _playbackServiceEntrypoint, backgroundTaskEntrypoint: _playbackServiceEntrypoint,
@ -109,8 +57,8 @@ class Player {
currentTrack.add(playlist.value[index]); currentTrack.add(playlist.value[index]);
} }
/// Connect listeners and initialize streams. @override
void setup() { Future<void> setup() async {
if (_playbackServiceStateSubscription != null) { if (_playbackServiceStateSubscription != null) {
_playbackServiceStateSubscription.cancel(); _playbackServiceStateSubscription.cancel();
} }
@ -125,8 +73,12 @@ class Player {
).listen((msg) { ).listen((msg) {
// If state is null, the background audio service has stopped. // If state is null, the background audio service has stopped.
if (msg == null) { if (msg == null) {
_stop(); dispose();
} else { } else {
if (!active.value) {
active.add(true);
}
if (msg is _StatusMessage) { if (msg is _StatusMessage) {
playing.add(msg.playing); playing.add(msg.playing);
} else if (msg is _PositionMessage) { } else if (msg is _PositionMessage) {
@ -154,9 +106,23 @@ class Player {
} }
} }
/// Toggle whether the player is playing or paused. @override
/// Future<void> addTracks(List<InternalTrack> tracks) async {
/// If the player is not active, this will do nothing. if (!AudioService.running) {
await _start();
}
await AudioService.customAction('addTracks', jsonEncode(tracks));
}
@override
Future<void> removeTrack(int index) async {
if (AudioService.running) {
await AudioService.customAction('removeTrack', index);
}
}
@override
Future<void> playPause() async { Future<void> playPause() async {
if (active.value) { if (active.value) {
if (playing.value) { if (playing.value) {
@ -167,29 +133,7 @@ class Player {
} }
} }
/// Add a list of tracks to the players playlist. @override
Future<void> addTracks(List<InternalTrack> tracks) async {
if (!AudioService.running) {
await start();
}
await AudioService.customAction('addTracks', jsonEncode(tracks));
}
/// Remove the track at [index] from the playlist.
///
/// If the player is not active or an invalid value is provided, this will do
/// nothing.
Future<void> removeTrack(int index) async {
if (AudioService.running) {
await AudioService.customAction('removeTrack', index);
}
}
/// 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 { Future<void> seekTo(double pos) async {
if (active.value && pos >= 0.0 && pos <= 1.0) { if (active.value && pos >= 0.0 && pos <= 1.0) {
final durationMs = duration.value.inMilliseconds; final durationMs = duration.value.inMilliseconds;
@ -197,45 +141,31 @@ class Player {
} }
} }
/// Play the previous track in the playlist. @override
///
/// If the player is not active or there is no previous track, this will do
/// nothing.
Future<void> skipToNext() async {
if (AudioService.running) {
await AudioService.skipToNext();
}
}
/// Skip to the next track in the playlist.
///
/// If the player is not active or there is no next track, this will do
/// nothing. If more than five seconds of the current track have been played,
/// this will go back to its beginning instead.
Future<void> skipToPrevious() async { Future<void> skipToPrevious() async {
if (AudioService.running) { if (AudioService.running) {
await AudioService.skipToPrevious(); await AudioService.skipToPrevious();
} }
} }
/// Switch to the track with the index [index] in the playlist. @override
Future<void> skipToNext() async {
if (AudioService.running) {
await AudioService.skipToNext();
}
}
@override
Future<void> skipTo(int index) async { Future<void> skipTo(int index) async {
if (AudioService.running) { if (AudioService.running) {
await AudioService.customAction('skipTo', index); await AudioService.customAction('skipTo', index);
} }
} }
/// Tidy up. @override
void dispose() { void dispose() {
super.dispose();
_playbackServiceStateSubscription.cancel(); _playbackServiceStateSubscription.cancel();
active.close();
playlist.close();
currentIndex.close();
currentTrack.close();
playing.close();
position.close();
duration.close();
normalizedPosition.close();
} }
} }
@ -363,7 +293,7 @@ class _PlaybackService extends BackgroundAudioTask {
/// Initialize database. /// Initialize database.
Future<void> _load() async { Future<void> _load() async {
final moorPort = IsolateNameServer.lookupPortByName('moorPort'); final moorPort = IsolateNameServer.lookupPortByName('moor');
final moorIsolate = MoorIsolate.fromConnectPort(moorPort); final moorIsolate = MoorIsolate.fromConnectPort(moorPort);
db = Database.connect(await moorIsolate.connect()); db = Database.connect(await moorIsolate.connect());
_loading.complete(); _loading.complete();
@ -397,7 +327,7 @@ class _PlaybackService extends BackgroundAudioTask {
final title = workInfo.work.title; final title = workInfo.work.title;
AudioServiceBackground.setMediaItem(MediaItem( AudioServiceBackground.setMediaItem(MediaItem(
id: track.uri, id: track.identifier,
album: composers, album: composers,
title: title, title: title,
)); ));
@ -456,7 +386,7 @@ class _PlaybackService extends BackgroundAudioTask {
/// Set the current track, update the player and notify the system. /// Set the current track, update the player and notify the system.
Future<void> _setCurrentTrack(int index) async { Future<void> _setCurrentTrack(int index) async {
_currentTrack = index; _currentTrack = index;
_durationMs = await _player.setUri(_playlist[_currentTrack].uri); _durationMs = await _player.setUri(_playlist[_currentTrack].identifier);
_setState(); _setState();
} }
@ -508,7 +438,7 @@ class _PlaybackService extends BackgroundAudioTask {
} }
@override @override
void onCustomAction(String name, dynamic arguments) { Future<void> onCustomAction(String name, dynamic arguments) async {
super.onCustomAction(name, arguments); super.onCustomAction(name, arguments);
// addTracks expects a List<Map<String, dynamic>> as its argument. // addTracks expects a List<Map<String, dynamic>> as its argument.

View file

@ -1,10 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
import '../editors/tracks.dart';
import '../icons.dart'; import '../icons.dart';
import '../widgets/lists.dart';
import 'person.dart'; import 'person.dart';
import 'settings.dart'; import 'settings.dart';
@ -19,7 +17,7 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View file

@ -1,10 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
import '../editors/person.dart';
import '../widgets/lists.dart';
import 'work.dart'; import 'work.dart';
class PersonScreen extends StatefulWidget { class PersonScreen extends StatefulWidget {
@ -23,7 +20,7 @@ class _PersonScreenState extends State<PersonScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(

View file

@ -1,12 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
import '../music_library.dart';
import '../widgets/play_pause_button.dart'; import '../widgets/play_pause_button.dart';
import '../widgets/recording_tile.dart';
class ProgramScreen extends StatefulWidget { class ProgramScreen extends StatefulWidget {
@override @override
@ -14,7 +12,7 @@ class ProgramScreen extends StatefulWidget {
} }
class _ProgramScreenState extends State<ProgramScreen> { class _ProgramScreenState extends State<ProgramScreen> {
BackendState backend; MusicusBackendState backend;
StreamSubscription<bool> playerActiveSubscription; StreamSubscription<bool> playerActiveSubscription;
@ -29,14 +27,14 @@ class _ProgramScreenState extends State<ProgramScreen> {
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
backend = Backend.of(context); backend = MusicusBackend.of(context);
if (playerActiveSubscription != null) { if (playerActiveSubscription != null) {
playerActiveSubscription.cancel(); playerActiveSubscription.cancel();
} }
// Close the program screen, if the player is no longer active. // Close the program screen, if the player is no longer active.
playerActiveSubscription = backend.player.active.listen((active) { playerActiveSubscription = backend.playback.active.listen((active) {
if (!active) { if (!active) {
Navigator.pop(context); Navigator.pop(context);
} }
@ -46,7 +44,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
playlistSubscription.cancel(); playlistSubscription.cancel();
} }
playlistSubscription = backend.player.playlist.listen((playlist) { playlistSubscription = backend.playback.playlist.listen((playlist) {
updateProgram(playlist); updateProgram(playlist);
}); });
@ -54,7 +52,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
positionSubscription.cancel(); positionSubscription.cancel();
} }
positionSubscription = backend.player.normalizedPosition.listen((pos) { positionSubscription = backend.playback.normalizedPosition.listen((pos) {
if (!seeking) { if (!seeking) {
setState(() { setState(() {
position = pos; position = pos;
@ -154,7 +152,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
title: Text('Program'), title: Text('Program'),
), ),
body: StreamBuilder<int>( body: StreamBuilder<int>(
stream: backend.player.currentIndex, stream: backend.playback.currentIndex,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return ListView.builder( return ListView.builder(
@ -181,7 +179,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
], ],
), ),
onTap: () { onTap: () {
backend.player.skipTo(index); backend.playback.skipTo(index);
}, },
onLongPress: () { onLongPress: () {
showDialog( showDialog(
@ -192,7 +190,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
ListTile( ListTile(
title: Text('Remove from playlist'), title: Text('Remove from playlist'),
onTap: () { onTap: () {
backend.player.removeTrack(index); backend.playback.removeTrack(index);
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
@ -220,7 +218,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
}, },
onChangeEnd: (pos) { onChangeEnd: (pos) {
seeking = false; seeking = false;
backend.player.seekTo(pos); backend.playback.seekTo(pos);
}, },
onChanged: (pos) { onChanged: (pos) {
setState(() { setState(() {
@ -233,7 +231,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
Padding( Padding(
padding: const EdgeInsets.only(left: 24.0), padding: const EdgeInsets.only(left: 24.0),
child: StreamBuilder<Duration>( child: StreamBuilder<Duration>(
stream: backend.player.position, stream: backend.playback.position,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return DurationText(snapshot.data); return DurationText(snapshot.data);
@ -247,21 +245,21 @@ class _ProgramScreenState extends State<ProgramScreen> {
IconButton( IconButton(
icon: const Icon(Icons.skip_previous), icon: const Icon(Icons.skip_previous),
onPressed: () { onPressed: () {
backend.player.skipToPrevious(); backend.playback.skipToPrevious();
}, },
), ),
PlayPauseButton(), PlayPauseButton(),
IconButton( IconButton(
icon: const Icon(Icons.skip_next), icon: const Icon(Icons.skip_next),
onPressed: () { onPressed: () {
backend.player.skipToNext(); backend.playback.skipToNext();
}, },
), ),
Spacer(), Spacer(),
Padding( Padding(
padding: const EdgeInsets.only(right: 20.0), padding: const EdgeInsets.only(right: 20.0),
child: StreamBuilder<Duration>( child: StreamBuilder<Duration>(
stream: backend.player.duration, stream: backend.playback.duration,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return DurationText(snapshot.data); return DurationText(snapshot.data);

View file

@ -1,9 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_common/musicus_common.dart';
import '../backend.dart';
import '../settings.dart';
class ServerSettingsScreen extends StatefulWidget { class ServerSettingsScreen extends StatefulWidget {
@override @override
@ -13,16 +11,16 @@ class ServerSettingsScreen extends StatefulWidget {
class _ServerSettingsScreenState extends State<ServerSettingsScreen> { class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
final hostController = TextEditingController(); final hostController = TextEditingController();
final portController = TextEditingController(); final portController = TextEditingController();
final basePathController = TextEditingController(); final apiPathController = TextEditingController();
BackendState backend; MusicusBackendState backend;
StreamSubscription<ServerSettings> serverSubscription; StreamSubscription<MusicusServerSettings> serverSubscription;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
backend = Backend.of(context); backend = MusicusBackend.of(context);
if (serverSubscription != null) { if (serverSubscription != null) {
serverSubscription.cancel(); serverSubscription.cancel();
@ -34,10 +32,10 @@ class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
}); });
} }
void _settingsChanged(ServerSettings settings) { void _settingsChanged(MusicusServerSettings settings) {
hostController.text = settings.host; hostController.text = settings.host;
portController.text = settings.port.toString(); portController.text = settings.port.toString();
basePathController.text = settings.basePath; apiPathController.text = settings.apiPath;
} }
@override @override
@ -50,15 +48,15 @@ class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
icon: const Icon(Icons.restore), icon: const Icon(Icons.restore),
tooltip: 'Reset to default', tooltip: 'Reset to default',
onPressed: () { onPressed: () {
backend.settings.resetServerSettings(); backend.settings.resetServer();
}, },
), ),
FlatButton( FlatButton(
onPressed: () async { onPressed: () async {
await backend.settings.setServerSettings(ServerSettings( await backend.settings.setServer(MusicusServerSettings(
host: hostController.text, host: hostController.text,
port: int.parse(portController.text), port: int.parse(portController.text),
basePath: basePathController.text, apiPath: apiPathController.text,
)); ));
Navigator.pop(context); Navigator.pop(context);
@ -91,7 +89,7 @@ class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: TextField( child: TextField(
controller: basePathController, controller: apiPathController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'API path', labelText: 'API path',
), ),

View file

@ -1,14 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../backend.dart'; import 'package:musicus_common/musicus_common.dart';
import '../settings.dart';
import 'server_settings.dart'; import 'server_settings.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
static const _platform = MethodChannel('de.johrpan.musicus/platform');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
final settings = backend.settings; final settings = backend.settings;
return Scaffold( return Scaffold(
@ -18,18 +19,23 @@ class SettingsScreen extends StatelessWidget {
body: ListView( body: ListView(
children: <Widget>[ children: <Widget>[
StreamBuilder<String>( StreamBuilder<String>(
stream: settings.musicLibraryUri, stream: settings.musicLibraryPath,
builder: (context, snapshot) { builder: (context, snapshot) {
return ListTile( return ListTile(
title: Text('Music library path'), title: Text('Music library path'),
subtitle: Text(snapshot.data ?? 'Choose folder'), subtitle: Text(snapshot.data ?? 'Choose folder'),
isThreeLine: snapshot.hasData, isThreeLine: snapshot.hasData,
onTap: () { onTap: () async {
settings.chooseMusicLibraryUri(); final uri =
await _platform.invokeMethod<String>('openTree');
if (uri != null) {
settings.setMusicLibraryPath(uri);
}
}, },
); );
}), }),
StreamBuilder<ServerSettings>( StreamBuilder<MusicusServerSettings>(
stream: settings.server, stream: settings.server,
builder: (context, snapshot) { builder: (context, snapshot) {
final s = snapshot.data; final s = snapshot.data;
@ -37,10 +43,10 @@ class SettingsScreen extends StatelessWidget {
return ListTile( return ListTile(
title: Text('Musicus server'), title: Text('Musicus server'),
subtitle: Text( subtitle: Text(
s != null ? '${s.host}:${s.port}${s.basePath}' : '...'), s != null ? '${s.host}:${s.port}${s.apiPath}' : '...'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () async { onTap: () async {
final ServerSettings result = await Navigator.push( final MusicusServerSettings result = await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ServerSettingsScreen(), builder: (context) => ServerSettingsScreen(),
@ -48,7 +54,7 @@ class SettingsScreen extends StatelessWidget {
); );
if (result != null) { if (result != null) {
settings.setServerSettings(result); settings.setServer(result);
} }
}, },
); );

View file

@ -1,11 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
import '../editors/work.dart';
import '../widgets/texts.dart';
import '../widgets/lists.dart';
class WorkScreen extends StatelessWidget { class WorkScreen extends StatelessWidget {
final WorkInfo workInfo; final WorkInfo workInfo;
@ -15,7 +11,7 @@ class WorkScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -46,11 +42,11 @@ class WorkScreen extends StatelessWidget {
performanceInfos: recordingInfo.performances, performanceInfos: recordingInfo.performances,
), ),
onTap: () { onTap: () {
final tracks = backend.ml.tracks[recordingInfo.recording.id]; final tracks = backend.library.tracks[recordingInfo.recording.id];
tracks.sort((t1, t2) => t1.track.index.compareTo(t2.track.index)); tracks.sort((t1, t2) => t1.track.index.compareTo(t2.track.index));
backend.player backend.playback
.addTracks(backend.ml.tracks[recordingInfo.recording.id]); .addTracks(backend.library.tracks[recordingInfo.recording.id]);
}, },
), ),
), ),

View file

@ -1,102 +1,30 @@
import 'package:flutter/services.dart'; import 'package:musicus_common/musicus_common.dart';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
/// Settings concerning the Musicus server to connect to. class SettingsStorage extends MusicusSettingsStorage {
/// SharedPreferences _pref;
/// 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<String>();
/// Musicus server to connect to.
final server = BehaviorSubject<ServerSettings>();
SharedPreferences _shPref;
/// Initialize the settings.
Future<void> load() async { Future<void> load() async {
_shPref = await SharedPreferences.getInstance(); _pref = 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. @override
Future<void> chooseMusicLibraryUri() async { Future<int> getInt(String key) {
final uri = await _platform.invokeMethod<String>('openTree'); return Future.value(_pref.getInt(key));
if (uri != null) {
musicLibraryUri.add(uri);
await _shPref.setString('musicLibraryUri', uri);
}
} }
/// Change the Musicus server settings. @override
Future<void> setServerSettings(ServerSettings settings) async { Future<String> getString(String key) {
await _shPref.setString('serverHost', settings.host); return Future.value(_pref.getString(key));
await _shPref.setInt('serverPort', settings.port);
await _shPref.setString('serverBasePath', settings.basePath);
server.add(settings);
} }
/// Reset the server settings to their defaults. @override
Future<void> resetServerSettings() async { Future<void> setInt(String key, int value) async {
await setServerSettings(ServerSettings( await _pref.setInt(key, value);
host: defaultHost,
port: defaultPort,
basePath: defaultBasePath,
));
} }
/// Tidy up. @override
void dispose() { Future<void> setString(String key, String value) async {
musicLibraryUri.close(); await _pref.setString(key, value);
server.close();
} }
} }

View file

@ -1,8 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_common/musicus_common.dart';
import '../backend.dart';
class PlayPauseButton extends StatefulWidget { class PlayPauseButton extends StatefulWidget {
@override @override
@ -12,7 +11,7 @@ class PlayPauseButton extends StatefulWidget {
class _PlayPauseButtonState extends State<PlayPauseButton> class _PlayPauseButtonState extends State<PlayPauseButton>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
AnimationController playPauseAnimation; AnimationController playPauseAnimation;
BackendState backend; MusicusBackendState backend;
StreamSubscription<bool> playingSubscription; StreamSubscription<bool> playingSubscription;
@override @override
@ -29,14 +28,14 @@ class _PlayPauseButtonState extends State<PlayPauseButton>
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
backend = Backend.of(context); backend = MusicusBackend.of(context);
playPauseAnimation.value = backend.player.playing.value ? 1.0 : 0.0; playPauseAnimation.value = backend.playback.playing.value ? 1.0 : 0.0;
if (playingSubscription != null) { if (playingSubscription != null) {
playingSubscription.cancel(); playingSubscription.cancel();
} }
playingSubscription = backend.player.playing.listen((playing) => playingSubscription = backend.playback.playing.listen((playing) =>
playing ? playPauseAnimation.forward() : playPauseAnimation.reverse()); playing ? playPauseAnimation.forward() : playPauseAnimation.reverse());
} }
@ -47,7 +46,7 @@ class _PlayPauseButtonState extends State<PlayPauseButton>
icon: AnimatedIcons.play_pause, icon: AnimatedIcons.play_pause,
progress: playPauseAnimation, progress: playPauseAnimation,
), ),
onPressed: backend.player.playPause, onPressed: backend.playback.playPause,
); );
} }

View file

@ -1,17 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:musicus_common/musicus_common.dart';
import 'package:musicus_database/musicus_database.dart'; import 'package:musicus_database/musicus_database.dart';
import '../backend.dart';
import '../music_library.dart';
import '../screens/program.dart'; import '../screens/program.dart';
import 'play_pause_button.dart'; import 'play_pause_button.dart';
import 'texts.dart';
class PlayerBar extends StatelessWidget { class PlayerBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final backend = Backend.of(context); final backend = MusicusBackend.of(context);
return BottomAppBar( return BottomAppBar(
child: InkWell( child: InkWell(
@ -19,7 +17,7 @@ class PlayerBar extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
StreamBuilder( StreamBuilder(
stream: backend.player.normalizedPosition, stream: backend.playback.normalizedPosition,
builder: (context, snapshot) => LinearProgressIndicator( builder: (context, snapshot) => LinearProgressIndicator(
value: snapshot.data, value: snapshot.data,
), ),
@ -32,7 +30,7 @@ class PlayerBar extends StatelessWidget {
), ),
Expanded( Expanded(
child: StreamBuilder<InternalTrack>( child: StreamBuilder<InternalTrack>(
stream: backend.player.currentTrack, stream: backend.playback.currentTrack,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.data != null) { if (snapshot.data != null) {
final recordingId = snapshot.data.track.recordingId; final recordingId = snapshot.data.track.recordingId;

View file

@ -17,6 +17,8 @@ dependencies:
moor_ffi: moor_ffi:
musicus_client: musicus_client:
path: ../client path: ../client
musicus_common:
path: ../common
musicus_database: musicus_database:
path: ../database path: ../database
musicus_player: musicus_player: