291 lines
11 KiB
Dart
291 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
|
|
/// Shared constants for mini player layout
|
|
class MiniPlayerLayout {
|
|
static const double height = 72.0;
|
|
static const double artSize = 72.0;
|
|
static const double textLeftOffset = 10.0; // Gap between art and text
|
|
static const double textLeft = artSize + textLeftOffset; // 82px
|
|
// 3-line layout: track, artist, player
|
|
static const double primaryTop = 9.0;
|
|
static const double secondaryTop = 26.0;
|
|
static const double tertiaryTop = 46.0;
|
|
// 2-line layout (evenly spaced): player name, hint
|
|
// Height 72 / 3 = 24px spacing. Line 1 center at 24, Line 2 center at 48
|
|
static const double primaryTop2Line = 14.0; // 24 - (18/2) = 15, adjusted to 14
|
|
static const double secondaryTop2Line = 40.0; // raised slightly from 42
|
|
static const double textRightPadding = 12.0;
|
|
static const double powerButtonSize = 40.0; // Power button tap area
|
|
static const double iconSize = 28.0;
|
|
static const double iconOpacity = 0.4;
|
|
static const double secondaryTextOpacity = 0.6;
|
|
static const double primaryFontSize = 16.0; // 3-line playing mode
|
|
static const double primaryFontSize2Line = 18.0; // larger for 2-line off mode
|
|
static const double secondaryFontSize = 14.0;
|
|
static const double tertiaryFontSize = 14.0;
|
|
static const FontWeight primaryFontWeight = FontWeight.w500;
|
|
}
|
|
|
|
/// Unified mini player content widget used by:
|
|
/// - DeviceSelectorBar (non-playing state)
|
|
/// - ExpandablePlayer collapsed state (playing state)
|
|
/// - Peek content during swipe gestures
|
|
class MiniPlayerContent extends StatelessWidget {
|
|
/// Primary text line (track name or device name)
|
|
final String primaryText;
|
|
|
|
/// Secondary text line (artist name or "Swipe to switch device")
|
|
/// If null, primary text will be vertically centered
|
|
final String? secondaryText;
|
|
|
|
/// Tertiary text line (player name when playing)
|
|
/// If provided, uses 3-line layout; if null, uses centered 2-line layout
|
|
final String? tertiaryText;
|
|
|
|
/// Album art URL - if null, shows device icon
|
|
final String? imageUrl;
|
|
|
|
/// Player name for icon selection when no image
|
|
final String playerName;
|
|
|
|
/// Background color for the content area
|
|
final Color backgroundColor;
|
|
|
|
/// Text color
|
|
final Color textColor;
|
|
|
|
/// Width of the content area
|
|
final double width;
|
|
|
|
/// Horizontal slide offset for swipe animation (-1 to 1)
|
|
final double slideOffset;
|
|
|
|
/// Whether secondary text is a hint (shows lightbulb icon)
|
|
final bool isHint;
|
|
|
|
/// Whether to show the progress bar overlay (for playing state)
|
|
final bool showProgress;
|
|
|
|
/// Progress value 0.0 to 1.0 (only used if showProgress is true)
|
|
final double progress;
|
|
|
|
/// Callback for power button tap (only shown in 2-line mode when provided)
|
|
final VoidCallback? onPowerToggle;
|
|
|
|
/// Whether the player is currently powered on
|
|
final bool isPoweredOn;
|
|
|
|
const MiniPlayerContent({
|
|
super.key,
|
|
required this.primaryText,
|
|
this.secondaryText,
|
|
this.tertiaryText,
|
|
this.imageUrl,
|
|
required this.playerName,
|
|
required this.backgroundColor,
|
|
required this.textColor,
|
|
required this.width,
|
|
this.slideOffset = 0.0,
|
|
this.isHint = false,
|
|
this.showProgress = false,
|
|
this.progress = 0.0,
|
|
this.onPowerToggle,
|
|
this.isPoweredOn = true,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasSecondaryLine = secondaryText != null && secondaryText!.isNotEmpty;
|
|
final hasTertiaryLine = tertiaryText != null && tertiaryText!.isNotEmpty;
|
|
final is2LineMode = !hasTertiaryLine;
|
|
final showPowerButton = is2LineMode && onPowerToggle != null;
|
|
final slidePixels = slideOffset * width;
|
|
|
|
// Use 3-line layout when tertiary exists, 2-line centered otherwise
|
|
final primaryTop = hasTertiaryLine ? MiniPlayerLayout.primaryTop : MiniPlayerLayout.primaryTop2Line;
|
|
final secondaryTop = hasTertiaryLine ? MiniPlayerLayout.secondaryTop : MiniPlayerLayout.secondaryTop2Line;
|
|
|
|
// Calculate right padding (extra space for power button in 2-line mode)
|
|
final rightPadding = showPowerButton
|
|
? MiniPlayerLayout.powerButtonSize + 8.0
|
|
: MiniPlayerLayout.textRightPadding;
|
|
|
|
// Darkened background for icon area (matches DeviceSelectorBar)
|
|
final iconAreaBackground = Color.lerp(backgroundColor, Colors.black, 0.15)!;
|
|
|
|
// Font size for primary text (larger in 2-line mode)
|
|
final primaryFontSize = is2LineMode
|
|
? MiniPlayerLayout.primaryFontSize2Line
|
|
: MiniPlayerLayout.primaryFontSize;
|
|
|
|
return SizedBox(
|
|
width: width,
|
|
height: MiniPlayerLayout.height,
|
|
child: Stack(
|
|
clipBehavior: Clip.hardEdge,
|
|
children: [
|
|
// Progress bar background (only for playing state)
|
|
if (showProgress && progress > 0)
|
|
Positioned(
|
|
left: MiniPlayerLayout.artSize + slidePixels,
|
|
top: 0,
|
|
bottom: 0,
|
|
width: (width - MiniPlayerLayout.artSize) * progress,
|
|
child: Container(color: backgroundColor),
|
|
),
|
|
|
|
// Art / Icon area - centered vertically
|
|
Positioned(
|
|
left: slidePixels,
|
|
top: (MiniPlayerLayout.height - MiniPlayerLayout.artSize) / 2,
|
|
child: SizedBox(
|
|
width: MiniPlayerLayout.artSize,
|
|
height: MiniPlayerLayout.artSize,
|
|
child: imageUrl != null
|
|
? CachedNetworkImage(
|
|
imageUrl: imageUrl!,
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: 256,
|
|
memCacheHeight: 256,
|
|
fadeInDuration: Duration.zero,
|
|
fadeOutDuration: Duration.zero,
|
|
placeholder: (_, __) => _buildIconArea(iconAreaBackground),
|
|
errorWidget: (_, __, ___) => _buildIconArea(iconAreaBackground),
|
|
)
|
|
: _buildIconArea(iconAreaBackground),
|
|
),
|
|
),
|
|
|
|
// Primary text line
|
|
Positioned(
|
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
|
top: hasSecondaryLine
|
|
? primaryTop
|
|
: (MiniPlayerLayout.height - primaryFontSize) / 2,
|
|
right: rightPadding - slidePixels,
|
|
child: Text(
|
|
primaryText,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontSize: primaryFontSize,
|
|
fontWeight: MiniPlayerLayout.primaryFontWeight,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
|
|
// Secondary text line (with optional hint icon)
|
|
if (hasSecondaryLine)
|
|
Positioned(
|
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
|
top: secondaryTop,
|
|
right: rightPadding - slidePixels,
|
|
child: isHint
|
|
? Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.lightbulb_outline,
|
|
size: 14,
|
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
secondaryText!,
|
|
style: TextStyle(
|
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
|
fontSize: MiniPlayerLayout.secondaryFontSize,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: Text(
|
|
secondaryText!,
|
|
style: TextStyle(
|
|
color: textColor.withOpacity(MiniPlayerLayout.secondaryTextOpacity),
|
|
fontSize: MiniPlayerLayout.secondaryFontSize,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
|
|
// Tertiary text line (player name, only for 3-line layout)
|
|
if (hasTertiaryLine)
|
|
Positioned(
|
|
left: MiniPlayerLayout.textLeft + slidePixels,
|
|
top: MiniPlayerLayout.tertiaryTop,
|
|
right: MiniPlayerLayout.textRightPadding - slidePixels,
|
|
child: Text(
|
|
tertiaryText!,
|
|
style: TextStyle(
|
|
color: textColor,
|
|
fontSize: MiniPlayerLayout.tertiaryFontSize,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
|
|
// Power button (only for 2-line mode when callback provided)
|
|
// Matches PlayerCard power button style
|
|
if (showPowerButton)
|
|
Positioned(
|
|
right: 8.0 - slidePixels,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Center(
|
|
child: IconButton(
|
|
onPressed: onPowerToggle,
|
|
icon: Icon(
|
|
Icons.power_settings_new_rounded,
|
|
color: isPoweredOn ? textColor : textColor.withOpacity(0.5),
|
|
size: 20,
|
|
),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Build the icon area with device-appropriate icon
|
|
Widget _buildIconArea(Color background) {
|
|
return Container(
|
|
color: background,
|
|
child: Center(
|
|
child: Icon(
|
|
_getPlayerIcon(playerName),
|
|
color: textColor.withOpacity(MiniPlayerLayout.iconOpacity),
|
|
size: MiniPlayerLayout.iconSize,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Get appropriate icon based on player name
|
|
static IconData _getPlayerIcon(String playerName) {
|
|
final nameLower = playerName.toLowerCase();
|
|
if (nameLower.contains('phone') || nameLower.contains('ensemble') || nameLower.contains('mobile')) {
|
|
return Icons.phone_android_rounded;
|
|
} else if (nameLower.contains('group') || nameLower.contains('sync') || nameLower.contains('all')) {
|
|
return Icons.speaker_group_rounded;
|
|
} else if (nameLower.contains('tv') || nameLower.contains('television')) {
|
|
return Icons.tv_rounded;
|
|
} else if (nameLower.contains('cast') || nameLower.contains('chromecast')) {
|
|
return Icons.cast_rounded;
|
|
} else {
|
|
return Icons.speaker_rounded;
|
|
}
|
|
}
|
|
}
|