Pato05
2862c9ec05
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
1226 lines
41 KiB
Dart
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);
|
|
}
|
|
}
|