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
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

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
Widget build(BuildContext context) {
final backend = Backend.of(context);
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(

View file

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

View file

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

View file

@ -36,7 +36,7 @@ class _RecordingEditorState extends State<RecordingEditor> {
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<RecordingEditor> {
@override
Widget build(BuildContext context) {
final backend = Backend.of(context);
final backend = MusicusBackend.of(context);
Future<void> selectWork() async {
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 '../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<TracksEditor> {
BackendState backend;
MusicusBackendState backend;
WorkInfo workInfo;
RecordingInfo recordingInfo;
String parentId;
@ -29,7 +29,7 @@ class _TracksEditorState extends State<TracksEditor> {
@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<TracksEditor> {
backend.db.updateWork(workInfo);
backend.db.updateRecording(recordingInfo);
backend.ml.addTracks(parentId, tracks);
backend.library.addTracks(parentId, tracks);
Navigator.pop(context);
},

View file

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

View file

@ -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<String, dynamic> json) => InternalTrack(
track: Track.fromJson(json['track']),
uri: json['uri'],
identifier: json['identifier'],
);
Map<String, dynamic> 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<int, List<InternalTrack>> tracks = {};
MusicLibrary(this.treeUri);
MusicusLibrary(this.basePath, this.platform);
/// Load all available tracks.
///
@ -130,13 +128,13 @@ class MusicLibrary {
Future<void> load() async {
// TODO: Consider capping the recursion somewhere.
Future<void> 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<void> _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)) {

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> {
BackendState backend;
MusicusBackendState backend;
List<Document> history = [];
List<Document> children = [];
Set<Document> selection = {};
@ -33,7 +33,7 @@ class _FilesSelectorState extends State<FilesSelector> {
void didChangeDependencies() {
super.didChangeDependencies();
backend = Backend.of(context);
backend = MusicusBackend.of(context);
loadChildren();
}
@ -130,9 +130,8 @@ class _FilesSelectorState extends State<FilesSelector> {
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) {

View file

@ -35,7 +35,7 @@ class _InstrumentsSelectorState extends State<InstrumentsSelector> {
@override
Widget build(BuildContext context) {
final backend = Backend.of(context);
final backend = MusicusBackend.of(context);
return Scaffold(
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
Widget build(BuildContext context) {
final backend = Backend.of(context);
final backend = MusicusBackend.of(context);
return Column(
children: <Widget>[
@ -222,7 +222,7 @@ class _EnsemblesListState extends State<EnsemblesList> {
@override
Widget build(BuildContext context) {
final backend = Backend.of(context);
final backend = MusicusBackend.of(context);
return Column(
children: <Widget>[
@ -287,7 +287,7 @@ class _WorksListState extends State<WorksList> {
@override
Widget build(BuildContext context) {
final backend = Backend.of(context);
final backend = MusicusBackend.of(context);
return Column(
children: <Widget>[
@ -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<RecordingInfo>(
fetch: (page, _) async {

View file

@ -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<Work>(
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<List<Person>>(
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 '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<String>('openTree');
if (uri != null) {
backend.settings.setMusicLibraryPath(uri);
}
},
),
],
@ -82,7 +90,7 @@ class _ContentState extends State<Content> with SingleTickerProviderStateMixin {
final nestedNavigator = GlobalKey<NavigatorState>();
AnimationController playerBarAnimation;
BackendState backend;
MusicusBackendState backend;
StreamSubscription<bool> playerActiveSubscription;
@override
@ -99,14 +107,14 @@ class _ContentState extends State<Content> 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());
}

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: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<void> 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(),
),
));

View file

@ -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<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 {
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<List<Document>> getChildren(
String treeUri, String parentId) async {
@override
Future<List<Document>> getChildren(String parentId) async {
final List<Map<dynamic, dynamic>> 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<String> readFile(String treeUri, String id) async {
@override
Future<String> getIdentifier(String parentId, String fileName) async {
return await _platform.invokeMethod(
'getUriByName',
{
'treeUri': basePath,
'parentId': parentId,
'fileName': fileName,
},
);
}
@override
Future<String> 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<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 {
@override
Future<String> 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<void> writeFileByName(
String treeUri, String parentId, String fileName, String content) async {
@override
Future<void> writeDocumentByName(
String parentId, String fileName, String contents) async {
await _platform.invokeMethod(
'writeFileByName',
{
'treeUri': treeUri,
'treeUri': basePath,
'parentId': parentId,
'fileName': fileName,
'content': content,
'content': contents,
},
);
}

View file

@ -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(<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);
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<void> start() async {
Future<void> _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<void> 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<void> addTracks(List<InternalTrack> tracks) async {
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 {
if (active.value) {
if (playing.value) {
@ -167,29 +133,7 @@ class Player {
}
}
/// Add a list of tracks to the players playlist.
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.
@override
Future<void> 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<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.
@override
Future<void> skipToPrevious() async {
if (AudioService.running) {
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 {
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<void> _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<void> _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<void> onCustomAction(String name, dynamic arguments) async {
super.onCustomAction(name, arguments);
// addTracks expects a List<Map<String, dynamic>> as its argument.

View file

@ -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<HomeScreen> {
@override
Widget build(BuildContext context) {
final backend = Backend.of(context);
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(

View file

@ -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<PersonScreen> {
@override
Widget build(BuildContext context) {
final backend = Backend.of(context);
final backend = MusicusBackend.of(context);
return Scaffold(
appBar: AppBar(

View file

@ -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<ProgramScreen> {
BackendState backend;
MusicusBackendState backend;
StreamSubscription<bool> playerActiveSubscription;
@ -29,14 +27,14 @@ class _ProgramScreenState extends State<ProgramScreen> {
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<ProgramScreen> {
playlistSubscription.cancel();
}
playlistSubscription = backend.player.playlist.listen((playlist) {
playlistSubscription = backend.playback.playlist.listen((playlist) {
updateProgram(playlist);
});
@ -54,7 +52,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
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<ProgramScreen> {
title: Text('Program'),
),
body: StreamBuilder<int>(
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<ProgramScreen> {
],
),
onTap: () {
backend.player.skipTo(index);
backend.playback.skipTo(index);
},
onLongPress: () {
showDialog(
@ -192,7 +190,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
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<ProgramScreen> {
},
onChangeEnd: (pos) {
seeking = false;
backend.player.seekTo(pos);
backend.playback.seekTo(pos);
},
onChanged: (pos) {
setState(() {
@ -233,7 +231,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
Padding(
padding: const EdgeInsets.only(left: 24.0),
child: StreamBuilder<Duration>(
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<ProgramScreen> {
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<Duration>(
stream: backend.player.duration,
stream: backend.playback.duration,
builder: (context, snapshot) {
if (snapshot.hasData) {
return DurationText(snapshot.data);

View file

@ -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<ServerSettingsScreen> {
final hostController = TextEditingController();
final portController = TextEditingController();
final basePathController = TextEditingController();
final apiPathController = TextEditingController();
BackendState backend;
StreamSubscription<ServerSettings> serverSubscription;
MusicusBackendState backend;
StreamSubscription<MusicusServerSettings> 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<ServerSettingsScreen> {
});
}
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<ServerSettingsScreen> {
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<ServerSettingsScreen> {
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: basePathController,
controller: apiPathController,
decoration: InputDecoration(
labelText: 'API path',
),

View file

@ -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: <Widget>[
StreamBuilder<String>(
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<String>('openTree');
if (uri != null) {
settings.setMusicLibraryPath(uri);
}
},
);
}),
StreamBuilder<ServerSettings>(
StreamBuilder<MusicusServerSettings>(
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);
}
},
);

View file

@ -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]);
},
),
),

View file

@ -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<String>();
/// Musicus server to connect to.
final server = BehaviorSubject<ServerSettings>();
SharedPreferences _shPref;
/// Initialize the settings.
Future<void> load() async {
_shPref = await SharedPreferences.getInstance();
final uri = _shPref.getString('musicLibraryUri');
if (uri != null) {
musicLibraryUri.add(uri);
_pref = await SharedPreferences.getInstance();
}
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,
));
@override
Future<int> getInt(String key) {
return Future.value(_pref.getInt(key));
}
/// Open the system picker to select a new music library URI.
Future<void> chooseMusicLibraryUri() async {
final uri = await _platform.invokeMethod<String>('openTree');
if (uri != null) {
musicLibraryUri.add(uri);
await _shPref.setString('musicLibraryUri', uri);
}
@override
Future<String> getString(String key) {
return Future.value(_pref.getString(key));
}
/// Change the Musicus server settings.
Future<void> 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<void> setInt(String key, int value) async {
await _pref.setInt(key, value);
}
/// Reset the server settings to their defaults.
Future<void> resetServerSettings() async {
await setServerSettings(ServerSettings(
host: defaultHost,
port: defaultPort,
basePath: defaultBasePath,
));
}
/// Tidy up.
void dispose() {
musicLibraryUri.close();
server.close();
@override
Future<void> setString(String key, String value) async {
await _pref.setString(key, value);
}
}

View file

@ -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<PlayPauseButton>
with SingleTickerProviderStateMixin {
AnimationController playPauseAnimation;
BackendState backend;
MusicusBackendState backend;
StreamSubscription<bool> playingSubscription;
@override
@ -29,14 +28,14 @@ class _PlayPauseButtonState extends State<PlayPauseButton>
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<PlayPauseButton>
icon: AnimatedIcons.play_pause,
progress: playPauseAnimation,
),
onPressed: backend.player.playPause,
onPressed: backend.playback.playPause,
);
}

View file

@ -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: <Widget>[
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<InternalTrack>(
stream: backend.player.currentTrack,
stream: backend.playback.currentTrack,
builder: (context, snapshot) {
if (snapshot.data != null) {
final recordingId = snapshot.data.track.recordingId;

View file

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