mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00
feat: LAN connect a.k.a control remote Spotube playback and local output device selection (#1355)
* feat: add connect server support * feat: add ability discover and connect to same network Spotube(s) and sync queue * feat(connect): add player controls, shuffle, loop, progress bar and queue support * feat: make control page adaptive * feat: add volume control support * cd: upgrade macos runner version * chore: upgrade inappwebview version to 6 * feat: customized devices button * feat: add user icon next to devices button * feat: add play in remote device support * feat: show alert when new client connects * fix: ignore the device itself from broadcast list * fix: volume control not working * feat: add ability to select current device's output speaker
This commit is contained in:
parent
044d3b4820
commit
68374efd3e
4
.github/workflows/spotube-release-binary.yml
vendored
4
.github/workflows/spotube-release-binary.yml
vendored
@ -284,7 +284,7 @@ jobs:
|
|||||||
|
|
||||||
macos:
|
macos:
|
||||||
|
|
||||||
runs-on: macos-12
|
runs-on: macos-14
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: subosito/flutter-action@v2.12.0
|
- uses: subosito/flutter-action@v2.12.0
|
||||||
@ -349,7 +349,7 @@ jobs:
|
|||||||
limit-access-to-actor: true
|
limit-access-to-actor: true
|
||||||
|
|
||||||
iOS:
|
iOS:
|
||||||
runs-on: macos-latest
|
runs-on: macos-14
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: subosito/flutter-action@v2.10.0
|
- uses: subosito/flutter-action@v2.10.0
|
||||||
|
@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
|
|||||||
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
|
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
|
||||||
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
|
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
|
||||||
- [Your First Code Contribution](#your-first-code-contribution)
|
- [Your First Code Contribution](#your-first-code-contribution)
|
||||||
- [Submit translations](#submit-translations)
|
- [Submit Translations](#submit-translations)
|
||||||
|
|
||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
|
|
||||||
@ -123,16 +123,16 @@ Do the following:
|
|||||||
- Install Development dependencies in linux
|
- Install Development dependencies in linux
|
||||||
- Debian (>=12/Bookworm)/Ubuntu
|
- Debian (>=12/Bookworm)/Ubuntu
|
||||||
```bash
|
```bash
|
||||||
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev
|
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan
|
||||||
```
|
```
|
||||||
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
|
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
|
||||||
- Arch/Manjaro
|
- Arch/Manjaro
|
||||||
```bash
|
```bash
|
||||||
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify
|
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
|
||||||
```
|
```
|
||||||
- Fedora
|
- Fedora
|
||||||
```bash
|
```bash
|
||||||
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel
|
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns
|
||||||
```
|
```
|
||||||
- Clone the Repo
|
- Clone the Repo
|
||||||
- Create a `.env` in root of the project following the `.env.example` template
|
- Create a `.env` in root of the project following the `.env.example` template
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
# platform :ios, '12.0'
|
platform :ios, '13.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
@ -5,6 +5,9 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- audio_session (0.0.1):
|
- audio_session (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- bonsoir_darwin (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- device_info_plus (0.0.1):
|
- device_info_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- DKImagePickerController/Core (4.3.4):
|
- DKImagePickerController/Core (4.3.4):
|
||||||
@ -44,11 +47,13 @@ PODS:
|
|||||||
- file_selector_ios (0.0.1):
|
- file_selector_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- flutter_inappwebview (0.0.1):
|
- flutter_broadcasts (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- flutter_inappwebview/Core (= 0.0.1)
|
- flutter_inappwebview_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- flutter_inappwebview_ios/Core (= 0.0.1)
|
||||||
- OrderedSet (~> 5.0)
|
- OrderedSet (~> 5.0)
|
||||||
- flutter_inappwebview/Core (0.0.1):
|
- flutter_inappwebview_ios/Core (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- OrderedSet (~> 5.0)
|
- OrderedSet (~> 5.0)
|
||||||
- flutter_keyboard_visibility (0.0.1):
|
- flutter_keyboard_visibility (0.0.1):
|
||||||
@ -102,11 +107,13 @@ DEPENDENCIES:
|
|||||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||||
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
||||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
|
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
|
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
- flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
|
||||||
|
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||||
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
|
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
|
||||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
@ -142,6 +149,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/audio_service/ios"
|
:path: ".symlinks/plugins/audio_service/ios"
|
||||||
audio_session:
|
audio_session:
|
||||||
:path: ".symlinks/plugins/audio_session/ios"
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
|
bonsoir_darwin:
|
||||||
|
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
file_picker:
|
file_picker:
|
||||||
@ -150,8 +159,10 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/file_selector_ios/ios"
|
:path: ".symlinks/plugins/file_selector_ios/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_inappwebview:
|
flutter_broadcasts:
|
||||||
:path: ".symlinks/plugins/flutter_inappwebview/ios"
|
:path: ".symlinks/plugins/flutter_broadcasts/ios"
|
||||||
|
flutter_inappwebview_ios:
|
||||||
|
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
||||||
flutter_keyboard_visibility:
|
flutter_keyboard_visibility:
|
||||||
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
||||||
flutter_mailer:
|
flutter_mailer:
|
||||||
@ -191,13 +202,15 @@ SPEC CHECKSUMS:
|
|||||||
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
|
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
|
||||||
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
||||||
audio_session: 4f3e461722055d21515cf3261b64c973c062f345
|
audio_session: 4f3e461722055d21515cf3261b64c973c062f345
|
||||||
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
|
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||||
|
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||||
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
||||||
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
|
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||||
flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62
|
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
||||||
|
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||||
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
|
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
|
||||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||||
@ -221,6 +234,6 @@ SPEC CHECKSUMS:
|
|||||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||||
|
|
||||||
PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd
|
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
|
||||||
|
|
||||||
COCOAPODS: 1.15.2
|
COCOAPODS: 1.15.2
|
||||||
|
@ -66,5 +66,11 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
<true />
|
<true />
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>To allow other devices on the network control playback of Spotube securely.</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_spotube._tcp</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:spotify/spotify.dart' hide Search;
|
import 'package:spotify/spotify.dart' hide Search;
|
||||||
import 'package:spotube/models/spotify/recommendation_seeds.dart';
|
import 'package:spotube/models/spotify/recommendation_seeds.dart';
|
||||||
import 'package:spotube/pages/album/album.dart';
|
import 'package:spotube/pages/album/album.dart';
|
||||||
|
import 'package:spotube/pages/connect/connect.dart';
|
||||||
|
import 'package:spotube/pages/connect/control/control.dart';
|
||||||
import 'package:spotube/pages/getting_started/getting_started.dart';
|
import 'package:spotube/pages/getting_started/getting_started.dart';
|
||||||
import 'package:spotube/pages/home/genres/genre_playlists.dart';
|
import 'package:spotube/pages/home/genres/genre_playlists.dart';
|
||||||
import 'package:spotube/pages/home/genres/genres.dart';
|
import 'package:spotube/pages/home/genres/genres.dart';
|
||||||
@ -173,6 +175,21 @@ final routerProvider = Provider((ref) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: "/connect",
|
||||||
|
pageBuilder: (context, state) => const SpotubePage(
|
||||||
|
child: ConnectPage(),
|
||||||
|
),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: "control",
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
return const SpotubePage(
|
||||||
|
child: ConnectControlPage(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
])
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
@ -116,4 +116,9 @@ abstract class SpotubeIcons {
|
|||||||
static const openCollective = SimpleIcons.opencollective;
|
static const openCollective = SimpleIcons.opencollective;
|
||||||
static const anonymous = FeatherIcons.user;
|
static const anonymous = FeatherIcons.user;
|
||||||
static const history = FeatherIcons.clock;
|
static const history = FeatherIcons.clock;
|
||||||
|
static const connect = FeatherIcons.link;
|
||||||
|
static const speaker = FeatherIcons.speaker;
|
||||||
|
static const monitor = FeatherIcons.monitor;
|
||||||
|
static const power = FeatherIcons.power;
|
||||||
|
static const bluetooth = FeatherIcons.bluetooth;
|
||||||
}
|
}
|
||||||
|
@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||||
import 'package:spotube/extensions/artist_simple.dart';
|
import 'package:spotube/extensions/artist_simple.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/extensions/image.dart';
|
import 'package:spotube/extensions/image.dart';
|
||||||
import 'package:spotube/extensions/track.dart';
|
import 'package:spotube/extensions/track.dart';
|
||||||
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/spotify/spotify.dart';
|
import 'package:spotube/provider/spotify/spotify.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty) return;
|
||||||
|
|
||||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
playlistNotifier.addCollection(album.id!);
|
if (isRemoteDevice) {
|
||||||
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
|
await remotePlayback.load(
|
||||||
|
WebSocketLoadEventData(
|
||||||
|
tracks: fetchedTracks,
|
||||||
|
collectionId: album.id!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||||
|
playlistNotifier.addCollection(album.id!);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
}
|
}
|
||||||
|
85
lib/components/connect/connect_device.dart
Normal file
85
lib/components/connect/connect_device.dart
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/provider/connect/clients.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
|
class ConnectDeviceButton extends HookConsumerWidget {
|
||||||
|
const ConnectDeviceButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final ThemeData(:colorScheme) = Theme.of(context);
|
||||||
|
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
|
final connectClients = ref.watch(connectClientsProvider);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 40 * pixelRatio,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
fit: StackFit.loose,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
ServiceUtils.push(context, "/connect");
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (connectClients.asData?.value.resolvedService !=
|
||||||
|
null) ...[
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.greenAccent,
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(5),
|
||||||
|
],
|
||||||
|
Text(context.l10n.devices),
|
||||||
|
if (connectClients.asData?.value.services.isNotEmpty ==
|
||||||
|
true)
|
||||||
|
Text(
|
||||||
|
" (${connectClients.asData?.value.services.length})",
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
colorScheme.onPrimaryContainer.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(35),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
child: IconButton.filled(
|
||||||
|
icon: const Icon(SpotubeIcons.speaker),
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.standard,
|
||||||
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
ServiceUtils.push(context, "/connect");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
60
lib/components/connect/local_devices.dart
Normal file
60
lib/components/connect/local_devices.dart
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
|
|
||||||
|
class ConnectPageLocalDevices extends HookWidget {
|
||||||
|
const ConnectPageLocalDevices({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final ThemeData(:textTheme) = Theme.of(context);
|
||||||
|
final devicesFuture = useFuture(audioPlayer.devices);
|
||||||
|
final devicesStream = useStream(audioPlayer.devicesStream);
|
||||||
|
final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice);
|
||||||
|
final selectedDeviceStream = useStream(audioPlayer.selectedDeviceStream);
|
||||||
|
|
||||||
|
final devices = devicesStream.data ?? devicesFuture.data;
|
||||||
|
final selectedDevice =
|
||||||
|
selectedDeviceStream.data ?? selectedDeviceFuture.data;
|
||||||
|
|
||||||
|
if (devices == null) {
|
||||||
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverMainAxisGroup(
|
||||||
|
slivers: [
|
||||||
|
const SliverGap(10),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
context.l10n.this_device,
|
||||||
|
style: textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(10),
|
||||||
|
SliverList.separated(
|
||||||
|
itemCount: devices.length,
|
||||||
|
separatorBuilder: (context, index) => const Gap(10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final device = devices[index];
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(SpotubeIcons.speaker),
|
||||||
|
title: Text(device.description),
|
||||||
|
subtitle: Text(device.name),
|
||||||
|
selected: selectedDevice == device,
|
||||||
|
onTap: () => audioPlayer.setAudioDevice(device),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -283,12 +283,17 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
trackSnapshot.isLoading ? 5 : filteredTracks.length,
|
trackSnapshot.isLoading ? 5 : filteredTracks.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (trackSnapshot.isLoading) {
|
if (trackSnapshot.isLoading) {
|
||||||
return TrackTile(track: FakeData.track, index: index);
|
return TrackTile(
|
||||||
|
playlist: playlist,
|
||||||
|
track: FakeData.track,
|
||||||
|
index: index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final track = filteredTracks[index];
|
final track = filteredTracks[index];
|
||||||
return TrackTile(
|
return TrackTile(
|
||||||
index: index,
|
index: index,
|
||||||
|
playlist: playlist,
|
||||||
track: track,
|
track: track,
|
||||||
userPlaylist: false,
|
userPlaylist: false,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@ -311,8 +316,11 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: 5,
|
itemCount: 5,
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) => TrackTile(
|
||||||
TrackTile(track: FakeData.track, index: index),
|
track: FakeData.track,
|
||||||
|
index: index,
|
||||||
|
playlist: playlist,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -26,6 +26,7 @@ import 'package:spotube/models/local_track.dart';
|
|||||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication_provider.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
|
import 'package:spotube/provider/volume_provider.dart';
|
||||||
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
||||||
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
@ -46,9 +47,7 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||||
(value) => value.activeTrack,
|
(value) => value.activeTrack,
|
||||||
));
|
));
|
||||||
final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
final isLocalTrack = currentTrack is LocalTrack;
|
||||||
(value) => value.activeTrack is LocalTrack,
|
|
||||||
));
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
@ -240,7 +239,7 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (isLocalTrack)
|
if (isLocalTrack)
|
||||||
Text(
|
Text(
|
||||||
currentTrack?.artists?.asString() ?? "",
|
currentTrack.artists?.asString() ?? "",
|
||||||
style: theme.textTheme.bodyMedium!.copyWith(
|
style: theme.textTheme.bodyMedium!.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: bodyTextColor,
|
color: bodyTextColor,
|
||||||
@ -304,10 +303,25 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
.height *
|
.height *
|
||||||
.7,
|
.7,
|
||||||
),
|
),
|
||||||
builder: (context) {
|
builder: (context) => Consumer(
|
||||||
return const PlayerQueue(
|
builder: (context, ref, _) {
|
||||||
floating: false);
|
final playlist = ref.watch(
|
||||||
},
|
ProxyPlaylistNotifier
|
||||||
|
.provider,
|
||||||
|
);
|
||||||
|
final playlistNotifier =
|
||||||
|
ref.read(
|
||||||
|
ProxyPlaylistNotifier
|
||||||
|
.notifier,
|
||||||
|
);
|
||||||
|
return PlayerQueue
|
||||||
|
.fromProxyPlaylistNotifier(
|
||||||
|
floating: false,
|
||||||
|
playlist: playlist,
|
||||||
|
notifier: playlistNotifier,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: null),
|
: null),
|
||||||
@ -365,11 +379,21 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
enabledThumbRadius: 8,
|
enabledThumbRadius: 8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding:
|
||||||
child: VolumeSlider(
|
const EdgeInsets.symmetric(horizontal: 16),
|
||||||
fullWidth: true,
|
child: Consumer(builder: (context, ref, _) {
|
||||||
),
|
final volume = ref.watch(volumeProvider);
|
||||||
|
return VolumeSlider(
|
||||||
|
fullWidth: true,
|
||||||
|
value: volume,
|
||||||
|
onChanged: (value) {
|
||||||
|
ref
|
||||||
|
.read(volumeProvider.notifier)
|
||||||
|
.setVolume(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
onPressed: playlist.isFetching == true
|
onPressed: playlist.isFetching == true
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
switch (audioPlayer.loopMode) {
|
audioPlayer.setLoopMode(
|
||||||
case PlaybackLoopMode.all:
|
switch (loopMode) {
|
||||||
audioPlayer
|
PlaybackLoopMode.all =>
|
||||||
.setLoopMode(PlaybackLoopMode.one);
|
PlaybackLoopMode.one,
|
||||||
break;
|
PlaybackLoopMode.one =>
|
||||||
case PlaybackLoopMode.one:
|
PlaybackLoopMode.none,
|
||||||
audioPlayer
|
PlaybackLoopMode.none =>
|
||||||
.setLoopMode(PlaybackLoopMode.none);
|
PlaybackLoopMode.all,
|
||||||
break;
|
},
|
||||||
case PlaybackLoopMode.none:
|
);
|
||||||
audioPlayer
|
|
||||||
.setLoopMode(PlaybackLoopMode.all);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: PlayerTrackDetails(
|
child: PlayerTrackDetails(
|
||||||
albumArt: albumArt,
|
track: playlist.activeTrack,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -3,15 +3,13 @@ import 'dart:ui';
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/fake.dart';
|
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||||
@ -20,19 +18,43 @@ import 'package:spotube/extensions/artist_simple.dart';
|
|||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
||||||
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
|
|
||||||
class PlayerQueue extends HookConsumerWidget {
|
class PlayerQueue extends HookConsumerWidget {
|
||||||
final bool floating;
|
final bool floating;
|
||||||
|
final ProxyPlaylist playlist;
|
||||||
|
|
||||||
|
final Future<void> Function(Track track) onJump;
|
||||||
|
final Future<void> Function(String trackId) onRemove;
|
||||||
|
final Future<void> Function(int oldIndex, int newIndex) onReorder;
|
||||||
|
final Future<void> Function() onStop;
|
||||||
|
|
||||||
const PlayerQueue({
|
const PlayerQueue({
|
||||||
this.floating = true,
|
this.floating = true,
|
||||||
|
required this.playlist,
|
||||||
|
required this.onJump,
|
||||||
|
required this.onRemove,
|
||||||
|
required this.onReorder,
|
||||||
|
required this.onStop,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
PlayerQueue.fromProxyPlaylistNotifier({
|
||||||
|
this.floating = true,
|
||||||
|
required this.playlist,
|
||||||
|
required ProxyPlaylistNotifier notifier,
|
||||||
|
super.key,
|
||||||
|
}) : onJump = notifier.jumpToTrack,
|
||||||
|
onRemove = notifier.removeTrack,
|
||||||
|
onReorder = notifier.moveTrack,
|
||||||
|
onStop = notifier.stop;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||||
|
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||||
final controller = useAutoScrollController();
|
final controller = useAutoScrollController();
|
||||||
final searchText = useState('');
|
final searchText = useState('');
|
||||||
|
|
||||||
@ -48,7 +70,6 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
topRight: Radius.circular(10),
|
topRight: Radius.circular(10),
|
||||||
);
|
);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final mediaQuery = MediaQuery.of(context);
|
|
||||||
final headlineColor = theme.textTheme.headlineSmall?.color;
|
final headlineColor = theme.textTheme.headlineSmall?.color;
|
||||||
|
|
||||||
final filteredTracks = useMemoized(
|
final filteredTracks = useMemoized(
|
||||||
@ -87,198 +108,204 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
return const NotFound(vertical: true);
|
return const NotFound(vertical: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ClipRRect(
|
return LayoutBuilder(
|
||||||
borderRadius: borderRadius,
|
builder: (context, constrains) {
|
||||||
clipBehavior: Clip.hardEdge,
|
return ClipRRect(
|
||||||
child: BackdropFilter(
|
borderRadius: borderRadius,
|
||||||
filter: ImageFilter.blur(
|
clipBehavior: Clip.hardEdge,
|
||||||
sigmaX: 15,
|
child: BackdropFilter(
|
||||||
sigmaY: 15,
|
filter: ImageFilter.blur(
|
||||||
),
|
sigmaX: 15,
|
||||||
child: Container(
|
sigmaY: 15,
|
||||||
padding: const EdgeInsets.only(
|
),
|
||||||
top: 5.0,
|
child: Container(
|
||||||
),
|
padding: const EdgeInsets.only(
|
||||||
decoration: BoxDecoration(
|
top: 5.0,
|
||||||
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
|
),
|
||||||
borderRadius: borderRadius,
|
decoration: BoxDecoration(
|
||||||
),
|
color: theme.colorScheme.surfaceVariant.withOpacity(0.5),
|
||||||
child: CallbackShortcuts(
|
borderRadius: borderRadius,
|
||||||
bindings: {
|
),
|
||||||
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
child: CallbackShortcuts(
|
||||||
if (!isSearching.value) {
|
bindings: {
|
||||||
Navigator.of(context).pop();
|
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
||||||
}
|
if (!isSearching.value) {
|
||||||
isSearching.value = false;
|
Navigator.of(context).pop();
|
||||||
searchText.value = '';
|
}
|
||||||
}
|
isSearching.value = false;
|
||||||
},
|
searchText.value = '';
|
||||||
child: InterScrollbar(
|
}
|
||||||
controller: controller,
|
},
|
||||||
child: CustomScrollView(
|
child: InterScrollbar(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
slivers: [
|
child: CustomScrollView(
|
||||||
if (!floating)
|
controller: controller,
|
||||||
SliverToBoxAdapter(
|
slivers: [
|
||||||
child: Center(
|
if (!floating)
|
||||||
child: Container(
|
SliverToBoxAdapter(
|
||||||
height: 5,
|
child: Center(
|
||||||
width: 100,
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 5, top: 2),
|
height: 5,
|
||||||
decoration: BoxDecoration(
|
width: 100,
|
||||||
color: headlineColor,
|
margin: const EdgeInsets.only(bottom: 5, top: 2),
|
||||||
borderRadius: BorderRadius.circular(20),
|
decoration: BoxDecoration(
|
||||||
),
|
color: headlineColor,
|
||||||
),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
SliverAppBar(
|
|
||||||
floating: true,
|
|
||||||
pinned: false,
|
|
||||||
snap: false,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
automaticallyImplyLeading: !isSearching.value,
|
|
||||||
title: BackdropFilter(
|
|
||||||
filter: ImageFilter.blur(
|
|
||||||
sigmaX: 10,
|
|
||||||
sigmaY: 10,
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
height: kToolbarHeight,
|
|
||||||
child: mediaQuery.mdAndUp || !isSearching.value
|
|
||||||
? Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
context.l10n.tracks_in_queue(tracks.length),
|
|
||||||
style: TextStyle(
|
|
||||||
color: headlineColor,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
if (mediaQuery.mdAndUp || isSearching.value)
|
|
||||||
TextField(
|
|
||||||
onChanged: (value) {
|
|
||||||
searchText.value = value;
|
|
||||||
},
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: context.l10n.search,
|
|
||||||
isDense: true,
|
|
||||||
prefixIcon: mediaQuery.smAndDown
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.arrow_back_ios_new_outlined,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
isSearching.value = false;
|
|
||||||
searchText.value = '';
|
|
||||||
},
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
minimumSize: const Size.square(20),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(SpotubeIcons.filter),
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxHeight: 40,
|
|
||||||
maxWidth: mediaQuery.smAndDown
|
|
||||||
? mediaQuery.size.width - 40
|
|
||||||
: 300,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
else
|
|
||||||
IconButton.filledTonal(
|
|
||||||
icon: const Icon(SpotubeIcons.filter),
|
|
||||||
onPressed: () {
|
|
||||||
isSearching.value = !isSearching.value;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
if (mediaQuery.mdAndUp || !isSearching.value) ...[
|
SliverAppBar(
|
||||||
const SizedBox(width: 10),
|
floating: true,
|
||||||
FilledButton(
|
pinned: false,
|
||||||
style: FilledButton.styleFrom(
|
snap: false,
|
||||||
backgroundColor:
|
backgroundColor: Colors.transparent,
|
||||||
theme.scaffoldBackgroundColor.withOpacity(0.5),
|
elevation: 0,
|
||||||
foregroundColor:
|
automaticallyImplyLeading: !isSearching.value,
|
||||||
theme.textTheme.headlineSmall?.color,
|
title: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(
|
||||||
|
sigmaX: 10,
|
||||||
|
sigmaY: 10,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: SizedBox(
|
||||||
children: [
|
height: kToolbarHeight,
|
||||||
const Icon(SpotubeIcons.playlistRemove),
|
child: mediaQuery.mdAndUp || !isSearching.value
|
||||||
const SizedBox(width: 5),
|
? Align(
|
||||||
Text(context.l10n.clear_all),
|
alignment: Alignment.centerLeft,
|
||||||
],
|
child: Text(
|
||||||
|
context.l10n
|
||||||
|
.tracks_in_queue(tracks.length),
|
||||||
|
style: TextStyle(
|
||||||
|
color: headlineColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
|
||||||
playlistNotifier.stop();
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
actions: [
|
||||||
],
|
if (mediaQuery.mdAndUp || isSearching.value)
|
||||||
|
TextField(
|
||||||
|
onChanged: (value) {
|
||||||
|
searchText.value = value;
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: context.l10n.search,
|
||||||
|
isDense: true,
|
||||||
|
prefixIcon: mediaQuery.smAndDown
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.arrow_back_ios_new_outlined,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
isSearching.value = false;
|
||||||
|
searchText.value = '';
|
||||||
|
},
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: const Size.square(20),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(SpotubeIcons.filter),
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: 40,
|
||||||
|
maxWidth: mediaQuery.smAndDown
|
||||||
|
? mediaQuery.size.width - 40
|
||||||
|
: 300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton.filledTonal(
|
||||||
|
icon: const Icon(SpotubeIcons.filter),
|
||||||
|
onPressed: () {
|
||||||
|
isSearching.value = !isSearching.value;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (mediaQuery.mdAndUp || !isSearching.value) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor
|
||||||
|
.withOpacity(0.5),
|
||||||
|
foregroundColor:
|
||||||
|
theme.textTheme.headlineSmall?.color,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(SpotubeIcons.playlistRemove),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Text(context.l10n.clear_all),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
playlistNotifier.stop();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SliverGap(10),
|
||||||
|
SliverReorderableList(
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
playlistNotifier.moveTrack(oldIndex, newIndex);
|
||||||
|
},
|
||||||
|
itemCount: filteredTracks.length,
|
||||||
|
onReorderStart: (index) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
},
|
||||||
|
onReorderEnd: (index) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
},
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final track = filteredTracks.elementAt(i);
|
||||||
|
return AutoScrollTag(
|
||||||
|
key: ValueKey<int>(i),
|
||||||
|
controller: controller,
|
||||||
|
index: i,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: TrackTile(
|
||||||
|
playlist: playlist,
|
||||||
|
index: i,
|
||||||
|
track: track,
|
||||||
|
onTap: () async {
|
||||||
|
if (playlist.activeTrack?.id == track.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await playlistNotifier.jumpToTrack(track);
|
||||||
|
},
|
||||||
|
leadingActions: [
|
||||||
|
if (!isSearching.value &&
|
||||||
|
searchText.value.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: ReorderableDragStartListener(
|
||||||
|
index: i,
|
||||||
|
child: const Icon(
|
||||||
|
SpotubeIcons.dragHandle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SliverGap(100),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SliverGap(10),
|
),
|
||||||
SliverReorderableList(
|
|
||||||
onReorder: (oldIndex, newIndex) {
|
|
||||||
playlistNotifier.moveTrack(oldIndex, newIndex);
|
|
||||||
},
|
|
||||||
itemCount: filteredTracks.length,
|
|
||||||
onReorderStart: (index) {
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
},
|
|
||||||
onReorderEnd: (index) {
|
|
||||||
HapticFeedback.selectionClick();
|
|
||||||
},
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final track = filteredTracks.elementAt(i);
|
|
||||||
return AutoScrollTag(
|
|
||||||
key: ValueKey<int>(i),
|
|
||||||
controller: controller,
|
|
||||||
index: i,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: TrackTile(
|
|
||||||
index: i,
|
|
||||||
track: track,
|
|
||||||
onTap: () async {
|
|
||||||
if (playlist.activeTrack?.id == track.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await playlistNotifier.jumpToTrack(track);
|
|
||||||
},
|
|
||||||
leadingActions: [
|
|
||||||
if (!isSearching.value &&
|
|
||||||
searchText.value.isEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8.0),
|
|
||||||
child: ReorderableDragStartListener(
|
|
||||||
index: i,
|
|
||||||
child: const Icon(
|
|
||||||
SpotubeIcons.dragHandle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SliverGap(100),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,13 +8,14 @@ import 'package:spotube/components/shared/links/artist_link.dart';
|
|||||||
import 'package:spotube/components/shared/links/link_text.dart';
|
import 'package:spotube/components/shared/links/link_text.dart';
|
||||||
import 'package:spotube/extensions/artist_simple.dart';
|
import 'package:spotube/extensions/artist_simple.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/extensions/image.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
class PlayerTrackDetails extends HookConsumerWidget {
|
class PlayerTrackDetails extends HookConsumerWidget {
|
||||||
final String? albumArt;
|
|
||||||
final Color? color;
|
final Color? color;
|
||||||
const PlayerTrackDetails({super.key, this.albumArt, this.color});
|
final Track? track;
|
||||||
|
const PlayerTrackDetails({super.key, this.color, this.track});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: UniversalImage(
|
child: UniversalImage(
|
||||||
path: albumArt ?? "",
|
path: (track?.album?.images)
|
||||||
|
.asUrlString(placeholder: ImagePlaceholder.albumArt),
|
||||||
placeholder: Assets.albumPlaceholder.path,
|
placeholder: Assets.albumPlaceholder.path,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -3,37 +3,39 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/provider/volume_provider.dart';
|
|
||||||
|
|
||||||
class VolumeSlider extends HookConsumerWidget {
|
class VolumeSlider extends HookConsumerWidget {
|
||||||
final bool fullWidth;
|
final bool fullWidth;
|
||||||
|
|
||||||
|
final double value;
|
||||||
|
final ValueChanged<double> onChanged;
|
||||||
|
|
||||||
const VolumeSlider({
|
const VolumeSlider({
|
||||||
super.key,
|
super.key,
|
||||||
this.fullWidth = false,
|
this.fullWidth = false,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final volume = ref.watch(volumeProvider);
|
|
||||||
final volumeNotifier = ref.watch(volumeProvider.notifier);
|
|
||||||
|
|
||||||
var slider = Listener(
|
var slider = Listener(
|
||||||
onPointerSignal: (event) async {
|
onPointerSignal: (event) async {
|
||||||
if (event is PointerScrollEvent) {
|
if (event is PointerScrollEvent) {
|
||||||
if (event.scrollDelta.dy > 0) {
|
if (event.scrollDelta.dy > 0) {
|
||||||
final value = volume - .2;
|
final newValue = value - .2;
|
||||||
volumeNotifier.setVolume(value < 0 ? 0 : value);
|
onChanged(newValue < 0 ? 0 : newValue);
|
||||||
} else {
|
} else {
|
||||||
final value = volume + .2;
|
final newValue = value + .2;
|
||||||
volumeNotifier.setVolume(value > 1 ? 1 : value);
|
onChanged(newValue > 1 ? 1 : newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Slider(
|
child: Slider(
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 1,
|
||||||
value: volume,
|
value: value,
|
||||||
onChanged: volumeNotifier.setVolume,
|
onChanged: onChanged,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return Row(
|
return Row(
|
||||||
@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
volume == 0
|
value == 0
|
||||||
? SpotubeIcons.volumeMute
|
? SpotubeIcons.volumeMute
|
||||||
: volume <= 0.2
|
: value <= 0.2
|
||||||
? SpotubeIcons.volumeLow
|
? SpotubeIcons.volumeLow
|
||||||
: volume <= 0.6
|
: value <= 0.6
|
||||||
? SpotubeIcons.volumeMedium
|
? SpotubeIcons.volumeMedium
|
||||||
: SpotubeIcons.volumeHigh,
|
: SpotubeIcons.volumeHigh,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (volume == 0) {
|
if (value == 0) {
|
||||||
volumeNotifier.setVolume(1);
|
onChanged(1);
|
||||||
} else {
|
} else {
|
||||||
volumeNotifier.setVolume(0);
|
onChanged(0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||||
import 'package:spotube/extensions/image.dart';
|
import 'package:spotube/extensions/image.dart';
|
||||||
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/spotify/spotify.dart';
|
import 'package:spotube/provider/spotify/spotify.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
@ -71,8 +74,19 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty) return;
|
||||||
|
|
||||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
playlistNotifier.addCollection(playlist.id!);
|
if (isRemoteDevice) {
|
||||||
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
|
await remotePlayback.load(
|
||||||
|
WebSocketLoadEventData(
|
||||||
|
tracks: fetchedTracks,
|
||||||
|
collectionId: playlist.id!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||||
|
playlistNotifier.addCollection(playlist.id!);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
|
@ -19,9 +19,11 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
|||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication_provider.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart' hide volumeProvider;
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||||
|
import 'package:spotube/provider/volume_provider.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
|
||||||
class BottomPlayer extends HookConsumerWidget {
|
class BottomPlayer extends HookConsumerWidget {
|
||||||
@ -34,6 +36,7 @@ class BottomPlayer extends HookConsumerWidget {
|
|||||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||||
final layoutMode =
|
final layoutMode =
|
||||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||||
|
final remoteControl = ref.watch(connectProvider);
|
||||||
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
@ -73,7 +76,9 @@ class BottomPlayer extends HookConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
|
Expanded(
|
||||||
|
child: PlayerTrackDetails(track: playlist.activeTrack),
|
||||||
|
),
|
||||||
// controls
|
// controls
|
||||||
Flexible(
|
Flexible(
|
||||||
flex: 3,
|
flex: 3,
|
||||||
@ -121,10 +126,20 @@ class BottomPlayer extends HookConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
height: 40,
|
height: 40,
|
||||||
constraints: const BoxConstraints(maxWidth: 250),
|
constraints: const BoxConstraints(maxWidth: 250),
|
||||||
child: const VolumeSlider(),
|
padding: const EdgeInsets.only(right: 10),
|
||||||
|
child: Consumer(builder: (context, ref, _) {
|
||||||
|
final volume = ref.watch(volumeProvider);
|
||||||
|
return VolumeSlider(
|
||||||
|
fullWidth: true,
|
||||||
|
value: volume,
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(volumeProvider.notifier).setVolume(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
70
lib/components/shared/dialogs/select_device_dialog.dart
Normal file
70
lib/components/shared/dialogs/select_device_dialog.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/provider/connect/clients.dart';
|
||||||
|
|
||||||
|
class SelectDeviceDialog extends HookConsumerWidget {
|
||||||
|
const SelectDeviceDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final isRemoteService = useState(false);
|
||||||
|
|
||||||
|
final connectClients = ref.watch(connectClientsProvider);
|
||||||
|
final remoteService = connectClients.asData!.value.resolvedService!;
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text("Choose the device:"),
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
"There are multiple device connected.\n"
|
||||||
|
"Choose the device you want this action to take place",
|
||||||
|
),
|
||||||
|
RadioListTile.adaptive(
|
||||||
|
title: Text(remoteService.name),
|
||||||
|
value: true,
|
||||||
|
groupValue: isRemoteService.value,
|
||||||
|
onChanged: (value) {
|
||||||
|
isRemoteService.value = value!;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RadioListTile.adaptive(
|
||||||
|
title: const Text("This Device"),
|
||||||
|
value: false,
|
||||||
|
groupValue: isRemoteService.value,
|
||||||
|
onChanged: (value) {
|
||||||
|
isRemoteService.value = !value!;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(isRemoteService.value);
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.select),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
|
||||||
|
final connectClients = ref.read(connectClientsProvider);
|
||||||
|
|
||||||
|
if (connectClients.asData?.value.resolvedService == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isRemote = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const SelectDeviceDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return isRemote ?? false;
|
||||||
|
}
|
@ -26,6 +26,8 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
|
|||||||
final double? titleWidth;
|
final double? titleWidth;
|
||||||
final Widget? title;
|
final Widget? title;
|
||||||
|
|
||||||
|
final bool _sliver;
|
||||||
|
|
||||||
const PageWindowTitleBar({
|
const PageWindowTitleBar({
|
||||||
super.key,
|
super.key,
|
||||||
this.actions,
|
this.actions,
|
||||||
@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
|
|||||||
this.titleTextStyle,
|
this.titleTextStyle,
|
||||||
this.titleWidth,
|
this.titleWidth,
|
||||||
this.toolbarTextStyle,
|
this.toolbarTextStyle,
|
||||||
});
|
}) : _sliver = false,
|
||||||
|
pinned = false,
|
||||||
|
floating = false,
|
||||||
|
snap = false,
|
||||||
|
stretch = false;
|
||||||
|
|
||||||
|
final bool pinned;
|
||||||
|
final bool floating;
|
||||||
|
final bool snap;
|
||||||
|
final bool stretch;
|
||||||
|
|
||||||
|
const PageWindowTitleBar.sliver({
|
||||||
|
super.key,
|
||||||
|
this.actions,
|
||||||
|
this.title,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.actionsIconTheme,
|
||||||
|
this.automaticallyImplyLeading = false,
|
||||||
|
this.centerTitle,
|
||||||
|
this.foregroundColor,
|
||||||
|
this.leading,
|
||||||
|
this.leadingWidth,
|
||||||
|
this.titleSpacing,
|
||||||
|
this.titleTextStyle,
|
||||||
|
this.titleWidth,
|
||||||
|
this.toolbarTextStyle,
|
||||||
|
this.pinned = false,
|
||||||
|
this.floating = false,
|
||||||
|
this.snap = false,
|
||||||
|
this.stretch = false,
|
||||||
|
}) : _sliver = true,
|
||||||
|
toolbarOpacity = 1;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
if (widget._sliver) {
|
||||||
|
return SliverLayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final hasFullscreen =
|
||||||
|
mediaQuery.size.width == constraints.crossAxisExtent;
|
||||||
|
final hasLeadingOrCanPop =
|
||||||
|
widget.leading != null || Navigator.canPop(context);
|
||||||
|
|
||||||
|
return SliverPadding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: DesktopTools.platform.isMacOS &&
|
||||||
|
hasFullscreen &&
|
||||||
|
hasLeadingOrCanPop
|
||||||
|
? 65
|
||||||
|
: 0,
|
||||||
|
),
|
||||||
|
sliver: SliverAppBar(
|
||||||
|
leading: widget.leading,
|
||||||
|
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||||
|
actions: [
|
||||||
|
...?widget.actions,
|
||||||
|
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
|
||||||
|
],
|
||||||
|
backgroundColor: widget.backgroundColor,
|
||||||
|
foregroundColor: widget.foregroundColor,
|
||||||
|
actionsIconTheme: widget.actionsIconTheme,
|
||||||
|
centerTitle: widget.centerTitle,
|
||||||
|
titleSpacing: widget.titleSpacing,
|
||||||
|
leadingWidth: widget.leadingWidth,
|
||||||
|
toolbarTextStyle: widget.toolbarTextStyle,
|
||||||
|
titleTextStyle: widget.titleTextStyle,
|
||||||
|
title: widget.title,
|
||||||
|
pinned: widget.pinned,
|
||||||
|
floating: widget.floating,
|
||||||
|
snap: widget.snap,
|
||||||
|
stretch: widget.stretch,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return LayoutBuilder(builder: (context, constrains) {
|
return LayoutBuilder(builder: (context, constrains) {
|
||||||
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
|
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
|
||||||
final hasLeadingOrCanPop =
|
final hasLeadingOrCanPop =
|
||||||
@ -349,10 +424,7 @@ class WindowButton extends StatelessWidget {
|
|||||||
|
|
||||||
class MinimizeWindowButton extends WindowButton {
|
class MinimizeWindowButton extends WindowButton {
|
||||||
MinimizeWindowButton(
|
MinimizeWindowButton(
|
||||||
{super.key,
|
{super.key, super.colors, super.onPressed, bool? animate})
|
||||||
super.colors,
|
|
||||||
super.onPressed,
|
|
||||||
bool? animate})
|
|
||||||
: super(
|
: super(
|
||||||
animate: animate ?? false,
|
animate: animate ?? false,
|
||||||
iconBuilder: (buttonContext) =>
|
iconBuilder: (buttonContext) =>
|
||||||
@ -362,10 +434,7 @@ class MinimizeWindowButton extends WindowButton {
|
|||||||
|
|
||||||
class MaximizeWindowButton extends WindowButton {
|
class MaximizeWindowButton extends WindowButton {
|
||||||
MaximizeWindowButton(
|
MaximizeWindowButton(
|
||||||
{super.key,
|
{super.key, super.colors, super.onPressed, bool? animate})
|
||||||
super.colors,
|
|
||||||
super.onPressed,
|
|
||||||
bool? animate})
|
|
||||||
: super(
|
: super(
|
||||||
animate: animate ?? false,
|
animate: animate ?? false,
|
||||||
iconBuilder: (buttonContext) =>
|
iconBuilder: (buttonContext) =>
|
||||||
@ -374,11 +443,7 @@ class MaximizeWindowButton extends WindowButton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RestoreWindowButton extends WindowButton {
|
class RestoreWindowButton extends WindowButton {
|
||||||
RestoreWindowButton(
|
RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
|
||||||
{super.key,
|
|
||||||
super.colors,
|
|
||||||
super.onPressed,
|
|
||||||
bool? animate})
|
|
||||||
: super(
|
: super(
|
||||||
animate: animate ?? false,
|
animate: animate ?? false,
|
||||||
iconBuilder: (buttonContext) =>
|
iconBuilder: (buttonContext) =>
|
||||||
@ -394,10 +459,7 @@ final _defaultCloseButtonColors = WindowButtonColors(
|
|||||||
|
|
||||||
class CloseWindowButton extends WindowButton {
|
class CloseWindowButton extends WindowButton {
|
||||||
CloseWindowButton(
|
CloseWindowButton(
|
||||||
{super.key,
|
{super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
|
||||||
WindowButtonColors? colors,
|
|
||||||
super.onPressed,
|
|
||||||
bool? animate})
|
|
||||||
: super(
|
: super(
|
||||||
colors: colors ?? _defaultCloseButtonColors,
|
colors: colors ?? _defaultCloseButtonColors,
|
||||||
animate: animate ?? false,
|
animate: animate ?? false,
|
||||||
|
@ -18,7 +18,7 @@ import 'package:spotube/extensions/duration.dart';
|
|||||||
import 'package:spotube/extensions/image.dart';
|
import 'package:spotube/extensions/image.dart';
|
||||||
import 'package:spotube/models/local_track.dart';
|
import 'package:spotube/models/local_track.dart';
|
||||||
import 'package:spotube/provider/blacklist_provider.dart';
|
import 'package:spotube/provider/blacklist_provider.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||||
|
|
||||||
class TrackTile extends HookConsumerWidget {
|
class TrackTile extends HookConsumerWidget {
|
||||||
/// [index] will not be shown if null
|
/// [index] will not be shown if null
|
||||||
@ -30,6 +30,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
final VoidCallback? onLongPress;
|
final VoidCallback? onLongPress;
|
||||||
final bool userPlaylist;
|
final bool userPlaylist;
|
||||||
final String? playlistId;
|
final String? playlistId;
|
||||||
|
final ProxyPlaylist playlist;
|
||||||
|
|
||||||
final List<Widget>? leadingActions;
|
final List<Widget>? leadingActions;
|
||||||
|
|
||||||
@ -38,6 +39,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
this.index,
|
this.index,
|
||||||
required this.track,
|
required this.track,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
|
required this.playlist,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
this.onChanged,
|
this.onChanged,
|
||||||
@ -48,7 +50,6 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
final blacklist = ref.watch(BlackListNotifier.provider);
|
final blacklist = ref.watch(BlackListNotifier.provider);
|
||||||
@ -65,10 +66,10 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
|
|
||||||
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
|
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
|
||||||
|
|
||||||
final isPlaying = track.id == playlist.activeTrack?.id;
|
|
||||||
|
|
||||||
final isLoading = useState(false);
|
final isLoading = useState(false);
|
||||||
|
|
||||||
|
final isPlaying = playlist.activeTrack?.id == track.id;
|
||||||
|
|
||||||
final isSelected = isPlaying || isLoading.value;
|
final isSelected = isPlaying || isLoading.value;
|
||||||
|
|
||||||
return LayoutBuilder(builder: (context, constrains) {
|
return LayoutBuilder(builder: (context, constrains) {
|
||||||
|
@ -8,12 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/fake.dart';
|
import 'package:spotube/collections/fake.dart';
|
||||||
|
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
||||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
|
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
|
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
|
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
|
||||||
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget {
|
|||||||
loadingBuilder: (context) => Skeletonizer(
|
loadingBuilder: (context) => Skeletonizer(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
child: TrackTile(
|
child: TrackTile(
|
||||||
|
playlist: playlist,
|
||||||
track: FakeData.track,
|
track: FakeData.track,
|
||||||
index: 0,
|
index: 0,
|
||||||
),
|
),
|
||||||
@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
10,
|
10,
|
||||||
(index) => TrackTile(track: FakeData.track, index: index),
|
(index) => TrackTile(
|
||||||
|
track: FakeData.track,
|
||||||
|
index: index,
|
||||||
|
playlist: playlist,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final track = tracks[index];
|
final track = tracks[index];
|
||||||
return TrackTile(
|
return TrackTile(
|
||||||
|
playlist: playlist,
|
||||||
track: track,
|
track: track,
|
||||||
index: index,
|
index: index,
|
||||||
selected: trackViewState.selectedTrackIds.contains(track.id!),
|
selected: trackViewState.selectedTrackIds.contains(track.id!),
|
||||||
@ -125,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isActive || playlist.tracks.contains(track)) {
|
final isRemoteDevice =
|
||||||
await playlistNotifier.jumpToTrack(track);
|
await showSelectDeviceDialog(context, ref);
|
||||||
|
|
||||||
|
if (isRemoteDevice) {
|
||||||
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
|
final remoteQueue = ref.read(queueProvider);
|
||||||
|
if (remoteQueue.collections.contains(props.collectionId) ||
|
||||||
|
remoteQueue.tracks.any((s) => s.id == track.id)) {
|
||||||
|
await playlistNotifier.jumpToTrack(track);
|
||||||
|
} else {
|
||||||
|
final tracks = await props.pagination.onFetchAll();
|
||||||
|
await remotePlayback.load(
|
||||||
|
WebSocketLoadEventData(
|
||||||
|
tracks: tracks,
|
||||||
|
collectionId: props.collectionId,
|
||||||
|
initialIndex: index,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
final tracks = await props.pagination.onFetchAll();
|
if (isActive || playlist.tracks.contains(track)) {
|
||||||
await playlistNotifier.load(
|
await playlistNotifier.jumpToTrack(track);
|
||||||
tracks,
|
} else {
|
||||||
initialIndex: index,
|
final tracks = await props.pagination.onFetchAll();
|
||||||
autoPlay: true,
|
await playlistNotifier.load(
|
||||||
);
|
tracks,
|
||||||
playlistNotifier.addCollection(props.collectionId);
|
initialIndex: index,
|
||||||
|
autoPlay: true,
|
||||||
|
);
|
||||||
|
playlistNotifier.addCollection(props.collectionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -6,8 +6,11 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
|
|
||||||
@ -43,13 +46,25 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
|||||||
|
|
||||||
final allTracks = await props.pagination.onFetchAll();
|
final allTracks = await props.pagination.onFetchAll();
|
||||||
|
|
||||||
await playlistNotifier.load(
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
allTracks,
|
if (isRemoteDevice) {
|
||||||
autoPlay: true,
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
initialIndex: Random().nextInt(allTracks.length),
|
await remotePlayback.load(
|
||||||
);
|
WebSocketLoadEventData(
|
||||||
await audioPlayer.setShuffle(true);
|
tracks: allTracks,
|
||||||
playlistNotifier.addCollection(props.collectionId);
|
collectionId: props.collectionId,
|
||||||
|
initialIndex: Random().nextInt(allTracks.length)),
|
||||||
|
);
|
||||||
|
await remotePlayback.setShuffle(true);
|
||||||
|
} else {
|
||||||
|
await playlistNotifier.load(
|
||||||
|
allTracks,
|
||||||
|
autoPlay: true,
|
||||||
|
initialIndex: Random().nextInt(allTracks.length),
|
||||||
|
);
|
||||||
|
await audioPlayer.setShuffle(true);
|
||||||
|
playlistNotifier.addCollection(props.collectionId);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
|||||||
|
|
||||||
final allTracks = await props.pagination.onFetchAll();
|
final allTracks = await props.pagination.onFetchAll();
|
||||||
|
|
||||||
await playlistNotifier.load(allTracks, autoPlay: true);
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
playlistNotifier.addCollection(props.collectionId);
|
if (isRemoteDevice) {
|
||||||
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
|
await remotePlayback.load(
|
||||||
|
WebSocketLoadEventData(
|
||||||
|
tracks: allTracks,
|
||||||
|
collectionId: props.collectionId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await playlistNotifier.load(allTracks, autoPlay: true);
|
||||||
|
playlistNotifier.addCollection(props.collectionId);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
|
@ -313,5 +313,12 @@
|
|||||||
"help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.",
|
"help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.",
|
||||||
"contribute_on_github": "Contribute on GitHub",
|
"contribute_on_github": "Contribute on GitHub",
|
||||||
"donate_on_open_collective": "Donate on Open Collective",
|
"donate_on_open_collective": "Donate on Open Collective",
|
||||||
"browse_anonymously": "Browse Anonymously"
|
"browse_anonymously": "Browse Anonymously",
|
||||||
|
"enable_connect": "Enable Connect",
|
||||||
|
"enable_connect_description": "Control Spotube from other devices",
|
||||||
|
"devices": "Devices",
|
||||||
|
"select": "Select",
|
||||||
|
"connect_client_alert": "You're being controlled by {client}",
|
||||||
|
"this_device": "This Device",
|
||||||
|
"remote": "Remote"
|
||||||
}
|
}
|
@ -23,6 +23,9 @@ import 'package:spotube/l10n/l10n.dart';
|
|||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/models/skip_segment.dart';
|
import 'package:spotube/models/skip_segment.dart';
|
||||||
import 'package:spotube/models/source_match.dart';
|
import 'package:spotube/models/source_match.dart';
|
||||||
|
import 'package:spotube/provider/connect/clients.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
|
import 'package:spotube/provider/connect/server.dart';
|
||||||
import 'package:spotube/provider/palette_provider.dart';
|
import 'package:spotube/provider/palette_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
@ -180,6 +183,9 @@ class SpotubeState extends ConsumerState<Spotube> {
|
|||||||
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
|
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
|
ref.listen(connectServerProvider, (_, __) {});
|
||||||
|
ref.listen(connectClientsProvider, (_, __) {});
|
||||||
|
|
||||||
useDisableBatteryOptimizations();
|
useDisableBatteryOptimizations();
|
||||||
useInitSysTray(ref);
|
useInitSysTray(ref);
|
||||||
useDeepLinking(ref);
|
useDeepLinking(ref);
|
||||||
|
16
lib/models/connect/connect.dart
Normal file
16
lib/models/connect/connect.dart
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
library connect;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/extensions/track.dart';
|
||||||
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||||
|
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||||
|
|
||||||
|
part 'connect.freezed.dart';
|
||||||
|
part 'connect.g.dart';
|
||||||
|
|
||||||
|
part 'ws_event.dart';
|
||||||
|
part 'load.dart';
|
216
lib/models/connect/connect.freezed.dart
Normal file
216
lib/models/connect/connect.freezed.dart
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
// coverage:ignore-file
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'connect.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
final _privateConstructorUsedError = UnsupportedError(
|
||||||
|
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
|
||||||
|
|
||||||
|
WebSocketLoadEventData _$WebSocketLoadEventDataFromJson(
|
||||||
|
Map<String, dynamic> json) {
|
||||||
|
return _WebSocketLoadEventData.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$WebSocketLoadEventData {
|
||||||
|
@JsonKey(name: 'tracks', toJson: _tracksJson)
|
||||||
|
List<Track> get tracks => throw _privateConstructorUsedError;
|
||||||
|
String? get collectionId => throw _privateConstructorUsedError;
|
||||||
|
int? get initialIndex => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
$WebSocketLoadEventDataCopyWith<WebSocketLoadEventData> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $WebSocketLoadEventDataCopyWith<$Res> {
|
||||||
|
factory $WebSocketLoadEventDataCopyWith(WebSocketLoadEventData value,
|
||||||
|
$Res Function(WebSocketLoadEventData) then) =
|
||||||
|
_$WebSocketLoadEventDataCopyWithImpl<$Res, WebSocketLoadEventData>;
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
|
||||||
|
String? collectionId,
|
||||||
|
int? initialIndex});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$WebSocketLoadEventDataCopyWithImpl<$Res,
|
||||||
|
$Val extends WebSocketLoadEventData>
|
||||||
|
implements $WebSocketLoadEventDataCopyWith<$Res> {
|
||||||
|
_$WebSocketLoadEventDataCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? tracks = null,
|
||||||
|
Object? collectionId = freezed,
|
||||||
|
Object? initialIndex = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_value.copyWith(
|
||||||
|
tracks: null == tracks
|
||||||
|
? _value.tracks
|
||||||
|
: tracks // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<Track>,
|
||||||
|
collectionId: freezed == collectionId
|
||||||
|
? _value.collectionId
|
||||||
|
: collectionId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
initialIndex: freezed == initialIndex
|
||||||
|
? _value.initialIndex
|
||||||
|
: initialIndex // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
) as $Val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res>
|
||||||
|
implements $WebSocketLoadEventDataCopyWith<$Res> {
|
||||||
|
factory _$$WebSocketLoadEventDataImplCopyWith(
|
||||||
|
_$WebSocketLoadEventDataImpl value,
|
||||||
|
$Res Function(_$WebSocketLoadEventDataImpl) then) =
|
||||||
|
__$$WebSocketLoadEventDataImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call(
|
||||||
|
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
|
||||||
|
String? collectionId,
|
||||||
|
int? initialIndex});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>
|
||||||
|
extends _$WebSocketLoadEventDataCopyWithImpl<$Res,
|
||||||
|
_$WebSocketLoadEventDataImpl>
|
||||||
|
implements _$$WebSocketLoadEventDataImplCopyWith<$Res> {
|
||||||
|
__$$WebSocketLoadEventDataImplCopyWithImpl(
|
||||||
|
_$WebSocketLoadEventDataImpl _value,
|
||||||
|
$Res Function(_$WebSocketLoadEventDataImpl) _then)
|
||||||
|
: super(_value, _then);
|
||||||
|
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? tracks = null,
|
||||||
|
Object? collectionId = freezed,
|
||||||
|
Object? initialIndex = freezed,
|
||||||
|
}) {
|
||||||
|
return _then(_$WebSocketLoadEventDataImpl(
|
||||||
|
tracks: null == tracks
|
||||||
|
? _value._tracks
|
||||||
|
: tracks // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<Track>,
|
||||||
|
collectionId: freezed == collectionId
|
||||||
|
? _value.collectionId
|
||||||
|
: collectionId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
initialIndex: freezed == initialIndex
|
||||||
|
? _value.initialIndex
|
||||||
|
: initialIndex // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData {
|
||||||
|
_$WebSocketLoadEventDataImpl(
|
||||||
|
{@JsonKey(name: 'tracks', toJson: _tracksJson)
|
||||||
|
required final List<Track> tracks,
|
||||||
|
this.collectionId,
|
||||||
|
this.initialIndex})
|
||||||
|
: _tracks = tracks;
|
||||||
|
|
||||||
|
factory _$WebSocketLoadEventDataImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$$WebSocketLoadEventDataImplFromJson(json);
|
||||||
|
|
||||||
|
final List<Track> _tracks;
|
||||||
|
@override
|
||||||
|
@JsonKey(name: 'tracks', toJson: _tracksJson)
|
||||||
|
List<Track> get tracks {
|
||||||
|
if (_tracks is EqualUnmodifiableListView) return _tracks;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String? collectionId;
|
||||||
|
@override
|
||||||
|
final int? initialIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$WebSocketLoadEventDataImpl &&
|
||||||
|
const DeepCollectionEquality().equals(other._tracks, _tracks) &&
|
||||||
|
(identical(other.collectionId, collectionId) ||
|
||||||
|
other.collectionId == collectionId) &&
|
||||||
|
(identical(other.initialIndex, initialIndex) ||
|
||||||
|
other.initialIndex == initialIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,
|
||||||
|
const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex);
|
||||||
|
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl>
|
||||||
|
get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl<
|
||||||
|
_$WebSocketLoadEventDataImpl>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$$WebSocketLoadEventDataImplToJson(
|
||||||
|
this,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _WebSocketLoadEventData implements WebSocketLoadEventData {
|
||||||
|
factory _WebSocketLoadEventData(
|
||||||
|
{@JsonKey(name: 'tracks', toJson: _tracksJson)
|
||||||
|
required final List<Track> tracks,
|
||||||
|
final String? collectionId,
|
||||||
|
final int? initialIndex}) = _$WebSocketLoadEventDataImpl;
|
||||||
|
|
||||||
|
factory _WebSocketLoadEventData.fromJson(Map<String, dynamic> json) =
|
||||||
|
_$WebSocketLoadEventDataImpl.fromJson;
|
||||||
|
|
||||||
|
@override
|
||||||
|
@JsonKey(name: 'tracks', toJson: _tracksJson)
|
||||||
|
List<Track> get tracks;
|
||||||
|
@override
|
||||||
|
String? get collectionId;
|
||||||
|
@override
|
||||||
|
int? get initialIndex;
|
||||||
|
@override
|
||||||
|
@JsonKey(ignore: true)
|
||||||
|
_$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl>
|
||||||
|
get copyWith => throw _privateConstructorUsedError;
|
||||||
|
}
|
25
lib/models/connect/connect.g.dart
Normal file
25
lib/models/connect/connect.g.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'connect.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson(
|
||||||
|
Map<String, dynamic> json) =>
|
||||||
|
_$WebSocketLoadEventDataImpl(
|
||||||
|
tracks: (json['tracks'] as List<dynamic>)
|
||||||
|
.map((e) => Track.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
collectionId: json['collectionId'] as String?,
|
||||||
|
initialIndex: json['initialIndex'] as int?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$WebSocketLoadEventDataImplToJson(
|
||||||
|
_$WebSocketLoadEventDataImpl instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'tracks': _tracksJson(instance.tracks),
|
||||||
|
'collectionId': instance.collectionId,
|
||||||
|
'initialIndex': instance.initialIndex,
|
||||||
|
};
|
27
lib/models/connect/load.dart
Normal file
27
lib/models/connect/load.dart
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
part of 'connect.dart';
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _tracksJson(List<Track> tracks) {
|
||||||
|
return tracks.map((e) => e.toJson()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class WebSocketLoadEventData with _$WebSocketLoadEventData {
|
||||||
|
factory WebSocketLoadEventData({
|
||||||
|
@JsonKey(name: 'tracks', toJson: _tracksJson) required List<Track> tracks,
|
||||||
|
String? collectionId,
|
||||||
|
int? initialIndex,
|
||||||
|
}) = _WebSocketLoadEventData;
|
||||||
|
|
||||||
|
factory WebSocketLoadEventData.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$WebSocketLoadEventDataFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketLoadEvent extends WebSocketEvent<WebSocketLoadEventData> {
|
||||||
|
WebSocketLoadEvent(WebSocketLoadEventData data) : super(WsEvent.load, data);
|
||||||
|
|
||||||
|
factory WebSocketLoadEvent.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WebSocketLoadEvent(
|
||||||
|
WebSocketLoadEventData.fromJson(json['data'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
374
lib/models/connect/ws_event.dart
Normal file
374
lib/models/connect/ws_event.dart
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
part of 'connect.dart';
|
||||||
|
|
||||||
|
enum WsEvent {
|
||||||
|
error,
|
||||||
|
volume,
|
||||||
|
removeTrack,
|
||||||
|
addTrack,
|
||||||
|
reorder,
|
||||||
|
shuffle,
|
||||||
|
loop,
|
||||||
|
seek,
|
||||||
|
duration,
|
||||||
|
queue,
|
||||||
|
position,
|
||||||
|
playing,
|
||||||
|
resume,
|
||||||
|
pause,
|
||||||
|
load,
|
||||||
|
next,
|
||||||
|
previous,
|
||||||
|
jump,
|
||||||
|
stop;
|
||||||
|
|
||||||
|
static WsEvent fromString(String value) {
|
||||||
|
return WsEvent.values.firstWhere((e) => e.name == value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef EventCallback<T> = FutureOr<void> Function(T event);
|
||||||
|
|
||||||
|
class WebSocketEvent<T> {
|
||||||
|
final WsEvent type;
|
||||||
|
final T data;
|
||||||
|
|
||||||
|
WebSocketEvent(this.type, this.data);
|
||||||
|
|
||||||
|
factory WebSocketEvent.fromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
T Function(dynamic) fromJson,
|
||||||
|
) {
|
||||||
|
return WebSocketEvent(
|
||||||
|
WsEvent.fromString(json["type"]),
|
||||||
|
fromJson(json["data"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String toJson() {
|
||||||
|
return jsonEncode({
|
||||||
|
"type": type.name,
|
||||||
|
"data": data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onPosition(
|
||||||
|
EventCallback<WebSocketPositionEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.position) {
|
||||||
|
await callback(WebSocketPositionEvent.fromJson({"data": data}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onPlaying(
|
||||||
|
EventCallback<WebSocketPlayingEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.playing) {
|
||||||
|
await callback(WebSocketPlayingEvent(data as bool));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onResume(
|
||||||
|
EventCallback<WebSocketResumeEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.resume) {
|
||||||
|
await callback(WebSocketResumeEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onPause(
|
||||||
|
EventCallback<WebSocketPauseEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.pause) {
|
||||||
|
await callback(WebSocketPauseEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onStop(
|
||||||
|
EventCallback<WebSocketStopEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.stop) {
|
||||||
|
await callback(WebSocketStopEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onLoad(
|
||||||
|
EventCallback<WebSocketLoadEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.load) {
|
||||||
|
await callback(
|
||||||
|
WebSocketLoadEvent(
|
||||||
|
WebSocketLoadEventData.fromJson(data as Map<String, dynamic>),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onNext(
|
||||||
|
EventCallback<WebSocketNextEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.next) {
|
||||||
|
await callback(WebSocketNextEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onPrevious(
|
||||||
|
EventCallback<WebSocketPreviousEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.previous) {
|
||||||
|
await callback(WebSocketPreviousEvent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onJump(
|
||||||
|
EventCallback<WebSocketJumpEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.jump) {
|
||||||
|
await callback(WebSocketJumpEvent(data as int));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onError(
|
||||||
|
EventCallback<WebSocketErrorEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.error) {
|
||||||
|
await callback(WebSocketErrorEvent(data as String));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onQueue(
|
||||||
|
EventCallback<WebSocketQueueEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.queue) {
|
||||||
|
await callback(
|
||||||
|
WebSocketQueueEvent.fromJson(data as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onDuration(
|
||||||
|
EventCallback<WebSocketDurationEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.duration) {
|
||||||
|
await callback(
|
||||||
|
WebSocketDurationEvent(
|
||||||
|
Duration(seconds: data as int),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onSeek(
|
||||||
|
EventCallback<WebSocketSeekEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.seek) {
|
||||||
|
await callback(
|
||||||
|
WebSocketSeekEvent(
|
||||||
|
Duration(seconds: data as int),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onShuffle(
|
||||||
|
EventCallback<WebSocketShuffleEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.shuffle) {
|
||||||
|
await callback(WebSocketShuffleEvent(data as bool));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onLoop(
|
||||||
|
EventCallback<WebSocketLoopEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.loop) {
|
||||||
|
await callback(
|
||||||
|
WebSocketLoopEvent(
|
||||||
|
PlaybackLoopMode.fromString(data as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onRemoveTrack(
|
||||||
|
EventCallback<WebSocketRemoveTrackEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.removeTrack) {
|
||||||
|
await callback(WebSocketRemoveTrackEvent(data as String));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onAddTrack(
|
||||||
|
EventCallback<WebSocketAddTrackEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.addTrack) {
|
||||||
|
await callback(
|
||||||
|
WebSocketAddTrackEvent.fromJson(data as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onReorder(
|
||||||
|
EventCallback<WebSocketReorderEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.reorder) {
|
||||||
|
await callback(
|
||||||
|
WebSocketReorderEvent.fromJson(data as Map<String, dynamic>));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onVolume(
|
||||||
|
EventCallback<WebSocketVolumeEvent> callback,
|
||||||
|
) async {
|
||||||
|
if (type == WsEvent.volume) {
|
||||||
|
await callback(WebSocketVolumeEvent(data as double));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketLoopEvent extends WebSocketEvent<PlaybackLoopMode> {
|
||||||
|
WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data);
|
||||||
|
|
||||||
|
WebSocketLoopEvent.fromJson(Map<String, dynamic> json)
|
||||||
|
: super(
|
||||||
|
WsEvent.loop, PlaybackLoopMode.fromString(json["data"] as String));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toJson() {
|
||||||
|
return jsonEncode({
|
||||||
|
"type": type.name,
|
||||||
|
"data": data.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketPositionEvent extends WebSocketEvent<Duration> {
|
||||||
|
WebSocketPositionEvent(Duration data) : super(WsEvent.position, data);
|
||||||
|
|
||||||
|
WebSocketPositionEvent.fromJson(Map<String, dynamic> json)
|
||||||
|
: super(WsEvent.position, Duration(seconds: json["data"] as int));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toJson() {
|
||||||
|
return jsonEncode({
|
||||||
|
"type": type.name,
|
||||||
|
"data": data.inSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketDurationEvent extends WebSocketEvent<Duration> {
|
||||||
|
WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data);
|
||||||
|
|
||||||
|
WebSocketDurationEvent.fromJson(Map<String, dynamic> json)
|
||||||
|
: super(WsEvent.duration, Duration(seconds: json["data"] as int));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toJson() {
|
||||||
|
return jsonEncode({
|
||||||
|
"type": type.name,
|
||||||
|
"data": data.inSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketSeekEvent extends WebSocketEvent<Duration> {
|
||||||
|
WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data);
|
||||||
|
|
||||||
|
WebSocketSeekEvent.fromJson(Map<String, dynamic> json)
|
||||||
|
: super(WsEvent.seek, Duration(seconds: json["data"] as int));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toJson() {
|
||||||
|
return jsonEncode({
|
||||||
|
"type": type.name,
|
||||||
|
"data": data.inSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketShuffleEvent extends WebSocketEvent<bool> {
|
||||||
|
WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketPlayingEvent extends WebSocketEvent<bool> {
|
||||||
|
WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketResumeEvent extends WebSocketEvent<void> {
|
||||||
|
WebSocketResumeEvent() : super(WsEvent.resume, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketPauseEvent extends WebSocketEvent<void> {
|
||||||
|
WebSocketPauseEvent() : super(WsEvent.pause, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketStopEvent extends WebSocketEvent<void> {
|
||||||
|
WebSocketStopEvent() : super(WsEvent.stop, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketNextEvent extends WebSocketEvent<void> {
|
||||||
|
WebSocketNextEvent() : super(WsEvent.next, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketPreviousEvent extends WebSocketEvent<void> {
|
||||||
|
WebSocketPreviousEvent() : super(WsEvent.previous, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketJumpEvent extends WebSocketEvent<int> {
|
||||||
|
WebSocketJumpEvent(int data) : super(WsEvent.jump, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketErrorEvent extends WebSocketEvent<String> {
|
||||||
|
WebSocketErrorEvent(String data) : super(WsEvent.error, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketQueueEvent extends WebSocketEvent<ProxyPlaylist> {
|
||||||
|
WebSocketQueueEvent(ProxyPlaylist data) : super(WsEvent.queue, data);
|
||||||
|
|
||||||
|
factory WebSocketQueueEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
|
WebSocketQueueEvent(
|
||||||
|
ProxyPlaylist.fromJsonRaw(json),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketRemoveTrackEvent extends WebSocketEvent<String> {
|
||||||
|
WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketAddTrackEvent extends WebSocketEvent<Track> {
|
||||||
|
WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data);
|
||||||
|
|
||||||
|
WebSocketAddTrackEvent.fromJson(Map<String, dynamic> json)
|
||||||
|
: super(
|
||||||
|
WsEvent.addTrack,
|
||||||
|
Track.fromJson(json["data"] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef ReorderData = ({int oldIndex, int newIndex});
|
||||||
|
|
||||||
|
class WebSocketReorderEvent extends WebSocketEvent<ReorderData> {
|
||||||
|
WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data);
|
||||||
|
|
||||||
|
factory WebSocketReorderEvent.fromJson(Map<String, dynamic> json) =>
|
||||||
|
WebSocketReorderEvent(
|
||||||
|
(
|
||||||
|
oldIndex: json["oldIndex"] as int,
|
||||||
|
newIndex: json["newIndex"] as int,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toJson() {
|
||||||
|
return jsonEncode({
|
||||||
|
"type": type.name,
|
||||||
|
"data": {
|
||||||
|
"oldIndex": data.oldIndex,
|
||||||
|
"newIndex": data.newIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketVolumeEvent extends WebSocketEvent<double> {
|
||||||
|
WebSocketVolumeEvent(double data) : super(WsEvent.volume, data);
|
||||||
|
}
|
@ -4,8 +4,11 @@ import 'package:skeletonizer/skeletonizer.dart';
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/fake.dart';
|
import 'package:spotube/collections/fake.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/spotify/spotify.dart';
|
import 'package:spotube/provider/spotify/spotify.dart';
|
||||||
|
|
||||||
@ -39,16 +42,41 @@ class ArtistPageTopTracks extends HookConsumerWidget {
|
|||||||
|
|
||||||
void playPlaylist(List<Track> tracks, {Track? currentTrack}) async {
|
void playPlaylist(List<Track> tracks, {Track? currentTrack}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
if (!isPlaylistPlaying) {
|
|
||||||
playlistNotifier.load(
|
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||||
tracks,
|
if (isRemoteDevice) {
|
||||||
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
autoPlay: true,
|
final remotePlaylist = ref.read(queueProvider);
|
||||||
);
|
|
||||||
} else if (isPlaylistPlaying &&
|
final isPlaylistPlaying = remotePlaylist.containsTracks(tracks);
|
||||||
currentTrack.id != null &&
|
|
||||||
currentTrack.id != playlist.activeTrack?.id) {
|
if (!isPlaylistPlaying) {
|
||||||
await playlistNotifier.jumpToTrack(currentTrack);
|
await remotePlayback.load(
|
||||||
|
WebSocketLoadEventData(
|
||||||
|
tracks: tracks,
|
||||||
|
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isPlaylistPlaying &&
|
||||||
|
currentTrack.id != null &&
|
||||||
|
currentTrack.id != remotePlaylist.activeTrack?.id) {
|
||||||
|
final index = playlist.tracks
|
||||||
|
.toList()
|
||||||
|
.indexWhere((s) => s.id == currentTrack!.id);
|
||||||
|
await remotePlayback.jumpTo(index);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isPlaylistPlaying) {
|
||||||
|
playlistNotifier.load(
|
||||||
|
tracks,
|
||||||
|
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
|
autoPlay: true,
|
||||||
|
);
|
||||||
|
} else if (isPlaylistPlaying &&
|
||||||
|
currentTrack.id != null &&
|
||||||
|
currentTrack.id != playlist.activeTrack?.id) {
|
||||||
|
await playlistNotifier.jumpToTrack(currentTrack);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,6 +135,7 @@ class ArtistPageTopTracks extends HookConsumerWidget {
|
|||||||
final track = topTracks.elementAt(index);
|
final track = topTracks.elementAt(index);
|
||||||
return TrackTile(
|
return TrackTile(
|
||||||
index: index,
|
index: index,
|
||||||
|
playlist: playlist,
|
||||||
track: track,
|
track: track,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
playPlaylist(
|
playPlaylist(
|
||||||
|
93
lib/pages/connect/connect.dart
Normal file
93
lib/pages/connect/connect.dart
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/connect/local_devices.dart';
|
||||||
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/provider/connect/clients.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
|
class ConnectPage extends HookConsumerWidget {
|
||||||
|
const ConnectPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final ThemeData(:colorScheme, :textTheme) = Theme.of(context);
|
||||||
|
|
||||||
|
final connectClients = ref.watch(connectClientsProvider);
|
||||||
|
final connectClientsNotifier = ref.read(connectClientsProvider.notifier);
|
||||||
|
final discoveredDevices = connectClients.asData?.value.services;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: PageWindowTitleBar(
|
||||||
|
automaticallyImplyLeading: true,
|
||||||
|
title: Text(context.l10n.devices),
|
||||||
|
),
|
||||||
|
body: ListTileTheme(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
selectedTileColor: colorScheme.secondary.withOpacity(0.1),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(10.0),
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
context.l10n.remote,
|
||||||
|
style: textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(10),
|
||||||
|
SliverList.separated(
|
||||||
|
itemCount: discoveredDevices?.length ?? 0,
|
||||||
|
separatorBuilder: (context, index) => const Gap(10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final device = discoveredDevices![index];
|
||||||
|
final selected =
|
||||||
|
connectClients.asData?.value.resolvedService?.name ==
|
||||||
|
device.name;
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(SpotubeIcons.monitor),
|
||||||
|
title: Text(device.name),
|
||||||
|
subtitle: selected
|
||||||
|
? Text(
|
||||||
|
"${connectClients.asData?.value.resolvedService?.host}"
|
||||||
|
":${connectClients.asData?.value.resolvedService?.port}",
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
selected: selected,
|
||||||
|
onTap: () {
|
||||||
|
if (selected) {
|
||||||
|
ServiceUtils.push(
|
||||||
|
context,
|
||||||
|
"/connect/control",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
connectClientsNotifier.resolveService(device);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailing: selected
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(SpotubeIcons.power),
|
||||||
|
onPressed: () =>
|
||||||
|
connectClientsNotifier.clearResolvedService(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const ConnectPageLocalDevices(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
317
lib/pages/connect/control/control.dart
Normal file
317
lib/pages/connect/control/control.dart
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/player/player_queue.dart';
|
||||||
|
import 'package:spotube/components/player/volume_slider.dart';
|
||||||
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
|
import 'package:spotube/components/shared/links/anchor_button.dart';
|
||||||
|
import 'package:spotube/components/shared/links/artist_link.dart';
|
||||||
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/extensions/duration.dart';
|
||||||
|
import 'package:spotube/extensions/image.dart';
|
||||||
|
import 'package:spotube/provider/connect/clients.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
|
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
|
class ConnectControlPage extends HookConsumerWidget {
|
||||||
|
const ConnectControlPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, ref) {
|
||||||
|
final ThemeData(:textTheme, :colorScheme) = Theme.of(context);
|
||||||
|
|
||||||
|
final resolvedService =
|
||||||
|
ref.watch(connectClientsProvider).asData?.value.resolvedService;
|
||||||
|
final connectNotifier = ref.read(connectProvider.notifier);
|
||||||
|
final playlist = ref.watch(queueProvider);
|
||||||
|
final playing = ref.watch(playingProvider);
|
||||||
|
final shuffled = ref.watch(shuffleProvider);
|
||||||
|
final loopMode = ref.watch(loopModeProvider);
|
||||||
|
|
||||||
|
final resumePauseStyle = IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primary,
|
||||||
|
foregroundColor: colorScheme.onPrimary,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
iconSize: 24,
|
||||||
|
);
|
||||||
|
final buttonStyle = IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.surface.withOpacity(0.4),
|
||||||
|
minimumSize: const Size(28, 28),
|
||||||
|
);
|
||||||
|
|
||||||
|
final activeButtonStyle = IconButton.styleFrom(
|
||||||
|
backgroundColor: colorScheme.primaryContainer,
|
||||||
|
foregroundColor: colorScheme.onPrimaryContainer,
|
||||||
|
minimumSize: const Size(28, 28),
|
||||||
|
);
|
||||||
|
|
||||||
|
final playerQueue = Consumer(builder: (context, ref, _) {
|
||||||
|
final playlist = ref.watch(queueProvider);
|
||||||
|
return PlayerQueue(
|
||||||
|
playlist: playlist,
|
||||||
|
floating: true,
|
||||||
|
onJump: (track) async {
|
||||||
|
final index = playlist.tracks.toList().indexOf(track);
|
||||||
|
connectNotifier.jumpTo(index);
|
||||||
|
},
|
||||||
|
onRemove: (track) async {
|
||||||
|
await connectNotifier.removeTrack(track);
|
||||||
|
},
|
||||||
|
onStop: () async => connectNotifier.stop(),
|
||||||
|
onReorder: (oldIndex, newIndex) async {
|
||||||
|
await connectNotifier.reorder(
|
||||||
|
(oldIndex: oldIndex, newIndex: newIndex),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.listen(connectClientsProvider, (prev, next) {
|
||||||
|
if (next.asData?.value.resolvedService == null) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: PageWindowTitleBar(
|
||||||
|
title: Text(resolvedService!.name),
|
||||||
|
automaticallyImplyLeading: true,
|
||||||
|
),
|
||||||
|
body: LayoutBuilder(builder: (context, constrains) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 10,
|
||||||
|
).copyWith(top: 0),
|
||||||
|
constraints:
|
||||||
|
const BoxConstraints(maxHeight: 400, maxWidth: 400),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: UniversalImage(
|
||||||
|
path: (playlist.activeTrack?.album?.images)
|
||||||
|
.asUrlString(
|
||||||
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
|
),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
sliver: SliverMainAxisGroup(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: AnchorButton(
|
||||||
|
playlist.activeTrack?.name ?? "",
|
||||||
|
style: textTheme.titleLarge!,
|
||||||
|
onTap: () {
|
||||||
|
ServiceUtils.push(
|
||||||
|
context,
|
||||||
|
"/track/${playlist.activeTrack?.id}",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: ArtistLink(
|
||||||
|
artists: playlist.activeTrack?.artists ?? [],
|
||||||
|
textStyle: textTheme.bodyMedium!,
|
||||||
|
mainAxisAlignment: WrapAlignment.start,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(30),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final position = ref.watch(positionProvider);
|
||||||
|
final duration = ref.watch(durationProvider);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Slider(
|
||||||
|
value: position > duration
|
||||||
|
? 0
|
||||||
|
: position.inSeconds.toDouble(),
|
||||||
|
min: 0,
|
||||||
|
max: duration.inSeconds.toDouble(),
|
||||||
|
onChanged: (value) {
|
||||||
|
connectNotifier
|
||||||
|
.seek(Duration(seconds: value.toInt()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(position.toHumanReadableString()),
|
||||||
|
Text(duration.toHumanReadableString()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: shuffled
|
||||||
|
? context.l10n.unshuffle_playlist
|
||||||
|
: context.l10n.shuffle_playlist,
|
||||||
|
icon: const Icon(SpotubeIcons.shuffle),
|
||||||
|
style: shuffled ? activeButtonStyle : buttonStyle,
|
||||||
|
onPressed: playlist.activeTrack == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
connectNotifier.setShuffle(!shuffled);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: context.l10n.previous_track,
|
||||||
|
icon: const Icon(SpotubeIcons.skipBack),
|
||||||
|
onPressed: playlist.activeTrack == null
|
||||||
|
? null
|
||||||
|
: connectNotifier.previous,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: playing
|
||||||
|
? context.l10n.pause_playback
|
||||||
|
: context.l10n.resume_playback,
|
||||||
|
icon: playlist.activeTrack == null
|
||||||
|
? SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
playing
|
||||||
|
? SpotubeIcons.pause
|
||||||
|
: SpotubeIcons.play,
|
||||||
|
),
|
||||||
|
style: resumePauseStyle,
|
||||||
|
onPressed: playlist.activeTrack == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
if (playing) {
|
||||||
|
connectNotifier.pause();
|
||||||
|
} else {
|
||||||
|
connectNotifier.resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: context.l10n.next_track,
|
||||||
|
icon: const Icon(SpotubeIcons.skipForward),
|
||||||
|
onPressed: playlist.activeTrack == null
|
||||||
|
? null
|
||||||
|
: connectNotifier.next,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: loopMode == PlaybackLoopMode.one
|
||||||
|
? context.l10n.loop_track
|
||||||
|
: loopMode == PlaybackLoopMode.all
|
||||||
|
? context.l10n.repeat_playlist
|
||||||
|
: null,
|
||||||
|
icon: Icon(
|
||||||
|
loopMode == PlaybackLoopMode.one
|
||||||
|
? SpotubeIcons.repeatOne
|
||||||
|
: SpotubeIcons.repeat,
|
||||||
|
),
|
||||||
|
style: loopMode == PlaybackLoopMode.one ||
|
||||||
|
loopMode == PlaybackLoopMode.all
|
||||||
|
? activeButtonStyle
|
||||||
|
: buttonStyle,
|
||||||
|
onPressed: playlist.activeTrack == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
connectNotifier.setLoopMode(
|
||||||
|
switch (loopMode) {
|
||||||
|
PlaybackLoopMode.all =>
|
||||||
|
PlaybackLoopMode.one,
|
||||||
|
PlaybackLoopMode.one =>
|
||||||
|
PlaybackLoopMode.none,
|
||||||
|
PlaybackLoopMode.none =>
|
||||||
|
PlaybackLoopMode.all,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(30),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Consumer(builder: (context, ref, _) {
|
||||||
|
final volume = ref.watch(volumeProvider);
|
||||||
|
return VolumeSlider(
|
||||||
|
fullWidth: true,
|
||||||
|
value: volume,
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(volumeProvider.notifier).state = value;
|
||||||
|
connectNotifier.setVolume(value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverGap(30),
|
||||||
|
if (constrains.mdAndDown)
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(SpotubeIcons.queue),
|
||||||
|
label: Text(context.l10n.queue),
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return playerQueue;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (constrains.lgAndUp) ...[
|
||||||
|
const VerticalDivider(thickness: 1),
|
||||||
|
Expanded(
|
||||||
|
child: playerQueue,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -3,6 +3,8 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
|
import 'package:spotube/components/connect/connect_device.dart';
|
||||||
import 'package:spotube/components/home/sections/featured.dart';
|
import 'package:spotube/components/home/sections/featured.dart';
|
||||||
import 'package:spotube/components/home/sections/friends.dart';
|
import 'package:spotube/components/home/sections/friends.dart';
|
||||||
import 'package:spotube/components/home/sections/genres.dart';
|
import 'package:spotube/components/home/sections/genres.dart';
|
||||||
@ -20,15 +22,21 @@ class HomePage extends HookConsumerWidget {
|
|||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar:
|
|
||||||
DesktopTools.platform.isLinux || DesktopTools.platform.isWindows
|
|
||||||
? const PageWindowTitleBar()
|
|
||||||
: null,
|
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
slivers: [
|
slivers: [
|
||||||
if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb)
|
PageWindowTitleBar.sliver(
|
||||||
const SliverGap(20),
|
pinned: DesktopTools.platform.isDesktop,
|
||||||
|
actions: [
|
||||||
|
const ConnectDeviceButton(),
|
||||||
|
const Gap(10),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
icon: const Icon(SpotubeIcons.user),
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
const Gap(10),
|
||||||
|
],
|
||||||
|
),
|
||||||
const HomeGenresSection(),
|
const HomeGenresSection(),
|
||||||
const SliverToBoxAdapter(child: HomeFeaturedSection()),
|
const SliverToBoxAdapter(child: HomeFeaturedSection()),
|
||||||
const HomePageFriendsSection(),
|
const HomePageFriendsSection(),
|
||||||
|
@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget {
|
|||||||
MediaQuery.of(context).size.height * .7,
|
MediaQuery.of(context).size.height * .7,
|
||||||
),
|
),
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return const PlayerQueue(floating: true);
|
return Consumer(builder: (context, ref, _) {
|
||||||
|
final playlist = ref
|
||||||
|
.watch(ProxyPlaylistNotifier.provider);
|
||||||
|
|
||||||
|
return PlayerQueue
|
||||||
|
.fromProxyPlaylistNotifier(
|
||||||
|
floating: true,
|
||||||
|
playlist: playlist,
|
||||||
|
notifier: ref
|
||||||
|
.read(ProxyPlaylistNotifier.notifier),
|
||||||
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -27,19 +27,17 @@ class WebViewLogin extends HookConsumerWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: InAppWebView(
|
child: InAppWebView(
|
||||||
initialOptions: InAppWebViewGroupOptions(
|
initialSettings: InAppWebViewSettings(
|
||||||
crossPlatform: InAppWebViewOptions(
|
userAgent:
|
||||||
userAgent:
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36",
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
initialUrlRequest: URLRequest(
|
initialUrlRequest: URLRequest(
|
||||||
url: Uri.parse("https://accounts.spotify.com/"),
|
url: WebUri("https://accounts.spotify.com/"),
|
||||||
),
|
),
|
||||||
androidOnPermissionRequest: (controller, origin, resources) async {
|
onPermissionRequest: (controller, permissionRequest) async {
|
||||||
return PermissionRequestResponse(
|
return PermissionResponse(
|
||||||
resources: resources,
|
resources: permissionRequest.resources,
|
||||||
action: PermissionRequestResponseAction.GRANT,
|
action: PermissionResponseAction.GRANT,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onLoadStop: (controller, action) async {
|
onLoadStop: (controller, action) async {
|
||||||
|
@ -16,7 +16,9 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart';
|
|||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/configurators/use_endless_playback.dart';
|
import 'package:spotube/hooks/configurators/use_endless_playback.dart';
|
||||||
import 'package:spotube/hooks/configurators/use_update_checker.dart';
|
import 'package:spotube/hooks/configurators/use_update_checker.dart';
|
||||||
|
import 'package:spotube/provider/connect/server.dart';
|
||||||
import 'package:spotube/provider/download_manager_provider.dart';
|
import 'package:spotube/provider/download_manager_provider.dart';
|
||||||
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/services/connectivity_adapter.dart';
|
import 'package:spotube/services/connectivity_adapter.dart';
|
||||||
import 'package:spotube/utils/persisted_state_notifier.dart';
|
import 'package:spotube/utils/persisted_state_notifier.dart';
|
||||||
|
|
||||||
@ -53,50 +55,75 @@ class RootApp extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
final subscription = ConnectionCheckerService
|
final subscriptions = [
|
||||||
.instance.onConnectivityChanged
|
ConnectionCheckerService.instance.onConnectivityChanged
|
||||||
.listen((status) {
|
.listen((status) {
|
||||||
if (status) {
|
if (status) {
|
||||||
|
scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
SpotubeIcons.wifi,
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(context.l10n.connection_restored),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: theme.colorScheme.primary,
|
||||||
|
showCloseIcon: true,
|
||||||
|
width: 350,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
scaffoldMessenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
SpotubeIcons.noWifi,
|
||||||
|
color: theme.colorScheme.onError,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(context.l10n.you_are_offline),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: theme.colorScheme.error,
|
||||||
|
showCloseIcon: true,
|
||||||
|
width: 300,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
connectClientStream.listen((clientOrigin) {
|
||||||
scaffoldMessenger.showSnackBar(
|
scaffoldMessenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
backgroundColor: Colors.yellow[600],
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
content: Row(
|
content: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
SpotubeIcons.wifi,
|
SpotubeIcons.error,
|
||||||
color: theme.colorScheme.onPrimary,
|
color: Colors.black,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(context.l10n.connection_restored),
|
Text(
|
||||||
],
|
context.l10n.connect_client_alert(clientOrigin),
|
||||||
),
|
style: const TextStyle(color: Colors.black),
|
||||||
backgroundColor: theme.colorScheme.primary,
|
|
||||||
showCloseIcon: true,
|
|
||||||
width: 350,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
scaffoldMessenger.showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
SpotubeIcons.noWifi,
|
|
||||||
color: theme.colorScheme.onError,
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(context.l10n.you_are_offline),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: theme.colorScheme.error,
|
|
||||||
showCloseIcon: true,
|
|
||||||
width: 300,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
})
|
||||||
});
|
];
|
||||||
|
|
||||||
return () {
|
return () {
|
||||||
subscription.cancel();
|
for (final subscription in subscriptions) {
|
||||||
|
subscription.cancel();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -191,7 +218,19 @@ class RootApp extends HookConsumerWidget {
|
|||||||
top: 40,
|
top: 40,
|
||||||
bottom: 100,
|
bottom: 100,
|
||||||
),
|
),
|
||||||
child: const PlayerQueue(floating: true),
|
child: Consumer(
|
||||||
|
builder: (context, ref, _) {
|
||||||
|
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||||
|
final playlistNotifier =
|
||||||
|
ref.read(ProxyPlaylistNotifier.notifier);
|
||||||
|
|
||||||
|
return PlayerQueue.fromProxyPlaylistNotifier(
|
||||||
|
floating: true,
|
||||||
|
playlist: playlist,
|
||||||
|
notifier: playlistNotifier,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
bottomNavigationBar: Column(
|
bottomNavigationBar: Column(
|
||||||
|
@ -3,8 +3,11 @@ import 'package:flutter/material.dart' hide Page;
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
|
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
|
||||||
|
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
|
import 'package:spotube/provider/connect/connect.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/spotify/spotify.dart';
|
import 'package:spotube/provider/spotify/spotify.dart';
|
||||||
|
|
||||||
@ -46,26 +49,60 @@ class SearchTracksSection extends HookConsumerWidget {
|
|||||||
return TrackTile(
|
return TrackTile(
|
||||||
index: i,
|
index: i,
|
||||||
track: track,
|
track: track,
|
||||||
|
playlist: playlist,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final isTrackPlaying = playlist.activeTrack?.id == track.id;
|
final isRemoteDevice =
|
||||||
if (!isTrackPlaying && context.mounted) {
|
await showSelectDeviceDialog(context, ref);
|
||||||
final shouldPlay = (playlist.tracks.length) > 20
|
|
||||||
? await showPromptDialog(
|
|
||||||
context: context,
|
|
||||||
title: context.l10n.playing_track(
|
|
||||||
track.name!,
|
|
||||||
),
|
|
||||||
message: context.l10n.queue_clear_alert(
|
|
||||||
playlist.tracks.length,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: true;
|
|
||||||
|
|
||||||
if (shouldPlay) {
|
if (isRemoteDevice) {
|
||||||
await playlistNotifier.load(
|
final remotePlayback = ref.read(connectProvider.notifier);
|
||||||
[track],
|
final remotePlaylist = ref.read(queueProvider);
|
||||||
autoPlay: true,
|
|
||||||
);
|
final isTrackPlaying =
|
||||||
|
remotePlaylist.activeTrack?.id == track.id;
|
||||||
|
|
||||||
|
if (!isTrackPlaying && context.mounted) {
|
||||||
|
final shouldPlay = (playlist.tracks.length) > 20
|
||||||
|
? await showPromptDialog(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.playing_track(
|
||||||
|
track.name!,
|
||||||
|
),
|
||||||
|
message: context.l10n.queue_clear_alert(
|
||||||
|
playlist.tracks.length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (shouldPlay) {
|
||||||
|
await remotePlayback.load(
|
||||||
|
WebSocketLoadEventData(
|
||||||
|
tracks: [track],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final isTrackPlaying = playlist.activeTrack?.id == track.id;
|
||||||
|
if (!isTrackPlaying && context.mounted) {
|
||||||
|
final shouldPlay = (playlist.tracks.length) > 20
|
||||||
|
? await showPromptDialog(
|
||||||
|
context: context,
|
||||||
|
title: context.l10n.playing_track(
|
||||||
|
track.name!,
|
||||||
|
),
|
||||||
|
message: context.l10n.queue_clear_alert(
|
||||||
|
playlist.tracks.length,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (shouldPlay) {
|
||||||
|
await playlistNotifier.load(
|
||||||
|
[track],
|
||||||
|
autoPlay: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -227,6 +227,13 @@ class SettingsPlaybackSection extends HookConsumerWidget {
|
|||||||
value: preferences.endlessPlayback,
|
value: preferences.endlessPlayback,
|
||||||
onChanged: preferencesNotifier.setEndlessPlayback,
|
onChanged: preferencesNotifier.setEndlessPlayback,
|
||||||
),
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: Text(context.l10n.enable_connect),
|
||||||
|
subtitle: Text(context.l10n.enable_connect_description),
|
||||||
|
secondary: const Icon(SpotubeIcons.connect),
|
||||||
|
value: preferences.enableConnect,
|
||||||
|
onChanged: preferencesNotifier.setEnableConnect,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
111
lib/provider/connect/clients.dart
Normal file
111
lib/provider/connect/clients.dart
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import 'package:bonsoir/bonsoir.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotube/services/device_info/device_info.dart';
|
||||||
|
|
||||||
|
class ConnectClientsState {
|
||||||
|
final List<BonsoirService> services;
|
||||||
|
final ResolvedBonsoirService? resolvedService;
|
||||||
|
final BonsoirDiscovery discovery;
|
||||||
|
|
||||||
|
ConnectClientsState({
|
||||||
|
required this.services,
|
||||||
|
required this.discovery,
|
||||||
|
this.resolvedService,
|
||||||
|
});
|
||||||
|
|
||||||
|
ConnectClientsState copyWith({
|
||||||
|
List<BonsoirService>? services,
|
||||||
|
BonsoirDiscovery? discovery,
|
||||||
|
ResolvedBonsoirService? resolvedService,
|
||||||
|
}) {
|
||||||
|
return ConnectClientsState(
|
||||||
|
services: services ?? this.services,
|
||||||
|
discovery: discovery ?? this.discovery,
|
||||||
|
resolvedService: resolvedService ?? this.resolvedService,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConnectClientsNotifier extends AsyncNotifier<ConnectClientsState> {
|
||||||
|
ConnectClientsNotifier();
|
||||||
|
|
||||||
|
@override
|
||||||
|
build() async {
|
||||||
|
final discovery = BonsoirDiscovery(type: '_spotube._tcp');
|
||||||
|
final deviceId = await DeviceInfoService.instance.deviceId();
|
||||||
|
await discovery.ready;
|
||||||
|
|
||||||
|
final subscription = discovery.eventStream?.listen((event) {
|
||||||
|
// ignore device itself
|
||||||
|
if (event.service?.attributes["deviceId"] == deviceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case BonsoirDiscoveryEventType.discoveryServiceFound:
|
||||||
|
state = AsyncData(state.value!.copyWith(
|
||||||
|
services: [
|
||||||
|
...?state.value?.services,
|
||||||
|
event.service!,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
case BonsoirDiscoveryEventType.discoveryServiceResolved:
|
||||||
|
state = AsyncData(
|
||||||
|
state.value!.copyWith(
|
||||||
|
resolvedService: event.service as ResolvedBonsoirService,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case BonsoirDiscoveryEventType.discoveryServiceLost:
|
||||||
|
state = AsyncData(
|
||||||
|
ConnectClientsState(
|
||||||
|
services: state.value!.services
|
||||||
|
.where((s) => s.name != event.service!.name)
|
||||||
|
.toList(),
|
||||||
|
discovery: state.value!.discovery,
|
||||||
|
resolvedService:
|
||||||
|
event.service?.name == state.value!.resolvedService!.name
|
||||||
|
? null
|
||||||
|
: state.value!.resolvedService,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.onDispose(() {
|
||||||
|
subscription?.cancel();
|
||||||
|
discovery.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
await discovery.start();
|
||||||
|
|
||||||
|
return ConnectClientsState(
|
||||||
|
services: [],
|
||||||
|
discovery: discovery,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resolveService(BonsoirService service) async {
|
||||||
|
if (state.value == null) return;
|
||||||
|
await service.resolve(state.value!.discovery.serviceResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearResolvedService() async {
|
||||||
|
if (state.value == null) return;
|
||||||
|
state = AsyncData(
|
||||||
|
ConnectClientsState(
|
||||||
|
services: state.value!.services,
|
||||||
|
discovery: state.value!.discovery,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final connectClientsProvider =
|
||||||
|
AsyncNotifierProvider<ConnectClientsNotifier, ConnectClientsState>(
|
||||||
|
() => ConnectClientsNotifier(),
|
||||||
|
);
|
184
lib/provider/connect/connect.dart
Normal file
184
lib/provider/connect/connect.dart
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:catcher_2/catcher_2.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
|
import 'package:spotube/provider/connect/clients.dart';
|
||||||
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||||
|
import 'package:spotube/services/audio_player/loop_mode.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'package:web_socket_channel/status.dart' as status;
|
||||||
|
|
||||||
|
final playingProvider = StateProvider<bool>(
|
||||||
|
(ref) => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final positionProvider = StateProvider<Duration>(
|
||||||
|
(ref) => Duration.zero,
|
||||||
|
);
|
||||||
|
|
||||||
|
final durationProvider = StateProvider<Duration>(
|
||||||
|
(ref) => Duration.zero,
|
||||||
|
);
|
||||||
|
|
||||||
|
final shuffleProvider = StateProvider<bool>(
|
||||||
|
(ref) => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final loopModeProvider = StateProvider<PlaybackLoopMode>(
|
||||||
|
(ref) => PlaybackLoopMode.none,
|
||||||
|
);
|
||||||
|
|
||||||
|
final queueProvider = StateProvider<ProxyPlaylist>(
|
||||||
|
(ref) => ProxyPlaylist({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final volumeProvider = StateProvider<double>(
|
||||||
|
(ref) => 1.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
|
||||||
|
@override
|
||||||
|
build() async {
|
||||||
|
try {
|
||||||
|
final connectClients = ref.watch(connectClientsProvider);
|
||||||
|
print('Building ConnectNotifier');
|
||||||
|
|
||||||
|
if (connectClients.asData?.value.resolvedService == null) return null;
|
||||||
|
|
||||||
|
final service = connectClients.asData!.value.resolvedService!;
|
||||||
|
|
||||||
|
print(
|
||||||
|
'Connecting to ${service.name}: ws://${service.host}:${service.port}/ws');
|
||||||
|
|
||||||
|
final channel = WebSocketChannel.connect(
|
||||||
|
Uri.parse('ws://${service.host}:${service.port}/ws'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await channel.ready;
|
||||||
|
|
||||||
|
print(
|
||||||
|
'Connected to ${service.name}: ws://${service.host}:${service.port}/ws');
|
||||||
|
|
||||||
|
final subscription = channel.stream.listen(
|
||||||
|
(message) {
|
||||||
|
final event =
|
||||||
|
WebSocketEvent.fromJson(jsonDecode(message), (data) => data);
|
||||||
|
|
||||||
|
event.onQueue((event) {
|
||||||
|
ref.read(queueProvider.notifier).state = event.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onPlaying((event) {
|
||||||
|
ref.read(playingProvider.notifier).state = event.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onPosition((event) {
|
||||||
|
ref.read(positionProvider.notifier).state = event.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onDuration((event) {
|
||||||
|
ref.read(durationProvider.notifier).state = event.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onShuffle((event) {
|
||||||
|
ref.read(shuffleProvider.notifier).state = event.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onLoop((event) {
|
||||||
|
ref.read(loopModeProvider.notifier).state = event.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onVolume((event) {
|
||||||
|
ref.read(volumeProvider.notifier).state = event.data;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
Catcher2.reportCheckedError(
|
||||||
|
error,
|
||||||
|
StackTrace.current,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ref.onDispose(() {
|
||||||
|
subscription.cancel();
|
||||||
|
channel.sink.close(status.goingAway);
|
||||||
|
});
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
} catch (e, stack) {
|
||||||
|
Catcher2.reportCheckedError(e, stack);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> emit(Object message) async {
|
||||||
|
if (state.value == null) return;
|
||||||
|
state.value?.sink.add(
|
||||||
|
message is String ? message : (message as dynamic).toJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resume() async {
|
||||||
|
emit(WebSocketResumeEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
emit(WebSocketPauseEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stop() async {
|
||||||
|
emit(WebSocketStopEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> jumpTo(int position) async {
|
||||||
|
emit(WebSocketJumpEvent(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load(WebSocketLoadEventData data) async {
|
||||||
|
emit(WebSocketLoadEvent(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> next() async {
|
||||||
|
emit(WebSocketNextEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> previous() async {
|
||||||
|
emit(WebSocketPreviousEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> seek(Duration position) async {
|
||||||
|
emit(WebSocketSeekEvent(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setShuffle(bool value) async {
|
||||||
|
emit(WebSocketShuffleEvent(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLoopMode(PlaybackLoopMode value) async {
|
||||||
|
emit(WebSocketLoopEvent(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addTrack(Track data) async {
|
||||||
|
emit(WebSocketAddTrackEvent(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeTrack(String data) async {
|
||||||
|
emit(WebSocketRemoveTrackEvent(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reorder(ReorderData data) async {
|
||||||
|
emit(WebSocketReorderEvent(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(double value) async {
|
||||||
|
emit(WebSocketVolumeEvent(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final connectProvider =
|
||||||
|
AsyncNotifierProvider<ConnectNotifier, WebSocketChannel?>(
|
||||||
|
() => ConnectNotifier(),
|
||||||
|
);
|
261
lib/provider/connect/server.dart
Normal file
261
lib/provider/connect/server.dart
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:catcher_2/catcher_2.dart';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf/shelf_io.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import 'package:shelf_web_socket/shelf_web_socket.dart';
|
||||||
|
import 'package:spotube/models/connect/connect.dart';
|
||||||
|
import 'package:spotube/models/logger.dart';
|
||||||
|
import 'package:spotube/provider/connect/clients.dart';
|
||||||
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
|
import 'package:bonsoir/bonsoir.dart';
|
||||||
|
import 'package:spotube/services/device_info/device_info.dart';
|
||||||
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'package:spotube/provider/volume_provider.dart';
|
||||||
|
|
||||||
|
final logger = getLogger('ConnectServer');
|
||||||
|
final _connectClientStreamController = StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
Stream<String> get connectClientStream => _connectClientStreamController.stream;
|
||||||
|
|
||||||
|
final connectServerProvider = FutureProvider((ref) async {
|
||||||
|
final enabled =
|
||||||
|
ref.watch(userPreferencesProvider.select((s) => s.enableConnect));
|
||||||
|
final resolvedService = await ref
|
||||||
|
.watch(connectClientsProvider.selectAsync((s) => s.resolvedService));
|
||||||
|
final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier);
|
||||||
|
|
||||||
|
if (!enabled || resolvedService != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final app = Router();
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
"/ping",
|
||||||
|
(Request req) {
|
||||||
|
return Response.ok("pong");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final subscriptions = <StreamSubscription>[];
|
||||||
|
|
||||||
|
FutureOr<Response> websocket(Request req) => webSocketHandler(
|
||||||
|
(WebSocketChannel channel, String? protocol) async {
|
||||||
|
final context =
|
||||||
|
(req.context["shelf.io.connection_info"] as HttpConnectionInfo?);
|
||||||
|
final origin =
|
||||||
|
"${context?.remoteAddress.host}:${context?.remotePort}";
|
||||||
|
_connectClientStreamController.add(origin);
|
||||||
|
|
||||||
|
ref.listen(
|
||||||
|
ProxyPlaylistNotifier.provider,
|
||||||
|
(previous, next) {
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketQueueEvent(next).toJson(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
fireImmediately: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// because audioPlayer events doesn't fireImmediately
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketPlayingEvent(audioPlayer.isPlaying).toJson(),
|
||||||
|
);
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero)
|
||||||
|
.toJson(),
|
||||||
|
);
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero)
|
||||||
|
.toJson(),
|
||||||
|
);
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(),
|
||||||
|
);
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketLoopEvent(audioPlayer.loopMode).toJson(),
|
||||||
|
);
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketVolumeEvent(audioPlayer.volume).toJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
subscriptions.addAll([
|
||||||
|
audioPlayer.positionStream.listen(
|
||||||
|
(position) {
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketPositionEvent(position).toJson(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
audioPlayer.playingStream.listen(
|
||||||
|
(playing) {
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketPlayingEvent(playing).toJson(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
audioPlayer.durationStream.listen(
|
||||||
|
(duration) {
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketDurationEvent(duration).toJson(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
audioPlayer.shuffledStream.listen(
|
||||||
|
(shuffled) {
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketShuffleEvent(shuffled).toJson(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
audioPlayer.loopModeStream.listen(
|
||||||
|
(loopMode) {
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketLoopEvent(loopMode).toJson(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
audioPlayer.volumeStream.listen(
|
||||||
|
(volume) {
|
||||||
|
channel.sink.add(
|
||||||
|
WebSocketVolumeEvent(volume).toJson(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
channel.stream.listen(
|
||||||
|
(message) {
|
||||||
|
try {
|
||||||
|
final event = WebSocketEvent.fromJson(
|
||||||
|
jsonDecode(message),
|
||||||
|
(data) => data,
|
||||||
|
);
|
||||||
|
|
||||||
|
event.onLoad((event) async {
|
||||||
|
await playbackNotifier.load(
|
||||||
|
event.data.tracks,
|
||||||
|
autoPlay: true,
|
||||||
|
initialIndex: event.data.initialIndex ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (event.data.collectionId != null) {
|
||||||
|
playbackNotifier.addCollection(event.data.collectionId!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onPause((event) async {
|
||||||
|
await audioPlayer.pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onResume((event) async {
|
||||||
|
await audioPlayer.resume();
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onStop((event) async {
|
||||||
|
await audioPlayer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onNext((event) async {
|
||||||
|
await playbackNotifier.next();
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onPrevious((event) async {
|
||||||
|
await playbackNotifier.previous();
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onJump((event) async {
|
||||||
|
await playbackNotifier.jumpTo(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onSeek((event) async {
|
||||||
|
await audioPlayer.seek(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onShuffle((event) async {
|
||||||
|
await audioPlayer.setShuffle(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onLoop((event) async {
|
||||||
|
await audioPlayer.setLoopMode(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onAddTrack((event) async {
|
||||||
|
await playbackNotifier.addTrack(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onRemoveTrack((event) async {
|
||||||
|
await playbackNotifier.removeTrack(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onReorder((event) async {
|
||||||
|
await playbackNotifier.moveTrack(
|
||||||
|
event.data.oldIndex,
|
||||||
|
event.data.newIndex,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
event.onVolume((event) async {
|
||||||
|
ref.read(volumeProvider.notifier).setVolume(event.data);
|
||||||
|
});
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
Catcher2.reportCheckedError(e, stackTrace);
|
||||||
|
channel.sink.add(WebSocketErrorEvent(e.toString()).toJson());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
logger.i('Connection closed');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
)(req);
|
||||||
|
|
||||||
|
final port = Random().nextInt(17000) + 3000;
|
||||||
|
|
||||||
|
final server = await serve(
|
||||||
|
(request) {
|
||||||
|
if (request.url.path.startsWith('ws')) {
|
||||||
|
return websocket(request);
|
||||||
|
}
|
||||||
|
return app(request);
|
||||||
|
},
|
||||||
|
InternetAddress.anyIPv4,
|
||||||
|
port,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.i('Server running on http://${server.address.host}:${server.port}');
|
||||||
|
|
||||||
|
final service = BonsoirService(
|
||||||
|
name: await DeviceInfoService.instance.computerName(),
|
||||||
|
type: '_spotube._tcp',
|
||||||
|
port: port,
|
||||||
|
attributes: {
|
||||||
|
"id": PrimitiveUtils.uuid.v4(),
|
||||||
|
"deviceId": await DeviceInfoService.instance.deviceId(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final broadcast = BonsoirBroadcast(service: service);
|
||||||
|
|
||||||
|
await broadcast.ready;
|
||||||
|
await broadcast.start();
|
||||||
|
|
||||||
|
ref.onDispose(() async {
|
||||||
|
logger.i('Stopping server');
|
||||||
|
for (final subscription in subscriptions) {
|
||||||
|
await subscription.cancel();
|
||||||
|
}
|
||||||
|
await broadcast.stop();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
});
|
@ -27,6 +27,16 @@ class ProxyPlaylist {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
factory ProxyPlaylist.fromJsonRaw(Map<String, dynamic> json) => ProxyPlaylist(
|
||||||
|
json['tracks'] == null
|
||||||
|
? <Track>{}
|
||||||
|
: (json['tracks'] as List).map((t) => Track.fromJson(t)).toSet(),
|
||||||
|
json['active'] as int?,
|
||||||
|
json['collections'] == null
|
||||||
|
? {}
|
||||||
|
: (json['collections'] as List).toSet().cast<String>(),
|
||||||
|
);
|
||||||
|
|
||||||
Track? get activeTrack =>
|
Track? get activeTrack =>
|
||||||
active == null || active == -1 ? null : tracks.elementAtOrNull(active!);
|
active == null || active == -1 ? null : tracks.elementAtOrNull(active!);
|
||||||
|
|
||||||
@ -62,8 +72,8 @@ class ProxyPlaylist {
|
|||||||
/// Otherwise default super.toJson() is used
|
/// Otherwise default super.toJson() is used
|
||||||
static Map<String, dynamic> _makeAppropriateTrackJson(Track track) {
|
static Map<String, dynamic> _makeAppropriateTrackJson(Track track) {
|
||||||
return switch (track.runtimeType) {
|
return switch (track.runtimeType) {
|
||||||
LocalTrack => track.toJson(),
|
LocalTrack() => track.toJson(),
|
||||||
SourcedTrack => track.toJson(),
|
SourcedTrack() => track.toJson(),
|
||||||
_ => track.toJson(),
|
_ => track.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -127,6 +127,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
|
|||||||
state = state.copyWith(endlessPlayback: endless);
|
state = state.copyWith(endlessPlayback: endless);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setEnableConnect(bool enable) {
|
||||||
|
state = state.copyWith(enableConnect: enable);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> _getDefaultDownloadDirectory() async {
|
Future<String> _getDefaultDownloadDirectory() async {
|
||||||
if (kIsAndroid) return "/storage/emulated/0/Download/Spotube";
|
if (kIsAndroid) return "/storage/emulated/0/Download/Spotube";
|
||||||
|
|
||||||
|
@ -91,6 +91,7 @@ class UserPreferences with _$UserPreferences {
|
|||||||
@Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec,
|
@Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec,
|
||||||
@Default(true) bool discordPresence,
|
@Default(true) bool discordPresence,
|
||||||
@Default(true) bool endlessPlayback,
|
@Default(true) bool endlessPlayback,
|
||||||
|
@Default(false) bool enableConnect,
|
||||||
}) = _UserPreferences;
|
}) = _UserPreferences;
|
||||||
factory UserPreferences.fromJson(Map<String, dynamic> json) =>
|
factory UserPreferences.fromJson(Map<String, dynamic> json) =>
|
||||||
_$UserPreferencesFromJson(json);
|
_$UserPreferencesFromJson(json);
|
||||||
|
@ -50,6 +50,7 @@ mixin _$UserPreferences {
|
|||||||
SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError;
|
SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError;
|
||||||
bool get discordPresence => throw _privateConstructorUsedError;
|
bool get discordPresence => throw _privateConstructorUsedError;
|
||||||
bool get endlessPlayback => throw _privateConstructorUsedError;
|
bool get endlessPlayback => throw _privateConstructorUsedError;
|
||||||
|
bool get enableConnect => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(ignore: true)
|
||||||
@ -93,7 +94,8 @@ abstract class $UserPreferencesCopyWith<$Res> {
|
|||||||
SourceCodecs streamMusicCodec,
|
SourceCodecs streamMusicCodec,
|
||||||
SourceCodecs downloadMusicCodec,
|
SourceCodecs downloadMusicCodec,
|
||||||
bool discordPresence,
|
bool discordPresence,
|
||||||
bool endlessPlayback});
|
bool endlessPlayback,
|
||||||
|
bool enableConnect});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
@ -131,6 +133,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
|
|||||||
Object? downloadMusicCodec = null,
|
Object? downloadMusicCodec = null,
|
||||||
Object? discordPresence = null,
|
Object? discordPresence = null,
|
||||||
Object? endlessPlayback = null,
|
Object? endlessPlayback = null,
|
||||||
|
Object? enableConnect = null,
|
||||||
}) {
|
}) {
|
||||||
return _then(_value.copyWith(
|
return _then(_value.copyWith(
|
||||||
audioQuality: null == audioQuality
|
audioQuality: null == audioQuality
|
||||||
@ -221,6 +224,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
|
|||||||
? _value.endlessPlayback
|
? _value.endlessPlayback
|
||||||
: endlessPlayback // ignore: cast_nullable_to_non_nullable
|
: endlessPlayback // ignore: cast_nullable_to_non_nullable
|
||||||
as bool,
|
as bool,
|
||||||
|
enableConnect: null == enableConnect
|
||||||
|
? _value.enableConnect
|
||||||
|
: enableConnect // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
) as $Val);
|
) as $Val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -263,7 +270,8 @@ abstract class _$$UserPreferencesImplCopyWith<$Res>
|
|||||||
SourceCodecs streamMusicCodec,
|
SourceCodecs streamMusicCodec,
|
||||||
SourceCodecs downloadMusicCodec,
|
SourceCodecs downloadMusicCodec,
|
||||||
bool discordPresence,
|
bool discordPresence,
|
||||||
bool endlessPlayback});
|
bool endlessPlayback,
|
||||||
|
bool enableConnect});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// @nodoc
|
/// @nodoc
|
||||||
@ -299,6 +307,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
|
|||||||
Object? downloadMusicCodec = null,
|
Object? downloadMusicCodec = null,
|
||||||
Object? discordPresence = null,
|
Object? discordPresence = null,
|
||||||
Object? endlessPlayback = null,
|
Object? endlessPlayback = null,
|
||||||
|
Object? enableConnect = null,
|
||||||
}) {
|
}) {
|
||||||
return _then(_$UserPreferencesImpl(
|
return _then(_$UserPreferencesImpl(
|
||||||
audioQuality: null == audioQuality
|
audioQuality: null == audioQuality
|
||||||
@ -389,6 +398,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
|
|||||||
? _value.endlessPlayback
|
? _value.endlessPlayback
|
||||||
: endlessPlayback // ignore: cast_nullable_to_non_nullable
|
: endlessPlayback // ignore: cast_nullable_to_non_nullable
|
||||||
as bool,
|
as bool,
|
||||||
|
enableConnect: null == enableConnect
|
||||||
|
? _value.enableConnect
|
||||||
|
: enableConnect // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -426,7 +439,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
|
|||||||
this.streamMusicCodec = SourceCodecs.weba,
|
this.streamMusicCodec = SourceCodecs.weba,
|
||||||
this.downloadMusicCodec = SourceCodecs.m4a,
|
this.downloadMusicCodec = SourceCodecs.m4a,
|
||||||
this.discordPresence = true,
|
this.discordPresence = true,
|
||||||
this.endlessPlayback = true});
|
this.endlessPlayback = true,
|
||||||
|
this.enableConnect = false});
|
||||||
|
|
||||||
factory _$UserPreferencesImpl.fromJson(Map<String, dynamic> json) =>
|
factory _$UserPreferencesImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
_$$UserPreferencesImplFromJson(json);
|
_$$UserPreferencesImplFromJson(json);
|
||||||
@ -503,10 +517,13 @@ class _$UserPreferencesImpl implements _UserPreferences {
|
|||||||
@override
|
@override
|
||||||
@JsonKey()
|
@JsonKey()
|
||||||
final bool endlessPlayback;
|
final bool endlessPlayback;
|
||||||
|
@override
|
||||||
|
@JsonKey()
|
||||||
|
final bool enableConnect;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)';
|
return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -556,7 +573,9 @@ class _$UserPreferencesImpl implements _UserPreferences {
|
|||||||
(identical(other.discordPresence, discordPresence) ||
|
(identical(other.discordPresence, discordPresence) ||
|
||||||
other.discordPresence == discordPresence) &&
|
other.discordPresence == discordPresence) &&
|
||||||
(identical(other.endlessPlayback, endlessPlayback) ||
|
(identical(other.endlessPlayback, endlessPlayback) ||
|
||||||
other.endlessPlayback == endlessPlayback));
|
other.endlessPlayback == endlessPlayback) &&
|
||||||
|
(identical(other.enableConnect, enableConnect) ||
|
||||||
|
other.enableConnect == enableConnect));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(ignore: true)
|
||||||
@ -584,7 +603,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
|
|||||||
streamMusicCodec,
|
streamMusicCodec,
|
||||||
downloadMusicCodec,
|
downloadMusicCodec,
|
||||||
discordPresence,
|
discordPresence,
|
||||||
endlessPlayback
|
endlessPlayback,
|
||||||
|
enableConnect
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(ignore: true)
|
||||||
@ -633,7 +653,8 @@ abstract class _UserPreferences implements UserPreferences {
|
|||||||
final SourceCodecs streamMusicCodec,
|
final SourceCodecs streamMusicCodec,
|
||||||
final SourceCodecs downloadMusicCodec,
|
final SourceCodecs downloadMusicCodec,
|
||||||
final bool discordPresence,
|
final bool discordPresence,
|
||||||
final bool endlessPlayback}) = _$UserPreferencesImpl;
|
final bool endlessPlayback,
|
||||||
|
final bool enableConnect}) = _$UserPreferencesImpl;
|
||||||
|
|
||||||
factory _UserPreferences.fromJson(Map<String, dynamic> json) =
|
factory _UserPreferences.fromJson(Map<String, dynamic> json) =
|
||||||
_$UserPreferencesImpl.fromJson;
|
_$UserPreferencesImpl.fromJson;
|
||||||
@ -691,6 +712,8 @@ abstract class _UserPreferences implements UserPreferences {
|
|||||||
@override
|
@override
|
||||||
bool get endlessPlayback;
|
bool get endlessPlayback;
|
||||||
@override
|
@override
|
||||||
|
bool get enableConnect;
|
||||||
|
@override
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(ignore: true)
|
||||||
_$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith =>
|
_$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith =>
|
||||||
throw _privateConstructorUsedError;
|
throw _privateConstructorUsedError;
|
||||||
|
@ -59,6 +59,7 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson(
|
|||||||
SourceCodecs.m4a,
|
SourceCodecs.m4a,
|
||||||
discordPresence: json['discordPresence'] as bool? ?? true,
|
discordPresence: json['discordPresence'] as bool? ?? true,
|
||||||
endlessPlayback: json['endlessPlayback'] as bool? ?? true,
|
endlessPlayback: json['endlessPlayback'] as bool? ?? true,
|
||||||
|
enableConnect: json['enableConnect'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$$UserPreferencesImplToJson(
|
Map<String, dynamic> _$$UserPreferencesImplToJson(
|
||||||
@ -87,6 +88,7 @@ Map<String, dynamic> _$$UserPreferencesImplToJson(
|
|||||||
'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!,
|
'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!,
|
||||||
'discordPresence': instance.discordPresence,
|
'discordPresence': instance.discordPresence,
|
||||||
'endlessPlayback': instance.endlessPlayback,
|
'endlessPlayback': instance.endlessPlayback,
|
||||||
|
'enableConnect': instance.enableConnect,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$SourceQualitiesEnumMap = {
|
const _$SourceQualitiesEnumMap = {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:catcher_2/catcher_2.dart';
|
import 'package:catcher_2/catcher_2.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:spotube/services/audio_player/mk_state_player.dart';
|
import 'package:spotube/services/audio_player/mk_state_player.dart';
|
||||||
// import 'package:just_audio/just_audio.dart' as ja;
|
// import 'package:just_audio/just_audio.dart' as ja;
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
@ -14,7 +15,7 @@ part 'audio_player_impl.dart';
|
|||||||
|
|
||||||
abstract class AudioPlayerInterface {
|
abstract class AudioPlayerInterface {
|
||||||
final MkPlayerWithState _mkPlayer;
|
final MkPlayerWithState _mkPlayer;
|
||||||
// final ja.AudioPlayer? _justAudio;
|
// final ja.AudioPlayer? _justAudxio;
|
||||||
|
|
||||||
AudioPlayerInterface()
|
AudioPlayerInterface()
|
||||||
: _mkPlayer = MkPlayerWithState(
|
: _mkPlayer = MkPlayerWithState(
|
||||||
@ -60,6 +61,14 @@ abstract class AudioPlayerInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<AudioDevice> get selectedDevice async {
|
||||||
|
return _mkPlayer.state.audioDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AudioDevice>> get devices async {
|
||||||
|
return _mkPlayer.state.audioDevices;
|
||||||
|
}
|
||||||
|
|
||||||
bool get hasSource {
|
bool get hasSource {
|
||||||
return _mkPlayer.playlist.medias.isNotEmpty;
|
return _mkPlayer.playlist.medias.isNotEmpty;
|
||||||
// if (mkSupportedPlatform) {
|
// if (mkSupportedPlatform) {
|
||||||
|
@ -83,6 +83,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
|
|||||||
// await _justAudio?.setSpeed(speed);
|
// await _justAudio?.setSpeed(speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setAudioDevice(AudioDevice device) async {
|
||||||
|
await _mkPlayer.setAudioDevice(device);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await _mkPlayer.dispose();
|
await _mkPlayer.dispose();
|
||||||
// await _justAudio?.dispose();
|
// await _justAudio?.dispose();
|
||||||
|
@ -140,4 +140,10 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface {
|
|||||||
// .cast<String>();
|
// .cast<String>();
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stream<List<AudioDevice>> get devicesStream =>
|
||||||
|
_mkPlayer.stream.audioDevices.asBroadcastStream();
|
||||||
|
|
||||||
|
Stream<AudioDevice> get selectedDeviceStream =>
|
||||||
|
_mkPlayer.stream.audioDevice.asBroadcastStream();
|
||||||
}
|
}
|
||||||
|
34
lib/services/device_info/device_info.dart
Normal file
34
lib/services/device_info/device_info.dart
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
|
||||||
|
class DeviceInfoService {
|
||||||
|
final DeviceInfoPlugin deviceInfo;
|
||||||
|
DeviceInfoService._() : deviceInfo = DeviceInfoPlugin();
|
||||||
|
|
||||||
|
static final instance = DeviceInfoService._();
|
||||||
|
|
||||||
|
Future<String> deviceId() async {
|
||||||
|
final info = await deviceInfo.deviceInfo;
|
||||||
|
|
||||||
|
return switch (info) {
|
||||||
|
AndroidDeviceInfo() => info.id,
|
||||||
|
IosDeviceInfo() => info.identifierForVendor ?? info.model,
|
||||||
|
MacOsDeviceInfo() => info.systemGUID ?? info.model,
|
||||||
|
WindowsDeviceInfo() => info.deviceId,
|
||||||
|
LinuxDeviceInfo() => info.machineId ?? info.id,
|
||||||
|
_ => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> computerName() async {
|
||||||
|
final info = await deviceInfo.deviceInfo;
|
||||||
|
|
||||||
|
return switch (info) {
|
||||||
|
AndroidDeviceInfo() => info.model,
|
||||||
|
IosDeviceInfo() => info.localizedModel,
|
||||||
|
MacOsDeviceInfo() => info.computerName,
|
||||||
|
WindowsDeviceInfo() => info.computerName,
|
||||||
|
LinuxDeviceInfo() => info.name,
|
||||||
|
_ => 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -18,6 +18,11 @@ dependencies:
|
|||||||
- libjsoncpp25
|
- libjsoncpp25
|
||||||
- libmpv1 | libmpv2
|
- libmpv1 | libmpv2
|
||||||
- xdg-user-dirs
|
- xdg-user-dirs
|
||||||
|
- avahi-daemon
|
||||||
|
- avahi-discover
|
||||||
|
- avahi-utils
|
||||||
|
- libnss-mdns
|
||||||
|
- mdns-scan
|
||||||
|
|
||||||
essential: false
|
essential: false
|
||||||
icon: assets/spotube-logo.png
|
icon: assets/spotube-logo.png
|
||||||
|
@ -13,6 +13,9 @@ requires:
|
|||||||
- libsecret
|
- libsecret
|
||||||
- libnotify
|
- libnotify
|
||||||
- xdg-user-dirs
|
- xdg-user-dirs
|
||||||
|
- avahi
|
||||||
|
- mdns-scan
|
||||||
|
- nss-mdns
|
||||||
|
|
||||||
display_name: Spotube
|
display_name: Spotube
|
||||||
|
|
||||||
|
@ -8,8 +8,10 @@ import Foundation
|
|||||||
import app_links
|
import app_links
|
||||||
import audio_service
|
import audio_service
|
||||||
import audio_session
|
import audio_session
|
||||||
|
import bonsoir_darwin
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
|
import flutter_inappwebview_macos
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import local_notifier
|
import local_notifier
|
||||||
import media_kit_libs_macos_audio
|
import media_kit_libs_macos_audio
|
||||||
@ -28,8 +30,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||||
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
||||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||||
|
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
|
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
|
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
|
||||||
MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))
|
MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
platform :osx, '10.14'
|
platform :osx, '10.15'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
@ -5,10 +5,16 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- audio_session (0.0.1):
|
- audio_session (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- bonsoir_darwin (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- device_info_plus (0.0.1):
|
- device_info_plus (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- file_selector_macos (0.0.1):
|
- file_selector_macos (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- flutter_inappwebview_macos (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
|
- OrderedSet (~> 5.0)
|
||||||
- flutter_secure_storage_macos (6.1.1):
|
- flutter_secure_storage_macos (6.1.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- FlutterMacOS (1.0.0)
|
- FlutterMacOS (1.0.0)
|
||||||
@ -22,6 +28,7 @@ PODS:
|
|||||||
- media_kit_native_event_loop (1.0.0):
|
- media_kit_native_event_loop (1.0.0):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- metadata_god (0.0.1)
|
- metadata_god (0.0.1)
|
||||||
|
- OrderedSet (5.0.0)
|
||||||
- package_info_plus (0.0.1):
|
- package_info_plus (0.0.1):
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@ -50,8 +57,10 @@ DEPENDENCIES:
|
|||||||
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
|
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
|
||||||
- audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`)
|
- audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`)
|
||||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||||
|
- bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||||
|
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
|
||||||
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
|
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
|
||||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
- local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`)
|
- local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`)
|
||||||
@ -72,6 +81,7 @@ DEPENDENCIES:
|
|||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- FMDB
|
- FMDB
|
||||||
|
- OrderedSet
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
app_links:
|
app_links:
|
||||||
@ -80,10 +90,14 @@ EXTERNAL SOURCES:
|
|||||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos
|
||||||
audio_session:
|
audio_session:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||||
|
bonsoir_darwin:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||||
file_selector_macos:
|
file_selector_macos:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
||||||
|
flutter_inappwebview_macos:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
|
||||||
flutter_secure_storage_macos:
|
flutter_secure_storage_macos:
|
||||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
|
||||||
FlutterMacOS:
|
FlutterMacOS:
|
||||||
@ -121,8 +135,10 @@ SPEC CHECKSUMS:
|
|||||||
app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67
|
app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67
|
||||||
audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9
|
audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9
|
||||||
audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72
|
audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72
|
||||||
|
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||||
device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f
|
device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f
|
||||||
file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9
|
file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9
|
||||||
|
flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d
|
||||||
flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea
|
flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea
|
||||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||||
@ -130,6 +146,7 @@ SPEC CHECKSUMS:
|
|||||||
media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da
|
media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da
|
||||||
media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5
|
media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5
|
||||||
metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7
|
metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7
|
||||||
|
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||||
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
|
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
|
||||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||||
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
|
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
|
||||||
@ -141,6 +158,6 @@ SPEC CHECKSUMS:
|
|||||||
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
||||||
window_size: 339dafa0b27a95a62a843042038fa6c3c48de195
|
window_size: 339dafa0b27a95a62a843042038fa6c3c48de195
|
||||||
|
|
||||||
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
|
PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0
|
||||||
|
|
||||||
COCOAPODS: 1.15.2
|
COCOAPODS: 1.15.2
|
||||||
|
@ -436,7 +436,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
};
|
};
|
||||||
@ -567,7 +567,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@ -592,7 +592,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
};
|
};
|
||||||
|
134
pubspec.lock
134
pubspec.lock
@ -177,6 +177,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
|
bonsoir:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: bonsoir
|
||||||
|
sha256: "9703ca3ce201c7ab6cd278ae5a530a125959687f59c2b97822f88a8db5bef106"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.9"
|
||||||
|
bonsoir_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_android
|
||||||
|
sha256: "19583ae34a5e5743fa2c16619e4ec699b35ae5e6cece59b99b1cf21c1b4ed618"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.4"
|
||||||
|
bonsoir_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_darwin
|
||||||
|
sha256: "985c4c38b4cbfa57ed5870e724a7e17aa080ee7f49d03b43e6d08781511505c6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.2"
|
||||||
|
bonsoir_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_linux
|
||||||
|
sha256: "65554b20bc169c68c311eb31fab46ccdd8ee3d3dd89a2d57c338f4cbf6ceb00d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.2"
|
||||||
|
bonsoir_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_platform_interface
|
||||||
|
sha256: "4ee898bec0b5a63f04f82b06da9896ae8475f32a33b6fa395bea56399daeb9f0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.2"
|
||||||
|
bonsoir_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: bonsoir_windows
|
||||||
|
sha256: abbc90b73ac39e823b0c127da43b91d8906dcc530fc0cec4e169cf0d8c4404b1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.4"
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -478,10 +526,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263"
|
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.8"
|
version: "0.7.10"
|
||||||
device_frame:
|
device_frame:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -494,10 +542,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659"
|
sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.3"
|
version: "9.1.2"
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -786,10 +834,58 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_inappwebview
|
name: flutter_inappwebview
|
||||||
sha256: f73505c792cf083d5566e1a94002311be497d984b5607f25be36d685cf6361cf
|
sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.7.2+3"
|
version: "6.0.0"
|
||||||
|
flutter_inappwebview_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_android
|
||||||
|
sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.13"
|
||||||
|
flutter_inappwebview_internal_annotations:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_internal_annotations
|
||||||
|
sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
flutter_inappwebview_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_ios
|
||||||
|
sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.13"
|
||||||
|
flutter_inappwebview_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_macos
|
||||||
|
sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.11"
|
||||||
|
flutter_inappwebview_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_platform_interface
|
||||||
|
sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.10"
|
||||||
|
flutter_inappwebview_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_web
|
||||||
|
sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.8"
|
||||||
flutter_keyboard_visibility:
|
flutter_keyboard_visibility:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1146,6 +1242,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.1"
|
||||||
|
http_methods:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_methods
|
||||||
|
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1882,13 +1986,21 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shelf
|
name: shelf
|
||||||
sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4
|
sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
shelf_router:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf_router
|
||||||
|
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.4"
|
||||||
shelf_static:
|
shelf_static:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1898,7 +2010,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
version: "1.1.2"
|
||||||
shelf_web_socket:
|
shelf_web_socket:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shelf_web_socket
|
name: shelf_web_socket
|
||||||
sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
|
sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
|
||||||
@ -2311,13 +2423,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.0"
|
version: "0.5.0"
|
||||||
web_socket_channel:
|
web_socket_channel:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: web_socket_channel
|
name: web_socket_channel
|
||||||
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
|
sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.4.4"
|
||||||
webdriver:
|
webdriver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -25,7 +25,7 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.5
|
cupertino_icons: ^1.0.5
|
||||||
curved_navigation_bar: ^1.0.3
|
curved_navigation_bar: ^1.0.3
|
||||||
dbus: ^0.7.8
|
dbus: ^0.7.8
|
||||||
device_info_plus: ^9.0.3
|
device_info_plus: ^9.1.2
|
||||||
device_preview: ^1.1.0
|
device_preview: ^1.1.0
|
||||||
dio: ^5.4.1
|
dio: ^5.4.1
|
||||||
disable_battery_optimization: ^1.1.0+1
|
disable_battery_optimization: ^1.1.0+1
|
||||||
@ -43,7 +43,7 @@ dependencies:
|
|||||||
flutter_displaymode: ^0.6.0
|
flutter_displaymode: ^0.6.0
|
||||||
flutter_feather_icons: ^2.0.0+1
|
flutter_feather_icons: ^2.0.0+1
|
||||||
flutter_hooks: ^0.20.0
|
flutter_hooks: ^0.20.0
|
||||||
flutter_inappwebview: ^5.7.2+3
|
flutter_inappwebview: ^6.0.0
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_native_splash: ^2.3.10
|
flutter_native_splash: ^2.3.10
|
||||||
@ -123,6 +123,11 @@ dependencies:
|
|||||||
flutter_broadcasts: ^0.4.0
|
flutter_broadcasts: ^0.4.0
|
||||||
freezed_annotation: ^2.4.1
|
freezed_annotation: ^2.4.1
|
||||||
spotify: ^0.13.3
|
spotify: ^0.13.3
|
||||||
|
bonsoir: ^5.1.9
|
||||||
|
shelf: ^1.4.1
|
||||||
|
shelf_router: ^1.1.4
|
||||||
|
shelf_web_socket: ^1.0.4
|
||||||
|
web_socket_channel: ^2.4.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.3.2
|
build_runner: ^2.3.2
|
||||||
|
@ -1,6 +1,203 @@
|
|||||||
{
|
{
|
||||||
|
"ar": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"bn": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ca": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"de": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"es": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fa": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fr": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"hi": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"it": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ja": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ko": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ne": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"nl": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pl": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pt": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ru": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"tr": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"uk": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
"vi": [
|
"vi": [
|
||||||
"friends",
|
"friends",
|
||||||
"no_lyrics_available"
|
"no_lyrics_available",
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
|
],
|
||||||
|
|
||||||
|
"zh": [
|
||||||
|
"enable_connect",
|
||||||
|
"enable_connect_description",
|
||||||
|
"devices",
|
||||||
|
"select",
|
||||||
|
"connect_client_alert",
|
||||||
|
"this_device",
|
||||||
|
"remote"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <app_links/app_links_plugin_c_api.h>
|
#include <app_links/app_links_plugin_c_api.h>
|
||||||
|
#include <bonsoir_windows/bonsoir_windows_plugin_c_api.h>
|
||||||
#include <dart_discord_rpc/dart_discord_rpc_plugin.h>
|
#include <dart_discord_rpc/dart_discord_rpc_plugin.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
@ -23,6 +24,8 @@
|
|||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
AppLinksPluginCApiRegisterWithRegistrar(
|
AppLinksPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||||
|
BonsoirWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
|
||||||
DartDiscordRpcPluginRegisterWithRegistrar(
|
DartDiscordRpcPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("DartDiscordRpcPlugin"));
|
registry->GetRegistrarForPlugin("DartDiscordRpcPlugin"));
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
app_links
|
app_links
|
||||||
|
bonsoir_windows
|
||||||
dart_discord_rpc
|
dart_discord_rpc
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
Loading…
Reference in New Issue
Block a user