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(), 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 _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, ); }, ); } }