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
299 lines
9.9 KiB
Dart
299 lines
9.9 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:freezer/settings.dart';
|
|
import 'package:freezer/translations.i18n.dart';
|
|
import 'package:rxdart/rxdart.dart';
|
|
|
|
import '../api/player/audio_handler.dart';
|
|
import 'cached_image.dart';
|
|
|
|
class PlayerBar extends StatelessWidget {
|
|
final VoidCallback? onTap;
|
|
final bool shouldHaveHero;
|
|
final Color? backgroundColor;
|
|
final FocusNode? focusNode;
|
|
const PlayerBar({
|
|
Key? key,
|
|
this.onTap,
|
|
this.shouldHaveHero = true,
|
|
this.backgroundColor,
|
|
this.focusNode,
|
|
}) : super(key: key);
|
|
|
|
final double iconSize = 28;
|
|
|
|
double parsePosition(Duration position) {
|
|
if (audioHandler.mediaItem.value == null) return 0.0;
|
|
if (audioHandler.mediaItem.value!.duration!.inSeconds == 0) {
|
|
return 0.0; //Division by 0
|
|
}
|
|
return position.inSeconds /
|
|
audioHandler.mediaItem.value!.duration!.inSeconds;
|
|
}
|
|
|
|
Color? get _backgroundColor => backgroundColor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
height: 68.0,
|
|
child: Column(mainAxisSize: MainAxisSize.max, children: <Widget>[
|
|
Expanded(
|
|
child: StreamBuilder<MediaItem?>(
|
|
stream: audioHandler.mediaItem,
|
|
initialData: audioHandler.mediaItem.valueOrNull,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.data == null) {
|
|
return Material(
|
|
child: ListTile(
|
|
dense: true,
|
|
visualDensity: VisualDensity.standard,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0, vertical: 6.0),
|
|
leading: Image.asset(
|
|
'assets/cover_thumb.jpg',
|
|
width: 48.0,
|
|
height: 48.0,
|
|
),
|
|
title: Text('Nothing is currently playing'.i18n),
|
|
),
|
|
);
|
|
}
|
|
final currentMediaItem = snapshot.data!;
|
|
final image = CachedImage(
|
|
width: 50,
|
|
height: 50,
|
|
url: currentMediaItem.extras!['thumb'] ??
|
|
currentMediaItem.artUri.toString(),
|
|
);
|
|
final leadingWidget = shouldHaveHero
|
|
? Hero(tag: currentMediaItem.id, child: image)
|
|
: image;
|
|
return Material(
|
|
child: ListTile(
|
|
dense: true,
|
|
tileColor: _backgroundColor,
|
|
focusNode: focusNode,
|
|
visualDensity: VisualDensity.standard,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0, vertical: 0.0),
|
|
onTap: onTap,
|
|
leading: AnimatedSwitcher(
|
|
key: const ValueKey('player_bar_art_switcher'),
|
|
duration: const Duration(milliseconds: 250),
|
|
child: leadingWidget),
|
|
title: Text(
|
|
currentMediaItem.displayTitle!,
|
|
overflow: TextOverflow.clip,
|
|
maxLines: 1,
|
|
),
|
|
subtitle: Text(
|
|
currentMediaItem.displaySubtitle ?? '',
|
|
overflow: TextOverflow.clip,
|
|
maxLines: 1,
|
|
),
|
|
trailing: IconTheme(
|
|
data: IconThemeData(
|
|
color: settings.isDark
|
|
? Colors.white
|
|
: Colors.grey[600]),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
PrevNextButton(
|
|
iconSize,
|
|
prev: true,
|
|
),
|
|
PlayPauseButton(iconSize),
|
|
PrevNextButton(iconSize)
|
|
],
|
|
),
|
|
)));
|
|
}),
|
|
),
|
|
SizedBox(
|
|
height: 3.0,
|
|
child: StreamBuilder<Duration>(
|
|
stream: AudioService.position,
|
|
builder: (context, snapshot) {
|
|
return LinearProgressIndicator(
|
|
value: parsePosition(snapshot.data ?? Duration.zero),
|
|
);
|
|
}),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class PrevNextButton extends StatelessWidget {
|
|
final double size;
|
|
final bool prev;
|
|
final bool hidePrev;
|
|
const PrevNextButton(this.size,
|
|
{super.key, this.prev = false, this.hidePrev = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Listens to both mediaItem updates (a.k.a. when the song changes)
|
|
// and queue updates (so for example, in SmartTrackLists, when the songs are fetched, it's updated with the new items)
|
|
return StreamBuilder<bool>(
|
|
stream: Rx.combineLatest2<MediaItem?, List<MediaItem>, bool>(
|
|
audioHandler.mediaItem,
|
|
audioHandler.queue,
|
|
(_, queue) => playerHelper.queueIndex < queue.length - 1),
|
|
builder: (context, snapshot) {
|
|
if (!prev) {
|
|
return IconButton(
|
|
splashRadius: size * 2.0,
|
|
icon: Icon(
|
|
Icons.skip_next,
|
|
semanticLabel: "Play next".i18n,
|
|
),
|
|
iconSize: size,
|
|
onPressed:
|
|
snapshot.data == true ? () => audioHandler.skipToNext() : null,
|
|
);
|
|
}
|
|
final canGoPrev = playerHelper.queueIndex > 0;
|
|
|
|
if (!canGoPrev && hidePrev) return const SizedBox.shrink();
|
|
|
|
return IconButton(
|
|
splashRadius: size * 2.0,
|
|
icon: Icon(
|
|
Icons.skip_previous,
|
|
semanticLabel: "Play previous".i18n,
|
|
),
|
|
iconSize: size,
|
|
onPressed: canGoPrev ? () => audioHandler.skipToPrevious() : null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class PlayPauseButton extends StatefulWidget {
|
|
final double size;
|
|
final bool filled;
|
|
final Color? iconColor;
|
|
|
|
/// The color of the card if [filled] is true
|
|
final Color? color;
|
|
const PlayPauseButton(
|
|
this.size, {
|
|
Key? key,
|
|
this.filled = false,
|
|
this.color,
|
|
this.iconColor,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<PlayPauseButton> createState() => _PlayPauseButtonState();
|
|
}
|
|
|
|
class _PlayPauseButtonState extends State<PlayPauseButton>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AnimationController _controller;
|
|
late final Animation<double> _animation;
|
|
late StreamSubscription _subscription;
|
|
late bool _canPlay = audioHandler.playbackState.value.processingState ==
|
|
AudioProcessingState.ready ||
|
|
audioHandler.playbackState.value.processingState ==
|
|
AudioProcessingState.idle;
|
|
|
|
@override
|
|
void initState() {
|
|
_controller = AnimationController(
|
|
vsync: this, duration: const Duration(milliseconds: 200));
|
|
_animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOut);
|
|
|
|
_subscription = audioHandler.playbackState.listen((playbackState) {
|
|
if (playbackState.processingState == AudioProcessingState.ready ||
|
|
audioHandler.playbackState.value.processingState ==
|
|
AudioProcessingState.idle) {
|
|
if (playbackState.playing) {
|
|
_controller.forward();
|
|
} else {
|
|
_controller.reverse();
|
|
}
|
|
if (!_canPlay) setState(() => _canPlay = true);
|
|
return;
|
|
}
|
|
setState(() => _canPlay = false);
|
|
});
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_subscription.cancel();
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _playPause() {
|
|
if (audioHandler.playbackState.value.playing) {
|
|
audioHandler.pause();
|
|
} else {
|
|
audioHandler.play();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Widget? child;
|
|
if (_canPlay) {
|
|
final icon = AnimatedIcon(
|
|
icon: AnimatedIcons.play_pause,
|
|
progress: _animation,
|
|
semanticLabel: audioHandler.playbackState.value.playing
|
|
? 'Pause'.i18n
|
|
: 'Play'.i18n,
|
|
);
|
|
if (!widget.filled) {
|
|
return IconButton(
|
|
color: widget.iconColor,
|
|
icon: icon,
|
|
iconSize: widget.size,
|
|
onPressed: _playPause);
|
|
} else {
|
|
child = Material(
|
|
type: MaterialType.transparency,
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: _playPause,
|
|
child: IconTheme.merge(
|
|
child: Center(child: icon),
|
|
data: IconThemeData(
|
|
size: widget.size / 2, color: widget.iconColor))),
|
|
);
|
|
}
|
|
} else {
|
|
switch (audioHandler.playbackState.value.processingState) {
|
|
//Stopped/Error
|
|
case AudioProcessingState.error:
|
|
child = null;
|
|
break;
|
|
//Loading, connecting, rewinding...
|
|
default:
|
|
child = const Center(child: CircularProgressIndicator());
|
|
break;
|
|
}
|
|
}
|
|
if (widget.filled) {
|
|
return SizedBox.square(
|
|
dimension: widget.size,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(seconds: 1),
|
|
decoration:
|
|
BoxDecoration(shape: BoxShape.circle, color: widget.color),
|
|
child: child));
|
|
} else {
|
|
return SizedBox.square(dimension: widget.size, child: child);
|
|
}
|
|
}
|
|
}
|