feat: add play in remote device support

This commit is contained in:
Kingkor Roy Tirtho 2024-03-28 21:11:51 +06:00
parent 940b79c880
commit 2f3a2e671d
13 changed files with 334 additions and 79 deletions

View File

@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
await playlistNotifier.load(fetchedTracks, autoPlay: true); final isRemoteDevice = await showSelectDeviceDialog(context, ref);
playlistNotifier.addCollection(album.id!); if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
WebSocketLoadEventData(
tracks: fetchedTracks,
collectionId: album.id!,
),
);
} else {
await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(album.id!);
}
} finally { } finally {
updating.value = false; updating.value = false;
} }

View File

@ -12,11 +12,14 @@ class ConnectDeviceButton extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:colorScheme) = Theme.of(context); final ThemeData(:colorScheme) = Theme.of(context);
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final connectClients = ref.watch(connectClientsProvider); final connectClients = ref.watch(connectClientsProvider);
return SizedBox( return SizedBox(
height: 40, height: 40 * pixelRatio,
child: Stack( child: Stack(
alignment: Alignment.centerRight,
fit: StackFit.loose,
children: [ children: [
Center( Center(
child: InkWell( child: InkWell(
@ -66,8 +69,10 @@ class ConnectDeviceButton extends HookConsumerWidget {
right: 0, right: 0,
child: IconButton.filled( child: IconButton.filled(
icon: const Icon(SpotubeIcons.speaker), icon: const Icon(SpotubeIcons.speaker),
style: style: IconButton.styleFrom(
IconButton.styleFrom(foregroundColor: colorScheme.onPrimary), visualDensity: VisualDensity.standard,
foregroundColor: colorScheme.onPrimary,
),
onPressed: () { onPressed: () {
ServiceUtils.push(context, "/connect"); ServiceUtils.push(context, "/connect");
}, },

View File

@ -231,7 +231,7 @@ class PlayerQueue extends HookConsumerWidget {
onReorder(oldIndex, newIndex); onReorder(oldIndex, newIndex);
}, },
scrollController: controller, scrollController: controller,
itemCount: tracks.length, itemCount: tracks.length + 1,
shrinkWrap: true, shrinkWrap: true,
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
onReorderStart: (index) { onReorderStart: (index) {
@ -241,6 +241,15 @@ class PlayerQueue extends HookConsumerWidget {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i == tracks.length) {
return AutoScrollTag(
index: i,
controller: controller,
key: const ValueKey('end'),
child: const Gap(100),
);
}
final track = tracks.elementAt(i); final track = tracks.elementAt(i);
return AutoScrollTag( return AutoScrollTag(
key: ValueKey(i), key: ValueKey(i),
@ -277,8 +286,12 @@ class PlayerQueue extends HookConsumerWidget {
controller: controller, controller: controller,
child: ListView.builder( child: ListView.builder(
controller: controller, controller: controller,
itemCount: filteredTracks.length, itemCount: filteredTracks.length + 1,
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i == filteredTracks.length) {
return const Gap(100);
}
final track = filteredTracks.elementAt(i); final track = filteredTracks.elementAt(i);
return Padding( return Padding(
padding: padding:
@ -299,7 +312,6 @@ class PlayerQueue extends HookConsumerWidget {
), ),
), ),
), ),
const Gap(100),
], ],
), ),
), ),

View File

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

View File

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

View File

@ -8,12 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/shared/dialogs/select_device_dialog.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@ -131,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget {
return; return;
} }
if (isActive || playlist.tracks.contains(track)) { final isRemoteDevice =
await playlistNotifier.jumpToTrack(track); await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(props.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await remotePlayback.load(
WebSocketLoadEventData(
tracks: tracks,
collectionId: props.collectionId,
initialIndex: index,
),
);
}
} else { } else {
final tracks = await props.pagination.onFetchAll(); if (isActive || playlist.tracks.contains(track)) {
await playlistNotifier.load( await playlistNotifier.jumpToTrack(track);
tracks, } else {
initialIndex: index, final tracks = await props.pagination.onFetchAll();
autoPlay: true, await playlistNotifier.load(
); tracks,
playlistNotifier.addCollection(props.collectionId); initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(props.collectionId);
}
} }
}, },
); );

View File

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

View File

@ -316,5 +316,6 @@
"browse_anonymously": "Browse Anonymously", "browse_anonymously": "Browse Anonymously",
"enable_connect": "Enable Connect", "enable_connect": "Enable Connect",
"enable_connect_description": "Control Spotube from other devices", "enable_connect_description": "Control Spotube from other devices",
"devices": "Devices" "devices": "Devices",
"select": "Select"
} }

View File

@ -95,7 +95,11 @@ class WebSocketEvent<T> {
EventCallback<WebSocketLoadEvent> callback, EventCallback<WebSocketLoadEvent> callback,
) async { ) async {
if (type == WsEvent.load) { if (type == WsEvent.load) {
await callback(WebSocketLoadEvent.fromJson(data as Map<String, dynamic>)); await callback(
WebSocketLoadEvent(
WebSocketLoadEventData.fromJson(data as Map<String, dynamic>),
),
);
} }
} }

View File

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

View File

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

View File

@ -19,6 +19,7 @@ import 'package:bonsoir/bonsoir.dart';
import 'package:spotube/services/device_info/device_info.dart'; import 'package:spotube/services/device_info/device_info.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:spotube/provider/volume_provider.dart';
final logger = getLogger('ConnectServer'); final logger = getLogger('ConnectServer');
@ -193,7 +194,7 @@ final connectServerProvider = FutureProvider((ref) async {
}); });
event.onVolume((event) async { event.onVolume((event) async {
await audioPlayer.setVolume(event.data); ref.read(volumeProvider.notifier).setVolume(event.data);
}); });
} catch (e, stackTrace) { } catch (e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace); Catcher2.reportCheckedError(e, stackTrace);

View File

@ -2,109 +2,127 @@
"ar": [ "ar": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"bn": [ "bn": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"ca": [ "ca": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"de": [ "de": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"es": [ "es": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"fa": [ "fa": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"fr": [ "fr": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"hi": [ "hi": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"it": [ "it": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"ja": [ "ja": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"ko": [ "ko": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"ne": [ "ne": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"nl": [ "nl": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"pl": [ "pl": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"pt": [ "pt": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"ru": [ "ru": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"tr": [ "tr": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"uk": [ "uk": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"vi": [ "vi": [
@ -112,12 +130,14 @@
"no_lyrics_available", "no_lyrics_available",
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
], ],
"zh": [ "zh": [
"enable_connect", "enable_connect",
"enable_connect_description", "enable_connect_description",
"devices" "devices",
"select"
] ]
} }