1239 lines
42 KiB
Dart
1239 lines
42 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) {
|
|
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(
|
|
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(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.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,
|
|
),
|
|
),
|
|
Flexible(
|
|
child: 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);
|
|
|
|
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({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>[
|
|
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.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(
|
|
// style: ButtonStyle(
|
|
// elevation: MaterialStatePropertyAll(0.5),
|
|
// padding: MaterialStatePropertyAll(
|
|
// EdgeInsets.symmetric(horizontal: 16, vertical: 4)),
|
|
// foregroundColor: MaterialStatePropertyAll(
|
|
// Theme.of(context).colorScheme.onSurface)),
|
|
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,
|
|
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),
|
|
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),
|
|
const Expanded(child: SizedBox()),
|
|
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);
|
|
}
|
|
}
|