freezer/lib/ui/player_bar.dart

331 lines
11 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:freezer/api/player/player_helper.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({
super.key,
this.onTap,
this.shouldHaveHero = true,
this.backgroundColor,
this.focusNode,
});
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) {
// lazy way to prevent dragging up
return GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragEnd: (_) {},
onVerticalDragUpdate: (_) {},
child: 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(
rounded: true,
width: 50,
height: 50,
url: currentMediaItem.extras!['thumb'] ??
currentMediaItem.artUri.toString(),
);
final leadingWidget = shouldHaveHero
? Hero(tag: currentMediaItem.id, child: image)
: image;
return Material(
type: MaterialType.transparency,
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 bool material3;
final Color? iconColor;
/// The color of the card if [filled] is true
final Color? color;
const PlayPauseButton(
this.size, {
super.key,
this.filled = false,
this.material3 = true,
this.color,
this.iconColor,
});
@override
State<PlayPauseButton> createState() => _PlayPauseButtonState();
}
class _PlayPauseButtonState extends State<PlayPauseButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
late StreamSubscription _stateSubscription;
late StreamSubscription _playingSubscription;
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);
_stateSubscription = playerHelper.processingState.listen((processingState) {
if (processingState == AudioProcessingState.ready ||
audioHandler.playbackState.value.processingState ==
AudioProcessingState.idle) {
if (!_canPlay) setState(() => _canPlay = true);
return;
}
setState(() => _canPlay = false);
});
_playingSubscription = playerHelper.playing.listen((playing) {
if (playing) {
_controller.forward();
} else {
_controller.reverse();
}
});
super.initState();
}
@override
void dispose() {
_stateSubscription.cancel();
_playingSubscription.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) {
child = IconButton(
color: widget.iconColor,
icon: icon,
iconSize: widget.size,
onPressed: _playPause);
} else {
child = Material(
type: MaterialType.transparency,
child: InkWell(
customBorder: widget.material3 ? null : 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.material3 && widget.filled) {
return StreamBuilder<bool>(
stream: playerHelper.playing,
builder: (context, snapshot) {
return AnimatedContainer(
clipBehavior: Clip.antiAlias,
width: widget.size * (snapshot.data == true ? 1.5 : 1),
height: widget.size,
duration: const Duration(milliseconds: 250),
decoration: BoxDecoration(
borderRadius: snapshot.data == true
? BorderRadius.circular(widget.size / 3)
: BorderRadius.circular(widget.size * 0.5),
color: widget.color),
child: child);
});
}
if (widget.filled) {
return AnimatedContainer(
width: widget.size,
height: widget.size,
duration: const Duration(seconds: 1),
decoration:
BoxDecoration(shape: BoxShape.circle, color: widget.color),
child: child);
}
return SizedBox.square(dimension: widget.size * 2, child: child);
}
}