import 'dart:async'; import 'dart:math'; import 'dart:ui'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:octo_image/octo_image.dart'; import 'package:simple_gesture_detector/simple_gesture_detector.dart'; import '../components/favourite_button.dart'; import '../services/finamp_settings_helper.dart'; import '../services/music_player_background_task.dart'; import '../models/jellyfin_models.dart'; import '../components/album_image.dart'; import '../components/PlayerScreen/song_name.dart'; import '../components/PlayerScreen/progress_slider.dart'; import '../components/PlayerScreen/player_buttons.dart'; import '../components/PlayerScreen/queue_button.dart'; import '../components/PlayerScreen/playback_mode.dart'; import '../components/PlayerScreen/add_to_playlist_button.dart'; import '../components/PlayerScreen/sleep_timer_button.dart'; final _albumImageProvider = StateProvider.autoDispose( (_) => null, ); class PlayerScreen extends StatelessWidget { const PlayerScreen({Key? key}) : super(key: key); static const routeName = "/nowplaying"; @override Widget build(BuildContext context) { final audioHandler = GetIt.instance(); return SimpleGestureDetector( onVerticalSwipe: (direction) { if (!FinampSettingsHelper.finampSettings.disableGesture && direction == SwipeDirection.down) { Navigator.of(context).pop(); } }, onHorizontalSwipe: (direction) { if (!FinampSettingsHelper.finampSettings.disableGesture) { switch (direction) { case SwipeDirection.left: audioHandler.skipToNext(); break; case SwipeDirection.right: audioHandler.skipToPrevious(); break; default: break; } } }, child: Scaffold( appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, actions: const [SleepTimerButton(), AddToPlaylistButton()], ), // Required for sleep timer input resizeToAvoidBottomInset: false, extendBodyBehindAppBar: true, body: Stack( children: [ if (FinampSettingsHelper.finampSettings.showCoverAsPlayerBackground) const _BlurredPlayerScreenBackground(), const _PlayerAmbientVisualizer(), SafeArea( child: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 720; final horizontalPadding = isWide ? 40.0 : 20.0; return Padding( padding: EdgeInsets.fromLTRB( horizontalPadding, 16, horizontalPadding, 24, ), child: isWide ? Row( children: const [ Expanded(flex: 5, child: _PlayerArtworkSurface()), SizedBox(width: 32), Expanded( flex: 4, child: _PlayerControlsSurface(), ), ], ) : const Column( children: [ Expanded(flex: 6, child: _PlayerArtworkSurface()), SizedBox(height: 18), Expanded( flex: 5, child: _PlayerControlsSurface(), ), ], ), ); }, ), ), ], ), ), ); } } 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? _subscription; bool _isPlaying = false; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1800), ); final audioHandler = GetIt.instance(); _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); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(36), boxShadow: [ BoxShadow( color: colorScheme.shadow.withOpacity(0.24), blurRadius: 36, offset: const Offset(0, 20), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(36), child: ColoredBox( color: colorScheme.surfaceVariant.withOpacity(0.34), child: const Padding( padding: EdgeInsets.all(8), child: ClipRRect( borderRadius: BorderRadius.all(Radius.circular(28)), child: _PlayerScreenAlbumImage(), ), ), ), ), ), ), ); } } class _PlayerControlsSurface extends StatelessWidget { const _PlayerControlsSurface({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final surfaceColor = Color.alphaBlend( colorScheme.primary.withOpacity(0.07), colorScheme.surface, ); return Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: DecoratedBox( decoration: BoxDecoration( color: surfaceColor, borderRadius: BorderRadius.circular(32), border: Border.all( color: colorScheme.outlineVariant.withOpacity(0.45), ), boxShadow: [ BoxShadow( color: colorScheme.shadow.withOpacity(0.12), blurRadius: 28, offset: const Offset(0, 14), ), ], ), child: const Padding( padding: EdgeInsets.fromLTRB(22, 24, 22, 18), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ SongName(), SizedBox(height: 24), ProgressSlider(), SizedBox(height: 18), PlayerButtons(), SizedBox(height: 18), _PlayerSecondaryActions(), ], ), ), ), ), ); } } class _PlayerSecondaryActions extends StatelessWidget { const _PlayerSecondaryActions({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return const Row( children: [ Expanded(child: PlaybackMode()), _PlayerScreenFavoriteButton(), SizedBox(width: 8), QueueButton(), ], ); } } /// This widget is just an AlbumImage in a StreamBuilder to get the song id. class _PlayerScreenAlbumImage extends ConsumerWidget { const _PlayerScreenAlbumImage({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { final audioHandler = GetIt.instance(); return StreamBuilder( stream: audioHandler.mediaItem, builder: (context, snapshot) { final item = snapshot.data?.extras?["itemJson"] == null ? null : BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"]); return item == null ? AspectRatio( aspectRatio: 1, child: ClipRRect( borderRadius: AlbumImage.borderRadius, child: Container(color: Theme.of(context).cardColor), ), ) : AlbumImage( item: item, imageProviderCallback: (imageProvider) => // We need a post frame callback because otherwise this // widget rebuilds on the same frame WidgetsBinding.instance.addPostFrameCallback( (_) => 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). itemsToPrecache: audioHandler.queue.value .sublist( min( (audioHandler.playbackState.value.queueIndex ?? 0) + 1, audioHandler.queue.value.length, ), ) .take(3) .map((e) => BaseItemDto.fromJson(e.extras!["itemJson"])) .toList(), ); }, ); } } /// Same as [_PlayerScreenAlbumImage], but with a BlurHash instead. We also /// filter the BlurHash so that it works as a background image. class _BlurredPlayerScreenBackground extends ConsumerWidget { const _BlurredPlayerScreenBackground({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { final imageProvider = ref.watch(_albumImageProvider); return ClipRect( child: imageProvider == null ? const SizedBox.shrink() : OctoImage( image: imageProvider, fit: BoxFit.cover, placeholderBuilder: (_) => const SizedBox.shrink(), errorBuilder: (_, __, ___) => const SizedBox.shrink(), imageBuilder: (context, child) => ColorFiltered( colorFilter: ColorFilter.mode( Theme.of(context).brightness == Brightness.dark ? Colors.black.withOpacity(0.35) : Colors.white.withOpacity(0.75), BlendMode.srcOver, ), child: ImageFiltered( imageFilter: ImageFilter.blur( sigmaX: 85, sigmaY: 85, tileMode: TileMode.mirror, ), child: SizedBox.expand(child: child), ), ), ), ); } } class _PlayerScreenFavoriteButton extends StatelessWidget { const _PlayerScreenFavoriteButton({Key? key}) : super(key: key); @override Widget build(BuildContext context) { final audioHandler = GetIt.instance(); return StreamBuilder( stream: audioHandler.mediaItem, builder: (context, snapshot) { return FavoriteButton( item: snapshot.data?.extras?["itemJson"] == null ? null : BaseItemDto.fromJson(snapshot.data!.extras!["itemJson"]), inPlayer: true, ); }, ); } }