spotube/lib/components/shared/page_window_title_bar.dart
Kingkor Roy Tirtho 68374efd3e
feat: LAN connect a.k.a control remote Spotube playback and local output device selection (#1355)
* feat: add connect server support

* feat: add ability discover and connect to same network Spotube(s) and sync queue

* feat(connect): add player controls, shuffle, loop, progress bar and queue support

* feat: make control page adaptive

* feat: add volume control support

* cd: upgrade macos runner version

* chore: upgrade inappwebview version to 6

* feat: customized devices button

* feat: add user icon next to devices button

* feat: add play in remote device support

* feat: show alert when new client connects

* fix: ignore the device itself from broadcast list

* fix: volume control not working

* feat: add ability to select current device's output speaker
2024-04-04 22:22:00 +06:00

650 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:titlebar_buttons/titlebar_buttons.dart';
import 'dart:math';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform;
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
class PageWindowTitleBar extends StatefulHookConsumerWidget
implements PreferredSizeWidget {
final Widget? leading;
final bool automaticallyImplyLeading;
final List<Widget>? actions;
final Color? backgroundColor;
final Color? foregroundColor;
final IconThemeData? actionsIconTheme;
final bool? centerTitle;
final double? titleSpacing;
final double toolbarOpacity;
final double? leadingWidth;
final TextStyle? toolbarTextStyle;
final TextStyle? titleTextStyle;
final double? titleWidth;
final Widget? title;
final bool _sliver;
const PageWindowTitleBar({
super.key,
this.actions,
this.title,
this.toolbarOpacity = 1,
this.backgroundColor,
this.actionsIconTheme,
this.automaticallyImplyLeading = false,
this.centerTitle,
this.foregroundColor,
this.leading,
this.leadingWidth,
this.titleSpacing,
this.titleTextStyle,
this.titleWidth,
this.toolbarTextStyle,
}) : _sliver = false,
pinned = false,
floating = false,
snap = false,
stretch = false;
final bool pinned;
final bool floating;
final bool snap;
final bool stretch;
const PageWindowTitleBar.sliver({
super.key,
this.actions,
this.title,
this.backgroundColor,
this.actionsIconTheme,
this.automaticallyImplyLeading = false,
this.centerTitle,
this.foregroundColor,
this.leading,
this.leadingWidth,
this.titleSpacing,
this.titleTextStyle,
this.titleWidth,
this.toolbarTextStyle,
this.pinned = false,
this.floating = false,
this.snap = false,
this.stretch = false,
}) : _sliver = true,
toolbarOpacity = 1;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
ConsumerState<PageWindowTitleBar> createState() => _PageWindowTitleBarState();
}
class _PageWindowTitleBarState extends ConsumerState<PageWindowTitleBar> {
void onDrag(details) {
final systemTitleBar =
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
if (kIsDesktop && !systemTitleBar) {
DesktopTools.window.startDragging();
}
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
if (widget._sliver) {
return SliverLayoutBuilder(
builder: (context, constraints) {
final hasFullscreen =
mediaQuery.size.width == constraints.crossAxisExtent;
final hasLeadingOrCanPop =
widget.leading != null || Navigator.canPop(context);
return SliverPadding(
padding: EdgeInsets.only(
left: DesktopTools.platform.isMacOS &&
hasFullscreen &&
hasLeadingOrCanPop
? 65
: 0,
),
sliver: SliverAppBar(
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
actions: [
...?widget.actions,
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
],
backgroundColor: widget.backgroundColor,
foregroundColor: widget.foregroundColor,
actionsIconTheme: widget.actionsIconTheme,
centerTitle: widget.centerTitle,
titleSpacing: widget.titleSpacing,
leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
title: widget.title,
pinned: widget.pinned,
floating: widget.floating,
snap: widget.snap,
stretch: widget.stretch,
),
);
},
);
}
return LayoutBuilder(builder: (context, constrains) {
final hasFullscreen = mediaQuery.size.width == constrains.maxWidth;
final hasLeadingOrCanPop =
widget.leading != null || Navigator.canPop(context);
return GestureDetector(
onHorizontalDragStart: onDrag,
onVerticalDragStart: onDrag,
child: Padding(
padding: EdgeInsets.only(
left: DesktopTools.platform.isMacOS &&
hasFullscreen &&
hasLeadingOrCanPop
? 65
: 0,
),
child: AppBar(
leading: widget.leading,
automaticallyImplyLeading: widget.automaticallyImplyLeading,
actions: [
...?widget.actions,
WindowTitleBarButtons(foregroundColor: widget.foregroundColor),
],
backgroundColor: widget.backgroundColor,
foregroundColor: widget.foregroundColor,
actionsIconTheme: widget.actionsIconTheme,
centerTitle: widget.centerTitle,
titleSpacing: widget.titleSpacing,
toolbarOpacity: widget.toolbarOpacity,
leadingWidth: widget.leadingWidth,
toolbarTextStyle: widget.toolbarTextStyle,
titleTextStyle: widget.titleTextStyle,
title: widget.title,
),
),
);
});
}
}
class WindowTitleBarButtons extends HookConsumerWidget {
final Color? foregroundColor;
const WindowTitleBarButtons({
super.key,
this.foregroundColor,
});
@override
Widget build(BuildContext context, ref) {
final preferences = ref.watch(userPreferencesProvider);
final isMaximized = useState<bool?>(null);
const type = ThemeType.auto;
Future<void> onClose() async {
await DesktopTools.window.close();
}
useEffect(() {
if (kIsDesktop) {
DesktopTools.window.isMaximized().then((value) {
isMaximized.value = value;
});
}
return null;
}, []);
if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) {
return const SizedBox.shrink();
}
if (kIsWindows) {
final theme = Theme.of(context);
final colors = WindowButtonColors(
normal: Colors.transparent,
iconNormal: foregroundColor ?? theme.colorScheme.onBackground,
mouseOver: theme.colorScheme.onBackground.withOpacity(0.1),
mouseDown: theme.colorScheme.onBackground.withOpacity(0.2),
iconMouseOver: theme.colorScheme.onBackground,
iconMouseDown: theme.colorScheme.onBackground,
);
final closeColors = WindowButtonColors(
normal: Colors.transparent,
iconNormal: foregroundColor ?? theme.colorScheme.onBackground,
mouseOver: Colors.red,
mouseDown: Colors.red[800]!,
iconMouseOver: Colors.white,
iconMouseDown: Colors.black,
);
return Padding(
padding: const EdgeInsets.only(bottom: 25),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MinimizeWindowButton(
onPressed: DesktopTools.window.minimize,
colors: colors,
),
if (isMaximized.value != true)
MaximizeWindowButton(
colors: colors,
onPressed: () {
DesktopTools.window.maximize();
isMaximized.value = true;
},
)
else
RestoreWindowButton(
colors: colors,
onPressed: () {
DesktopTools.window.unmaximize();
isMaximized.value = false;
},
),
CloseWindowButton(
colors: closeColors,
onPressed: onClose,
),
],
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 20, left: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedMinimizeButton(
type: type,
onPressed: DesktopTools.window.minimize,
),
DecoratedMaximizeButton(
type: type,
onPressed: () async {
if (await DesktopTools.window.isMaximized()) {
await DesktopTools.window.unmaximize();
isMaximized.value = false;
} else {
await DesktopTools.window.maximize();
isMaximized.value = true;
}
},
),
DecoratedCloseButton(
type: type,
onPressed: onClose,
),
],
),
);
}
}
typedef WindowButtonIconBuilder = Widget Function(
WindowButtonContext buttonContext);
typedef WindowButtonBuilder = Widget Function(
WindowButtonContext buttonContext, Widget icon);
class WindowButtonContext {
BuildContext context;
MouseState mouseState;
Color? backgroundColor;
Color iconColor;
WindowButtonContext(
{required this.context,
required this.mouseState,
this.backgroundColor,
required this.iconColor});
}
class WindowButtonColors {
late Color normal;
late Color mouseOver;
late Color mouseDown;
late Color iconNormal;
late Color iconMouseOver;
late Color iconMouseDown;
WindowButtonColors(
{Color? normal,
Color? mouseOver,
Color? mouseDown,
Color? iconNormal,
Color? iconMouseOver,
Color? iconMouseDown}) {
this.normal = normal ?? _defaultButtonColors.normal;
this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver;
this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown;
this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal;
this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver;
this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown;
}
}
final _defaultButtonColors = WindowButtonColors(
normal: Colors.transparent,
iconNormal: const Color(0xFF805306),
mouseOver: const Color(0xFF404040),
mouseDown: const Color(0xFF202020),
iconMouseOver: const Color(0xFFFFFFFF),
iconMouseDown: const Color(0xFFF0F0F0),
);
class WindowButton extends StatelessWidget {
final WindowButtonBuilder? builder;
final WindowButtonIconBuilder? iconBuilder;
late final WindowButtonColors colors;
final bool animate;
final EdgeInsets? padding;
final VoidCallback? onPressed;
WindowButton(
{super.key,
WindowButtonColors? colors,
this.builder,
@required this.iconBuilder,
this.padding,
this.onPressed,
this.animate = false}) {
this.colors = colors ?? _defaultButtonColors;
}
Color getBackgroundColor(MouseState mouseState) {
if (mouseState.isMouseDown) return colors.mouseDown;
if (mouseState.isMouseOver) return colors.mouseOver;
return colors.normal;
}
Color getIconColor(MouseState mouseState) {
if (mouseState.isMouseDown) return colors.iconMouseDown;
if (mouseState.isMouseOver) return colors.iconMouseOver;
return colors.iconNormal;
}
@override
Widget build(BuildContext context) {
if (kIsWeb) {
return Container();
} else {
// Don't show button on macOS
if (Platform.isMacOS) {
return Container();
}
}
return MouseStateBuilder(
builder: (context, mouseState) {
WindowButtonContext buttonContext = WindowButtonContext(
mouseState: mouseState,
context: context,
backgroundColor: getBackgroundColor(mouseState),
iconColor: getIconColor(mouseState));
var icon =
(iconBuilder != null) ? iconBuilder!(buttonContext) : Container();
var fadeOutColor =
getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0);
var padding = this.padding ?? const EdgeInsets.all(10);
var animationMs =
mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0);
Widget iconWithPadding = Padding(padding: padding, child: icon);
iconWithPadding = AnimatedContainer(
curve: Curves.easeOut,
duration: Duration(milliseconds: animationMs),
color: buttonContext.backgroundColor ?? fadeOutColor,
child: iconWithPadding);
var button =
(builder != null) ? builder!(buttonContext, icon) : iconWithPadding;
return SizedBox(
width: 45,
height: 32,
child: button,
);
},
onPressed: () {
if (onPressed != null) onPressed!();
},
);
}
}
class MinimizeWindowButton extends WindowButton {
MinimizeWindowButton(
{super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MinimizeIcon(color: buttonContext.iconColor),
);
}
class MaximizeWindowButton extends WindowButton {
MaximizeWindowButton(
{super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
MaximizeIcon(color: buttonContext.iconColor),
);
}
class RestoreWindowButton extends WindowButton {
RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate})
: super(
animate: animate ?? false,
iconBuilder: (buttonContext) =>
RestoreIcon(color: buttonContext.iconColor),
);
}
final _defaultCloseButtonColors = WindowButtonColors(
mouseOver: const Color(0xFFD32F2F),
mouseDown: const Color(0xFFB71C1C),
iconNormal: const Color(0xFF805306),
iconMouseOver: const Color(0xFFFFFFFF));
class CloseWindowButton extends WindowButton {
CloseWindowButton(
{super.key, WindowButtonColors? colors, super.onPressed, bool? animate})
: super(
colors: colors ?? _defaultCloseButtonColors,
animate: animate ?? false,
iconBuilder: (buttonContext) =>
CloseIcon(color: buttonContext.iconColor),
);
}
// Switched to CustomPaint icons by https://github.com/esDotDev
/// Close
class CloseIcon extends StatelessWidget {
final Color color;
const CloseIcon({super.key, required this.color});
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.topLeft,
child: Stack(children: [
// Use rotated containers instead of a painter because it renders slightly crisper than a painter for some reason.
Transform.rotate(
angle: pi * .25,
child:
Center(child: Container(width: 14, height: 1, color: color))),
Transform.rotate(
angle: pi * -.25,
child:
Center(child: Container(width: 14, height: 1, color: color))),
]),
);
}
/// Maximize
class MaximizeIcon extends StatelessWidget {
final Color color;
const MaximizeIcon({super.key, required this.color});
@override
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
}
class _MaximizePainter extends _IconPainter {
_MaximizePainter(super.color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p);
}
}
/// Restore
class RestoreIcon extends StatelessWidget {
final Color color;
const RestoreIcon({
super.key,
required this.color,
});
@override
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
}
class _RestorePainter extends _IconPainter {
_RestorePainter(super.color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p);
canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p);
canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p);
canvas.drawLine(
Offset(size.width, 0), Offset(size.width, size.height - 2), p);
canvas.drawLine(Offset(size.width, size.height - 2),
Offset(size.width - 2, size.height - 2), p);
}
}
/// Minimize
class MinimizeIcon extends StatelessWidget {
final Color color;
const MinimizeIcon({super.key, required this.color});
@override
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
}
class _MinimizePainter extends _IconPainter {
_MinimizePainter(super.color);
@override
void paint(Canvas canvas, Size size) {
Paint p = getPaint(color);
canvas.drawLine(
Offset(0, size.height / 2), Offset(size.width, size.height / 2), p);
}
}
/// Helpers
abstract class _IconPainter extends CustomPainter {
_IconPainter(this.color);
final Color color;
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _AlignedPaint extends StatelessWidget {
const _AlignedPaint(this.painter);
final CustomPainter painter;
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.center,
child: CustomPaint(size: const Size(10, 10), painter: painter));
}
}
Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint()
..color = color
..style = PaintingStyle.stroke
..isAntiAlias = isAntiAlias
..strokeWidth = 1;
typedef MouseStateBuilderCB = Widget Function(
BuildContext context, MouseState mouseState);
class MouseState {
bool isMouseOver = false;
bool isMouseDown = false;
MouseState();
@override
String toString() {
return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver";
}
}
T? _ambiguate<T>(T? value) => value;
class MouseStateBuilder extends StatefulWidget {
final MouseStateBuilderCB builder;
final VoidCallback? onPressed;
const MouseStateBuilder({super.key, required this.builder, this.onPressed});
@override
_MouseStateBuilderState createState() => _MouseStateBuilderState();
}
class _MouseStateBuilderState extends State<MouseStateBuilder> {
late MouseState _mouseState;
_MouseStateBuilderState() {
_mouseState = MouseState();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (event) {
setState(() {
_mouseState.isMouseOver = true;
});
},
onExit: (event) {
setState(() {
_mouseState.isMouseOver = false;
});
},
child: GestureDetector(
onTapDown: (_) {
setState(() {
_mouseState.isMouseDown = true;
});
},
onTapCancel: () {
setState(() {
_mouseState.isMouseDown = false;
});
},
onTap: () {
setState(() {
_mouseState.isMouseDown = false;
_mouseState.isMouseOver = false;
});
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) {
if (widget.onPressed != null) {
widget.onPressed!();
}
});
},
onTapUp: (_) {},
child: widget.builder(context, _mouseState)));
}
}