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,
onPressed: () => audioPlayer.setAudioDevice(device),
leading: const Icon(SpotubeIcons.speaker), leading: const Icon(SpotubeIcons.speaker),
title: Text(device.description), title: Text(device.description),
subtitle: Text(device.name), subtitle: Text(device.name),
selected: selectedDevice == device,
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,22 +16,19 @@ 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: [
TitleBar(
automaticallyImplyLeading: true, automaticallyImplyLeading: true,
title: Text(context.l10n.devices), title: Text(context.l10n.devices),
), )
body: ListTileTheme( ],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
selectedTileColor: colorScheme.secondary.withOpacity(0.1),
child: Padding( child: Padding(
padding: const EdgeInsets.all(10.0), padding: const EdgeInsets.all(10.0),
child: CustomScrollView( child: CustomScrollView(
@ -41,7 +38,7 @@ class ConnectPage extends HookConsumerWidget {
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
child: Text( child: Text(
context.l10n.remote, context.l10n.remote,
style: textTheme.titleMedium, style: typography.bold,
), ),
), ),
), ),
@ -54,8 +51,8 @@ class ConnectPage extends HookConsumerWidget {
final selected = final selected =
connectClients.asData?.value.resolvedService?.name == connectClients.asData?.value.resolvedService?.name ==
device.name; device.name;
return Card( return ButtonTile(
child: ListTile( selected: selected,
leading: const Icon(SpotubeIcons.monitor), leading: const Icon(SpotubeIcons.monitor),
title: Text(device.name), title: Text(device.name),
subtitle: selected subtitle: selected
@ -64,8 +61,15 @@ class ConnectPage extends HookConsumerWidget {
":${connectClients.asData?.value.resolvedService?.port}", ":${connectClients.asData?.value.resolvedService?.port}",
) )
: null, : null,
selected: selected, trailing: selected
onTap: () { ? IconButton.outline(
icon: const Icon(SpotubeIcons.power),
size: ButtonSize.small,
onPressed: () =>
connectClientsNotifier.clearResolvedService(),
)
: null,
onPressed: () {
if (selected) { if (selected) {
ServiceUtils.pushNamed( ServiceUtils.pushNamed(
context, context,
@ -75,14 +79,6 @@ class ConnectPage extends HookConsumerWidget {
connectClientsNotifier.resolveService(device); connectClientsNotifier.resolveService(device);
} }
}, },
trailing: selected
? IconButton(
icon: const Icon(SpotubeIcons.power),
onPressed: () =>
connectClientsNotifier.clearResolvedService(),
)
: null,
),
); );
}, },
), ),
@ -90,7 +86,6 @@ class ConnectPage extends HookConsumerWidget {
], ],
), ),
), ),
),
); );
} }
} }

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: [
TitleBar(
title: Text(resolvedService!.name), title: Text(resolvedService!.name),
automaticallyImplyLeading: true, 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,43 +189,59 @@ class ConnectControlPage extends HookConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton( Tooltip(
tooltip: shuffled tooltip: TooltipContainer(
child: Text(
shuffled
? context.l10n.unshuffle_playlist ? context.l10n.unshuffle_playlist
: context.l10n.shuffle_playlist, : context.l10n.shuffle_playlist,
),
),
child: IconButton(
icon: const Icon(SpotubeIcons.shuffle), icon: const Icon(SpotubeIcons.shuffle),
style: shuffled ? activeButtonStyle : buttonStyle, variance: shuffled
? ButtonVariance.secondary
: ButtonVariance.ghost,
onPressed: playlist.activeTrack == null onPressed: playlist.activeTrack == null
? null ? null
: () { : () {
connectNotifier.setShuffle(!shuffled); connectNotifier.setShuffle(!shuffled);
}, },
), ),
IconButton( ),
tooltip: context.l10n.previous_track, Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.previous_track),
),
child: IconButton.ghost(
icon: const Icon(SpotubeIcons.skipBack), icon: const Icon(SpotubeIcons.skipBack),
onPressed: playlist.activeTrack == null onPressed: playlist.activeTrack == null
? null ? null
: connectNotifier.previous, : connectNotifier.previous,
), ),
IconButton( ),
tooltip: playing Tooltip(
tooltip: TooltipContainer(
child: Text(
playing
? context.l10n.pause_playback ? context.l10n.pause_playback
: context.l10n.resume_playback, : context.l10n.resume_playback,
),
),
child: IconButton.primary(
shape: ButtonShape.circle,
icon: playlist.activeTrack == null icon: playlist.activeTrack == null
? SizedBox( ? const SizedBox(
height: 20, height: 20,
width: 20, width: 20,
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: colorScheme.onPrimary, onSurface: false),
),
) )
: Icon( : Icon(
playing playing
? SpotubeIcons.pause ? SpotubeIcons.pause
: SpotubeIcons.play, : SpotubeIcons.play,
), ),
style: resumePauseStyle,
onPressed: playlist.activeTrack == null onPressed: playlist.activeTrack == null
? null ? null
: () { : () {
@ -244,28 +252,37 @@ class ConnectControlPage extends HookConsumerWidget {
} }
}, },
), ),
IconButton( ),
tooltip: context.l10n.next_track, Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.next_track)),
child: IconButton.ghost(
icon: const Icon(SpotubeIcons.skipForward), icon: const Icon(SpotubeIcons.skipForward),
onPressed: playlist.activeTrack == null onPressed: playlist.activeTrack == null
? null ? null
: connectNotifier.next, : connectNotifier.next,
), ),
IconButton( ),
tooltip: loopMode == PlaylistMode.single Tooltip(
tooltip: TooltipContainer(
child: Text(
loopMode == PlaylistMode.single
? context.l10n.loop_track ? context.l10n.loop_track
: loopMode == PlaylistMode.loop : loopMode == PlaylistMode.loop
? context.l10n.repeat_playlist ? context.l10n.repeat_playlist
: null, : context.l10n.no_loop,
),
),
child: IconButton(
icon: Icon( icon: Icon(
loopMode == PlaylistMode.single loopMode == PlaylistMode.single
? SpotubeIcons.repeatOne ? SpotubeIcons.repeatOne
: SpotubeIcons.repeat, : SpotubeIcons.repeat,
), ),
style: loopMode == PlaylistMode.single || variance: loopMode == PlaylistMode.single ||
loopMode == PlaylistMode.loop loopMode == PlaylistMode.loop
? activeButtonStyle ? ButtonVariance.secondary
: buttonStyle, : ButtonVariance.ghost,
onPressed: playlist.activeTrack == null onPressed: playlist.activeTrack == null
? null ? null
: () async { : () async {
@ -275,15 +292,52 @@ class ConnectControlPage extends HookConsumerWidget {
PlaylistMode.single, PlaylistMode.single,
PlaylistMode.single => PlaylistMode.single =>
PlaylistMode.none, PlaylistMode.none,
PlaylistMode.none => PlaylistMode.loop, 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",