mirror of
https://github.com/johrpan/musicus_mobile.git
synced 2025-10-26 10:47:25 +01:00
Move reusable code from mobile to common
This will be useful for a future desktop application.
This commit is contained in:
parent
6e1255f26e
commit
711b19c998
40 changed files with 813 additions and 581 deletions
13
README.md
13
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
|
`database` – A Database of classical music. This package will be used by all
|
||||||
standalone Musicus applications for storing classical music metadata.
|
standalone Musicus applications for storing classical music metadata.
|
||||||
|
|
||||||
|
`server` – A simple http server hosting a Musicus database. The server is
|
||||||
|
developed using the [Aqueduct framework](https://aqueduct.io).
|
||||||
|
|
||||||
|
`client` – A client library for the Musicus server.
|
||||||
|
|
||||||
|
`common` – Common building blocks for Musicus client apps. This includes shared
|
||||||
|
UI and backend code for the mobile app and the (future) desktop app.
|
||||||
|
|
||||||
`mobile` – The Musicus mobile app. It is being developed using
|
`mobile` – The Musicus mobile app. It is being developed using
|
||||||
[Flutter toolkit](https://flutter.dev) and only runs on Android for now.
|
[Flutter toolkit](https://flutter.dev) and only runs on Android for now.
|
||||||
|
|
||||||
`player` – The simplest possible audio player plugin. This is used by the
|
`player` – The simplest possible audio player plugin. This is used by the
|
||||||
mobile app for playback.
|
mobile app for playback.
|
||||||
|
|
||||||
`server` – A simple http server hosting a Musicus database. The server is
|
|
||||||
developed using the [Aqueduct framework](https://aqueduct.io).
|
|
||||||
|
|
||||||
`client` – A client library for the Musicus server.
|
|
||||||
|
|
||||||
## Hacking
|
## Hacking
|
||||||
|
|
||||||
Picking up Dart as a programming language and Flutter as an UI toolkit should
|
Picking up Dart as a programming language and Flutter as an UI toolkit should
|
||||||
|
|
|
||||||
31
common/.gitignore
vendored
Normal file
31
common/.gitignore
vendored
Normal 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/
|
||||||
24
common/lib/musicus_common.dart
Normal file
24
common/lib/musicus_common.dart
Normal 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
221
common/lib/src/backend.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -30,7 +30,7 @@ class _EnsembleEditorState extends State<EnsembleEditor> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -30,7 +30,7 @@ class _InstrumentEditorState extends State<InstrumentEditor> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -32,7 +32,7 @@ class _PersonEditorState extends State<PersonEditor> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -36,7 +36,7 @@ class _RecordingEditorState extends State<RecordingEditor> {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
if (widget.recordingInfo != null) {
|
if (widget.recordingInfo != null) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
() async {
|
() async {
|
||||||
workInfo = await backend.db.getWork(widget.recordingInfo.recording.id);
|
workInfo = await backend.db.getWork(widget.recordingInfo.recording.id);
|
||||||
|
|
@ -47,7 +47,7 @@ class _RecordingEditorState extends State<RecordingEditor> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
Future<void> selectWork() async {
|
Future<void> selectWork() async {
|
||||||
final WorkInfo newWorkInfo = await Navigator.push(
|
final WorkInfo newWorkInfo = await Navigator.push(
|
||||||
|
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:musicus_database/musicus_database.dart';
|
import 'package:musicus_database/musicus_database.dart';
|
||||||
|
|
||||||
import '../backend.dart';
|
import '../backend.dart';
|
||||||
import '../music_library.dart';
|
import '../library.dart';
|
||||||
import '../selectors/files.dart';
|
import '../selectors/files.dart';
|
||||||
import '../selectors/recording.dart';
|
import '../selectors/recording.dart';
|
||||||
import '../widgets/recording_tile.dart';
|
import '../widgets/recording_tile.dart';
|
||||||
|
|
@ -21,7 +21,7 @@ class TracksEditor extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TracksEditorState extends State<TracksEditor> {
|
class _TracksEditorState extends State<TracksEditor> {
|
||||||
BackendState backend;
|
MusicusBackendState backend;
|
||||||
WorkInfo workInfo;
|
WorkInfo workInfo;
|
||||||
RecordingInfo recordingInfo;
|
RecordingInfo recordingInfo;
|
||||||
String parentId;
|
String parentId;
|
||||||
|
|
@ -29,7 +29,7 @@ class _TracksEditorState extends State<TracksEditor> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
backend = Backend.of(context);
|
backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -57,7 +57,7 @@ class _TracksEditorState extends State<TracksEditor> {
|
||||||
backend.db.updateWork(workInfo);
|
backend.db.updateWork(workInfo);
|
||||||
backend.db.updateRecording(recordingInfo);
|
backend.db.updateRecording(recordingInfo);
|
||||||
|
|
||||||
backend.ml.addTracks(parentId, tracks);
|
backend.library.addTracks(parentId, tracks);
|
||||||
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
|
|
@ -200,7 +200,7 @@ class _WorkEditorState extends State<WorkEditor> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
final List<Widget> partTiles = [];
|
final List<Widget> partTiles = [];
|
||||||
for (var i = 0; i < parts.length; i++) {
|
for (var i = 0; i < parts.length; i++) {
|
||||||
|
|
@ -1,32 +1,31 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
import 'platform.dart';
|
import 'platform.dart';
|
||||||
|
|
||||||
/// Bundles a [Track] with the URI of the audio file it represents.
|
/// Bundles a [Track] with information on how to find the corresponding file.
|
||||||
///
|
|
||||||
/// The uri shouldn't be stored on disk, but will be used at runtime.
|
|
||||||
class InternalTrack {
|
class InternalTrack {
|
||||||
/// The represented track.
|
/// The represented track.
|
||||||
final Track track;
|
final Track track;
|
||||||
|
|
||||||
/// The URI of the represented audio file as retrieved from the SAF.
|
/// A string identifying the track for playback.
|
||||||
final String uri;
|
///
|
||||||
|
/// This will be the result of calling the platform objects getIdentifier()
|
||||||
|
/// function with the file name of the track.
|
||||||
|
final String identifier;
|
||||||
|
|
||||||
InternalTrack({
|
InternalTrack({
|
||||||
this.track,
|
this.track,
|
||||||
this.uri,
|
this.identifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory InternalTrack.fromJson(Map<String, dynamic> json) => InternalTrack(
|
factory InternalTrack.fromJson(Map<String, dynamic> json) => InternalTrack(
|
||||||
track: Track.fromJson(json['track']),
|
track: Track.fromJson(json['track']),
|
||||||
uri: json['uri'],
|
identifier: json['identifier'],
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
'track': track.toJson(),
|
'track': track.toJson(),
|
||||||
'uri': uri,
|
'identifier': identifier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,13 +105,12 @@ class MusicusFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manager for all available tracks and their representation on disk.
|
/// Manager for all available tracks and their representation on disk.
|
||||||
class MusicLibrary {
|
class MusicusLibrary {
|
||||||
static const platform = MethodChannel('de.johrpan.musicus/platform');
|
/// String representing the music library base path.
|
||||||
|
final String basePath;
|
||||||
|
|
||||||
/// URI of the music library folder.
|
/// Access to platform dependent functionality.
|
||||||
///
|
final MusicusPlatform platform;
|
||||||
/// This is a tree URI in the terms of the Android Storage Access Framework.
|
|
||||||
final String treeUri;
|
|
||||||
|
|
||||||
/// Map of all available tracks by recording ID.
|
/// Map of all available tracks by recording ID.
|
||||||
///
|
///
|
||||||
|
|
@ -120,7 +118,7 @@ class MusicLibrary {
|
||||||
/// audio file alongside the real [Track] object.
|
/// audio file alongside the real [Track] object.
|
||||||
final Map<int, List<InternalTrack>> tracks = {};
|
final Map<int, List<InternalTrack>> tracks = {};
|
||||||
|
|
||||||
MusicLibrary(this.treeUri);
|
MusicusLibrary(this.basePath, this.platform);
|
||||||
|
|
||||||
/// Load all available tracks.
|
/// Load all available tracks.
|
||||||
///
|
///
|
||||||
|
|
@ -130,13 +128,13 @@ class MusicLibrary {
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
// TODO: Consider capping the recursion somewhere.
|
// TODO: Consider capping the recursion somewhere.
|
||||||
Future<void> recurse([String parentId]) async {
|
Future<void> recurse([String parentId]) async {
|
||||||
final children = await Platform.getChildren(treeUri, parentId);
|
final children = await platform.getChildren(parentId);
|
||||||
|
|
||||||
for (final child in children) {
|
for (final child in children) {
|
||||||
if (child.isDirectory) {
|
if (child.isDirectory) {
|
||||||
recurse(child.id);
|
recurse(child.id);
|
||||||
} else if (child.name == 'musicus.json') {
|
} else if (child.name == 'musicus.json') {
|
||||||
final content = await Platform.readFile(treeUri, child.id);
|
final content = await platform.readDocument(child.id);
|
||||||
final musicusFile = MusicusFile.fromJson(jsonDecode(content));
|
final musicusFile = MusicusFile.fromJson(jsonDecode(content));
|
||||||
for (final track in musicusFile.tracks) {
|
for (final track in musicusFile.tracks) {
|
||||||
_indexTrack(parentId, track);
|
_indexTrack(parentId, track);
|
||||||
|
|
@ -156,7 +154,7 @@ class MusicLibrary {
|
||||||
MusicusFile musicusFile;
|
MusicusFile musicusFile;
|
||||||
|
|
||||||
final oldContent =
|
final oldContent =
|
||||||
await Platform.readFileByName(treeUri, parentId, 'musicus.json');
|
await platform.readDocumentByName(parentId, 'musicus.json');
|
||||||
|
|
||||||
if (oldContent != null) {
|
if (oldContent != null) {
|
||||||
musicusFile = MusicusFile.fromJson(jsonDecode(oldContent));
|
musicusFile = MusicusFile.fromJson(jsonDecode(oldContent));
|
||||||
|
|
@ -169,15 +167,15 @@ class MusicLibrary {
|
||||||
musicusFile.tracks.add(track);
|
musicusFile.tracks.add(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Platform.writeFileByName(
|
await platform.writeDocumentByName(
|
||||||
treeUri, parentId, 'musicus.json', jsonEncode(musicusFile.toJson()));
|
parentId, 'musicus.json', jsonEncode(musicusFile.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a track to the map of available tracks.
|
/// Add a track to the map of available tracks.
|
||||||
Future<void> _indexTrack(String parentId, Track track) async {
|
Future<void> _indexTrack(String parentId, Track track) async {
|
||||||
final iTrack = InternalTrack(
|
final iTrack = InternalTrack(
|
||||||
track: track,
|
track: track,
|
||||||
uri: await Platform.getUriByName(treeUri, parentId, track.fileName),
|
identifier: await platform.getIdentifier(parentId, track.fileName),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (tracks.containsKey(track.recordingId)) {
|
if (tracks.containsKey(track.recordingId)) {
|
||||||
76
common/lib/src/platform.dart
Normal file
76
common/lib/src/platform.dart
Normal 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);
|
||||||
|
}
|
||||||
96
common/lib/src/playback.dart
Normal file
96
common/lib/src/playback.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ class FilesSelector extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FilesSelectorState extends State<FilesSelector> {
|
class _FilesSelectorState extends State<FilesSelector> {
|
||||||
BackendState backend;
|
MusicusBackendState backend;
|
||||||
List<Document> history = [];
|
List<Document> history = [];
|
||||||
List<Document> children = [];
|
List<Document> children = [];
|
||||||
Set<Document> selection = {};
|
Set<Document> selection = {};
|
||||||
|
|
@ -33,7 +33,7 @@ class _FilesSelectorState extends State<FilesSelector> {
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|
||||||
backend = Backend.of(context);
|
backend = MusicusBackend.of(context);
|
||||||
loadChildren();
|
loadChildren();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,9 +130,8 @@ class _FilesSelectorState extends State<FilesSelector> {
|
||||||
selection = {};
|
selection = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
final newChildren = await Platform.getChildren(
|
final newChildren = await backend.platform
|
||||||
backend.settings.musicLibraryUri.value,
|
.getChildren(history.isNotEmpty ? history.last.id : null);
|
||||||
history.isNotEmpty ? history.last.id : null);
|
|
||||||
|
|
||||||
newChildren.sort((d1, d2) {
|
newChildren.sort((d1, d2) {
|
||||||
if (d1.isDirectory != d2.isDirectory) {
|
if (d1.isDirectory != d2.isDirectory) {
|
||||||
|
|
@ -35,7 +35,7 @@ class _InstrumentsSelectorState extends State<InstrumentsSelector> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
112
common/lib/src/settings.dart
Normal file
112
common/lib/src/settings.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -161,7 +161,7 @@ class _PersonsListState extends State<PersonsList> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
|
@ -222,7 +222,7 @@ class _EnsemblesListState extends State<EnsemblesList> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
|
@ -287,7 +287,7 @@ class _WorksListState extends State<WorksList> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
|
@ -346,7 +346,7 @@ class RecordingsList extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return PagedListView<RecordingInfo>(
|
return PagedListView<RecordingInfo>(
|
||||||
fetch: (page, _) async {
|
fetch: (page, _) async {
|
||||||
|
|
@ -45,7 +45,7 @@ class WorkText extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return StreamBuilder<Work>(
|
return StreamBuilder<Work>(
|
||||||
stream: backend.db.workById(workId).watchSingle(),
|
stream: backend.db.workById(workId).watchSingle(),
|
||||||
|
|
@ -61,7 +61,7 @@ class ComposersText extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return StreamBuilder<List<Person>>(
|
return StreamBuilder<List<Person>>(
|
||||||
stream: backend.db.composersByWork(workId).watch(),
|
stream: backend.db.composersByWork(workId).watch(),
|
||||||
18
common/pubspec.yaml
Normal file
18
common/pubspec.yaml
Normal 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:
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
|
|
||||||
import 'backend.dart';
|
|
||||||
import 'screens/home.dart';
|
import 'screens/home.dart';
|
||||||
import 'widgets/player_bar.dart';
|
import 'widgets/player_bar.dart';
|
||||||
|
|
||||||
class App extends StatelessWidget {
|
class App extends StatelessWidget {
|
||||||
|
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Musicus',
|
title: 'Musicus',
|
||||||
|
|
@ -36,11 +39,11 @@ class App extends StatelessWidget {
|
||||||
),
|
),
|
||||||
home: Builder(
|
home: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
if (backend.status == BackendStatus.loading) {
|
if (backend.status == MusicusBackendStatus.loading) {
|
||||||
return Material(
|
return Material(
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
);
|
);
|
||||||
} else if (backend.status == BackendStatus.setup) {
|
} else if (backend.status == MusicusBackendStatus.setup) {
|
||||||
return Material(
|
return Material(
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -57,8 +60,13 @@ class App extends StatelessWidget {
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.folder_open),
|
leading: const Icon(Icons.folder_open),
|
||||||
title: Text('Choose path'),
|
title: Text('Choose path'),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
backend.settings.chooseMusicLibraryUri();
|
final uri =
|
||||||
|
await _platform.invokeMethod<String>('openTree');
|
||||||
|
|
||||||
|
if (uri != null) {
|
||||||
|
backend.settings.setMusicLibraryPath(uri);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -82,7 +90,7 @@ class _ContentState extends State<Content> with SingleTickerProviderStateMixin {
|
||||||
final nestedNavigator = GlobalKey<NavigatorState>();
|
final nestedNavigator = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
AnimationController playerBarAnimation;
|
AnimationController playerBarAnimation;
|
||||||
BackendState backend;
|
MusicusBackendState backend;
|
||||||
StreamSubscription<bool> playerActiveSubscription;
|
StreamSubscription<bool> playerActiveSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -99,14 +107,14 @@ class _ContentState extends State<Content> with SingleTickerProviderStateMixin {
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|
||||||
backend = Backend.of(context);
|
backend = MusicusBackend.of(context);
|
||||||
playerBarAnimation.value = backend.player.active.value ? 1.0 : 0.0;
|
playerBarAnimation.value = backend.playback.active.value ? 1.0 : 0.0;
|
||||||
|
|
||||||
if (playerActiveSubscription != null) {
|
if (playerActiveSubscription != null) {
|
||||||
playerActiveSubscription.cancel();
|
playerActiveSubscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
playerActiveSubscription = backend.player.active.listen((active) =>
|
playerActiveSubscription = backend.playback.active.listen((active) =>
|
||||||
active ? playerBarAnimation.forward() : playerBarAnimation.reverse());
|
active ? playerBarAnimation.forward() : playerBarAnimation.reverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,26 @@
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart' as pp;
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
import 'backend.dart';
|
import 'settings.dart';
|
||||||
|
import 'platform.dart';
|
||||||
|
import 'playback.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
final dir = await pp.getApplicationDocumentsDirectory();
|
||||||
|
final dbPath = p.join(dir.path, 'db.sqlite');
|
||||||
|
|
||||||
void main() {
|
|
||||||
runApp(AudioServiceWidget(
|
runApp(AudioServiceWidget(
|
||||||
child: Backend(
|
child: MusicusBackend(
|
||||||
|
dbPath: dbPath,
|
||||||
|
settingsStorage: SettingsStorage(),
|
||||||
|
platform: MusicusAndroidPlatform(),
|
||||||
|
playback: Playback(),
|
||||||
child: App(),
|
child: App(),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,16 @@
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
|
|
||||||
/// Object representing a document in Storage Access Framework terms.
|
class MusicusAndroidPlatform extends MusicusPlatform {
|
||||||
class Document {
|
|
||||||
/// Unique document ID given by the SAF.
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
/// Name of the document (i.e. file name).
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
/// Document ID of the parent document.
|
|
||||||
final String parent;
|
|
||||||
|
|
||||||
/// Whether this document represents a directory.
|
|
||||||
final bool isDirectory;
|
|
||||||
|
|
||||||
// Use Map<dynamic, dynamic> here, as we get casting errors otherwise. This
|
|
||||||
// won't be typesafe anyway.
|
|
||||||
Document.fromJson(Map<dynamic, dynamic> json)
|
|
||||||
: id = json['id'],
|
|
||||||
name = json['name'],
|
|
||||||
parent = json['parent'],
|
|
||||||
isDirectory = json['isDirectory'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Collection of methods that are implemented platform dependent.
|
|
||||||
class Platform {
|
|
||||||
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
||||||
|
|
||||||
/// Get child documents.
|
@override
|
||||||
///
|
Future<List<Document>> getChildren(String parentId) async {
|
||||||
/// [treeId] is the base URI as requested from the SAF.
|
|
||||||
/// [parentId] is the document ID of the parent. If this is null, the children
|
|
||||||
/// of the tree base will be returned.
|
|
||||||
static Future<List<Document>> getChildren(
|
|
||||||
String treeUri, String parentId) async {
|
|
||||||
final List<Map<dynamic, dynamic>> childrenJson =
|
final List<Map<dynamic, dynamic>> childrenJson =
|
||||||
await _platform.invokeListMethod(
|
await _platform.invokeListMethod(
|
||||||
'getChildren',
|
'getChildren',
|
||||||
{
|
{
|
||||||
'treeUri': treeUri,
|
'treeUri': basePath,
|
||||||
'parentId': parentId,
|
'parentId': parentId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -48,65 +20,51 @@ class Platform {
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read contents of file.
|
@override
|
||||||
///
|
Future<String> getIdentifier(String parentId, String fileName) async {
|
||||||
/// [treeId] is the base URI from the SAF, [id] is the document ID of the
|
return await _platform.invokeMethod(
|
||||||
/// file.
|
'getUriByName',
|
||||||
static Future<String> readFile(String treeUri, String id) async {
|
{
|
||||||
|
'treeUri': basePath,
|
||||||
|
'parentId': parentId,
|
||||||
|
'fileName': fileName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String> readDocument(String id) async {
|
||||||
return await _platform.invokeMethod(
|
return await _platform.invokeMethod(
|
||||||
'readFile',
|
'readFile',
|
||||||
{
|
{
|
||||||
'treeUri': treeUri,
|
'treeUri': basePath,
|
||||||
'id': id,
|
'id': id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get document URI by file name
|
@override
|
||||||
///
|
Future<String> readDocumentByName(String parentId, String fileName) async {
|
||||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
|
||||||
/// the parent directory.
|
|
||||||
static Future<String> getUriByName(
|
|
||||||
String treeUri, String parentId, String fileName) async {
|
|
||||||
return await _platform.invokeMethod(
|
|
||||||
'getUriByName',
|
|
||||||
{
|
|
||||||
'treeUri': treeUri,
|
|
||||||
'parentId': parentId,
|
|
||||||
'fileName': fileName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read contents of file by name
|
|
||||||
///
|
|
||||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
|
||||||
/// the parent directory.
|
|
||||||
static Future<String> readFileByName(
|
|
||||||
String treeUri, String parentId, String fileName) async {
|
|
||||||
return await _platform.invokeMethod(
|
return await _platform.invokeMethod(
|
||||||
'readFileByName',
|
'readFileByName',
|
||||||
{
|
{
|
||||||
'treeUri': treeUri,
|
'treeUri': basePath,
|
||||||
'parentId': parentId,
|
'parentId': parentId,
|
||||||
'fileName': fileName,
|
'fileName': fileName,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write to file by name
|
@override
|
||||||
///
|
Future<void> writeDocumentByName(
|
||||||
/// [treeId] is the base URI from the SAF, [parentId] is the document ID of
|
String parentId, String fileName, String contents) async {
|
||||||
/// the parent directory.
|
|
||||||
static Future<void> writeFileByName(
|
|
||||||
String treeUri, String parentId, String fileName, String content) async {
|
|
||||||
await _platform.invokeMethod(
|
await _platform.invokeMethod(
|
||||||
'writeFileByName',
|
'writeFileByName',
|
||||||
{
|
{
|
||||||
'treeUri': treeUri,
|
'treeUri': basePath,
|
||||||
'parentId': parentId,
|
'parentId': parentId,
|
||||||
'fileName': fileName,
|
'fileName': fileName,
|
||||||
'content': content,
|
'content': contents,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@ import 'dart:ui';
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:moor/isolate.dart';
|
import 'package:moor/isolate.dart';
|
||||||
import 'package:musicus_database/musicus_database.dart';
|
import 'package:musicus_database/musicus_database.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import 'package:musicus_player/musicus_player.dart';
|
import 'package:musicus_player/musicus_player.dart';
|
||||||
import 'package:rxdart/rxdart.dart';
|
|
||||||
|
|
||||||
import 'music_library.dart';
|
|
||||||
|
|
||||||
const _portName = 'playbackService';
|
const _portName = 'playbackService';
|
||||||
|
|
||||||
|
|
@ -18,61 +16,11 @@ void _playbackServiceEntrypoint() {
|
||||||
AudioServiceBackground.run(() => _PlaybackService());
|
AudioServiceBackground.run(() => _PlaybackService());
|
||||||
}
|
}
|
||||||
|
|
||||||
class Player {
|
class Playback extends MusicusPlayback {
|
||||||
/// Whether the player is active.
|
|
||||||
///
|
|
||||||
/// This means, that there is at least one item in the queue and the playback
|
|
||||||
/// service is ready to play.
|
|
||||||
final active = BehaviorSubject.seeded(false);
|
|
||||||
|
|
||||||
/// The current playlist.
|
|
||||||
///
|
|
||||||
/// If the player is not active, this will be an empty list.
|
|
||||||
final playlist = BehaviorSubject.seeded(<InternalTrack>[]);
|
|
||||||
|
|
||||||
/// Index of the currently played (or paused) track within the playlist.
|
|
||||||
///
|
|
||||||
/// This will be zero, if the player is not active!
|
|
||||||
final currentIndex = BehaviorSubject.seeded(0);
|
|
||||||
|
|
||||||
/// The currently played track.
|
|
||||||
///
|
|
||||||
/// This will be null, if there is no current track.
|
|
||||||
final currentTrack = BehaviorSubject<InternalTrack>.seeded(null);
|
|
||||||
|
|
||||||
/// Whether we are currently playing or not.
|
|
||||||
///
|
|
||||||
/// This will be false, if the player is not active.
|
|
||||||
final playing = BehaviorSubject.seeded(false);
|
|
||||||
|
|
||||||
/// Current playback position.
|
|
||||||
///
|
|
||||||
/// If the player is not active, this will default to zero.
|
|
||||||
final position = BehaviorSubject.seeded(const Duration());
|
|
||||||
|
|
||||||
/// Duration of the current track.
|
|
||||||
///
|
|
||||||
/// If the player is not active, the duration will default to 1 s.
|
|
||||||
final duration = BehaviorSubject.seeded(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
/// Playback position normalized to the range from zero to one.
|
|
||||||
final normalizedPosition = BehaviorSubject.seeded(0.0);
|
|
||||||
|
|
||||||
StreamSubscription _playbackServiceStateSubscription;
|
StreamSubscription _playbackServiceStateSubscription;
|
||||||
|
|
||||||
/// Set everything to its default because the playback service was stopped.
|
|
||||||
void _stop() {
|
|
||||||
active.add(false);
|
|
||||||
playlist.add([]);
|
|
||||||
currentIndex.add(0);
|
|
||||||
playing.add(false);
|
|
||||||
position.add(const Duration());
|
|
||||||
duration.add(const Duration(seconds: 1));
|
|
||||||
normalizedPosition.add(0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start playback service.
|
/// Start playback service.
|
||||||
Future<void> start() async {
|
Future<void> _start() async {
|
||||||
if (!AudioService.running) {
|
if (!AudioService.running) {
|
||||||
await AudioService.start(
|
await AudioService.start(
|
||||||
backgroundTaskEntrypoint: _playbackServiceEntrypoint,
|
backgroundTaskEntrypoint: _playbackServiceEntrypoint,
|
||||||
|
|
@ -109,8 +57,8 @@ class Player {
|
||||||
currentTrack.add(playlist.value[index]);
|
currentTrack.add(playlist.value[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect listeners and initialize streams.
|
@override
|
||||||
void setup() {
|
Future<void> setup() async {
|
||||||
if (_playbackServiceStateSubscription != null) {
|
if (_playbackServiceStateSubscription != null) {
|
||||||
_playbackServiceStateSubscription.cancel();
|
_playbackServiceStateSubscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
@ -125,8 +73,12 @@ class Player {
|
||||||
).listen((msg) {
|
).listen((msg) {
|
||||||
// If state is null, the background audio service has stopped.
|
// If state is null, the background audio service has stopped.
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
_stop();
|
dispose();
|
||||||
} else {
|
} else {
|
||||||
|
if (!active.value) {
|
||||||
|
active.add(true);
|
||||||
|
}
|
||||||
|
|
||||||
if (msg is _StatusMessage) {
|
if (msg is _StatusMessage) {
|
||||||
playing.add(msg.playing);
|
playing.add(msg.playing);
|
||||||
} else if (msg is _PositionMessage) {
|
} else if (msg is _PositionMessage) {
|
||||||
|
|
@ -154,9 +106,23 @@ class Player {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle whether the player is playing or paused.
|
@override
|
||||||
///
|
Future<void> addTracks(List<InternalTrack> tracks) async {
|
||||||
/// If the player is not active, this will do nothing.
|
if (!AudioService.running) {
|
||||||
|
await _start();
|
||||||
|
}
|
||||||
|
|
||||||
|
await AudioService.customAction('addTracks', jsonEncode(tracks));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> removeTrack(int index) async {
|
||||||
|
if (AudioService.running) {
|
||||||
|
await AudioService.customAction('removeTrack', index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> playPause() async {
|
Future<void> playPause() async {
|
||||||
if (active.value) {
|
if (active.value) {
|
||||||
if (playing.value) {
|
if (playing.value) {
|
||||||
|
|
@ -167,29 +133,7 @@ class Player {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a list of tracks to the players playlist.
|
@override
|
||||||
Future<void> addTracks(List<InternalTrack> tracks) async {
|
|
||||||
if (!AudioService.running) {
|
|
||||||
await start();
|
|
||||||
}
|
|
||||||
|
|
||||||
await AudioService.customAction('addTracks', jsonEncode(tracks));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the track at [index] from the playlist.
|
|
||||||
///
|
|
||||||
/// If the player is not active or an invalid value is provided, this will do
|
|
||||||
/// nothing.
|
|
||||||
Future<void> removeTrack(int index) async {
|
|
||||||
if (AudioService.running) {
|
|
||||||
await AudioService.customAction('removeTrack', index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seek to [pos], which is a value between (and including) zero and one.
|
|
||||||
///
|
|
||||||
/// If the player is not active or an invalid value is provided, this will do
|
|
||||||
/// nothing.
|
|
||||||
Future<void> seekTo(double pos) async {
|
Future<void> seekTo(double pos) async {
|
||||||
if (active.value && pos >= 0.0 && pos <= 1.0) {
|
if (active.value && pos >= 0.0 && pos <= 1.0) {
|
||||||
final durationMs = duration.value.inMilliseconds;
|
final durationMs = duration.value.inMilliseconds;
|
||||||
|
|
@ -197,45 +141,31 @@ class Player {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play the previous track in the playlist.
|
@override
|
||||||
///
|
|
||||||
/// If the player is not active or there is no previous track, this will do
|
|
||||||
/// nothing.
|
|
||||||
Future<void> skipToNext() async {
|
|
||||||
if (AudioService.running) {
|
|
||||||
await AudioService.skipToNext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Skip to the next track in the playlist.
|
|
||||||
///
|
|
||||||
/// If the player is not active or there is no next track, this will do
|
|
||||||
/// nothing. If more than five seconds of the current track have been played,
|
|
||||||
/// this will go back to its beginning instead.
|
|
||||||
Future<void> skipToPrevious() async {
|
Future<void> skipToPrevious() async {
|
||||||
if (AudioService.running) {
|
if (AudioService.running) {
|
||||||
await AudioService.skipToPrevious();
|
await AudioService.skipToPrevious();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch to the track with the index [index] in the playlist.
|
@override
|
||||||
|
Future<void> skipToNext() async {
|
||||||
|
if (AudioService.running) {
|
||||||
|
await AudioService.skipToNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> skipTo(int index) async {
|
Future<void> skipTo(int index) async {
|
||||||
if (AudioService.running) {
|
if (AudioService.running) {
|
||||||
await AudioService.customAction('skipTo', index);
|
await AudioService.customAction('skipTo', index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tidy up.
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
_playbackServiceStateSubscription.cancel();
|
_playbackServiceStateSubscription.cancel();
|
||||||
active.close();
|
|
||||||
playlist.close();
|
|
||||||
currentIndex.close();
|
|
||||||
currentTrack.close();
|
|
||||||
playing.close();
|
|
||||||
position.close();
|
|
||||||
duration.close();
|
|
||||||
normalizedPosition.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,7 +293,7 @@ class _PlaybackService extends BackgroundAudioTask {
|
||||||
|
|
||||||
/// Initialize database.
|
/// Initialize database.
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final moorPort = IsolateNameServer.lookupPortByName('moorPort');
|
final moorPort = IsolateNameServer.lookupPortByName('moor');
|
||||||
final moorIsolate = MoorIsolate.fromConnectPort(moorPort);
|
final moorIsolate = MoorIsolate.fromConnectPort(moorPort);
|
||||||
db = Database.connect(await moorIsolate.connect());
|
db = Database.connect(await moorIsolate.connect());
|
||||||
_loading.complete();
|
_loading.complete();
|
||||||
|
|
@ -397,7 +327,7 @@ class _PlaybackService extends BackgroundAudioTask {
|
||||||
final title = workInfo.work.title;
|
final title = workInfo.work.title;
|
||||||
|
|
||||||
AudioServiceBackground.setMediaItem(MediaItem(
|
AudioServiceBackground.setMediaItem(MediaItem(
|
||||||
id: track.uri,
|
id: track.identifier,
|
||||||
album: composers,
|
album: composers,
|
||||||
title: title,
|
title: title,
|
||||||
));
|
));
|
||||||
|
|
@ -456,7 +386,7 @@ class _PlaybackService extends BackgroundAudioTask {
|
||||||
/// Set the current track, update the player and notify the system.
|
/// Set the current track, update the player and notify the system.
|
||||||
Future<void> _setCurrentTrack(int index) async {
|
Future<void> _setCurrentTrack(int index) async {
|
||||||
_currentTrack = index;
|
_currentTrack = index;
|
||||||
_durationMs = await _player.setUri(_playlist[_currentTrack].uri);
|
_durationMs = await _player.setUri(_playlist[_currentTrack].identifier);
|
||||||
_setState();
|
_setState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,7 +438,7 @@ class _PlaybackService extends BackgroundAudioTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onCustomAction(String name, dynamic arguments) {
|
Future<void> onCustomAction(String name, dynamic arguments) async {
|
||||||
super.onCustomAction(name, arguments);
|
super.onCustomAction(name, arguments);
|
||||||
|
|
||||||
// addTracks expects a List<Map<String, dynamic>> as its argument.
|
// addTracks expects a List<Map<String, dynamic>> as its argument.
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import 'package:musicus_database/musicus_database.dart';
|
import 'package:musicus_database/musicus_database.dart';
|
||||||
|
|
||||||
import '../backend.dart';
|
|
||||||
import '../editors/tracks.dart';
|
|
||||||
import '../icons.dart';
|
import '../icons.dart';
|
||||||
import '../widgets/lists.dart';
|
|
||||||
|
|
||||||
import 'person.dart';
|
import 'person.dart';
|
||||||
import 'settings.dart';
|
import 'settings.dart';
|
||||||
|
|
@ -19,7 +17,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import 'package:musicus_database/musicus_database.dart';
|
import 'package:musicus_database/musicus_database.dart';
|
||||||
|
|
||||||
import '../backend.dart';
|
|
||||||
import '../editors/person.dart';
|
|
||||||
import '../widgets/lists.dart';
|
|
||||||
|
|
||||||
import 'work.dart';
|
import 'work.dart';
|
||||||
|
|
||||||
class PersonScreen extends StatefulWidget {
|
class PersonScreen extends StatefulWidget {
|
||||||
|
|
@ -23,7 +20,7 @@ class _PersonScreenState extends State<PersonScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import 'package:musicus_database/musicus_database.dart';
|
import 'package:musicus_database/musicus_database.dart';
|
||||||
|
|
||||||
import '../backend.dart';
|
|
||||||
import '../music_library.dart';
|
|
||||||
import '../widgets/play_pause_button.dart';
|
import '../widgets/play_pause_button.dart';
|
||||||
import '../widgets/recording_tile.dart';
|
|
||||||
|
|
||||||
class ProgramScreen extends StatefulWidget {
|
class ProgramScreen extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -14,7 +12,7 @@ class ProgramScreen extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProgramScreenState extends State<ProgramScreen> {
|
class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
BackendState backend;
|
MusicusBackendState backend;
|
||||||
|
|
||||||
StreamSubscription<bool> playerActiveSubscription;
|
StreamSubscription<bool> playerActiveSubscription;
|
||||||
|
|
||||||
|
|
@ -29,14 +27,14 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|
||||||
backend = Backend.of(context);
|
backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
if (playerActiveSubscription != null) {
|
if (playerActiveSubscription != null) {
|
||||||
playerActiveSubscription.cancel();
|
playerActiveSubscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the program screen, if the player is no longer active.
|
// Close the program screen, if the player is no longer active.
|
||||||
playerActiveSubscription = backend.player.active.listen((active) {
|
playerActiveSubscription = backend.playback.active.listen((active) {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +44,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
playlistSubscription.cancel();
|
playlistSubscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
playlistSubscription = backend.player.playlist.listen((playlist) {
|
playlistSubscription = backend.playback.playlist.listen((playlist) {
|
||||||
updateProgram(playlist);
|
updateProgram(playlist);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -54,7 +52,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
positionSubscription.cancel();
|
positionSubscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
positionSubscription = backend.player.normalizedPosition.listen((pos) {
|
positionSubscription = backend.playback.normalizedPosition.listen((pos) {
|
||||||
if (!seeking) {
|
if (!seeking) {
|
||||||
setState(() {
|
setState(() {
|
||||||
position = pos;
|
position = pos;
|
||||||
|
|
@ -154,7 +152,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
title: Text('Program'),
|
title: Text('Program'),
|
||||||
),
|
),
|
||||||
body: StreamBuilder<int>(
|
body: StreamBuilder<int>(
|
||||||
stream: backend.player.currentIndex,
|
stream: backend.playback.currentIndex,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
|
|
@ -181,7 +179,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
backend.player.skipTo(index);
|
backend.playback.skipTo(index);
|
||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
showDialog(
|
showDialog(
|
||||||
|
|
@ -192,7 +190,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text('Remove from playlist'),
|
title: Text('Remove from playlist'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
backend.player.removeTrack(index);
|
backend.playback.removeTrack(index);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -220,7 +218,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
},
|
},
|
||||||
onChangeEnd: (pos) {
|
onChangeEnd: (pos) {
|
||||||
seeking = false;
|
seeking = false;
|
||||||
backend.player.seekTo(pos);
|
backend.playback.seekTo(pos);
|
||||||
},
|
},
|
||||||
onChanged: (pos) {
|
onChanged: (pos) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -233,7 +231,7 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 24.0),
|
padding: const EdgeInsets.only(left: 24.0),
|
||||||
child: StreamBuilder<Duration>(
|
child: StreamBuilder<Duration>(
|
||||||
stream: backend.player.position,
|
stream: backend.playback.position,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return DurationText(snapshot.data);
|
return DurationText(snapshot.data);
|
||||||
|
|
@ -247,21 +245,21 @@ class _ProgramScreenState extends State<ProgramScreen> {
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.skip_previous),
|
icon: const Icon(Icons.skip_previous),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
backend.player.skipToPrevious();
|
backend.playback.skipToPrevious();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
PlayPauseButton(),
|
PlayPauseButton(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.skip_next),
|
icon: const Icon(Icons.skip_next),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
backend.player.skipToNext();
|
backend.playback.skipToNext();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Spacer(),
|
Spacer(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: 20.0),
|
padding: const EdgeInsets.only(right: 20.0),
|
||||||
child: StreamBuilder<Duration>(
|
child: StreamBuilder<Duration>(
|
||||||
stream: backend.player.duration,
|
stream: backend.playback.duration,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return DurationText(snapshot.data);
|
return DurationText(snapshot.data);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import '../backend.dart';
|
|
||||||
import '../settings.dart';
|
|
||||||
|
|
||||||
class ServerSettingsScreen extends StatefulWidget {
|
class ServerSettingsScreen extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -13,16 +11,16 @@ class ServerSettingsScreen extends StatefulWidget {
|
||||||
class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
|
class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
|
||||||
final hostController = TextEditingController();
|
final hostController = TextEditingController();
|
||||||
final portController = TextEditingController();
|
final portController = TextEditingController();
|
||||||
final basePathController = TextEditingController();
|
final apiPathController = TextEditingController();
|
||||||
|
|
||||||
BackendState backend;
|
MusicusBackendState backend;
|
||||||
StreamSubscription<ServerSettings> serverSubscription;
|
StreamSubscription<MusicusServerSettings> serverSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|
||||||
backend = Backend.of(context);
|
backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
if (serverSubscription != null) {
|
if (serverSubscription != null) {
|
||||||
serverSubscription.cancel();
|
serverSubscription.cancel();
|
||||||
|
|
@ -34,10 +32,10 @@ class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _settingsChanged(ServerSettings settings) {
|
void _settingsChanged(MusicusServerSettings settings) {
|
||||||
hostController.text = settings.host;
|
hostController.text = settings.host;
|
||||||
portController.text = settings.port.toString();
|
portController.text = settings.port.toString();
|
||||||
basePathController.text = settings.basePath;
|
apiPathController.text = settings.apiPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -50,15 +48,15 @@ class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
|
||||||
icon: const Icon(Icons.restore),
|
icon: const Icon(Icons.restore),
|
||||||
tooltip: 'Reset to default',
|
tooltip: 'Reset to default',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
backend.settings.resetServerSettings();
|
backend.settings.resetServer();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
FlatButton(
|
FlatButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await backend.settings.setServerSettings(ServerSettings(
|
await backend.settings.setServer(MusicusServerSettings(
|
||||||
host: hostController.text,
|
host: hostController.text,
|
||||||
port: int.parse(portController.text),
|
port: int.parse(portController.text),
|
||||||
basePath: basePathController.text,
|
apiPath: apiPathController.text,
|
||||||
));
|
));
|
||||||
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|
@ -91,7 +89,7 @@ class _ServerSettingsScreenState extends State<ServerSettingsScreen> {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: basePathController,
|
controller: apiPathController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'API path',
|
labelText: 'API path',
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../backend.dart';
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import '../settings.dart';
|
|
||||||
|
|
||||||
import 'server_settings.dart';
|
import 'server_settings.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
|
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
final settings = backend.settings;
|
final settings = backend.settings;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -18,18 +19,23 @@ class SettingsScreen extends StatelessWidget {
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
StreamBuilder<String>(
|
StreamBuilder<String>(
|
||||||
stream: settings.musicLibraryUri,
|
stream: settings.musicLibraryPath,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text('Music library path'),
|
title: Text('Music library path'),
|
||||||
subtitle: Text(snapshot.data ?? 'Choose folder'),
|
subtitle: Text(snapshot.data ?? 'Choose folder'),
|
||||||
isThreeLine: snapshot.hasData,
|
isThreeLine: snapshot.hasData,
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
settings.chooseMusicLibraryUri();
|
final uri =
|
||||||
|
await _platform.invokeMethod<String>('openTree');
|
||||||
|
|
||||||
|
if (uri != null) {
|
||||||
|
settings.setMusicLibraryPath(uri);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
StreamBuilder<ServerSettings>(
|
StreamBuilder<MusicusServerSettings>(
|
||||||
stream: settings.server,
|
stream: settings.server,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final s = snapshot.data;
|
final s = snapshot.data;
|
||||||
|
|
@ -37,10 +43,10 @@ class SettingsScreen extends StatelessWidget {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text('Musicus server'),
|
title: Text('Musicus server'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
s != null ? '${s.host}:${s.port}${s.basePath}' : '...'),
|
s != null ? '${s.host}:${s.port}${s.apiPath}' : '...'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final ServerSettings result = await Navigator.push(
|
final MusicusServerSettings result = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => ServerSettingsScreen(),
|
builder: (context) => ServerSettingsScreen(),
|
||||||
|
|
@ -48,7 +54,7 @@ class SettingsScreen extends StatelessWidget {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
settings.setServerSettings(result);
|
settings.setServer(result);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import 'package:musicus_database/musicus_database.dart';
|
import 'package:musicus_database/musicus_database.dart';
|
||||||
|
|
||||||
import '../backend.dart';
|
|
||||||
import '../editors/work.dart';
|
|
||||||
import '../widgets/texts.dart';
|
|
||||||
import '../widgets/lists.dart';
|
|
||||||
|
|
||||||
class WorkScreen extends StatelessWidget {
|
class WorkScreen extends StatelessWidget {
|
||||||
final WorkInfo workInfo;
|
final WorkInfo workInfo;
|
||||||
|
|
||||||
|
|
@ -15,7 +11,7 @@ class WorkScreen extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -46,11 +42,11 @@ class WorkScreen extends StatelessWidget {
|
||||||
performanceInfos: recordingInfo.performances,
|
performanceInfos: recordingInfo.performances,
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final tracks = backend.ml.tracks[recordingInfo.recording.id];
|
final tracks = backend.library.tracks[recordingInfo.recording.id];
|
||||||
tracks.sort((t1, t2) => t1.track.index.compareTo(t2.track.index));
|
tracks.sort((t1, t2) => t1.track.index.compareTo(t2.track.index));
|
||||||
|
|
||||||
backend.player
|
backend.playback
|
||||||
.addTracks(backend.ml.tracks[recordingInfo.recording.id]);
|
.addTracks(backend.library.tracks[recordingInfo.recording.id]);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,30 @@
|
||||||
import 'package:flutter/services.dart';
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import 'package:meta/meta.dart';
|
|
||||||
import 'package:rxdart/rxdart.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Settings concerning the Musicus server to connect to.
|
class SettingsStorage extends MusicusSettingsStorage {
|
||||||
///
|
SharedPreferences _pref;
|
||||||
/// We don't support setting a scheme here, because there may be password being
|
|
||||||
/// submitted in the future, so we default to HTTPS.
|
|
||||||
class ServerSettings {
|
|
||||||
static const defaultHost = 'musicus.johrpan.de';
|
|
||||||
static const defaultPort = 1833;
|
|
||||||
static const defaultBasePath = '/api';
|
|
||||||
|
|
||||||
/// Host to connect to, e.g. 'musicus.johrpan.de';
|
|
||||||
final String host;
|
|
||||||
|
|
||||||
/// Port to connect to.
|
|
||||||
final int port;
|
|
||||||
|
|
||||||
/// Path to the API.
|
|
||||||
///
|
|
||||||
/// This should be null, if the API is at the root of the host.
|
|
||||||
final String basePath;
|
|
||||||
|
|
||||||
ServerSettings({
|
|
||||||
@required this.host,
|
|
||||||
@required this.port,
|
|
||||||
@required this.basePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Manager for all settings that are persisted.
|
|
||||||
class Settings {
|
|
||||||
static const defaultHost = 'musicus.johrpan.de';
|
|
||||||
static const defaultPort = 443;
|
|
||||||
static const defaultBasePath = '/api';
|
|
||||||
|
|
||||||
static const _platform = MethodChannel('de.johrpan.musicus/platform');
|
|
||||||
|
|
||||||
/// The tree storage access framework tree URI of the music library.
|
|
||||||
final musicLibraryUri = BehaviorSubject<String>();
|
|
||||||
|
|
||||||
/// Musicus server to connect to.
|
|
||||||
final server = BehaviorSubject<ServerSettings>();
|
|
||||||
|
|
||||||
SharedPreferences _shPref;
|
|
||||||
|
|
||||||
/// Initialize the settings.
|
|
||||||
Future<void> load() async {
|
Future<void> load() async {
|
||||||
_shPref = await SharedPreferences.getInstance();
|
_pref = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
final uri = _shPref.getString('musicLibraryUri');
|
|
||||||
if (uri != null) {
|
|
||||||
musicLibraryUri.add(uri);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final host = _shPref.getString('serverHost') ?? defaultHost;
|
@override
|
||||||
final port = _shPref.getInt('serverPort') ?? defaultPort;
|
Future<int> getInt(String key) {
|
||||||
final basePath = _shPref.getString('serverBasePath') ?? defaultBasePath;
|
return Future.value(_pref.getInt(key));
|
||||||
|
|
||||||
server.add(ServerSettings(
|
|
||||||
host: host,
|
|
||||||
port: port,
|
|
||||||
basePath: basePath,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the system picker to select a new music library URI.
|
@override
|
||||||
Future<void> chooseMusicLibraryUri() async {
|
Future<String> getString(String key) {
|
||||||
final uri = await _platform.invokeMethod<String>('openTree');
|
return Future.value(_pref.getString(key));
|
||||||
|
|
||||||
if (uri != null) {
|
|
||||||
musicLibraryUri.add(uri);
|
|
||||||
await _shPref.setString('musicLibraryUri', uri);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Change the Musicus server settings.
|
@override
|
||||||
Future<void> setServerSettings(ServerSettings settings) async {
|
Future<void> setInt(String key, int value) async {
|
||||||
await _shPref.setString('serverHost', settings.host);
|
await _pref.setInt(key, value);
|
||||||
await _shPref.setInt('serverPort', settings.port);
|
|
||||||
await _shPref.setString('serverBasePath', settings.basePath);
|
|
||||||
|
|
||||||
server.add(settings);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the server settings to their defaults.
|
@override
|
||||||
Future<void> resetServerSettings() async {
|
Future<void> setString(String key, String value) async {
|
||||||
await setServerSettings(ServerSettings(
|
await _pref.setString(key, value);
|
||||||
host: defaultHost,
|
|
||||||
port: defaultPort,
|
|
||||||
basePath: defaultBasePath,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tidy up.
|
|
||||||
void dispose() {
|
|
||||||
musicLibraryUri.close();
|
|
||||||
server.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import '../backend.dart';
|
|
||||||
|
|
||||||
class PlayPauseButton extends StatefulWidget {
|
class PlayPauseButton extends StatefulWidget {
|
||||||
@override
|
@override
|
||||||
|
|
@ -12,7 +11,7 @@ class PlayPauseButton extends StatefulWidget {
|
||||||
class _PlayPauseButtonState extends State<PlayPauseButton>
|
class _PlayPauseButtonState extends State<PlayPauseButton>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
AnimationController playPauseAnimation;
|
AnimationController playPauseAnimation;
|
||||||
BackendState backend;
|
MusicusBackendState backend;
|
||||||
StreamSubscription<bool> playingSubscription;
|
StreamSubscription<bool> playingSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -29,14 +28,14 @@ class _PlayPauseButtonState extends State<PlayPauseButton>
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|
||||||
backend = Backend.of(context);
|
backend = MusicusBackend.of(context);
|
||||||
playPauseAnimation.value = backend.player.playing.value ? 1.0 : 0.0;
|
playPauseAnimation.value = backend.playback.playing.value ? 1.0 : 0.0;
|
||||||
|
|
||||||
if (playingSubscription != null) {
|
if (playingSubscription != null) {
|
||||||
playingSubscription.cancel();
|
playingSubscription.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
playingSubscription = backend.player.playing.listen((playing) =>
|
playingSubscription = backend.playback.playing.listen((playing) =>
|
||||||
playing ? playPauseAnimation.forward() : playPauseAnimation.reverse());
|
playing ? playPauseAnimation.forward() : playPauseAnimation.reverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,7 +46,7 @@ class _PlayPauseButtonState extends State<PlayPauseButton>
|
||||||
icon: AnimatedIcons.play_pause,
|
icon: AnimatedIcons.play_pause,
|
||||||
progress: playPauseAnimation,
|
progress: playPauseAnimation,
|
||||||
),
|
),
|
||||||
onPressed: backend.player.playPause,
|
onPressed: backend.playback.playPause,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,15 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:musicus_common/musicus_common.dart';
|
||||||
import 'package:musicus_database/musicus_database.dart';
|
import 'package:musicus_database/musicus_database.dart';
|
||||||
|
|
||||||
import '../backend.dart';
|
|
||||||
import '../music_library.dart';
|
|
||||||
import '../screens/program.dart';
|
import '../screens/program.dart';
|
||||||
|
|
||||||
import 'play_pause_button.dart';
|
import 'play_pause_button.dart';
|
||||||
import 'texts.dart';
|
|
||||||
|
|
||||||
class PlayerBar extends StatelessWidget {
|
class PlayerBar extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final backend = Backend.of(context);
|
final backend = MusicusBackend.of(context);
|
||||||
|
|
||||||
return BottomAppBar(
|
return BottomAppBar(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
|
|
@ -19,7 +17,7 @@ class PlayerBar extends StatelessWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
StreamBuilder(
|
StreamBuilder(
|
||||||
stream: backend.player.normalizedPosition,
|
stream: backend.playback.normalizedPosition,
|
||||||
builder: (context, snapshot) => LinearProgressIndicator(
|
builder: (context, snapshot) => LinearProgressIndicator(
|
||||||
value: snapshot.data,
|
value: snapshot.data,
|
||||||
),
|
),
|
||||||
|
|
@ -32,7 +30,7 @@ class PlayerBar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StreamBuilder<InternalTrack>(
|
child: StreamBuilder<InternalTrack>(
|
||||||
stream: backend.player.currentTrack,
|
stream: backend.playback.currentTrack,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.data != null) {
|
if (snapshot.data != null) {
|
||||||
final recordingId = snapshot.data.track.recordingId;
|
final recordingId = snapshot.data.track.recordingId;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ dependencies:
|
||||||
moor_ffi:
|
moor_ffi:
|
||||||
musicus_client:
|
musicus_client:
|
||||||
path: ../client
|
path: ../client
|
||||||
|
musicus_common:
|
||||||
|
path: ../common
|
||||||
musicus_database:
|
musicus_database:
|
||||||
path: ../database
|
path: ../database
|
||||||
musicus_player:
|
musicus_player:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue