freezer/lib/ui/player_screen.dart
Pato05 4b5d0bd09c
improve player screen with blurred album art
ui improvements in lyrics screen
animated bars when track is playing
fix back button when player screen is open
instantly pop when track is changed in queue list
2024-04-29 16:23:22 +02:00

1390 lines
47 KiB
Dart

// ignore_for_file: unused_import
import 'dart:convert';
import 'dart:ui';
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/player/audio_handler.dart';
import 'package:freezer/api/player/player_helper.dart';
import 'package:freezer/main.dart';
import 'package:freezer/page_routes/fade.dart';
import 'package:freezer/settings.dart';
import 'package:freezer/translations.i18n.dart';
import 'package:freezer/ui/cached_image.dart';
import 'package:freezer/ui/fancy_scaffold.dart';
import 'package:freezer/ui/lyrics_screen.dart';
import 'package:freezer/ui/menu.dart';
import 'package:freezer/ui/player_bar.dart';
import 'package:freezer/ui/queue_screen.dart';
import 'package:freezer/ui/settings_screen.dart';
import 'package:logging/logging.dart';
import 'package:marquee/marquee.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
const _blurStrength = 90.0;
/// A simple [ChangeNotifier] that listens to the [AudioHandler.mediaItem] stream and
/// notifies its listeners when background changes
class BackgroundProvider extends ChangeNotifier {
PaletteGenerator? _palette;
Color? _dominantColor;
ImageProvider? _imageProvider;
StreamSubscription? _mediaItemSub;
bool _isDisposed = false;
BackgroundProvider();
/// Calculate background color from [mediaItem]
///
/// Warning: this function is expensive to call, and should only be called when songs change!
Future<void> _updateColor(MediaItem mediaItem) async {
if (!settings.colorGradientBackground &&
!settings.blurPlayerBackground &&
!settings.enableFilledPlayButton &&
!settings.playerAlbumArtDropShadow) return;
final imageProvider = CachedNetworkImageProvider(
mediaItem.extras!['thumb'] ?? mediaItem.artUri.toString(),
cacheManager: cacheManager);
//Run in isolate
_palette = await PaletteGenerator.fromImageProvider(imageProvider);
_dominantColor = _palette!.dominantColor!.color;
_imageProvider = settings.blurPlayerBackground ? imageProvider : null;
if (!_isDisposed) notifyListeners();
}
@override
void addListener(VoidCallback listener) {
print('[PROVIDER] listener added $hasListeners');
_mediaItemSub ??= audioHandler.mediaItem.listen((mediaItem) {
if (mediaItem == null) return;
_updateColor(mediaItem);
});
super.addListener(listener);
}
@override
void removeListener(VoidCallback listener) {
super.removeListener(listener);
print('[PROVIDER] listener removed! hasListeners? $hasListeners');
}
@override
void dispose() {
print('[PROVIDER] DISPOSED');
_isDisposed = true;
_mediaItemSub?.cancel();
super.dispose();
}
Color? get dominantColor => _dominantColor;
PaletteGenerator? get palette => _palette;
ImageProvider<Object>? get imageProvider => _imageProvider;
}
class PlayerScreen extends StatelessWidget {
const PlayerScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
lazy: false,
create: (context) => BackgroundProvider(),
child: PlayerArtColorScheme(
child: PlayerScreenBackground(
child: MainScreen.of(context).isDesktop
? const PlayerScreenDesktop()
: OrientationBuilder(
builder: (context, orientation) =>
orientation == Orientation.landscape
? const PlayerScreenHorizontal()
: const PlayerScreenVertical())),
),
);
}
}
/// Will inject a [Theme] containing a [ColorScheme] generated
/// from [BackgroundProvider.dominantColor]
class PlayerArtColorScheme extends StatelessWidget {
final Widget child;
final bool enabled;
final bool forceDark;
const PlayerArtColorScheme({
super.key,
required this.child,
this.enabled = true,
this.forceDark = false,
});
@override
Widget build(BuildContext context) {
final backgroundProvider = context.watch<BackgroundProvider>();
if (backgroundProvider.dominantColor == null) {
// do nothing
return child;
}
final brightness =
forceDark ? Brightness.dark : Theme.of(context).colorScheme.brightness;
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.fromSeed(
seedColor: backgroundProvider.dominantColor!,
brightness: brightness,
)),
child: child);
}
}
/// Will change the background based on [BackgroundProvider],
/// it will wrap the [child] in a [Scaffold] and [SafeArea] widget
class PlayerScreenBackground extends StatelessWidget {
final Widget child;
final bool enabled;
final PreferredSizeWidget? appBar;
const PlayerScreenBackground({
super.key,
required this.child,
this.enabled = true,
this.appBar,
});
Widget _buildChild(
BuildContext context, BackgroundProvider provider, Widget child) {
final isLightMode = Theme.of(context).brightness == Brightness.light;
return Stack(children: [
if (provider.imageProvider != null || settings.colorGradientBackground)
Positioned.fill(
child: provider.imageProvider != null
? DecoratedBox(
decoration: BoxDecoration(
color: Color.lerp(provider.dominantColor,
isLightMode ? Colors.white : Colors.black, 0.75)),
child: ImageFiltered(
imageFilter: ImageFilter.blur(
tileMode: TileMode.decal,
sigmaX: _blurStrength,
sigmaY: _blurStrength,
),
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: provider.imageProvider!,
fit: BoxFit.cover,
opacity: 0.35,
)),
),
),
)
: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
if (provider.dominantColor != null)
provider.dominantColor!,
Theme.of(context).scaffoldBackgroundColor,
],
stops: const [0.0, 0.6],
)),
)),
child,
]);
}
static SystemUiOverlayStyle? getSystemUiOverlayStyle(BuildContext context,
{bool enabled = true}) {
final hasBackground = enabled &&
(settings.blurPlayerBackground || settings.colorGradientBackground);
if (!hasBackground) return null;
const color = Colors.transparent;
final brightness = hasBackground
? Brightness.light
: (ThemeData.estimateBrightnessForColor(color) == Brightness.light
? Brightness.dark
: Brightness.light);
return SystemUiOverlayStyle(
statusBarColor: color,
statusBarBrightness: brightness,
statusBarIconBrightness: brightness,
systemNavigationBarIconBrightness: brightness,
systemNavigationBarColor: color,
systemNavigationBarDividerColor: color,
);
}
@override
Widget build(BuildContext context) {
final hasBackground = enabled &&
(settings.blurPlayerBackground || settings.colorGradientBackground);
final color = hasBackground
? Colors.transparent
: Theme.of(context).colorScheme.background;
Widget widgetChild = Scaffold(
appBar: appBar,
backgroundColor: color,
body: SafeArea(child: child),
);
if (enabled) {
widgetChild = Consumer<BackgroundProvider>(
builder: (context, provider, child) {
return _buildChild(context, provider, child!);
},
child: widgetChild,
);
}
final suios = getSystemUiOverlayStyle(context, enabled: enabled);
if (appBar == null && suios != null) {
widgetChild = AnnotatedRegion<SystemUiOverlayStyle>(
value: suios,
child: widgetChild,
);
}
return widgetChild;
}
}
//Landscape
class PlayerScreenHorizontal extends StatelessWidget {
const PlayerScreenHorizontal({super.key});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
const Expanded(
flex: 4,
child: Padding(
padding: EdgeInsets.all(8.0),
child: BigAlbumArt(),
),
),
const SizedBox(width: 56.0),
//Right side
Expanded(
flex: 5,
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 8, 0),
child: PlayerScreenTopRow(
textSize: 24.sp,
iconSize: 36.sp,
textWidth: 350.w,
short: true),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: PlayerTextSubtext(textSize: 35.sp),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: SeekBar(textSize: 24.sp),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: PlaybackControls(46.sp),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: BottomBarControls(size: 30.sp),
)
],
),
)
],
);
}
}
//Portrait
class PlayerScreenVertical extends StatelessWidget {
const PlayerScreenVertical({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: PlayerScreenTopRow(
textSize: 14.spMax,
iconSize: 20.spMax,
),
),
const Flexible(child: BigAlbumArt()),
Padding(
padding: const EdgeInsets.only(left: 24.0, right: 16.0),
child: PlayerTextSubtext(textSize: 24.spMax),
),
SeekBar(textSize: 14.spMax),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: PlaybackControls(32.spMax),
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0),
child: BottomBarControls(size: 22.spMax),
)
],
));
}
}
class PlayerScreenDesktop extends StatelessWidget {
const PlayerScreenDesktop({super.key});
@override
Widget build(BuildContext context) {
return Row(children: [
AspectRatio(
aspectRatio: 9 / 16,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: PlayerScreenTopRow(
textSize: 12.h, iconSize: 21.h, desktopMode: true),
),
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints.loose(const Size.square(500)),
child: const BigAlbumArt(showLyricsButton: false)),
),
Padding(
padding: const EdgeInsets.only(left: 24.0, right: 16.0),
child: PlayerTextSubtext(textSize: 22.h),
),
SeekBar(textSize: 16.h),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: PlaybackControls(28.h),
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0),
child: BottomBarControls(
size: 20.h,
desktopMode: true,
),
)
]),
),
),
const Expanded(
flex: 2,
child: Padding(
padding: EdgeInsets.only(left: 16.0, right: 16.0, top: 16.0),
child: _DesktopTabView(),
)),
]);
}
}
class _DesktopTabView extends StatelessWidget {
const _DesktopTabView({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Column(children: [
TabBar(
tabs: [
Tab(
text: 'Queue'.i18n,
height: 48.0,
),
Tab(
text: 'Lyrics'.i18n,
),
],
labelStyle: Theme.of(context)
.textTheme
.labelLarge!
.copyWith(fontSize: 18.0)),
Expanded(
child: SizedBox.expand(
child: Material(
type: MaterialType.transparency,
child: TabBarView(children: [
QueueListWidget(
closePlayer: FancyScaffold.of(context)!.closePanel,
isInsidePlayer: true,
),
const LyricsWidget(),
]),
),
)),
]),
);
}
}
class FitOrScrollText extends StatefulWidget {
final String text;
final TextStyle style;
final TextAlign? textAlign;
final TextDirection? textDirection;
final int? maxLines;
const FitOrScrollText({
required this.text,
required this.style,
this.textAlign,
this.textDirection,
this.maxLines,
super.key,
});
@override
State<FitOrScrollText> createState() => _FitOrScrollTextState();
}
class _FitOrScrollTextState extends State<FitOrScrollText> {
bool _checkTextFits(String text, BoxConstraints constraints) {
final textPainter = TextPainter(
text: TextSpan(text: text, style: widget.style),
textAlign: widget.textAlign ?? TextAlign.left,
textDirection: widget.textDirection ?? TextDirection.ltr,
maxLines: widget.maxLines,
);
textPainter.layout(maxWidth: constraints.maxWidth);
return !(textPainter.didExceedMaxLines ||
textPainter.height > constraints.maxHeight ||
textPainter.width > constraints.maxWidth);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
return _checkTextFits(widget.text, constraints)
? Text(
widget.text,
maxLines: widget.maxLines,
style: widget.style,
)
: Marquee(
text: widget.text,
style: widget.style,
blankSpace: 32.0,
startPadding: 0.0,
numberOfRounds: 2,
accelerationDuration: const Duration(seconds: 1),
pauseAfterRound: const Duration(seconds: 2),
crossAxisAlignment: CrossAxisAlignment.end,
fadingEdgeEndFraction: 0.05,
fadingEdgeStartFraction: 0.05,
);
});
}
}
class PlayerTextSubtext extends StatelessWidget {
final double textSize;
const PlayerTextSubtext({super.key, required this.textSize});
@override
Widget build(BuildContext context) {
return StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
final currentMediaItem = snapshot.data!;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SizedBox(
height: 1.5 * textSize,
child: FitOrScrollText(
key: Key(currentMediaItem.displayTitle!),
text: currentMediaItem.displayTitle!,
maxLines: 1,
style: TextStyle(
fontSize: textSize,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis)),
),
// child: currentMediaItem.displayTitle!.length >= 26
// ? Marquee(
// key: Key(currentMediaItem.displayTitle!),
// text: currentMediaItem.displayTitle!,
// style: TextStyle(
// fontSize: textSize, fontWeight: FontWeight.bold),
// blankSpace: 32.0,
// startPadding: 0.0,
// accelerationDuration: const Duration(seconds: 1),
// pauseAfterRound: const Duration(seconds: 2),
// crossAxisAlignment: CrossAxisAlignment.start,
// fadingEdgeEndFraction: 0.05,
// fadingEdgeStartFraction: 0.05,
// )
// : Text(
// currentMediaItem.displayTitle!,
// maxLines: 1,
// overflow: TextOverflow.ellipsis,
// textAlign: TextAlign.start,
// style: TextStyle(
// fontSize: textSize, fontWeight: FontWeight.bold),
// )),
Text(
currentMediaItem.displaySubtitle ?? '',
maxLines: 1,
textAlign: TextAlign.start,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: textSize * 0.8, // 20% smaller
color: Theme.of(context).colorScheme.primary,
),
),
]),
),
const SizedBox(width: 8.0),
FavoriteButton(size: textSize),
],
);
});
}
}
class QualityInfoWidget extends StatelessWidget {
final double? textSize;
const QualityInfoWidget({Key? key, this.textSize}) : super(key: key);
String _getQualityStringFromInfo(StreamQualityInfo info) {
if (audioHandler.mediaItem.value == null) return '';
int bitrate = info.quality == AudioQuality.MP3_128
? 128
: info.quality == AudioQuality.MP3_320
? 320
: info.calculateBitrate(audioHandler.mediaItem.value!.duration!);
return '${info.format.name} ${bitrate}kbps';
}
@override
Widget build(BuildContext context) {
return TextButton(
// style: ButtonStyle(
// elevation: MaterialStatePropertyAll(0.5),
// padding: MaterialStatePropertyAll(
// EdgeInsets.symmetric(horizontal: 16, vertical: 4)),
// foregroundColor: MaterialStatePropertyAll(
// Theme.of(context).colorScheme.onSurface)),
child: StreamBuilder<String>(
stream:
playerHelper.streamInfo.map<String>(_getQualityStringFromInfo),
builder: (context, snapshot) => Text(snapshot.data ?? '',
style: textSize == null
? null
: TextStyle(fontSize: textSize! * 0.9))),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const QualitySettings())),
);
}
}
void Function([TapUpDetails?]) _onMenuPressedCallback(BuildContext context) {
return ([details]) {
final currentMediaItem = audioHandler.mediaItem.value!;
Track t = Track.fromMediaItem(currentMediaItem);
MenuSheet m = MenuSheet(context, navigateCallback: () {
// close player
FancyScaffold.of(context)?.closePanel();
});
if (currentMediaItem.extras!['show'] == null) {
m.defaultTrackMenu(t,
options: [m.sleepTimer(), m.wakelock()], details: details);
} else {
m.defaultShowEpisodeMenu(Show.fromJson(currentMediaItem.extras!['show']),
ShowEpisode.fromMediaItem(currentMediaItem),
options: [m.sleepTimer(), m.wakelock()], details: details);
}
};
}
class PlayerMenuButtonDesktop extends StatelessWidget {
final double size;
const PlayerMenuButtonDesktop({super.key, required this.size});
@override
Widget build(BuildContext context) {
return InkWell(
customBorder: const CircleBorder(),
onTapUp: _onMenuPressedCallback(context),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
Icons.more_vert,
semanticLabel: "Options".i18n,
size: size,
),
),
);
}
}
class PlayerMenuButton extends StatelessWidget {
final double size;
const PlayerMenuButton({super.key, required this.size});
@override
Widget build(BuildContext context) {
return IconButton(
iconSize: size,
icon: Icon(
Icons.more_vert,
semanticLabel: "Options".i18n,
),
onPressed: _onMenuPressedCallback(context));
}
}
class RepeatButton extends StatefulWidget {
final double iconSize;
const RepeatButton(this.iconSize, {Key? key}) : super(key: key);
@override
State<RepeatButton> createState() => _RepeatButtonState();
}
class _RepeatButtonState extends State<RepeatButton> {
// ignore: missing_return
Icon get repeatIcon {
switch (playerHelper.repeatType) {
case AudioServiceRepeatMode.none:
return Icon(
Icons.repeat,
semanticLabel: "Repeat off".i18n,
);
case AudioServiceRepeatMode.one:
return Icon(
Icons.repeat_one,
semanticLabel: "Repeat one".i18n,
);
case AudioServiceRepeatMode.group:
case AudioServiceRepeatMode.all:
return Icon(
Icons.repeat,
semanticLabel: "Repeat".i18n,
);
}
}
@override
Widget build(BuildContext context) {
return IconButton(
color: playerHelper.repeatType == AudioServiceRepeatMode.none
? null
: Theme.of(context).colorScheme.primary,
iconSize: widget.iconSize,
icon: repeatIcon,
onPressed: () async {
await playerHelper.changeRepeat();
setState(() {});
},
);
}
}
class ShuffleButton extends StatefulWidget {
final double iconSize;
const ShuffleButton({Key? key, required this.iconSize}) : super(key: key);
@override
State<ShuffleButton> createState() => _ShuffleButtonState();
}
class _ShuffleButtonState extends State<ShuffleButton> {
@override
Widget build(BuildContext context) => IconButton(
icon: const Icon(Icons.shuffle),
iconSize: widget.iconSize,
color: playerHelper.shuffleEnabled
? Theme.of(context).colorScheme.primary
: null,
onPressed: _toggleShuffle,
);
void _toggleShuffle() {
playerHelper.toggleShuffle().then((_) => setState(() {}));
}
}
class FavoriteButton extends StatefulWidget {
final double size;
const FavoriteButton({Key? key, required this.size}) : super(key: key);
@override
State<FavoriteButton> createState() => _FavoriteButtonState();
}
class _FavoriteButtonState extends State<FavoriteButton> {
Icon get libraryIcon {
if (cache.checkTrackFavorite(
Track.fromMediaItem(audioHandler.mediaItem.value!))) {
return Icon(
Icons.favorite,
semanticLabel: "Unlove".i18n,
);
}
return Icon(
Icons.favorite_border,
semanticLabel: "Love".i18n,
);
}
@override
Widget build(BuildContext context) => StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return IconButton(
onPressed: () {}, icon: const Icon(Icons.favorite_border));
}
final mediaItem = snapshot.data!;
return IconButton(
icon: libraryIcon,
iconSize: widget.size,
onPressed: () async {
if (cache.checkTrackFavorite(Track.fromMediaItem(mediaItem))) {
//Remove from library
setState(() => cache.libraryTracks.remove(mediaItem.id));
await DeezerAPI.instance.removeFavorite(mediaItem.id);
await cache.save();
} else {
//Add
setState(() => cache.libraryTracks.add(mediaItem.id));
await DeezerAPI.instance.addFavoriteTrack(mediaItem.id);
await cache.save();
}
},
);
});
}
class ForwardReplay30Button extends StatelessWidget {
final bool forward;
const ForwardReplay30Button({super.key, required this.forward});
void _seek(Duration position) {
// validate position
if (position.isNegative) return;
if (position > audioHandler.mediaItem.value!.duration!) return;
audioHandler.seek(position);
}
@override
Widget build(BuildContext context) {
if (forward) {
return IconButton(
onPressed: () => _seek(audioHandler.playbackState.value.position +
const Duration(seconds: 30)),
icon: const Icon(Icons.forward_30));
}
return IconButton(
onPressed: () => _seek(audioHandler.playbackState.value.position -
const Duration(seconds: 30)),
icon: const Icon(Icons.replay_30));
}
}
class PlaybackControls extends StatelessWidget {
final double size;
const PlaybackControls(this.size, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: StreamBuilder<QueueSource>(
stream: playerHelper.queueSource,
builder: (context, snapshot) {
final queueSource = snapshot.data;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
queueSource?.source != 'show'
? ShuffleButton(iconSize: size * 0.75)
: const ForwardReplay30Button(forward: false),
PrevNextButton(size, prev: true),
if (settings.enableFilledPlayButton)
Consumer<BackgroundProvider>(builder: (context, provider, _) {
final color = provider.dominantColor == null
? Colors.transparent
: Theme.of(context).brightness == Brightness.light
? provider.dominantColor!
: darken(provider.dominantColor!);
return PlayPauseButton(size * 2.25,
filled: true,
material3: settings.enableMaterial3PlayButton,
color: color,
iconColor: Color.lerp(
(ThemeData.estimateBrightnessForColor(color) ==
Brightness.light
? Colors.black
: Colors.white),
color,
0.25));
})
else
PlayPauseButton(size * 1.25),
PrevNextButton(size),
queueSource?.source != 'show'
? RepeatButton(size * 0.75)
: const ForwardReplay30Button(forward: true),
],
);
}),
);
}
}
class BigAlbumArt extends StatefulWidget {
final bool showLyricsButton;
const BigAlbumArt({super.key, this.showLyricsButton = true});
@override
State<BigAlbumArt> createState() => _BigAlbumArtState();
}
class _BigAlbumArtState extends State<BigAlbumArt> with WidgetsBindingObserver {
final _pageController = PageController(
initialPage: playerHelper.queueIndex,
keepPage: false,
viewportFraction: 1.0,
);
StreamSubscription? _currentItemSub;
/// is true on pointer down event
/// used to distinguish between [PageController.animateToPage] and user gesture
bool _userScroll = true;
/// whether the user has already scrolled the [PageView],
/// so to avoid calling [PageController.animateToPage] again.
bool _initiatedByUser = false;
void _listenForMediaItemChanges() {
if (_currentItemSub != null) return;
if (audioHandler.mediaItem.hasValue && _pageController.hasClients) {
_pageController.jumpToPage(playerHelper.queueIndex);
}
_currentItemSub = audioHandler.mediaItem.listen((event) async {
if (_initiatedByUser) {
_initiatedByUser = false;
return;
}
if (!_pageController.hasClients) return;
if (_pageController.page?.toInt() == playerHelper.queueIndex) return;
print('animating controller to page');
_userScroll = false;
await _pageController.animateToPage(playerHelper.queueIndex,
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
_userScroll = true;
});
}
@override
void initState() {
_listenForMediaItemChanges();
super.initState();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.paused:
_currentItemSub?.cancel();
case AppLifecycleState.resumed:
_listenForMediaItemChanges();
default:
break;
}
super.didChangeAppLifecycleState(state);
}
@override
void dispose() {
_currentItemSub?.cancel();
super.dispose();
}
void _pushLyrics() {
// enable wakelock if not already enabled
// ideally we would use WakelockPlus.enabled
final wakelockChanged = !cache.wakelock;
if (wakelockChanged) {
WakelockPlus.enable();
}
builder(ctx) => ChangeNotifierProvider<BackgroundProvider>.value(
value: Provider.of<BackgroundProvider>(context),
child: const LyricsScreen());
final pushed = Navigator.of(context).pushRoute(builder: builder);
if (wakelockChanged) {
pushed.then((_) => WakelockPlus.disable());
}
}
@override
Widget build(BuildContext context) {
final child = GestureDetector(
onTap: () => Navigator.push(
context,
FadePageRoute(
barrierDismissible: true,
opaque: false,
builder: (context) {
final mediaItem = audioHandler.mediaItem.value!;
return ZoomableImageRoute(
imageUrl: mediaItem.artUri.toString(), heroKey: mediaItem.id);
},
)),
child: LayoutBuilder(
builder: (context, constraints) => Stack(
children: [
StreamBuilder<List<MediaItem>>(
stream: audioHandler.queue,
initialData: audioHandler.queue.valueOrNull,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator());
}
final queue = snapshot.data!;
return PageView.builder(
controller: _pageController,
onPageChanged: (int index) {
// ignore if not initiated by user.
if (!_userScroll) return;
Logger('BigAlbumArt')
.fine('page changed, skipping to media item');
if (queue[index].id ==
audioHandler.mediaItem.value?.id) {
return;
}
audioHandler.skipToQueueItem(index);
},
itemCount: queue.length,
itemBuilder: (context, i) => Padding(
padding: const EdgeInsets.all(8.0),
child: Hero(
tag: queue[i].id,
child: ClipRRect(
borderRadius: BorderRadius.circular(8.0),
child: CachedImage(
url: queue[i].artUri.toString(),
fullThumb: true,
),
),
),
));
}),
if (widget.showLyricsButton)
StreamBuilder<MediaItem?>(
initialData: audioHandler.mediaItem.valueOrNull,
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (snapshot.data == null) {
return const SizedBox.shrink();
}
final l = snapshot.data!.extras?['lyrics'] == null
? null
: Lyrics.fromJson(
jsonDecode(snapshot.data!.extras!['lyrics']));
if (l == null || l.id == null || l.id == '0') {
return const SizedBox.shrink();
}
return Positioned(
key: const ValueKey('lyrics_button'),
bottom: 16.0,
right: 16.0,
child: LyricsButton(
onTap: _pushLyrics,
size: 20.spMax,
),
);
},
),
],
)),
);
return AspectRatio(
aspectRatio: 1.0,
child: Center(
child: settings.playerAlbumArtDropShadow
? Consumer<BackgroundProvider>(
builder: (context, background, child) => AnimatedContainer(
duration: const Duration(seconds: 1),
decoration: BoxDecoration(boxShadow: [
BoxShadow(
color:
(background.dominantColor ?? Colors.transparent)
.withOpacity(0.5),
spreadRadius: 20.0,
blurRadius: 150.0)
]),
child: child),
child: child,
)
: child,
),
);
}
}
class LyricsButton extends StatelessWidget {
final VoidCallback onTap;
final double size;
const LyricsButton({super.key, required this.onTap, this.size = 24.0});
@override
Widget build(BuildContext context) {
return Consumer<BackgroundProvider>(
builder: (context, provider, child) => Material(
color: Color.lerp(Theme.of(context).colorScheme.background,
provider.dominantColor, 0.25),
borderRadius: BorderRadius.circular(size * 2 / 3),
clipBehavior: Clip.antiAlias,
child: child),
child: InkWell(
onTap: onTap,
child: Padding(
padding:
EdgeInsets.symmetric(horizontal: size / 1.25, vertical: size / 6),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.subtitles, size: size),
SizedBox(width: size / 3),
Text(
'Lyrics'.i18n,
style: TextStyle(fontSize: size * 0.8),
),
]),
),
),
);
}
}
//Top row containing QueueSource, queue...
class PlayerScreenTopRow extends StatelessWidget {
final double? textSize;
final double? iconSize;
final double? textWidth;
final bool short;
final bool desktopMode;
const PlayerScreenTopRow({
super.key,
this.textSize,
this.iconSize,
this.textWidth,
this.short = false,
this.desktopMode = false,
});
@override
Widget build(BuildContext context) {
final size = iconSize ?? 52.sp;
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
IconButton(
onPressed: FancyScaffold.of(context)!.closePanel,
icon: Icon(
Icons.keyboard_arrow_down,
semanticLabel: 'Close'.i18n,
),
iconSize: size,
splashRadius: size * 1.5,
),
Expanded(
child: StreamBuilder<QueueSource>(
stream: playerHelper.queueSource,
builder: (context, snapshot) {
final queueSource = snapshot.data;
if (queueSource == null) {
return const SizedBox.shrink();
}
return RichText(
textAlign: TextAlign.center,
maxLines: 2,
text: TextSpan(
children: [
if (!short)
TextSpan(
text: '${'PLAYING FROM'.i18n}\n',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
fontSize: (textSize ?? 38.sp) * 0.85)),
TextSpan(text: queueSource.text ?? '')
],
style: Theme.of(context)
.textTheme
.bodySmall!
.copyWith(fontSize: textSize ?? 38.sp)));
}),
),
desktopMode
? PlayerMenuButtonDesktop(size: size)
: PlayerMenuButton(size: size)
],
);
}
}
class SeekBar extends StatefulWidget {
final double textSize;
const SeekBar({Key? key, this.textSize = 16.0}) : super(key: key);
@override
State<SeekBar> createState() => _SeekBarState();
}
class _SeekBarState extends State<SeekBar> {
bool _seeking = false;
late StreamSubscription _subscription;
final position = ValueNotifier<Duration>(Duration.zero);
@override
void initState() {
_subscription = AudioService.position.listen((position) {
if (_seeking) return; // user is seeking
this.position.value = position;
});
super.initState();
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
double parseDuration(Duration position) {
if (position > duration) return duration.inMilliseconds.toDouble();
return position.inMilliseconds.toDouble();
}
//Duration to mm:ss
String _timeString(Duration d) {
return "${d.inMinutes}:${d.inSeconds.remainder(60).toString().padLeft(2, '0')}";
}
Duration get duration {
if (audioHandler.mediaItem.value == null) return Duration.zero;
return audioHandler.mediaItem.value!.duration!;
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ValueListenableBuilder<Duration>(
valueListenable: position,
builder: (context, value, _) => StreamBuilder<Duration>(
stream: playerHelper.bufferPosition,
builder: (context, snapshot) {
return Slider(
secondaryTrackValue:
parseDuration(snapshot.data ?? Duration.zero),
focusNode: FocusNode(
canRequestFocus: false,
skipTraversal:
true), // Don't focus on Slider - it doesn't work (and not needed)
value: parseDuration(value),
max: duration.inMilliseconds.toDouble(),
onChangeStart: (double d) {
_seeking = true;
position.value = Duration(milliseconds: d.toInt());
},
onChanged: (double d) {
position.value = Duration(milliseconds: d.toInt());
},
onChangeEnd: (double d) {
_seeking = false;
audioHandler.seek(Duration(milliseconds: d.toInt()));
},
);
})),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
ValueListenableBuilder<Duration>(
valueListenable: position,
builder: (context, value, _) => Text(
_timeString(value),
style: TextStyle(
fontSize: widget.textSize,
color: Theme.of(context)
.textTheme
.bodyMedium!
.color!
.withOpacity(.75)),
)),
StreamBuilder<MediaItem?>(
stream: audioHandler.mediaItem,
builder: (context, snapshot) => Text(
_timeString(snapshot.data?.duration ?? Duration.zero),
style: TextStyle(
fontSize: widget.textSize,
color: Theme.of(context)
.textTheme
.bodyMedium!
.color!
.withOpacity(.75)),
)),
],
),
),
],
);
}
}
class BottomBarControls extends StatelessWidget {
final double size;
final bool
desktopMode; // removed in desktop mode, because there's a tabbed view which includes it
const BottomBarControls({
super.key,
required this.size,
this.desktopMode = false,
});
@override
Widget build(BuildContext context) {
final iconSize = size * 0.9;
if (playerHelper.queueSource.valueOrNull?.source == 'show') {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
QualityInfoWidget(
textSize: size * 0.75,
),
const Expanded(child: SizedBox()),
if (!desktopMode)
IconButton(
icon: Icon(
Icons.playlist_play,
semanticLabel: "Queue".i18n,
),
iconSize: size,
splashRadius: size * 1.5,
onPressed: () => Navigator.of(context).pushRoute(
builder: (ctx) => QueueScreen(
closePlayer: FancyScaffold.of(context)!.closePanel,
)),
),
],
);
}
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
QualityInfoWidget(textSize: size * 0.75),
const Expanded(child: SizedBox()),
IconButton(
icon: Icon(
Icons.sentiment_very_dissatisfied,
semanticLabel: "Dislike".i18n,
),
iconSize: iconSize,
onPressed: () async {
unawaited(DeezerAPI.instance
.dislikeTrack(audioHandler.mediaItem.value!.id));
if (playerHelper.queueIndex <
audioHandler.queue.value.length - 1) {
audioHandler.skipToNext();
}
}),
// IconButton(
// iconSize: size,
// icon: Icon(
// Icons.file_download,
// semanticLabel: "Download".i18n,
// ),
// onPressed: () async {
// Track t = Track.fromMediaItem(audioHandler.mediaItem.value!);
// if (await downloadManager.addOfflineTrack(t,
// private: false, context: context, isSingleton: true) !=
// false)
// Fluttertoast.showToast(
// msg: 'Downloads added!'.i18n,
// gravity: ToastGravity.BOTTOM,
// toastLength: Toast.LENGTH_SHORT);
// },
// ),
if (!desktopMode)
IconButton(
icon: Icon(
Icons.playlist_play,
semanticLabel: "Queue".i18n,
),
iconSize: size,
splashRadius: size * 1.5,
onPressed: () => Navigator.of(context).pushRoute(
builder: (ctx) => QueueScreen(
closePlayer: FancyScaffold.of(context)!.closePanel,
)),
),
],
);
}
}