feat: add connect server support

This commit is contained in:
Kingkor Roy Tirtho 2024-03-24 17:03:41 +06:00
parent ee97aedcfc
commit c399baa5ab
29 changed files with 900 additions and 28 deletions

View File

@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
- [Your First Code Contribution](#your-first-code-contribution)
- [Submit translations](#submit-translations)
- [Submit Translations](#submit-translations)
## Code of Conduct
@ -123,16 +123,16 @@ Do the following:
- Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu
```bash
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan
```
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
- Arch/Manjaro
```bash
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
```
- Fedora
```bash
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns
```
- Clone the Repo
- Create a `.env` in root of the project following the `.env.example` template

View File

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '12.0'
platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@ -66,5 +66,11 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>NSLocalNetworkUsageDescription</key>
<string>To allow other devices on the network control playback of Spotube securely.</string>
<key>NSBonjourServices</key>
<array>
<string>_spotube._tcp</string>
</array>
</dict>
</plist>

View File

@ -116,4 +116,5 @@ abstract class SpotubeIcons {
static const openCollective = SimpleIcons.opencollective;
static const anonymous = FeatherIcons.user;
static const history = FeatherIcons.clock;
static const connect = FeatherIcons.link;
}

View File

@ -313,5 +313,7 @@
"help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.",
"contribute_on_github": "Contribute on GitHub",
"donate_on_open_collective": "Donate on Open Collective",
"browse_anonymously": "Browse Anonymously"
"browse_anonymously": "Browse Anonymously",
"enable_connect": "Enable Connect",
"enable_connect_description": "Control Spotube from other devices"
}

View File

@ -23,6 +23,7 @@ import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/source_match.dart';
import 'package:spotube/provider/connect/server.dart';
import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@ -180,6 +181,8 @@ class SpotubeState extends ConsumerState<Spotube> {
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
final router = ref.watch(routerProvider);
ref.read(connectServerProvider);
useDisableBatteryOptimizations();
useInitSysTray(ref);
useDeepLinking(ref);

View File

@ -0,0 +1,15 @@
library connect;
import 'dart:async';
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
part 'connect.freezed.dart';
part 'connect.g.dart';
part 'ws_event.dart';
part 'load.dart';

View File

@ -0,0 +1,216 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'connect.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
WebSocketLoadEventData _$WebSocketLoadEventDataFromJson(
Map<String, dynamic> json) {
return _WebSocketLoadEventData.fromJson(json);
}
/// @nodoc
mixin _$WebSocketLoadEventData {
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks => throw _privateConstructorUsedError;
String? get collectionId => throw _privateConstructorUsedError;
int? get initialIndex => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$WebSocketLoadEventDataCopyWith<WebSocketLoadEventData> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $WebSocketLoadEventDataCopyWith<$Res> {
factory $WebSocketLoadEventDataCopyWith(WebSocketLoadEventData value,
$Res Function(WebSocketLoadEventData) then) =
_$WebSocketLoadEventDataCopyWithImpl<$Res, WebSocketLoadEventData>;
@useResult
$Res call(
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
String? collectionId,
int? initialIndex});
}
/// @nodoc
class _$WebSocketLoadEventDataCopyWithImpl<$Res,
$Val extends WebSocketLoadEventData>
implements $WebSocketLoadEventDataCopyWith<$Res> {
_$WebSocketLoadEventDataCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? tracks = null,
Object? collectionId = freezed,
Object? initialIndex = freezed,
}) {
return _then(_value.copyWith(
tracks: null == tracks
? _value.tracks
: tracks // ignore: cast_nullable_to_non_nullable
as List<Track>,
collectionId: freezed == collectionId
? _value.collectionId
: collectionId // ignore: cast_nullable_to_non_nullable
as String?,
initialIndex: freezed == initialIndex
? _value.initialIndex
: initialIndex // ignore: cast_nullable_to_non_nullable
as int?,
) as $Val);
}
}
/// @nodoc
abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res>
implements $WebSocketLoadEventDataCopyWith<$Res> {
factory _$$WebSocketLoadEventDataImplCopyWith(
_$WebSocketLoadEventDataImpl value,
$Res Function(_$WebSocketLoadEventDataImpl) then) =
__$$WebSocketLoadEventDataImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
String? collectionId,
int? initialIndex});
}
/// @nodoc
class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>
extends _$WebSocketLoadEventDataCopyWithImpl<$Res,
_$WebSocketLoadEventDataImpl>
implements _$$WebSocketLoadEventDataImplCopyWith<$Res> {
__$$WebSocketLoadEventDataImplCopyWithImpl(
_$WebSocketLoadEventDataImpl _value,
$Res Function(_$WebSocketLoadEventDataImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? tracks = null,
Object? collectionId = freezed,
Object? initialIndex = freezed,
}) {
return _then(_$WebSocketLoadEventDataImpl(
tracks: null == tracks
? _value._tracks
: tracks // ignore: cast_nullable_to_non_nullable
as List<Track>,
collectionId: freezed == collectionId
? _value.collectionId
: collectionId // ignore: cast_nullable_to_non_nullable
as String?,
initialIndex: freezed == initialIndex
? _value.initialIndex
: initialIndex // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData {
_$WebSocketLoadEventDataImpl(
{@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks,
this.collectionId,
this.initialIndex})
: _tracks = tracks;
factory _$WebSocketLoadEventDataImpl.fromJson(Map<String, dynamic> json) =>
_$$WebSocketLoadEventDataImplFromJson(json);
final List<Track> _tracks;
@override
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks {
if (_tracks is EqualUnmodifiableListView) return _tracks;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tracks);
}
@override
final String? collectionId;
@override
final int? initialIndex;
@override
String toString() {
return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$WebSocketLoadEventDataImpl &&
const DeepCollectionEquality().equals(other._tracks, _tracks) &&
(identical(other.collectionId, collectionId) ||
other.collectionId == collectionId) &&
(identical(other.initialIndex, initialIndex) ||
other.initialIndex == initialIndex));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(runtimeType,
const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl>
get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl<
_$WebSocketLoadEventDataImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$WebSocketLoadEventDataImplToJson(
this,
);
}
}
abstract class _WebSocketLoadEventData implements WebSocketLoadEventData {
factory _WebSocketLoadEventData(
{@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks,
final String? collectionId,
final int? initialIndex}) = _$WebSocketLoadEventDataImpl;
factory _WebSocketLoadEventData.fromJson(Map<String, dynamic> json) =
_$WebSocketLoadEventDataImpl.fromJson;
@override
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks;
@override
String? get collectionId;
@override
int? get initialIndex;
@override
@JsonKey(ignore: true)
_$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connect.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson(
Map<String, dynamic> json) =>
_$WebSocketLoadEventDataImpl(
tracks: (json['tracks'] as List<dynamic>)
.map((e) => Track.fromJson(e as Map<String, dynamic>))
.toList(),
collectionId: json['collectionId'] as String?,
initialIndex: json['initialIndex'] as int?,
);
Map<String, dynamic> _$$WebSocketLoadEventDataImplToJson(
_$WebSocketLoadEventDataImpl instance) =>
<String, dynamic>{
'tracks': _tracksJson(instance.tracks),
'collectionId': instance.collectionId,
'initialIndex': instance.initialIndex,
};

View File

@ -0,0 +1,27 @@
part of 'connect.dart';
List<Map<String, dynamic>> _tracksJson(List<Track> tracks) {
return tracks.map((e) => e.toJson()).toList();
}
@freezed
class WebSocketLoadEventData with _$WebSocketLoadEventData {
factory WebSocketLoadEventData({
@JsonKey(name: 'tracks', toJson: _tracksJson) required List<Track> tracks,
String? collectionId,
int? initialIndex,
}) = _WebSocketLoadEventData;
factory WebSocketLoadEventData.fromJson(Map<String, dynamic> json) =>
_$WebSocketLoadEventDataFromJson(json);
}
class WebSocketLoadEvent extends WebSocketEvent<WebSocketLoadEventData> {
WebSocketLoadEvent(WebSocketLoadEventData data) : super(WsEvent.load, data);
factory WebSocketLoadEvent.fromJson(Map<String, dynamic> json) {
return WebSocketLoadEvent(
WebSocketLoadEventData.fromJson(json['data'] as Map<String, dynamic>),
);
}
}

View File

@ -0,0 +1,191 @@
part of 'connect.dart';
enum WsEvent {
error,
queue,
position,
playing,
resume,
pause,
load,
next,
previous,
jump,
stop;
static WsEvent fromString(String value) {
return WsEvent.values.firstWhere((e) => e.name == value);
}
}
typedef EventCallback<T> = FutureOr<void> Function(T event);
class WebSocketEvent<T> {
final WsEvent type;
final T data;
WebSocketEvent(this.type, this.data);
factory WebSocketEvent.fromJson(
Map<String, dynamic> json,
T Function(dynamic) fromJson,
) {
return WebSocketEvent(
WsEvent.fromString(json["type"]),
fromJson(json["data"]),
);
}
String toJson() {
return jsonEncode({
"type": type.name,
"data": data,
});
}
Future<void> onPosition(
EventCallback<WebSocketPositionEvent> callback,
) async {
if (type == WsEvent.position) {
await callback(
WebSocketPositionEvent.fromJson(data as Map<String, dynamic>));
}
}
Future<void> onPlaying(
EventCallback<WebSocketPlayingEvent> callback,
) async {
if (type == WsEvent.playing) {
await callback(WebSocketPlayingEvent(data as bool));
}
}
Future<void> onResume(
EventCallback<WebSocketResumeEvent> callback,
) async {
if (type == WsEvent.resume) {
await callback(WebSocketResumeEvent());
}
}
Future<void> onPause(
EventCallback<WebSocketPauseEvent> callback,
) async {
if (type == WsEvent.pause) {
await callback(WebSocketPauseEvent());
}
}
Future<void> onStop(
EventCallback<WebSocketStopEvent> callback,
) async {
if (type == WsEvent.stop) {
await callback(WebSocketStopEvent());
}
}
Future<void> onLoad(
EventCallback<WebSocketLoadEvent> callback,
) async {
if (type == WsEvent.load) {
await callback(WebSocketLoadEvent.fromJson(data as Map<String, dynamic>));
}
}
Future<void> onNext(
EventCallback<WebSocketNextEvent> callback,
) async {
if (type == WsEvent.next) {
await callback(WebSocketNextEvent());
}
}
Future<void> onPrevious(
EventCallback<WebSocketPreviousEvent> callback,
) async {
if (type == WsEvent.previous) {
await callback(WebSocketPreviousEvent());
}
}
Future<void> onJump(
EventCallback<WebSocketJumpEvent> callback,
) async {
if (type == WsEvent.jump) {
await callback(WebSocketJumpEvent(data as int));
}
}
Future<void> onError(
EventCallback<WebSocketErrorEvent> callback,
) async {
if (type == WsEvent.error) {
await callback(WebSocketErrorEvent(data as String));
}
}
Future<void> onQueue(
EventCallback<WebSocketQueueEvent> callback,
) async {
if (type == WsEvent.queue) {
await callback(
WebSocketQueueEvent.fromJson(data as Map<String, dynamic>));
}
}
}
class WebSocketPositionEvent extends WebSocketEvent<Duration> {
WebSocketPositionEvent(Duration data) : super(WsEvent.position, data);
WebSocketPositionEvent.fromJson(Map<String, dynamic> json)
: super(WsEvent.position, Duration(seconds: json["data"] as int));
@override
String toJson() {
return jsonEncode({
"type": type.name,
"data": data.inSeconds,
});
}
}
class WebSocketPlayingEvent extends WebSocketEvent<bool> {
WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data);
}
class WebSocketResumeEvent extends WebSocketEvent<void> {
WebSocketResumeEvent() : super(WsEvent.resume, null);
}
class WebSocketPauseEvent extends WebSocketEvent<void> {
WebSocketPauseEvent() : super(WsEvent.pause, null);
}
class WebSocketStopEvent extends WebSocketEvent<void> {
WebSocketStopEvent() : super(WsEvent.stop, null);
}
class WebSocketNextEvent extends WebSocketEvent<void> {
WebSocketNextEvent() : super(WsEvent.next, null);
}
class WebSocketPreviousEvent extends WebSocketEvent<void> {
WebSocketPreviousEvent() : super(WsEvent.previous, null);
}
class WebSocketJumpEvent extends WebSocketEvent<int> {
WebSocketJumpEvent(int data) : super(WsEvent.jump, data);
}
class WebSocketErrorEvent extends WebSocketEvent<String> {
WebSocketErrorEvent(String data) : super(WsEvent.error, data);
}
class WebSocketQueueEvent extends WebSocketEvent<ProxyPlaylist> {
WebSocketQueueEvent(ProxyPlaylist data) : super(WsEvent.queue, data);
factory WebSocketQueueEvent.fromJson(Map<String, dynamic> json) =>
WebSocketQueueEvent(
ProxyPlaylist.fromJsonRaw(json["data"] as Map<String, dynamic>),
);
}

View File

@ -227,6 +227,13 @@ class SettingsPlaybackSection extends HookConsumerWidget {
value: preferences.endlessPlayback,
onChanged: preferencesNotifier.setEndlessPlayback,
),
SwitchListTile(
title: Text(context.l10n.enable_connect),
subtitle: Text(context.l10n.enable_connect_description),
secondary: const Icon(SpotubeIcons.connect),
value: preferences.enableConnect,
onChanged: preferencesNotifier.setEnableConnect,
),
],
);
}

View File

@ -0,0 +1,152 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:catcher_2/catcher_2.dart';
import 'package:shelf/shelf_io.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:bonsoir/bonsoir.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
final logger = getLogger('ConnectServer');
final connectServerProvider = FutureProvider((ref) async {
final enabled =
ref.watch(userPreferencesProvider.select((s) => s.enableConnect));
final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier);
if (!enabled) {
return null;
}
final app = Router();
final subscriptions = <StreamSubscription>[];
final websocket = webSocketHandler(
(WebSocketChannel channel, String? protocol) {
ref.listen(
ProxyPlaylistNotifier.provider,
(previous, next) {
channel.sink.add(
WebSocketQueueEvent(next).toJson(),
);
},
);
subscriptions.addAll([
audioPlayer.positionStream.listen(
(position) {
channel.sink.add(
WebSocketPositionEvent(position).toJson(),
);
},
),
audioPlayer.playingStream.listen(
(playing) {
channel.sink.add(
WebSocketEvent(WsEvent.playing, playing).toJson(),
);
},
),
channel.stream.listen(
(message) {
try {
final event = WebSocketEvent.fromJson(
jsonDecode(message),
(data) => data,
);
event.onLoad((event) async {
await playbackNotifier.load(
event.data.tracks,
autoPlay: true,
initialIndex: event.data.initialIndex ?? 0,
);
if (event.data.collectionId != null) {
playbackNotifier.addCollection(event.data.collectionId!);
}
});
event.onPause((event) async {
await audioPlayer.pause();
});
event.onResume((event) async {
await audioPlayer.resume();
});
event.onStop((event) async {
await audioPlayer.stop();
});
event.onNext((event) async {
await playbackNotifier.next();
});
event.onPrevious((event) async {
await playbackNotifier.previous();
});
event.onJump((event) async {
await playbackNotifier.jumpTo(event.data);
});
} catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
channel.sink.add(WebSocketErrorEvent(e.toString()).toJson());
}
},
onDone: () {
logger.i('Connection closed');
},
),
]);
},
);
final port = Random().nextInt(17000) + 3000;
final server = await serve(
(request) {
if (request.url.path.startsWith('ws')) {
return websocket(request);
}
return app(request);
},
InternetAddress.loopbackIPv4,
port,
);
logger.i('Server running on http://${server.address.host}:${server.port}');
final service = BonsoirService(
name: 'Spotube',
type: '_spotube._tcp',
port: port,
);
final broadcast = BonsoirBroadcast(service: service);
await broadcast.ready;
await broadcast.start();
ref.onDispose(() async {
logger.i('Stopping server');
for (final subscription in subscriptions) {
await subscription.cancel();
}
await broadcast.stop();
await server.close();
});
return app;
});

View File

@ -27,6 +27,16 @@ class ProxyPlaylist {
);
}
factory ProxyPlaylist.fromJsonRaw(Map<String, dynamic> json) => ProxyPlaylist(
json['tracks'] == null
? <Track>{}
: (json['tracks'] as List).map((t) => Track.fromJson(t)).toSet(),
json['active'] as int?,
json['collections'] == null
? {}
: (json['collections'] as List).toSet().cast<String>(),
);
Track? get activeTrack =>
active == null || active == -1 ? null : tracks.elementAtOrNull(active!);
@ -62,8 +72,8 @@ class ProxyPlaylist {
/// Otherwise default super.toJson() is used
static Map<String, dynamic> _makeAppropriateTrackJson(Track track) {
return switch (track.runtimeType) {
LocalTrack => track.toJson(),
SourcedTrack => track.toJson(),
LocalTrack() => track.toJson(),
SourcedTrack() => track.toJson(),
_ => track.toJson(),
};
}

View File

@ -127,6 +127,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = state.copyWith(endlessPlayback: endless);
}
void setEnableConnect(bool enable) {
state = state.copyWith(enableConnect: enable);
}
Future<String> _getDefaultDownloadDirectory() async {
if (kIsAndroid) return "/storage/emulated/0/Download/Spotube";

View File

@ -91,6 +91,7 @@ class UserPreferences with _$UserPreferences {
@Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec,
@Default(true) bool discordPresence,
@Default(true) bool endlessPlayback,
@Default(false) bool enableConnect,
}) = _UserPreferences;
factory UserPreferences.fromJson(Map<String, dynamic> json) =>
_$UserPreferencesFromJson(json);

View File

@ -50,6 +50,7 @@ mixin _$UserPreferences {
SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError;
bool get discordPresence => throw _privateConstructorUsedError;
bool get endlessPlayback => throw _privateConstructorUsedError;
bool get enableConnect => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
@ -93,7 +94,8 @@ abstract class $UserPreferencesCopyWith<$Res> {
SourceCodecs streamMusicCodec,
SourceCodecs downloadMusicCodec,
bool discordPresence,
bool endlessPlayback});
bool endlessPlayback,
bool enableConnect});
}
/// @nodoc
@ -131,6 +133,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
Object? downloadMusicCodec = null,
Object? discordPresence = null,
Object? endlessPlayback = null,
Object? enableConnect = null,
}) {
return _then(_value.copyWith(
audioQuality: null == audioQuality
@ -221,6 +224,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
? _value.endlessPlayback
: endlessPlayback // ignore: cast_nullable_to_non_nullable
as bool,
enableConnect: null == enableConnect
? _value.enableConnect
: enableConnect // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
@ -263,7 +270,8 @@ abstract class _$$UserPreferencesImplCopyWith<$Res>
SourceCodecs streamMusicCodec,
SourceCodecs downloadMusicCodec,
bool discordPresence,
bool endlessPlayback});
bool endlessPlayback,
bool enableConnect});
}
/// @nodoc
@ -299,6 +307,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
Object? downloadMusicCodec = null,
Object? discordPresence = null,
Object? endlessPlayback = null,
Object? enableConnect = null,
}) {
return _then(_$UserPreferencesImpl(
audioQuality: null == audioQuality
@ -389,6 +398,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
? _value.endlessPlayback
: endlessPlayback // ignore: cast_nullable_to_non_nullable
as bool,
enableConnect: null == enableConnect
? _value.enableConnect
: enableConnect // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
@ -426,7 +439,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
this.streamMusicCodec = SourceCodecs.weba,
this.downloadMusicCodec = SourceCodecs.m4a,
this.discordPresence = true,
this.endlessPlayback = true});
this.endlessPlayback = true,
this.enableConnect = false});
factory _$UserPreferencesImpl.fromJson(Map<String, dynamic> json) =>
_$$UserPreferencesImplFromJson(json);
@ -503,10 +517,13 @@ class _$UserPreferencesImpl implements _UserPreferences {
@override
@JsonKey()
final bool endlessPlayback;
@override
@JsonKey()
final bool enableConnect;
@override
String toString() {
return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)';
return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)';
}
@override
@ -556,7 +573,9 @@ class _$UserPreferencesImpl implements _UserPreferences {
(identical(other.discordPresence, discordPresence) ||
other.discordPresence == discordPresence) &&
(identical(other.endlessPlayback, endlessPlayback) ||
other.endlessPlayback == endlessPlayback));
other.endlessPlayback == endlessPlayback) &&
(identical(other.enableConnect, enableConnect) ||
other.enableConnect == enableConnect));
}
@JsonKey(ignore: true)
@ -584,7 +603,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
streamMusicCodec,
downloadMusicCodec,
discordPresence,
endlessPlayback
endlessPlayback,
enableConnect
]);
@JsonKey(ignore: true)
@ -633,7 +653,8 @@ abstract class _UserPreferences implements UserPreferences {
final SourceCodecs streamMusicCodec,
final SourceCodecs downloadMusicCodec,
final bool discordPresence,
final bool endlessPlayback}) = _$UserPreferencesImpl;
final bool endlessPlayback,
final bool enableConnect}) = _$UserPreferencesImpl;
factory _UserPreferences.fromJson(Map<String, dynamic> json) =
_$UserPreferencesImpl.fromJson;
@ -691,6 +712,8 @@ abstract class _UserPreferences implements UserPreferences {
@override
bool get endlessPlayback;
@override
bool get enableConnect;
@override
@JsonKey(ignore: true)
_$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith =>
throw _privateConstructorUsedError;

View File

@ -59,6 +59,7 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson(
SourceCodecs.m4a,
discordPresence: json['discordPresence'] as bool? ?? true,
endlessPlayback: json['endlessPlayback'] as bool? ?? true,
enableConnect: json['enableConnect'] as bool? ?? false,
);
Map<String, dynamic> _$$UserPreferencesImplToJson(
@ -87,6 +88,7 @@ Map<String, dynamic> _$$UserPreferencesImplToJson(
'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!,
'discordPresence': instance.discordPresence,
'endlessPlayback': instance.endlessPlayback,
'enableConnect': instance.enableConnect,
};
const _$SourceQualitiesEnumMap = {

View File

@ -18,6 +18,11 @@ dependencies:
- libjsoncpp25
- libmpv1 | libmpv2
- xdg-user-dirs
- avahi-daemon
- avahi-discover
- avahi-utils
- libnss-mdns
- mdns-scan
essential: false
icon: assets/spotube-logo.png

View File

@ -13,6 +13,9 @@ requires:
- libsecret
- libnotify
- xdg-user-dirs
- avahi
- mdns-scan
- nss-mdns
display_name: Spotube

View File

@ -8,6 +8,7 @@ import Foundation
import app_links
import audio_service
import audio_session
import bonsoir_darwin
import device_info_plus
import file_selector_macos
import flutter_secure_storage_macos
@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))

View File

@ -1,4 +1,4 @@
platform :osx, '10.14'
platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@ -5,6 +5,9 @@ PODS:
- FlutterMacOS
- audio_session (0.0.1):
- FlutterMacOS
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
@ -50,6 +53,7 @@ DEPENDENCIES:
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
- audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`)
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
- bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
@ -80,6 +84,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos
audio_session:
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
bonsoir_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_selector_macos:
@ -121,6 +127,7 @@ SPEC CHECKSUMS:
app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67
audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9
audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f
file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9
flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea
@ -141,6 +148,6 @@ SPEC CHECKSUMS:
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
window_size: 339dafa0b27a95a62a843042038fa6c3c48de195
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0
COCOAPODS: 1.15.2

View File

@ -436,7 +436,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};
@ -567,7 +567,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -592,7 +592,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MACOSX_DEPLOYMENT_TARGET = 10.15;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_VERSION = 5.0;
};

View File

@ -177,6 +177,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
bonsoir:
dependency: "direct main"
description:
name: bonsoir
sha256: "9703ca3ce201c7ab6cd278ae5a530a125959687f59c2b97822f88a8db5bef106"
url: "https://pub.dev"
source: hosted
version: "5.1.9"
bonsoir_android:
dependency: transitive
description:
name: bonsoir_android
sha256: "19583ae34a5e5743fa2c16619e4ec699b35ae5e6cece59b99b1cf21c1b4ed618"
url: "https://pub.dev"
source: hosted
version: "5.1.4"
bonsoir_darwin:
dependency: transitive
description:
name: bonsoir_darwin
sha256: "985c4c38b4cbfa57ed5870e724a7e17aa080ee7f49d03b43e6d08781511505c6"
url: "https://pub.dev"
source: hosted
version: "5.1.2"
bonsoir_linux:
dependency: transitive
description:
name: bonsoir_linux
sha256: "65554b20bc169c68c311eb31fab46ccdd8ee3d3dd89a2d57c338f4cbf6ceb00d"
url: "https://pub.dev"
source: hosted
version: "5.1.2"
bonsoir_platform_interface:
dependency: transitive
description:
name: bonsoir_platform_interface
sha256: "4ee898bec0b5a63f04f82b06da9896ae8475f32a33b6fa395bea56399daeb9f0"
url: "https://pub.dev"
source: hosted
version: "5.1.2"
bonsoir_windows:
dependency: transitive
description:
name: bonsoir_windows
sha256: abbc90b73ac39e823b0c127da43b91d8906dcc530fc0cec4e169cf0d8c4404b1
url: "https://pub.dev"
source: hosted
version: "5.1.4"
boolean_selector:
dependency: transitive
description:
@ -478,10 +526,10 @@ packages:
dependency: "direct main"
description:
name: dbus
sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263"
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
url: "https://pub.dev"
source: hosted
version: "0.7.8"
version: "0.7.10"
device_frame:
dependency: transitive
description:
@ -1146,6 +1194,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
http_methods:
dependency: transitive
description:
name: http_methods
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_multi_server:
dependency: transitive
description:
@ -1882,13 +1938,21 @@ packages:
source: hosted
version: "2.3.2"
shelf:
dependency: transitive
dependency: "direct main"
description:
name: shelf
sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4
url: "https://pub.dev"
source: hosted
version: "1.4.1"
shelf_router:
dependency: "direct main"
description:
name: shelf_router
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
url: "https://pub.dev"
source: hosted
version: "1.1.4"
shelf_static:
dependency: transitive
description:
@ -1898,7 +1962,7 @@ packages:
source: hosted
version: "1.1.2"
shelf_web_socket:
dependency: transitive
dependency: "direct main"
description:
name: shelf_web_socket
sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
@ -2311,13 +2375,13 @@ packages:
source: hosted
version: "0.5.0"
web_socket_channel:
dependency: transitive
dependency: "direct main"
description:
name: web_socket_channel
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
version: "2.4.4"
webdriver:
dependency: transitive
description:

View File

@ -123,6 +123,11 @@ dependencies:
flutter_broadcasts: ^0.4.0
freezed_annotation: ^2.4.1
spotify: ^0.13.3
bonsoir: ^5.1.9
shelf: ^1.4.1
shelf_router: ^1.1.4
shelf_web_socket: ^1.0.4
web_socket_channel: ^2.4.4
dev_dependencies:
build_runner: ^2.3.2

View File

@ -1,6 +1,103 @@
{
"ar": [
"enable_connect",
"enable_connect_description"
],
"bn": [
"enable_connect",
"enable_connect_description"
],
"ca": [
"enable_connect",
"enable_connect_description"
],
"de": [
"enable_connect",
"enable_connect_description"
],
"es": [
"enable_connect",
"enable_connect_description"
],
"fa": [
"enable_connect",
"enable_connect_description"
],
"fr": [
"enable_connect",
"enable_connect_description"
],
"hi": [
"enable_connect",
"enable_connect_description"
],
"it": [
"enable_connect",
"enable_connect_description"
],
"ja": [
"enable_connect",
"enable_connect_description"
],
"ko": [
"enable_connect",
"enable_connect_description"
],
"ne": [
"enable_connect",
"enable_connect_description"
],
"nl": [
"enable_connect",
"enable_connect_description"
],
"pl": [
"enable_connect",
"enable_connect_description"
],
"pt": [
"enable_connect",
"enable_connect_description"
],
"ru": [
"enable_connect",
"enable_connect_description"
],
"tr": [
"enable_connect",
"enable_connect_description"
],
"uk": [
"enable_connect",
"enable_connect_description"
],
"vi": [
"friends",
"no_lyrics_available"
"no_lyrics_available",
"enable_connect",
"enable_connect_description"
],
"zh": [
"enable_connect",
"enable_connect_description"
]
}

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <app_links/app_links_plugin_c_api.h>
#include <bonsoir_windows/bonsoir_windows_plugin_c_api.h>
#include <dart_discord_rpc/dart_discord_rpc_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
@ -23,6 +24,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
AppLinksPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
BonsoirWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
DartDiscordRpcPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DartDiscordRpcPlugin"));
FileSelectorWindowsRegisterWithRegistrar(

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
app_links
bonsoir_windows
dart_discord_rpc
file_selector_windows
flutter_secure_storage_windows