From 68374efd3ec556f31b937e5b96920787b54eec78 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 4 Apr 2024 22:22:00 +0600 Subject: [PATCH] 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 --- .github/workflows/spotube-release-binary.yml | 4 +- CONTRIBUTION.md | 8 +- ios/Podfile | 2 +- ios/Podfile.lock | 31 +- ios/Runner/Info.plist | 6 + lib/collections/routes.dart | 17 + lib/collections/spotube_icons.dart | 5 + lib/components/album/album_card.dart | 18 +- lib/components/connect/connect_device.dart | 85 ++++ lib/components/connect/local_devices.dart | 60 +++ lib/components/library/user_local_tracks.dart | 14 +- lib/components/player/player.dart | 50 ++- lib/components/player/player_controls.dart | 24 +- lib/components/player/player_overlay.dart | 2 +- lib/components/player/player_queue.dart | 397 ++++++++++-------- .../player/player_track_details.dart | 8 +- lib/components/player/volume_slider.dart | 34 +- lib/components/playlist/playlist_card.dart | 18 +- lib/components/root/bottom_player.dart | 21 +- .../shared/dialogs/select_device_dialog.dart | 70 +++ .../shared/page_window_title_bar.dart | 98 ++++- .../shared/track_tile/track_tile.dart | 9 +- .../sections/body/track_view_body.dart | 50 ++- .../sections/header/header_buttons.dart | 44 +- lib/l10n/app_en.arb | 9 +- lib/main.dart | 6 + lib/models/connect/connect.dart | 16 + lib/models/connect/connect.freezed.dart | 216 ++++++++++ lib/models/connect/connect.g.dart | 25 ++ lib/models/connect/load.dart | 27 ++ lib/models/connect/ws_event.dart | 374 +++++++++++++++++ lib/pages/artist/section/top_tracks.dart | 49 ++- lib/pages/connect/connect.dart | 93 ++++ lib/pages/connect/control/control.dart | 317 ++++++++++++++ lib/pages/home/home.dart | 20 +- lib/pages/lyrics/mini_lyrics.dart | 13 +- lib/pages/mobile_login/mobile_login.dart | 18 +- lib/pages/root/root_app.dart | 103 +++-- lib/pages/search/sections/tracks.dart | 73 +++- lib/pages/settings/sections/playback.dart | 7 + lib/provider/connect/clients.dart | 111 +++++ lib/provider/connect/connect.dart | 184 ++++++++ lib/provider/connect/server.dart | 261 ++++++++++++ .../proxy_playlist/proxy_playlist.dart | 14 +- .../user_preferences_provider.dart | 4 + .../user_preferences_state.dart | 1 + .../user_preferences_state.freezed.dart | 37 +- .../user_preferences_state.g.dart | 2 + lib/services/audio_player/audio_player.dart | 11 +- .../audio_player/audio_player_impl.dart | 4 + .../audio_players_streams_mixin.dart | 6 + lib/services/device_info/device_info.dart | 34 ++ linux/packaging/deb/make_config.yaml | 5 + linux/packaging/rpm/make_config.yaml | 3 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + macos/Podfile | 2 +- macos/Podfile.lock | 19 +- macos/Runner.xcodeproj/project.pbxproj | 6 +- pubspec.lock | 134 +++++- pubspec.yaml | 9 +- untranslated_messages.json | 199 ++++++++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 63 files changed, 3089 insertions(+), 406 deletions(-) create mode 100644 lib/components/connect/connect_device.dart create mode 100644 lib/components/connect/local_devices.dart create mode 100644 lib/components/shared/dialogs/select_device_dialog.dart create mode 100644 lib/models/connect/connect.dart create mode 100644 lib/models/connect/connect.freezed.dart create mode 100644 lib/models/connect/connect.g.dart create mode 100644 lib/models/connect/load.dart create mode 100644 lib/models/connect/ws_event.dart create mode 100644 lib/pages/connect/connect.dart create mode 100644 lib/pages/connect/control/control.dart create mode 100644 lib/provider/connect/clients.dart create mode 100644 lib/provider/connect/connect.dart create mode 100644 lib/provider/connect/server.dart create mode 100644 lib/services/device_info/device_info.dart 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 get tracks => throw _privateConstructorUsedError; + String? get collectionId => throw _privateConstructorUsedError; + int? get initialIndex => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $WebSocketLoadEventDataCopyWith 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 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, + 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 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, + 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 tracks, + this.collectionId, + this.initialIndex}) + : _tracks = tracks; + + factory _$WebSocketLoadEventDataImpl.fromJson(Map json) => + _$$WebSocketLoadEventDataImplFromJson(json); + + final List _tracks; + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List 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 toJson() { + return _$$WebSocketLoadEventDataImplToJson( + this, + ); + } +} + +abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { + factory _WebSocketLoadEventData( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final String? collectionId, + final int? initialIndex}) = _$WebSocketLoadEventDataImpl; + + factory _WebSocketLoadEventData.fromJson(Map json) = + _$WebSocketLoadEventDataImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + String? get collectionId; + @override + int? get initialIndex; + @override + @JsonKey(ignore: true) + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart new file mode 100644 index 000000000..f636e0350 --- /dev/null +++ b/lib/models/connect/connect.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connect.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( + Map json) => + _$WebSocketLoadEventDataImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(e as Map)) + .toList(), + collectionId: json['collectionId'] as String?, + initialIndex: json['initialIndex'] as int?, + ); + +Map _$$WebSocketLoadEventDataImplToJson( + _$WebSocketLoadEventDataImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collectionId': instance.collectionId, + 'initialIndex': instance.initialIndex, + }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart new file mode 100644 index 000000000..d750cddd2 --- /dev/null +++ b/lib/models/connect/load.dart @@ -0,0 +1,27 @@ +part of 'connect.dart'; + +List> _tracksJson(List tracks) { + return tracks.map((e) => e.toJson()).toList(); +} + +@freezed +class WebSocketLoadEventData with _$WebSocketLoadEventData { + factory WebSocketLoadEventData({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + String? collectionId, + int? initialIndex, + }) = _WebSocketLoadEventData; + + factory WebSocketLoadEventData.fromJson(Map json) => + _$WebSocketLoadEventDataFromJson(json); +} + +class WebSocketLoadEvent extends WebSocketEvent { + WebSocketLoadEvent(WebSocketLoadEventData data) : super(WsEvent.load, data); + + factory WebSocketLoadEvent.fromJson(Map json) { + return WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(json['data'] as Map), + ); + } +} diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart new file mode 100644 index 000000000..2d7213b1b --- /dev/null +++ b/lib/models/connect/ws_event.dart @@ -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 = FutureOr Function(T event); + +class WebSocketEvent { + final WsEvent type; + final T data; + + WebSocketEvent(this.type, this.data); + + factory WebSocketEvent.fromJson( + Map json, + T Function(dynamic) fromJson, + ) { + return WebSocketEvent( + WsEvent.fromString(json["type"]), + fromJson(json["data"]), + ); + } + + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data, + }); + } + + Future onPosition( + EventCallback callback, + ) async { + if (type == WsEvent.position) { + await callback(WebSocketPositionEvent.fromJson({"data": data})); + } + } + + Future onPlaying( + EventCallback callback, + ) async { + if (type == WsEvent.playing) { + await callback(WebSocketPlayingEvent(data as bool)); + } + } + + Future onResume( + EventCallback callback, + ) async { + if (type == WsEvent.resume) { + await callback(WebSocketResumeEvent()); + } + } + + Future onPause( + EventCallback callback, + ) async { + if (type == WsEvent.pause) { + await callback(WebSocketPauseEvent()); + } + } + + Future onStop( + EventCallback callback, + ) async { + if (type == WsEvent.stop) { + await callback(WebSocketStopEvent()); + } + } + + Future onLoad( + EventCallback callback, + ) async { + if (type == WsEvent.load) { + await callback( + WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(data as Map), + ), + ); + } + } + + Future onNext( + EventCallback callback, + ) async { + if (type == WsEvent.next) { + await callback(WebSocketNextEvent()); + } + } + + Future onPrevious( + EventCallback callback, + ) async { + if (type == WsEvent.previous) { + await callback(WebSocketPreviousEvent()); + } + } + + Future onJump( + EventCallback callback, + ) async { + if (type == WsEvent.jump) { + await callback(WebSocketJumpEvent(data as int)); + } + } + + Future onError( + EventCallback callback, + ) async { + if (type == WsEvent.error) { + await callback(WebSocketErrorEvent(data as String)); + } + } + + Future onQueue( + EventCallback callback, + ) async { + if (type == WsEvent.queue) { + await callback( + WebSocketQueueEvent.fromJson(data as Map), + ); + } + } + + Future onDuration( + EventCallback callback, + ) async { + if (type == WsEvent.duration) { + await callback( + WebSocketDurationEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onSeek( + EventCallback callback, + ) async { + if (type == WsEvent.seek) { + await callback( + WebSocketSeekEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onShuffle( + EventCallback callback, + ) async { + if (type == WsEvent.shuffle) { + await callback(WebSocketShuffleEvent(data as bool)); + } + } + + Future onLoop( + EventCallback callback, + ) async { + if (type == WsEvent.loop) { + await callback( + WebSocketLoopEvent( + PlaybackLoopMode.fromString(data as String), + ), + ); + } + } + + Future onRemoveTrack( + EventCallback callback, + ) async { + if (type == WsEvent.removeTrack) { + await callback(WebSocketRemoveTrackEvent(data as String)); + } + } + + Future onAddTrack( + EventCallback callback, + ) async { + if (type == WsEvent.addTrack) { + await callback( + WebSocketAddTrackEvent.fromJson(data as Map)); + } + } + + Future onReorder( + EventCallback callback, + ) async { + if (type == WsEvent.reorder) { + await callback( + WebSocketReorderEvent.fromJson(data as Map)); + } + } + + Future onVolume( + EventCallback callback, + ) async { + if (type == WsEvent.volume) { + await callback(WebSocketVolumeEvent(data as double)); + } + } +} + +class WebSocketLoopEvent extends WebSocketEvent { + WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data); + + WebSocketLoopEvent.fromJson(Map 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 { + WebSocketPositionEvent(Duration data) : super(WsEvent.position, data); + + WebSocketPositionEvent.fromJson(Map 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 { + WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data); + + WebSocketDurationEvent.fromJson(Map 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 { + WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data); + + WebSocketSeekEvent.fromJson(Map 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 { + WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data); +} + +class WebSocketPlayingEvent extends WebSocketEvent { + WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data); +} + +class WebSocketResumeEvent extends WebSocketEvent { + WebSocketResumeEvent() : super(WsEvent.resume, null); +} + +class WebSocketPauseEvent extends WebSocketEvent { + WebSocketPauseEvent() : super(WsEvent.pause, null); +} + +class WebSocketStopEvent extends WebSocketEvent { + WebSocketStopEvent() : super(WsEvent.stop, null); +} + +class WebSocketNextEvent extends WebSocketEvent { + WebSocketNextEvent() : super(WsEvent.next, null); +} + +class WebSocketPreviousEvent extends WebSocketEvent { + WebSocketPreviousEvent() : super(WsEvent.previous, null); +} + +class WebSocketJumpEvent extends WebSocketEvent { + WebSocketJumpEvent(int data) : super(WsEvent.jump, data); +} + +class WebSocketErrorEvent extends WebSocketEvent { + WebSocketErrorEvent(String data) : super(WsEvent.error, data); +} + +class WebSocketQueueEvent extends WebSocketEvent { + WebSocketQueueEvent(ProxyPlaylist data) : super(WsEvent.queue, data); + + factory WebSocketQueueEvent.fromJson(Map json) => + WebSocketQueueEvent( + ProxyPlaylist.fromJsonRaw(json), + ); +} + +class WebSocketRemoveTrackEvent extends WebSocketEvent { + WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data); +} + +class WebSocketAddTrackEvent extends WebSocketEvent { + WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data); + + WebSocketAddTrackEvent.fromJson(Map json) + : super( + WsEvent.addTrack, + Track.fromJson(json["data"] as Map), + ); +} + +typedef ReorderData = ({int oldIndex, int newIndex}); + +class WebSocketReorderEvent extends WebSocketEvent { + WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data); + + factory WebSocketReorderEvent.fromJson(Map 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 { + WebSocketVolumeEvent(double data) : super(WsEvent.volume, data); +} diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 173ace541..9dec5f7c4 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -4,8 +4,11 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.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/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/spotify/spotify.dart'; @@ -39,16 +42,41 @@ class ArtistPageTopTracks extends HookConsumerWidget { void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - 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); + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); + + if (!isPlaylistPlaying) { + 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); return TrackTile( index: index, + playlist: playlist, track: track, onTap: () async { playPlaylist( diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart new file mode 100644 index 000000000..170a0c729 --- /dev/null +++ b/lib/pages/connect/connect.dart @@ -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(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart new file mode 100644 index 000000000..162565681 --- /dev/null +++ b/lib/pages/connect/control/control.dart @@ -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, + ), + ] + ], + ); + }), + ), + ); + } +} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index ed297065f..487ceb4cb 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,6 +3,8 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.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/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/friends.dart'; import 'package:spotube/components/home/sections/genres.dart'; @@ -20,15 +22,21 @@ class HomePage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: - DesktopTools.platform.isLinux || DesktopTools.platform.isWindows - ? const PageWindowTitleBar() - : null, body: CustomScrollView( controller: controller, slivers: [ - if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) - const SliverGap(20), + PageWindowTitleBar.sliver( + pinned: DesktopTools.platform.isDesktop, + actions: [ + const ConnectDeviceButton(), + const Gap(10), + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.user), + onPressed: () {}, + ), + const Gap(10), + ], + ), const HomeGenresSection(), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index a617909cd..310df75ce 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget { MediaQuery.of(context).size.height * .7, ), 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), + ); + }); }, ); } diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index d9a309edf..6260e2845 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -27,19 +27,17 @@ class WebViewLogin extends HookConsumerWidget { return Scaffold( body: SafeArea( child: InAppWebView( - initialOptions: InAppWebViewGroupOptions( - crossPlatform: InAppWebViewOptions( - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", - ), + initialSettings: InAppWebViewSettings( + userAgent: + "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( - url: Uri.parse("https://accounts.spotify.com/"), + url: WebUri("https://accounts.spotify.com/"), ), - androidOnPermissionRequest: (controller, origin, resources) async { - return PermissionRequestResponse( - resources: resources, - action: PermissionRequestResponseAction.GRANT, + onPermissionRequest: (controller, permissionRequest) async { + return PermissionResponse( + resources: permissionRequest.resources, + action: PermissionResponseAction.GRANT, ); }, onLoadStop: (controller, action) async { diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index b562adab9..2e079200d 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -16,7 +16,9 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.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/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -53,50 +55,75 @@ class RootApp extends HookConsumerWidget { } }); - final subscription = ConnectionCheckerService - .instance.onConnectivityChanged - .listen((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), - ], + final subscriptions = [ + ConnectionCheckerService.instance.onConnectivityChanged + .listen((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, ), - backgroundColor: theme.colorScheme.primary, - showCloseIcon: true, - width: 350, - ), - ); - } else { + ); + } 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( SnackBar( + backgroundColor: Colors.yellow[600], + behavior: SnackBarBehavior.floating, content: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - SpotubeIcons.noWifi, - color: theme.colorScheme.onError, + const Icon( + SpotubeIcons.error, + color: Colors.black, ), const SizedBox(width: 10), - Text(context.l10n.you_are_offline), + Text( + context.l10n.connect_client_alert(clientOrigin), + style: const TextStyle(color: Colors.black), + ), ], ), - backgroundColor: theme.colorScheme.error, - showCloseIcon: true, - width: 300, ), ); - } - }); + }) + ]; return () { - subscription.cancel(); + for (final subscription in subscriptions) { + subscription.cancel(); + } }; }, []); @@ -191,7 +218,19 @@ class RootApp extends HookConsumerWidget { top: 40, 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, bottomNavigationBar: Column( diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 0fdb50af4..2152cc458 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -3,8 +3,11 @@ import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.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/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/spotify/spotify.dart'; @@ -46,26 +49,60 @@ class SearchTracksSection extends HookConsumerWidget { return TrackTile( index: i, track: track, + playlist: playlist, onTap: () async { - 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; + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + 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, + ); + } } } }, diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index b3f0d897d..e023cc60f 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -227,6 +227,13 @@ class SettingsPlaybackSection extends HookConsumerWidget { value: preferences.endlessPlayback, onChanged: preferencesNotifier.setEndlessPlayback, ), + SwitchListTile( + title: Text(context.l10n.enable_connect), + subtitle: Text(context.l10n.enable_connect_description), + secondary: const Icon(SpotubeIcons.connect), + value: preferences.enableConnect, + onChanged: preferencesNotifier.setEnableConnect, + ), ], ); } diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart new file mode 100644 index 000000000..282c96aa6 --- /dev/null +++ b/lib/provider/connect/clients.dart @@ -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 services; + final ResolvedBonsoirService? resolvedService; + final BonsoirDiscovery discovery; + + ConnectClientsState({ + required this.services, + required this.discovery, + this.resolvedService, + }); + + ConnectClientsState copyWith({ + List? services, + BonsoirDiscovery? discovery, + ResolvedBonsoirService? resolvedService, + }) { + return ConnectClientsState( + services: services ?? this.services, + discovery: discovery ?? this.discovery, + resolvedService: resolvedService ?? this.resolvedService, + ); + } +} + +class ConnectClientsNotifier extends AsyncNotifier { + 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 resolveService(BonsoirService service) async { + if (state.value == null) return; + await service.resolve(state.value!.discovery.serviceResolver); + } + + Future clearResolvedService() async { + if (state.value == null) return; + state = AsyncData( + ConnectClientsState( + services: state.value!.services, + discovery: state.value!.discovery, + ), + ); + } +} + +final connectClientsProvider = + AsyncNotifierProvider( + () => ConnectClientsNotifier(), +); diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart new file mode 100644 index 000000000..65daaf553 --- /dev/null +++ b/lib/provider/connect/connect.dart @@ -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( + (ref) => false, +); + +final positionProvider = StateProvider( + (ref) => Duration.zero, +); + +final durationProvider = StateProvider( + (ref) => Duration.zero, +); + +final shuffleProvider = StateProvider( + (ref) => false, +); + +final loopModeProvider = StateProvider( + (ref) => PlaybackLoopMode.none, +); + +final queueProvider = StateProvider( + (ref) => ProxyPlaylist({}), +); + +final volumeProvider = StateProvider( + (ref) => 1.0, +); + +class ConnectNotifier extends AsyncNotifier { + @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 emit(Object message) async { + if (state.value == null) return; + state.value?.sink.add( + message is String ? message : (message as dynamic).toJson(), + ); + } + + Future resume() async { + emit(WebSocketResumeEvent()); + } + + Future pause() async { + emit(WebSocketPauseEvent()); + } + + Future stop() async { + emit(WebSocketStopEvent()); + } + + Future jumpTo(int position) async { + emit(WebSocketJumpEvent(position)); + } + + Future load(WebSocketLoadEventData data) async { + emit(WebSocketLoadEvent(data)); + } + + Future next() async { + emit(WebSocketNextEvent()); + } + + Future previous() async { + emit(WebSocketPreviousEvent()); + } + + Future seek(Duration position) async { + emit(WebSocketSeekEvent(position)); + } + + Future setShuffle(bool value) async { + emit(WebSocketShuffleEvent(value)); + } + + Future setLoopMode(PlaybackLoopMode value) async { + emit(WebSocketLoopEvent(value)); + } + + Future addTrack(Track data) async { + emit(WebSocketAddTrackEvent(data)); + } + + Future removeTrack(String data) async { + emit(WebSocketRemoveTrackEvent(data)); + } + + Future reorder(ReorderData data) async { + emit(WebSocketReorderEvent(data)); + } + + Future setVolume(double value) async { + emit(WebSocketVolumeEvent(value)); + } +} + +final connectProvider = + AsyncNotifierProvider( + () => ConnectNotifier(), +); diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart new file mode 100644 index 000000000..0469e3f51 --- /dev/null +++ b/lib/provider/connect/server.dart @@ -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.broadcast(); + +Stream 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 = []; + + FutureOr 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; +}); diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index 026b34037..efc818ed2 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -27,6 +27,16 @@ class ProxyPlaylist { ); } + factory ProxyPlaylist.fromJsonRaw(Map json) => ProxyPlaylist( + json['tracks'] == null + ? {} + : (json['tracks'] as List).map((t) => Track.fromJson(t)).toSet(), + json['active'] as int?, + json['collections'] == null + ? {} + : (json['collections'] as List).toSet().cast(), + ); + Track? get activeTrack => active == null || active == -1 ? null : tracks.elementAtOrNull(active!); @@ -62,8 +72,8 @@ class ProxyPlaylist { /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { return switch (track.runtimeType) { - LocalTrack => track.toJson(), - SourcedTrack => track.toJson(), + LocalTrack() => track.toJson(), + SourcedTrack() => track.toJson(), _ => track.toJson(), }; } diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 875f36cc8..42b38746d 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -127,6 +127,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(endlessPlayback: endless); } + void setEnableConnect(bool enable) { + state = state.copyWith(enableConnect: enable); + } + Future _getDefaultDownloadDirectory() async { if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index cf6c0597e..e35c73b5e 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -91,6 +91,7 @@ class UserPreferences with _$UserPreferences { @Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec, @Default(true) bool discordPresence, @Default(true) bool endlessPlayback, + @Default(false) bool enableConnect, }) = _UserPreferences; factory UserPreferences.fromJson(Map json) => _$UserPreferencesFromJson(json); diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index 4d08d1a90..a5b076bb6 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -50,6 +50,7 @@ mixin _$UserPreferences { SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; bool get discordPresence => throw _privateConstructorUsedError; bool get endlessPlayback => throw _privateConstructorUsedError; + bool get enableConnect => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -93,7 +94,8 @@ abstract class $UserPreferencesCopyWith<$Res> { SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -131,6 +133,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_value.copyWith( audioQuality: null == audioQuality @@ -221,6 +224,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } } @@ -263,7 +270,8 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -299,6 +307,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_$UserPreferencesImpl( audioQuality: null == audioQuality @@ -389,6 +398,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -426,7 +439,8 @@ class _$UserPreferencesImpl implements _UserPreferences { this.streamMusicCodec = SourceCodecs.weba, this.downloadMusicCodec = SourceCodecs.m4a, this.discordPresence = true, - this.endlessPlayback = true}); + this.endlessPlayback = true, + this.enableConnect = false}); factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -503,10 +517,13 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final bool endlessPlayback; + @override + @JsonKey() + final bool enableConnect; @override String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)'; + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -556,7 +573,9 @@ class _$UserPreferencesImpl implements _UserPreferences { (identical(other.discordPresence, discordPresence) || other.discordPresence == discordPresence) && (identical(other.endlessPlayback, endlessPlayback) || - other.endlessPlayback == endlessPlayback)); + other.endlessPlayback == endlessPlayback) && + (identical(other.enableConnect, enableConnect) || + other.enableConnect == enableConnect)); } @JsonKey(ignore: true) @@ -584,7 +603,8 @@ class _$UserPreferencesImpl implements _UserPreferences { streamMusicCodec, downloadMusicCodec, discordPresence, - endlessPlayback + endlessPlayback, + enableConnect ]); @JsonKey(ignore: true) @@ -633,7 +653,8 @@ abstract class _UserPreferences implements UserPreferences { final SourceCodecs streamMusicCodec, final SourceCodecs downloadMusicCodec, final bool discordPresence, - final bool endlessPlayback}) = _$UserPreferencesImpl; + final bool endlessPlayback, + final bool enableConnect}) = _$UserPreferencesImpl; factory _UserPreferences.fromJson(Map json) = _$UserPreferencesImpl.fromJson; @@ -691,6 +712,8 @@ abstract class _UserPreferences implements UserPreferences { @override bool get endlessPlayback; @override + bool get enableConnect; + @override @JsonKey(ignore: true) _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index ce4882470..8bdd12cc6 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -59,6 +59,7 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( SourceCodecs.m4a, discordPresence: json['discordPresence'] as bool? ?? true, endlessPlayback: json['endlessPlayback'] as bool? ?? true, + enableConnect: json['enableConnect'] as bool? ?? false, ); Map _$$UserPreferencesImplToJson( @@ -87,6 +88,7 @@ Map _$$UserPreferencesImplToJson( 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, 'discordPresence': instance.discordPresence, 'endlessPlayback': instance.endlessPlayback, + 'enableConnect': instance.enableConnect, }; const _$SourceQualitiesEnumMap = { diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index b39579645..0a22bec1a 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,4 +1,5 @@ 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:just_audio/just_audio.dart' as ja; import 'dart:async'; @@ -14,7 +15,7 @@ part 'audio_player_impl.dart'; abstract class AudioPlayerInterface { final MkPlayerWithState _mkPlayer; - // final ja.AudioPlayer? _justAudio; + // final ja.AudioPlayer? _justAudxio; AudioPlayerInterface() : _mkPlayer = MkPlayerWithState( @@ -60,6 +61,14 @@ abstract class AudioPlayerInterface { } } + Future get selectedDevice async { + return _mkPlayer.state.audioDevice; + } + + Future> get devices async { + return _mkPlayer.state.audioDevices; + } + bool get hasSource { return _mkPlayer.playlist.medias.isNotEmpty; // if (mkSupportedPlatform) { diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 2af94dd75..bfa132207 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -83,6 +83,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // await _justAudio?.setSpeed(speed); } + Future setAudioDevice(AudioDevice device) async { + await _mkPlayer.setAudioDevice(device); + } + Future dispose() async { await _mkPlayer.dispose(); // await _justAudio?.dispose(); diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index a736dc1c8..f05ba5efe 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -140,4 +140,10 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // .cast(); // } } + + Stream> get devicesStream => + _mkPlayer.stream.audioDevices.asBroadcastStream(); + + Stream get selectedDeviceStream => + _mkPlayer.stream.audioDevice.asBroadcastStream(); } diff --git a/lib/services/device_info/device_info.dart b/lib/services/device_info/device_info.dart new file mode 100644 index 000000000..87ddd6eb9 --- /dev/null +++ b/lib/services/device_info/device_info.dart @@ -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 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 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', + }; + } +} diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index f4c279b49..95777f567 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -18,6 +18,11 @@ dependencies: - libjsoncpp25 - libmpv1 | libmpv2 - xdg-user-dirs + - avahi-daemon + - avahi-discover + - avahi-utils + - libnss-mdns + - mdns-scan essential: false icon: assets/spotube-logo.png diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 1f952d0e5..12b4473e5 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -13,6 +13,9 @@ requires: - libsecret - libnotify - xdg-user-dirs + - avahi + - mdns-scan + - nss-mdns display_name: Spotube diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a7965e14c..a9f6650ff 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,8 +8,10 @@ import Foundation import app_links import audio_service import audio_session +import bonsoir_darwin import device_info_plus import file_selector_macos +import flutter_inappwebview_macos import flutter_secure_storage_macos import local_notifier import media_kit_libs_macos_audio @@ -28,8 +30,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) diff --git a/macos/Podfile b/macos/Podfile index 049abe295..9ec46f8cd 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 566e8196e..317de385f 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,10 +5,16 @@ PODS: - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS + - flutter_inappwebview_macos (0.0.1): + - FlutterMacOS + - OrderedSet (~> 5.0) - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) @@ -22,6 +28,7 @@ PODS: - media_kit_native_event_loop (1.0.0): - FlutterMacOS - metadata_god (0.0.1) + - OrderedSet (5.0.0) - package_info_plus (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): @@ -50,8 +57,10 @@ DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_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`) - FlutterMacOS (from `Flutter/ephemeral`) - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) @@ -72,6 +81,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - OrderedSet EXTERNAL SOURCES: app_links: @@ -80,10 +90,14 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + bonsoir_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: :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: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: @@ -121,8 +135,10 @@ SPEC CHECKSUMS: app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a @@ -130,6 +146,7 @@ SPEC CHECKSUMS: media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 + OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 @@ -141,6 +158,6 @@ SPEC CHECKSUMS: window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 -PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 +PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index a2dd74c4e..bf5d70cf7 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -436,7 +436,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -567,7 +567,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -592,7 +592,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/pubspec.lock b/pubspec.lock index bbf4faebb..47c1aba34 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + bonsoir: + dependency: "direct main" + description: + name: bonsoir + sha256: "9703ca3ce201c7ab6cd278ae5a530a125959687f59c2b97822f88a8db5bef106" + url: "https://pub.dev" + source: hosted + version: "5.1.9" + bonsoir_android: + dependency: transitive + description: + name: bonsoir_android + sha256: "19583ae34a5e5743fa2c16619e4ec699b35ae5e6cece59b99b1cf21c1b4ed618" + url: "https://pub.dev" + source: hosted + version: "5.1.4" + bonsoir_darwin: + dependency: transitive + description: + name: bonsoir_darwin + sha256: "985c4c38b4cbfa57ed5870e724a7e17aa080ee7f49d03b43e6d08781511505c6" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_linux: + dependency: transitive + description: + name: bonsoir_linux + sha256: "65554b20bc169c68c311eb31fab46ccdd8ee3d3dd89a2d57c338f4cbf6ceb00d" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_platform_interface: + dependency: transitive + description: + name: bonsoir_platform_interface + sha256: "4ee898bec0b5a63f04f82b06da9896ae8475f32a33b6fa395bea56399daeb9f0" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_windows: + dependency: transitive + description: + name: bonsoir_windows + sha256: abbc90b73ac39e823b0c127da43b91d8906dcc530fc0cec4e169cf0d8c4404b1 + url: "https://pub.dev" + source: hosted + version: "5.1.4" boolean_selector: dependency: transitive description: @@ -478,10 +526,10 @@ packages: dependency: "direct main" description: name: dbus - sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" device_frame: dependency: transitive description: @@ -494,10 +542,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" + sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" url: "https://pub.dev" source: hosted - version: "9.0.3" + version: "9.1.2" device_info_plus_platform_interface: dependency: transitive description: @@ -786,10 +834,58 @@ packages: dependency: "direct main" description: name: flutter_inappwebview - sha256: f73505c792cf083d5566e1a94002311be497d984b5607f25be36d685cf6361cf + sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" + url: "https://pub.dev" + source: hosted + 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: "5.7.2+3" + 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: dependency: transitive description: @@ -1146,6 +1242,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" http_multi_server: dependency: transitive description: @@ -1882,13 +1986,21 @@ packages: source: hosted version: "2.3.2" shelf: - dependency: transitive + dependency: "direct main" description: name: shelf sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted version: "1.4.1" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" shelf_static: dependency: transitive description: @@ -1898,7 +2010,7 @@ packages: source: hosted version: "1.1.2" shelf_web_socket: - dependency: transitive + dependency: "direct main" description: name: shelf_web_socket sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" @@ -2311,13 +2423,13 @@ packages: source: hosted version: "0.5.0" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.4" webdriver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ef8401bce..9f323a6f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 - device_info_plus: ^9.0.3 + device_info_plus: ^9.1.2 device_preview: ^1.1.0 dio: ^5.4.1 disable_battery_optimization: ^1.1.0+1 @@ -43,7 +43,7 @@ dependencies: flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 flutter_hooks: ^0.20.0 - flutter_inappwebview: ^5.7.2+3 + flutter_inappwebview: ^6.0.0 flutter_localizations: sdk: flutter flutter_native_splash: ^2.3.10 @@ -123,6 +123,11 @@ dependencies: flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 spotify: ^0.13.3 + bonsoir: ^5.1.9 + shelf: ^1.4.1 + shelf_router: ^1.1.4 + shelf_web_socket: ^1.0.4 + web_socket_channel: ^2.4.4 dev_dependencies: build_runner: ^2.3.2 diff --git a/untranslated_messages.json b/untranslated_messages.json index 4275f4610..be7d38f18 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -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": [ "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" ] } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index fcf9927e0..d8a9db298 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -23,6 +24,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + BonsoirWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); DartDiscordRpcPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0fe6e076b..902927444 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + bonsoir_windows dart_discord_rpc file_selector_windows flutter_secure_storage_windows