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: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<int>(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 &&

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",
"resume_playback": "Resume Playback",
"loop_track": "Loop track",
"no_loop": "No loop",
"repeat_playlist": "Repeat playlist",
"queue": "Queue",
"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: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(
return ButtonTile(
selected: selectedDevice == device,
onPressed: () => audioPlayer.setAudioDevice(device),
leading: const Icon(SpotubeIcons.speaker),
title: Text(device.description),
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: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,22 +16,19 @@ 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(
headers: [
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(
@ -41,7 +38,7 @@ class ConnectPage extends HookConsumerWidget {
sliver: SliverToBoxAdapter(
child: Text(
context.l10n.remote,
style: textTheme.titleMedium,
style: typography.bold,
),
),
),
@ -54,8 +51,8 @@ class ConnectPage extends HookConsumerWidget {
final selected =
connectClients.asData?.value.resolvedService?.name ==
device.name;
return Card(
child: ListTile(
return ButtonTile(
selected: selected,
leading: const Icon(SpotubeIcons.monitor),
title: Text(device.name),
subtitle: selected
@ -64,8 +61,15 @@ class ConnectPage extends HookConsumerWidget {
":${connectClients.asData?.value.resolvedService?.port}",
)
: null,
selected: selected,
onTap: () {
trailing: selected
? IconButton.outline(
icon: const Icon(SpotubeIcons.power),
size: ButtonSize.small,
onPressed: () =>
connectClientsNotifier.clearResolvedService(),
)
: null,
onPressed: () {
if (selected) {
ServiceUtils.pushNamed(
context,
@ -75,14 +79,6 @@ class ConnectPage extends HookConsumerWidget {
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: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(
headers: [
TitleBar(
title: Text(resolvedService!.name),
automaticallyImplyLeading: true,
),
body: LayoutBuilder(builder: (context, constrains) {
)
],
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,43 +189,59 @@ class ConnectControlPage extends HookConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
tooltip: shuffled
Tooltip(
tooltip: TooltipContainer(
child: Text(
shuffled
? context.l10n.unshuffle_playlist
: context.l10n.shuffle_playlist,
),
),
child: IconButton(
icon: const Icon(SpotubeIcons.shuffle),
style: shuffled ? activeButtonStyle : buttonStyle,
variance: shuffled
? ButtonVariance.secondary
: ButtonVariance.ghost,
onPressed: playlist.activeTrack == null
? null
: () {
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),
onPressed: playlist.activeTrack == null
? null
: connectNotifier.previous,
),
IconButton(
tooltip: playing
),
Tooltip(
tooltip: TooltipContainer(
child: Text(
playing
? context.l10n.pause_playback
: context.l10n.resume_playback,
),
),
child: IconButton.primary(
shape: ButtonShape.circle,
icon: playlist.activeTrack == null
? SizedBox(
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: colorScheme.onPrimary,
),
onSurface: false),
)
: Icon(
playing
? SpotubeIcons.pause
: SpotubeIcons.play,
),
style: resumePauseStyle,
onPressed: playlist.activeTrack == 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),
onPressed: playlist.activeTrack == null
? null
: connectNotifier.next,
),
IconButton(
tooltip: loopMode == PlaylistMode.single
),
Tooltip(
tooltip: TooltipContainer(
child: Text(
loopMode == PlaylistMode.single
? context.l10n.loop_track
: loopMode == PlaylistMode.loop
? context.l10n.repeat_playlist
: null,
: context.l10n.no_loop,
),
),
child: IconButton(
icon: Icon(
loopMode == PlaylistMode.single
? SpotubeIcons.repeatOne
: SpotubeIcons.repeat,
),
style: loopMode == PlaylistMode.single ||
variance: loopMode == PlaylistMode.single ||
loopMode == PlaylistMode.loop
? activeButtonStyle
: buttonStyle,
? ButtonVariance.secondary
: ButtonVariance.ghost,
onPressed: playlist.activeTrack == null
? null
: () async {
@ -275,15 +292,52 @@ class ConnectControlPage extends HookConsumerWidget {
PlaylistMode.single,
PlaylistMode.single =>
PlaylistMode.none,
PlaylistMode.none => PlaylistMode.loop,
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)),
],
),
),

View File

@ -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",