diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 39858d8..e2aea12 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,5 @@ { - "recommendations": ["dart-code.dart-code"] -} + "recommendations": [ + "dart-code.dart-code", + ] +} \ No newline at end of file diff --git a/lib/spinify_developer.dart b/lib/spinify_developer.dart new file mode 100644 index 0000000..053537b --- /dev/null +++ b/lib/spinify_developer.dart @@ -0,0 +1,6 @@ +library spinify.developer; + +export 'spinify.dart'; +export 'src/model/spinify_interface.dart'; +export 'src/model/transport_interface.dart'; +export 'src/transport_fake.dart'; diff --git a/lib/src/event_bus.dart b/lib/src/event_bus.dart index ae17465..2ac157b 100644 --- a/lib/src/event_bus.dart +++ b/lib/src/event_bus.dart @@ -6,13 +6,12 @@ import 'dart:collection'; import 'package:meta/meta.dart'; import 'logger.dart' as log; -import 'spinify_interface.dart'; +import 'model/spinify_interface.dart'; /// SpinifyBus Singleton class /// That class is used to manage the event queue and work as a singleton /// event bus to process, dispatch and manage all the events /// in the Spinify clients. -@internal @immutable final class SpinifyEventBus { SpinifyEventBus._internal(); @@ -84,6 +83,7 @@ abstract interface class ISpinifyEventBus$Bucket { void unsubscribe(String event, Future Function() callback); /// Dispose the bucket + /// Do not use it directly @internal @visibleForTesting void dispose(); diff --git a/lib/src/model/command.dart b/lib/src/model/command.dart new file mode 100644 index 0000000..53bc6f3 --- /dev/null +++ b/lib/src/model/command.dart @@ -0,0 +1,191 @@ +import 'package:meta/meta.dart'; + +/// {@template command} +/// Command sent from a client to a server. +/// +/// Server will +/// only take the first non-null request out of these and may return an error +/// if client passed more than one request. +/// {@category Command} +/// {@endtemplate} +@immutable +sealed class SpinifyCommand implements Comparable { + /// {@macro command} + const SpinifyCommand({ + required this.id, + required this.timestamp, + }); + + /// ID of command to let client match replies to commands. + final int id; + + /// Command type. + abstract final String type; + + /// Timestamp of command. + final DateTime timestamp; + + @override + int compareTo(SpinifyCommand other) => + switch (timestamp.compareTo(other.timestamp)) { + 0 => id.compareTo(other.id), + int result => result, + }; + @override + int get hashCode => id ^ type.hashCode ^ timestamp.microsecondsSinceEpoch; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpinifyCommand && + id == other.id && + type == other.type && + timestamp == other.timestamp; + + @override + String toString() => '$type{id: $id}'; +} + +/// {@macro command} +final class SpinifyConnectRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyConnectRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'ConnectRequest'; +} + +/// {@macro command} +final class SpinifySubscribeRequest extends SpinifyCommand { + /// {@macro command} + const SpinifySubscribeRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'SubscribeRequest'; +} + +/// {@macro command} +final class SpinifyUnsubscribeRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyUnsubscribeRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'UnsubscribeRequest'; +} + +/// {@macro command} +final class SpinifyPublishRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyPublishRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'PublishRequest'; +} + +/// {@macro command} +final class SpinifyPresenceRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyPresenceRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'PresenceRequest'; +} + +/// {@macro command} +final class SpinifyPresenceStatsRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyPresenceStatsRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'PresenceStatsRequest'; +} + +/// {@macro command} +final class SpinifyHistoryRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyHistoryRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'HistoryRequest'; +} + +/// {@macro command} +final class SpinifyPingRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyPingRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'PingRequest'; +} + +/// {@macro command} +final class SpinifySendRequest extends SpinifyCommand { + /// {@macro command} + const SpinifySendRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'SendRequest'; +} + +/// {@macro command} +final class SpinifyRPCRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyRPCRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'RPCRequest'; +} + +/// {@macro command} +final class SpinifyRefreshRequest extends SpinifyCommand { + /// {@macro command} + const SpinifyRefreshRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'RefreshRequest'; +} + +/// {@macro command} +final class SpinifySubRefreshRequest extends SpinifyCommand { + /// {@macro command} + const SpinifySubRefreshRequest({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'SubRefreshRequest'; +} diff --git a/lib/src/model/events.dart b/lib/src/model/event_bus_events.dart similarity index 82% rename from lib/src/model/events.dart rename to lib/src/model/event_bus_events.dart index 59e9fdd..ec53333 100644 --- a/lib/src/model/events.dart +++ b/lib/src/model/event_bus_events.dart @@ -8,7 +8,8 @@ abstract interface class ClientEvents { static const String close = '${prefix}_close'; static const String connecting = '${prefix}_connecting'; static const String connected = '${prefix}_connected'; - static const String disconnecting = '${prefix}_disconnecting'; static const String disconnected = '${prefix}_disconnected'; static const String stateChanged = '${prefix}_state_changed'; + static const String command = '${prefix}_command'; + static const String reply = '${prefix}_reply'; } diff --git a/lib/src/model/reply.dart b/lib/src/model/reply.dart new file mode 100644 index 0000000..ed5d366 --- /dev/null +++ b/lib/src/model/reply.dart @@ -0,0 +1,196 @@ +import 'package:meta/meta.dart'; + +/// {@template reply} +/// Reply sent from a server to a client. +/// +/// Server will +/// only take the first non-null request out of these and may return an error +/// if client passed more than one request. +/// {@category Reply} +/// {@endtemplate} +@immutable +sealed class SpinifyReply implements Comparable { + /// {@macro reply} + const SpinifyReply({ + required this.id, + required this.timestamp, + }); + + /// Id will only be set to a value > 0 for replies to commands. + /// For pushes it will have zero value. + final int id; + + /// Timestamp of reply. + final DateTime timestamp; + + /// Reply type. + abstract final String type; + + @override + int compareTo(SpinifyReply other) => + switch (timestamp.compareTo(other.timestamp)) { + 0 => id.compareTo(other.id), + int result => result, + }; + + @override + int get hashCode => id ^ type.hashCode ^ timestamp.microsecondsSinceEpoch; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SpinifyReply && + id == other.id && + type == other.type && + timestamp == other.timestamp; + + @override + String toString() => '$type{id: $id}'; +} + +/// {@macro reply} +final class SpinifyConnectResult extends SpinifyReply { + /// {@macro reply} + const SpinifyConnectResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'ConnectResult'; +} + +/// {@macro reply} +final class SpinifySubscribeResult extends SpinifyReply { + /// {@macro reply} + const SpinifySubscribeResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'SubscribeResult'; +} + +/// {@macro reply} +final class SpinifyUnsubscribeResult extends SpinifyReply { + /// {@macro reply} + const SpinifyUnsubscribeResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'UnsubscribeResult'; +} + +/// {@macro reply} +final class SpinifyPublishResult extends SpinifyReply { + /// {@macro reply} + const SpinifyPublishResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'PublishResult'; +} + +/// {@macro reply} +final class SpinifyPresenceResult extends SpinifyReply { + /// {@macro reply} + const SpinifyPresenceResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'PresenceResult'; +} + +/// {@macro reply} +final class SpinifyPresenceStatsResult extends SpinifyReply { + /// {@macro reply} + const SpinifyPresenceStatsResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'PresenceStatsResult'; +} + +/// {@macro reply} +final class SpinifyHistoryResult extends SpinifyReply { + /// {@macro reply} + const SpinifyHistoryResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'HistoryResult'; +} + +/// {@macro reply} +final class SpinifyPingResult extends SpinifyReply { + /// {@macro reply} + const SpinifyPingResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'PingResult'; +} + +/// {@macro reply} +final class SpinifyRPCResult extends SpinifyReply { + /// {@macro reply} + const SpinifyRPCResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'RPCResult'; +} + +/// {@macro reply} +final class SpinifyRefreshResult extends SpinifyReply { + /// {@macro reply} + const SpinifyRefreshResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'RefreshResult'; +} + +/// {@macro reply} +final class SpinifySubRefreshResult extends SpinifyReply { + /// {@macro reply} + const SpinifySubRefreshResult({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'SubRefreshResult'; +} + +/// Error can only be set in replies to commands. For pushes it will have zero +/// value. +/// +/// {@macro reply} +final class SpinifyError extends SpinifyReply { + /// {@macro reply} + const SpinifyError({ + required super.id, + required super.timestamp, + }); + + @override + String get type => 'Error'; +} diff --git a/lib/src/spinify_interface.dart b/lib/src/model/spinify_interface.dart similarity index 92% rename from lib/src/spinify_interface.dart rename to lib/src/model/spinify_interface.dart index 98de412..9296f8e 100644 --- a/lib/src/spinify_interface.dart +++ b/lib/src/model/spinify_interface.dart @@ -1,16 +1,16 @@ import 'dart:async'; -import '../src.old/subscription/subscription.dart'; -import 'model/config.dart'; -import 'model/history.dart'; -import 'model/metrics.dart'; -import 'model/presence.dart'; -import 'model/presence_stats.dart'; -import 'model/pushes_stream.dart'; -import 'model/state.dart'; -import 'model/states_stream.dart'; -import 'model/stream_position.dart'; -import 'model/subscription_config.dart'; +import '../../src.old/subscription/subscription.dart'; +import 'config.dart'; +import 'history.dart'; +import 'metrics.dart'; +import 'presence.dart'; +import 'presence_stats.dart'; +import 'pushes_stream.dart'; +import 'state.dart'; +import 'states_stream.dart'; +import 'stream_position.dart'; +import 'subscription_config.dart'; /// Spinify client interface. abstract interface class ISpinify diff --git a/lib/src/subscription_interface.dart b/lib/src/model/subscription_interface.dart similarity index 95% rename from lib/src/subscription_interface.dart rename to lib/src/model/subscription_interface.dart index c5e26d3..3afa214 100644 --- a/lib/src/subscription_interface.dart +++ b/lib/src/model/subscription_interface.dart @@ -1,12 +1,12 @@ import 'dart:async'; -import 'model/history.dart'; -import 'model/presence.dart'; -import 'model/presence_stats.dart'; -import 'model/pushes_stream.dart'; -import 'model/stream_position.dart'; -import 'model/subscription_state.dart'; -import 'model/subscription_states_stream.dart'; +import 'history.dart'; +import 'presence.dart'; +import 'presence_stats.dart'; +import 'pushes_stream.dart'; +import 'stream_position.dart'; +import 'subscription_state.dart'; +import 'subscription_states_stream.dart'; /// {@template subscription} /// Spinify subscription interface. diff --git a/lib/src/model/transport_interface.dart b/lib/src/model/transport_interface.dart new file mode 100644 index 0000000..7d1cc50 --- /dev/null +++ b/lib/src/model/transport_interface.dart @@ -0,0 +1,26 @@ +import 'command.dart'; +import 'reply.dart'; + +/// Create a Spinify transport +/// (e.g. WebSocket or gRPC with JSON or Protocol Buffers). +typedef CreateSpinifyTransport = Future Function( + /// URL for the connection + String url, + + /// Additional headers for the connection (optional) + Map headers, +); + +/// Spinify transport interface. +abstract interface class ISpinifyTransport { + /// Send command to the server. + Future send(SpinifyCommand command); + + /// Set handler for [SpinifyReply] messages. + // ignore: avoid_setters_without_getters + set onReply(void Function(SpinifyReply reply) handler); + + /// Disconnect from the server. + /// Client if not needed anymore. + Future disconnect(int code, String reason); +} diff --git a/lib/src/spinify_impl.dart b/lib/src/spinify_impl.dart index 59e64f8..e3fa160 100644 --- a/lib/src/spinify_impl.dart +++ b/lib/src/spinify_impl.dart @@ -4,25 +4,95 @@ import 'package:meta/meta.dart'; import '../src.old/subscription/subscription.dart'; import 'event_bus.dart'; +import 'model/command.dart'; import 'model/config.dart'; -import 'model/events.dart'; +import 'model/event_bus_events.dart'; import 'model/history.dart'; import 'model/metrics.dart'; import 'model/presence.dart'; import 'model/presence_stats.dart'; import 'model/pushes_stream.dart'; +import 'model/reply.dart'; +import 'model/spinify_interface.dart'; import 'model/state.dart'; import 'model/states_stream.dart'; import 'model/stream_position.dart'; import 'model/subscription_config.dart'; -import 'spinify_interface.dart'; +import 'model/transport_interface.dart'; +import 'transport_ws_pb_stub.dart' + // ignore: uri_does_not_exist + if (dart.library.html) 'transport_ws_pb_js.dart' + // ignore: uri_does_not_exist + if (dart.library.io) 'transport_ws_pb_vm.dart'; + +abstract base class SpinifyCallbacks { + static Future Function(Object?) _castCallback( + Future Function(T data) fn) { + Future skip(_) async {} + return (data) { + if (data is T) return fn(data); + assert(false, 'Unexpected data type: $data'); + return skip(data); + }; + } + + @mustCallSuper + void _initCallbacks(ISpinifyEventBus$Bucket bucket) { + void subscribeVoid(String event, Future Function() callback) { + bucket.subscribe(event, (_) => callback()); + } + + void subscribeValue( + String event, Future Function(T data) callback) { + bucket.subscribe(event, _castCallback(_onConnecting)); + } + + subscribeVoid(ClientEvents.init, _onInit); + subscribeVoid(ClientEvents.close, _onClose); + subscribeValue(ClientEvents.connecting, _onConnecting); + subscribeValue(ClientEvents.connected, _onConnected); + subscribeValue(ClientEvents.disconnected, _onDisconnected); + subscribeValue(ClientEvents.stateChanged, _onStateChanged); + subscribeValue(ClientEvents.command, _onCommand); + subscribeValue(ClientEvents.reply, _onReply); + + bucket.pushEvent(ClientEvents.init); + } + + @mustCallSuper + Future _onInit() async {} + + @mustCallSuper + Future _onClose() async {} + + @mustCallSuper + Future _onConnecting(SpinifyState$Connecting state) async {} + + @mustCallSuper + Future _onConnected(SpinifyState$Connected state) async {} + + @mustCallSuper + Future _onDisconnected(SpinifyState$Disconnected state) async {} + + @mustCallSuper + Future _onStateChanged(SpinifyState state) async {} + + @mustCallSuper + Future _onCommand(SpinifyCommand command) async {} + + @mustCallSuper + Future _onReply(SpinifyReply reply) async {} +} /// Base class for Spinify client. -abstract base class SpinifyBase implements ISpinify { +abstract base class SpinifyBase extends SpinifyCallbacks implements ISpinify { /// Create a new Spinify client. - SpinifyBase(this.config) : id = _idCounter++ { + SpinifyBase(this.config, {CreateSpinifyTransport? createTransport}) + : id = _idCounter++, + _createTransport = createTransport ?? $create$WS$PB$Transport { _bucket = SpinifyEventBus.instance.registerClient(this); - _initClient(); + _initCallbacks(_bucket); + _bucket.pushEvent(ClientEvents.init); } /// Unique client ID counter for Spinify clients. @@ -42,16 +112,11 @@ abstract base class SpinifyBase implements ISpinify { /// Event Bus Bucket for client events and event subscriptions. late final ISpinifyEventBus$Bucket _bucket; + final CreateSpinifyTransport _createTransport; - @mustCallSuper - void _initClient() { - _bucket - ..pushEvent(ClientEvents.init) - ..subscribe(ClientEvents.close, _spinifyBase$OnClose); - } - - @mustCallSuper - Future _spinifyBase$OnClose(_) async { + @override + Future _onClose() async { + await super._onClose(); _isClosed = true; SpinifyEventBus.instance.unregisterClient(this); } @@ -90,42 +155,35 @@ base mixin SpinifyStateMixin on SpinifyBase { final StreamController _statesController = StreamController.broadcast(); - @override - @mustCallSuper - void _initClient() { - _bucket - ..subscribe(ClientEvents.disconnected, _spinifyStateMixin$OnDisconnected) - ..subscribe(ClientEvents.connecting, _spinifyStateMixin$OnConnecting) - ..subscribe(ClientEvents.connected, _spinifyStateMixin$OnConnectedState); - super._initClient(); - } - @nonVirtual - void _changeState(SpinifyState state) { + Future _changeState(SpinifyState state) { _statesController.add(_state = state); - _bucket.pushEvent(ClientEvents.stateChanged, state); + return _bucket.pushEvent(ClientEvents.stateChanged, state); } - @mustCallSuper - Future _spinifyStateMixin$OnDisconnected(Object? data) async { - _changeState(data as SpinifyState$Disconnected); + @override + Future _onConnecting(SpinifyState$Connecting state) async { + await super._onConnecting(state); + await _changeState(state); } - @mustCallSuper - Future _spinifyStateMixin$OnConnecting(Object? data) async { - _changeState(data as SpinifyState$Connecting); + @override + Future _onConnected(SpinifyState$Connected state) async { + await super._onConnected(state); + await _changeState(state); } - @mustCallSuper - Future _spinifyStateMixin$OnConnectedState(Object? data) async { - _changeState(data as SpinifyState$Connected); + @override + Future _onDisconnected(SpinifyState$Disconnected state) async { + await super._onDisconnected(state); + await _changeState(state); } @override @mustCallSuper Future close() async { await super.close(); - _changeState(SpinifyState$Closed()); + await _changeState(SpinifyState$Closed()); } } diff --git a/lib/src/transport_fake.dart b/lib/src/transport_fake.dart new file mode 100644 index 0000000..c31ddf9 --- /dev/null +++ b/lib/src/transport_fake.dart @@ -0,0 +1,59 @@ +import 'dart:async'; + +import 'model/command.dart'; +import 'model/reply.dart'; +import 'model/transport_interface.dart'; + +/// Spinify fake transport +class SpinifyTransportFake implements ISpinifyTransport { + /// Create a fake transport. + SpinifyTransportFake({ + // Delay in milliseconds + int delay = 10, + }) : _delay = delay; + + final int _delay; + + Future _sleep() => Future.delayed(Duration(milliseconds: _delay)); + + bool get _isConnected => _timer != null; + Timer? _timer; + + @override + Future connect(String url) async { + if (_isConnected) return; + await _sleep(); + _timer = Timer.periodic(const Duration(seconds: 25), (timer) {}); + } + + @override + Future send(SpinifyCommand command) async { + if (!_isConnected) throw StateError('Not connected'); + await _sleep(); + if (command is SpinifyPingRequest) + Timer( + Duration(milliseconds: _delay), + () { + if (_isConnected) + _handler?.call( + SpinifyPingResult( + id: command.id, + timestamp: DateTime.now(), + ), + ); + }, + ); + } + + @override + set onReply(void Function(SpinifyReply reply) handler) => _handler = handler; + void Function(SpinifyReply reply)? _handler; + + @override + Future disconnect(int code, String reason) async { + if (!_isConnected) return; + await _sleep(); + _timer?.cancel(); + _timer = null; + } +} diff --git a/lib/src/transport_ws_pb_js.dart b/lib/src/transport_ws_pb_js.dart new file mode 100644 index 0000000..1268f77 --- /dev/null +++ b/lib/src/transport_ws_pb_js.dart @@ -0,0 +1,11 @@ +import 'package:meta/meta.dart'; + +import 'model/transport_interface.dart'; + +/// Create a WebSocket Protocol Buffers transport. +@internal +Future $create$WS$PB$Transport( + String url, + Map headers, +) => + throw UnimplementedError(); diff --git a/lib/src/transport_ws_pb_stub.dart b/lib/src/transport_ws_pb_stub.dart new file mode 100644 index 0000000..1268f77 --- /dev/null +++ b/lib/src/transport_ws_pb_stub.dart @@ -0,0 +1,11 @@ +import 'package:meta/meta.dart'; + +import 'model/transport_interface.dart'; + +/// Create a WebSocket Protocol Buffers transport. +@internal +Future $create$WS$PB$Transport( + String url, + Map headers, +) => + throw UnimplementedError(); diff --git a/lib/src/transport_ws_pb_vm.dart b/lib/src/transport_ws_pb_vm.dart new file mode 100644 index 0000000..1268f77 --- /dev/null +++ b/lib/src/transport_ws_pb_vm.dart @@ -0,0 +1,11 @@ +import 'package:meta/meta.dart'; + +import 'model/transport_interface.dart'; + +/// Create a WebSocket Protocol Buffers transport. +@internal +Future $create$WS$PB$Transport( + String url, + Map headers, +) => + throw UnimplementedError(); diff --git a/test/unit/spinify_test.dart b/test/unit/spinify_test.dart index b29ce02..d813107 100644 --- a/test/unit/spinify_test.dart +++ b/test/unit/spinify_test.dart @@ -1,4 +1,4 @@ -import 'package:spinify/spinify.dart'; +import 'package:spinify/spinify_developer.dart'; import 'package:test/test.dart'; void main() {