diff --git a/lib/components/titlebar/titlebar.dart b/lib/components/titlebar/titlebar.dart index 5c3f7940..cba33ab4 100644 --- a/lib/components/titlebar/titlebar.dart +++ b/lib/components/titlebar/titlebar.dart @@ -1,3 +1,4 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/components/button/back_button.dart'; @@ -60,6 +61,7 @@ class TitleBar extends HookConsumerWidget implements PreferredSizeWidget { @override Widget build(BuildContext context, ref) { final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context); + final lastClicked = useRef(DateTime.now().millisecondsSinceEpoch); return SizedBox( height: height ?? 56, @@ -71,6 +73,23 @@ class TitleBar extends HookConsumerWidget implements PreferredSizeWidget { return GestureDetector( onHorizontalDragStart: (_) => 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( leading: leading.isEmpty && automaticallyImplyLeading && diff --git a/lib/components/ui/button_tile.dart b/lib/components/ui/button_tile.dart new file mode 100644 index 00000000..d865b583 --- /dev/null +++ b/lib/components/ui/button_tile.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4109edb7..0be79bda 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -97,6 +97,7 @@ "pause_playback": "Pause Playback", "resume_playback": "Resume Playback", "loop_track": "Loop track", + "no_loop": "No loop", "repeat_playlist": "Repeat playlist", "queue": "Queue", "alternative_track_sources": "Alternative track sources", diff --git a/lib/modules/connect/local_devices.dart b/lib/modules/connect/local_devices.dart index dd7db971..138e9e13 100644 --- a/lib/modules/connect/local_devices.dart +++ b/lib/modules/connect/local_devices.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.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/components/ui/button_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -10,7 +10,7 @@ class ConnectPageLocalDevices extends HookWidget { @override Widget build(BuildContext context) { - final ThemeData(:textTheme) = Theme.of(context); + final ThemeData(:typography) = Theme.of(context); final devicesFuture = useFuture(audioPlayer.devices); final devicesStream = useStream(audioPlayer.devicesStream); final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice); @@ -32,7 +32,7 @@ class ConnectPageLocalDevices extends HookWidget { sliver: SliverToBoxAdapter( child: Text( context.l10n.this_device, - style: textTheme.titleMedium, + style: typography.bold, ), ), ), @@ -43,14 +43,12 @@ class ConnectPageLocalDevices extends HookWidget { 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), - ), + return ButtonTile( + selected: selectedDevice == device, + onPressed: () => audioPlayer.setAudioDevice(device), + leading: const Icon(SpotubeIcons.speaker), + title: Text(device.description), + subtitle: Text(device.name), ); }, ), diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart index cdda39da..a91c883d 100644 --- a/lib/modules/player/player_queue.dart +++ b/lib/modules/player/player_queue.dart @@ -224,7 +224,7 @@ class PlayerQueue extends HookConsumerWidget { ); }, ), - const SliverGap(100), + const SliverSafeArea(sliver: SliverGap(100)), ], ), ), diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index 1a5dfb31..55c72026 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.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/components/ui/button_tile.dart'; import 'package:spotube/modules/connect/local_devices.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; @@ -16,79 +16,74 @@ class ConnectPage extends HookConsumerWidget { @override 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 connectClientsNotifier = ref.read(connectClientsProvider.notifier); final discoveredDevices = connectClients.asData?.value.services; return Scaffold( - appBar: TitleBar( - 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, - ), + headers: [ + TitleBar( + automaticallyImplyLeading: true, + title: Text(context.l10n.devices), + ) + ], + 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: typography.bold, ), ), - 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.pushNamed( - context, - ConnectControlPage.name, - ); - } else { - connectClientsNotifier.resolveService(device); - } - }, - trailing: selected - ? IconButton( - icon: const Icon(SpotubeIcons.power), - onPressed: () => - connectClientsNotifier.clearResolvedService(), - ) - : null, - ), - ); - }, - ), - const ConnectPageLocalDevices(), - ], - ), + ), + 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 ButtonTile( + selected: selected, + leading: const Icon(SpotubeIcons.monitor), + title: Text(device.name), + subtitle: selected + ? Text( + "${connectClients.asData?.value.resolvedService?.host}" + ":${connectClients.asData?.value.resolvedService?.port}", + ) + : null, + trailing: selected + ? IconButton.outline( + icon: const Icon(SpotubeIcons.power), + size: ButtonSize.small, + onPressed: () => + connectClientsNotifier.clearResolvedService(), + ) + : null, + onPressed: () { + if (selected) { + ServiceUtils.pushNamed( + context, + ConnectControlPage.name, + ); + } else { + connectClientsNotifier.resolveService(device); + } + }, + ); + }, + ), + const ConnectPageLocalDevices(), + ], ), ), ); diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index 03406546..b92a5482 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -1,7 +1,7 @@ -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:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/volume_slider.dart'; @@ -53,7 +53,7 @@ class ConnectControlPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final ThemeData(:typography, :colorScheme) = Theme.of(context); final resolvedService = ref.watch(connectClientsProvider).asData?.value.resolvedService; @@ -63,23 +63,6 @@ class ConnectControlPage extends HookConsumerWidget { 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), - ); - ref.listen(connectClientsProvider, (prev, next) { if (next.asData?.value.resolvedService == null) { context.pop(); @@ -87,12 +70,15 @@ class ConnectControlPage extends HookConsumerWidget { }); return SafeArea( + bottom: false, child: Scaffold( - appBar: TitleBar( - title: Text(resolvedService!.name), - automaticallyImplyLeading: true, - ), - body: LayoutBuilder(builder: (context, constrains) { + headers: [ + TitleBar( + title: Text(resolvedService!.name), + automaticallyImplyLeading: true, + ) + ], + child: LayoutBuilder(builder: (context, constrains) { return Row( children: [ Expanded( @@ -106,7 +92,7 @@ class ConnectControlPage extends HookConsumerWidget { vertical: 10, ).copyWith(top: 0), constraints: - const BoxConstraints(maxHeight: 400, maxWidth: 400), + const BoxConstraints(maxHeight: 350, maxWidth: 350), child: ClipRRect( borderRadius: BorderRadius.circular(20), child: UniversalImage( @@ -126,7 +112,7 @@ class ConnectControlPage extends HookConsumerWidget { SliverToBoxAdapter( child: AnchorButton( playlist.activeTrack?.name ?? "", - style: textTheme.titleLarge!, + style: typography.h4, onTap: () { if (playlist.activeTrack == null) return; ServiceUtils.pushNamed( @@ -142,7 +128,7 @@ class ConnectControlPage extends HookConsumerWidget { SliverToBoxAdapter( child: ArtistLink( artists: playlist.activeTrack?.artists ?? [], - textStyle: textTheme.bodyMedium!, + textStyle: typography.normal, mainAxisAlignment: WrapAlignment.start, onOverflowArtistClick: () => ServiceUtils.pushNamed( @@ -164,19 +150,25 @@ class ConnectControlPage extends HookConsumerWidget { final position = ref.watch(positionProvider); final duration = ref.watch(durationProvider); + final progress = duration.inSeconds == 0 + ? 0 + : position.inSeconds / duration.inSeconds; + 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(), + value: + SliderValue.single(progress.toDouble()), onChanged: (value) { - connectNotifier - .seek(Duration(seconds: value.toInt())); + connectNotifier.seek( + Duration( + seconds: + (value.value * duration.inSeconds) + .toInt(), + ), + ); }, ), Row( @@ -197,93 +189,155 @@ class ConnectControlPage extends HookConsumerWidget { 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 == PlaylistMode.single - ? context.l10n.loop_track - : loopMode == PlaylistMode.loop - ? context.l10n.repeat_playlist - : null, - icon: Icon( - loopMode == PlaylistMode.single - ? SpotubeIcons.repeatOne - : SpotubeIcons.repeat, + Tooltip( + tooltip: TooltipContainer( + child: Text( + shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + ), + ), + child: IconButton( + icon: const Icon(SpotubeIcons.shuffle), + variance: shuffled + ? ButtonVariance.secondary + : ButtonVariance.ghost, + onPressed: playlist.activeTrack == null + ? null + : () { + connectNotifier.setShuffle(!shuffled); + }, + ), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.previous_track), + ), + child: IconButton.ghost( + icon: const Icon(SpotubeIcons.skipBack), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.previous, + ), + ), + Tooltip( + tooltip: TooltipContainer( + child: Text( + playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + ), + ), + child: IconButton.primary( + shape: ButtonShape.circle, + icon: playlist.activeTrack == null + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + onSurface: false), + ) + : Icon( + playing + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + onPressed: playlist.activeTrack == null + ? null + : () { + if (playing) { + connectNotifier.pause(); + } else { + connectNotifier.resume(); + } + }, + ), + ), + 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), + 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( padding: const EdgeInsets.symmetric(horizontal: 20), sliver: SliverToBoxAdapter( @@ -300,25 +354,7 @@ class ConnectControlPage extends HookConsumerWidget { }), ), ), - 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 const RemotePlayerQueue(); - }, - ); - }, - ), - ), - ) + const SliverSafeArea(sliver: SliverGap(10)), ], ), ), diff --git a/untranslated_messages.json b/untranslated_messages.json index 05b5aca3..fae95f00 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,5 +1,6 @@ { "ar": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -9,6 +10,7 @@ ], "bn": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -18,6 +20,7 @@ ], "ca": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -27,6 +30,7 @@ ], "cs": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -36,6 +40,7 @@ ], "de": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -45,6 +50,7 @@ ], "es": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -54,6 +60,7 @@ ], "eu": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -63,6 +70,7 @@ ], "fa": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -72,6 +80,7 @@ ], "fi": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -81,6 +90,7 @@ ], "fr": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -90,6 +100,7 @@ ], "hi": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -99,6 +110,7 @@ ], "id": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -108,6 +120,7 @@ ], "it": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -117,6 +130,7 @@ ], "ja": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -126,6 +140,7 @@ ], "ka": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -135,6 +150,7 @@ ], "ko": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -144,6 +160,7 @@ ], "ne": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -153,6 +170,7 @@ ], "nl": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -162,6 +180,7 @@ ], "pl": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -171,6 +190,7 @@ ], "pt": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -180,6 +200,7 @@ ], "ru": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -189,6 +210,7 @@ ], "th": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -198,6 +220,7 @@ ], "tr": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -207,6 +230,7 @@ ], "uk": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -216,6 +240,7 @@ ], "vi": [ + "no_loop", "undo", "download_all", "add_all_to_playlist", @@ -225,6 +250,7 @@ ], "zh": [ + "no_loop", "undo", "download_all", "add_all_to_playlist",