diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml
index 68ea2d675..e05bf75df 100644
--- a/.github/workflows/spotube-release-binary.yml
+++ b/.github/workflows/spotube-release-binary.yml
@@ -284,7 +284,7 @@ jobs:
macos:
- runs-on: macos-12
+ runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.12.0
@@ -349,7 +349,7 @@ jobs:
limit-access-to-actor: true
iOS:
- runs-on: macos-latest
+ runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.10.0
diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md
index 13996cead..e859f9e6f 100644
--- a/CONTRIBUTION.md
+++ b/CONTRIBUTION.md
@@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
- [Your First Code Contribution](#your-first-code-contribution)
- - [Submit translations](#submit-translations)
+ - [Submit Translations](#submit-translations)
## Code of Conduct
@@ -123,16 +123,16 @@ Do the following:
- Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu
```bash
- $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev
+ $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan
```
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
- Arch/Manjaro
```bash
- yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify
+ yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
```
- Fedora
```bash
- dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel
+ dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns
```
- Clone the Repo
- Create a `.env` in root of the project following the `.env.example` template
diff --git a/ios/Podfile b/ios/Podfile
index bc3dcaa6b..7235f4824 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
-# platform :ios, '12.0'
+platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 0b75217f2..1d048cc9b 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -5,6 +5,9 @@ PODS:
- Flutter
- audio_session (0.0.1):
- Flutter
+ - bonsoir_darwin (0.0.1):
+ - Flutter
+ - FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.4):
@@ -44,11 +47,13 @@ PODS:
- file_selector_ios (0.0.1):
- Flutter
- Flutter (1.0.0)
- - flutter_inappwebview (0.0.1):
+ - flutter_broadcasts (0.0.1):
+ - Flutter
+ - flutter_inappwebview_ios (0.0.1):
- Flutter
- - flutter_inappwebview/Core (= 0.0.1)
+ - flutter_inappwebview_ios/Core (= 0.0.1)
- OrderedSet (~> 5.0)
- - flutter_inappwebview/Core (0.0.1):
+ - flutter_inappwebview_ios/Core (0.0.1):
- Flutter
- OrderedSet (~> 5.0)
- flutter_keyboard_visibility (0.0.1):
@@ -102,11 +107,13 @@ DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
- audio_service (from `.symlinks/plugins/audio_service/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
+ - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`)
- - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
+ - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
+ - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
@@ -142,6 +149,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/audio_service/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
+ bonsoir_darwin:
+ :path: ".symlinks/plugins/bonsoir_darwin/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
@@ -150,8 +159,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/file_selector_ios/ios"
Flutter:
:path: Flutter
- flutter_inappwebview:
- :path: ".symlinks/plugins/flutter_inappwebview/ios"
+ flutter_broadcasts:
+ :path: ".symlinks/plugins/flutter_broadcasts/ios"
+ flutter_inappwebview_ios:
+ :path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_mailer:
@@ -191,13 +202,15 @@ SPEC CHECKSUMS:
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
audio_session: 4f3e461722055d21515cf3261b64c973c062f345
- device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
+ bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
+ device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
- flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62
+ flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
+ flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
@@ -221,6 +234,6 @@ SPEC CHECKSUMS:
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
-PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd
+PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
COCOAPODS: 1.15.2
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 8e103cfa2..ffd511a4b 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -66,5 +66,11 @@
UIViewControllerBasedStatusBarAppearance
+ NSLocalNetworkUsageDescription
+ To allow other devices on the network control playback of Spotube securely.
+ NSBonjourServices
+
+ _spotube._tcp
+
\ No newline at end of file
diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart
index 8428aaf3b..800674056 100644
--- a/lib/collections/routes.dart
+++ b/lib/collections/routes.dart
@@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/pages/album/album.dart';
+import 'package:spotube/pages/connect/connect.dart';
+import 'package:spotube/pages/connect/control/control.dart';
import 'package:spotube/pages/getting_started/getting_started.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart';
import 'package:spotube/pages/home/genres/genres.dart';
@@ -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(
diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart
index 98c8ad450..6de212840 100644
--- a/lib/collections/spotube_icons.dart
+++ b/lib/collections/spotube_icons.dart
@@ -116,4 +116,9 @@ abstract class SpotubeIcons {
static const openCollective = SimpleIcons.opencollective;
static const anonymous = FeatherIcons.user;
static const history = FeatherIcons.clock;
+ static const connect = FeatherIcons.link;
+ static const speaker = FeatherIcons.speaker;
+ static const monitor = FeatherIcons.monitor;
+ static const power = FeatherIcons.power;
+ static const bluetooth = FeatherIcons.bluetooth;
}
diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart
index 083c19498..678bfd06a 100644
--- a/lib/components/album/album_card.dart
+++ b/lib/components/album/album_card.dart
@@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
+import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/track.dart';
+import 'package:spotube/models/connect/connect.dart';
+import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return;
- await playlistNotifier.load(fetchedTracks, autoPlay: true);
- playlistNotifier.addCollection(album.id!);
+ final isRemoteDevice = await showSelectDeviceDialog(context, ref);
+ if (isRemoteDevice) {
+ final remotePlayback = ref.read(connectProvider.notifier);
+ await remotePlayback.load(
+ WebSocketLoadEventData(
+ tracks: fetchedTracks,
+ collectionId: album.id!,
+ ),
+ );
+ } else {
+ await playlistNotifier.load(fetchedTracks, autoPlay: true);
+ playlistNotifier.addCollection(album.id!);
+ }
} finally {
updating.value = false;
}
diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart
new file mode 100644
index 000000000..8ece074f6
--- /dev/null
+++ b/lib/components/connect/connect_device.dart
@@ -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");
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/components/connect/local_devices.dart b/lib/components/connect/local_devices.dart
new file mode 100644
index 000000000..dd7db9713
--- /dev/null
+++ b/lib/components/connect/local_devices.dart
@@ -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),
+ ),
+ );
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart
index e20985700..778558f60 100644
--- a/lib/components/library/user_local_tracks.dart
+++ b/lib/components/library/user_local_tracks.dart
@@ -283,12 +283,17 @@ class UserLocalTracks extends HookConsumerWidget {
trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) {
if (trackSnapshot.isLoading) {
- return TrackTile(track: FakeData.track, index: index);
+ return TrackTile(
+ playlist: playlist,
+ track: FakeData.track,
+ index: index,
+ );
}
final track = filteredTracks[index];
return TrackTile(
index: index,
+ playlist: playlist,
track: track,
userPlaylist: false,
onTap: () async {
@@ -311,8 +316,11 @@ class UserLocalTracks extends HookConsumerWidget {
enabled: true,
child: ListView.builder(
itemCount: 5,
- itemBuilder: (context, index) =>
- TrackTile(track: FakeData.track, index: index),
+ itemBuilder: (context, index) => TrackTile(
+ track: FakeData.track,
+ index: index,
+ playlist: playlist,
+ ),
),
),
),
diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart
index 5559be732..6dbd9b11f 100644
--- a/lib/components/player/player.dart
+++ b/lib/components/player/player.dart
@@ -26,6 +26,7 @@ import 'package:spotube/models/local_track.dart';
import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/provider/authentication_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:url_launcher/url_launcher_string.dart';
@@ -46,9 +47,7 @@ class PlayerView extends HookConsumerWidget {
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
(value) => value.activeTrack,
));
- final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
- (value) => value.activeTrack is LocalTrack,
- ));
+ final isLocalTrack = currentTrack is LocalTrack;
final mediaQuery = MediaQuery.of(context);
useEffect(() {
@@ -240,7 +239,7 @@ class PlayerView extends HookConsumerWidget {
),
if (isLocalTrack)
Text(
- currentTrack?.artists?.asString() ?? "",
+ currentTrack.artists?.asString() ?? "",
style: theme.textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.bold,
color: bodyTextColor,
@@ -304,10 +303,25 @@ class PlayerView extends HookConsumerWidget {
.height *
.7,
),
- builder: (context) {
- return const PlayerQueue(
- floating: false);
- },
+ builder: (context) => Consumer(
+ builder: (context, ref, _) {
+ final playlist = ref.watch(
+ ProxyPlaylistNotifier
+ .provider,
+ );
+ final playlistNotifier =
+ ref.read(
+ ProxyPlaylistNotifier
+ .notifier,
+ );
+ return PlayerQueue
+ .fromProxyPlaylistNotifier(
+ floating: false,
+ playlist: playlist,
+ notifier: playlistNotifier,
+ );
+ },
+ ),
);
}
: null),
@@ -365,11 +379,21 @@ class PlayerView extends HookConsumerWidget {
enabledThumbRadius: 8,
),
),
- child: const Padding(
- padding: EdgeInsets.symmetric(horizontal: 16),
- child: VolumeSlider(
- fullWidth: true,
- ),
+ child: Padding(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 16),
+ child: Consumer(builder: (context, ref, _) {
+ final volume = ref.watch(volumeProvider);
+ return VolumeSlider(
+ fullWidth: true,
+ value: volume,
+ onChanged: (value) {
+ ref
+ .read(volumeProvider.notifier)
+ .setVolume(value);
+ },
+ );
+ }),
),
),
],
diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart
index 02cbfff5b..0190e2e69 100644
--- a/lib/components/player/player_controls.dart
+++ b/lib/components/player/player_controls.dart
@@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
onPressed: playlist.isFetching == true
? null
: () async {
- switch (audioPlayer.loopMode) {
- case PlaybackLoopMode.all:
- audioPlayer
- .setLoopMode(PlaybackLoopMode.one);
- break;
- case PlaybackLoopMode.one:
- audioPlayer
- .setLoopMode(PlaybackLoopMode.none);
- break;
- case PlaybackLoopMode.none:
- audioPlayer
- .setLoopMode(PlaybackLoopMode.all);
- break;
- }
+ audioPlayer.setLoopMode(
+ switch (loopMode) {
+ PlaybackLoopMode.all =>
+ PlaybackLoopMode.one,
+ PlaybackLoopMode.one =>
+ PlaybackLoopMode.none,
+ PlaybackLoopMode.none =>
+ PlaybackLoopMode.all,
+ },
+ );
},
);
}),
diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart
index 1ad91a524..e2ca96749 100644
--- a/lib/components/player/player_overlay.dart
+++ b/lib/components/player/player_overlay.dart
@@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget {
width: double.infinity,
color: Colors.transparent,
child: PlayerTrackDetails(
- albumArt: albumArt,
+ track: playlist.activeTrack,
color: textColor,
),
),
diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart
index 7641fad50..0bf61da46 100644
--- a/lib/components/player/player_queue.dart
+++ b/lib/components/player/player_queue.dart
@@ -3,15 +3,13 @@ import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import 'package:flutter/widgets.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
-import 'package:sliver_tools/sliver_tools.dart';
-import 'package:spotube/collections/fake.dart';
+import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/not_found.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/context.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';
class PlayerQueue extends HookConsumerWidget {
final bool floating;
+ final ProxyPlaylist playlist;
+
+ final Future Function(Track track) onJump;
+ final Future Function(String trackId) onRemove;
+ final Future Function(int oldIndex, int newIndex) onReorder;
+ final Future Function() onStop;
+
const PlayerQueue({
this.floating = true,
+ required this.playlist,
+ required this.onJump,
+ required this.onRemove,
+ required this.onReorder,
+ required this.onStop,
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
Widget build(BuildContext context, ref) {
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
+ final mediaQuery = MediaQuery.of(context);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
+ final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final controller = useAutoScrollController();
final searchText = useState('');
@@ -48,7 +70,6 @@ class PlayerQueue extends HookConsumerWidget {
topRight: Radius.circular(10),
);
final theme = Theme.of(context);
- final mediaQuery = MediaQuery.of(context);
final headlineColor = theme.textTheme.headlineSmall?.color;
final filteredTracks = useMemoized(
@@ -87,198 +108,204 @@ class PlayerQueue extends HookConsumerWidget {
return const NotFound(vertical: true);
}
- return ClipRRect(
- borderRadius: borderRadius,
- clipBehavior: Clip.hardEdge,
- child: BackdropFilter(
- filter: ImageFilter.blur(
- sigmaX: 15,
- sigmaY: 15,
- ),
- child: Container(
- padding: const EdgeInsets.only(
- top: 5.0,
- ),
- decoration: BoxDecoration(
- color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
- borderRadius: borderRadius,
- ),
- child: CallbackShortcuts(
- bindings: {
- LogicalKeySet(LogicalKeyboardKey.escape): () {
- if (!isSearching.value) {
- Navigator.of(context).pop();
- }
- isSearching.value = false;
- searchText.value = '';
- }
- },
- child: InterScrollbar(
- controller: controller,
- child: CustomScrollView(
- controller: controller,
- slivers: [
- if (!floating)
- SliverToBoxAdapter(
- child: Center(
- child: Container(
- height: 5,
- width: 100,
- margin: const EdgeInsets.only(bottom: 5, top: 2),
- 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,
+ return LayoutBuilder(
+ builder: (context, constrains) {
+ return ClipRRect(
+ borderRadius: borderRadius,
+ clipBehavior: Clip.hardEdge,
+ child: BackdropFilter(
+ filter: ImageFilter.blur(
+ sigmaX: 15,
+ sigmaY: 15,
+ ),
+ child: Container(
+ padding: const EdgeInsets.only(
+ top: 5.0,
+ ),
+ decoration: BoxDecoration(
+ color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
+ borderRadius: borderRadius,
+ ),
+ child: CallbackShortcuts(
+ bindings: {
+ LogicalKeySet(LogicalKeyboardKey.escape): () {
+ if (!isSearching.value) {
+ Navigator.of(context).pop();
+ }
+ isSearching.value = false;
+ searchText.value = '';
+ }
+ },
+ child: InterScrollbar(
+ controller: controller,
+ child: CustomScrollView(
+ controller: controller,
+ slivers: [
+ if (!floating)
+ SliverToBoxAdapter(
+ child: Center(
+ child: Container(
+ height: 5,
+ width: 100,
+ margin: const EdgeInsets.only(bottom: 5, top: 2),
+ decoration: BoxDecoration(
+ color: headlineColor,
+ borderRadius: BorderRadius.circular(20),
+ ),
),
),
- )
- 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,
+ 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: Row(
- children: [
- const Icon(SpotubeIcons.playlistRemove),
- const SizedBox(width: 5),
- Text(context.l10n.clear_all),
- ],
+ 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,
),
- 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(i),
- controller: controller,
- index: i,
- child: Material(
- color: Colors.transparent,
- child: TrackTile(
+ 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(i),
+ controller: controller,
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,
+ 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(100),
- ],
+ ),
),
),
),
- ),
- ),
+ );
+ },
);
}
}
diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart
index 95fecdc2e..65e40fe64 100644
--- a/lib/components/player/player_track_details.dart
+++ b/lib/components/player/player_track_details.dart
@@ -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/extensions/artist_simple.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/utils/service_utils.dart';
class PlayerTrackDetails extends HookConsumerWidget {
- final String? albumArt;
final Color? color;
- const PlayerTrackDetails({super.key, this.albumArt, this.color});
+ final Track? track;
+ const PlayerTrackDetails({super.key, this.color, this.track});
@override
Widget build(BuildContext context, ref) {
@@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
- path: albumArt ?? "",
+ path: (track?.album?.images)
+ .asUrlString(placeholder: ImagePlaceholder.albumArt),
placeholder: Assets.albumPlaceholder.path,
),
),
diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart
index 7596a3473..102bbef6e 100644
--- a/lib/components/player/volume_slider.dart
+++ b/lib/components/player/volume_slider.dart
@@ -3,37 +3,39 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
-import 'package:spotube/provider/volume_provider.dart';
class VolumeSlider extends HookConsumerWidget {
final bool fullWidth;
+
+ final double value;
+ final ValueChanged onChanged;
+
const VolumeSlider({
super.key,
this.fullWidth = false,
+ required this.value,
+ required this.onChanged,
});
@override
Widget build(BuildContext context, ref) {
- final volume = ref.watch(volumeProvider);
- final volumeNotifier = ref.watch(volumeProvider.notifier);
-
var slider = Listener(
onPointerSignal: (event) async {
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) {
- final value = volume - .2;
- volumeNotifier.setVolume(value < 0 ? 0 : value);
+ final newValue = value - .2;
+ onChanged(newValue < 0 ? 0 : newValue);
} else {
- final value = volume + .2;
- volumeNotifier.setVolume(value > 1 ? 1 : value);
+ final newValue = value + .2;
+ onChanged(newValue > 1 ? 1 : newValue);
}
}
},
child: Slider(
min: 0,
max: 1,
- value: volume,
- onChanged: volumeNotifier.setVolume,
+ value: value,
+ onChanged: onChanged,
),
);
return Row(
@@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget {
children: [
IconButton(
icon: Icon(
- volume == 0
+ value == 0
? SpotubeIcons.volumeMute
- : volume <= 0.2
+ : value <= 0.2
? SpotubeIcons.volumeLow
- : volume <= 0.6
+ : value <= 0.6
? SpotubeIcons.volumeMedium
: SpotubeIcons.volumeHigh,
size: 16,
),
onPressed: () {
- if (volume == 0) {
- volumeNotifier.setVolume(1);
+ if (value == 0) {
+ onChanged(1);
} else {
- volumeNotifier.setVolume(0);
+ onChanged(0);
}
},
),
diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart
index 8915e97ae..e5b87d6d7 100644
--- a/lib/components/playlist/playlist_card.dart
+++ b/lib/components/playlist/playlist_card.dart
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
+import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/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/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@@ -71,8 +74,19 @@ class PlaylistCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return;
- await playlistNotifier.load(fetchedTracks, autoPlay: true);
- playlistNotifier.addCollection(playlist.id!);
+ final isRemoteDevice = await showSelectDeviceDialog(context, ref);
+ 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 {
if (context.mounted) {
updating.value = false;
diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart
index 16633f7c2..19fa7c93c 100644
--- a/lib/components/root/bottom_player.dart
+++ b/lib/components/root/bottom_player.dart
@@ -19,9 +19,11 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/models/logger.dart';
import 'package:flutter/material.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/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
+import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/utils/platform.dart';
class BottomPlayer extends HookConsumerWidget {
@@ -34,6 +36,7 @@ class BottomPlayer extends HookConsumerWidget {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
+ final remoteControl = ref.watch(connectProvider);
final mediaQuery = MediaQuery.of(context);
@@ -73,7 +76,9 @@ class BottomPlayer extends HookConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
+ Expanded(
+ child: PlayerTrackDetails(track: playlist.activeTrack),
+ ),
// controls
Flexible(
flex: 3,
@@ -121,10 +126,20 @@ class BottomPlayer extends HookConsumerWidget {
Container(
height: 40,
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);
+ },
+ );
+ }),
)
],
- )
+ ),
],
),
),
diff --git a/lib/components/shared/dialogs/select_device_dialog.dart b/lib/components/shared/dialogs/select_device_dialog.dart
new file mode 100644
index 000000000..cd8dedb7c
--- /dev/null
+++ b/lib/components/shared/dialogs/select_device_dialog.dart
@@ -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 showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
+ final connectClients = ref.read(connectClientsProvider);
+
+ if (connectClients.asData?.value.resolvedService == null) {
+ return false;
+ }
+
+ final isRemote = await showDialog(
+ context: context,
+ builder: (context) => const SelectDeviceDialog(),
+ );
+
+ return isRemote ?? false;
+}
diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart
index ff40bac7a..f956fa284 100644
--- a/lib/components/shared/page_window_title_bar.dart
+++ b/lib/components/shared/page_window_title_bar.dart
@@ -26,6 +26,8 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
final double? titleWidth;
final Widget? title;
+ final bool _sliver;
+
const PageWindowTitleBar({
super.key,
this.actions,
@@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
this.titleTextStyle,
this.titleWidth,
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
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState {
Widget build(BuildContext 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) {
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
final hasLeadingOrCanPop =
@@ -349,10 +424,7 @@ class WindowButton extends StatelessWidget {
class MinimizeWindowButton extends WindowButton {
MinimizeWindowButton(
- {super.key,
- super.colors,
- super.onPressed,
- bool? animate})
+ {super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
@@ -362,10 +434,7 @@ class MinimizeWindowButton extends WindowButton {
class MaximizeWindowButton extends WindowButton {
MaximizeWindowButton(
- {super.key,
- super.colors,
- super.onPressed,
- bool? animate})
+ {super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
@@ -374,11 +443,7 @@ class MaximizeWindowButton extends WindowButton {
}
class RestoreWindowButton extends WindowButton {
- RestoreWindowButton(
- {super.key,
- super.colors,
- super.onPressed,
- bool? animate})
+ RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
@@ -394,10 +459,7 @@ final _defaultCloseButtonColors = WindowButtonColors(
class CloseWindowButton extends WindowButton {
CloseWindowButton(
- {super.key,
- WindowButtonColors? colors,
- super.onPressed,
- bool? animate})
+ {super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
: super(
colors: colors ?? _defaultCloseButtonColors,
animate: animate ?? false,
diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart
index 897abdae7..61061d241 100644
--- a/lib/components/shared/track_tile/track_tile.dart
+++ b/lib/components/shared/track_tile/track_tile.dart
@@ -18,7 +18,7 @@ import 'package:spotube/extensions/duration.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/local_track.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 {
/// [index] will not be shown if null
@@ -30,6 +30,7 @@ class TrackTile extends HookConsumerWidget {
final VoidCallback? onLongPress;
final bool userPlaylist;
final String? playlistId;
+ final ProxyPlaylist playlist;
final List? leadingActions;
@@ -38,6 +39,7 @@ class TrackTile extends HookConsumerWidget {
this.index,
required this.track,
this.selected = false,
+ required this.playlist,
this.onTap,
this.onLongPress,
this.onChanged,
@@ -48,7 +50,6 @@ class TrackTile extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
- final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final theme = Theme.of(context);
final blacklist = ref.watch(BlackListNotifier.provider);
@@ -65,10 +66,10 @@ class TrackTile extends HookConsumerWidget {
final showOptionCbRef = useRef?>(null);
- final isPlaying = track.id == playlist.activeTrack?.id;
-
final isLoading = useState(false);
+ final isPlaying = playlist.activeTrack?.id == track.id;
+
final isSelected = isPlaying || isLoading.value;
return LayoutBuilder(builder: (context, constrains) {
diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart
index 661e5af4a..803684451 100644
--- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart
+++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart
@@ -8,12 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.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/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/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_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/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget {
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: TrackTile(
+ playlist: playlist,
track: FakeData.track,
index: 0,
),
@@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget {
child: Column(
children: List.generate(
10,
- (index) => TrackTile(track: FakeData.track, index: index),
+ (index) => TrackTile(
+ track: FakeData.track,
+ index: index,
+ playlist: playlist,
+ ),
),
),
),
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(
+ playlist: playlist,
track: track,
index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!),
@@ -125,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget {
return;
}
- if (isActive || playlist.tracks.contains(track)) {
- await playlistNotifier.jumpToTrack(track);
+ final isRemoteDevice =
+ 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 {
- final tracks = await props.pagination.onFetchAll();
- await playlistNotifier.load(
- tracks,
- initialIndex: index,
- autoPlay: true,
- );
- playlistNotifier.addCollection(props.collectionId);
+ if (isActive || playlist.tracks.contains(track)) {
+ await playlistNotifier.jumpToTrack(track);
+ } else {
+ final tracks = await props.pagination.onFetchAll();
+ await playlistNotifier.load(
+ tracks,
+ initialIndex: index,
+ autoPlay: true,
+ );
+ playlistNotifier.addCollection(props.collectionId);
+ }
}
},
);
diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart
index 513f7aaa3..f505f7652 100644
--- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart
+++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart
@@ -6,8 +6,11 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.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/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/services/audio_player/audio_player.dart';
@@ -43,13 +46,25 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final allTracks = await props.pagination.onFetchAll();
- await playlistNotifier.load(
- allTracks,
- autoPlay: true,
- initialIndex: Random().nextInt(allTracks.length),
- );
- await audioPlayer.setShuffle(true);
- playlistNotifier.addCollection(props.collectionId);
+ final isRemoteDevice = await showSelectDeviceDialog(context, ref);
+ if (isRemoteDevice) {
+ final remotePlayback = ref.read(connectProvider.notifier);
+ await remotePlayback.load(
+ WebSocketLoadEventData(
+ tracks: allTracks,
+ 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 {
isLoading.value = false;
}
@@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
final allTracks = await props.pagination.onFetchAll();
- await playlistNotifier.load(allTracks, autoPlay: true);
- playlistNotifier.addCollection(props.collectionId);
+ final isRemoteDevice = await showSelectDeviceDialog(context, ref);
+ 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 {
isLoading.value = false;
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 8257eac92..832862c01 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -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.",
"contribute_on_github": "Contribute on GitHub",
"donate_on_open_collective": "Donate on Open Collective",
- "browse_anonymously": "Browse Anonymously"
+ "browse_anonymously": "Browse Anonymously",
+ "enable_connect": "Enable Connect",
+ "enable_connect_description": "Control Spotube from other devices",
+ "devices": "Devices",
+ "select": "Select",
+ "connect_client_alert": "You're being controlled by {client}",
+ "this_device": "This Device",
+ "remote": "Remote"
}
\ No newline at end of file
diff --git a/lib/main.dart b/lib/main.dart
index 5c100fd33..2a2d8d186 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -23,6 +23,9 @@ import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/models/source_match.dart';
+import 'package:spotube/provider/connect/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/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@@ -180,6 +183,9 @@ class SpotubeState extends ConsumerState {
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
final router = ref.watch(routerProvider);
+ ref.listen(connectServerProvider, (_, __) {});
+ ref.listen(connectClientsProvider, (_, __) {});
+
useDisableBatteryOptimizations();
useInitSysTray(ref);
useDeepLinking(ref);
diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart
new file mode 100644
index 000000000..efb373150
--- /dev/null
+++ b/lib/models/connect/connect.dart
@@ -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';
diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart
new file mode 100644
index 000000000..dcbd783dc
--- /dev/null
+++ b/lib/models/connect/connect.freezed.dart
@@ -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 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 json) {
+ return _WebSocketLoadEventData.fromJson(json);
+}
+
+/// @nodoc
+mixin _$WebSocketLoadEventData {
+ @JsonKey(name: 'tracks', toJson: _tracksJson)
+ List