diff --git a/lib/settings.dart b/lib/settings.dart index a923b08..702f6fb 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -178,6 +178,9 @@ class Settings { @HiveField(50, defaultValue: false) bool useColorTrayIcon = false; + @HiveField(51, defaultValue: 1) + int lyricsStyle = 1; + static LazyBox? __box; static Future> get _box async => __box ??= await Hive.openLazyBox('settings'); diff --git a/lib/ui/lyrics_screen.dart b/lib/ui/lyrics_screen.dart index 40561d6..e4d05c0 100644 --- a/lib/ui/lyrics_screen.dart +++ b/lib/ui/lyrics_screen.dart @@ -13,30 +13,34 @@ import 'package:freezer/api/player/player_helper.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:freezer/ui/error.dart'; +import 'package:freezer/ui/lyrics_styles/classic.dart'; +import 'package:freezer/ui/lyrics_styles/lyrics_style.dart'; import 'package:freezer/ui/player_bar.dart'; import 'package:freezer/ui/player_screen.dart'; import 'package:mini_music_visualizer/mini_music_visualizer.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; class LyricsScreen extends StatelessWidget { const LyricsScreen({super.key}); @override Widget build(BuildContext context) { - return PlayerScreenBackground( - enabled: settings.playerBackgroundOnLyrics, - appBar: AppBar( - systemOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle( - context, - enabled: settings.playerBackgroundOnLyrics), - forceMaterialTransparency: true, - ), - child: const Column( - children: [ - Expanded(child: LyricsWidget()), - Divider(height: 1.0, thickness: 1.0), - PlayerBar(backgroundColor: Colors.transparent), - ], - )); + return PlayerArtColorScheme( + child: PlayerScreenBackground( + enabled: settings.playerBackgroundOnLyrics, + appBar: AppBar( + systemOverlayStyle: + PlayerScreenBackground.getSystemUiOverlayStyle(context, + enabled: settings.playerBackgroundOnLyrics), + forceMaterialTransparency: true, + ), + child: const Column( + children: [ + Expanded(child: LyricsWidget()), + Divider(height: 1.0, thickness: 1.0), + PlayerBar(backgroundColor: Colors.transparent), + ], + ))); } } @@ -55,10 +59,12 @@ class _LyricsWidgetState extends State Duration _nextOffset = Duration.zero; Duration _currentOffset = Duration.zero; String? _currentTrackId; - final ScrollController _controller = ScrollController(); - static const double height = 110.0; + final _controller = AutoScrollController( + suggestedRowHeight: height, + axis: Axis.vertical, + ); + static const double height = 82.0; static const double additionalTranslationHeight = 40.0; - BoxConstraints? _widgetConstraints; Lyrics? _lyrics; bool _loading = true; CancelToken? _lyricsCancelToken; @@ -71,9 +77,6 @@ class _LyricsWidgetState extends State bool _showTranslation = false; bool _availableTranslation = false; - // each individual lyric widget's height, either computed or cached - final _lyricHeights = HashMap(); - Future _loadForId(String trackId) async { if (_currentTrackId == trackId) return; _currentTrackId = trackId; @@ -133,20 +136,12 @@ class _LyricsWidgetState extends State void _scrollToLyric() { if (!_controller.hasClients) return; - //Lyric height, screen height, appbar height - final actualHeight = - height + (_showTranslation ? additionalTranslationHeight : 0.0); - // _currentIndex + 1 because there's also the initial one (though we need to sum half) - var scrollTo = actualHeight * (_currentIndex + 1); - if (scrollTo < 0.0) scrollTo = 0.0; - if (scrollTo > _controller.position.maxScrollExtent) { - scrollTo = _controller.position.maxScrollExtent; - } _animatedScroll = true; _controller - .animateTo(scrollTo, - duration: const Duration(milliseconds: 250), curve: Curves.ease) + .scrollToIndex(_currentIndex + 1, + duration: const Duration(milliseconds: 250), + preferPosition: AutoScrollPosition.middle) .then((_) => _animatedScroll = false); } @@ -236,14 +231,71 @@ class _LyricsWidgetState extends State return ScrollConfiguration.of(context).copyWith(scrollbars: false); } + Color get activeTextColor => Theme.of(context).colorScheme.onBackground; + Color get inactiveTextColor => activeTextColor.withOpacity(0.25); + + Widget _buildLyricWidget(int i, LyricsStyle lyricsStyle) { + final isActive = _currentIndex == --i; + final textColor = + isActive || _freeScroll ? activeTextColor : inactiveTextColor; + if (i == -1) { + return SizedBox( + height: height, + child: Center( + child: SizedBox( + width: 8.0 * 3 + 6.0, + child: StreamBuilder( + initialData: playerHelper.playing.valueOrNull, + stream: playerHelper.playing, + builder: (context, snapshot) { + return MiniMusicVisualizer( + color: textColor, + width: 8.0, + height: 16.0, + animate: (snapshot.data ?? false) && isActive, + ); + }), + ))); + } + return DecoratedBox( + decoration: lyricsStyle.getBoxDecoration(isActive), + child: InkWell( + borderRadius: BorderRadius.circular(12.0), + onTap: _syncedLyrics && _lyrics!.id != null + ? () => audioHandler.seek(_lyrics!.lyrics![i].offset!) + : null, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 28.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(_lyrics!.lyrics![i].text!, + textAlign: !_syncedLyrics + ? TextAlign.start + : lyricsStyle.getTextAlignment(), + style: lyricsStyle.getTextStyle( + isActive, textColor, _syncedLyrics ? 26.0 : 20.0)), + if (_showTranslation) + Text(_lyrics!.lyrics![i].translated!, + textAlign: TextAlign.start, + style: TextStyle( + color: Color.lerp( + Theme.of(context).colorScheme.onBackground, + Colors.black, + 0.12), + fontSize: 20.0)), + ], + ), + ))); + } + @override Widget build(BuildContext context) { - final textColor = - settings.playerBackgroundOnLyrics && settings.blurPlayerBackground - ? Theme.of(context).brightness == Brightness.light - ? Colors.black87 - : Colors.white70 - : Theme.of(context).colorScheme.onBackground; + final lyricsStyle = LyricsStyle.fromIndex(settings.lyricsStyle); return Stack( children: [ _error != null @@ -253,17 +305,17 @@ class _LyricsWidgetState extends State _loading ? const Center(child: CircularProgressIndicator()) : LayoutBuilder(builder: (context, constraints) { - _widgetConstraints = constraints; return NotificationListener( onNotification: (notification) { if (!_syncedLyrics) return false; - final extentDiff = + final extentDelta = (notification.metrics.extentBefore - notification.metrics.extentAfter) .abs(); // avoid accidental clicks - const extentThreshold = 10.0; - if (extentDiff >= extentThreshold && + const extentThreshold = 9000.0; + print('delta: $extentDelta'); + if (extentDelta >= extentThreshold && !_animatedScroll && !_loading && !_freeScroll) { @@ -282,98 +334,14 @@ class _LyricsWidgetState extends State vertical: constraints.maxHeight / 2 - height / 2), controller: _controller, - itemExtent: !_syncedLyrics - ? null - : height + - (_showTranslation - ? additionalTranslationHeight - : 0.0), itemCount: _lyrics!.lyrics!.length + 1, itemBuilder: (BuildContext context, int i) { - if (i-- == 0) { - return SizedBox( - height: height, - child: Center( - child: SizedBox( - width: 8.0 * 3 + 6.0, - child: StreamBuilder( - initialData: playerHelper - .playing.valueOrNull, - stream: playerHelper.playing, - builder: (context, snapshot) { - return MiniMusicVisualizer( - color: textColor, - width: 8.0, - height: 16.0, - animate: (snapshot.data ?? - false) && - _currentIndex == -1, - ); - }), - ))); - } - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(8.0), - color: _currentIndex == i - ? Colors.grey.withOpacity(0.25) - : Colors.transparent, - ), - child: InkWell( - borderRadius: - BorderRadius.circular(12.0), - onTap: _syncedLyrics && - _lyrics!.id != null - ? () => audioHandler.seek( - _lyrics!.lyrics![i].offset!) - : null, - child: Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 4.0, - //vertical: 24.0, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Text( - _lyrics!.lyrics![i].text!, - textAlign: _syncedLyrics - ? TextAlign.center - : TextAlign.start, - style: TextStyle( - color: textColor, - fontSize: _syncedLyrics - ? 26.0 - : 20.0, - fontWeight: - (_currentIndex == i) - ? FontWeight - .bold - : FontWeight - .normal), - ), - if (_showTranslation) - Text( - _lyrics!.lyrics![i] - .translated!, - textAlign: - TextAlign.center, - style: TextStyle( - color: Color.lerp( - Theme.of( - context) - .colorScheme - .onBackground, - Colors.black, - 0.12), - fontSize: 20.0)), - ], - ), - ))); + return AutoScrollTag( + key: ValueKey(i), + controller: _controller, + index: i, + child: + _buildLyricWidget(i, lyricsStyle)); }, )))); }), diff --git a/lib/ui/lyrics_styles/classic.dart b/lib/ui/lyrics_styles/classic.dart new file mode 100644 index 0000000..0857faa --- /dev/null +++ b/lib/ui/lyrics_styles/classic.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:freezer/ui/lyrics_styles/lyrics_style.dart'; + +class ClassicLyricsStyle implements LyricsStyle { + @override + TextAlign getTextAlignment() => TextAlign.center; + + @override + TextStyle getTextStyle(bool isActive, Color textColor, double textSize) => + TextStyle( + fontWeight: FontWeight.bold, color: textColor, fontSize: textSize); + + @override + BoxDecoration getBoxDecoration(bool isActive) => BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + color: isActive ? Colors.grey.withOpacity(0.25) : Colors.transparent); +} diff --git a/lib/ui/lyrics_styles/lyrics_style.dart b/lib/ui/lyrics_styles/lyrics_style.dart new file mode 100644 index 0000000..166d70e --- /dev/null +++ b/lib/ui/lyrics_styles/lyrics_style.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:freezer/ui/lyrics_styles/classic.dart'; +import 'package:freezer/ui/lyrics_styles/modern.dart'; + +class LyricsStyle { + static const classic = 0; + static const modern = 1; + + LyricsStyle._(); + + factory LyricsStyle.fromIndex(int i) { + return switch (i) { + classic => ClassicLyricsStyle(), + modern => ModernLyricsStyle(), + _ => LyricsStyle._(), + }; + } + + TextAlign getTextAlignment() => + throw Exception('getTextAlignment() has not been implemented'); + + TextStyle getTextStyle(bool isActive, Color textColor, double textSize) => + throw Exception('getTextStyle() has not been implemented'); + + BoxDecoration getBoxDecoration(bool isActive) => + throw Exception('getBoxDecoration() has not been implemented'); +} diff --git a/lib/ui/lyrics_styles/modern.dart b/lib/ui/lyrics_styles/modern.dart new file mode 100644 index 0000000..67f9335 --- /dev/null +++ b/lib/ui/lyrics_styles/modern.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:freezer/ui/lyrics_styles/lyrics_style.dart'; + +class ModernLyricsStyle implements LyricsStyle { + @override + TextAlign getTextAlignment() => TextAlign.start; + + @override + TextStyle getTextStyle(bool isActive, Color textColor, double textSize) => + TextStyle( + fontWeight: FontWeight.bold, color: textColor, fontSize: textSize); + + @override + BoxDecoration getBoxDecoration(bool isActive) => const BoxDecoration(); +} diff --git a/lib/ui/player_screen.dart b/lib/ui/player_screen.dart index ac874c4..b810dca 100644 --- a/lib/ui/player_screen.dart +++ b/lib/ui/player_screen.dart @@ -197,7 +197,7 @@ class PlayerScreenBackground extends StatelessWidget { if (provider.dominantColor != null) Color.lerp(provider.dominantColor, isLightMode ? Colors.white : Colors.black, 0.5)!, - Theme.of(context).scaffoldBackgroundColor, + Theme.of(context).colorScheme.background, ], stops: const [0.0, 0.6], )), @@ -886,6 +886,8 @@ class BigAlbumArt extends StatefulWidget { } class _BigAlbumArtState extends State with WidgetsBindingObserver { + static final _logger = Logger('_BigAlbumArtState'); + final _pageController = PageController( initialPage: playerHelper.queueIndex, keepPage: false, @@ -893,13 +895,13 @@ class _BigAlbumArtState extends State with WidgetsBindingObserver { ); StreamSubscription? _currentItemSub; - /// is true on pointer down event - /// used to distinguish between [PageController.animateToPage] and user gesture + /// Always true, except when [PageController.animateToPage] is called programmatically + /// + /// Gets reset to true when [onPageChanged] is called again bool _userScroll = true; - /// whether the user has already scrolled the [PageView], - /// so to avoid calling [PageController.animateToPage] again. - bool _initiatedByUser = false; + /// true when the [PageController.animateToPage] [Future] hasn't completed yet. + bool _isAnimationRunning = false; void _listenForMediaItemChanges() { if (_currentItemSub != null) return; @@ -909,21 +911,32 @@ class _BigAlbumArtState extends State with WidgetsBindingObserver { } _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'); + if (((_pageController.page?.toInt() ?? 0) - playerHelper.queueIndex) + .abs() > + 1) { + _userScroll = false; + _logger.fine('jumping to page (difference > 1)'); + _pageController.jumpToPage(playerHelper.queueIndex); + return; + } + + _logger.fine('animating controller to page'); _userScroll = false; + _isAnimationRunning = true; await _pageController.animateToPage(playerHelper.queueIndex, - duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); - _userScroll = true; + duration: const Duration(milliseconds: 300), curve: Curves.ease); + _isAnimationRunning = false; }); } + void _cancelMediaItemSubscription() { + _currentItemSub?.cancel(); + _currentItemSub = null; + } + @override void initState() { _listenForMediaItemChanges(); @@ -934,7 +947,7 @@ class _BigAlbumArtState extends State with WidgetsBindingObserver { void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.paused: - _currentItemSub?.cancel(); + _cancelMediaItemSubscription(); case AppLifecycleState.resumed: _listenForMediaItemChanges(); default: @@ -971,6 +984,7 @@ class _BigAlbumArtState extends State with WidgetsBindingObserver { onTap: () => Navigator.push( context, FadePageRoute( + blur: true, barrierDismissible: true, opaque: false, builder: (context) { @@ -994,9 +1008,13 @@ class _BigAlbumArtState extends State with WidgetsBindingObserver { return PageView.builder( controller: _pageController, onPageChanged: (int index) { + _logger.finest('onPageChanged()'); // ignore if not initiated by user. - if (!_userScroll) return; - Logger('BigAlbumArt') + if (_isAnimationRunning || !_userScroll) { + _userScroll = true; + return; + } + _logger .fine('page changed, skipping to media item'); if (queue[index].id == audioHandler.mediaItem.value?.id) { @@ -1020,6 +1038,16 @@ class _BigAlbumArtState extends State with WidgetsBindingObserver { ), )); }), + if (kDebugMode) ...[ + TextButton( + onPressed: _cancelMediaItemSubscription, + child: const Text('Unsubscribe')), + Positioned( + right: 0, + child: TextButton( + onPressed: _listenForMediaItemChanges, + child: const Text('Subscribe'))), + ], if (widget.showLyricsButton) StreamBuilder( initialData: audioHandler.mediaItem.valueOrNull, @@ -1171,7 +1199,6 @@ class PlayerScreenTopRow extends StatelessWidget { .copyWith(fontSize: textSize ?? 38.sp))); }), ), - PlayerMenuButtonDesktop(size: size), desktopMode ? PlayerMenuButtonDesktop(size: size) : PlayerMenuButton(size: size) diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index a27648a..fe83cff 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -16,6 +16,7 @@ import 'package:freezer/api/player/systray.dart'; import 'package:freezer/icons.dart'; import 'package:freezer/ui/login_on_other_device.dart'; import 'package:freezer/ui/login_screen.dart'; +import 'package:freezer/ui/lyrics_styles/lyrics_style.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:scrobblenaut/scrobblenaut.dart'; @@ -292,6 +293,34 @@ class _AppearanceSettingsState extends State { settings.save(); } : null), + ListTile( + title: Text('Lyrics style'.i18n), + subtitle: Text( + 'Style of the transition between screens within the app'.i18n), + leading: const Icon(Icons.auto_awesome_motion), + onTap: () => showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: Text('Select lyrics style'.i18n), + children: [ + SimpleDialogOption( + child: const Text('Modern (default)'), + onPressed: () => + Navigator.pop(context, LyricsStyle.modern), + ), + SimpleDialogOption( + child: const Text('Classic'), + onPressed: () => + Navigator.pop(context, LyricsStyle.classic), + ), + ]); + }).then((value) { + if (value == null) return; + settings.lyricsStyle = value; + settings.save(); + }), + ), ListTile( title: const Text('Screens style'), subtitle: const Text( diff --git a/pubspec.lock b/pubspec.lock index b11365b..b744244 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1305,6 +1305,14 @@ packages: url: "https://github.com/Pato05/Scrobblenaut.git" source: git version: "3.0.0" + scroll_to_index: + dependency: "direct main" + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" share_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7eefa74..043abe0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -100,6 +100,7 @@ dependencies: mini_music_visualizer: git: https://github.com/Pato05/mini_music_visualizer.git fading_edge_scrollview: ^3.0.0 + scroll_to_index: ^3.0.1 #deezcryptor: #path: deezcryptor/