import 'dart:async'; import 'dart:collection'; import 'package:audio_service/audio_service.dart'; import 'package:dio/dio.dart'; import 'package:fading_edge_scrollview/fading_edge_scrollview.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/pipe_api.dart'; import 'package:freezer/api/player/audio_handler.dart'; 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 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), ], ))); } } class LyricsWidget extends StatefulWidget { const LyricsWidget({super.key}); @override State createState() => _LyricsWidgetState(); } class _LyricsWidgetState extends State with WidgetsBindingObserver { StreamSubscription? _mediaItemSub; StreamSubscription? _positionSub; int _currentIndex = -1; Duration _nextOffset = Duration.zero; Duration _currentOffset = Duration.zero; String? _currentTrackId; final _controller = AutoScrollController( suggestedRowHeight: height, axis: Axis.vertical, ); static const double height = 82.0; static const double additionalTranslationHeight = 40.0; Lyrics? _lyrics; bool _loading = true; CancelToken? _lyricsCancelToken; Object? _error; bool _freeScroll = false; bool _animatedScroll = false; bool _syncedLyrics = false; bool _showTranslation = false; bool _availableTranslation = false; Future _loadForId(String trackId) async { if (_currentTrackId == trackId) return; _currentTrackId = trackId; print('cancelling req?'); // cancel current request, if applicable _lyricsCancelToken?.cancel(); _currentIndex = -1; _currentOffset = Duration.zero; _nextOffset = Duration.zero; //Fetch setState(() { _freeScroll = false; _loading = true; _lyrics = null; _error = null; }); try { _lyricsCancelToken = CancelToken(); final lyrics = await pipeAPI.lyrics(trackId, cancelToken: _lyricsCancelToken); if (lyrics == null) { setState(() { _error = 'No lyrics available.'; }); return; } _syncedLyrics = lyrics.sync; _availableTranslation = lyrics.lyrics![0].translated != null; if (!_availableTranslation) { _showTranslation = false; } if (!mounted) return; setState(() { _loading = false; _lyrics = lyrics; }); SchedulerBinding.instance.addPostFrameCallback( (_) => _updatePosition(audioHandler.playbackState.value.position)); } on DioException catch (e) { if (e.type != DioExceptionType.cancel) rethrow; } catch (e) { _currentTrackId = null; if (!mounted) return; setState(() { _error = e; }); } finally { _lyricsCancelToken = null; // dispose of cancel token after lyrics are fetched. } } void _scrollToLyric() { if (!_controller.hasClients) return; _animatedScroll = true; _controller .scrollToIndex(_currentIndex + 1, duration: const Duration(milliseconds: 250), preferPosition: AutoScrollPosition.middle) .then((_) => _animatedScroll = false); } void _updatePosition(Duration position) { if (_loading) return; if (!_syncedLyrics) return; if (position < _nextOffset && position > _currentOffset) return; _currentIndex = _lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position) ?? -1; if (_currentIndex < 0) return; if (_currentIndex < _lyrics!.lyrics!.length - 1) { // update nextOffset _nextOffset = _lyrics!.lyrics![_currentIndex + 1].offset!; } else { // dummy position so that the before-hand condition always returns false _nextOffset = const Duration(days: 69); } _currentOffset = _lyrics!.lyrics![_currentIndex].offset!; setState(() => _currentIndex); if (_freeScroll) return; _scrollToLyric(); } void _makeSubscriptions() { if (_mediaItemSub != null || _positionSub != null) return; _positionSub = AudioService.position.listen(_updatePosition); /// Track change = reload new lyrics _mediaItemSub = audioHandler.mediaItem.listen((mediaItem) { if (mediaItem == null) return; if (_controller.hasClients) _controller.jumpTo(0.0); _loadForId(mediaItem.id); }); } void _cancelSubscriptions() { _mediaItemSub?.cancel(); _positionSub?.cancel(); _mediaItemSub = null; _positionSub = null; } @override void initState() { SchedulerBinding.instance.addPostFrameCallback((_) { //Enable visualizer // if (settings.lyricsVisualizer) playerHelper.startVisualizer(); _makeSubscriptions(); }); WidgetsBinding.instance.addObserver(this); super.initState(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.paused: _cancelSubscriptions(); break; case AppLifecycleState.resumed: _makeSubscriptions(); break; default: break; } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _cancelSubscriptions(); //Stop visualizer // if (settings.lyricsVisualizer) playerHelper.stopVisualizer(); super.dispose(); } ScrollBehavior get _scrollBehavior { if (_freeScroll) { return ScrollConfiguration.of(context); } 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: lyricsStyle.getTextStyle( isActive, Color.lerp(textColor, Colors.black, 0.25)! .withOpacity(isActive ? 1.0 : 0.25), 20.0)), ], ), ))); } @override Widget build(BuildContext context) { final lyricsStyle = LyricsStyle.fromIndex(settings.lyricsStyle); var extentBefore = 0.0; return Stack( children: [ _error != null ? ErrorScreen(message: _error.toString()) : // Loading lyrics _loading ? const Center(child: CircularProgressIndicator()) : LayoutBuilder(builder: (context, constraints) { return NotificationListener( onNotification: (notification) { if (!_syncedLyrics) return false; // avoid accidental clicks final extentDelta = (notification.metrics.pixels - extentBefore) .abs(); extentBefore = notification.metrics.pixels; const extentThreshold = 20.0; if (!_animatedScroll && !_freeScroll && !_loading && extentDelta >= extentThreshold) { setState(() => _freeScroll = true); } return false; }, child: ScrollConfiguration( behavior: _scrollBehavior, child: _syncedLyrics ? FadingEdgeScrollView.fromScrollView( gradientFractionOnStart: 0.25, gradientFractionOnEnd: 0.25, child: ListView.builder( padding: EdgeInsets.symmetric( horizontal: 8.0, vertical: !_syncedLyrics ? 4.0 : constraints.maxHeight / 2 - height / 2), controller: _controller, itemCount: _lyrics!.lyrics!.length + 1, itemBuilder: (BuildContext context, int i) { return AutoScrollTag( key: ValueKey(i), controller: _controller, index: i, child: _buildLyricWidget( i, lyricsStyle)); }, )) : FadingEdgeScrollView .fromSingleChildScrollView( gradientFractionOnStart: 0.25, gradientFractionOnEnd: 0.25, child: SingleChildScrollView( controller: _controller, child: Padding( padding: const EdgeInsets.all(8.0), child: Text( _lyrics!.lyrics![0].text!, style: TextStyle(fontSize: 20.0), ), ))))); }), if (_availableTranslation) Positioned( bottom: 16.0, left: 0, right: 0, child: Center( child: ElevatedButton( onPressed: () { setState(() => _showTranslation = !_showTranslation); SchedulerBinding.instance .addPostFrameCallback((_) => _scrollToLyric()); }, child: Text(_showTranslation ? 'Without translation'.i18n : 'With translation'.i18n)), ), ), if (_freeScroll) Positioned( bottom: 16.0, right: 16.0, child: FloatingActionButton( child: const Icon(Icons.sync), onPressed: () => setState(() { _freeScroll = false; _scrollToLyric(); }))) ], ); } }