redesign app phase 3
This commit is contained in:
+208
-147
@@ -265,91 +265,87 @@ class _MusicScreenState extends State<MusicScreen>
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: getFloatingActionButton(),
|
||||
),
|
||||
body: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withOpacity(0.45),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
Theme.of(context).colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
if (!isSearching)
|
||||
_MusicHeroHeader(
|
||||
title: _finampUserHelper
|
||||
.currentUser?.currentView?.name ??
|
||||
AppLocalizations.of(context)!.music,
|
||||
activeTab: tabs
|
||||
.elementAt(_tabController!.index)
|
||||
.toLocalisedString(context),
|
||||
isFavourite: finampSettings.isFavourite,
|
||||
isOffline: finampSettings.isOffline,
|
||||
onShuffle: () async {
|
||||
try {
|
||||
await _audioServiceHelper.shuffleAll(
|
||||
FinampSettingsHelper
|
||||
.finampSettings.isFavourite,
|
||||
);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
},
|
||||
body: Stack(
|
||||
children: [
|
||||
const _MusicBackdrop(),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
if (!isSearching)
|
||||
_MusicHeroHeader(
|
||||
title: _finampUserHelper
|
||||
.currentUser?.currentView?.name ??
|
||||
AppLocalizations.of(context)!.music,
|
||||
activeTab: tabs
|
||||
.elementAt(_tabController!.index)
|
||||
.toLocalisedString(context),
|
||||
isFavourite: finampSettings.isFavourite,
|
||||
isOffline: finampSettings.isOffline,
|
||||
onShuffle: () async {
|
||||
try {
|
||||
await _audioServiceHelper.shuffleAll(
|
||||
FinampSettingsHelper
|
||||
.finampSettings.isFavourite,
|
||||
);
|
||||
} catch (e) {
|
||||
errorSnackbar(e, context);
|
||||
}
|
||||
},
|
||||
),
|
||||
_ModernMusicTabBar(
|
||||
controller: _tabController!,
|
||||
tabs: tabs.toList(),
|
||||
),
|
||||
_ModernMusicTabBar(
|
||||
controller: _tabController!,
|
||||
tabs: tabs.toList(),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(28),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.shadow
|
||||
.withOpacity(0.08),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, -6),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 0),
|
||||
child: Material(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withOpacity(0.74),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(34),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outlineVariant
|
||||
.withOpacity(0.42),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: tabs
|
||||
.map((tabType) => MusicScreenTabView(
|
||||
tabContentType: tabType,
|
||||
searchTerm: searchQuery,
|
||||
isFavourite: finampSettings.isFavourite,
|
||||
sortBy: finampSettings
|
||||
.getTabSortBy(tabType),
|
||||
sortOrder: finampSettings
|
||||
.getSortOrder(tabType),
|
||||
view: _finampUserHelper
|
||||
.currentUser?.currentView,
|
||||
))
|
||||
.toList(),
|
||||
elevation: 16,
|
||||
shadowColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.shadow
|
||||
.withOpacity(0.24),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: tabs
|
||||
.map((tabType) => MusicScreenTabView(
|
||||
tabContentType: tabType,
|
||||
searchTerm: searchQuery,
|
||||
isFavourite:
|
||||
finampSettings.isFavourite,
|
||||
sortBy: finampSettings
|
||||
.getTabSortBy(tabType),
|
||||
sortOrder: finampSettings
|
||||
.getSortOrder(tabType),
|
||||
view: _finampUserHelper
|
||||
.currentUser?.currentView,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -360,6 +356,78 @@ class _MusicScreenState extends State<MusicScreen>
|
||||
}
|
||||
}
|
||||
|
||||
class _MusicBackdrop extends StatelessWidget {
|
||||
const _MusicBackdrop();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
const Color(0xFF081D38),
|
||||
colorScheme.background,
|
||||
const Color(0xFF020611),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: -110,
|
||||
right: -80,
|
||||
child: _GlowOrb(
|
||||
color: colorScheme.primary.withOpacity(0.28),
|
||||
size: 280,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 90,
|
||||
left: -120,
|
||||
child: _GlowOrb(
|
||||
color: colorScheme.tertiary.withOpacity(0.20),
|
||||
size: 260,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -140,
|
||||
right: -130,
|
||||
child: _GlowOrb(
|
||||
color: colorScheme.secondary.withOpacity(0.16),
|
||||
size: 320,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlowOrb extends StatelessWidget {
|
||||
const _GlowOrb({required this.color, required this.size});
|
||||
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [color, Colors.transparent],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MusicHeroHeader extends StatelessWidget {
|
||||
const _MusicHeroHeader({
|
||||
required this.title,
|
||||
@@ -381,73 +449,64 @@ class _MusicHeroHeader extends StatelessWidget {
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 86, 20, 12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.primary.withOpacity(0.95),
|
||||
colorScheme.tertiary.withOpacity(0.78),
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withOpacity(0.28),
|
||||
blurRadius: 34,
|
||||
offset: const Offset(0, 18),
|
||||
padding: const EdgeInsets.fromLTRB(24, 88, 24, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isOffline ? 'OFFLINE MODE' : 'FINAMP MUSIC',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: 2.2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
color: colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 0.98,
|
||||
letterSpacing: -1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Browse your Jellyfin collection with artwork-first shelves.',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
isOffline ? 'Offline library' : 'Your Jellyfin music',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onPrimary.withOpacity(0.76),
|
||||
letterSpacing: 0.6,
|
||||
FilledButton.icon(
|
||||
onPressed: onShuffle,
|
||||
icon: const Icon(Icons.shuffle_rounded),
|
||||
label: const Text('Shuffle'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: colorScheme.onPrimary,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.04,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: onShuffle,
|
||||
icon: const Icon(Icons.shuffle_rounded),
|
||||
label: const Text('Shuffle'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.onPrimary,
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_HeroChip(
|
||||
icon: isFavourite
|
||||
? Icons.favorite_rounded
|
||||
: Icons.library_music_rounded,
|
||||
label: isFavourite ? 'Favourites' : activeTab,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 12),
|
||||
_HeroChip(
|
||||
icon:
|
||||
isFavourite ? Icons.favorite_rounded : Icons.album_rounded,
|
||||
label: isFavourite ? 'Favourites' : activeTab,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -466,19 +525,19 @@ class _HeroChip extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onPrimary.withOpacity(0.14),
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.58),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: colorScheme.onPrimary.withOpacity(0.16)),
|
||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.55)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: colorScheme.onPrimary, size: 18),
|
||||
Icon(icon, color: colorScheme.secondary, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
@@ -502,10 +561,10 @@ class _ModernMusicTabBar extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withOpacity(0.7),
|
||||
color: colorScheme.surfaceVariant.withOpacity(0.50),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border:
|
||||
Border.all(color: colorScheme.outlineVariant.withOpacity(0.4)),
|
||||
Border.all(color: colorScheme.outlineVariant.withOpacity(0.55)),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: controller,
|
||||
@@ -518,7 +577,9 @@ class _ModernMusicTabBar extends StatelessWidget {
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicator: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: colorScheme.primary,
|
||||
gradient: LinearGradient(
|
||||
colors: [colorScheme.primary, colorScheme.secondary],
|
||||
),
|
||||
),
|
||||
labelColor: colorScheme.onPrimary,
|
||||
unselectedLabelColor: colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
@@ -68,6 +69,7 @@ class PlayerScreen extends StatelessWidget {
|
||||
children: [
|
||||
if (FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground)
|
||||
const _BlurredPlayerScreenBackground(),
|
||||
const _PlayerAmbientVisualizer(),
|
||||
SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -113,6 +115,158 @@ class PlayerScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerAmbientVisualizer extends StatefulWidget {
|
||||
const _PlayerAmbientVisualizer({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_PlayerAmbientVisualizer> createState() =>
|
||||
_PlayerAmbientVisualizerState();
|
||||
}
|
||||
|
||||
class _PlayerAmbientVisualizerState extends State<_PlayerAmbientVisualizer>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
StreamSubscription<PlaybackState>? _subscription;
|
||||
bool _isPlaying = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1800),
|
||||
);
|
||||
|
||||
final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();
|
||||
_subscription = audioHandler.playbackState.listen((playbackState) {
|
||||
if (_isPlaying == playbackState.playing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isPlaying = playbackState.playing;
|
||||
});
|
||||
|
||||
if (_isPlaying) {
|
||||
_controller.repeat();
|
||||
} else {
|
||||
_controller.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _isPlaying ? 1 : 0.18,
|
||||
duration: const Duration(milliseconds: 450),
|
||||
child: RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return CustomPaint(
|
||||
painter: _AmbientVisualizerPainter(
|
||||
progress: _controller.value,
|
||||
primary: colorScheme.primary,
|
||||
secondary: colorScheme.secondary,
|
||||
tertiary: colorScheme.tertiary,
|
||||
),
|
||||
size: Size.infinite,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmbientVisualizerPainter extends CustomPainter {
|
||||
const _AmbientVisualizerPainter({
|
||||
required this.progress,
|
||||
required this.primary,
|
||||
required this.secondary,
|
||||
required this.tertiary,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final Color primary;
|
||||
final Color secondary;
|
||||
final Color tertiary;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width * 0.5, size.height * 0.42);
|
||||
final pulse = 0.5 + sin(progress * pi * 2) * 0.5;
|
||||
|
||||
_paintOrb(
|
||||
canvas,
|
||||
center.translate(size.width * -0.22, size.height * -0.08),
|
||||
size.shortestSide * (0.34 + pulse * 0.05),
|
||||
primary.withOpacity(0.16),
|
||||
);
|
||||
_paintOrb(
|
||||
canvas,
|
||||
center.translate(size.width * 0.23, size.height * 0.02),
|
||||
size.shortestSide * (0.30 + (1 - pulse) * 0.07),
|
||||
tertiary.withOpacity(0.14),
|
||||
);
|
||||
|
||||
final barPaint = Paint()..style = PaintingStyle.fill;
|
||||
final barCount = max(16, (size.width / 28).floor());
|
||||
final maxBarHeight = size.height * 0.14;
|
||||
final baseline = size.height * 0.86;
|
||||
final totalWidth = min(size.width * 0.72, 620.0);
|
||||
final gap = totalWidth / barCount;
|
||||
final startX = (size.width - totalWidth) / 2;
|
||||
|
||||
for (var i = 0; i < barCount; i++) {
|
||||
final wave = sin(progress * pi * 2 + i * 0.62);
|
||||
final alternateWave = sin(progress * pi * 4 + i * 0.31);
|
||||
final normalized = (wave + alternateWave * 0.35 + 1.35) / 2.7;
|
||||
final barHeight = 12 + normalized * maxBarHeight;
|
||||
final x = startX + i * gap + gap * 0.22;
|
||||
final width = gap * 0.48;
|
||||
final rect = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(x, baseline - barHeight, width, barHeight),
|
||||
Radius.circular(width),
|
||||
);
|
||||
|
||||
barPaint.color = Color.lerp(primary, secondary, i / barCount)!
|
||||
.withOpacity(0.16 + normalized * 0.26);
|
||||
canvas.drawRRect(rect, barPaint);
|
||||
}
|
||||
}
|
||||
|
||||
void _paintOrb(Canvas canvas, Offset center, double radius, Color color) {
|
||||
final paint = Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [color, Colors.transparent],
|
||||
).createShader(Rect.fromCircle(center: center, radius: radius));
|
||||
|
||||
canvas.drawCircle(center, radius, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _AmbientVisualizerPainter oldDelegate) {
|
||||
return progress != oldDelegate.progress ||
|
||||
primary != oldDelegate.primary ||
|
||||
secondary != oldDelegate.secondary ||
|
||||
tertiary != oldDelegate.tertiary;
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerArtworkSurface extends StatelessWidget {
|
||||
const _PlayerArtworkSurface({Key? key}) : super(key: key);
|
||||
|
||||
@@ -250,9 +404,9 @@ class _PlayerScreenAlbumImage extends ConsumerWidget {
|
||||
// We need a post frame callback because otherwise this
|
||||
// widget rebuilds on the same frame
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => ref.read(_albumImageProvider.notifier).state =
|
||||
imageProvider,
|
||||
),
|
||||
(_) => ref.read(_albumImageProvider.notifier).state =
|
||||
imageProvider,
|
||||
),
|
||||
// Here we awkwardly get the next 3 queue items so that we
|
||||
// can precache them (so that the image is already loaded
|
||||
// when the next song comes on).
|
||||
|
||||
Reference in New Issue
Block a user