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:
|
||||
|
||||
runs-on: macos-12
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2.12.0
|
||||
@ -349,7 +349,7 @@ jobs:
|
||||
limit-access-to-actor: true
|
||||
|
||||
iOS:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2.10.0
|
||||
|
@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents
|
||||
- [Before Submitting an Enhancement](#before-submitting-an-enhancement)
|
||||
- [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion)
|
||||
- [Your First Code Contribution](#your-first-code-contribution)
|
||||
- [Submit translations](#submit-translations)
|
||||
- [Submit Translations](#submit-translations)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
@ -123,16 +123,16 @@ Do the following:
|
||||
- Install Development dependencies in linux
|
||||
- Debian (>=12/Bookworm)/Ubuntu
|
||||
```bash
|
||||
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev
|
||||
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan
|
||||
```
|
||||
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
|
||||
- Arch/Manjaro
|
||||
```bash
|
||||
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify
|
||||
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan
|
||||
```
|
||||
- Fedora
|
||||
```bash
|
||||
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel
|
||||
dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns
|
||||
```
|
||||
- Clone the Repo
|
||||
- Create a `.env` in root of the project following the `.env.example` template
|
||||
|
@ -1,5 +1,5 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '12.0'
|
||||
platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
@ -5,6 +5,9 @@ PODS:
|
||||
- Flutter
|
||||
- audio_session (0.0.1):
|
||||
- Flutter
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- DKImagePickerController/Core (4.3.4):
|
||||
@ -44,11 +47,13 @@ PODS:
|
||||
- file_selector_ios (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_inappwebview (0.0.1):
|
||||
- flutter_broadcasts (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview/Core (= 0.0.1)
|
||||
- flutter_inappwebview_ios (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview_ios/Core (= 0.0.1)
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_inappwebview/Core (0.0.1):
|
||||
- flutter_inappwebview_ios/Core (0.0.1):
|
||||
- Flutter
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_keyboard_visibility (0.0.1):
|
||||
@ -102,11 +107,13 @@ DEPENDENCIES:
|
||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
||||
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
||||
- flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`)
|
||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
|
||||
- flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
@ -142,6 +149,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/audio_service/ios"
|
||||
audio_session:
|
||||
:path: ".symlinks/plugins/audio_session/ios"
|
||||
bonsoir_darwin:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
@ -150,8 +159,10 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/file_selector_ios/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_inappwebview:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview/ios"
|
||||
flutter_broadcasts:
|
||||
:path: ".symlinks/plugins/flutter_broadcasts/ios"
|
||||
flutter_inappwebview_ios:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
||||
flutter_keyboard_visibility:
|
||||
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
|
||||
flutter_mailer:
|
||||
@ -191,13 +202,15 @@ SPEC CHECKSUMS:
|
||||
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
|
||||
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
||||
audio_session: 4f3e461722055d21515cf3261b64c973c062f345
|
||||
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
|
||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
|
||||
file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62
|
||||
flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882
|
||||
flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0
|
||||
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
|
||||
flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
@ -221,6 +234,6 @@ SPEC CHECKSUMS:
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
|
||||
|
||||
PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd
|
||||
PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
|
@ -66,5 +66,11 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true />
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>To allow other devices on the network control playback of Spotube securely.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_spotube._tcp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart' hide Search;
|
||||
import 'package:spotube/models/spotify/recommendation_seeds.dart';
|
||||
import 'package:spotube/pages/album/album.dart';
|
||||
import 'package:spotube/pages/connect/connect.dart';
|
||||
import 'package:spotube/pages/connect/control/control.dart';
|
||||
import 'package:spotube/pages/getting_started/getting_started.dart';
|
||||
import 'package:spotube/pages/home/genres/genre_playlists.dart';
|
||||
import 'package:spotube/pages/home/genres/genres.dart';
|
||||
@ -173,6 +175,21 @@ final routerProvider = Provider((ref) {
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: "/connect",
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: ConnectPage(),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "control",
|
||||
pageBuilder: (context, state) {
|
||||
return const SpotubePage(
|
||||
child: ConnectControlPage(),
|
||||
);
|
||||
},
|
||||
)
|
||||
])
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
|
@ -116,4 +116,9 @@ abstract class SpotubeIcons {
|
||||
static const openCollective = SimpleIcons.opencollective;
|
||||
static const anonymous = FeatherIcons.user;
|
||||
static const history = FeatherIcons.clock;
|
||||
static const connect = FeatherIcons.link;
|
||||
static const speaker = FeatherIcons.speaker;
|
||||
static const monitor = FeatherIcons.monitor;
|
||||
static const power = FeatherIcons.power;
|
||||
static const bluetooth = FeatherIcons.bluetooth;
|
||||
}
|
||||
|
@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/extensions/track.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget {
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: fetchedTracks,
|
||||
collectionId: album.id!,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
|
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,
|
||||
itemBuilder: (context, index) {
|
||||
if (trackSnapshot.isLoading) {
|
||||
return TrackTile(track: FakeData.track, index: index);
|
||||
return TrackTile(
|
||||
playlist: playlist,
|
||||
track: FakeData.track,
|
||||
index: index,
|
||||
);
|
||||
}
|
||||
|
||||
final track = filteredTracks[index];
|
||||
return TrackTile(
|
||||
index: index,
|
||||
playlist: playlist,
|
||||
track: track,
|
||||
userPlaylist: false,
|
||||
onTap: () async {
|
||||
@ -311,8 +316,11 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
enabled: true,
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) =>
|
||||
TrackTile(track: FakeData.track, index: index),
|
||||
itemBuilder: (context, index) => TrackTile(
|
||||
track: FakeData.track,
|
||||
index: index,
|
||||
playlist: playlist,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -26,6 +26,7 @@ import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/volume_provider.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/youtube.dart';
|
||||
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@ -46,9 +47,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||
(value) => value.activeTrack,
|
||||
));
|
||||
final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select(
|
||||
(value) => value.activeTrack is LocalTrack,
|
||||
));
|
||||
final isLocalTrack = currentTrack is LocalTrack;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
useEffect(() {
|
||||
@ -240,7 +239,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
),
|
||||
if (isLocalTrack)
|
||||
Text(
|
||||
currentTrack?.artists?.asString() ?? "",
|
||||
currentTrack.artists?.asString() ?? "",
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: bodyTextColor,
|
||||
@ -304,10 +303,25 @@ class PlayerView extends HookConsumerWidget {
|
||||
.height *
|
||||
.7,
|
||||
),
|
||||
builder: (context) {
|
||||
return const PlayerQueue(
|
||||
floating: false);
|
||||
builder: (context) => Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final playlist = ref.watch(
|
||||
ProxyPlaylistNotifier
|
||||
.provider,
|
||||
);
|
||||
final playlistNotifier =
|
||||
ref.read(
|
||||
ProxyPlaylistNotifier
|
||||
.notifier,
|
||||
);
|
||||
return PlayerQueue
|
||||
.fromProxyPlaylistNotifier(
|
||||
floating: false,
|
||||
playlist: playlist,
|
||||
notifier: playlistNotifier,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
: null),
|
||||
@ -365,11 +379,21 @@ class PlayerView extends HookConsumerWidget {
|
||||
enabledThumbRadius: 8,
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: VolumeSlider(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Consumer(builder: (context, ref, _) {
|
||||
final volume = ref.watch(volumeProvider);
|
||||
return VolumeSlider(
|
||||
fullWidth: true,
|
||||
),
|
||||
value: volume,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(volumeProvider.notifier)
|
||||
.setVolume(value);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget {
|
||||
onPressed: playlist.isFetching == true
|
||||
? null
|
||||
: () async {
|
||||
switch (audioPlayer.loopMode) {
|
||||
case PlaybackLoopMode.all:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.one);
|
||||
break;
|
||||
case PlaybackLoopMode.one:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.none);
|
||||
break;
|
||||
case PlaybackLoopMode.none:
|
||||
audioPlayer
|
||||
.setLoopMode(PlaybackLoopMode.all);
|
||||
break;
|
||||
}
|
||||
audioPlayer.setLoopMode(
|
||||
switch (loopMode) {
|
||||
PlaybackLoopMode.all =>
|
||||
PlaybackLoopMode.one,
|
||||
PlaybackLoopMode.one =>
|
||||
PlaybackLoopMode.none,
|
||||
PlaybackLoopMode.none =>
|
||||
PlaybackLoopMode.all,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget {
|
||||
width: double.infinity,
|
||||
color: Colors.transparent,
|
||||
child: PlayerTrackDetails(
|
||||
albumArt: albumArt,
|
||||
track: playlist.activeTrack,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
|
@ -3,15 +3,13 @@ import 'dart:ui';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:spotube/collections/fake.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||
@ -20,19 +18,43 @@ import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
|
||||
class PlayerQueue extends HookConsumerWidget {
|
||||
final bool floating;
|
||||
final ProxyPlaylist playlist;
|
||||
|
||||
final Future<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({
|
||||
this.floating = true,
|
||||
required this.playlist,
|
||||
required this.onJump,
|
||||
required this.onRemove,
|
||||
required this.onReorder,
|
||||
required this.onStop,
|
||||
super.key,
|
||||
});
|
||||
|
||||
PlayerQueue.fromProxyPlaylistNotifier({
|
||||
this.floating = true,
|
||||
required this.playlist,
|
||||
required ProxyPlaylistNotifier notifier,
|
||||
super.key,
|
||||
}) : onJump = notifier.jumpToTrack,
|
||||
onRemove = notifier.removeTrack,
|
||||
onReorder = notifier.moveTrack,
|
||||
onStop = notifier.stop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final controller = useAutoScrollController();
|
||||
final searchText = useState('');
|
||||
|
||||
@ -48,7 +70,6 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
topRight: Radius.circular(10),
|
||||
);
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final headlineColor = theme.textTheme.headlineSmall?.color;
|
||||
|
||||
final filteredTracks = useMemoized(
|
||||
@ -87,6 +108,8 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
return const NotFound(vertical: true);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
@ -150,7 +173,8 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
? Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
context.l10n.tracks_in_queue(tracks.length),
|
||||
context.l10n
|
||||
.tracks_in_queue(tracks.length),
|
||||
style: TextStyle(
|
||||
color: headlineColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
@ -204,8 +228,8 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
const SizedBox(width: 10),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor:
|
||||
theme.scaffoldBackgroundColor.withOpacity(0.5),
|
||||
backgroundColor: theme.scaffoldBackgroundColor
|
||||
.withOpacity(0.5),
|
||||
foregroundColor:
|
||||
theme.textTheme.headlineSmall?.color,
|
||||
),
|
||||
@ -246,6 +270,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: TrackTile(
|
||||
playlist: playlist,
|
||||
index: i,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
@ -280,5 +305,7 @@ class PlayerQueue extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -8,13 +8,14 @@ import 'package:spotube/components/shared/links/artist_link.dart';
|
||||
import 'package:spotube/components/shared/links/link_text.dart';
|
||||
import 'package:spotube/extensions/artist_simple.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
|
||||
class PlayerTrackDetails extends HookConsumerWidget {
|
||||
final String? albumArt;
|
||||
final Color? color;
|
||||
const PlayerTrackDetails({super.key, this.albumArt, this.color});
|
||||
final Track? track;
|
||||
const PlayerTrackDetails({super.key, this.color, this.track});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: UniversalImage(
|
||||
path: albumArt ?? "",
|
||||
path: (track?.album?.images)
|
||||
.asUrlString(placeholder: ImagePlaceholder.albumArt),
|
||||
placeholder: Assets.albumPlaceholder.path,
|
||||
),
|
||||
),
|
||||
|
@ -3,37 +3,39 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/provider/volume_provider.dart';
|
||||
|
||||
class VolumeSlider extends HookConsumerWidget {
|
||||
final bool fullWidth;
|
||||
|
||||
final double value;
|
||||
final ValueChanged<double> onChanged;
|
||||
|
||||
const VolumeSlider({
|
||||
super.key,
|
||||
this.fullWidth = false,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final volume = ref.watch(volumeProvider);
|
||||
final volumeNotifier = ref.watch(volumeProvider.notifier);
|
||||
|
||||
var slider = Listener(
|
||||
onPointerSignal: (event) async {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) {
|
||||
final value = volume - .2;
|
||||
volumeNotifier.setVolume(value < 0 ? 0 : value);
|
||||
final newValue = value - .2;
|
||||
onChanged(newValue < 0 ? 0 : newValue);
|
||||
} else {
|
||||
final value = volume + .2;
|
||||
volumeNotifier.setVolume(value > 1 ? 1 : value);
|
||||
final newValue = value + .2;
|
||||
onChanged(newValue > 1 ? 1 : newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Slider(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: volume,
|
||||
onChanged: volumeNotifier.setVolume,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
);
|
||||
return Row(
|
||||
@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
volume == 0
|
||||
value == 0
|
||||
? SpotubeIcons.volumeMute
|
||||
: volume <= 0.2
|
||||
: value <= 0.2
|
||||
? SpotubeIcons.volumeLow
|
||||
: volume <= 0.6
|
||||
: value <= 0.6
|
||||
? SpotubeIcons.volumeMedium
|
||||
: SpotubeIcons.volumeHigh,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () {
|
||||
if (volume == 0) {
|
||||
volumeNotifier.setVolume(1);
|
||||
if (value == 0) {
|
||||
onChanged(1);
|
||||
} else {
|
||||
volumeNotifier.setVolume(0);
|
||||
onChanged(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
@ -71,8 +74,19 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: fetchedTracks,
|
||||
collectionId: playlist.id!,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(playlist.id!);
|
||||
}
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
updating.value = false;
|
||||
|
@ -19,9 +19,11 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart' hide volumeProvider;
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
|
||||
import 'package:spotube/provider/volume_provider.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
class BottomPlayer extends HookConsumerWidget {
|
||||
@ -34,6 +36,7 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final layoutMode =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||
final remoteControl = ref.watch(connectProvider);
|
||||
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
@ -73,7 +76,9 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
|
||||
Expanded(
|
||||
child: PlayerTrackDetails(track: playlist.activeTrack),
|
||||
),
|
||||
// controls
|
||||
Flexible(
|
||||
flex: 3,
|
||||
@ -121,10 +126,20 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
Container(
|
||||
height: 40,
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: const VolumeSlider(),
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Consumer(builder: (context, ref, _) {
|
||||
final volume = ref.watch(volumeProvider);
|
||||
return VolumeSlider(
|
||||
fullWidth: true,
|
||||
value: volume,
|
||||
onChanged: (value) {
|
||||
ref.read(volumeProvider.notifier).setVolume(value);
|
||||
},
|
||||
);
|
||||
}),
|
||||
)
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
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 Widget? title;
|
||||
|
||||
final bool _sliver;
|
||||
|
||||
const PageWindowTitleBar({
|
||||
super.key,
|
||||
this.actions,
|
||||
@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget
|
||||
this.titleTextStyle,
|
||||
this.titleWidth,
|
||||
this.toolbarTextStyle,
|
||||
});
|
||||
}) : _sliver = false,
|
||||
pinned = false,
|
||||
floating = false,
|
||||
snap = false,
|
||||
stretch = false;
|
||||
|
||||
final bool pinned;
|
||||
final bool floating;
|
||||
final bool snap;
|
||||
final bool stretch;
|
||||
|
||||
const PageWindowTitleBar.sliver({
|
||||
super.key,
|
||||
this.actions,
|
||||
this.title,
|
||||
this.backgroundColor,
|
||||
this.actionsIconTheme,
|
||||
this.automaticallyImplyLeading = false,
|
||||
this.centerTitle,
|
||||
this.foregroundColor,
|
||||
this.leading,
|
||||
this.leadingWidth,
|
||||
this.titleSpacing,
|
||||
this.titleTextStyle,
|
||||
this.titleWidth,
|
||||
this.toolbarTextStyle,
|
||||
this.pinned = false,
|
||||
this.floating = false,
|
||||
this.snap = false,
|
||||
this.stretch = false,
|
||||
}) : _sliver = true,
|
||||
toolbarOpacity = 1;
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
|
||||
Widget build(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
if (widget._sliver) {
|
||||
return SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final hasFullscreen =
|
||||
mediaQuery.size.width == constraints.crossAxisExtent;
|
||||
final hasLeadingOrCanPop =
|
||||
widget.leading != null || Navigator.canPop(context);
|
||||
|
||||
return SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
left: DesktopTools.platform.isMacOS &&
|
||||
hasFullscreen &&
|
||||
hasLeadingOrCanPop
|
||||
? 65
|
||||
: 0,
|
||||
),
|
||||
sliver: SliverAppBar(
|
||||
leading: widget.leading,
|
||||
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
||||
actions: [
|
||||
...?widget.actions,
|
||||
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
|
||||
],
|
||||
backgroundColor: widget.backgroundColor,
|
||||
foregroundColor: widget.foregroundColor,
|
||||
actionsIconTheme: widget.actionsIconTheme,
|
||||
centerTitle: widget.centerTitle,
|
||||
titleSpacing: widget.titleSpacing,
|
||||
leadingWidth: widget.leadingWidth,
|
||||
toolbarTextStyle: widget.toolbarTextStyle,
|
||||
titleTextStyle: widget.titleTextStyle,
|
||||
title: widget.title,
|
||||
pinned: widget.pinned,
|
||||
floating: widget.floating,
|
||||
snap: widget.snap,
|
||||
stretch: widget.stretch,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
|
||||
final hasLeadingOrCanPop =
|
||||
@ -349,10 +424,7 @@ class WindowButton extends StatelessWidget {
|
||||
|
||||
class MinimizeWindowButton extends WindowButton {
|
||||
MinimizeWindowButton(
|
||||
{super.key,
|
||||
super.colors,
|
||||
super.onPressed,
|
||||
bool? animate})
|
||||
{super.key, super.colors, super.onPressed, bool? animate})
|
||||
: super(
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
@ -362,10 +434,7 @@ class MinimizeWindowButton extends WindowButton {
|
||||
|
||||
class MaximizeWindowButton extends WindowButton {
|
||||
MaximizeWindowButton(
|
||||
{super.key,
|
||||
super.colors,
|
||||
super.onPressed,
|
||||
bool? animate})
|
||||
{super.key, super.colors, super.onPressed, bool? animate})
|
||||
: super(
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
@ -374,11 +443,7 @@ class MaximizeWindowButton extends WindowButton {
|
||||
}
|
||||
|
||||
class RestoreWindowButton extends WindowButton {
|
||||
RestoreWindowButton(
|
||||
{super.key,
|
||||
super.colors,
|
||||
super.onPressed,
|
||||
bool? animate})
|
||||
RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
|
||||
: super(
|
||||
animate: animate ?? false,
|
||||
iconBuilder: (buttonContext) =>
|
||||
@ -394,10 +459,7 @@ final _defaultCloseButtonColors = WindowButtonColors(
|
||||
|
||||
class CloseWindowButton extends WindowButton {
|
||||
CloseWindowButton(
|
||||
{super.key,
|
||||
WindowButtonColors? colors,
|
||||
super.onPressed,
|
||||
bool? animate})
|
||||
{super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
|
||||
: super(
|
||||
colors: colors ?? _defaultCloseButtonColors,
|
||||
animate: animate ?? false,
|
||||
|
@ -18,7 +18,7 @@ import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/extensions/image.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart';
|
||||
|
||||
class TrackTile extends HookConsumerWidget {
|
||||
/// [index] will not be shown if null
|
||||
@ -30,6 +30,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
final VoidCallback? onLongPress;
|
||||
final bool userPlaylist;
|
||||
final String? playlistId;
|
||||
final ProxyPlaylist playlist;
|
||||
|
||||
final List<Widget>? leadingActions;
|
||||
|
||||
@ -38,6 +39,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
this.index,
|
||||
required this.track,
|
||||
this.selected = false,
|
||||
required this.playlist,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.onChanged,
|
||||
@ -48,7 +50,6 @@ class TrackTile extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final blacklist = ref.watch(BlackListNotifier.provider);
|
||||
@ -65,10 +66,10 @@ class TrackTile extends HookConsumerWidget {
|
||||
|
||||
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
|
||||
|
||||
final isPlaying = track.id == playlist.activeTrack?.id;
|
||||
|
||||
final isLoading = useState(false);
|
||||
|
||||
final isPlaying = playlist.activeTrack?.id == track.id;
|
||||
|
||||
final isSelected = isPlaying || isLoading.value;
|
||||
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
|
@ -8,12 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/fake.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
loadingBuilder: (context) => Skeletonizer(
|
||||
enabled: true,
|
||||
child: TrackTile(
|
||||
playlist: playlist,
|
||||
track: FakeData.track,
|
||||
index: 0,
|
||||
),
|
||||
@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
child: Column(
|
||||
children: List.generate(
|
||||
10,
|
||||
(index) => TrackTile(track: FakeData.track, index: index),
|
||||
(index) => TrackTile(
|
||||
track: FakeData.track,
|
||||
index: index,
|
||||
playlist: playlist,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final track = tracks[index];
|
||||
return TrackTile(
|
||||
playlist: playlist,
|
||||
track: track,
|
||||
index: index,
|
||||
selected: trackViewState.selectedTrackIds.contains(track.id!),
|
||||
@ -125,6 +134,26 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final isRemoteDevice =
|
||||
await showSelectDeviceDialog(context, ref);
|
||||
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
final remoteQueue = ref.read(queueProvider);
|
||||
if (remoteQueue.collections.contains(props.collectionId) ||
|
||||
remoteQueue.tracks.any((s) => s.id == track.id)) {
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
} else {
|
||||
final tracks = await props.pagination.onFetchAll();
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: tracks,
|
||||
collectionId: props.collectionId,
|
||||
initialIndex: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (isActive || playlist.tracks.contains(track)) {
|
||||
await playlistNotifier.jumpToTrack(track);
|
||||
} else {
|
||||
@ -136,6 +165,7 @@ class TrackViewBodySection extends HookConsumerWidget {
|
||||
);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
|
@ -6,8 +6,11 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
|
||||
@ -43,6 +46,17 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
|
||||
final allTracks = await props.pagination.onFetchAll();
|
||||
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: allTracks,
|
||||
collectionId: props.collectionId,
|
||||
initialIndex: Random().nextInt(allTracks.length)),
|
||||
);
|
||||
await remotePlayback.setShuffle(true);
|
||||
} else {
|
||||
await playlistNotifier.load(
|
||||
allTracks,
|
||||
autoPlay: true,
|
||||
@ -50,6 +64,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
);
|
||||
await audioPlayer.setShuffle(true);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget {
|
||||
|
||||
final allTracks = await props.pagination.onFetchAll();
|
||||
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: allTracks,
|
||||
collectionId: props.collectionId,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await playlistNotifier.load(allTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(props.collectionId);
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
@ -313,5 +313,12 @@
|
||||
"help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.",
|
||||
"contribute_on_github": "Contribute on GitHub",
|
||||
"donate_on_open_collective": "Donate on Open Collective",
|
||||
"browse_anonymously": "Browse Anonymously"
|
||||
"browse_anonymously": "Browse Anonymously",
|
||||
"enable_connect": "Enable Connect",
|
||||
"enable_connect_description": "Control Spotube from other devices",
|
||||
"devices": "Devices",
|
||||
"select": "Select",
|
||||
"connect_client_alert": "You're being controlled by {client}",
|
||||
"this_device": "This Device",
|
||||
"remote": "Remote"
|
||||
}
|
@ -23,6 +23,9 @@ import 'package:spotube/l10n/l10n.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/models/skip_segment.dart';
|
||||
import 'package:spotube/models/source_match.dart';
|
||||
import 'package:spotube/provider/connect/clients.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/server.dart';
|
||||
import 'package:spotube/provider/palette_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
@ -180,6 +183,9 @@ class SpotubeState extends ConsumerState<Spotube> {
|
||||
ref.watch(paletteProvider.select((s) => s?.dominantColor?.color));
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
ref.listen(connectServerProvider, (_, __) {});
|
||||
ref.listen(connectClientsProvider, (_, __) {});
|
||||
|
||||
useDisableBatteryOptimizations();
|
||||
useInitSysTray(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:spotube/collections/fake.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
@ -39,6 +42,30 @@ class ArtistPageTopTracks extends HookConsumerWidget {
|
||||
|
||||
void playPlaylist(List<Track> tracks, {Track? currentTrack}) async {
|
||||
currentTrack ??= tracks.first;
|
||||
|
||||
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
final remotePlaylist = ref.read(queueProvider);
|
||||
|
||||
final isPlaylistPlaying = remotePlaylist.containsTracks(tracks);
|
||||
|
||||
if (!isPlaylistPlaying) {
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: tracks,
|
||||
initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
),
|
||||
);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != remotePlaylist.activeTrack?.id) {
|
||||
final index = playlist.tracks
|
||||
.toList()
|
||||
.indexWhere((s) => s.id == currentTrack!.id);
|
||||
await remotePlayback.jumpTo(index);
|
||||
}
|
||||
} else {
|
||||
if (!isPlaylistPlaying) {
|
||||
playlistNotifier.load(
|
||||
tracks,
|
||||
@ -51,6 +78,7 @@ class ArtistPageTopTracks extends HookConsumerWidget {
|
||||
await playlistNotifier.jumpToTrack(currentTrack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
@ -107,6 +135,7 @@ class ArtistPageTopTracks extends HookConsumerWidget {
|
||||
final track = topTracks.elementAt(index);
|
||||
return TrackTile(
|
||||
index: index,
|
||||
playlist: playlist,
|
||||
track: track,
|
||||
onTap: () async {
|
||||
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:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/connect/connect_device.dart';
|
||||
import 'package:spotube/components/home/sections/featured.dart';
|
||||
import 'package:spotube/components/home/sections/friends.dart';
|
||||
import 'package:spotube/components/home/sections/genres.dart';
|
||||
@ -20,15 +22,21 @@ class HomePage extends HookConsumerWidget {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Scaffold(
|
||||
appBar:
|
||||
DesktopTools.platform.isLinux || DesktopTools.platform.isWindows
|
||||
? const PageWindowTitleBar()
|
||||
: null,
|
||||
body: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb)
|
||||
const SliverGap(20),
|
||||
PageWindowTitleBar.sliver(
|
||||
pinned: DesktopTools.platform.isDesktop,
|
||||
actions: [
|
||||
const ConnectDeviceButton(),
|
||||
const Gap(10),
|
||||
IconButton.filledTonal(
|
||||
icon: const Icon(SpotubeIcons.user),
|
||||
onPressed: () {},
|
||||
),
|
||||
const Gap(10),
|
||||
],
|
||||
),
|
||||
const HomeGenresSection(),
|
||||
const SliverToBoxAdapter(child: HomeFeaturedSection()),
|
||||
const HomePageFriendsSection(),
|
||||
|
@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget {
|
||||
MediaQuery.of(context).size.height * .7,
|
||||
),
|
||||
builder: (context) {
|
||||
return const PlayerQueue(floating: true);
|
||||
return Consumer(builder: (context, ref, _) {
|
||||
final playlist = ref
|
||||
.watch(ProxyPlaylistNotifier.provider);
|
||||
|
||||
return PlayerQueue
|
||||
.fromProxyPlaylistNotifier(
|
||||
floating: true,
|
||||
playlist: playlist,
|
||||
notifier: ref
|
||||
.read(ProxyPlaylistNotifier.notifier),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -27,19 +27,17 @@ class WebViewLogin extends HookConsumerWidget {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: InAppWebView(
|
||||
initialOptions: InAppWebViewGroupOptions(
|
||||
crossPlatform: InAppWebViewOptions(
|
||||
initialSettings: InAppWebViewSettings(
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36",
|
||||
),
|
||||
),
|
||||
initialUrlRequest: URLRequest(
|
||||
url: Uri.parse("https://accounts.spotify.com/"),
|
||||
url: WebUri("https://accounts.spotify.com/"),
|
||||
),
|
||||
androidOnPermissionRequest: (controller, origin, resources) async {
|
||||
return PermissionRequestResponse(
|
||||
resources: resources,
|
||||
action: PermissionRequestResponseAction.GRANT,
|
||||
onPermissionRequest: (controller, permissionRequest) async {
|
||||
return PermissionResponse(
|
||||
resources: permissionRequest.resources,
|
||||
action: PermissionResponseAction.GRANT,
|
||||
);
|
||||
},
|
||||
onLoadStop: (controller, action) async {
|
||||
|
@ -16,7 +16,9 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/configurators/use_endless_playback.dart';
|
||||
import 'package:spotube/hooks/configurators/use_update_checker.dart';
|
||||
import 'package:spotube/provider/connect/server.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/services/connectivity_adapter.dart';
|
||||
import 'package:spotube/utils/persisted_state_notifier.dart';
|
||||
|
||||
@ -53,8 +55,8 @@ class RootApp extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
final subscription = ConnectionCheckerService
|
||||
.instance.onConnectivityChanged
|
||||
final subscriptions = [
|
||||
ConnectionCheckerService.instance.onConnectivityChanged
|
||||
.listen((status) {
|
||||
if (status) {
|
||||
scaffoldMessenger.showSnackBar(
|
||||
@ -93,10 +95,35 @@ class RootApp extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}),
|
||||
connectClientStream.listen((clientOrigin) {
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Colors.yellow[600],
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
SpotubeIcons.error,
|
||||
color: Colors.black,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
context.l10n.connect_client_alert(clientOrigin),
|
||||
style: const TextStyle(color: Colors.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
];
|
||||
|
||||
return () {
|
||||
for (final subscription in subscriptions) {
|
||||
subscription.cancel();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@ -191,7 +218,19 @@ class RootApp extends HookConsumerWidget {
|
||||
top: 40,
|
||||
bottom: 100,
|
||||
),
|
||||
child: const PlayerQueue(floating: true),
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier =
|
||||
ref.read(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
return PlayerQueue.fromProxyPlaylistNotifier(
|
||||
floating: true,
|
||||
playlist: playlist,
|
||||
notifier: playlistNotifier,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: null,
|
||||
bottomNavigationBar: Column(
|
||||
|
@ -3,8 +3,11 @@ import 'package:flutter/material.dart' hide Page;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
|
||||
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/provider/connect/connect.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/spotify/spotify.dart';
|
||||
|
||||
@ -46,7 +49,40 @@ class SearchTracksSection extends HookConsumerWidget {
|
||||
return TrackTile(
|
||||
index: i,
|
||||
track: track,
|
||||
playlist: playlist,
|
||||
onTap: () async {
|
||||
final isRemoteDevice =
|
||||
await showSelectDeviceDialog(context, ref);
|
||||
|
||||
if (isRemoteDevice) {
|
||||
final remotePlayback = ref.read(connectProvider.notifier);
|
||||
final remotePlaylist = ref.read(queueProvider);
|
||||
|
||||
final isTrackPlaying =
|
||||
remotePlaylist.activeTrack?.id == track.id;
|
||||
|
||||
if (!isTrackPlaying && context.mounted) {
|
||||
final shouldPlay = (playlist.tracks.length) > 20
|
||||
? await showPromptDialog(
|
||||
context: context,
|
||||
title: context.l10n.playing_track(
|
||||
track.name!,
|
||||
),
|
||||
message: context.l10n.queue_clear_alert(
|
||||
playlist.tracks.length,
|
||||
),
|
||||
)
|
||||
: true;
|
||||
|
||||
if (shouldPlay) {
|
||||
await remotePlayback.load(
|
||||
WebSocketLoadEventData(
|
||||
tracks: [track],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final isTrackPlaying = playlist.activeTrack?.id == track.id;
|
||||
if (!isTrackPlaying && context.mounted) {
|
||||
final shouldPlay = (playlist.tracks.length) > 20
|
||||
@ -68,6 +104,7 @@ class SearchTracksSection extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
@ -227,6 +227,13 @@ class SettingsPlaybackSection extends HookConsumerWidget {
|
||||
value: preferences.endlessPlayback,
|
||||
onChanged: preferencesNotifier.setEndlessPlayback,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(context.l10n.enable_connect),
|
||||
subtitle: Text(context.l10n.enable_connect_description),
|
||||
secondary: const Icon(SpotubeIcons.connect),
|
||||
value: preferences.enableConnect,
|
||||
onChanged: preferencesNotifier.setEnableConnect,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
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 =>
|
||||
active == null || active == -1 ? null : tracks.elementAtOrNull(active!);
|
||||
|
||||
@ -62,8 +72,8 @@ class ProxyPlaylist {
|
||||
/// Otherwise default super.toJson() is used
|
||||
static Map<String, dynamic> _makeAppropriateTrackJson(Track track) {
|
||||
return switch (track.runtimeType) {
|
||||
LocalTrack => track.toJson(),
|
||||
SourcedTrack => track.toJson(),
|
||||
LocalTrack() => track.toJson(),
|
||||
SourcedTrack() => track.toJson(),
|
||||
_ => track.toJson(),
|
||||
};
|
||||
}
|
||||
|
@ -127,6 +127,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
|
||||
state = state.copyWith(endlessPlayback: endless);
|
||||
}
|
||||
|
||||
void setEnableConnect(bool enable) {
|
||||
state = state.copyWith(enableConnect: enable);
|
||||
}
|
||||
|
||||
Future<String> _getDefaultDownloadDirectory() async {
|
||||
if (kIsAndroid) return "/storage/emulated/0/Download/Spotube";
|
||||
|
||||
|
@ -91,6 +91,7 @@ class UserPreferences with _$UserPreferences {
|
||||
@Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec,
|
||||
@Default(true) bool discordPresence,
|
||||
@Default(true) bool endlessPlayback,
|
||||
@Default(false) bool enableConnect,
|
||||
}) = _UserPreferences;
|
||||
factory UserPreferences.fromJson(Map<String, dynamic> json) =>
|
||||
_$UserPreferencesFromJson(json);
|
||||
|
@ -50,6 +50,7 @@ mixin _$UserPreferences {
|
||||
SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError;
|
||||
bool get discordPresence => throw _privateConstructorUsedError;
|
||||
bool get endlessPlayback => throw _privateConstructorUsedError;
|
||||
bool get enableConnect => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
@ -93,7 +94,8 @@ abstract class $UserPreferencesCopyWith<$Res> {
|
||||
SourceCodecs streamMusicCodec,
|
||||
SourceCodecs downloadMusicCodec,
|
||||
bool discordPresence,
|
||||
bool endlessPlayback});
|
||||
bool endlessPlayback,
|
||||
bool enableConnect});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -131,6 +133,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
|
||||
Object? downloadMusicCodec = null,
|
||||
Object? discordPresence = null,
|
||||
Object? endlessPlayback = null,
|
||||
Object? enableConnect = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
audioQuality: null == audioQuality
|
||||
@ -221,6 +224,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
|
||||
? _value.endlessPlayback
|
||||
: endlessPlayback // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
enableConnect: null == enableConnect
|
||||
? _value.enableConnect
|
||||
: enableConnect // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
@ -263,7 +270,8 @@ abstract class _$$UserPreferencesImplCopyWith<$Res>
|
||||
SourceCodecs streamMusicCodec,
|
||||
SourceCodecs downloadMusicCodec,
|
||||
bool discordPresence,
|
||||
bool endlessPlayback});
|
||||
bool endlessPlayback,
|
||||
bool enableConnect});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -299,6 +307,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
|
||||
Object? downloadMusicCodec = null,
|
||||
Object? discordPresence = null,
|
||||
Object? endlessPlayback = null,
|
||||
Object? enableConnect = null,
|
||||
}) {
|
||||
return _then(_$UserPreferencesImpl(
|
||||
audioQuality: null == audioQuality
|
||||
@ -389,6 +398,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
|
||||
? _value.endlessPlayback
|
||||
: endlessPlayback // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
enableConnect: null == enableConnect
|
||||
? _value.enableConnect
|
||||
: enableConnect // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -426,7 +439,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
|
||||
this.streamMusicCodec = SourceCodecs.weba,
|
||||
this.downloadMusicCodec = SourceCodecs.m4a,
|
||||
this.discordPresence = true,
|
||||
this.endlessPlayback = true});
|
||||
this.endlessPlayback = true,
|
||||
this.enableConnect = false});
|
||||
|
||||
factory _$UserPreferencesImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$UserPreferencesImplFromJson(json);
|
||||
@ -503,10 +517,13 @@ class _$UserPreferencesImpl implements _UserPreferences {
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool endlessPlayback;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool enableConnect;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)';
|
||||
return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)';
|
||||
}
|
||||
|
||||
@override
|
||||
@ -556,7 +573,9 @@ class _$UserPreferencesImpl implements _UserPreferences {
|
||||
(identical(other.discordPresence, discordPresence) ||
|
||||
other.discordPresence == discordPresence) &&
|
||||
(identical(other.endlessPlayback, endlessPlayback) ||
|
||||
other.endlessPlayback == endlessPlayback));
|
||||
other.endlessPlayback == endlessPlayback) &&
|
||||
(identical(other.enableConnect, enableConnect) ||
|
||||
other.enableConnect == enableConnect));
|
||||
}
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@ -584,7 +603,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
|
||||
streamMusicCodec,
|
||||
downloadMusicCodec,
|
||||
discordPresence,
|
||||
endlessPlayback
|
||||
endlessPlayback,
|
||||
enableConnect
|
||||
]);
|
||||
|
||||
@JsonKey(ignore: true)
|
||||
@ -633,7 +653,8 @@ abstract class _UserPreferences implements UserPreferences {
|
||||
final SourceCodecs streamMusicCodec,
|
||||
final SourceCodecs downloadMusicCodec,
|
||||
final bool discordPresence,
|
||||
final bool endlessPlayback}) = _$UserPreferencesImpl;
|
||||
final bool endlessPlayback,
|
||||
final bool enableConnect}) = _$UserPreferencesImpl;
|
||||
|
||||
factory _UserPreferences.fromJson(Map<String, dynamic> json) =
|
||||
_$UserPreferencesImpl.fromJson;
|
||||
@ -691,6 +712,8 @@ abstract class _UserPreferences implements UserPreferences {
|
||||
@override
|
||||
bool get endlessPlayback;
|
||||
@override
|
||||
bool get enableConnect;
|
||||
@override
|
||||
@JsonKey(ignore: true)
|
||||
_$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
|
@ -59,6 +59,7 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson(
|
||||
SourceCodecs.m4a,
|
||||
discordPresence: json['discordPresence'] as bool? ?? true,
|
||||
endlessPlayback: json['endlessPlayback'] as bool? ?? true,
|
||||
enableConnect: json['enableConnect'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$UserPreferencesImplToJson(
|
||||
@ -87,6 +88,7 @@ Map<String, dynamic> _$$UserPreferencesImplToJson(
|
||||
'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!,
|
||||
'discordPresence': instance.discordPresence,
|
||||
'endlessPlayback': instance.endlessPlayback,
|
||||
'enableConnect': instance.enableConnect,
|
||||
};
|
||||
|
||||
const _$SourceQualitiesEnumMap = {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:catcher_2/catcher_2.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:spotube/services/audio_player/mk_state_player.dart';
|
||||
// import 'package:just_audio/just_audio.dart' as ja;
|
||||
import 'dart:async';
|
||||
@ -14,7 +15,7 @@ part 'audio_player_impl.dart';
|
||||
|
||||
abstract class AudioPlayerInterface {
|
||||
final MkPlayerWithState _mkPlayer;
|
||||
// final ja.AudioPlayer? _justAudio;
|
||||
// final ja.AudioPlayer? _justAudxio;
|
||||
|
||||
AudioPlayerInterface()
|
||||
: _mkPlayer = MkPlayerWithState(
|
||||
@ -60,6 +61,14 @@ abstract class AudioPlayerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
Future<AudioDevice> get selectedDevice async {
|
||||
return _mkPlayer.state.audioDevice;
|
||||
}
|
||||
|
||||
Future<List<AudioDevice>> get devices async {
|
||||
return _mkPlayer.state.audioDevices;
|
||||
}
|
||||
|
||||
bool get hasSource {
|
||||
return _mkPlayer.playlist.medias.isNotEmpty;
|
||||
// if (mkSupportedPlatform) {
|
||||
|
@ -83,6 +83,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface
|
||||
// await _justAudio?.setSpeed(speed);
|
||||
}
|
||||
|
||||
Future<void> setAudioDevice(AudioDevice device) async {
|
||||
await _mkPlayer.setAudioDevice(device);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _mkPlayer.dispose();
|
||||
// await _justAudio?.dispose();
|
||||
|
@ -140,4 +140,10 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface {
|
||||
// .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
|
||||
- libmpv1 | libmpv2
|
||||
- xdg-user-dirs
|
||||
- avahi-daemon
|
||||
- avahi-discover
|
||||
- avahi-utils
|
||||
- libnss-mdns
|
||||
- mdns-scan
|
||||
|
||||
essential: false
|
||||
icon: assets/spotube-logo.png
|
||||
|
@ -13,6 +13,9 @@ requires:
|
||||
- libsecret
|
||||
- libnotify
|
||||
- xdg-user-dirs
|
||||
- avahi
|
||||
- mdns-scan
|
||||
- nss-mdns
|
||||
|
||||
display_name: Spotube
|
||||
|
||||
|
@ -8,8 +8,10 @@ import Foundation
|
||||
import app_links
|
||||
import audio_service
|
||||
import audio_session
|
||||
import bonsoir_darwin
|
||||
import device_info_plus
|
||||
import file_selector_macos
|
||||
import flutter_inappwebview_macos
|
||||
import flutter_secure_storage_macos
|
||||
import local_notifier
|
||||
import media_kit_libs_macos_audio
|
||||
@ -28,8 +30,10 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
|
||||
AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin"))
|
||||
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
|
||||
SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
|
||||
MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))
|
||||
|
@ -1,4 +1,4 @@
|
||||
platform :osx, '10.14'
|
||||
platform :osx, '10.15'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
@ -5,10 +5,16 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- audio_session (0.0.1):
|
||||
- FlutterMacOS
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- file_selector_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- flutter_inappwebview_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_secure_storage_macos (6.1.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
@ -22,6 +28,7 @@ PODS:
|
||||
- media_kit_native_event_loop (1.0.0):
|
||||
- FlutterMacOS
|
||||
- metadata_god (0.0.1)
|
||||
- OrderedSet (5.0.0)
|
||||
- package_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- path_provider_foundation (0.0.1):
|
||||
@ -50,8 +57,10 @@ DEPENDENCIES:
|
||||
- app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`)
|
||||
- audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`)
|
||||
- audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`)
|
||||
- bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
|
||||
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
|
||||
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`)
|
||||
@ -72,6 +81,7 @@ DEPENDENCIES:
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- FMDB
|
||||
- OrderedSet
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
app_links:
|
||||
@ -80,10 +90,14 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos
|
||||
audio_session:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos
|
||||
bonsoir_darwin:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin
|
||||
device_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||
file_selector_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
|
||||
flutter_inappwebview_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
|
||||
flutter_secure_storage_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
|
||||
FlutterMacOS:
|
||||
@ -121,8 +135,10 @@ SPEC CHECKSUMS:
|
||||
app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67
|
||||
audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9
|
||||
audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72
|
||||
bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842
|
||||
device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f
|
||||
file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9
|
||||
flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d
|
||||
flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea
|
||||
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
@ -130,6 +146,7 @@ SPEC CHECKSUMS:
|
||||
media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da
|
||||
media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5
|
||||
metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38
|
||||
@ -141,6 +158,6 @@ SPEC CHECKSUMS:
|
||||
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
||||
window_size: 339dafa0b27a95a62a843042038fa6c3c48de195
|
||||
|
||||
PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7
|
||||
PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
|
@ -436,7 +436,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
@ -567,7 +567,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -592,7 +592,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.14;
|
||||
MACOSX_DEPLOYMENT_TARGET = 10.15;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
|
134
pubspec.lock
134
pubspec.lock
@ -177,6 +177,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
bonsoir:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bonsoir
|
||||
sha256: "9703ca3ce201c7ab6cd278ae5a530a125959687f59c2b97822f88a8db5bef106"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.9"
|
||||
bonsoir_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_android
|
||||
sha256: "19583ae34a5e5743fa2c16619e4ec699b35ae5e6cece59b99b1cf21c1b4ed618"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.4"
|
||||
bonsoir_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_darwin
|
||||
sha256: "985c4c38b4cbfa57ed5870e724a7e17aa080ee7f49d03b43e6d08781511505c6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.2"
|
||||
bonsoir_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_linux
|
||||
sha256: "65554b20bc169c68c311eb31fab46ccdd8ee3d3dd89a2d57c338f4cbf6ceb00d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.2"
|
||||
bonsoir_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_platform_interface
|
||||
sha256: "4ee898bec0b5a63f04f82b06da9896ae8475f32a33b6fa395bea56399daeb9f0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.2"
|
||||
bonsoir_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_windows
|
||||
sha256: abbc90b73ac39e823b0c127da43b91d8906dcc530fc0cec4e169cf0d8c4404b1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.4"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -478,10 +526,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263"
|
||||
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.8"
|
||||
version: "0.7.10"
|
||||
device_frame:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -494,10 +542,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659"
|
||||
sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.3"
|
||||
version: "9.1.2"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -786,10 +834,58 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_inappwebview
|
||||
sha256: f73505c792cf083d5566e1a94002311be497d984b5607f25be36d685cf6361cf
|
||||
sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1146,6 +1242,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
http_methods:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_methods
|
||||
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1882,13 +1986,21 @@ packages:
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf
|
||||
sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
shelf_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_router
|
||||
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
shelf_static:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1898,7 +2010,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1"
|
||||
@ -2311,13 +2423,13 @@ packages:
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b
|
||||
sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
version: "2.4.4"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -25,7 +25,7 @@ dependencies:
|
||||
cupertino_icons: ^1.0.5
|
||||
curved_navigation_bar: ^1.0.3
|
||||
dbus: ^0.7.8
|
||||
device_info_plus: ^9.0.3
|
||||
device_info_plus: ^9.1.2
|
||||
device_preview: ^1.1.0
|
||||
dio: ^5.4.1
|
||||
disable_battery_optimization: ^1.1.0+1
|
||||
@ -43,7 +43,7 @@ dependencies:
|
||||
flutter_displaymode: ^0.6.0
|
||||
flutter_feather_icons: ^2.0.0+1
|
||||
flutter_hooks: ^0.20.0
|
||||
flutter_inappwebview: ^5.7.2+3
|
||||
flutter_inappwebview: ^6.0.0
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
flutter_native_splash: ^2.3.10
|
||||
@ -123,6 +123,11 @@ dependencies:
|
||||
flutter_broadcasts: ^0.4.0
|
||||
freezed_annotation: ^2.4.1
|
||||
spotify: ^0.13.3
|
||||
bonsoir: ^5.1.9
|
||||
shelf: ^1.4.1
|
||||
shelf_router: ^1.1.4
|
||||
shelf_web_socket: ^1.0.4
|
||||
web_socket_channel: ^2.4.4
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.3.2
|
||||
|
@ -1,6 +1,203 @@
|
||||
{
|
||||
"ar": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"bn": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"ca": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"de": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"es": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"fa": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"hi": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"it": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"ja": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"ko": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"ne": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"nl": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"pl": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"pt": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"tr": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"uk": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"vi": [
|
||||
"friends",
|
||||
"no_lyrics_available"
|
||||
"no_lyrics_available",
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
],
|
||||
|
||||
"zh": [
|
||||
"enable_connect",
|
||||
"enable_connect_description",
|
||||
"devices",
|
||||
"select",
|
||||
"connect_client_alert",
|
||||
"this_device",
|
||||
"remote"
|
||||
]
|
||||
}
|
||||
|
@ -7,6 +7,7 @@
|
||||
#include "generated_plugin_registrant.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 <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
@ -23,6 +24,8 @@
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AppLinksPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
|
||||
BonsoirWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
|
||||
DartDiscordRpcPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("DartDiscordRpcPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
|
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
app_links
|
||||
bonsoir_windows
|
||||
dart_discord_rpc
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
|
Loading…
Reference in New Issue
Block a user