freezer/lib/ui/player_screen.dart
Pato05 2862c9ec05
remove browser login for desktop
restore translations functionality
make scrollViews handle mouse pointers like touch, so that pull to refresh functionality is available
exit app if opening cache or settings fails (another instance running)
remove draggable_scrollbar and use builtin widget instead
fix email login
better way to manage lyrics (less updates and lookups in the lyrics List)
fix player_screen on mobile (too big -> just average :))
right click: use TapUp events instead
desktop: show context menu on triple dots button also
avoid showing connection error if the homepage is cached and available offline
i'm probably forgetting something idk
2023-10-25 00:32:28 +02:00

1226 lines
41 KiB
Dart

// ignore_for_file: unused_import
import 'dart:ui';
import 'dart:async';
import 'package:cached_network_image/cached_network_image.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/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';
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) {
_mediaItemSub ??= audioHandler.mediaItem.listen((mediaItem) {
if (mediaItem == null) return;
_updateColor(mediaItem);
});
super.addListener(listener);
}
@override
void removeListener(VoidCallback listener) {
super.removeListener(listener);
if (!hasListeners && _mediaItemSub != null) {
_mediaItemSub!.cancel();
_mediaItemSub = null;
}
}
@override
void dispose() {
_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(
create: (context) => BackgroundProvider(),
child: PlayerScreenBackground(
child: MainScreen.of(context).isDesktop
? const PlayerScreenDesktop()
: OrientationBuilder(
builder: (context, orientation) =>
orientation == Orientation.landscape
? const PlayerScreenHorizontal()
: const PlayerScreenVertical())),
);
}
}
/// 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) {
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),
)),
),
),
)
: 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).scaffoldBackgroundColor;
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(8, 0, 8, 0),
child: PlayerScreenTopRow(
textSize: 24.sp,
iconSize: 36.sp,
textWidth: 350.w,
short: true),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: PlayerTextSubtext(textSize: 35.sp),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SeekBar(textSize: 24.sp),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: PlaybackControls(46.sp),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.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.symmetric(horizontal: 24.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: [
Flexible(
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: 10.sp,
iconSize: 17.sp,
showQueueButton: false,
),
),
ConstrainedBox(
constraints: BoxConstraints.loose(const Size.square(500)),
child: const BigAlbumArt()),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: PlayerTextSubtext(textSize: 18.sp),
),
SeekBar(textSize: 12.sp),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: PlaybackControls(24.sp),
),
Padding(
padding:
const EdgeInsets.symmetric(vertical: 0, horizontal: 16.0),
child: BottomBarControls(
size: 16.sp,
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);
print(textPainter.didExceedMaxLines);
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,
accelerationDuration: const Duration(seconds: 1),
pauseAfterRound: const Duration(seconds: 2),
crossAxisAlignment: CrossAxisAlignment.start,
fadingEdgeEndFraction: 0.05,
fadingEdgeStartFraction: 0.05,
);
});
}
}
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 Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
FitOrScrollText(
key: Key(currentMediaItem.displayTitle!),
text: currentMediaItem.displayTitle!,
maxLines: 1,
style: TextStyle(
fontSize: textSize, fontWeight: FontWeight.bold)),
// 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),
// )),
const SizedBox(height: 2.0),
Text(
currentMediaItem.displaySubtitle ?? '',
maxLines: 1,
textAlign: TextAlign.start,
overflow: TextOverflow.clip,
style: TextStyle(
fontSize: textSize * 0.8, // 20% smaller
color: Theme.of(context).colorScheme.primary,
),
),
]);
});
}
}
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 StreamBuilder<String>(
stream: playerHelper.streamInfo.map<String>(_getQualityStringFromInfo),
builder: (context, snapshot) {
return TextButton(
child: Text(snapshot.data ?? '',
style: textSize == null ? null : TextStyle(fontSize: textSize)),
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.removeFavorite(mediaItem.id);
await cache.save();
} else {
//Add
setState(() => cache.libraryTracks.add(mediaItem.id));
await deezerAPI.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: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisSize: MainAxisSize.max,
children: [
playerHelper.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,
color: color,
iconColor: Color.lerp(
(ThemeData.estimateBrightnessForColor(color) ==
Brightness.light
? Colors.black
: Colors.white),
color,
0.25));
})
else
PlayPauseButton(size * 1.25),
PrevNextButton(size),
playerHelper.queueSource?.source != 'show'
? RepeatButton(size * 0.75)
: const ForwardReplay30Button(forward: true),
],
),
);
}
}
class BigAlbumArt extends StatefulWidget {
const BigAlbumArt({super.key});
@override
State<BigAlbumArt> createState() => _BigAlbumArtState();
}
class _BigAlbumArtState extends State<BigAlbumArt> {
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 = false;
/// whether the user has already scrolled the [PageView],
/// so to avoid calling [PageController.animateToPage] again.
bool _initiatedByUser = false;
@override
void initState() {
_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');
await _pageController.animateToPage(playerHelper.queueIndex,
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
});
super.initState();
}
@override
void dispose() {
_currentItemSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final child = GestureDetector(
// onVerticalDragUpdate: (DragUpdateDetails details) {
// if (details.delta.dy > 16) {
// Navigator.of(context).pop();
// }
// },
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);
},
)
// PageRouteBuilder(
// opaque: false, // transparent background
// barrierDismissible: true,
// pageBuilder: (context, animation, __) {
// return FadeTransition(
// opacity: animation,
// child: PhotoView(
// imageProvider: CachedNetworkImageProvider(
// audioHandler.mediaItem.value!.artUri.toString()),
// maxScale: 8.0,
// minScale: 0.2,
// heroAttributes: PhotoViewHeroAttributes(
// tag: audioHandler.mediaItem.value!.id),
// backgroundDecoration: const BoxDecoration(
// color: Color.fromARGB(0x90, 0, 0, 0))),
// );
// }),
),
onHorizontalDragDown: (_) => _userScroll = true,
// delayed a bit, so to make sure that the page view updated.
onHorizontalDragEnd: (_) => Future.delayed(
const Duration(milliseconds: 100), () => _userScroll = false),
child: 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;
// }
_initiatedByUser = true;
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,
),
),
),
));
}),
);
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,
);
}
}
//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});
@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,
),
if (playerHelper.queueSource != null)
Expanded(
child: RichText(
textAlign: TextAlign.center,
maxLines: 2,
text: TextSpan(children: [
if (!short)
TextSpan(
text:
'${'Playing from:'.i18n.toUpperCase().withoutLast(1)}\n',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.5,
fontSize: (textSize ?? 38.sp) * 0.85)),
TextSpan(text: playerHelper.queueSource!.text ?? '')
], style: TextStyle(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),
],
);
}
}
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) {
if (playerHelper.queueSource?.source == 'show') {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
QualityInfoWidget(
textSize: size * 0.75,
),
PlayerMenuButton(size: size),
],
);
}
return Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
QualityInfoWidget(textSize: size * 0.75),
if (!desktopMode)
IconButton(
iconSize: size,
icon: Icon(
Icons.subtitles,
semanticLabel: "Lyrics".i18n,
),
onPressed: () => _pushLyrics(context)),
IconButton(
icon: Icon(
Icons.sentiment_very_dissatisfied,
semanticLabel: "Dislike".i18n,
),
iconSize: size * 0.85,
onPressed: () async {
unawaited(
deezerAPI.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);
// },
// ),
FavoriteButton(size: size * 0.85),
desktopMode
? PlayerMenuButtonDesktop(size: size)
: PlayerMenuButton(size: size)
],
);
}
void _pushLyrics(BuildContext context) {
builder(ctx) => ChangeNotifierProvider<BackgroundProvider>.value(
value: Provider.of<BackgroundProvider>(context),
child: const LyricsScreen());
Navigator.of(context).pushRoute(builder: builder);
}
}