freezer/lib/ui/player_screen.dart

1343 lines
46 KiB
Dart
Raw Normal View History

// ignore_for_file: unused_import
import 'dart:convert';
2023-07-29 02:17:26 +00:00
import 'dart:ui';
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
2023-07-29 02:17:26 +00:00
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';
2023-07-29 02:17:26 +00:00
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';
2023-07-29 02:17:26 +00:00
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';
2023-07-29 02:17:26 +00:00
import 'package:marquee/marquee.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:photo_view/photo_view.dart';
import 'package:provider/provider.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;
2023-07-29 02:17:26 +00:00
ImageProvider? _imageProvider;
StreamSubscription? _mediaItemSub;
bool _isDisposed = false;
BackgroundProvider();
2023-07-29 02:17:26 +00:00
/// 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;
2023-07-29 02:17:26 +00:00
final imageProvider = CachedNetworkImageProvider(
mediaItem.extras!['thumb'] ?? mediaItem.artUri.toString(),
cacheManager: cacheManager);
2023-07-29 02:17:26 +00:00
//Run in isolate
_palette = await PaletteGenerator.fromImageProvider(imageProvider);
_dominantColor = _palette!.dominantColor!.color;
2023-07-29 02:17:26 +00:00
_imageProvider = settings.blurPlayerBackground ? imageProvider : null;
if (!_isDisposed) notifyListeners();
2023-07-29 02:17:26 +00:00
}
@override
void addListener(VoidCallback listener) {
print('[PROVIDER] listener added $hasListeners');
2023-07-29 02:17:26 +00:00
_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');
2023-07-29 02:17:26 +00:00
}
@override
void dispose() {
print('[PROVIDER] DISPOSED');
_isDisposed = true;
2023-07-29 02:17:26 +00:00
_mediaItemSub?.cancel();
super.dispose();
}
Color? get dominantColor => _dominantColor;
PaletteGenerator? get palette => _palette;
2023-07-29 02:17:26 +00:00
ImageProvider<Object>? get imageProvider => _imageProvider;
}
class PlayerScreen extends StatelessWidget {
const PlayerScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
2024-03-31 19:33:45 +00:00
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())),
),
2023-07-29 02:17:26 +00:00
);
}
}
/// 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) {
2024-03-31 19:33:45 +00:00
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);
}
}
2023-07-29 02:17:26 +00:00
/// 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,
2023-07-29 02:17:26 +00:00
required this.child,
this.enabled = true,
this.appBar,
});
Widget _buildChild(
BuildContext context, BackgroundProvider provider, Widget child) {
return Stack(children: [
if (provider.imageProvider != null || settings.colorGradientBackground)
Positioned.fill(
child: provider.imageProvider != null
? DecoratedBox(
decoration: const BoxDecoration(color: Colors.black),
child: ImageFiltered(
imageFilter: ImageFilter.blur(
tileMode: TileMode.decal,
sigmaX: _blurStrength,
sigmaY: _blurStrength,
),
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: provider.imageProvider!,
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.white
.withOpacity(settings.isDark ? 0.55 : 0.75),
BlendMode.dstATop),
)),
),
2023-07-29 02:17:26 +00:00
),
)
: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
if (provider.dominantColor != null)
provider.dominantColor!,
2023-07-29 02:17:26 +00:00
Theme.of(context).scaffoldBackgroundColor,
],
stops: const [0.0, 0.6],
2023-07-29 02:17:26 +00:00
)),
)),
child,
]);
}
static SystemUiOverlayStyle? getSystemUiOverlayStyle(BuildContext context,
2023-07-29 02:17:26 +00:00
{bool enabled = true}) {
final hasBackground = enabled &&
(settings.blurPlayerBackground || settings.colorGradientBackground);
if (!hasBackground) return null;
const color = Colors.transparent;
2023-07-29 02:17:26 +00:00
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
2024-03-31 19:33:45 +00:00
: Theme.of(context).colorScheme.background;
2023-07-29 02:17:26 +00:00
Widget widgetChild = Scaffold(
appBar: appBar,
backgroundColor: color,
body: SafeArea(child: child),
);
if (enabled) {
2023-07-29 02:17:26 +00:00
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) {
2023-07-29 02:17:26 +00:00
widgetChild = AnnotatedRegion<SystemUiOverlayStyle>(
value: suios,
2023-07-29 02:17:26 +00:00
child: widgetChild,
);
}
2023-07-29 02:17:26 +00:00
return widgetChild;
}
}
//Landscape
class PlayerScreenHorizontal extends StatelessWidget {
const PlayerScreenHorizontal({super.key});
2023-07-29 02:17:26 +00:00
@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),
)
],
),
)
],
2023-07-29 02:17:26 +00:00
);
}
}
//Portrait
class PlayerScreenVertical extends StatelessWidget {
const PlayerScreenVertical({super.key});
2023-07-29 02:17:26 +00:00
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
2023-07-29 02:17:26 +00:00
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.symmetric(horizontal: 24.0),
child: PlayerTextSubtext(textSize: 24.spMax),
2023-07-29 02:17:26 +00:00
),
SeekBar(textSize: 14.spMax),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: PlaybackControls(32.spMax),
),
2023-07-29 02:17:26 +00:00
Padding(
padding:
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0),
child: BottomBarControls(size: 22.spMax),
2023-07-29 02:17:26 +00:00
)
],
));
}
}
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,
showQueueButton: false,
),
),
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints.loose(const Size.square(500)),
2024-02-19 21:50:57 +00:00
child: const BigAlbumArt(showLyricsButton: false)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.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,
);
});
}
}
2023-07-29 02:17:26 +00:00
class PlayerTextSubtext extends StatelessWidget {
final double textSize;
const PlayerTextSubtext({Key? key, required this.textSize}) : super(key: key);
@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),
],
);
2023-07-29 02:17:26 +00:00
});
}
}
class QualityInfoWidget extends StatelessWidget {
2023-07-29 02:17:26 +00:00
final double? textSize;
const QualityInfoWidget({Key? key, this.textSize}) : super(key: key);
String _getQualityStringFromInfo(StreamQualityInfo info) {
if (audioHandler.mediaItem.value == null) return '';
2023-07-29 02:17:26 +00:00
int bitrate = info.quality == AudioQuality.MP3_128
? 128
: info.quality == AudioQuality.MP3_320
? 320
: info.calculateBitrate(audioHandler.mediaItem.value!.duration!);
2023-07-29 02:17:26 +00:00
return '${info.format.name} ${bitrate}kbps';
2023-07-29 02:17:26 +00:00
}
@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())),
);
2023-07-29 02:17:26 +00:00
}
}
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,
),
),
);
}
}
2023-07-29 02:17:26 +00:00
class PlayerMenuButton extends StatelessWidget {
final double size;
const PlayerMenuButton({super.key, required this.size});
2023-07-29 02:17:26 +00:00
@override
Widget build(BuildContext context) {
return IconButton(
iconSize: size,
icon: Icon(
Icons.more_vert,
semanticLabel: "Options".i18n,
),
onPressed: _onMenuPressedCallback(context));
2023-07-29 02:17:26 +00:00
}
}
class RepeatButton extends StatefulWidget {
final double iconSize;
const RepeatButton(this.iconSize, {Key? key}) : super(key: key);
2023-07-29 02:17:26 +00:00
@override
State<RepeatButton> createState() => _RepeatButtonState();
2023-07-29 02:17:26 +00:00
}
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,
2023-07-29 02:17:26 +00:00
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();
2023-07-29 02:17:26 +00:00
}
class _ShuffleButtonState extends State<ShuffleButton> {
@override
Widget build(BuildContext context) => IconButton(
icon: const Icon(Icons.shuffle),
2023-07-29 02:17:26 +00:00
iconSize: widget.iconSize,
color: playerHelper.shuffleEnabled
? Theme.of(context).colorScheme.primary
: null,
2023-07-29 02:17:26 +00:00
onPressed: _toggleShuffle,
);
void _toggleShuffle() {
playerHelper.toggleShuffle().then((_) => setState(() {}));
2023-07-29 02:17:26 +00:00
}
}
class FavoriteButton extends StatefulWidget {
final double size;
const FavoriteButton({Key? key, required this.size}) : super(key: key);
@override
State<FavoriteButton> createState() => _FavoriteButtonState();
2023-07-29 02:17:26 +00:00
}
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));
}
2023-07-29 02:17:26 +00:00
}
class PlaybackControls extends StatelessWidget {
final double size;
const PlaybackControls(this.size, {Key? key}) : super(key: key);
2023-07-29 02:17:26 +00:00
@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),
],
);
}),
2023-07-29 02:17:26 +00:00
);
}
}
class BigAlbumArt extends StatefulWidget {
2024-02-19 21:50:57 +00:00
final bool showLyricsButton;
const BigAlbumArt({super.key, this.showLyricsButton = true});
2023-07-29 02:17:26 +00:00
@override
State<BigAlbumArt> createState() => _BigAlbumArtState();
2023-07-29 02:17:26 +00:00
}
class _BigAlbumArtState extends State<BigAlbumArt> with WidgetsBindingObserver {
2023-07-29 02:17:26 +00:00
final _pageController = PageController(
initialPage: playerHelper.queueIndex,
keepPage: false,
2023-07-29 02:17:26 +00:00
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.
2023-07-29 02:17:26 +00:00
bool _initiatedByUser = false;
void _listenForMediaItemChanges() {
if (_currentItemSub != null) return;
2023-07-29 02:17:26 +00:00
_currentItemSub = audioHandler.mediaItem.listen((event) async {
if (_initiatedByUser) {
_initiatedByUser = false;
return;
}
if (!_pageController.hasClients) return;
if (_pageController.page?.toInt() == playerHelper.queueIndex) return;
2023-07-29 02:17:26 +00:00
print('animating controller to page');
_userScroll = false;
2023-07-29 02:17:26 +00:00
await _pageController.animateToPage(playerHelper.queueIndex,
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
_userScroll = true;
2023-07-29 02:17:26 +00:00
});
}
@override
void initState() {
_listenForMediaItemChanges();
2023-07-29 02:17:26 +00:00
super.initState();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.paused:
_currentItemSub?.cancel();
case AppLifecycleState.resumed:
_listenForMediaItemChanges();
default:
break;
}
super.didChangeAppLifecycleState(state);
}
2023-07-29 02:17:26 +00:00
@override
void dispose() {
_currentItemSub?.cancel();
super.dispose();
}
void _pushLyrics() {
builder(ctx) => ChangeNotifierProvider<BackgroundProvider>.value(
value: Provider.of<BackgroundProvider>(context),
child: const LyricsScreen());
Navigator.of(context).pushRoute(builder: builder);
}
2023-07-29 02:17:26 +00:00
@override
Widget build(BuildContext context) {
final child = GestureDetector(
2023-07-29 02:17:26 +00:00
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: 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,
),
),
),
));
}),
2024-02-19 21:50:57 +00:00
if (widget.showLyricsButton)
StreamBuilder<MediaItem?>(
initialData: audioHandler.mediaItem.valueOrNull,
stream: audioHandler.mediaItem,
builder: (context, snapshot) {
if (snapshot.data == null) return const SizedBox.shrink();
print(snapshot.data!.extras);
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: Consumer<BackgroundProvider>(
builder: (context, provider, child) => Material(
color: Color.lerp(
Theme.of(context).colorScheme.background,
provider.dominantColor,
0.25),
borderRadius: BorderRadius.circular(16.0),
clipBehavior: Clip.antiAlias,
child: child),
child: InkWell(
onTap: _pushLyrics,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0, vertical: 4.0),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.subtitles, size: 18.0),
const SizedBox(width: 8.0),
Text('Lyrics'.i18n),
]),
),
),
),
2024-02-19 21:50:57 +00:00
);
},
),
],
),
);
return AspectRatio(
aspectRatio: 1.0,
child: settings.playerAlbumArtDropShadow
? Consumer<BackgroundProvider>(
builder: (context, background, child) => AnimatedContainer(
duration: const Duration(seconds: 1),
decoration: BoxDecoration(boxShadow: [
BoxShadow(
color: background.dominantColor ?? Colors.transparent,
spreadRadius: 0.0,
blurRadius: 100.0)
]),
child: child),
child: child,
)
: child,
2023-07-29 02:17:26 +00:00
);
}
}
//Top row containing QueueSource, queue...
class PlayerScreenTopRow extends StatelessWidget {
final double? textSize;
final double? iconSize;
final double? textWidth;
final bool short;
final bool showQueueButton; // not needed on desktop
const PlayerScreenTopRow(
{super.key,
this.textSize,
this.iconSize,
this.textWidth,
this.short = false,
this.showQueueButton = true});
2023-07-29 02:17:26 +00:00
@override
Widget build(BuildContext context) {
final size = iconSize ?? 52.sp;
2023-07-29 02:17:26 +00:00
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)));
}),
),
showQueueButton
? IconButton(
icon: Icon(
Icons.menu,
semanticLabel: "Queue".i18n,
),
iconSize: size,
splashRadius: size * 1.5,
onPressed: () => Navigator.of(context).pushRoute(
builder: (ctx) => QueueScreen(
closePlayer: FancyScaffold.of(context)!.closePanel,
)),
)
: SizedBox.square(dimension: size + 16.0),
2023-07-29 02:17:26 +00:00
],
);
}
}
class SeekBar extends StatefulWidget {
final double textSize;
const SeekBar({Key? key, this.textSize = 16.0}) : super(key: key);
2023-07-29 02:17:26 +00:00
@override
State<SeekBar> createState() => _SeekBarState();
2023-07-29 02:17:26 +00:00
}
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()));
},
);
})),
2023-07-29 02:17:26 +00:00
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
2023-07-29 02:17:26 +00:00
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
ValueListenableBuilder<Duration>(
valueListenable: position,
builder: (context, value, _) => Text(
_timeString(value),
style: TextStyle(
fontSize: widget.textSize,
2023-07-29 02:17:26 +00:00
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,
2023-07-29 02:17:26 +00:00
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,
});
2023-07-29 02:17:26 +00:00
@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()),
PlayerMenuButton(size: size),
],
);
}
2023-07-29 02:17:26 +00:00
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
2023-07-29 02:17:26 +00:00
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
QualityInfoWidget(textSize: size * 0.75),
const Expanded(child: SizedBox()),
2023-07-29 02:17:26 +00:00
IconButton(
icon: Icon(
Icons.sentiment_very_dissatisfied,
semanticLabel: "Dislike".i18n,
),
iconSize: iconSize,
2023-07-29 02:17:26 +00:00
onPressed: () async {
unawaited(DeezerAPI.instance
.dislikeTrack(audioHandler.mediaItem.value!.id));
2023-07-29 02:17:26 +00:00
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);
// },
// ),
desktopMode
? PlayerMenuButtonDesktop(size: iconSize)
: PlayerMenuButton(size: iconSize)
2023-07-29 02:17:26 +00:00
],
);
}
}