diff --git a/lib/components/AlbumScreen/preset_chip.dart b/lib/components/AlbumScreen/preset_chip.dart new file mode 100644 index 000000000..5c1f8ea20 --- /dev/null +++ b/lib/components/AlbumScreen/preset_chip.dart @@ -0,0 +1,161 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:finamp/services/queue_service.dart'; +import 'package:get_it/get_it.dart'; + +const _radius = Radius.circular(4); +const _borderRadius = BorderRadius.all(_radius); +final _defaultBackgroundColour = Colors.white.withOpacity(0.1); +const _spacing = 8.0; + +enum PresetTypes { + speed, +} + +class PresetChips extends StatefulWidget { + const PresetChips({ + Key? key, + required this.type, + required this.values, + required this.activeValue, + this.onTap, + this.mainColour, + this.onPresetSelected, + this.chipWidth = 64.0, + this.chipHeight = 44.0, + }) : super(key: key); + + final PresetTypes type; + + final List values; + final double activeValue; + final Function()? onTap; + final Color? mainColour; // used for different background colours + final Function()? onPresetSelected; + final double chipWidth; + final double chipHeight; + + @override + State createState() => _PresetChipsState(); +} + +class _PresetChipsState extends State { + final _queueService = GetIt.instance(); + final _controller = ScrollController(); + bool scrolledAlready = false; + + void scrollToActivePreset(double currentValue, double maxWidth) { + if (!_controller.hasClients) return; + var offset = widget.chipWidth * widget.values.indexOf(currentValue) + + widget.chipWidth / 2 - + maxWidth / 2 - + _spacing / 2; + + offset = min(max(0, offset), + widget.chipWidth * (widget.values.length) - maxWidth - _spacing); + + _controller.animateTo( + offset, + duration: Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + ); + } + + PresetChip generatePresetChip(value, BoxConstraints constraints) { + // Scroll to the active preset + if (value == widget.activeValue) { + if (scrolledAlready) { + scrollToActivePreset(value, constraints.maxWidth); + } else { + Future.delayed(Duration(milliseconds: 200), () { + scrollToActivePreset(value, constraints.maxWidth); + }); + scrolledAlready = true; + } + } + + final stringValue = "x$value"; + + return PresetChip( + value: stringValue, + backgroundColour: + widget.mainColour?.withOpacity(value == widget.activeValue + ? 0.6 + : (value == 1.0) + ? 0.3 + : 0.1), + isSelected: value == widget.activeValue, + isPresetDefault: value == 1.0, + width: widget.chipWidth, + height: widget.chipHeight, + onTap: () { + setState(() {}); + _queueService.playbackSpeed = value; + widget.onPresetSelected?.call(); + }, + ); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + return SingleChildScrollView( + controller: _controller, + scrollDirection: Axis.horizontal, + child: Wrap( + spacing: _spacing, + crossAxisAlignment: WrapCrossAlignment.center, + children: List.generate(widget.values.length, + (index) => generatePresetChip(widget.values[index], constraints)), + ), + ); + }); + } +} + +class PresetChip extends StatelessWidget { + const PresetChip({ + Key? key, + required this.width, + required this.height, + this.value = "", + this.onTap, + this.backgroundColour, + this.isSelected, + this.isPresetDefault, + }) : super(key: key); + + final double width; + final double height; + final String value; + final void Function()? onTap; + final Color? backgroundColour; + final bool? isSelected; + final bool? isPresetDefault; + + @override + Widget build(BuildContext context) { + final backgroundColor = backgroundColour ?? _defaultBackgroundColour; + final color = Theme.of(context).textTheme.bodySmall?.color ?? Colors.white; + + return TextButton( + style: TextButton.styleFrom( + backgroundColor: backgroundColor, + shape: const RoundedRectangleBorder(borderRadius: _borderRadius), + minimumSize: Size(width, height), + padding: const EdgeInsets.symmetric(horizontal: 2.0), + visualDensity: VisualDensity.compact, + ), + onPressed: onTap, + child: Text( + value, + style: TextStyle( + color: color, + overflow: TextOverflow.visible, + fontWeight: isSelected! ? FontWeight.w800 : FontWeight.normal, + ), + softWrap: false, + ), + ); + } +} diff --git a/lib/components/AlbumScreen/song_list_tile.dart b/lib/components/AlbumScreen/song_list_tile.dart index d76c01137..7db15b1fe 100644 --- a/lib/components/AlbumScreen/song_list_tile.dart +++ b/lib/components/AlbumScreen/song_list_tile.dart @@ -183,7 +183,7 @@ class _SongListTileState extends ConsumerState if (widget.showPlayCount) TextSpan( text: - "· ${AppLocalizations.of(context)!.playCountValue(widget.item.userData?.playCount ?? 0)}", + " · ${AppLocalizations.of(context)!.playCountValue(widget.item.userData?.playCount ?? 0)}", style: TextStyle(color: Theme.of(context).disabledColor), ), @@ -358,11 +358,12 @@ class _SongListTileState extends ConsumerState if (!mounted) return false; GlobalSnackbar.message( - (scaffold) => FinampSettingsHelper.finampSettings.swipeInsertQueueNext - ? AppLocalizations.of(scaffold)! - .confirmAddToNextUp("track") - : AppLocalizations.of(scaffold)! - .confirmAddToQueue("track"), + (scaffold) => + FinampSettingsHelper.finampSettings.swipeInsertQueueNext + ? AppLocalizations.of(scaffold)! + .confirmAddToNextUp("track") + : AppLocalizations.of(scaffold)! + .confirmAddToQueue("track"), isConfirmation: true, ); diff --git a/lib/components/AlbumScreen/song_menu.dart b/lib/components/AlbumScreen/song_menu.dart index 45e630a79..d8f7503d9 100644 --- a/lib/components/AlbumScreen/song_menu.dart +++ b/lib/components/AlbumScreen/song_menu.dart @@ -8,6 +8,7 @@ import 'package:finamp/screens/artist_screen.dart'; import 'package:finamp/screens/blurred_player_screen_background.dart'; import 'package:finamp/services/current_track_metadata_provider.dart'; import 'package:finamp/services/feedback_helper.dart'; +import 'package:finamp/services/metadata_provider.dart'; import 'package:finamp/services/music_player_background_task.dart'; import 'package:finamp/services/queue_service.dart'; import 'package:flutter/material.dart'; @@ -32,6 +33,12 @@ import '../PlayerScreen/artist_chip.dart'; import '../album_image.dart'; import '../global_snackbar.dart'; import 'download_dialog.dart'; +import 'song_list_tile.dart'; +import 'speed_menu.dart'; + +const Duration songMenuDefaultAnimationDuration = Duration(milliseconds: 350); +const Curve songMenuDefaultInCurve = Curves.easeOutCubic; +const Curve songMenuDefaultOutCurve = Curves.easeInCubic; Future showModalSongMenu({ required BuildContext context, @@ -133,11 +140,21 @@ class _SongMenuState extends ConsumerState { ColorScheme? _imageTheme; ImageProvider? _imageProvider; + // Makes sure that widget doesn't just disappear after press while menu is visible + bool speedWidgetWasVisible = false; + bool showSpeedMenu = false; + final dragController = DraggableScrollableController(); + double initialSheetExtent = 0.0; + double inputStep = 0.9; + double oldExtent = 0.0; + @override void initState() { super.initState(); _imageTheme = widget.playerScreenTheme; // use player screen theme if provided + initialSheetExtent = widget.showPlaybackControls ? 0.6 : 0.45; + oldExtent = initialSheetExtent; } /// Sets the item's favourite on the Jellyfin server. @@ -187,6 +204,43 @@ class _SongMenuState extends ConsumerState { } } + void toggleSpeedMenu() { + setState(() { + showSpeedMenu = !showSpeedMenu; + }); + scrollToExtent(dragController, showSpeedMenu ? inputStep : null); + Vibrate.feedback(FeedbackType.selection); + } + + Future shouldShowSpeedControls(double currentSpeed, MetadataProvider? metadata) async { + if (currentSpeed != 1.0 || + FinampSettingsHelper.finampSettings.playbackSpeedVisibility == + PlaybackSpeedVisibility.visible) { + return true; + } + + if (FinampSettingsHelper.finampSettings.playbackSpeedVisibility == PlaybackSpeedVisibility.automatic) { + return metadata?.qualifiesForPlaybackSpeedControl ?? false; + } + + return false; +} + + void scrollToExtent( + DraggableScrollableController scrollController, double? percentage) { + var currentSize = scrollController.size; + if (percentage != null && + (percentage != inputStep || currentSize < percentage) || + scrollController.size == inputStep) { + scrollController.animateTo( + percentage ?? oldExtent, + duration: songMenuDefaultAnimationDuration, + curve: songMenuDefaultInCurve, + ); + } + oldExtent = currentSize; + } + @override Widget build(BuildContext context) { return Consumer(builder: (context, ref, child) { @@ -195,6 +249,8 @@ class _SongMenuState extends ConsumerState { Theme.of(context).iconTheme.color ?? Colors.white; + final metadata = ref.watch(currentTrackMetadataProvider).unwrapPrevious(); + final menuEntries = _menuEntries(context, iconColor); var stackHeight = widget.showPlaybackControls ? 255 : 155; stackHeight += menuEntries @@ -206,10 +262,12 @@ class _SongMenuState extends ConsumerState { return Stack(children: [ LayoutBuilder(builder: (context, constraints) { var size = (stackHeight / constraints.maxHeight).clamp(0.4, 1.0); + initialSheetExtent = size; return DraggableScrollableSheet( snap: true, + controller: dragController, initialChildSize: size, - minChildSize: size * 0.75, + minChildSize: size * 0.7, expand: false, builder: (context, scrollController) { return Stack( @@ -252,143 +310,184 @@ class _SongMenuState extends ConsumerState { if (widget.showPlaybackControls) SongMenuMask( child: StreamBuilder( - stream: Rx.combineLatest2( + stream: Rx.combineLatest3( _queueService.getPlaybackOrderStream(), _queueService.getLoopModeStream(), - (a, b) => PlaybackBehaviorInfo(a, b)), + _queueService.getPlaybackSpeedStream(), + (a, b, c) => PlaybackBehaviorInfo(a, b, c)), builder: (context, snapshot) { if (!snapshot.hasData) { return const SliverToBoxAdapter(); } - final playbackBehavior = snapshot.data!; - const playbackOrderIcons = { - FinampPlaybackOrder.linear: - TablerIcons.arrows_right, - FinampPlaybackOrder.shuffled: - TablerIcons.arrows_shuffle, - }; - final playbackOrderTooltips = { - FinampPlaybackOrder.linear: - AppLocalizations.of(context) - ?.playbackOrderLinearButtonLabel ?? - "Playing in order", - FinampPlaybackOrder.shuffled: - AppLocalizations.of(context) - ?.playbackOrderShuffledButtonLabel ?? - "Shuffling", - }; - const loopModeIcons = { - FinampLoopMode.none: TablerIcons.repeat, - FinampLoopMode.one: TablerIcons.repeat_once, - FinampLoopMode.all: TablerIcons.repeat, - }; - final loopModeTooltips = { - FinampLoopMode.none: AppLocalizations.of(context) - ?.loopModeNoneButtonLabel ?? - "Looping off", - FinampLoopMode.one: AppLocalizations.of(context) - ?.loopModeOneButtonLabel ?? - "Looping this song", - FinampLoopMode.all: AppLocalizations.of(context) - ?.loopModeAllButtonLabel ?? - "Looping all", - }; - - return SliverCrossAxisGroup( - // return SliverGrid.count( - // crossAxisCount: 3, - // mainAxisSpacing: 40, - // children: [ - slivers: [ - PlaybackAction( - icon: - playbackOrderIcons[playbackBehavior.order]!, - onPressed: () async { - _queueService.togglePlaybackOrder(); - }, - tooltip: playbackOrderTooltips[ - playbackBehavior.order]!, - iconColor: playbackBehavior.order == - FinampPlaybackOrder.shuffled - ? iconColor - : Theme.of(context) - .textTheme - .bodyMedium - ?.color ?? - Colors.white, - ), - ValueListenableBuilder( - valueListenable: _audioHandler.sleepTimer, - builder: (context, timerValue, child) { - final remainingMinutes = (_audioHandler - .sleepTimerRemaining.inSeconds / - 60.0) - .ceil(); - return PlaybackAction( - icon: timerValue != null - ? TablerIcons.hourglass_high - : TablerIcons.hourglass_empty, - onPressed: () async { - if (timerValue != null) { - await showDialog( - context: context, - builder: (context) => - const SleepTimerCancelDialog(), - ); - } else { - await showDialog( - context: context, - builder: (context) => - const SleepTimerDialog(), - ); - } - }, - tooltip: timerValue != null - ? AppLocalizations.of(context) - ?.sleepTimerRemainingTime( - remainingMinutes) ?? - "Sleeping in $remainingMinutes minutes" - : AppLocalizations.of(context)! - .sleepTimerTooltip, - iconColor: timerValue != null - ? iconColor - : Theme.of(context) - .textTheme - .bodyMedium - ?.color ?? - Colors.white, + final playbackBehavior = snapshot.data!; + const playbackOrderIcons = { + FinampPlaybackOrder.linear: TablerIcons.arrows_right, + FinampPlaybackOrder.shuffled: + TablerIcons.arrows_shuffle, + }; + final playbackOrderTooltips = { + FinampPlaybackOrder.linear: + AppLocalizations.of(context) + ?.playbackOrderLinearButtonLabel ?? + "Playing in order", + FinampPlaybackOrder.shuffled: + AppLocalizations.of(context) + ?.playbackOrderShuffledButtonLabel ?? + "Shuffling", + }; + const loopModeIcons = { + FinampLoopMode.none: TablerIcons.repeat, + FinampLoopMode.one: TablerIcons.repeat_once, + FinampLoopMode.all: TablerIcons.repeat, + }; + final loopModeTooltips = { + FinampLoopMode.none: AppLocalizations.of(context) + ?.loopModeNoneButtonLabel ?? + "Looping off", + FinampLoopMode.one: AppLocalizations.of(context) + ?.loopModeOneButtonLabel ?? + "Looping this song", + FinampLoopMode.all: AppLocalizations.of(context) + ?.loopModeAllButtonLabel ?? + "Looping all", + }; + + var sliverArray = [ + PlaybackAction( + icon: playbackOrderIcons[playbackBehavior.order]!, + onPressed: () async { + _queueService.togglePlaybackOrder(); + }, + tooltip: + playbackOrderTooltips[playbackBehavior.order]!, + iconColor: playbackBehavior.order == + FinampPlaybackOrder.shuffled + ? iconColor + : Theme.of(context) + .textTheme + .bodyMedium + ?.color ?? + Colors.white, + ), + ValueListenableBuilder( + valueListenable: _audioHandler.sleepTimer, + builder: (context, timerValue, child) { + final remainingMinutes = + (_audioHandler.sleepTimerRemaining.inSeconds / + 60.0) + .ceil(); + return PlaybackAction( + icon: timerValue != null + ? TablerIcons.hourglass_high + : TablerIcons.hourglass_empty, + onPressed: () async { + if (timerValue != null) { + await showDialog( + context: context, + builder: (context) => + const SleepTimerCancelDialog(), + ); + } else { + await showDialog( + context: context, + builder: (context) => + const SleepTimerDialog(), ); - }, - ), - PlaybackAction( - icon: loopModeIcons[playbackBehavior.loop]!, - onPressed: () async { - _queueService.toggleLoopMode(); - }, - tooltip: - loopModeTooltips[playbackBehavior.loop]!, - iconColor: - playbackBehavior.loop == FinampLoopMode.none - ? Theme.of(context) - .textTheme - .bodyMedium - ?.color ?? - Colors.white - : iconColor, - ), - ], - ); + } + }, + tooltip: timerValue != null + ? AppLocalizations.of(context) + ?.sleepTimerRemainingTime( + remainingMinutes) ?? + "Sleeping in $remainingMinutes minutes" + : AppLocalizations.of(context)! + .sleepTimerTooltip, + iconColor: timerValue != null + ? iconColor + : Theme.of(context) + .textTheme + .bodyMedium + ?.color ?? + Colors.white, + ); + }, + ), + // [Playback speed widget will be added here if conditions are met] + PlaybackAction( + icon: loopModeIcons[playbackBehavior.loop]!, + onPressed: () async { + _queueService.toggleLoopMode(); + }, + tooltip: loopModeTooltips[playbackBehavior.loop]!, + iconColor: + playbackBehavior.loop == FinampLoopMode.none + ? Theme.of(context) + .textTheme + .bodyMedium + ?.color ?? + Colors.white + : iconColor, + ), + ]; + + final speedWidget = PlaybackAction( + icon: TablerIcons.brand_speedtest, + onPressed: () { + toggleSpeedMenu(); }, - )), - SongMenuMask( + tooltip: AppLocalizations.of(context)!.playbackSpeedButtonLabel(playbackBehavior.speed), + iconColor: playbackBehavior.speed == 1.0 + ? Theme.of(context).textTheme.bodyMedium?.color ?? + Colors.white + : iconColor, + ); + + if (speedWidgetWasVisible) { + sliverArray.insertAll(2, [speedWidget]); + return SliverCrossAxisGroup( + slivers: sliverArray, + ); + } + return FutureBuilder( + future: + shouldShowSpeedControls(playbackBehavior.speed, metadata.value), + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.done && + snapshot.data == true) { + speedWidgetWasVisible = true; + sliverArray.insertAll(2, [speedWidget]); + } + return SliverCrossAxisGroup( + slivers: sliverArray, + ); + }); + }, + ), + ), + SliverToBoxAdapter( + child: AnimatedSwitcher( + duration: songMenuDefaultAnimationDuration, + switchInCurve: songMenuDefaultInCurve, + switchOutCurve: songMenuDefaultOutCurve, + transitionBuilder: (child, animation) { + return SizeTransition( + sizeFactor: animation, child: child); + }, + child: showSpeedMenu + ? SpeedMenu(iconColor: iconColor) + : null, + ), + ), + SongMenuMask( child: SliverPadding( padding: const EdgeInsets.only(left: 8.0), sliver: SliverList( delegate: SliverChildListDelegate(menuEntries), ), ), - ) + ), ], ), ], @@ -396,9 +495,9 @@ class _SongMenuState extends ConsumerState { }, ); }), - ]); - }); - } + ]); + }); +} List _menuEntries(BuildContext context, Color iconColor) { final downloadsService = GetIt.instance(); @@ -760,6 +859,7 @@ class _SongMenuState extends ConsumerState { ), ]; } + } class SongMenuSliverAppBar extends SliverPersistentHeaderDelegate { @@ -965,12 +1065,14 @@ class PlaybackAction extends StatelessWidget { const PlaybackAction({ super.key, required this.icon, + this.value, required this.onPressed, required this.tooltip, required this.iconColor, }); final IconData icon; + final String? value; final Function() onPressed; final String tooltip; final Color iconColor; @@ -984,10 +1086,10 @@ class PlaybackAction extends StatelessWidget { Icon( icon, color: iconColor, - size: 32, + size: 35, weight: 1.0, ), - const SizedBox(height: 12), + const SizedBox(height: 9), SizedBox( height: 2 * 12 * 1.4 + 2, child: Align( diff --git a/lib/components/AlbumScreen/speed_menu.dart b/lib/components/AlbumScreen/speed_menu.dart new file mode 100644 index 000000000..1dbb8c346 --- /dev/null +++ b/lib/components/AlbumScreen/speed_menu.dart @@ -0,0 +1,256 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; +import 'package:finamp/services/feedback_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; +import 'package:flutter_vibrate/flutter_vibrate.dart'; +import 'package:get_it/get_it.dart'; + +import 'package:finamp/services/queue_service.dart'; +import '../../services/finamp_settings_helper.dart'; +import 'preset_chip.dart'; + +final _queueService = GetIt.instance(); +final presets = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 3.5]; +const speedSliderMin = 0.35; +const speedSliderMax = 2.50; +const speedSliderStep = 0.05; +const speedButtonStep = 0.10; + +class SpeedSlider extends StatefulWidget { + const SpeedSlider({ + Key? key, + required this.iconColor, + required this.saveSpeedInput, + }) : super(key: key); + + final Color iconColor; + final Function saveSpeedInput; + + @override + State createState() => _SpeedSliderState(); +} + +class _SpeedSliderState extends State { + double? _dragValue; + Timer? _debouncer; + + @override + void dispose() { + _debouncer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SliderTheme( + data: SliderTheme.of(context).copyWith( + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 24 / 2.0), + trackHeight: 24.0, + inactiveTrackColor: widget.iconColor.withOpacity(0.3), + activeTrackColor: + FinampSettingsHelper.finampSettings.playbackSpeed > speedSliderMax + ? widget.iconColor.withOpacity(0.4) + : widget.iconColor.withOpacity(0.6), + showValueIndicator: ShowValueIndicator.always, + valueIndicatorColor: + Color.lerp(Theme.of(context).cardColor, widget.iconColor, 0.6), + valueIndicatorTextStyle: Theme.of(context).textTheme.labelLarge, + valueIndicatorShape: const RectangularSliderValueIndicatorShape(), + tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 1.5), + activeTickMarkColor: widget.iconColor.withOpacity(0.9), + overlayShape: SliderComponentShape.noOverlay, // get rid of padding + ), + child: ExcludeSemantics( + child: Slider( + min: speedSliderMin, + max: speedSliderMax, + value: _dragValue ?? + clampDouble(FinampSettingsHelper.finampSettings.playbackSpeed, + speedSliderMin, speedSliderMax), + // divisions: ((speedSliderMax - speedSliderMin) / speedSliderStep / 2).round(), + onChanged: (value) { + value = ((value / speedSliderStep).round() * speedSliderStep); + if (_dragValue != value) { + setState(() { + _dragValue = value; + }); + FeedbackHelper.feedback(FeedbackType.impact); + _debouncer?.cancel(); + _debouncer = Timer(const Duration(milliseconds: 150), () { + widget.saveSpeedInput(value); + }); + } + }, + onChangeStart: (value) { + value = (value / speedSliderStep).round() * speedSliderStep; + setState(() { + _dragValue = value; + }); + }, + onChangeEnd: (value) { + _dragValue = null; + value = (value / speedSliderStep).round() * speedSliderStep; + widget.saveSpeedInput(value); + FeedbackHelper.feedback(FeedbackType.selection); + }, + label: + (_dragValue ?? FinampSettingsHelper.finampSettings.playbackSpeed) + .toStringAsFixed(2), + ), + ), + ); + } +} + +class SpeedMenu extends StatefulWidget { + const SpeedMenu({ + Key? key, + required this.iconColor, + this.scrollFunction, + }) : super(key: key); + + final Color iconColor; + final Function()? scrollFunction; + + @override + State createState() => _SpeedMenuState(); +} + +class _SpeedMenuState extends State { + final _textController = TextEditingController( + text: FinampSettingsHelper.finampSettings.playbackSpeed.toString()); + final _settingsListener = FinampSettingsHelper.finampSettingsListener; + + InputDecoration inputFieldDecoration() { + return InputDecoration( + filled: true, + fillColor: widget.iconColor.withOpacity(0.1), + contentPadding: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + label: Center(child: Text(AppLocalizations.of(context)!.speed)), + floatingLabelBehavior: FloatingLabelBehavior.never, + constraints: const BoxConstraints( + maxWidth: 82, + maxHeight: 40, + ), + isDense: true, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(16), + ), + ); + } + + void saveSpeedInput(double value) { + final valueDouble = (min(max(value, 0), 5) * 100).roundToDouble() / 100; + + _queueService.playbackSpeed = valueDouble; + setState(() {}); + + refreshInputText(); + } + + void refreshInputText() { + _textController.text = + FinampSettingsHelper.finampSettings.playbackSpeed.toString(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: widget.iconColor.withOpacity(0.1), + ), + margin: EdgeInsets.symmetric(horizontal: 10.0, vertical: 10.0), + child: Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 10.0), + child: ValueListenableBuilder( + valueListenable: _settingsListener, + builder: (BuildContext builder, value, Widget? child) { + _textController.text = + FinampSettingsHelper.finampSettings.playbackSpeed.toString(); + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: PresetChips( + type: PresetTypes.speed, + mainColour: widget.iconColor, + values: presets, + activeValue: + FinampSettingsHelper.finampSettings.playbackSpeed, + )), + Padding( + padding: const EdgeInsets.only( + top: 8.0, left: 12.0, right: 12.0, bottom: 2.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon( + TablerIcons.minus, + color: widget.iconColor, + ), + onPressed: () { + final currentSpeed = FinampSettingsHelper + .finampSettings.playbackSpeed; + + if (currentSpeed > speedSliderMin) { + _queueService.playbackSpeed = max( + speedSliderMin, + double.parse((currentSpeed - speedButtonStep) + .toStringAsFixed(2))); + Vibrate.feedback(FeedbackType.success); + } else { + Vibrate.feedback(FeedbackType.error); + } + }, + visualDensity: VisualDensity.compact, + tooltip: AppLocalizations.of(context)! + .playbackSpeedDecreaseLabel, + ), + Expanded( + child: SpeedSlider( + iconColor: widget.iconColor, + saveSpeedInput: saveSpeedInput, + ), + ), + IconButton( + icon: Icon( + TablerIcons.plus, + color: widget.iconColor, + ), + onPressed: () { + final currentSpeed = FinampSettingsHelper + .finampSettings.playbackSpeed; + + if (currentSpeed < speedSliderMax) { + _queueService.playbackSpeed = min( + speedSliderMax, + double.parse((currentSpeed + speedButtonStep) + .toStringAsFixed(2))); + Vibrate.feedback(FeedbackType.success); + } else { + Vibrate.feedback(FeedbackType.error); + } + }, + visualDensity: VisualDensity.compact, + tooltip: AppLocalizations.of(context)! + .playbackSpeedIncreaseLabel, + ), + ]), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/lib/components/Buttons/simple_button.dart b/lib/components/Buttons/simple_button.dart index a862d8140..6aaba1618 100644 --- a/lib/components/Buttons/simple_button.dart +++ b/lib/components/Buttons/simple_button.dart @@ -1,5 +1,3 @@ - - import 'package:finamp/color_schemes.g.dart'; import 'package:flutter/material.dart'; diff --git a/lib/components/LayoutSettingsScreen/CustomizationSettingsScreen/playback_speed_control_visibility_dropdown_list_tile.dart b/lib/components/LayoutSettingsScreen/CustomizationSettingsScreen/playback_speed_control_visibility_dropdown_list_tile.dart new file mode 100644 index 000000000..10130d804 --- /dev/null +++ b/lib/components/LayoutSettingsScreen/CustomizationSettingsScreen/playback_speed_control_visibility_dropdown_list_tile.dart @@ -0,0 +1,82 @@ +import 'package:finamp/services/metadata_provider.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:hive/hive.dart'; + +import '../../../models/finamp_models.dart'; +import '../../../services/finamp_settings_helper.dart'; + +class PlaybackSpeedControlVisibilityDropdownListTile extends StatelessWidget { + const PlaybackSpeedControlVisibilityDropdownListTile({Key? key}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: FinampSettingsHelper.finampSettingsListener, + builder: (_, box, __) { + return ListTile( + title: + Text(AppLocalizations.of(context)!.playbackSpeedControlSetting), + subtitle: RichText( + text: TextSpan( + children: [ + TextSpan( + text: AppLocalizations.of(context)! + .playbackSpeedControlSettingSubtitle, + style: Theme.of(context).textTheme.bodyMedium, + ), + const TextSpan(text: "\n"), + // tappable "more info" text + TextSpan( + text: AppLocalizations.of(context)!.moreInfo, + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.w500, + ), + recognizer: TapGestureRecognizer() + ..onTap = () { + showGeneralDialog( + context: context, + pageBuilder: (context, anim1, anim2) { + return AlertDialog( + title: Text(AppLocalizations.of(context)! + .playbackSpeedControlSetting), + content: Text(AppLocalizations.of(context)! + .playbackSpeedControlSettingDescription(MetadataProvider.speedControlLongTrackDuration.inMinutes, MetadataProvider.speedControlLongAlbumDuration.inHours, MetadataProvider.speedControlGenres.join(", "))), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: + Text(AppLocalizations.of(context)!.close), + ), + ], + ); + }); + }, + ), + ], + ), + ), + trailing: DropdownButton( + value: box.get("FinampSettings")?.playbackSpeedVisibility, + items: PlaybackSpeedVisibility.values + .map((e) => DropdownMenuItem( + value: e, + child: Text(e.toLocalisedString(context)), + )) + .toList(), + onChanged: (value) { + if (value != null) { + FinampSettingsHelper.setPlaybackSpeedVisibility(value); + } + }, + ), + ); + }, + ); + } +} diff --git a/lib/components/TabsSettingsScreen/hide_tab_toggle.dart b/lib/components/LayoutSettingsScreen/TabsSettingsScreen/hide_tab_toggle.dart similarity index 91% rename from lib/components/TabsSettingsScreen/hide_tab_toggle.dart rename to lib/components/LayoutSettingsScreen/TabsSettingsScreen/hide_tab_toggle.dart index f2c0df1e6..8ddf2e7e4 100644 --- a/lib/components/TabsSettingsScreen/hide_tab_toggle.dart +++ b/lib/components/LayoutSettingsScreen/TabsSettingsScreen/hide_tab_toggle.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; -import '../../services/finamp_settings_helper.dart'; -import '../../models/finamp_models.dart'; +import '../../../services/finamp_settings_helper.dart'; +import '../../../models/finamp_models.dart'; class HideTabToggle extends StatelessWidget { const HideTabToggle({ diff --git a/lib/components/PlayerScreen/feature_chips.dart b/lib/components/PlayerScreen/feature_chips.dart index ffd83f232..afa6d83a3 100644 --- a/lib/components/PlayerScreen/feature_chips.dart +++ b/lib/components/PlayerScreen/feature_chips.dart @@ -40,8 +40,18 @@ class FeatureState { int? get sampleRate => audioStream?.sampleRate; int? get bitDepth => audioStream?.bitDepth; - get features { - final features = []; + List get features { + final queueService = GetIt.instance(); + + final List features = []; + + if (queueService.playbackSpeed != 1.0) { + features.add( + FeatureProperties( + text: AppLocalizations.of(context)!.playbackSpeedFeatureText(queueService.playbackSpeed), + ), + ); + } // TODO this will likely be extremely outdated if offline, hide? if (currentTrack?.baseItem?.userData?.playCount != null) { @@ -149,8 +159,8 @@ class FeatureProperties { class FeatureChips extends ConsumerWidget { const FeatureChips({ - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -194,11 +204,11 @@ class FeatureChips extends ConsumerWidget { class Features extends StatelessWidget { const Features({ - Key? key, + super.key, required this.features, this.backgroundColor, this.color, - }) : super(key: key); + }); final FeatureState features; final Color? backgroundColor; @@ -225,11 +235,11 @@ class Features extends StatelessWidget { class _FeatureContent extends StatelessWidget { const _FeatureContent({ - Key? key, + super.key, required this.feature, required this.backgroundColor, this.color, - }) : super(key: key); + }); final FeatureProperties feature; final Color? backgroundColor; diff --git a/lib/components/PlayerScreen/queue_list.dart b/lib/components/PlayerScreen/queue_list.dart index 3f482d93c..d44f8d7df 100644 --- a/lib/components/PlayerScreen/queue_list.dart +++ b/lib/components/PlayerScreen/queue_list.dart @@ -1037,8 +1037,9 @@ Future setFavourite(FinampQueueItem track, BuildContext context) async { class PlaybackBehaviorInfo { final FinampPlaybackOrder order; final FinampLoopMode loop; + final double speed; - PlaybackBehaviorInfo(this.order, this.loop); + PlaybackBehaviorInfo(this.order, this.loop, this.speed); } class QueueSectionHeader extends StatelessWidget { @@ -1107,10 +1108,11 @@ class QueueSectionHeader extends StatelessWidget { ), if (controls) StreamBuilder( - stream: Rx.combineLatest2( + stream: Rx.combineLatest3( queueService.getPlaybackOrderStream(), queueService.getLoopModeStream(), - (a, b) => PlaybackBehaviorInfo(a, b)), + queueService.getPlaybackSpeedStream(), + (a, b, c) => PlaybackBehaviorInfo(a, b, c)), builder: (context, snapshot) { PlaybackBehaviorInfo? info = snapshot.data; return Row( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2aa1293c3..3088debc7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -458,6 +458,60 @@ "@list": {}, "grid": "Grid", "@grid": {}, + "customizationSettingsTitle": "Customization", + "@customizationSettingsTitle": { + "description": "Title for the customization settings screen" + }, + "playbackSpeedControlSetting": "Playback Speed Visibility", + "@playbackSpeedControlSetting": { + "description": "Title of the visibility setting of the playback speed controls" + }, + "playbackSpeedControlSettingSubtitle": "Whether the playback speed controls are shown in the player screen menu", + "@playbackSpeedControlSettingSubtitle": { + "description": "Subtitle for the playback speed visibility setting" + }, + "playbackSpeedControlSettingDescription": "Automatic:\nFinamp tries to identify whether the track you are playing is a podcast or (part of) an audiobook. This is considered to be the case if the track is longer than {trackDuration} minutes, if the track's album is longer than {albumDuration} hours, or if the track has at least one of these genres assigned: {genreList}\nPlayback speed controls will then be shown in the player screen menu.\n\nShown:\nThe playback speed controls will always be shown in the player screen menu.\n\nHidden:\nThe playback speed controls in the player screen menu are always hidden.", + "@playbackSpeedControlSettingDescription": { + "description": "Description for the dropdown that selects the replay gain mode, shown in a dialog that opens when the user presses the info icon next to the dropdown", + "placeholders": { + "trackDuration": { + "type": "int", + "example": "30" + }, + "albumDuration": { + "type": "int", + "example": "3" + }, + "genreList": { + "type": "String", + "example": "Podcast, Audiobook" + } + } + }, + "automatic": "Automatic", + "@automatic": { + "description": "Used as an option in the playback speed visibility settings" + }, + "shown": "Shown", + "@shown": { + "description": "Used as an option in the playback speed visibility settings" + }, + "hidden": "Hidden", + "@hidden": { + "description": "Used as an option in the playback speed visibility settings" + }, + "speed": "Speed", + "@speed": { + "description": "Used as a placeholder in the input of the playback speed menu" + }, + "reset": "Reset", + "@reset": { + "description": "Used for buttons in the playback speed menu and the settings" + }, + "apply": "Apply", + "@apply": { + "description": "Used for a button in the playback speed menu" + }, "portrait": "Portrait", "@portrait": {}, "landscape": "Landscape", @@ -813,6 +867,32 @@ "@playbackOrderShuffledButtonLabel": { "description": "Label for the button that toggles the playback order between linear/in-order and shuffle, while the queue is in shuffle mode" }, + "playbackSpeedButtonLabel": "Playing at x{speed} speed", + "@playbackSpeedButtonLabel": { + "description": "Label for the button that toggles visibility of the playback speed menu, {speed} is the current playback speed.", + "placeholders": { + "speed": { + "type": "double" + } + } + }, + "playbackSpeedFeatureText": "x{speed} speed", + "@playbackSpeedFeatureText": { + "description": "Label for the feature chip that is shown if the playback speed is different from x1, {speed} is the current playback speed.", + "placeholders": { + "speed": { + "type": "double" + } + } + }, + "playbackSpeedDecreaseLabel": "Decrease playback speed", + "@playbackSpeedDecreaseLabel": { + "description": "Label for the button in the speed menu that decreases the playback speed." + }, + "playbackSpeedIncreaseLabel": "Increase playback speed", + "@playbackSpeedIncreaseLabel": { + "description": "Label for the button in the speed menu that increases the playback speed." + }, "loopModeNoneButtonLabel": "Looping off", "@loopModeNoneButtonLabel": { "description": "Label for the button that toggles the loop mode between off, loop one, and loop all, while the queue is in loop off mode" @@ -936,6 +1016,7 @@ "close": "Close", "showUncensoredLogMessage": "This log contains your login information. Show?", "resetTabs": "Reset tabs", + "resetToDefaults": "Reset to defaults", "noMusicLibrariesTitle": "No Music Libraries", "@noMusicLibrariesTitle": { "description": "Title for message that shows on the views screen when no music libraries could be found." @@ -963,7 +1044,7 @@ "@volumeNormalizationModeSelectorSubtitle": { "description": "Subtitle for the dropdown that selects the replay gain mode" }, - "volumeNormalizationModeSelectorDescription": "Hybrid (Track + Album): Track gain is used for regular playback, but if an album is playing (either because it's the main playback queue source, or because it was added to the queue at some point), the album gain is used instead.\n\nTrack-based: Track gain is always used, regardless of whether an album is playing or not.\n\nAlbums Only: Volume Normalization is only applied while playing albums (using the album gain), but not for individual tracks.", + "volumeNormalizationModeSelectorDescription": "Hybrid (Track + Album):\nTrack gain is used for regular playback, but if an album is playing (either because it's the main playback queue source, or because it was added to the queue at some point), the album gain is used instead.\n\nTrack-based:\nTrack gain is always used, regardless of whether an album is playing or not.\n\nAlbums Only:\nVolume Normalization is only applied while playing albums (using the album gain), but not for individual tracks.", "@volumeNormalizationModeSelectorDescription": { "description": "Description for the dropdown that selects the replay gain mode, shown in a dialog that opens when the user presses the info icon next to the dropdown" }, diff --git a/lib/main.dart b/lib/main.dart index 8001864f5..7f2117646 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,6 +44,7 @@ import 'screens/album_screen.dart'; import 'screens/artist_screen.dart'; import 'screens/audio_service_settings_screen.dart'; import 'screens/downloads_location_screen.dart'; +import 'screens/customization_settings_screen.dart'; import 'screens/downloads_screen.dart'; import 'screens/language_selection_screen.dart'; import 'screens/layout_settings_screen.dart'; @@ -180,6 +181,7 @@ Future setupHive() async { Hive.registerAdapter(SortByAdapter()); Hive.registerAdapter(SortOrderAdapter()); Hive.registerAdapter(ContentViewTypeAdapter()); + Hive.registerAdapter(PlaybackSpeedVisibilityAdapter()); Hive.registerAdapter(DownloadedImageAdapter()); Hive.registerAdapter(ThemeModeAdapter()); Hive.registerAdapter(LocaleAdapter()); @@ -375,6 +377,8 @@ class Finamp extends StatelessWidget { const TabsSettingsScreen(), LayoutSettingsScreen.routeName: (context) => const LayoutSettingsScreen(), + CustomizationSettingsScreen.routeName: (context) => + const CustomizationSettingsScreen(), PlayerSettingsScreen.routeName: (context) => const PlayerSettingsScreen(), LanguageSelectionScreen.routeName: (context) => diff --git a/lib/models/finamp_models.dart b/lib/models/finamp_models.dart index b29993e0f..249a29632 100644 --- a/lib/models/finamp_models.dart +++ b/lib/models/finamp_models.dart @@ -71,6 +71,7 @@ const _volumeNormalizationActiveDefault = true; const _volumeNormalizationIOSBaseGainDefault = -5.0; const _volumeNormalizationModeDefault = VolumeNormalizationMode.hybrid; const _contentViewType = ContentViewType.list; +const _playbackSpeedVisibility = PlaybackSpeedVisibility.automatic; const _contentGridViewCrossAxisCountPortrait = 2; const _contentGridViewCrossAxisCountLandscape = 3; const _showTextOnGridView = true; @@ -85,6 +86,7 @@ const _bufferDurationSeconds = 600; const _tabOrder = TabContentType.values; const _swipeInsertQueueNext = true; const _defaultLoopMode = FinampLoopMode.none; +const _defaultPlaybackSpeed = 1.0; const _autoLoadLastQueueOnStartup = true; const _shouldTranscodeDownloadsDefault = TranscodeDownloadsSetting.never; const _shouldRedownloadTranscodesDefault = false; @@ -117,6 +119,7 @@ class FinampSettings { this.volumeNormalizationIOSBaseGain = _volumeNormalizationIOSBaseGainDefault, this.volumeNormalizationMode = _volumeNormalizationModeDefault, this.contentViewType = _contentViewType, + this.playbackSpeedVisibility = _playbackSpeedVisibility, this.contentGridViewCrossAxisCountPortrait = _contentGridViewCrossAxisCountPortrait, this.contentGridViewCrossAxisCountLandscape = @@ -133,6 +136,7 @@ class FinampSettings { required this.tabSortBy, required this.tabSortOrder, this.loopMode = _defaultLoopMode, + this.playbackSpeed = _defaultPlaybackSpeed, this.tabOrder = _tabOrder, this.autoloadLastQueueOnStartup = _autoLoadLastQueueOnStartup, this.hasCompletedBlurhashImageMigration = true, @@ -339,6 +343,13 @@ class FinampSettings { @HiveField(55, defaultValue: _showArtistChipImage) bool showArtistChipImage; + @HiveField(56, defaultValue: _defaultPlaybackSpeed) + double playbackSpeed; + + /// The content playback speed type defining how and whether to display the playback speed controls in the song menu + @HiveField(57, defaultValue: _playbackSpeedVisibility) + PlaybackSpeedVisibility playbackSpeedVisibility; + static Future create() async { final downloadLocation = await DownloadLocation.create( name: "Internal Storage", @@ -1773,3 +1784,47 @@ class DownloadedLyrics { @ignore LyricDto? _lyricDtoCached; } + +@HiveType(typeId: 67) +enum PlaybackSpeedVisibility { + @HiveField(0) + automatic, + @HiveField(1) + visible, + @HiveField(2) + hidden; + + /// Human-readable version of this enum. I've written longer descriptions on + /// enums like [TabContentType], and I can't be bothered to copy and paste it + /// again. + @override + @Deprecated("Use toLocalisedString when possible") + String toString() => _humanReadableName(this); + + String toLocalisedString(BuildContext context) => + _humanReadableLocalisedName(this, context); + + String _humanReadableName(PlaybackSpeedVisibility playbackSpeedVisibility) { + switch (playbackSpeedVisibility) { + case PlaybackSpeedVisibility.automatic: + return "Automatic"; + case PlaybackSpeedVisibility.visible: + return "On"; + case PlaybackSpeedVisibility.hidden: + return "Off"; + } + } + + String _humanReadableLocalisedName( + PlaybackSpeedVisibility playbackSpeedVisibility, BuildContext context) { + switch (playbackSpeedVisibility) { + case PlaybackSpeedVisibility.automatic: + return AppLocalizations.of(context)!.automatic; + case PlaybackSpeedVisibility.visible: + return AppLocalizations.of(context)!.shown; + case PlaybackSpeedVisibility.hidden: + return AppLocalizations.of(context)!.hidden; + } + } + +} diff --git a/lib/models/finamp_models.g.dart b/lib/models/finamp_models.g.dart index a689ceca8..4017faab2 100644 --- a/lib/models/finamp_models.g.dart +++ b/lib/models/finamp_models.g.dart @@ -86,6 +86,9 @@ class FinampSettingsAdapter extends TypeAdapter { contentViewType: fields[10] == null ? ContentViewType.list : fields[10] as ContentViewType, + playbackSpeedVisibility: fields[57] == null + ? PlaybackSpeedVisibility.automatic + : fields[57] as PlaybackSpeedVisibility, contentGridViewCrossAxisCountPortrait: fields[11] == null ? 2 : fields[11] as int, contentGridViewCrossAxisCountLandscape: @@ -111,6 +114,7 @@ class FinampSettingsAdapter extends TypeAdapter { loopMode: fields[27] == null ? FinampLoopMode.none : fields[27] as FinampLoopMode, + playbackSpeed: fields[56] == null ? 1.0 : fields[56] as double, tabOrder: fields[22] == null ? [ TabContentType.albums, @@ -162,7 +166,7 @@ class FinampSettingsAdapter extends TypeAdapter { @override void write(BinaryWriter writer, FinampSettings obj) { writer - ..writeByte(54) + ..writeByte(56) ..writeByte(0) ..write(obj.isOffline) ..writeByte(1) @@ -270,7 +274,11 @@ class FinampSettingsAdapter extends TypeAdapter { ..writeByte(54) ..write(obj.showArtistsTopSongs) ..writeByte(55) - ..write(obj.showArtistChipImage); + ..write(obj.showArtistChipImage) + ..writeByte(56) + ..write(obj.playbackSpeed) + ..writeByte(57) + ..write(obj.playbackSpeedVisibility); } @override @@ -1531,6 +1539,51 @@ class TranscodeDownloadsSettingAdapter typeId == other.typeId; } +class PlaybackSpeedVisibilityAdapter + extends TypeAdapter { + @override + final int typeId = 67; + + @override + PlaybackSpeedVisibility read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return PlaybackSpeedVisibility.automatic; + case 1: + return PlaybackSpeedVisibility.visible; + case 2: + return PlaybackSpeedVisibility.hidden; + default: + return PlaybackSpeedVisibility.automatic; + } + } + + @override + void write(BinaryWriter writer, PlaybackSpeedVisibility obj) { + switch (obj) { + case PlaybackSpeedVisibility.automatic: + writer.writeByte(0); + break; + case PlaybackSpeedVisibility.visible: + writer.writeByte(1); + break; + case PlaybackSpeedVisibility.hidden: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PlaybackSpeedVisibilityAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + // ************************************************************************** // IsarCollectionGenerator // ************************************************************************** diff --git a/lib/screens/customization_settings_screen.dart b/lib/screens/customization_settings_screen.dart new file mode 100644 index 000000000..1bb3db00b --- /dev/null +++ b/lib/screens/customization_settings_screen.dart @@ -0,0 +1,44 @@ +import 'package:finamp/components/LayoutSettingsScreen/CustomizationSettingsScreen/playback_speed_control_visibility_dropdown_list_tile.dart'; +import 'package:finamp/services/finamp_settings_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../components/LayoutSettingsScreen/TabsSettingsScreen/hide_tab_toggle.dart'; + +class CustomizationSettingsScreen extends StatefulWidget { + const CustomizationSettingsScreen({Key? key}) : super(key: key); + + static const routeName = "/settings/customization"; + + @override + State createState() => _CustomizationSettingsScreenState(); +} + +class _CustomizationSettingsScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.customizationSettingsTitle), + actions: [ + IconButton( + onPressed: () { + setState(() { + FinampSettingsHelper.resetCustomizationSettings(); + }); + }, + icon: const Icon(Icons.refresh), + tooltip: AppLocalizations.of(context)!.resetToDefaults, + ) + ], + ), + body: Scrollbar( + child: ListView( + children: [ + const PlaybackSpeedControlVisibilityDropdownListTile(), + ], + ), + ), + ); + } +} diff --git a/lib/screens/layout_settings_screen.dart b/lib/screens/layout_settings_screen.dart index 4968ba9f9..f159c703e 100644 --- a/lib/screens/layout_settings_screen.dart +++ b/lib/screens/layout_settings_screen.dart @@ -1,7 +1,9 @@ +import 'package:finamp/screens/customization_settings_screen.dart'; import 'package:finamp/components/LayoutSettingsScreen/show_artists_top_songs.dart'; import 'package:finamp/screens/player_settings_screen.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import '../components/LayoutSettingsScreen/content_grid_view_cross_axis_count_list_tile.dart'; import '../components/LayoutSettingsScreen/content_view_type_dropdown_list_tile.dart'; @@ -25,6 +27,12 @@ class LayoutSettingsScreen extends StatelessWidget { ), body: ListView( children: [ + ListTile( + leading: const Icon(TablerIcons.sparkles), + title: Text(AppLocalizations.of(context)!.customizationSettingsTitle), + onTap: () => + Navigator.of(context).pushNamed(CustomizationSettingsScreen.routeName), + ), ListTile( leading: const Icon(Icons.play_circle_outline), title: Text(AppLocalizations.of(context)!.playerScreen), diff --git a/lib/screens/tabs_settings_screen.dart b/lib/screens/tabs_settings_screen.dart index eaaae144d..a0bb1d0b1 100644 --- a/lib/screens/tabs_settings_screen.dart +++ b/lib/screens/tabs_settings_screen.dart @@ -2,7 +2,7 @@ import 'package:finamp/services/finamp_settings_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import '../components/TabsSettingsScreen/hide_tab_toggle.dart'; +import '../components/LayoutSettingsScreen/TabsSettingsScreen/hide_tab_toggle.dart'; class TabsSettingsScreen extends StatefulWidget { const TabsSettingsScreen({Key? key}) : super(key: key); diff --git a/lib/services/current_track_metadata_provider.dart b/lib/services/current_track_metadata_provider.dart index 67a33e43a..a7ef59fbe 100644 --- a/lib/services/current_track_metadata_provider.dart +++ b/lib/services/current_track_metadata_provider.dart @@ -18,7 +18,7 @@ final currentTrackMetadataProvider = BaseItemDto? base = itemToPrecache.baseItem; if (base != null) { // only fetch lyrics for the current track - final request = MetadataRequest(item: base, queueItem: itemToPrecache, includeLyrics: true); + final request = MetadataRequest(item: base, queueItem: itemToPrecache, includeLyrics: true, checkIfSpeedControlNeeded: FinampSettingsHelper.finampSettings.playbackSpeedVisibility == PlaybackSpeedVisibility.automatic); unawaited(ref.watch(metadataProvider(request).future)); } } @@ -29,6 +29,7 @@ final currentTrackMetadataProvider = item: currentTrack!.baseItem!, queueItem: currentTrack, includeLyrics: true, + checkIfSpeedControlNeeded: FinampSettingsHelper.finampSettings.playbackSpeedVisibility == PlaybackSpeedVisibility.automatic, ); return ref.watch(metadataProvider(request)); } diff --git a/lib/services/finamp_settings_helper.dart b/lib/services/finamp_settings_helper.dart index 72132a801..f95268158 100644 --- a/lib/services/finamp_settings_helper.dart +++ b/lib/services/finamp_settings_helper.dart @@ -168,6 +168,14 @@ class FinampSettingsHelper { .put("FinampSettings", finampSettingsTemp); } + static void setPlaybackSpeedVisibility( + PlaybackSpeedVisibility playbackSpeedVisibility) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.playbackSpeedVisibility = playbackSpeedVisibility; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + static void setShowTextOnGridView(bool showTextOnGridView) { FinampSettings finampSettingsTemp = finampSettings; finampSettingsTemp.showTextOnGridView = showTextOnGridView; @@ -257,6 +265,14 @@ class FinampSettingsHelper { .put("FinampSettings", finampSettingsTemp); } + /// Set the playbackSpeed property + static void setPlaybackSpeed(double speed) { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.playbackSpeed = speed; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + static void setHasCompletedBlurhashImageMigrationIdFix( bool hasCompletedBlurhashImageMigrationIdFix) { FinampSettings finampSettingsTemp = finampSettings; @@ -301,6 +317,14 @@ class FinampSettingsHelper { ); } + static void resetCustomizationSettings() { + FinampSettings finampSettingsTemp = finampSettings; + finampSettingsTemp.playbackSpeedVisibility = + PlaybackSpeedVisibility.automatic; + Hive.box("FinampSettings") + .put("FinampSettings", finampSettingsTemp); + } + static void setSwipeInsertQueueNext(bool swipeInsertQueueNext) { FinampSettings finampSettingsTemp = finampSettings; finampSettingsTemp.swipeInsertQueueNext = swipeInsertQueueNext; diff --git a/lib/services/metadata_provider.dart b/lib/services/metadata_provider.dart index dfcf63a56..05e74fc49 100644 --- a/lib/services/metadata_provider.dart +++ b/lib/services/metadata_provider.dart @@ -17,35 +17,44 @@ class MetadataRequest { required this.item, this.queueItem, this.includeLyrics = false, + this.checkIfSpeedControlNeeded = false, }) : super(); final BaseItemDto item; final FinampQueueItem? queueItem; final bool includeLyrics; + final bool checkIfSpeedControlNeeded; @override bool operator ==(Object other) { return other is MetadataRequest && other.includeLyrics == includeLyrics && + other.checkIfSpeedControlNeeded == checkIfSpeedControlNeeded && other.item.id == item.id && other.queueItem?.id == queueItem?.id; } @override - int get hashCode => Object.hash(item.id, queueItem?.id, includeLyrics); + int get hashCode => Object.hash(item.id, queueItem?.id, includeLyrics, checkIfSpeedControlNeeded); } class MetadataProvider { + static const speedControlGenres = ["audiobook", "podcast", "speech"]; + static const speedControlLongTrackDuration = Duration(minutes: 15); + static const speedControlLongAlbumDuration = Duration(hours: 3); + final MediaSourceInfo mediaSourceInfo; LyricDto? lyrics; bool isDownloaded; + bool qualifiesForPlaybackSpeedControl; MetadataProvider({ required this.mediaSourceInfo, this.lyrics, this.isDownloaded = false, + this.qualifiesForPlaybackSpeedControl = false, }); bool get hasLyrics => mediaSourceInfo.mediaStreams.any((e) => e.type == "Lyric"); @@ -135,6 +144,32 @@ final AutoDisposeFutureProviderFamily final metadata = MetadataProvider(mediaSourceInfo: playbackInfo, isDownloaded: localPlaybackInfo != null); + // check if item qualifies for having playback speed control available + if (request.checkIfSpeedControlNeeded) { + + for (final genre in request.item.genres ?? []) { + if (MetadataProvider.speedControlGenres.contains(genre.toLowerCase())) { + metadata.qualifiesForPlaybackSpeedControl = true; + break; + } + } + if (!metadata.qualifiesForPlaybackSpeedControl && metadata.mediaSourceInfo.runTimeTicks! > MetadataProvider.speedControlLongTrackDuration.inMicroseconds * 10) { + // we might want playback speed control for long tracks (like podcasts or audiobook chapters) + metadata.qualifiesForPlaybackSpeedControl = true; + } else { + // check if "album" is long enough to qualify for playback speed control + try { + final parent = await jellyfinApiHelper.getItemById(request.item.parentId!); + if (parent.runTimeTicks! > MetadataProvider.speedControlLongAlbumDuration.inMicroseconds * 10) { + metadata.qualifiesForPlaybackSpeedControl = true; + } + } catch(e) { + metadataProviderLogger.warning("Failed to check if '${request.item.name}' (${request.item.id}) qualifies for playback speed controls", e); + } + } + + } + if (request.includeLyrics && metadata.hasLyrics) { //!!! only use offline metadata if the app is in offline mode diff --git a/lib/services/music_player_background_task.dart b/lib/services/music_player_background_task.dart index 1722dcc5f..d02972407 100644 --- a/lib/services/music_player_background_task.dart +++ b/lib/services/music_player_background_task.dart @@ -172,6 +172,11 @@ class MusicPlayerBackgroundTask extends BaseAudioHandler { return _player.play(); } + @override + Future setSpeed(final double speed) async { + return _player.setSpeed(speed); + } + @override Future pause() => _player.pause(); diff --git a/lib/services/queue_service.dart b/lib/services/queue_service.dart index 5e1449f82..3397d9f21 100644 --- a/lib/services/queue_service.dart +++ b/lib/services/queue_service.dart @@ -50,6 +50,7 @@ class QueueService { FinampPlaybackOrder _playbackOrder = FinampPlaybackOrder.linear; FinampLoopMode _loopMode = FinampLoopMode.none; + double _playbackSpeed = 1.0; final _currentTrackStream = BehaviorSubject.seeded(null); final _queueStream = BehaviorSubject.seeded(null); @@ -58,6 +59,7 @@ class QueueService { BehaviorSubject.seeded(FinampPlaybackOrder.linear); final _loopModeStream = BehaviorSubject.seeded(FinampLoopMode.none); + final _playbackSpeedStream = BehaviorSubject.seeded(1.0); // external queue state @@ -81,6 +83,10 @@ class QueueService { loopMode = finampSettings.loopMode; _queueServiceLogger.info("Restored loop mode to $loopMode from settings"); + playbackSpeed = finampSettings.playbackSpeed; + _queueServiceLogger + .info("Restored playback speed to $playbackSpeed from settings"); + _shuffleOrder = NextUpShuffleOrder(queueService: this); _queueAudioSource = ConcatenatingAudioSource( children: [], @@ -756,6 +762,10 @@ class QueueService { return _loopModeStream; } + BehaviorSubject getPlaybackSpeedStream() { + return _playbackSpeedStream; + } + BehaviorSubject getCurrentTrackStream() { return _currentTrackStream; } @@ -764,6 +774,17 @@ class QueueService { return _currentTrack; } + set playbackSpeed(double speed) { + _playbackSpeed = speed; + _playbackSpeedStream.add(speed); + _audioHandler.setSpeed(speed); + FinampSettingsHelper.setPlaybackSpeed(playbackSpeed); + _queueServiceLogger.fine( + "Playback speed set to ${FinampSettingsHelper.finampSettings.playbackSpeed}"); + } + + double get playbackSpeed => _playbackSpeed; + set loopMode(FinampLoopMode mode) { _loopMode = mode; @@ -1134,4 +1155,4 @@ class NextUpShuffleOrder extends ShuffleOrder { void clear() { indices.clear(); } -} \ No newline at end of file +}