feat: LAN connect a.k.a control remote Spotube playback and local output 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
This commit is contained in:
Kingkor Roy Tirtho 2024-04-04 22:22:00 +06:00 committed by GitHub
parent 044d3b4820
commit 68374efd3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 3090 additions and 407 deletions

View File

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

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) - [Before Submitting an Enhancement](#before-submitting-an-enhancement)
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion) - [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
- [Your First Code Contribution](#your-first-code-contribution) - [Your First Code Contribution](#your-first-code-contribution)
- [Submit translations](#submit-translations) - [Submit Translations](#submit-translations)
## Code of Conduct ## Code of Conduct
@ -123,16 +123,16 @@ Do the following:
- Install Development dependencies in linux - Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu - Debian (>=12/Bookworm)/Ubuntu
```bash ```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) - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
- Arch/Manjaro - Arch/Manjaro
```bash ```bash
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
``` ```
- Fedora - Fedora
```bash ```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 - Clone the Repo
- Create a `.env` in root of the project following the `.env.example` template - 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 # 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. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@ -5,6 +5,9 @@ PODS:
- Flutter - Flutter
- audio_session (0.0.1): - audio_session (0.0.1):
- Flutter - Flutter
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
- Flutter - Flutter
- DKImagePickerController/Core (4.3.4): - DKImagePickerController/Core (4.3.4):
@ -44,11 +47,13 @@ PODS:
- file_selector_ios (0.0.1): - file_selector_ios (0.0.1):
- Flutter - Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- flutter_inappwebview (0.0.1): - flutter_broadcasts (0.0.1):
- Flutter - Flutter
- flutter_inappwebview/Core (= 0.0.1) - flutter_inappwebview_ios (0.0.1):
- Flutter
- flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0) - OrderedSet (~> 5.0)
- flutter_inappwebview/Core (0.0.1): - flutter_inappwebview_ios/Core (0.0.1):
- Flutter - Flutter
- OrderedSet (~> 5.0) - OrderedSet (~> 5.0)
- flutter_keyboard_visibility (0.0.1): - flutter_keyboard_visibility (0.0.1):
@ -102,11 +107,13 @@ DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`) - app_links (from `.symlinks/plugins/app_links/ios`)
- audio_service (from `.symlinks/plugins/audio_service/ios`) - audio_service (from `.symlinks/plugins/audio_service/ios`)
- audio_session (from `.symlinks/plugins/audio_session/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`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`) - 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_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`) - flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
@ -142,6 +149,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/audio_service/ios" :path: ".symlinks/plugins/audio_service/ios"
audio_session: audio_session:
:path: ".symlinks/plugins/audio_session/ios" :path: ".symlinks/plugins/audio_session/ios"
bonsoir_darwin:
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
device_info_plus: device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios" :path: ".symlinks/plugins/device_info_plus/ios"
file_picker: file_picker:
@ -150,8 +159,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/file_selector_ios/ios" :path: ".symlinks/plugins/file_selector_ios/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
flutter_inappwebview: flutter_broadcasts:
:path: ".symlinks/plugins/flutter_inappwebview/ios" :path: ".symlinks/plugins/flutter_broadcasts/ios"
flutter_inappwebview_ios:
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_keyboard_visibility: flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios" :path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_mailer: flutter_mailer:
@ -191,13 +202,15 @@ SPEC CHECKSUMS:
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_service: f509d65da41b9521a61f1c404dd58651f265a567
audio_session: 4f3e461722055d21515cf3261b64c973c062f345 audio_session: 4f3e461722055d21515cf3261b64c973c062f345
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62 flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
@ -221,6 +234,6 @@ SPEC CHECKSUMS:
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
COCOAPODS: 1.15.2 COCOAPODS: 1.15.2

View File

@ -66,5 +66,11 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true /> <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> </dict>
</plist> </plist>

View File

@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/pages/album/album.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/getting_started/getting_started.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/genres/genres.dart';
@ -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( GoRoute(

View File

@ -116,4 +116,9 @@ abstract class SpotubeIcons {
static const openCollective = SimpleIcons.opencollective; static const openCollective = SimpleIcons.opencollective;
static const anonymous = FeatherIcons.user; static const anonymous = FeatherIcons.user;
static const history = FeatherIcons.clock; 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;
} }

View File

@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.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/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/track.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
await playlistNotifier.load(fetchedTracks, autoPlay: true); final isRemoteDevice = await showSelectDeviceDialog(context, ref);
playlistNotifier.addCollection(album.id!); 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 { } finally {
updating.value = false; updating.value = false;
} }

View File

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

View File

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

View File

@ -283,12 +283,17 @@ class UserLocalTracks extends HookConsumerWidget {
trackSnapshot.isLoading ? 5 : filteredTracks.length, trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (trackSnapshot.isLoading) { if (trackSnapshot.isLoading) {
return TrackTile(track: FakeData.track, index: index); return TrackTile(
playlist: playlist,
track: FakeData.track,
index: index,
);
} }
final track = filteredTracks[index]; final track = filteredTracks[index];
return TrackTile( return TrackTile(
index: index, index: index,
playlist: playlist,
track: track, track: track,
userPlaylist: false, userPlaylist: false,
onTap: () async { onTap: () async {
@ -311,8 +316,11 @@ class UserLocalTracks extends HookConsumerWidget {
enabled: true, enabled: true,
child: ListView.builder( child: ListView.builder(
itemCount: 5, itemCount: 5,
itemBuilder: (context, index) => itemBuilder: (context, index) => TrackTile(
TrackTile(track: FakeData.track, index: index), track: FakeData.track,
index: index,
playlist: playlist,
),
), ),
), ),
), ),

View File

@ -26,6 +26,7 @@ import 'package:spotube/models/local_track.dart';
import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -46,9 +47,7 @@ class PlayerView extends HookConsumerWidget {
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
(value) => value.activeTrack, (value) => value.activeTrack,
)); ));
final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( final isLocalTrack = currentTrack is LocalTrack;
(value) => value.activeTrack is LocalTrack,
));
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
useEffect(() { useEffect(() {
@ -240,7 +239,7 @@ class PlayerView extends HookConsumerWidget {
), ),
if (isLocalTrack) if (isLocalTrack)
Text( Text(
currentTrack?.artists?.asString() ?? "", currentTrack.artists?.asString() ?? "",
style: theme.textTheme.bodyMedium!.copyWith( style: theme.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: bodyTextColor, color: bodyTextColor,
@ -304,10 +303,25 @@ class PlayerView extends HookConsumerWidget {
.height * .height *
.7, .7,
), ),
builder: (context) { builder: (context) => Consumer(
return const PlayerQueue( builder: (context, ref, _) {
floating: false); final playlist = ref.watch(
}, ProxyPlaylistNotifier
.provider,
);
final playlistNotifier =
ref.read(
ProxyPlaylistNotifier
.notifier,
);
return PlayerQueue
.fromProxyPlaylistNotifier(
floating: false,
playlist: playlist,
notifier: playlistNotifier,
);
},
),
); );
} }
: null), : null),
@ -365,11 +379,21 @@ class PlayerView extends HookConsumerWidget {
enabledThumbRadius: 8, enabledThumbRadius: 8,
), ),
), ),
child: const Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding:
child: VolumeSlider( const EdgeInsets.symmetric(horizontal: 16),
fullWidth: true, child: Consumer(builder: (context, ref, _) {
), final volume = ref.watch(volumeProvider);
return VolumeSlider(
fullWidth: true,
value: volume,
onChanged: (value) {
ref
.read(volumeProvider.notifier)
.setVolume(value);
},
);
}),
), ),
), ),
], ],

View File

@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
onPressed: playlist.isFetching == true onPressed: playlist.isFetching == true
? null ? null
: () async { : () async {
switch (audioPlayer.loopMode) { audioPlayer.setLoopMode(
case PlaybackLoopMode.all: switch (loopMode) {
audioPlayer PlaybackLoopMode.all =>
.setLoopMode(PlaybackLoopMode.one); PlaybackLoopMode.one,
break; PlaybackLoopMode.one =>
case PlaybackLoopMode.one: PlaybackLoopMode.none,
audioPlayer PlaybackLoopMode.none =>
.setLoopMode(PlaybackLoopMode.none); PlaybackLoopMode.all,
break; },
case PlaybackLoopMode.none: );
audioPlayer
.setLoopMode(PlaybackLoopMode.all);
break;
}
}, },
); );
}), }),

View File

@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget {
width: double.infinity, width: double.infinity,
color: Colors.transparent, color: Colors.transparent,
child: PlayerTrackDetails( child: PlayerTrackDetails(
albumArt: albumArt, track: playlist.activeTrack,
color: textColor, color: textColor,
), ),
), ),

View File

@ -3,15 +3,13 @@ import 'dart:ui';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
@ -20,19 +18,43 @@ import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
class PlayerQueue extends HookConsumerWidget { class PlayerQueue extends HookConsumerWidget {
final bool floating; final bool floating;
final ProxyPlaylist playlist;
final Future<void> Function(Track track) onJump;
final Future<void> Function(String trackId) onRemove;
final Future<void> Function(int oldIndex, int newIndex) onReorder;
final Future<void> Function() onStop;
const PlayerQueue({ const PlayerQueue({
this.floating = true, this.floating = true,
required this.playlist,
required this.onJump,
required this.onRemove,
required this.onReorder,
required this.onStop,
super.key, super.key,
}); });
PlayerQueue.fromProxyPlaylistNotifier({
this.floating = true,
required this.playlist,
required ProxyPlaylistNotifier notifier,
super.key,
}) : onJump = notifier.jumpToTrack,
onRemove = notifier.removeTrack,
onReorder = notifier.moveTrack,
onStop = notifier.stop;
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final mediaQuery = MediaQuery.of(context);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final controller = useAutoScrollController(); final controller = useAutoScrollController();
final searchText = useState(''); final searchText = useState('');
@ -48,7 +70,6 @@ class PlayerQueue extends HookConsumerWidget {
topRight: Radius.circular(10), topRight: Radius.circular(10),
); );
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final headlineColor = theme.textTheme.headlineSmall?.color; final headlineColor = theme.textTheme.headlineSmall?.color;
final filteredTracks = useMemoized( final filteredTracks = useMemoized(
@ -87,198 +108,204 @@ class PlayerQueue extends HookConsumerWidget {
return const NotFound(vertical: true); return const NotFound(vertical: true);
} }
return ClipRRect( return LayoutBuilder(
borderRadius: borderRadius, builder: (context, constrains) {
clipBehavior: Clip.hardEdge, return ClipRRect(
child: BackdropFilter( borderRadius: borderRadius,
filter: ImageFilter.blur( clipBehavior: Clip.hardEdge,
sigmaX: 15, child: BackdropFilter(
sigmaY: 15, filter: ImageFilter.blur(
), sigmaX: 15,
child: Container( sigmaY: 15,
padding: const EdgeInsets.only( ),
top: 5.0, child: Container(
), padding: const EdgeInsets.only(
decoration: BoxDecoration( top: 5.0,
color: theme.colorScheme.surfaceVariant.withOpacity(0.5), ),
borderRadius: borderRadius, decoration: BoxDecoration(
), color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
child: CallbackShortcuts( borderRadius: borderRadius,
bindings: { ),
LogicalKeySet(LogicalKeyboardKey.escape): () { child: CallbackShortcuts(
if (!isSearching.value) { bindings: {
Navigator.of(context).pop(); LogicalKeySet(LogicalKeyboardKey.escape): () {
} if (!isSearching.value) {
isSearching.value = false; Navigator.of(context).pop();
searchText.value = ''; }
} isSearching.value = false;
}, searchText.value = '';
child: InterScrollbar( }
controller: controller, },
child: CustomScrollView( child: InterScrollbar(
controller: controller, controller: controller,
slivers: [ child: CustomScrollView(
if (!floating) controller: controller,
SliverToBoxAdapter( slivers: [
child: Center( if (!floating)
child: Container( SliverToBoxAdapter(
height: 5, child: Center(
width: 100, child: Container(
margin: const EdgeInsets.only(bottom: 5, top: 2), height: 5,
decoration: BoxDecoration( width: 100,
color: headlineColor, margin: const EdgeInsets.only(bottom: 5, top: 2),
borderRadius: BorderRadius.circular(20), decoration: BoxDecoration(
), color: headlineColor,
), borderRadius: BorderRadius.circular(20),
), ),
),
SliverAppBar(
floating: true,
pinned: false,
snap: false,
backgroundColor: Colors.transparent,
elevation: 0,
automaticallyImplyLeading: !isSearching.value,
title: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
child: SizedBox(
height: kToolbarHeight,
child: mediaQuery.mdAndUp || !isSearching.value
? Align(
alignment: Alignment.centerLeft,
child: Text(
context.l10n.tracks_in_queue(tracks.length),
style: TextStyle(
color: headlineColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
)
: null,
),
),
actions: [
if (mediaQuery.mdAndUp || isSearching.value)
TextField(
onChanged: (value) {
searchText.value = value;
},
decoration: InputDecoration(
hintText: context.l10n.search,
isDense: true,
prefixIcon: mediaQuery.smAndDown
? IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_outlined,
),
onPressed: () {
isSearching.value = false;
searchText.value = '';
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size.square(20),
),
)
: const Icon(SpotubeIcons.filter),
constraints: BoxConstraints(
maxHeight: 40,
maxWidth: mediaQuery.smAndDown
? mediaQuery.size.width - 40
: 300,
), ),
), ),
)
else
IconButton.filledTonal(
icon: const Icon(SpotubeIcons.filter),
onPressed: () {
isSearching.value = !isSearching.value;
},
), ),
if (mediaQuery.mdAndUp || !isSearching.value) ...[ SliverAppBar(
const SizedBox(width: 10), floating: true,
FilledButton( pinned: false,
style: FilledButton.styleFrom( snap: false,
backgroundColor: backgroundColor: Colors.transparent,
theme.scaffoldBackgroundColor.withOpacity(0.5), elevation: 0,
foregroundColor: automaticallyImplyLeading: !isSearching.value,
theme.textTheme.headlineSmall?.color, title: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
), ),
child: Row( child: SizedBox(
children: [ height: kToolbarHeight,
const Icon(SpotubeIcons.playlistRemove), child: mediaQuery.mdAndUp || !isSearching.value
const SizedBox(width: 5), ? Align(
Text(context.l10n.clear_all), alignment: Alignment.centerLeft,
], child: Text(
context.l10n
.tracks_in_queue(tracks.length),
style: TextStyle(
color: headlineColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
)
: null,
), ),
onPressed: () {
playlistNotifier.stop();
Navigator.of(context).pop();
},
), ),
const SizedBox(width: 10), actions: [
], if (mediaQuery.mdAndUp || isSearching.value)
TextField(
onChanged: (value) {
searchText.value = value;
},
decoration: InputDecoration(
hintText: context.l10n.search,
isDense: true,
prefixIcon: mediaQuery.smAndDown
? IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_outlined,
),
onPressed: () {
isSearching.value = false;
searchText.value = '';
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size.square(20),
),
)
: const Icon(SpotubeIcons.filter),
constraints: BoxConstraints(
maxHeight: 40,
maxWidth: mediaQuery.smAndDown
? mediaQuery.size.width - 40
: 300,
),
),
)
else
IconButton.filledTonal(
icon: const Icon(SpotubeIcons.filter),
onPressed: () {
isSearching.value = !isSearching.value;
},
),
if (mediaQuery.mdAndUp || !isSearching.value) ...[
const SizedBox(width: 10),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: theme.scaffoldBackgroundColor
.withOpacity(0.5),
foregroundColor:
theme.textTheme.headlineSmall?.color,
),
child: Row(
children: [
const Icon(SpotubeIcons.playlistRemove),
const SizedBox(width: 5),
Text(context.l10n.clear_all),
],
),
onPressed: () {
playlistNotifier.stop();
Navigator.of(context).pop();
},
),
const SizedBox(width: 10),
],
],
),
const SliverGap(10),
SliverReorderableList(
onReorder: (oldIndex, newIndex) {
playlistNotifier.moveTrack(oldIndex, newIndex);
},
itemCount: filteredTracks.length,
onReorderStart: (index) {
HapticFeedback.selectionClick();
},
onReorderEnd: (index) {
HapticFeedback.selectionClick();
},
itemBuilder: (context, i) {
final track = filteredTracks.elementAt(i);
return AutoScrollTag(
key: ValueKey<int>(i),
controller: controller,
index: i,
child: Material(
color: Colors.transparent,
child: TrackTile(
playlist: playlist,
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
leadingActions: [
if (!isSearching.value &&
searchText.value.isEmpty)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ReorderableDragStartListener(
index: i,
child: const Icon(
SpotubeIcons.dragHandle,
),
),
),
],
),
),
);
},
),
const SliverGap(100),
], ],
), ),
const SliverGap(10), ),
SliverReorderableList(
onReorder: (oldIndex, newIndex) {
playlistNotifier.moveTrack(oldIndex, newIndex);
},
itemCount: filteredTracks.length,
onReorderStart: (index) {
HapticFeedback.selectionClick();
},
onReorderEnd: (index) {
HapticFeedback.selectionClick();
},
itemBuilder: (context, i) {
final track = filteredTracks.elementAt(i);
return AutoScrollTag(
key: ValueKey<int>(i),
controller: controller,
index: i,
child: Material(
color: Colors.transparent,
child: TrackTile(
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
leadingActions: [
if (!isSearching.value &&
searchText.value.isEmpty)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: ReorderableDragStartListener(
index: i,
child: const Icon(
SpotubeIcons.dragHandle,
),
),
),
],
),
),
);
},
),
const SliverGap(100),
],
), ),
), ),
), ),
), );
), },
); );
} }
} }

View File

@ -8,13 +8,14 @@ import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
class PlayerTrackDetails extends HookConsumerWidget { class PlayerTrackDetails extends HookConsumerWidget {
final String? albumArt;
final Color? color; final Color? color;
const PlayerTrackDetails({super.key, this.albumArt, this.color}); final Track? track;
const PlayerTrackDetails({super.key, this.color, this.track});
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: UniversalImage( child: UniversalImage(
path: albumArt ?? "", path: (track?.album?.images)
.asUrlString(placeholder: ImagePlaceholder.albumArt),
placeholder: Assets.albumPlaceholder.path, placeholder: Assets.albumPlaceholder.path,
), ),
), ),

View File

@ -3,37 +3,39 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/provider/volume_provider.dart';
class VolumeSlider extends HookConsumerWidget { class VolumeSlider extends HookConsumerWidget {
final bool fullWidth; final bool fullWidth;
final double value;
final ValueChanged<double> onChanged;
const VolumeSlider({ const VolumeSlider({
super.key, super.key,
this.fullWidth = false, this.fullWidth = false,
required this.value,
required this.onChanged,
}); });
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final volume = ref.watch(volumeProvider);
final volumeNotifier = ref.watch(volumeProvider.notifier);
var slider = Listener( var slider = Listener(
onPointerSignal: (event) async { onPointerSignal: (event) async {
if (event is PointerScrollEvent) { if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) { if (event.scrollDelta.dy > 0) {
final value = volume - .2; final newValue = value - .2;
volumeNotifier.setVolume(value < 0 ? 0 : value); onChanged(newValue < 0 ? 0 : newValue);
} else { } else {
final value = volume + .2; final newValue = value + .2;
volumeNotifier.setVolume(value > 1 ? 1 : value); onChanged(newValue > 1 ? 1 : newValue);
} }
} }
}, },
child: Slider( child: Slider(
min: 0, min: 0,
max: 1, max: 1,
value: volume, value: value,
onChanged: volumeNotifier.setVolume, onChanged: onChanged,
), ),
); );
return Row( return Row(
@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget {
children: [ children: [
IconButton( IconButton(
icon: Icon( icon: Icon(
volume == 0 value == 0
? SpotubeIcons.volumeMute ? SpotubeIcons.volumeMute
: volume <= 0.2 : value <= 0.2
? SpotubeIcons.volumeLow ? SpotubeIcons.volumeLow
: volume <= 0.6 : value <= 0.6
? SpotubeIcons.volumeMedium ? SpotubeIcons.volumeMedium
: SpotubeIcons.volumeHigh, : SpotubeIcons.volumeHigh,
size: 16, size: 16,
), ),
onPressed: () { onPressed: () {
if (volume == 0) { if (value == 0) {
volumeNotifier.setVolume(1); onChanged(1);
} else { } else {
volumeNotifier.setVolume(0); onChanged(0);
} }
}, },
), ),

View File

@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.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/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -71,8 +74,19 @@ class PlaylistCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
await playlistNotifier.load(fetchedTracks, autoPlay: true); final isRemoteDevice = await showSelectDeviceDialog(context, ref);
playlistNotifier.addCollection(playlist.id!); if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData(
tracks: fetchedTracks,
collectionId: playlist.id!,
),
);
} else {
await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(playlist.id!);
}
} finally { } finally {
if (context.mounted) { if (context.mounted) {
updating.value = false; updating.value = false;

View File

@ -19,9 +19,11 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/connect/connect.dart' hide volumeProvider;
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class BottomPlayer extends HookConsumerWidget { class BottomPlayer extends HookConsumerWidget {
@ -34,6 +36,7 @@ class BottomPlayer extends HookConsumerWidget {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
final remoteControl = ref.watch(connectProvider);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
@ -73,7 +76,9 @@ class BottomPlayer extends HookConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Expanded(child: PlayerTrackDetails(albumArt: albumArt)), Expanded(
child: PlayerTrackDetails(track: playlist.activeTrack),
),
// controls // controls
Flexible( Flexible(
flex: 3, flex: 3,
@ -121,10 +126,20 @@ class BottomPlayer extends HookConsumerWidget {
Container( Container(
height: 40, height: 40,
constraints: const BoxConstraints(maxWidth: 250), constraints: const BoxConstraints(maxWidth: 250),
child: const VolumeSlider(), padding: const EdgeInsets.only(right: 10),
child: Consumer(builder: (context, ref, _) {
final volume = ref.watch(volumeProvider);
return VolumeSlider(
fullWidth: true,
value: volume,
onChanged: (value) {
ref.read(volumeProvider.notifier).setVolume(value);
},
);
}),
) )
], ],
) ),
], ],
), ),
), ),

View File

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/connect/clients.dart';
class SelectDeviceDialog extends HookConsumerWidget {
const SelectDeviceDialog({super.key});
@override
Widget build(BuildContext context, ref) {
final isRemoteService = useState(false);
final connectClients = ref.watch(connectClientsProvider);
final remoteService = connectClients.asData!.value.resolvedService!;
return AlertDialog(
title: const Text("Choose the device:"),
insetPadding: const EdgeInsets.all(16),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"There are multiple device connected.\n"
"Choose the device you want this action to take place",
),
RadioListTile.adaptive(
title: Text(remoteService.name),
value: true,
groupValue: isRemoteService.value,
onChanged: (value) {
isRemoteService.value = value!;
},
),
RadioListTile.adaptive(
title: const Text("This Device"),
value: false,
groupValue: isRemoteService.value,
onChanged: (value) {
isRemoteService.value = !value!;
},
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(isRemoteService.value);
},
child: Text(context.l10n.select),
),
],
);
}
}
Future<bool> showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
final connectClients = ref.read(connectClientsProvider);
if (connectClients.asData?.value.resolvedService == null) {
return false;
}
final isRemote = await showDialog<bool>(
context: context,
builder: (context) => const SelectDeviceDialog(),
);
return isRemote ?? false;
}

View File

@ -26,6 +26,8 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
final double? titleWidth; final double? titleWidth;
final Widget? title; final Widget? title;
final bool _sliver;
const PageWindowTitleBar({ const PageWindowTitleBar({
super.key, super.key,
this.actions, this.actions,
@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
this.titleTextStyle, this.titleTextStyle,
this.titleWidth, this.titleWidth,
this.toolbarTextStyle, this.toolbarTextStyle,
}); }) : _sliver = false,
pinned = false,
floating = false,
snap = false,
stretch = false;
final bool pinned;
final bool floating;
final bool snap;
final bool stretch;
const PageWindowTitleBar.sliver({
super.key,
this.actions,
this.title,
this.backgroundColor,
this.actionsIconTheme,
this.automaticallyImplyLeading = false,
this.centerTitle,
this.foregroundColor,
this.leading,
this.leadingWidth,
this.titleSpacing,
this.titleTextStyle,
this.titleWidth,
this.toolbarTextStyle,
this.pinned = false,
this.floating = false,
this.snap = false,
this.stretch = false,
}) : _sliver = true,
toolbarOpacity = 1;
@override @override
Size get preferredSize => const Size.fromHeight(kToolbarHeight); Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
if (widget._sliver) {
return SliverLayoutBuilder(
builder: (context, constraints) {
final hasFullscreen =
mediaQuery.size.width == constraints.crossAxisExtent;
final hasLeadingOrCanPop =
widget.leading != null || Navigator.canPop(context);
return SliverPadding(
padding: EdgeInsets.only(
left: DesktopTools.platform.isMacOS &&
hasFullscreen &&
hasLeadingOrCanPop
? 65
: 0,
),
sliver: SliverAppBar(
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
actions: [
...?widget.actions,
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
],
backgroundColor: widget.backgroundColor,
foregroundColor: widget.foregroundColor,
actionsIconTheme: widget.actionsIconTheme,
centerTitle: widget.centerTitle,
titleSpacing: widget.titleSpacing,
leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
title: widget.title,
pinned: widget.pinned,
floating: widget.floating,
snap: widget.snap,
stretch: widget.stretch,
),
);
},
);
}
return LayoutBuilder(builder: (context, constrains) { return LayoutBuilder(builder: (context, constrains) {
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
final hasLeadingOrCanPop = final hasLeadingOrCanPop =
@ -349,10 +424,7 @@ class WindowButton extends StatelessWidget {
class MinimizeWindowButton extends WindowButton { class MinimizeWindowButton extends WindowButton {
MinimizeWindowButton( MinimizeWindowButton(
{super.key, {super.key, super.colors, super.onPressed, bool? animate})
super.colors,
super.onPressed,
bool? animate})
: super( : super(
animate: animate ?? false, animate: animate ?? false,
iconBuilder: (buttonContext) => iconBuilder: (buttonContext) =>
@ -362,10 +434,7 @@ class MinimizeWindowButton extends WindowButton {
class MaximizeWindowButton extends WindowButton { class MaximizeWindowButton extends WindowButton {
MaximizeWindowButton( MaximizeWindowButton(
{super.key, {super.key, super.colors, super.onPressed, bool? animate})
super.colors,
super.onPressed,
bool? animate})
: super( : super(
animate: animate ?? false, animate: animate ?? false,
iconBuilder: (buttonContext) => iconBuilder: (buttonContext) =>
@ -374,11 +443,7 @@ class MaximizeWindowButton extends WindowButton {
} }
class RestoreWindowButton extends WindowButton { class RestoreWindowButton extends WindowButton {
RestoreWindowButton( RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
{super.key,
super.colors,
super.onPressed,
bool? animate})
: super( : super(
animate: animate ?? false, animate: animate ?? false,
iconBuilder: (buttonContext) => iconBuilder: (buttonContext) =>
@ -394,10 +459,7 @@ final _defaultCloseButtonColors = WindowButtonColors(
class CloseWindowButton extends WindowButton { class CloseWindowButton extends WindowButton {
CloseWindowButton( CloseWindowButton(
{super.key, {super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
WindowButtonColors? colors,
super.onPressed,
bool? animate})
: super( : super(
colors: colors ?? _defaultCloseButtonColors, colors: colors ?? _defaultCloseButtonColors,
animate: animate ?? false, animate: animate ?? false,

View File

@ -18,7 +18,7 @@ import 'package:spotube/extensions/duration.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
class TrackTile extends HookConsumerWidget { class TrackTile extends HookConsumerWidget {
/// [index] will not be shown if null /// [index] will not be shown if null
@ -30,6 +30,7 @@ class TrackTile extends HookConsumerWidget {
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
final bool userPlaylist; final bool userPlaylist;
final String? playlistId; final String? playlistId;
final ProxyPlaylist playlist;
final List<Widget>? leadingActions; final List<Widget>? leadingActions;
@ -38,6 +39,7 @@ class TrackTile extends HookConsumerWidget {
this.index, this.index,
required this.track, required this.track,
this.selected = false, this.selected = false,
required this.playlist,
this.onTap, this.onTap,
this.onLongPress, this.onLongPress,
this.onChanged, this.onChanged,
@ -48,7 +50,6 @@ class TrackTile extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final theme = Theme.of(context); final theme = Theme.of(context);
final blacklist = ref.watch(BlackListNotifier.provider); final blacklist = ref.watch(BlackListNotifier.provider);
@ -65,10 +66,10 @@ class TrackTile extends HookConsumerWidget {
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null); final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
final isPlaying = track.id == playlist.activeTrack?.id;
final isLoading = useState(false); final isLoading = useState(false);
final isPlaying = playlist.activeTrack?.id == track.id;
final isSelected = isPlaying || isLoading.value; final isSelected = isPlaying || isLoading.value;
return LayoutBuilder(builder: (context, constrains) { return LayoutBuilder(builder: (context, constrains) {

View File

@ -8,12 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget {
loadingBuilder: (context) => Skeletonizer( loadingBuilder: (context) => Skeletonizer(
enabled: true, enabled: true,
child: TrackTile( child: TrackTile(
playlist: playlist,
track: FakeData.track, track: FakeData.track,
index: 0, index: 0,
), ),
@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget {
child: Column( child: Column(
children: List.generate( children: List.generate(
10, 10,
(index) => TrackTile(track: FakeData.track, index: index), (index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
), ),
), ),
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = tracks[index]; final track = tracks[index];
return TrackTile( return TrackTile(
playlist: playlist,
track: track, track: track,
index: index, index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!), selected: trackViewState.selectedTrackIds.contains(track.id!),
@ -125,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget {
return; return;
} }
if (isActive || playlist.tracks.contains(track)) { final isRemoteDevice =
await playlistNotifier.jumpToTrack(track); await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(props.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await remotePlayback.load(
WebSocketLoadEventData(
tracks: tracks,
collectionId: props.collectionId,
initialIndex: index,
),
);
}
} else { } else {
final tracks = await props.pagination.onFetchAll(); if (isActive || playlist.tracks.contains(track)) {
await playlistNotifier.load( await playlistNotifier.jumpToTrack(track);
tracks, } else {
initialIndex: index, final tracks = await props.pagination.onFetchAll();
autoPlay: true, await playlistNotifier.load(
); tracks,
playlistNotifier.addCollection(props.collectionId); initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(props.collectionId);
}
} }
}, },
); );

View File

@ -6,8 +6,11 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart'; import 'package:palette_generator/palette_generator.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -43,13 +46,25 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final allTracks = await props.pagination.onFetchAll(); final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.load( final isRemoteDevice = await showSelectDeviceDialog(context, ref);
allTracks, if (isRemoteDevice) {
autoPlay: true, final remotePlayback = ref.read(connectProvider.notifier);
initialIndex: Random().nextInt(allTracks.length), await remotePlayback.load(
); WebSocketLoadEventData(
await audioPlayer.setShuffle(true); tracks: allTracks,
playlistNotifier.addCollection(props.collectionId); collectionId: props.collectionId,
initialIndex: Random().nextInt(allTracks.length)),
);
await remotePlayback.setShuffle(true);
} else {
await playlistNotifier.load(
allTracks,
autoPlay: true,
initialIndex: Random().nextInt(allTracks.length),
);
await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(props.collectionId);
}
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }
@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final allTracks = await props.pagination.onFetchAll(); final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.load(allTracks, autoPlay: true); final isRemoteDevice = await showSelectDeviceDialog(context, ref);
playlistNotifier.addCollection(props.collectionId); if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData(
tracks: allTracks,
collectionId: props.collectionId,
),
);
} else {
await playlistNotifier.load(allTracks, autoPlay: true);
playlistNotifier.addCollection(props.collectionId);
}
} finally { } finally {
isLoading.value = false; isLoading.value = false;
} }

View File

@ -313,5 +313,12 @@
"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.", "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", "contribute_on_github": "Contribute on GitHub",
"donate_on_open_collective": "Donate on Open Collective", "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",
"devices": "Devices",
"select": "Select",
"connect_client_alert": "You're being controlled by {client}",
"this_device": "This Device",
"remote": "Remote"
} }

View File

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

View File

@ -0,0 +1,16 @@
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';
import 'package:spotube/services/audio_player/loop_mode.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,374 @@
part of 'connect.dart';
enum WsEvent {
error,
volume,
removeTrack,
addTrack,
reorder,
shuffle,
loop,
seek,
duration,
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": data}));
}
}
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(
WebSocketLoadEventData.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>),
);
}
}
Future<void> onDuration(
EventCallback<WebSocketDurationEvent> callback,
) async {
if (type == WsEvent.duration) {
await callback(
WebSocketDurationEvent(
Duration(seconds: data as int),
),
);
}
}
Future<void> onSeek(
EventCallback<WebSocketSeekEvent> callback,
) async {
if (type == WsEvent.seek) {
await callback(
WebSocketSeekEvent(
Duration(seconds: data as int),
),
);
}
}
Future<void> onShuffle(
EventCallback<WebSocketShuffleEvent> callback,
) async {
if (type == WsEvent.shuffle) {
await callback(WebSocketShuffleEvent(data as bool));
}
}
Future<void> onLoop(
EventCallback<WebSocketLoopEvent> callback,
) async {
if (type == WsEvent.loop) {
await callback(
WebSocketLoopEvent(
PlaybackLoopMode.fromString(data as String),
),
);
}
}
Future<void> onRemoveTrack(
EventCallback<WebSocketRemoveTrackEvent> callback,
) async {
if (type == WsEvent.removeTrack) {
await callback(WebSocketRemoveTrackEvent(data as String));
}
}
Future<void> onAddTrack(
EventCallback<WebSocketAddTrackEvent> callback,
) async {
if (type == WsEvent.addTrack) {
await callback(
WebSocketAddTrackEvent.fromJson(data as Map<String, dynamic>));
}
}
Future<void> onReorder(
EventCallback<WebSocketReorderEvent> callback,
) async {
if (type == WsEvent.reorder) {
await callback(
WebSocketReorderEvent.fromJson(data as Map<String, dynamic>));
}
}
Future<void> onVolume(
EventCallback<WebSocketVolumeEvent> callback,
) async {
if (type == WsEvent.volume) {
await callback(WebSocketVolumeEvent(data as double));
}
}
}
class WebSocketLoopEvent extends WebSocketEvent<PlaybackLoopMode> {
WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data);
WebSocketLoopEvent.fromJson(Map<String, dynamic> json)
: super(
WsEvent.loop, PlaybackLoopMode.fromString(json["data"] as String));
@override
String toJson() {
return jsonEncode({
"type": type.name,
"data": data.name,
});
}
}
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 WebSocketDurationEvent extends WebSocketEvent<Duration> {
WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data);
WebSocketDurationEvent.fromJson(Map<String, dynamic> json)
: super(WsEvent.duration, Duration(seconds: json["data"] as int));
@override
String toJson() {
return jsonEncode({
"type": type.name,
"data": data.inSeconds,
});
}
}
class WebSocketSeekEvent extends WebSocketEvent<Duration> {
WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data);
WebSocketSeekEvent.fromJson(Map<String, dynamic> json)
: super(WsEvent.seek, Duration(seconds: json["data"] as int));
@override
String toJson() {
return jsonEncode({
"type": type.name,
"data": data.inSeconds,
});
}
}
class WebSocketShuffleEvent extends WebSocketEvent<bool> {
WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data);
}
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),
);
}
class WebSocketRemoveTrackEvent extends WebSocketEvent<String> {
WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data);
}
class WebSocketAddTrackEvent extends WebSocketEvent<Track> {
WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data);
WebSocketAddTrackEvent.fromJson(Map<String, dynamic> json)
: super(
WsEvent.addTrack,
Track.fromJson(json["data"] as Map<String, dynamic>),
);
}
typedef ReorderData = ({int oldIndex, int newIndex});
class WebSocketReorderEvent extends WebSocketEvent<ReorderData> {
WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data);
factory WebSocketReorderEvent.fromJson(Map<String, dynamic> json) =>
WebSocketReorderEvent(
(
oldIndex: json["oldIndex"] as int,
newIndex: json["newIndex"] as int,
),
);
@override
String toJson() {
return jsonEncode({
"type": type.name,
"data": {
"oldIndex": data.oldIndex,
"newIndex": data.newIndex,
},
});
}
}
class WebSocketVolumeEvent extends WebSocketEvent<double> {
WebSocketVolumeEvent(double data) : super(WsEvent.volume, data);
}

View File

@ -4,8 +4,11 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
@ -39,16 +42,41 @@ class ArtistPageTopTracks extends HookConsumerWidget {
void playPlaylist(List<Track> tracks, {Track? currentTrack}) async { void playPlaylist(List<Track> tracks, {Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
if (!isPlaylistPlaying) {
playlistNotifier.load( final isRemoteDevice = await showSelectDeviceDialog(context, ref);
tracks, if (isRemoteDevice) {
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), final remotePlayback = ref.read(connectProvider.notifier);
autoPlay: true, final remotePlaylist = ref.read(queueProvider);
);
} else if (isPlaylistPlaying && final isPlaylistPlaying = remotePlaylist.containsTracks(tracks);
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) { if (!isPlaylistPlaying) {
await playlistNotifier.jumpToTrack(currentTrack); await remotePlayback.load(
WebSocketLoadEventData(
tracks: tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
),
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != remotePlaylist.activeTrack?.id) {
final index = playlist.tracks
.toList()
.indexWhere((s) => s.id == currentTrack!.id);
await remotePlayback.jumpTo(index);
}
} else {
if (!isPlaylistPlaying) {
playlistNotifier.load(
tracks,
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playlistNotifier.jumpToTrack(currentTrack);
}
} }
} }
@ -107,6 +135,7 @@ class ArtistPageTopTracks extends HookConsumerWidget {
final track = topTracks.elementAt(index); final track = topTracks.elementAt(index);
return TrackTile( return TrackTile(
index: index, index: index,
playlist: playlist,
track: track, track: track,
onTap: () async { onTap: () async {
playPlaylist( playPlaylist(

View File

@ -0,0 +1,93 @@
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/components/connect/local_devices.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/utils/service_utils.dart';
class ConnectPage extends HookConsumerWidget {
const ConnectPage({super.key});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:colorScheme, :textTheme) = Theme.of(context);
final connectClients = ref.watch(connectClientsProvider);
final connectClientsNotifier = ref.read(connectClientsProvider.notifier);
final discoveredDevices = connectClients.asData?.value.services;
return Scaffold(
appBar: PageWindowTitleBar(
automaticallyImplyLeading: true,
title: Text(context.l10n.devices),
),
body: ListTileTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
selectedTileColor: colorScheme.secondary.withOpacity(0.1),
child: Padding(
padding: const EdgeInsets.all(10.0),
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.remote,
style: textTheme.titleMedium,
),
),
),
const SliverGap(10),
SliverList.separated(
itemCount: discoveredDevices?.length ?? 0,
separatorBuilder: (context, index) => const Gap(10),
itemBuilder: (context, index) {
final device = discoveredDevices![index];
final selected =
connectClients.asData?.value.resolvedService?.name ==
device.name;
return Card(
child: ListTile(
leading: const Icon(SpotubeIcons.monitor),
title: Text(device.name),
subtitle: selected
? Text(
"${connectClients.asData?.value.resolvedService?.host}"
":${connectClients.asData?.value.resolvedService?.port}",
)
: null,
selected: selected,
onTap: () {
if (selected) {
ServiceUtils.push(
context,
"/connect/control",
);
} else {
connectClientsNotifier.resolveService(device);
}
},
trailing: selected
? IconButton(
icon: const Icon(SpotubeIcons.power),
onPressed: () =>
connectClientsNotifier.clearResolvedService(),
)
: null,
),
);
},
),
const ConnectPageLocalDevices(),
],
),
),
),
);
}
}

View File

@ -0,0 +1,317 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_queue.dart';
import 'package:spotube/components/player/volume_slider.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/anchor_button.dart';
import 'package:spotube/components/shared/links/artist_link.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/services/audio_player/loop_mode.dart';
import 'package:spotube/utils/service_utils.dart';
class ConnectControlPage extends HookConsumerWidget {
const ConnectControlPage({super.key});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
final resolvedService =
ref.watch(connectClientsProvider).asData?.value.resolvedService;
final connectNotifier = ref.read(connectProvider.notifier);
final playlist = ref.watch(queueProvider);
final playing = ref.watch(playingProvider);
final shuffled = ref.watch(shuffleProvider);
final loopMode = ref.watch(loopModeProvider);
final resumePauseStyle = IconButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.all(12),
iconSize: 24,
);
final buttonStyle = IconButton.styleFrom(
backgroundColor: colorScheme.surface.withOpacity(0.4),
minimumSize: const Size(28, 28),
);
final activeButtonStyle = IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer,
foregroundColor: colorScheme.onPrimaryContainer,
minimumSize: const Size(28, 28),
);
final playerQueue = Consumer(builder: (context, ref, _) {
final playlist = ref.watch(queueProvider);
return PlayerQueue(
playlist: playlist,
floating: true,
onJump: (track) async {
final index = playlist.tracks.toList().indexOf(track);
connectNotifier.jumpTo(index);
},
onRemove: (track) async {
await connectNotifier.removeTrack(track);
},
onStop: () async => connectNotifier.stop(),
onReorder: (oldIndex, newIndex) async {
await connectNotifier.reorder(
(oldIndex: oldIndex, newIndex: newIndex),
);
},
);
});
ref.listen(connectClientsProvider, (prev, next) {
if (next.asData?.value.resolvedService == null) {
context.pop();
}
});
return SafeArea(
child: Scaffold(
appBar: PageWindowTitleBar(
title: Text(resolvedService!.name),
automaticallyImplyLeading: true,
),
body: LayoutBuilder(builder: (context, constrains) {
return Row(
children: [
Expanded(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
).copyWith(top: 0),
constraints:
const BoxConstraints(maxHeight: 400, maxWidth: 400),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: UniversalImage(
path: (playlist.activeTrack?.album?.images)
.asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: AnchorButton(
playlist.activeTrack?.name ?? "",
style: textTheme.titleLarge!,
onTap: () {
ServiceUtils.push(
context,
"/track/${playlist.activeTrack?.id}",
);
},
),
),
SliverToBoxAdapter(
child: ArtistLink(
artists: playlist.activeTrack?.artists ?? [],
textStyle: textTheme.bodyMedium!,
mainAxisAlignment: WrapAlignment.start,
),
),
],
),
),
const SliverGap(30),
SliverToBoxAdapter(
child: Consumer(
builder: (context, ref, _) {
final position = ref.watch(positionProvider);
final duration = ref.watch(durationProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
children: [
Slider(
value: position > duration
? 0
: position.inSeconds.toDouble(),
min: 0,
max: duration.inSeconds.toDouble(),
onChanged: (value) {
connectNotifier
.seek(Duration(seconds: value.toInt()));
},
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(position.toHumanReadableString()),
Text(duration.toHumanReadableString()),
],
),
],
),
);
},
),
),
SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
tooltip: shuffled
? context.l10n.unshuffle_playlist
: context.l10n.shuffle_playlist,
icon: const Icon(SpotubeIcons.shuffle),
style: shuffled ? activeButtonStyle : buttonStyle,
onPressed: playlist.activeTrack == null
? null
: () {
connectNotifier.setShuffle(!shuffled);
},
),
IconButton(
tooltip: context.l10n.previous_track,
icon: const Icon(SpotubeIcons.skipBack),
onPressed: playlist.activeTrack == null
? null
: connectNotifier.previous,
),
IconButton(
tooltip: playing
? context.l10n.pause_playback
: context.l10n.resume_playback,
icon: playlist.activeTrack == null
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: colorScheme.onPrimary,
),
)
: Icon(
playing
? SpotubeIcons.pause
: SpotubeIcons.play,
),
style: resumePauseStyle,
onPressed: playlist.activeTrack == null
? null
: () {
if (playing) {
connectNotifier.pause();
} else {
connectNotifier.resume();
}
},
),
IconButton(
tooltip: context.l10n.next_track,
icon: const Icon(SpotubeIcons.skipForward),
onPressed: playlist.activeTrack == null
? null
: connectNotifier.next,
),
IconButton(
tooltip: loopMode == PlaybackLoopMode.one
? context.l10n.loop_track
: loopMode == PlaybackLoopMode.all
? context.l10n.repeat_playlist
: null,
icon: Icon(
loopMode == PlaybackLoopMode.one
? SpotubeIcons.repeatOne
: SpotubeIcons.repeat,
),
style: loopMode == PlaybackLoopMode.one ||
loopMode == PlaybackLoopMode.all
? activeButtonStyle
: buttonStyle,
onPressed: playlist.activeTrack == null
? null
: () async {
connectNotifier.setLoopMode(
switch (loopMode) {
PlaybackLoopMode.all =>
PlaybackLoopMode.one,
PlaybackLoopMode.one =>
PlaybackLoopMode.none,
PlaybackLoopMode.none =>
PlaybackLoopMode.all,
},
);
},
)
],
),
),
const SliverGap(30),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverToBoxAdapter(
child: Consumer(builder: (context, ref, _) {
final volume = ref.watch(volumeProvider);
return VolumeSlider(
fullWidth: true,
value: volume,
onChanged: (value) {
ref.read(volumeProvider.notifier).state = value;
connectNotifier.setVolume(value);
},
);
}),
),
),
const SliverGap(30),
if (constrains.mdAndDown)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverToBoxAdapter(
child: OutlinedButton.icon(
icon: const Icon(SpotubeIcons.queue),
label: Text(context.l10n.queue),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) {
return playerQueue;
},
);
},
),
),
)
],
),
),
if (constrains.lgAndUp) ...[
const VerticalDivider(thickness: 1),
Expanded(
child: playerQueue,
),
]
],
);
}),
),
);
}
}

View File

@ -3,6 +3,8 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/connect/connect_device.dart';
import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/featured.dart';
import 'package:spotube/components/home/sections/friends.dart'; import 'package:spotube/components/home/sections/friends.dart';
import 'package:spotube/components/home/sections/genres.dart'; import 'package:spotube/components/home/sections/genres.dart';
@ -20,15 +22,21 @@ class HomePage extends HookConsumerWidget {
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
appBar:
DesktopTools.platform.isLinux || DesktopTools.platform.isWindows
? const PageWindowTitleBar()
: null,
body: CustomScrollView( body: CustomScrollView(
controller: controller, controller: controller,
slivers: [ slivers: [
if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) PageWindowTitleBar.sliver(
const SliverGap(20), pinned: DesktopTools.platform.isDesktop,
actions: [
const ConnectDeviceButton(),
const Gap(10),
IconButton.filledTonal(
icon: const Icon(SpotubeIcons.user),
onPressed: () {},
),
const Gap(10),
],
),
const HomeGenresSection(), const HomeGenresSection(),
const SliverToBoxAdapter(child: HomeFeaturedSection()), const SliverToBoxAdapter(child: HomeFeaturedSection()),
const HomePageFriendsSection(), const HomePageFriendsSection(),

View File

@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget {
MediaQuery.of(context).size.height * .7, MediaQuery.of(context).size.height * .7,
), ),
builder: (context) { builder: (context) {
return const PlayerQueue(floating: true); return Consumer(builder: (context, ref, _) {
final playlist = ref
.watch(ProxyPlaylistNotifier.provider);
return PlayerQueue
.fromProxyPlaylistNotifier(
floating: true,
playlist: playlist,
notifier: ref
.read(ProxyPlaylistNotifier.notifier),
);
});
}, },
); );
} }

View File

@ -27,19 +27,17 @@ class WebViewLogin extends HookConsumerWidget {
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
child: InAppWebView( child: InAppWebView(
initialOptions: InAppWebViewGroupOptions( initialSettings: InAppWebViewSettings(
crossPlatform: InAppWebViewOptions( userAgent:
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36",
),
), ),
initialUrlRequest: URLRequest( initialUrlRequest: URLRequest(
url: Uri.parse("https://accounts.spotify.com/"), url: WebUri("https://accounts.spotify.com/"),
), ),
androidOnPermissionRequest: (controller, origin, resources) async { onPermissionRequest: (controller, permissionRequest) async {
return PermissionRequestResponse( return PermissionResponse(
resources: resources, resources: permissionRequest.resources,
action: PermissionRequestResponseAction.GRANT, action: PermissionResponseAction.GRANT,
); );
}, },
onLoadStop: (controller, action) async { onLoadStop: (controller, action) async {

View File

@ -16,7 +16,9 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart';
import 'package:spotube/hooks/configurators/use_update_checker.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart';
import 'package:spotube/provider/connect/server.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/services/connectivity_adapter.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
@ -53,50 +55,75 @@ class RootApp extends HookConsumerWidget {
} }
}); });
final subscription = ConnectionCheckerService final subscriptions = [
.instance.onConnectivityChanged ConnectionCheckerService.instance.onConnectivityChanged
.listen((status) { .listen((status) {
if (status) { if (status) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
SpotubeIcons.wifi,
color: theme.colorScheme.onPrimary,
),
const SizedBox(width: 10),
Text(context.l10n.connection_restored),
],
),
backgroundColor: theme.colorScheme.primary,
showCloseIcon: true,
width: 350,
),
);
} else {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
SpotubeIcons.noWifi,
color: theme.colorScheme.onError,
),
const SizedBox(width: 10),
Text(context.l10n.you_are_offline),
],
),
backgroundColor: theme.colorScheme.error,
showCloseIcon: true,
width: 300,
),
);
}
}),
connectClientStream.listen((clientOrigin) {
scaffoldMessenger.showSnackBar( scaffoldMessenger.showSnackBar(
SnackBar( SnackBar(
backgroundColor: Colors.yellow[600],
behavior: SnackBarBehavior.floating,
content: Row( content: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( const Icon(
SpotubeIcons.wifi, SpotubeIcons.error,
color: theme.colorScheme.onPrimary, color: Colors.black,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Text(context.l10n.connection_restored), Text(
], context.l10n.connect_client_alert(clientOrigin),
), style: const TextStyle(color: Colors.black),
backgroundColor: theme.colorScheme.primary,
showCloseIcon: true,
width: 350,
),
);
} else {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Row(
children: [
Icon(
SpotubeIcons.noWifi,
color: theme.colorScheme.onError,
), ),
const SizedBox(width: 10),
Text(context.l10n.you_are_offline),
], ],
), ),
backgroundColor: theme.colorScheme.error,
showCloseIcon: true,
width: 300,
), ),
); );
} })
}); ];
return () { return () {
subscription.cancel(); for (final subscription in subscriptions) {
subscription.cancel();
}
}; };
}, []); }, []);
@ -191,7 +218,19 @@ class RootApp extends HookConsumerWidget {
top: 40, top: 40,
bottom: 100, bottom: 100,
), ),
child: const PlayerQueue(floating: true), child: Consumer(
builder: (context, ref, _) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier =
ref.read(ProxyPlaylistNotifier.notifier);
return PlayerQueue.fromProxyPlaylistNotifier(
floating: true,
playlist: playlist,
notifier: playlistNotifier,
);
},
),
) )
: null, : null,
bottomNavigationBar: Column( bottomNavigationBar: Column(

View File

@ -3,8 +3,11 @@ import 'package:flutter/material.dart' hide Page;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.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/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
@ -46,26 +49,60 @@ class SearchTracksSection extends HookConsumerWidget {
return TrackTile( return TrackTile(
index: i, index: i,
track: track, track: track,
playlist: playlist,
onTap: () async { onTap: () async {
final isTrackPlaying = playlist.activeTrack?.id == track.id; final isRemoteDevice =
if (!isTrackPlaying && context.mounted) { await showSelectDeviceDialog(context, ref);
final shouldPlay = (playlist.tracks.length) > 20
? await showPromptDialog(
context: context,
title: context.l10n.playing_track(
track.name!,
),
message: context.l10n.queue_clear_alert(
playlist.tracks.length,
),
)
: true;
if (shouldPlay) { if (isRemoteDevice) {
await playlistNotifier.load( final remotePlayback = ref.read(connectProvider.notifier);
[track], final remotePlaylist = ref.read(queueProvider);
autoPlay: true,
); final isTrackPlaying =
remotePlaylist.activeTrack?.id == track.id;
if (!isTrackPlaying && context.mounted) {
final shouldPlay = (playlist.tracks.length) > 20
? await showPromptDialog(
context: context,
title: context.l10n.playing_track(
track.name!,
),
message: context.l10n.queue_clear_alert(
playlist.tracks.length,
),
)
: true;
if (shouldPlay) {
await remotePlayback.load(
WebSocketLoadEventData(
tracks: [track],
),
);
}
}
} else {
final isTrackPlaying = playlist.activeTrack?.id == track.id;
if (!isTrackPlaying && context.mounted) {
final shouldPlay = (playlist.tracks.length) > 20
? await showPromptDialog(
context: context,
title: context.l10n.playing_track(
track.name!,
),
message: context.l10n.queue_clear_alert(
playlist.tracks.length,
),
)
: true;
if (shouldPlay) {
await playlistNotifier.load(
[track],
autoPlay: true,
);
}
} }
} }
}, },

View File

@ -227,6 +227,13 @@ class SettingsPlaybackSection extends HookConsumerWidget {
value: preferences.endlessPlayback, value: preferences.endlessPlayback,
onChanged: preferencesNotifier.setEndlessPlayback, 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,111 @@
import 'package:bonsoir/bonsoir.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/services/device_info/device_info.dart';
class ConnectClientsState {
final List<BonsoirService> services;
final ResolvedBonsoirService? resolvedService;
final BonsoirDiscovery discovery;
ConnectClientsState({
required this.services,
required this.discovery,
this.resolvedService,
});
ConnectClientsState copyWith({
List<BonsoirService>? services,
BonsoirDiscovery? discovery,
ResolvedBonsoirService? resolvedService,
}) {
return ConnectClientsState(
services: services ?? this.services,
discovery: discovery ?? this.discovery,
resolvedService: resolvedService ?? this.resolvedService,
);
}
}
class ConnectClientsNotifier extends AsyncNotifier<ConnectClientsState> {
ConnectClientsNotifier();
@override
build() async {
final discovery = BonsoirDiscovery(type: '_spotube._tcp');
final deviceId = await DeviceInfoService.instance.deviceId();
await discovery.ready;
final subscription = discovery.eventStream?.listen((event) {
// ignore device itself
if (event.service?.attributes["deviceId"] == deviceId) {
return;
}
switch (event.type) {
case BonsoirDiscoveryEventType.discoveryServiceFound:
state = AsyncData(state.value!.copyWith(
services: [
...?state.value?.services,
event.service!,
],
));
break;
case BonsoirDiscoveryEventType.discoveryServiceResolved:
state = AsyncData(
state.value!.copyWith(
resolvedService: event.service as ResolvedBonsoirService,
),
);
break;
case BonsoirDiscoveryEventType.discoveryServiceLost:
state = AsyncData(
ConnectClientsState(
services: state.value!.services
.where((s) => s.name != event.service!.name)
.toList(),
discovery: state.value!.discovery,
resolvedService:
event.service?.name == state.value!.resolvedService!.name
? null
: state.value!.resolvedService,
),
);
break;
default:
break;
}
});
ref.onDispose(() {
subscription?.cancel();
discovery.stop();
});
await discovery.start();
return ConnectClientsState(
services: [],
discovery: discovery,
);
}
Future<void> resolveService(BonsoirService service) async {
if (state.value == null) return;
await service.resolve(state.value!.discovery.serviceResolver);
}
Future<void> clearResolvedService() async {
if (state.value == null) return;
state = AsyncData(
ConnectClientsState(
services: state.value!.services,
discovery: state.value!.discovery,
),
);
}
}
final connectClientsProvider =
AsyncNotifierProvider<ConnectClientsNotifier, ConnectClientsState>(
() => ConnectClientsNotifier(),
);

View File

@ -0,0 +1,184 @@
import 'dart:convert';
import 'package:catcher_2/catcher_2.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/clients.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
import 'package:spotube/services/audio_player/loop_mode.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
final playingProvider = StateProvider<bool>(
(ref) => false,
);
final positionProvider = StateProvider<Duration>(
(ref) => Duration.zero,
);
final durationProvider = StateProvider<Duration>(
(ref) => Duration.zero,
);
final shuffleProvider = StateProvider<bool>(
(ref) => false,
);
final loopModeProvider = StateProvider<PlaybackLoopMode>(
(ref) => PlaybackLoopMode.none,
);
final queueProvider = StateProvider<ProxyPlaylist>(
(ref) => ProxyPlaylist({}),
);
final volumeProvider = StateProvider<double>(
(ref) => 1.0,
);
class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
@override
build() async {
try {
final connectClients = ref.watch(connectClientsProvider);
print('Building ConnectNotifier');
if (connectClients.asData?.value.resolvedService == null) return null;
final service = connectClients.asData!.value.resolvedService!;
print(
'Connecting to ${service.name}: ws://${service.host}:${service.port}/ws');
final channel = WebSocketChannel.connect(
Uri.parse('ws://${service.host}:${service.port}/ws'),
);
await channel.ready;
print(
'Connected to ${service.name}: ws://${service.host}:${service.port}/ws');
final subscription = channel.stream.listen(
(message) {
final event =
WebSocketEvent.fromJson(jsonDecode(message), (data) => data);
event.onQueue((event) {
ref.read(queueProvider.notifier).state = event.data;
});
event.onPlaying((event) {
ref.read(playingProvider.notifier).state = event.data;
});
event.onPosition((event) {
ref.read(positionProvider.notifier).state = event.data;
});
event.onDuration((event) {
ref.read(durationProvider.notifier).state = event.data;
});
event.onShuffle((event) {
ref.read(shuffleProvider.notifier).state = event.data;
});
event.onLoop((event) {
ref.read(loopModeProvider.notifier).state = event.data;
});
event.onVolume((event) {
ref.read(volumeProvider.notifier).state = event.data;
});
},
onError: (error) {
Catcher2.reportCheckedError(
error,
StackTrace.current,
);
},
);
ref.onDispose(() {
subscription.cancel();
channel.sink.close(status.goingAway);
});
return channel;
} catch (e, stack) {
Catcher2.reportCheckedError(e, stack);
rethrow;
}
}
Future<void> emit(Object message) async {
if (state.value == null) return;
state.value?.sink.add(
message is String ? message : (message as dynamic).toJson(),
);
}
Future<void> resume() async {
emit(WebSocketResumeEvent());
}
Future<void> pause() async {
emit(WebSocketPauseEvent());
}
Future<void> stop() async {
emit(WebSocketStopEvent());
}
Future<void> jumpTo(int position) async {
emit(WebSocketJumpEvent(position));
}
Future<void> load(WebSocketLoadEventData data) async {
emit(WebSocketLoadEvent(data));
}
Future<void> next() async {
emit(WebSocketNextEvent());
}
Future<void> previous() async {
emit(WebSocketPreviousEvent());
}
Future<void> seek(Duration position) async {
emit(WebSocketSeekEvent(position));
}
Future<void> setShuffle(bool value) async {
emit(WebSocketShuffleEvent(value));
}
Future<void> setLoopMode(PlaybackLoopMode value) async {
emit(WebSocketLoopEvent(value));
}
Future<void> addTrack(Track data) async {
emit(WebSocketAddTrackEvent(data));
}
Future<void> removeTrack(String data) async {
emit(WebSocketRemoveTrackEvent(data));
}
Future<void> reorder(ReorderData data) async {
emit(WebSocketReorderEvent(data));
}
Future<void> setVolume(double value) async {
emit(WebSocketVolumeEvent(value));
}
}
final connectProvider =
AsyncNotifierProvider<ConnectNotifier, WebSocketChannel?>(
() => ConnectNotifier(),
);

View File

@ -0,0 +1,261 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:catcher_2/catcher_2.dart';
import 'package:shelf/shelf.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/connect/clients.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:spotube/services/device_info/device_info.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:spotube/provider/volume_provider.dart';
final logger = getLogger('ConnectServer');
final _connectClientStreamController = StreamController<String>.broadcast();
Stream<String> get connectClientStream => _connectClientStreamController.stream;
final connectServerProvider = FutureProvider((ref) async {
final enabled =
ref.watch(userPreferencesProvider.select((s) => s.enableConnect));
final resolvedService = await ref
.watch(connectClientsProvider.selectAsync((s) => s.resolvedService));
final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier);
if (!enabled || resolvedService != null) {
return null;
}
final app = Router();
app.get(
"/ping",
(Request req) {
return Response.ok("pong");
},
);
final subscriptions = <StreamSubscription>[];
FutureOr<Response> websocket(Request req) => webSocketHandler(
(WebSocketChannel channel, String? protocol) async {
final context =
(req.context["shelf.io.connection_info"] as HttpConnectionInfo?);
final origin =
"${context?.remoteAddress.host}:${context?.remotePort}";
_connectClientStreamController.add(origin);
ref.listen(
ProxyPlaylistNotifier.provider,
(previous, next) {
channel.sink.add(
WebSocketQueueEvent(next).toJson(),
);
},
fireImmediately: true,
);
// because audioPlayer events doesn't fireImmediately
channel.sink.add(
WebSocketPlayingEvent(audioPlayer.isPlaying).toJson(),
);
channel.sink.add(
WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero)
.toJson(),
);
channel.sink.add(
WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero)
.toJson(),
);
channel.sink.add(
WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(),
);
channel.sink.add(
WebSocketLoopEvent(audioPlayer.loopMode).toJson(),
);
channel.sink.add(
WebSocketVolumeEvent(audioPlayer.volume).toJson(),
);
subscriptions.addAll([
audioPlayer.positionStream.listen(
(position) {
channel.sink.add(
WebSocketPositionEvent(position).toJson(),
);
},
),
audioPlayer.playingStream.listen(
(playing) {
channel.sink.add(
WebSocketPlayingEvent(playing).toJson(),
);
},
),
audioPlayer.durationStream.listen(
(duration) {
channel.sink.add(
WebSocketDurationEvent(duration).toJson(),
);
},
),
audioPlayer.shuffledStream.listen(
(shuffled) {
channel.sink.add(
WebSocketShuffleEvent(shuffled).toJson(),
);
},
),
audioPlayer.loopModeStream.listen(
(loopMode) {
channel.sink.add(
WebSocketLoopEvent(loopMode).toJson(),
);
},
),
audioPlayer.volumeStream.listen(
(volume) {
channel.sink.add(
WebSocketVolumeEvent(volume).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);
});
event.onSeek((event) async {
await audioPlayer.seek(event.data);
});
event.onShuffle((event) async {
await audioPlayer.setShuffle(event.data);
});
event.onLoop((event) async {
await audioPlayer.setLoopMode(event.data);
});
event.onAddTrack((event) async {
await playbackNotifier.addTrack(event.data);
});
event.onRemoveTrack((event) async {
await playbackNotifier.removeTrack(event.data);
});
event.onReorder((event) async {
await playbackNotifier.moveTrack(
event.data.oldIndex,
event.data.newIndex,
);
});
event.onVolume((event) async {
ref.read(volumeProvider.notifier).setVolume(event.data);
});
} catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
channel.sink.add(WebSocketErrorEvent(e.toString()).toJson());
}
},
onDone: () {
logger.i('Connection closed');
},
),
]);
},
)(req);
final port = Random().nextInt(17000) + 3000;
final server = await serve(
(request) {
if (request.url.path.startsWith('ws')) {
return websocket(request);
}
return app(request);
},
InternetAddress.anyIPv4,
port,
);
logger.i('Server running on http://${server.address.host}:${server.port}');
final service = BonsoirService(
name: await DeviceInfoService.instance.computerName(),
type: '_spotube._tcp',
port: port,
attributes: {
"id": PrimitiveUtils.uuid.v4(),
"deviceId": await DeviceInfoService.instance.deviceId(),
},
);
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 => Track? get activeTrack =>
active == null || active == -1 ? null : tracks.elementAtOrNull(active!); active == null || active == -1 ? null : tracks.elementAtOrNull(active!);
@ -62,8 +72,8 @@ class ProxyPlaylist {
/// Otherwise default super.toJson() is used /// Otherwise default super.toJson() is used
static Map<String, dynamic> _makeAppropriateTrackJson(Track track) { static Map<String, dynamic> _makeAppropriateTrackJson(Track track) {
return switch (track.runtimeType) { return switch (track.runtimeType) {
LocalTrack => track.toJson(), LocalTrack() => track.toJson(),
SourcedTrack => track.toJson(), SourcedTrack() => track.toJson(),
_ => track.toJson(), _ => track.toJson(),
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import 'package:catcher_2/catcher_2.dart'; import 'package:catcher_2/catcher_2.dart';
import 'package:media_kit/media_kit.dart';
import 'package:spotube/services/audio_player/mk_state_player.dart'; import 'package:spotube/services/audio_player/mk_state_player.dart';
// import 'package:just_audio/just_audio.dart' as ja; // import 'package:just_audio/just_audio.dart' as ja;
import 'dart:async'; import 'dart:async';
@ -14,7 +15,7 @@ part 'audio_player_impl.dart';
abstract class AudioPlayerInterface { abstract class AudioPlayerInterface {
final MkPlayerWithState _mkPlayer; final MkPlayerWithState _mkPlayer;
// final ja.AudioPlayer? _justAudio; // final ja.AudioPlayer? _justAudxio;
AudioPlayerInterface() AudioPlayerInterface()
: _mkPlayer = MkPlayerWithState( : _mkPlayer = MkPlayerWithState(
@ -60,6 +61,14 @@ abstract class AudioPlayerInterface {
} }
} }
Future<AudioDevice> get selectedDevice async {
return _mkPlayer.state.audioDevice;
}
Future<List<AudioDevice>> get devices async {
return _mkPlayer.state.audioDevices;
}
bool get hasSource { bool get hasSource {
return _mkPlayer.playlist.medias.isNotEmpty; return _mkPlayer.playlist.medias.isNotEmpty;
// if (mkSupportedPlatform) { // if (mkSupportedPlatform) {

View File

@ -83,6 +83,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
// await _justAudio?.setSpeed(speed); // await _justAudio?.setSpeed(speed);
} }
Future<void> setAudioDevice(AudioDevice device) async {
await _mkPlayer.setAudioDevice(device);
}
Future<void> dispose() async { Future<void> dispose() async {
await _mkPlayer.dispose(); await _mkPlayer.dispose();
// await _justAudio?.dispose(); // await _justAudio?.dispose();

View File

@ -140,4 +140,10 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface {
// .cast<String>(); // .cast<String>();
// } // }
} }
Stream<List<AudioDevice>> get devicesStream =>
_mkPlayer.stream.audioDevices.asBroadcastStream();
Stream<AudioDevice> get selectedDeviceStream =>
_mkPlayer.stream.audioDevice.asBroadcastStream();
} }

View File

@ -0,0 +1,34 @@
import 'package:device_info_plus/device_info_plus.dart';
class DeviceInfoService {
final DeviceInfoPlugin deviceInfo;
DeviceInfoService._() : deviceInfo = DeviceInfoPlugin();
static final instance = DeviceInfoService._();
Future<String> deviceId() async {
final info = await deviceInfo.deviceInfo;
return switch (info) {
AndroidDeviceInfo() => info.id,
IosDeviceInfo() => info.identifierForVendor ?? info.model,
MacOsDeviceInfo() => info.systemGUID ?? info.model,
WindowsDeviceInfo() => info.deviceId,
LinuxDeviceInfo() => info.machineId ?? info.id,
_ => 'Unknown',
};
}
Future<String> computerName() async {
final info = await deviceInfo.deviceInfo;
return switch (info) {
AndroidDeviceInfo() => info.model,
IosDeviceInfo() => info.localizedModel,
MacOsDeviceInfo() => info.computerName,
WindowsDeviceInfo() => info.computerName,
LinuxDeviceInfo() => info.name,
_ => 'Unknown',
};
}
}

View File

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

View File

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

View File

@ -8,8 +8,10 @@ import Foundation
import app_links import app_links
import audio_service import audio_service
import audio_session import audio_session
import bonsoir_darwin
import device_info_plus import device_info_plus
import file_selector_macos import file_selector_macos
import flutter_inappwebview_macos
import flutter_secure_storage_macos import flutter_secure_storage_macos
import local_notifier import local_notifier
import media_kit_libs_macos_audio import media_kit_libs_macos_audio
@ -28,8 +30,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))

View File

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

View File

@ -5,10 +5,16 @@ PODS:
- FlutterMacOS - FlutterMacOS
- audio_session (0.0.1): - audio_session (0.0.1):
- FlutterMacOS - FlutterMacOS
- bonsoir_darwin (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
- FlutterMacOS - FlutterMacOS
- file_selector_macos (0.0.1): - file_selector_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- flutter_inappwebview_macos (0.0.1):
- FlutterMacOS
- OrderedSet (~> 5.0)
- flutter_secure_storage_macos (6.1.1): - flutter_secure_storage_macos (6.1.1):
- FlutterMacOS - FlutterMacOS
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
@ -22,6 +28,7 @@ PODS:
- media_kit_native_event_loop (1.0.0): - media_kit_native_event_loop (1.0.0):
- FlutterMacOS - FlutterMacOS
- metadata_god (0.0.1) - metadata_god (0.0.1)
- OrderedSet (5.0.0)
- package_info_plus (0.0.1): - package_info_plus (0.0.1):
- FlutterMacOS - FlutterMacOS
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
@ -50,8 +57,10 @@ DEPENDENCIES:
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
- audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`) - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`)
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/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`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`)
@ -72,6 +81,7 @@ DEPENDENCIES:
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- FMDB - FMDB
- OrderedSet
EXTERNAL SOURCES: EXTERNAL SOURCES:
app_links: app_links:
@ -80,10 +90,14 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos :path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos
audio_session: audio_session:
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
bonsoir_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin
device_info_plus: device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_selector_macos: file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
flutter_inappwebview_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
flutter_secure_storage_macos: flutter_secure_storage_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
FlutterMacOS: FlutterMacOS:
@ -121,8 +135,10 @@ SPEC CHECKSUMS:
app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67
audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9
audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f
file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9
flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d
flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
@ -130,6 +146,7 @@ SPEC CHECKSUMS:
media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da
media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5
metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
@ -141,6 +158,6 @@ SPEC CHECKSUMS:
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 window_size: 339dafa0b27a95a62a843042038fa6c3c48de195
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0
COCOAPODS: 1.15.2 COCOAPODS: 1.15.2

View File

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

View File

@ -177,6 +177,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -478,10 +526,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: dbus name: dbus
sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.8" version: "0.7.10"
device_frame: device_frame:
dependency: transitive dependency: transitive
description: description:
@ -494,10 +542,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: device_info_plus name: device_info_plus
sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.3" version: "9.1.2"
device_info_plus_platform_interface: device_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -786,10 +834,58 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_inappwebview name: flutter_inappwebview
sha256: f73505c792cf083d5566e1a94002311be497d984b5607f25be36d685cf6361cf sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.7.2+3" version: "6.0.0"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421
url: "https://pub.dev"
source: hosted
version: "1.0.13"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f
url: "https://pub.dev"
source: hosted
version: "1.0.13"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636
url: "https://pub.dev"
source: hosted
version: "1.0.11"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187"
url: "https://pub.dev"
source: hosted
version: "1.0.10"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07
url: "https://pub.dev"
source: hosted
version: "1.0.8"
flutter_keyboard_visibility: flutter_keyboard_visibility:
dependency: transitive dependency: transitive
description: description:
@ -1146,6 +1242,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" 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: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@ -1882,13 +1986,21 @@ packages:
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
shelf: shelf:
dependency: transitive dependency: "direct main"
description: description:
name: shelf name: shelf
sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" 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: shelf_static:
dependency: transitive dependency: transitive
description: description:
@ -1898,7 +2010,7 @@ packages:
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
shelf_web_socket: shelf_web_socket:
dependency: transitive dependency: "direct main"
description: description:
name: shelf_web_socket name: shelf_web_socket
sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
@ -2311,13 +2423,13 @@ packages:
source: hosted source: hosted
version: "0.5.0" version: "0.5.0"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: "direct main"
description: description:
name: web_socket_channel name: web_socket_channel
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.4"
webdriver: webdriver:
dependency: transitive dependency: transitive
description: description:

View File

@ -25,7 +25,7 @@ dependencies:
cupertino_icons: ^1.0.5 cupertino_icons: ^1.0.5
curved_navigation_bar: ^1.0.3 curved_navigation_bar: ^1.0.3
dbus: ^0.7.8 dbus: ^0.7.8
device_info_plus: ^9.0.3 device_info_plus: ^9.1.2
device_preview: ^1.1.0 device_preview: ^1.1.0
dio: ^5.4.1 dio: ^5.4.1
disable_battery_optimization: ^1.1.0+1 disable_battery_optimization: ^1.1.0+1
@ -43,7 +43,7 @@ dependencies:
flutter_displaymode: ^0.6.0 flutter_displaymode: ^0.6.0
flutter_feather_icons: ^2.0.0+1 flutter_feather_icons: ^2.0.0+1
flutter_hooks: ^0.20.0 flutter_hooks: ^0.20.0
flutter_inappwebview: ^5.7.2+3 flutter_inappwebview: ^6.0.0
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
flutter_native_splash: ^2.3.10 flutter_native_splash: ^2.3.10
@ -123,6 +123,11 @@ dependencies:
flutter_broadcasts: ^0.4.0 flutter_broadcasts: ^0.4.0
freezed_annotation: ^2.4.1 freezed_annotation: ^2.4.1
spotify: ^0.13.3 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: dev_dependencies:
build_runner: ^2.3.2 build_runner: ^2.3.2

View File

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

View File

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

View File

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