From 711b19c9985136b0b1b099743ff05144c2dd923d Mon Sep 17 00:00:00 2001 From: Elias Projahn Date: Mon, 4 May 2020 21:49:44 +0200 Subject: [PATCH] Move reusable code from mobile to common This will be useful for a future desktop application. --- README.md | 13 +- common/.gitignore | 31 +++ common/lib/musicus_common.dart | 24 ++ common/lib/src/backend.dart | 221 ++++++++++++++++++ .../lib/src}/editors/ensemble.dart | 2 +- .../lib/src}/editors/instrument.dart | 2 +- .../lib/src}/editors/performance.dart | 0 .../lib/src}/editors/person.dart | 2 +- .../lib/src}/editors/recording.dart | 4 +- .../lib/src}/editors/tracks.dart | 8 +- .../lib => common/lib/src}/editors/work.dart | 2 +- .../lib/src/library.dart | 44 ++-- common/lib/src/platform.dart | 76 ++++++ common/lib/src/playback.dart | 96 ++++++++ .../lib/src}/selectors/ensemble.dart | 0 .../lib/src}/selectors/files.dart | 9 +- .../lib/src}/selectors/instruments.dart | 2 +- .../lib/src}/selectors/person.dart | 0 .../lib/src}/selectors/recording.dart | 0 .../lib/src}/selectors/work.dart | 0 common/lib/src/settings.dart | 112 +++++++++ .../lib => common/lib/src}/widgets/lists.dart | 8 +- .../lib/src}/widgets/recording_tile.dart | 0 .../lib => common/lib/src}/widgets/texts.dart | 4 +- common/pubspec.yaml | 18 ++ mobile/lib/app.dart | 28 ++- mobile/lib/backend.dart | 176 -------------- mobile/lib/main.dart | 20 +- mobile/lib/platform.dart | 98 +++----- mobile/lib/{player.dart => playback.dart} | 156 ++++--------- mobile/lib/screens/home.dart | 6 +- mobile/lib/screens/person.dart | 7 +- mobile/lib/screens/program.dart | 30 ++- mobile/lib/screens/server_settings.dart | 24 +- mobile/lib/screens/settings.dart | 28 ++- mobile/lib/screens/work.dart | 14 +- mobile/lib/settings.dart | 104 ++------- mobile/lib/widgets/play_pause_button.dart | 13 +- mobile/lib/widgets/player_bar.dart | 10 +- mobile/pubspec.yaml | 2 + 40 files changed, 813 insertions(+), 581 deletions(-) create mode 100644 common/.gitignore create mode 100644 common/lib/musicus_common.dart create mode 100644 common/lib/src/backend.dart rename {mobile/lib => common/lib/src}/editors/ensemble.dart (98%) rename {mobile/lib => common/lib/src}/editors/instrument.dart (98%) rename {mobile/lib => common/lib/src}/editors/performance.dart (100%) rename {mobile/lib => common/lib/src}/editors/person.dart (98%) rename {mobile/lib => common/lib/src}/editors/recording.dart (98%) rename {mobile/lib => common/lib/src}/editors/tracks.dart (96%) rename {mobile/lib => common/lib/src}/editors/work.dart (99%) rename mobile/lib/music_library.dart => common/lib/src/library.dart (81%) create mode 100644 common/lib/src/platform.dart create mode 100644 common/lib/src/playback.dart rename {mobile/lib => common/lib/src}/selectors/ensemble.dart (100%) rename {mobile/lib => common/lib/src}/selectors/files.dart (95%) rename {mobile/lib => common/lib/src}/selectors/instruments.dart (98%) rename {mobile/lib => common/lib/src}/selectors/person.dart (100%) rename {mobile/lib => common/lib/src}/selectors/recording.dart (100%) rename {mobile/lib => common/lib/src}/selectors/work.dart (100%) create mode 100644 common/lib/src/settings.dart rename {mobile/lib => common/lib/src}/widgets/lists.dart (97%) rename {mobile/lib => common/lib/src}/widgets/recording_tile.dart (100%) rename {mobile/lib => common/lib/src}/widgets/texts.dart (94%) create mode 100644 common/pubspec.yaml delete mode 100644 mobile/lib/backend.dart rename mobile/lib/{player.dart => playback.dart} (80%) diff --git a/README.md b/README.md index 81aaf41..06235b1 100644 --- a/README.md +++ b/README.md @@ -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 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 [Flutter toolkit](https://flutter.dev) and only runs on Android for now. `player` – The simplest possible audio player plugin. This is used by the 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 Picking up Dart as a programming language and Flutter as an UI toolkit should diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 0000000..a242fcb --- /dev/null +++ b/common/.gitignore @@ -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/ diff --git a/common/lib/musicus_common.dart b/common/lib/musicus_common.dart new file mode 100644 index 0000000..1c4d383 --- /dev/null +++ b/common/lib/musicus_common.dart @@ -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'; \ No newline at end of file diff --git a/common/lib/src/backend.dart b/common/lib/src/backend.dart new file mode 100644 index 0000000..f1ed9aa --- /dev/null +++ b/common/lib/src/backend.dart @@ -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 { + /// 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 _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 _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; +} diff --git a/mobile/lib/editors/ensemble.dart b/common/lib/src/editors/ensemble.dart similarity index 98% rename from mobile/lib/editors/ensemble.dart rename to common/lib/src/editors/ensemble.dart index 8fbae4d..c45f36b 100644 --- a/mobile/lib/editors/ensemble.dart +++ b/common/lib/src/editors/ensemble.dart @@ -30,7 +30,7 @@ class _EnsembleEditorState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Scaffold( appBar: AppBar( diff --git a/mobile/lib/editors/instrument.dart b/common/lib/src/editors/instrument.dart similarity index 98% rename from mobile/lib/editors/instrument.dart rename to common/lib/src/editors/instrument.dart index b95c349..2c08da1 100644 --- a/mobile/lib/editors/instrument.dart +++ b/common/lib/src/editors/instrument.dart @@ -30,7 +30,7 @@ class _InstrumentEditorState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Scaffold( appBar: AppBar( diff --git a/mobile/lib/editors/performance.dart b/common/lib/src/editors/performance.dart similarity index 100% rename from mobile/lib/editors/performance.dart rename to common/lib/src/editors/performance.dart diff --git a/mobile/lib/editors/person.dart b/common/lib/src/editors/person.dart similarity index 98% rename from mobile/lib/editors/person.dart rename to common/lib/src/editors/person.dart index 68d5059..ae4b2ed 100644 --- a/mobile/lib/editors/person.dart +++ b/common/lib/src/editors/person.dart @@ -32,7 +32,7 @@ class _PersonEditorState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Scaffold( appBar: AppBar( diff --git a/mobile/lib/editors/recording.dart b/common/lib/src/editors/recording.dart similarity index 98% rename from mobile/lib/editors/recording.dart rename to common/lib/src/editors/recording.dart index cb74ff3..f14e996 100644 --- a/mobile/lib/editors/recording.dart +++ b/common/lib/src/editors/recording.dart @@ -36,7 +36,7 @@ class _RecordingEditorState extends State { super.initState(); if (widget.recordingInfo != null) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); () async { workInfo = await backend.db.getWork(widget.recordingInfo.recording.id); @@ -47,7 +47,7 @@ class _RecordingEditorState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); Future selectWork() async { final WorkInfo newWorkInfo = await Navigator.push( diff --git a/mobile/lib/editors/tracks.dart b/common/lib/src/editors/tracks.dart similarity index 96% rename from mobile/lib/editors/tracks.dart rename to common/lib/src/editors/tracks.dart index b08d1b6..eb76ebc 100644 --- a/mobile/lib/editors/tracks.dart +++ b/common/lib/src/editors/tracks.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:musicus_database/musicus_database.dart'; import '../backend.dart'; -import '../music_library.dart'; +import '../library.dart'; import '../selectors/files.dart'; import '../selectors/recording.dart'; import '../widgets/recording_tile.dart'; @@ -21,7 +21,7 @@ class TracksEditor extends StatefulWidget { } class _TracksEditorState extends State { - BackendState backend; + MusicusBackendState backend; WorkInfo workInfo; RecordingInfo recordingInfo; String parentId; @@ -29,7 +29,7 @@ class _TracksEditorState extends State { @override Widget build(BuildContext context) { - backend = Backend.of(context); + backend = MusicusBackend.of(context); return Scaffold( appBar: AppBar( @@ -57,7 +57,7 @@ class _TracksEditorState extends State { backend.db.updateWork(workInfo); backend.db.updateRecording(recordingInfo); - backend.ml.addTracks(parentId, tracks); + backend.library.addTracks(parentId, tracks); Navigator.pop(context); }, diff --git a/mobile/lib/editors/work.dart b/common/lib/src/editors/work.dart similarity index 99% rename from mobile/lib/editors/work.dart rename to common/lib/src/editors/work.dart index fe371db..74f443f 100644 --- a/mobile/lib/editors/work.dart +++ b/common/lib/src/editors/work.dart @@ -200,7 +200,7 @@ class _WorkEditorState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); final List partTiles = []; for (var i = 0; i < parts.length; i++) { diff --git a/mobile/lib/music_library.dart b/common/lib/src/library.dart similarity index 81% rename from mobile/lib/music_library.dart rename to common/lib/src/library.dart index b0d6a64..9b2e117 100644 --- a/mobile/lib/music_library.dart +++ b/common/lib/src/library.dart @@ -1,32 +1,31 @@ import 'dart:convert'; -import 'package:flutter/services.dart'; - import 'platform.dart'; -/// Bundles a [Track] with the URI of the audio file it represents. -/// -/// The uri shouldn't be stored on disk, but will be used at runtime. +/// Bundles a [Track] with information on how to find the corresponding file. class InternalTrack { /// The represented track. final Track track; - /// The URI of the represented audio file as retrieved from the SAF. - final String uri; + /// A string identifying the track for playback. + /// + /// This will be the result of calling the platform objects getIdentifier() + /// function with the file name of the track. + final String identifier; InternalTrack({ this.track, - this.uri, + this.identifier, }); factory InternalTrack.fromJson(Map json) => InternalTrack( track: Track.fromJson(json['track']), - uri: json['uri'], + identifier: json['identifier'], ); Map toJson() => { 'track': track.toJson(), - 'uri': uri, + 'identifier': identifier, }; } @@ -106,13 +105,12 @@ class MusicusFile { } /// Manager for all available tracks and their representation on disk. -class MusicLibrary { - static const platform = MethodChannel('de.johrpan.musicus/platform'); +class MusicusLibrary { + /// String representing the music library base path. + final String basePath; - /// URI of the music library folder. - /// - /// This is a tree URI in the terms of the Android Storage Access Framework. - final String treeUri; + /// Access to platform dependent functionality. + final MusicusPlatform platform; /// Map of all available tracks by recording ID. /// @@ -120,7 +118,7 @@ class MusicLibrary { /// audio file alongside the real [Track] object. final Map> tracks = {}; - MusicLibrary(this.treeUri); + MusicusLibrary(this.basePath, this.platform); /// Load all available tracks. /// @@ -130,13 +128,13 @@ class MusicLibrary { Future load() async { // TODO: Consider capping the recursion somewhere. Future recurse([String parentId]) async { - final children = await Platform.getChildren(treeUri, parentId); + final children = await platform.getChildren(parentId); for (final child in children) { if (child.isDirectory) { recurse(child.id); } else if (child.name == 'musicus.json') { - final content = await Platform.readFile(treeUri, child.id); + final content = await platform.readDocument(child.id); final musicusFile = MusicusFile.fromJson(jsonDecode(content)); for (final track in musicusFile.tracks) { _indexTrack(parentId, track); @@ -156,7 +154,7 @@ class MusicLibrary { MusicusFile musicusFile; final oldContent = - await Platform.readFileByName(treeUri, parentId, 'musicus.json'); + await platform.readDocumentByName(parentId, 'musicus.json'); if (oldContent != null) { musicusFile = MusicusFile.fromJson(jsonDecode(oldContent)); @@ -169,15 +167,15 @@ class MusicLibrary { musicusFile.tracks.add(track); } - await Platform.writeFileByName( - treeUri, parentId, 'musicus.json', jsonEncode(musicusFile.toJson())); + await platform.writeDocumentByName( + parentId, 'musicus.json', jsonEncode(musicusFile.toJson())); } /// Add a track to the map of available tracks. Future _indexTrack(String parentId, Track track) async { final iTrack = InternalTrack( track: track, - uri: await Platform.getUriByName(treeUri, parentId, track.fileName), + identifier: await platform.getIdentifier(parentId, track.fileName), ); if (tracks.containsKey(track.recordingId)) { diff --git a/common/lib/src/platform.dart b/common/lib/src/platform.dart new file mode 100644 index 0000000..e33a7ca --- /dev/null +++ b/common/lib/src/platform.dart @@ -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 here, as we get casting errors otherwise. This + // won't be typesafe anyway. + Document.fromJson(Map 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> getChildren(String parentId); + + /// Read the contents of a document by ID. + Future readDocument(String id); + + /// Read from a document by name. + /// + /// [parentId] is the document ID of the parent directory. + Future 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 getIdentifier(String parentId, String fileName); + + /// Write to a document by name. + /// + /// [parentId] is the document ID of the parent directory. + Future writeDocumentByName( + String parentId, String fileName, String contents); +} diff --git a/common/lib/src/playback.dart b/common/lib/src/playback.dart new file mode 100644 index 0000000..6de8a16 --- /dev/null +++ b/common/lib/src/playback.dart @@ -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([]); + + /// 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.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 setup(); + + /// Add a list of tracks to the players playlist. + Future addTracks(List tracks); + + /// Remove the track at [index] from the playlist. + Future removeTrack(int index); + + /// Toggle whether the player is playing or paused. + Future playPause(); + + /// Seek to [pos], which is a value between (and including) zero and one. + Future seekTo(double pos); + + /// Skip to the previous track in the playlist. + Future skipToPrevious(); + + /// Play the next track in the playlist. + Future skipToNext(); + + /// Switch to the track with the index [index] in the playlist. + Future 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(); + } +} \ No newline at end of file diff --git a/mobile/lib/selectors/ensemble.dart b/common/lib/src/selectors/ensemble.dart similarity index 100% rename from mobile/lib/selectors/ensemble.dart rename to common/lib/src/selectors/ensemble.dart diff --git a/mobile/lib/selectors/files.dart b/common/lib/src/selectors/files.dart similarity index 95% rename from mobile/lib/selectors/files.dart rename to common/lib/src/selectors/files.dart index 2e96b0a..ea4fb8a 100644 --- a/mobile/lib/selectors/files.dart +++ b/common/lib/src/selectors/files.dart @@ -24,7 +24,7 @@ class FilesSelector extends StatefulWidget { } class _FilesSelectorState extends State { - BackendState backend; + MusicusBackendState backend; List history = []; List children = []; Set selection = {}; @@ -33,7 +33,7 @@ class _FilesSelectorState extends State { void didChangeDependencies() { super.didChangeDependencies(); - backend = Backend.of(context); + backend = MusicusBackend.of(context); loadChildren(); } @@ -130,9 +130,8 @@ class _FilesSelectorState extends State { selection = {}; }); - final newChildren = await Platform.getChildren( - backend.settings.musicLibraryUri.value, - history.isNotEmpty ? history.last.id : null); + final newChildren = await backend.platform + .getChildren(history.isNotEmpty ? history.last.id : null); newChildren.sort((d1, d2) { if (d1.isDirectory != d2.isDirectory) { diff --git a/mobile/lib/selectors/instruments.dart b/common/lib/src/selectors/instruments.dart similarity index 98% rename from mobile/lib/selectors/instruments.dart rename to common/lib/src/selectors/instruments.dart index dcf74bf..adb7fb6 100644 --- a/mobile/lib/selectors/instruments.dart +++ b/common/lib/src/selectors/instruments.dart @@ -35,7 +35,7 @@ class _InstrumentsSelectorState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Scaffold( appBar: AppBar( diff --git a/mobile/lib/selectors/person.dart b/common/lib/src/selectors/person.dart similarity index 100% rename from mobile/lib/selectors/person.dart rename to common/lib/src/selectors/person.dart diff --git a/mobile/lib/selectors/recording.dart b/common/lib/src/selectors/recording.dart similarity index 100% rename from mobile/lib/selectors/recording.dart rename to common/lib/src/selectors/recording.dart diff --git a/mobile/lib/selectors/work.dart b/common/lib/src/selectors/work.dart similarity index 100% rename from mobile/lib/selectors/work.dart rename to common/lib/src/selectors/work.dart diff --git a/common/lib/src/settings.dart b/common/lib/src/settings.dart new file mode 100644 index 0000000..4f40ab0 --- /dev/null +++ b/common/lib/src/settings.dart @@ -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 load(); + Future getInt(String key); + Future getString(String key); + Future setInt(String key, int value); + Future 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(); + + /// Musicus server to connect to. + final server = BehaviorSubject(); + + /// Create a settings instance. + MusicusSettings(this.storage); + + /// Initialize the settings. + Future 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 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 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 resetServer() async { + await setServer(MusicusServerSettings( + host: defaultHost, + port: defaultPort, + apiPath: defaultApiPath, + )); + } + + /// Tidy up. + void dispose() { + musicLibraryPath.close(); + server.close(); + } +} diff --git a/mobile/lib/widgets/lists.dart b/common/lib/src/widgets/lists.dart similarity index 97% rename from mobile/lib/widgets/lists.dart rename to common/lib/src/widgets/lists.dart index 71f440a..306b698 100644 --- a/mobile/lib/widgets/lists.dart +++ b/common/lib/src/widgets/lists.dart @@ -161,7 +161,7 @@ class _PersonsListState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Column( children: [ @@ -222,7 +222,7 @@ class _EnsemblesListState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Column( children: [ @@ -287,7 +287,7 @@ class _WorksListState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Column( children: [ @@ -346,7 +346,7 @@ class RecordingsList extends StatelessWidget { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return PagedListView( fetch: (page, _) async { diff --git a/mobile/lib/widgets/recording_tile.dart b/common/lib/src/widgets/recording_tile.dart similarity index 100% rename from mobile/lib/widgets/recording_tile.dart rename to common/lib/src/widgets/recording_tile.dart diff --git a/mobile/lib/widgets/texts.dart b/common/lib/src/widgets/texts.dart similarity index 94% rename from mobile/lib/widgets/texts.dart rename to common/lib/src/widgets/texts.dart index d5b4ed5..4289c7f 100644 --- a/mobile/lib/widgets/texts.dart +++ b/common/lib/src/widgets/texts.dart @@ -45,7 +45,7 @@ class WorkText extends StatelessWidget { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return StreamBuilder( stream: backend.db.workById(workId).watchSingle(), @@ -61,7 +61,7 @@ class ComposersText extends StatelessWidget { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return StreamBuilder>( stream: backend.db.composersByWork(workId).watch(), diff --git a/common/pubspec.yaml b/common/pubspec.yaml new file mode 100644 index 0000000..18b9545 --- /dev/null +++ b/common/pubspec.yaml @@ -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: \ No newline at end of file diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 4db468c..55913bd 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,15 +1,18 @@ import 'dart:async'; 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 'widgets/player_bar.dart'; class App extends StatelessWidget { + static const _platform = MethodChannel('de.johrpan.musicus/platform'); + @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return MaterialApp( title: 'Musicus', @@ -36,11 +39,11 @@ class App extends StatelessWidget { ), home: Builder( builder: (context) { - if (backend.status == BackendStatus.loading) { + if (backend.status == MusicusBackendStatus.loading) { return Material( color: Theme.of(context).scaffoldBackgroundColor, ); - } else if (backend.status == BackendStatus.setup) { + } else if (backend.status == MusicusBackendStatus.setup) { return Material( color: Theme.of(context).scaffoldBackgroundColor, child: Column( @@ -57,8 +60,13 @@ class App extends StatelessWidget { ListTile( leading: const Icon(Icons.folder_open), title: Text('Choose path'), - onTap: () { - backend.settings.chooseMusicLibraryUri(); + onTap: () async { + final uri = + await _platform.invokeMethod('openTree'); + + if (uri != null) { + backend.settings.setMusicLibraryPath(uri); + } }, ), ], @@ -82,7 +90,7 @@ class _ContentState extends State with SingleTickerProviderStateMixin { final nestedNavigator = GlobalKey(); AnimationController playerBarAnimation; - BackendState backend; + MusicusBackendState backend; StreamSubscription playerActiveSubscription; @override @@ -99,14 +107,14 @@ class _ContentState extends State with SingleTickerProviderStateMixin { void didChangeDependencies() { super.didChangeDependencies(); - backend = Backend.of(context); - playerBarAnimation.value = backend.player.active.value ? 1.0 : 0.0; + backend = MusicusBackend.of(context); + playerBarAnimation.value = backend.playback.active.value ? 1.0 : 0.0; if (playerActiveSubscription != null) { playerActiveSubscription.cancel(); } - playerActiveSubscription = backend.player.active.listen((active) => + playerActiveSubscription = backend.playback.active.listen((active) => active ? playerBarAnimation.forward() : playerBarAnimation.reverse()); } diff --git a/mobile/lib/backend.dart b/mobile/lib/backend.dart deleted file mode 100644 index e86bd0b..0000000 --- a/mobile/lib/backend.dart +++ /dev/null @@ -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 _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 { - 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 _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 _updateMusicLibrary(String uri) async { - if (uri == null) { - setState(() { - status = BackendStatus.setup; - }); - } else { - ml = MusicLibrary(uri); - await ml.load(); - setState(() { - status = BackendStatus.ready; - }); - } - } - - Future _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; -} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 9b389c5..3f10c37 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -1,12 +1,26 @@ import 'package:audio_service/audio_service.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 'backend.dart'; +import 'settings.dart'; +import 'platform.dart'; +import 'playback.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final dir = await pp.getApplicationDocumentsDirectory(); + final dbPath = p.join(dir.path, 'db.sqlite'); -void main() { runApp(AudioServiceWidget( - child: Backend( + child: MusicusBackend( + dbPath: dbPath, + settingsStorage: SettingsStorage(), + platform: MusicusAndroidPlatform(), + playback: Playback(), child: App(), ), )); diff --git a/mobile/lib/platform.dart b/mobile/lib/platform.dart index 13dfba0..b24d5d5 100644 --- a/mobile/lib/platform.dart +++ b/mobile/lib/platform.dart @@ -1,44 +1,16 @@ import 'package:flutter/services.dart'; +import 'package:musicus_common/musicus_common.dart'; -/// Object representing a document in Storage Access Framework terms. -class Document { - /// Unique document ID given by the SAF. - final String id; - - /// Name of the document (i.e. file name). - final String name; - - /// Document ID of the parent document. - final String parent; - - /// Whether this document represents a directory. - final bool isDirectory; - - // Use Map here, as we get casting errors otherwise. This - // won't be typesafe anyway. - Document.fromJson(Map json) - : id = json['id'], - name = json['name'], - parent = json['parent'], - isDirectory = json['isDirectory']; -} - -/// Collection of methods that are implemented platform dependent. -class Platform { +class MusicusAndroidPlatform extends MusicusPlatform { static const _platform = MethodChannel('de.johrpan.musicus/platform'); - /// Get child documents. - /// - /// [treeId] is the base URI as requested from the SAF. - /// [parentId] is the document ID of the parent. If this is null, the children - /// of the tree base will be returned. - static Future> getChildren( - String treeUri, String parentId) async { + @override + Future> getChildren(String parentId) async { final List> childrenJson = await _platform.invokeListMethod( 'getChildren', { - 'treeUri': treeUri, + 'treeUri': basePath, 'parentId': parentId, }, ); @@ -48,65 +20,51 @@ class Platform { .toList(); } - /// Read contents of file. - /// - /// [treeId] is the base URI from the SAF, [id] is the document ID of the - /// file. - static Future readFile(String treeUri, String id) async { + @override + Future getIdentifier(String parentId, String fileName) async { + return await _platform.invokeMethod( + 'getUriByName', + { + 'treeUri': basePath, + 'parentId': parentId, + 'fileName': fileName, + }, + ); + } + + @override + Future readDocument(String id) async { return await _platform.invokeMethod( 'readFile', { - 'treeUri': treeUri, + 'treeUri': basePath, 'id': id, }, ); } - /// Get document URI by file name - /// - /// [treeId] is the base URI from the SAF, [parentId] is the document ID of - /// the parent directory. - static Future 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 readFileByName( - String treeUri, String parentId, String fileName) async { + @override + Future readDocumentByName(String parentId, String fileName) async { return await _platform.invokeMethod( 'readFileByName', { - 'treeUri': treeUri, + 'treeUri': basePath, 'parentId': parentId, 'fileName': fileName, }, ); } - /// Write to file by name - /// - /// [treeId] is the base URI from the SAF, [parentId] is the document ID of - /// the parent directory. - static Future writeFileByName( - String treeUri, String parentId, String fileName, String content) async { + @override + Future writeDocumentByName( + String parentId, String fileName, String contents) async { await _platform.invokeMethod( 'writeFileByName', { - 'treeUri': treeUri, + 'treeUri': basePath, 'parentId': parentId, 'fileName': fileName, - 'content': content, + 'content': contents, }, ); } diff --git a/mobile/lib/player.dart b/mobile/lib/playback.dart similarity index 80% rename from mobile/lib/player.dart rename to mobile/lib/playback.dart index ca45882..2afe5df 100644 --- a/mobile/lib/player.dart +++ b/mobile/lib/playback.dart @@ -6,10 +6,8 @@ import 'dart:ui'; import 'package:audio_service/audio_service.dart'; import 'package:moor/isolate.dart'; import 'package:musicus_database/musicus_database.dart'; +import 'package:musicus_common/musicus_common.dart'; import 'package:musicus_player/musicus_player.dart'; -import 'package:rxdart/rxdart.dart'; - -import 'music_library.dart'; const _portName = 'playbackService'; @@ -18,61 +16,11 @@ void _playbackServiceEntrypoint() { AudioServiceBackground.run(() => _PlaybackService()); } -class Player { - /// 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([]); - - /// 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.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); - +class Playback extends MusicusPlayback { 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. - Future start() async { + Future _start() async { if (!AudioService.running) { await AudioService.start( backgroundTaskEntrypoint: _playbackServiceEntrypoint, @@ -109,8 +57,8 @@ class Player { currentTrack.add(playlist.value[index]); } - /// Connect listeners and initialize streams. - void setup() { + @override + Future setup() async { if (_playbackServiceStateSubscription != null) { _playbackServiceStateSubscription.cancel(); } @@ -125,8 +73,12 @@ class Player { ).listen((msg) { // If state is null, the background audio service has stopped. if (msg == null) { - _stop(); + dispose(); } else { + if (!active.value) { + active.add(true); + } + if (msg is _StatusMessage) { playing.add(msg.playing); } else if (msg is _PositionMessage) { @@ -154,9 +106,23 @@ class Player { } } - /// Toggle whether the player is playing or paused. - /// - /// If the player is not active, this will do nothing. + @override + Future addTracks(List tracks) async { + if (!AudioService.running) { + await _start(); + } + + await AudioService.customAction('addTracks', jsonEncode(tracks)); + } + + @override + Future removeTrack(int index) async { + if (AudioService.running) { + await AudioService.customAction('removeTrack', index); + } + } + + @override Future playPause() async { if (active.value) { if (playing.value) { @@ -167,29 +133,7 @@ class Player { } } - /// Add a list of tracks to the players playlist. - Future addTracks(List 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 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. + @override Future seekTo(double pos) async { if (active.value && pos >= 0.0 && pos <= 1.0) { final durationMs = duration.value.inMilliseconds; @@ -197,45 +141,31 @@ class Player { } } - /// Play the previous track in the playlist. - /// - /// If the player is not active or there is no previous track, this will do - /// nothing. - Future 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. + @override Future skipToPrevious() async { if (AudioService.running) { await AudioService.skipToPrevious(); } } - /// Switch to the track with the index [index] in the playlist. + @override + Future skipToNext() async { + if (AudioService.running) { + await AudioService.skipToNext(); + } + } + + @override Future skipTo(int index) async { if (AudioService.running) { await AudioService.customAction('skipTo', index); } } - /// Tidy up. + @override void dispose() { + super.dispose(); _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. Future _load() async { - final moorPort = IsolateNameServer.lookupPortByName('moorPort'); + final moorPort = IsolateNameServer.lookupPortByName('moor'); final moorIsolate = MoorIsolate.fromConnectPort(moorPort); db = Database.connect(await moorIsolate.connect()); _loading.complete(); @@ -397,7 +327,7 @@ class _PlaybackService extends BackgroundAudioTask { final title = workInfo.work.title; AudioServiceBackground.setMediaItem(MediaItem( - id: track.uri, + id: track.identifier, album: composers, title: title, )); @@ -456,7 +386,7 @@ class _PlaybackService extends BackgroundAudioTask { /// Set the current track, update the player and notify the system. Future _setCurrentTrack(int index) async { _currentTrack = index; - _durationMs = await _player.setUri(_playlist[_currentTrack].uri); + _durationMs = await _player.setUri(_playlist[_currentTrack].identifier); _setState(); } @@ -508,7 +438,7 @@ class _PlaybackService extends BackgroundAudioTask { } @override - void onCustomAction(String name, dynamic arguments) { + Future onCustomAction(String name, dynamic arguments) async { super.onCustomAction(name, arguments); // addTracks expects a List> as its argument. diff --git a/mobile/lib/screens/home.dart b/mobile/lib/screens/home.dart index 0177fae..831d630 100644 --- a/mobile/lib/screens/home.dart +++ b/mobile/lib/screens/home.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:musicus_common/musicus_common.dart'; import 'package:musicus_database/musicus_database.dart'; -import '../backend.dart'; -import '../editors/tracks.dart'; import '../icons.dart'; -import '../widgets/lists.dart'; import 'person.dart'; import 'settings.dart'; @@ -19,7 +17,7 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Scaffold( appBar: AppBar( diff --git a/mobile/lib/screens/person.dart b/mobile/lib/screens/person.dart index 5702c3f..772509b 100644 --- a/mobile/lib/screens/person.dart +++ b/mobile/lib/screens/person.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:musicus_common/musicus_common.dart'; import 'package:musicus_database/musicus_database.dart'; -import '../backend.dart'; -import '../editors/person.dart'; -import '../widgets/lists.dart'; - import 'work.dart'; class PersonScreen extends StatefulWidget { @@ -23,7 +20,7 @@ class _PersonScreenState extends State { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Scaffold( appBar: AppBar( diff --git a/mobile/lib/screens/program.dart b/mobile/lib/screens/program.dart index 26a19b5..18ef2e3 100644 --- a/mobile/lib/screens/program.dart +++ b/mobile/lib/screens/program.dart @@ -1,12 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:musicus_common/musicus_common.dart'; import 'package:musicus_database/musicus_database.dart'; -import '../backend.dart'; -import '../music_library.dart'; import '../widgets/play_pause_button.dart'; -import '../widgets/recording_tile.dart'; class ProgramScreen extends StatefulWidget { @override @@ -14,7 +12,7 @@ class ProgramScreen extends StatefulWidget { } class _ProgramScreenState extends State { - BackendState backend; + MusicusBackendState backend; StreamSubscription playerActiveSubscription; @@ -29,14 +27,14 @@ class _ProgramScreenState extends State { void didChangeDependencies() { super.didChangeDependencies(); - backend = Backend.of(context); + backend = MusicusBackend.of(context); if (playerActiveSubscription != null) { playerActiveSubscription.cancel(); } // 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) { Navigator.pop(context); } @@ -46,7 +44,7 @@ class _ProgramScreenState extends State { playlistSubscription.cancel(); } - playlistSubscription = backend.player.playlist.listen((playlist) { + playlistSubscription = backend.playback.playlist.listen((playlist) { updateProgram(playlist); }); @@ -54,7 +52,7 @@ class _ProgramScreenState extends State { positionSubscription.cancel(); } - positionSubscription = backend.player.normalizedPosition.listen((pos) { + positionSubscription = backend.playback.normalizedPosition.listen((pos) { if (!seeking) { setState(() { position = pos; @@ -154,7 +152,7 @@ class _ProgramScreenState extends State { title: Text('Program'), ), body: StreamBuilder( - stream: backend.player.currentIndex, + stream: backend.playback.currentIndex, builder: (context, snapshot) { if (snapshot.hasData) { return ListView.builder( @@ -181,7 +179,7 @@ class _ProgramScreenState extends State { ], ), onTap: () { - backend.player.skipTo(index); + backend.playback.skipTo(index); }, onLongPress: () { showDialog( @@ -192,7 +190,7 @@ class _ProgramScreenState extends State { ListTile( title: Text('Remove from playlist'), onTap: () { - backend.player.removeTrack(index); + backend.playback.removeTrack(index); Navigator.pop(context); }, ), @@ -220,7 +218,7 @@ class _ProgramScreenState extends State { }, onChangeEnd: (pos) { seeking = false; - backend.player.seekTo(pos); + backend.playback.seekTo(pos); }, onChanged: (pos) { setState(() { @@ -233,7 +231,7 @@ class _ProgramScreenState extends State { Padding( padding: const EdgeInsets.only(left: 24.0), child: StreamBuilder( - stream: backend.player.position, + stream: backend.playback.position, builder: (context, snapshot) { if (snapshot.hasData) { return DurationText(snapshot.data); @@ -247,21 +245,21 @@ class _ProgramScreenState extends State { IconButton( icon: const Icon(Icons.skip_previous), onPressed: () { - backend.player.skipToPrevious(); + backend.playback.skipToPrevious(); }, ), PlayPauseButton(), IconButton( icon: const Icon(Icons.skip_next), onPressed: () { - backend.player.skipToNext(); + backend.playback.skipToNext(); }, ), Spacer(), Padding( padding: const EdgeInsets.only(right: 20.0), child: StreamBuilder( - stream: backend.player.duration, + stream: backend.playback.duration, builder: (context, snapshot) { if (snapshot.hasData) { return DurationText(snapshot.data); diff --git a/mobile/lib/screens/server_settings.dart b/mobile/lib/screens/server_settings.dart index a6dcc6e..779ddae 100644 --- a/mobile/lib/screens/server_settings.dart +++ b/mobile/lib/screens/server_settings.dart @@ -1,9 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; - -import '../backend.dart'; -import '../settings.dart'; +import 'package:musicus_common/musicus_common.dart'; class ServerSettingsScreen extends StatefulWidget { @override @@ -13,16 +11,16 @@ class ServerSettingsScreen extends StatefulWidget { class _ServerSettingsScreenState extends State { final hostController = TextEditingController(); final portController = TextEditingController(); - final basePathController = TextEditingController(); + final apiPathController = TextEditingController(); - BackendState backend; - StreamSubscription serverSubscription; + MusicusBackendState backend; + StreamSubscription serverSubscription; @override void didChangeDependencies() { super.didChangeDependencies(); - backend = Backend.of(context); + backend = MusicusBackend.of(context); if (serverSubscription != null) { serverSubscription.cancel(); @@ -34,10 +32,10 @@ class _ServerSettingsScreenState extends State { }); } - void _settingsChanged(ServerSettings settings) { + void _settingsChanged(MusicusServerSettings settings) { hostController.text = settings.host; portController.text = settings.port.toString(); - basePathController.text = settings.basePath; + apiPathController.text = settings.apiPath; } @override @@ -50,15 +48,15 @@ class _ServerSettingsScreenState extends State { icon: const Icon(Icons.restore), tooltip: 'Reset to default', onPressed: () { - backend.settings.resetServerSettings(); + backend.settings.resetServer(); }, ), FlatButton( onPressed: () async { - await backend.settings.setServerSettings(ServerSettings( + await backend.settings.setServer(MusicusServerSettings( host: hostController.text, port: int.parse(portController.text), - basePath: basePathController.text, + apiPath: apiPathController.text, )); Navigator.pop(context); @@ -91,7 +89,7 @@ class _ServerSettingsScreenState extends State { Padding( padding: const EdgeInsets.all(16.0), child: TextField( - controller: basePathController, + controller: apiPathController, decoration: InputDecoration( labelText: 'API path', ), diff --git a/mobile/lib/screens/settings.dart b/mobile/lib/screens/settings.dart index 01248c4..176e117 100644 --- a/mobile/lib/screens/settings.dart +++ b/mobile/lib/screens/settings.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; - -import '../backend.dart'; -import '../settings.dart'; +import 'package:flutter/services.dart'; +import 'package:musicus_common/musicus_common.dart'; import 'server_settings.dart'; class SettingsScreen extends StatelessWidget { + static const _platform = MethodChannel('de.johrpan.musicus/platform'); + @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); final settings = backend.settings; return Scaffold( @@ -18,18 +19,23 @@ class SettingsScreen extends StatelessWidget { body: ListView( children: [ StreamBuilder( - stream: settings.musicLibraryUri, + stream: settings.musicLibraryPath, builder: (context, snapshot) { return ListTile( title: Text('Music library path'), subtitle: Text(snapshot.data ?? 'Choose folder'), isThreeLine: snapshot.hasData, - onTap: () { - settings.chooseMusicLibraryUri(); + onTap: () async { + final uri = + await _platform.invokeMethod('openTree'); + + if (uri != null) { + settings.setMusicLibraryPath(uri); + } }, ); }), - StreamBuilder( + StreamBuilder( stream: settings.server, builder: (context, snapshot) { final s = snapshot.data; @@ -37,10 +43,10 @@ class SettingsScreen extends StatelessWidget { return ListTile( title: Text('Musicus server'), subtitle: Text( - s != null ? '${s.host}:${s.port}${s.basePath}' : '...'), + s != null ? '${s.host}:${s.port}${s.apiPath}' : '...'), trailing: const Icon(Icons.chevron_right), onTap: () async { - final ServerSettings result = await Navigator.push( + final MusicusServerSettings result = await Navigator.push( context, MaterialPageRoute( builder: (context) => ServerSettingsScreen(), @@ -48,7 +54,7 @@ class SettingsScreen extends StatelessWidget { ); if (result != null) { - settings.setServerSettings(result); + settings.setServer(result); } }, ); diff --git a/mobile/lib/screens/work.dart b/mobile/lib/screens/work.dart index 8cb3af6..002bf7c 100644 --- a/mobile/lib/screens/work.dart +++ b/mobile/lib/screens/work.dart @@ -1,11 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:musicus_common/musicus_common.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 { final WorkInfo workInfo; @@ -15,7 +11,7 @@ class WorkScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return Scaffold( appBar: AppBar( @@ -46,11 +42,11 @@ class WorkScreen extends StatelessWidget { performanceInfos: recordingInfo.performances, ), 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)); - backend.player - .addTracks(backend.ml.tracks[recordingInfo.recording.id]); + backend.playback + .addTracks(backend.library.tracks[recordingInfo.recording.id]); }, ), ), diff --git a/mobile/lib/settings.dart b/mobile/lib/settings.dart index a4e1c84..b3208e5 100644 --- a/mobile/lib/settings.dart +++ b/mobile/lib/settings.dart @@ -1,102 +1,30 @@ -import 'package:flutter/services.dart'; -import 'package:meta/meta.dart'; -import 'package:rxdart/rxdart.dart'; +import 'package:musicus_common/musicus_common.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'; +class SettingsStorage extends MusicusSettingsStorage { + SharedPreferences _pref; - /// 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, - )); + _pref = await SharedPreferences.getInstance(); } - /// 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); - } + @override + Future getInt(String key) { + return Future.value(_pref.getInt(key)); } - /// 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); + @override + Future getString(String key) { + return Future.value(_pref.getString(key)); } - /// Reset the server settings to their defaults. - Future resetServerSettings() async { - await setServerSettings(ServerSettings( - host: defaultHost, - port: defaultPort, - basePath: defaultBasePath, - )); + @override + Future setInt(String key, int value) async { + await _pref.setInt(key, value); } - /// Tidy up. - void dispose() { - musicLibraryUri.close(); - server.close(); + @override + Future setString(String key, String value) async { + await _pref.setString(key, value); } } diff --git a/mobile/lib/widgets/play_pause_button.dart b/mobile/lib/widgets/play_pause_button.dart index 9202201..b5926bf 100644 --- a/mobile/lib/widgets/play_pause_button.dart +++ b/mobile/lib/widgets/play_pause_button.dart @@ -1,8 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; - -import '../backend.dart'; +import 'package:musicus_common/musicus_common.dart'; class PlayPauseButton extends StatefulWidget { @override @@ -12,7 +11,7 @@ class PlayPauseButton extends StatefulWidget { class _PlayPauseButtonState extends State with SingleTickerProviderStateMixin { AnimationController playPauseAnimation; - BackendState backend; + MusicusBackendState backend; StreamSubscription playingSubscription; @override @@ -29,14 +28,14 @@ class _PlayPauseButtonState extends State void didChangeDependencies() { super.didChangeDependencies(); - backend = Backend.of(context); - playPauseAnimation.value = backend.player.playing.value ? 1.0 : 0.0; + backend = MusicusBackend.of(context); + playPauseAnimation.value = backend.playback.playing.value ? 1.0 : 0.0; if (playingSubscription != null) { playingSubscription.cancel(); } - playingSubscription = backend.player.playing.listen((playing) => + playingSubscription = backend.playback.playing.listen((playing) => playing ? playPauseAnimation.forward() : playPauseAnimation.reverse()); } @@ -47,7 +46,7 @@ class _PlayPauseButtonState extends State icon: AnimatedIcons.play_pause, progress: playPauseAnimation, ), - onPressed: backend.player.playPause, + onPressed: backend.playback.playPause, ); } diff --git a/mobile/lib/widgets/player_bar.dart b/mobile/lib/widgets/player_bar.dart index cef90c1..bc9fb49 100644 --- a/mobile/lib/widgets/player_bar.dart +++ b/mobile/lib/widgets/player_bar.dart @@ -1,17 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:musicus_common/musicus_common.dart'; import 'package:musicus_database/musicus_database.dart'; -import '../backend.dart'; -import '../music_library.dart'; import '../screens/program.dart'; import 'play_pause_button.dart'; -import 'texts.dart'; class PlayerBar extends StatelessWidget { @override Widget build(BuildContext context) { - final backend = Backend.of(context); + final backend = MusicusBackend.of(context); return BottomAppBar( child: InkWell( @@ -19,7 +17,7 @@ class PlayerBar extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ StreamBuilder( - stream: backend.player.normalizedPosition, + stream: backend.playback.normalizedPosition, builder: (context, snapshot) => LinearProgressIndicator( value: snapshot.data, ), @@ -32,7 +30,7 @@ class PlayerBar extends StatelessWidget { ), Expanded( child: StreamBuilder( - stream: backend.player.currentTrack, + stream: backend.playback.currentTrack, builder: (context, snapshot) { if (snapshot.data != null) { final recordingId = snapshot.data.track.recordingId; diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 48903ad..16eb29c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: moor_ffi: musicus_client: path: ../client + musicus_common: + path: ../common musicus_database: path: ../database musicus_player: