Skip to content

Commit

Permalink
feat: LAN connect a.k.a control remote Spotube playback and local out…
Browse files Browse the repository at this point in the history
…put device selection (#1355)

* feat: add connect server support

* feat: add ability discover and connect to same network Spotube(s) and sync queue

* feat(connect): add player controls, shuffle, loop, progress bar and queue support

* feat: make control page adaptive

* feat: add volume control support

* cd: upgrade macos runner version

* chore: upgrade inappwebview version to 6

* feat: customized devices button

* feat: add user icon next to devices button

* feat: add play in remote device support

* feat: show alert when new client connects

* fix: ignore the device itself from broadcast list

* fix: volume control not working

* feat: add ability to select current device's output speaker
  • Loading branch information
KRTirtho committed Apr 4, 2024
1 parent 044d3b4 commit 68374ef
Show file tree
Hide file tree
Showing 63 changed files with 3,089 additions and 406 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/spotube-release-binary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ jobs:

macos:

runs-on: macos-12
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0
Expand Down Expand Up @@ -349,7 +349,7 @@ jobs:
limit-access-to-actor: true

iOS:
runs-on: macos-latest
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.10.0
Expand Down
8 changes: 4 additions & 4 deletions CONTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion ios/Podfile
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
31 changes: 22 additions & 9 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ PODS:
- Flutter
- audio_session (0.0.1):
- Flutter
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.4):
Expand Down Expand Up @@ -44,11 +47,13 @@ PODS:
- file_selector_ios (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_inappwebview (0.0.1):
- flutter_broadcasts (0.0.1):
- Flutter
- flutter_inappwebview_ios (0.0.1):
- Flutter
- flutter_inappwebview/Core (= 0.0.1)
- flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0)
- flutter_inappwebview/Core (0.0.1):
- flutter_inappwebview_ios/Core (0.0.1):
- Flutter
- OrderedSet (~> 5.0)
- flutter_keyboard_visibility (0.0.1):
Expand Down Expand Up @@ -102,11 +107,13 @@ DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
- audio_service (from `.symlinks/plugins/audio_service/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
Expand Down Expand Up @@ -142,6 +149,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/audio_service/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
bonsoir_darwin:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
Expand All @@ -150,8 +159,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/file_selector_ios/ios"
Flutter:
:path: Flutter
flutter_inappwebview:
:path: ".symlinks/plugins/flutter_inappwebview/ios"
flutter_broadcasts:
:path: ".symlinks/plugins/flutter_broadcasts/ios"
flutter_inappwebview_ios:
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_mailer:
Expand Down Expand Up @@ -191,13 +202,15 @@ SPEC CHECKSUMS:
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
audio_session: 4f3e461722055d21515cf3261b64c973c062f345
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
Expand All @@ -221,6 +234,6 @@ SPEC CHECKSUMS:
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4

PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e

COCOAPODS: 1.15.2
6 changes: 6 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -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>
17 changes: 17 additions & 0 deletions lib/collections/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/connect/connect.dart';
import 'package:spotube/pages/connect/control/control.dart';
import 'package:spotube/pages/getting_started/getting_started.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart';
Expand Down Expand Up @@ -173,6 +175,21 @@ final routerProvider = Provider((ref) {
);
},
),
GoRoute(
path: "/connect",
pageBuilder: (context, state) => const SpotubePage(
child: ConnectPage(),
),
routes: [
GoRoute(
path: "control",
pageBuilder: (context, state) {
return const SpotubePage(
child: ConnectControlPage(),
);
},
)
])
],
),
GoRoute(
Expand Down
5 changes: 5 additions & 0 deletions lib/collections/spotube_icons.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,9 @@ abstract class SpotubeIcons {
static const openCollective = SimpleIcons.opencollective;
static const anonymous = FeatherIcons.user;
static const history = FeatherIcons.clock;
static const connect = FeatherIcons.link;
static const speaker = FeatherIcons.speaker;
static const monitor = FeatherIcons.monitor;
static const power = FeatherIcons.power;
static const bluetooth = FeatherIcons.bluetooth;
}
18 changes: 16 additions & 2 deletions lib/components/album/album_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
Expand Down Expand Up @@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget {

if (fetchedTracks.isEmpty) return;

await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(album.id!);
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData(
tracks: fetchedTracks,
collectionId: album.id!,
),
);
} else {
await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(album.id!);
}
} finally {
updating.value = false;
}
Expand Down
85 changes: 85 additions & 0 deletions lib/components/connect/connect_device.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/utils/service_utils.dart';

class ConnectDeviceButton extends HookConsumerWidget {
const ConnectDeviceButton({super.key});

@override
Widget build(BuildContext context, ref) {
final ThemeData(:colorScheme) = Theme.of(context);
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final connectClients = ref.watch(connectClientsProvider);

return SizedBox(
height: 40 * pixelRatio,
child: Stack(
alignment: Alignment.centerRight,
fit: StackFit.loose,
children: [
Center(
child: InkWell(
onTap: () {
ServiceUtils.push(context, "/connect");
},
borderRadius: BorderRadius.circular(50),
child: Ink(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50),
color: colorScheme.primaryContainer,
),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (connectClients.asData?.value.resolvedService !=
null) ...[
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: Colors.greenAccent,
borderRadius: BorderRadius.circular(50),
),
),
const Gap(5),
],
Text(context.l10n.devices),
if (connectClients.asData?.value.services.isNotEmpty ==
true)
Text(
" (${connectClients.asData?.value.services.length})",
style: TextStyle(
color:
colorScheme.onPrimaryContainer.withOpacity(0.5),
),
),
const Gap(35),
],
),
),
),
),
Positioned(
right: 0,
child: IconButton.filled(
icon: const Icon(SpotubeIcons.speaker),
style: IconButton.styleFrom(
visualDensity: VisualDensity.standard,
foregroundColor: colorScheme.onPrimary,
),
onPressed: () {
ServiceUtils.push(context, "/connect");
},
),
),
],
),
);
}
}
60 changes: 60 additions & 0 deletions lib/components/connect/local_devices.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/audio_player/audio_player.dart';

class ConnectPageLocalDevices extends HookWidget {
const ConnectPageLocalDevices({super.key});

@override
Widget build(BuildContext context) {
final ThemeData(:textTheme) = Theme.of(context);
final devicesFuture = useFuture(audioPlayer.devices);
final devicesStream = useStream(audioPlayer.devicesStream);
final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice);
final selectedDeviceStream = useStream(audioPlayer.selectedDeviceStream);

final devices = devicesStream.data ?? devicesFuture.data;
final selectedDevice =
selectedDeviceStream.data ?? selectedDeviceFuture.data;

if (devices == null) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}

return SliverMainAxisGroup(
slivers: [
const SliverGap(10),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.this_device,
style: textTheme.titleMedium,
),
),
),
const SliverGap(10),
SliverList.separated(
itemCount: devices.length,
separatorBuilder: (context, index) => const Gap(10),
itemBuilder: (context, index) {
final device = devices[index];

return Card(
child: ListTile(
leading: const Icon(SpotubeIcons.speaker),
title: Text(device.description),
subtitle: Text(device.name),
selected: selectedDevice == device,
onTap: () => audioPlayer.setAudioDevice(device),
),
);
},
),
],
);
}
}

0 comments on commit 68374ef

Please sign in to comment.