refactor: remote playback page to shadcn

This commit is contained in:
Kingkor Roy Tirtho 2025-01-04 23:31:09 +06:00
parent 780f5dee2e
commit af295be8c6
8 changed files with 385 additions and 215 deletions

View File

@ -1,3 +1,4 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/button/back_button.dart'; import 'package:spotube/components/button/back_button.dart';
@ -60,6 +61,7 @@ class TitleBar extends HookConsumerWidget implements PreferredSizeWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context); final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context);
final lastClicked = useRef<int>(DateTime.now().millisecondsSinceEpoch);
return SizedBox( return SizedBox(
height: height ?? 56, height: height ?? 56,
@ -71,6 +73,23 @@ class TitleBar extends HookConsumerWidget implements PreferredSizeWidget {
return GestureDetector( return GestureDetector(
onHorizontalDragStart: (_) => onDrag(ref), onHorizontalDragStart: (_) => onDrag(ref),
onVerticalDragStart: (_) => onDrag(ref), onVerticalDragStart: (_) => onDrag(ref),
onTapDown: (details) async {
final systemTitlebar = ref.read(
userPreferencesProvider.select((s) => s.systemTitleBar));
if (!kIsDesktop || systemTitlebar) return;
int currMills = DateTime.now().millisecondsSinceEpoch;
if ((currMills - lastClicked.value) < 500) {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
} else {
lastClicked.value = currMills;
}
},
child: AppBar( child: AppBar(
leading: leading.isEmpty && leading: leading.isEmpty &&
automaticallyImplyLeading && automaticallyImplyLeading &&

View File

@ -0,0 +1,95 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
class ButtonTile extends StatelessWidget {
final Widget? title;
final Widget? subtitle;
final Widget? leading;
final Widget? trailing;
final bool enabled;
final void Function()? onPressed;
final bool selected;
final ButtonVariance style;
const ButtonTile({
super.key,
this.title,
this.subtitle,
this.leading,
this.trailing,
this.enabled = true,
this.onPressed,
this.selected = false,
this.style = ButtonVariance.outline,
});
@override
Widget build(BuildContext context) {
final ThemeData(:colorScheme, :typography) = Theme.of(context);
return Button(
enabled: enabled,
onPressed: onPressed,
style: style.copyWith(
decoration: (context, states, value) {
final decoration = ButtonVariance.outline.decoration(context, states)
as BoxDecoration;
if (selected && style == ButtonVariance.outline) {
return decoration.copyWith(
border: Border.all(
color: colorScheme.primary,
width: 1.0,
),
color: colorScheme.primary.withAlpha(25),
);
}
return decoration;
},
iconTheme: (context, states, value) {
final iconTheme = ButtonVariance.outline.iconTheme(context, states);
if (selected && style == ButtonVariance.outline) {
return iconTheme.copyWith(
color: colorScheme.primary,
);
}
return iconTheme;
},
textStyle: (context, states, value) {
final textStyle = ButtonVariance.outline.textStyle(context, states);
if (selected && style == ButtonVariance.outline) {
return textStyle.copyWith(
color: colorScheme.primary,
);
}
return textStyle;
},
),
alignment: Alignment.centerLeft,
child: SizedBox(
width: double.infinity,
child: Basic(
padding: EdgeInsets.zero,
leadingAlignment: Alignment.center,
trailingAlignment: Alignment.center,
leading: leading,
title: title,
subtitle:
style == ButtonVariance.outline && selected && subtitle != null
? DefaultTextStyle(
style: typography.xSmall.copyWith(
color: colorScheme.primary,
),
child: subtitle!,
)
: subtitle,
trailing: trailing,
),
),
);
}
}

View File

@ -97,6 +97,7 @@
"pause_playback": "Pause Playback", "pause_playback": "Pause Playback",
"resume_playback": "Resume Playback", "resume_playback": "Resume Playback",
"loop_track": "Loop track", "loop_track": "Loop track",
"no_loop": "No loop",
"repeat_playlist": "Repeat playlist", "repeat_playlist": "Repeat playlist",
"queue": "Queue", "queue": "Queue",
"alternative_track_sources": "Alternative track sources", "alternative_track_sources": "Alternative track sources",

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -10,7 +10,7 @@ class ConnectPageLocalDevices extends HookWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData(:textTheme) = Theme.of(context); final ThemeData(:typography) = Theme.of(context);
final devicesFuture = useFuture(audioPlayer.devices); final devicesFuture = useFuture(audioPlayer.devices);
final devicesStream = useStream(audioPlayer.devicesStream); final devicesStream = useStream(audioPlayer.devicesStream);
final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice); final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice);
@ -32,7 +32,7 @@ class ConnectPageLocalDevices extends HookWidget {
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: Text( child: Text(
context.l10n.this_device, context.l10n.this_device,
style: textTheme.titleMedium, style: typography.bold,
), ),
), ),
), ),
@ -43,14 +43,12 @@ class ConnectPageLocalDevices extends HookWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final device = devices[index]; final device = devices[index];
return Card( return ButtonTile(
child: ListTile( selected: selectedDevice == device,
leading: const Icon(SpotubeIcons.speaker), onPressed: () => audioPlayer.setAudioDevice(device),
title: Text(device.description), leading: const Icon(SpotubeIcons.speaker),
subtitle: Text(device.name), title: Text(device.description),
selected: selectedDevice == device, subtitle: Text(device.name),
onTap: () => audioPlayer.setAudioDevice(device),
),
); );
}, },
), ),

View File

@ -224,7 +224,7 @@ class PlayerQueue extends HookConsumerWidget {
); );
}, },
), ),
const SliverGap(100), const SliverSafeArea(sliver: SliverGap(100)),
], ],
), ),
), ),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/ui/button_tile.dart';
import 'package:spotube/modules/connect/local_devices.dart'; import 'package:spotube/modules/connect/local_devices.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
@ -16,79 +16,74 @@ class ConnectPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:colorScheme, :textTheme) = Theme.of(context); final ThemeData(:colorScheme, :typography) = Theme.of(context);
final connectClients = ref.watch(connectClientsProvider); final connectClients = ref.watch(connectClientsProvider);
final connectClientsNotifier = ref.read(connectClientsProvider.notifier); final connectClientsNotifier = ref.read(connectClientsProvider.notifier);
final discoveredDevices = connectClients.asData?.value.services; final discoveredDevices = connectClients.asData?.value.services;
return Scaffold( return Scaffold(
appBar: TitleBar( headers: [
automaticallyImplyLeading: true, TitleBar(
title: Text(context.l10n.devices), automaticallyImplyLeading: true,
), title: Text(context.l10n.devices),
body: ListTileTheme( )
shape: RoundedRectangleBorder( ],
borderRadius: BorderRadius.circular(10), child: Padding(
), padding: const EdgeInsets.all(10.0),
selectedTileColor: colorScheme.secondary.withOpacity(0.1), child: CustomScrollView(
child: Padding( slivers: [
padding: const EdgeInsets.all(10.0), SliverPadding(
child: CustomScrollView( padding: const EdgeInsets.symmetric(horizontal: 8.0),
slivers: [ sliver: SliverToBoxAdapter(
SliverPadding( child: Text(
padding: const EdgeInsets.symmetric(horizontal: 8.0), context.l10n.remote,
sliver: SliverToBoxAdapter( style: typography.bold,
child: Text(
context.l10n.remote,
style: textTheme.titleMedium,
),
), ),
), ),
const SliverGap(10), ),
SliverList.separated( const SliverGap(10),
itemCount: discoveredDevices?.length ?? 0, SliverList.separated(
separatorBuilder: (context, index) => const Gap(10), itemCount: discoveredDevices?.length ?? 0,
itemBuilder: (context, index) { separatorBuilder: (context, index) => const Gap(10),
final device = discoveredDevices![index]; itemBuilder: (context, index) {
final selected = final device = discoveredDevices![index];
connectClients.asData?.value.resolvedService?.name == final selected =
device.name; connectClients.asData?.value.resolvedService?.name ==
return Card( device.name;
child: ListTile( return ButtonTile(
leading: const Icon(SpotubeIcons.monitor), selected: selected,
title: Text(device.name), leading: const Icon(SpotubeIcons.monitor),
subtitle: selected title: Text(device.name),
? Text( subtitle: selected
"${connectClients.asData?.value.resolvedService?.host}" ? Text(
":${connectClients.asData?.value.resolvedService?.port}", "${connectClients.asData?.value.resolvedService?.host}"
) ":${connectClients.asData?.value.resolvedService?.port}",
: null, )
selected: selected, : null,
onTap: () { trailing: selected
if (selected) { ? IconButton.outline(
ServiceUtils.pushNamed( icon: const Icon(SpotubeIcons.power),
context, size: ButtonSize.small,
ConnectControlPage.name, onPressed: () =>
); connectClientsNotifier.clearResolvedService(),
} else { )
connectClientsNotifier.resolveService(device); : null,
} onPressed: () {
}, if (selected) {
trailing: selected ServiceUtils.pushNamed(
? IconButton( context,
icon: const Icon(SpotubeIcons.power), ConnectControlPage.name,
onPressed: () => );
connectClientsNotifier.clearResolvedService(), } else {
) connectClientsNotifier.resolveService(device);
: null, }
), },
); );
}, },
), ),
const ConnectPageLocalDevices(), const ConnectPageLocalDevices(),
], ],
),
), ),
), ),
); );

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/player_queue.dart';
import 'package:spotube/modules/player/volume_slider.dart'; import 'package:spotube/modules/player/volume_slider.dart';
@ -53,7 +53,7 @@ class ConnectControlPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final ThemeData(:typography, :colorScheme) = Theme.of(context);
final resolvedService = final resolvedService =
ref.watch(connectClientsProvider).asData?.value.resolvedService; ref.watch(connectClientsProvider).asData?.value.resolvedService;
@ -63,23 +63,6 @@ class ConnectControlPage extends HookConsumerWidget {
final shuffled = ref.watch(shuffleProvider); final shuffled = ref.watch(shuffleProvider);
final loopMode = ref.watch(loopModeProvider); 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),
);
ref.listen(connectClientsProvider, (prev, next) { ref.listen(connectClientsProvider, (prev, next) {
if (next.asData?.value.resolvedService == null) { if (next.asData?.value.resolvedService == null) {
context.pop(); context.pop();
@ -87,12 +70,15 @@ class ConnectControlPage extends HookConsumerWidget {
}); });
return SafeArea( return SafeArea(
bottom: false,
child: Scaffold( child: Scaffold(
appBar: TitleBar( headers: [
title: Text(resolvedService!.name), TitleBar(
automaticallyImplyLeading: true, title: Text(resolvedService!.name),
), automaticallyImplyLeading: true,
body: LayoutBuilder(builder: (context, constrains) { )
],
child: LayoutBuilder(builder: (context, constrains) {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
@ -106,7 +92,7 @@ class ConnectControlPage extends HookConsumerWidget {
vertical: 10, vertical: 10,
).copyWith(top: 0), ).copyWith(top: 0),
constraints: constraints:
const BoxConstraints(maxHeight: 400, maxWidth: 400), const BoxConstraints(maxHeight: 350, maxWidth: 350),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: UniversalImage( child: UniversalImage(
@ -126,7 +112,7 @@ class ConnectControlPage extends HookConsumerWidget {
SliverToBoxAdapter( SliverToBoxAdapter(
child: AnchorButton( child: AnchorButton(
playlist.activeTrack?.name ?? "", playlist.activeTrack?.name ?? "",
style: textTheme.titleLarge!, style: typography.h4,
onTap: () { onTap: () {
if (playlist.activeTrack == null) return; if (playlist.activeTrack == null) return;
ServiceUtils.pushNamed( ServiceUtils.pushNamed(
@ -142,7 +128,7 @@ class ConnectControlPage extends HookConsumerWidget {
SliverToBoxAdapter( SliverToBoxAdapter(
child: ArtistLink( child: ArtistLink(
artists: playlist.activeTrack?.artists ?? [], artists: playlist.activeTrack?.artists ?? [],
textStyle: textTheme.bodyMedium!, textStyle: typography.normal,
mainAxisAlignment: WrapAlignment.start, mainAxisAlignment: WrapAlignment.start,
onOverflowArtistClick: () => onOverflowArtistClick: () =>
ServiceUtils.pushNamed( ServiceUtils.pushNamed(
@ -164,19 +150,25 @@ class ConnectControlPage extends HookConsumerWidget {
final position = ref.watch(positionProvider); final position = ref.watch(positionProvider);
final duration = ref.watch(durationProvider); final duration = ref.watch(durationProvider);
final progress = duration.inSeconds == 0
? 0
: position.inSeconds / duration.inSeconds;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column( child: Column(
children: [ children: [
Slider( Slider(
value: position > duration value:
? 0 SliderValue.single(progress.toDouble()),
: position.inSeconds.toDouble(),
min: 0,
max: duration.inSeconds.toDouble(),
onChanged: (value) { onChanged: (value) {
connectNotifier connectNotifier.seek(
.seek(Duration(seconds: value.toInt())); Duration(
seconds:
(value.value * duration.inSeconds)
.toInt(),
),
);
}, },
), ),
Row( Row(
@ -197,93 +189,155 @@ class ConnectControlPage extends HookConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton( Tooltip(
tooltip: shuffled tooltip: TooltipContainer(
? context.l10n.unshuffle_playlist child: Text(
: context.l10n.shuffle_playlist, shuffled
icon: const Icon(SpotubeIcons.shuffle), ? context.l10n.unshuffle_playlist
style: shuffled ? activeButtonStyle : buttonStyle, : context.l10n.shuffle_playlist,
onPressed: playlist.activeTrack == null ),
? null ),
: () { child: IconButton(
connectNotifier.setShuffle(!shuffled); icon: const Icon(SpotubeIcons.shuffle),
}, variance: shuffled
), ? ButtonVariance.secondary
IconButton( : ButtonVariance.ghost,
tooltip: context.l10n.previous_track, onPressed: playlist.activeTrack == null
icon: const Icon(SpotubeIcons.skipBack), ? null
onPressed: playlist.activeTrack == null : () {
? null connectNotifier.setShuffle(!shuffled);
: connectNotifier.previous, },
), ),
IconButton( ),
tooltip: playing Tooltip(
? context.l10n.pause_playback tooltip: TooltipContainer(
: context.l10n.resume_playback, child: Text(context.l10n.previous_track),
icon: playlist.activeTrack == null ),
? SizedBox( child: IconButton.ghost(
height: 20, icon: const Icon(SpotubeIcons.skipBack),
width: 20, onPressed: playlist.activeTrack == null
child: CircularProgressIndicator( ? null
color: colorScheme.onPrimary, : connectNotifier.previous,
), ),
) ),
: Icon( Tooltip(
playing tooltip: TooltipContainer(
? SpotubeIcons.pause child: Text(
: SpotubeIcons.play, playing
), ? context.l10n.pause_playback
style: resumePauseStyle, : context.l10n.resume_playback,
onPressed: playlist.activeTrack == null ),
? null ),
: () { child: IconButton.primary(
if (playing) { shape: ButtonShape.circle,
connectNotifier.pause(); icon: playlist.activeTrack == null
} else { ? const SizedBox(
connectNotifier.resume(); height: 20,
} width: 20,
}, child: CircularProgressIndicator(
), onSurface: false),
IconButton( )
tooltip: context.l10n.next_track, : Icon(
icon: const Icon(SpotubeIcons.skipForward), playing
onPressed: playlist.activeTrack == null ? SpotubeIcons.pause
? null : SpotubeIcons.play,
: connectNotifier.next, ),
), onPressed: playlist.activeTrack == null
IconButton( ? null
tooltip: loopMode == PlaylistMode.single : () {
? context.l10n.loop_track if (playing) {
: loopMode == PlaylistMode.loop connectNotifier.pause();
? context.l10n.repeat_playlist } else {
: null, connectNotifier.resume();
icon: Icon( }
loopMode == PlaylistMode.single },
? SpotubeIcons.repeatOne ),
: SpotubeIcons.repeat, ),
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.next_track)),
child: IconButton.ghost(
icon: const Icon(SpotubeIcons.skipForward),
onPressed: playlist.activeTrack == null
? null
: connectNotifier.next,
),
),
Tooltip(
tooltip: TooltipContainer(
child: Text(
loopMode == PlaylistMode.single
? context.l10n.loop_track
: loopMode == PlaylistMode.loop
? context.l10n.repeat_playlist
: context.l10n.no_loop,
),
),
child: IconButton(
icon: Icon(
loopMode == PlaylistMode.single
? SpotubeIcons.repeatOne
: SpotubeIcons.repeat,
),
variance: loopMode == PlaylistMode.single ||
loopMode == PlaylistMode.loop
? ButtonVariance.secondary
: ButtonVariance.ghost,
onPressed: playlist.activeTrack == null
? null
: () async {
connectNotifier.setLoopMode(
switch (loopMode) {
PlaylistMode.loop =>
PlaylistMode.single,
PlaylistMode.single =>
PlaylistMode.none,
PlaylistMode.none =>
PlaylistMode.loop,
},
);
},
), ),
style: loopMode == PlaylistMode.single ||
loopMode == PlaylistMode.loop
? activeButtonStyle
: buttonStyle,
onPressed: playlist.activeTrack == null
? null
: () async {
connectNotifier.setLoopMode(
switch (loopMode) {
PlaylistMode.loop =>
PlaylistMode.single,
PlaylistMode.single =>
PlaylistMode.none,
PlaylistMode.none => PlaylistMode.loop,
},
);
},
) )
], ],
), ),
), ),
const SliverGap(30), const SliverGap(30),
if (constrains.mdAndDown)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverToBoxAdapter(
child: Button.outline(
leading: const Icon(SpotubeIcons.queue),
child: Text(context.l10n.queue),
onPressed: () {
openDrawer(
context: context,
barrierDismissible: true,
draggable: true,
barrierColor: Colors.black.withAlpha(100),
borderRadius: BorderRadius.circular(10),
transformBackdrop: false,
position: OverlayPosition.bottom,
surfaceBlur: context.theme.surfaceBlur,
surfaceOpacity: 0.7,
expands: true,
builder: (context) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight:
MediaQuery.sizeOf(context).height *
0.8,
),
child: const RemotePlayerQueue(),
);
},
);
},
),
),
),
const SliverGap(30),
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
@ -300,25 +354,7 @@ class ConnectControlPage extends HookConsumerWidget {
}), }),
), ),
), ),
const SliverGap(30), const SliverSafeArea(sliver: SliverGap(10)),
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 const RemotePlayerQueue();
},
);
},
),
),
)
], ],
), ),
), ),

View File

@ -1,5 +1,6 @@
{ {
"ar": [ "ar": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -9,6 +10,7 @@
], ],
"bn": [ "bn": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -18,6 +20,7 @@
], ],
"ca": [ "ca": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -27,6 +30,7 @@
], ],
"cs": [ "cs": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -36,6 +40,7 @@
], ],
"de": [ "de": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -45,6 +50,7 @@
], ],
"es": [ "es": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -54,6 +60,7 @@
], ],
"eu": [ "eu": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -63,6 +70,7 @@
], ],
"fa": [ "fa": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -72,6 +80,7 @@
], ],
"fi": [ "fi": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -81,6 +90,7 @@
], ],
"fr": [ "fr": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -90,6 +100,7 @@
], ],
"hi": [ "hi": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -99,6 +110,7 @@
], ],
"id": [ "id": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -108,6 +120,7 @@
], ],
"it": [ "it": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -117,6 +130,7 @@
], ],
"ja": [ "ja": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -126,6 +140,7 @@
], ],
"ka": [ "ka": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -135,6 +150,7 @@
], ],
"ko": [ "ko": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -144,6 +160,7 @@
], ],
"ne": [ "ne": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -153,6 +170,7 @@
], ],
"nl": [ "nl": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -162,6 +180,7 @@
], ],
"pl": [ "pl": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -171,6 +190,7 @@
], ],
"pt": [ "pt": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -180,6 +200,7 @@
], ],
"ru": [ "ru": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -189,6 +210,7 @@
], ],
"th": [ "th": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -198,6 +220,7 @@
], ],
"tr": [ "tr": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -207,6 +230,7 @@
], ],
"uk": [ "uk": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -216,6 +240,7 @@
], ],
"vi": [ "vi": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",
@ -225,6 +250,7 @@
], ],
"zh": [ "zh": [
"no_loop",
"undo", "undo",
"download_all", "download_all",
"add_all_to_playlist", "add_all_to_playlist",