import 'dart:async'; import 'package:async/async.dart'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/player/audio_handler.dart'; import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:freezer/ui/error.dart'; import 'package:freezer/ui/player_bar.dart'; import 'package:freezer/ui/player_screen.dart'; class LyricsScreen extends StatelessWidget { const LyricsScreen({super.key}); @override Widget build(BuildContext context) { return PlayerScreenBackground( enabled: settings.playerBackgroundOnLyrics, appBar: AppBar( title: Text('Lyrics'.i18n), systemOverlayStyle: PlayerScreenBackground.getSystemUiOverlayStyle( context, enabled: settings.playerBackgroundOnLyrics), backgroundColor: Colors.transparent, ), child: const Column( children: [ Expanded(child: LyricsWidget()), Divider(height: 1.0, thickness: 1.0), PlayerBar(backgroundColor: Colors.transparent), ], )); } } class LyricsWidget extends StatefulWidget { const LyricsWidget({Key? key}) : super(key: key); @override State createState() => _LyricsWidgetState(); } class _LyricsWidgetState extends State { late StreamSubscription _mediaItemSub; late StreamSubscription _playbackStateSub; int? _currentIndex = -1; int? _prevIndex = -1; final ScrollController _controller = ScrollController(); final double height = 90; BoxConstraints? _widgetConstraints; Lyrics? _lyrics; bool _loading = true; CancelableOperation? _lyricsCancelable; Object? _error; bool _freeScroll = false; bool _animatedScroll = false; bool _syncedLyrics = false; Future _loadForId(String trackId) async { // cancel current request, if applicable await _lyricsCancelable?.cancel(); //Fetch if (_loading == false && _lyrics != null) { setState(() { _freeScroll = false; _loading = true; _lyrics = null; }); } try { _lyricsCancelable = CancelableOperation.fromFuture(deezerAPI.lyrics(trackId)); final lyrics = await _lyricsCancelable!.valueOrCancellation(null); if (lyrics == null) return; _syncedLyrics = lyrics.sync; if (!mounted) return; setState(() { _loading = false; _lyrics = lyrics; }); SchedulerBinding.instance.addPostFrameCallback( (_) => _updatePosition(audioHandler.playbackState.value.position)); } catch (e) { if (!mounted) return; setState(() { _error = e; }); } } Future _scrollToLyric() async { if (!_controller.hasClients) return; //Lyric height, screen height, appbar height double scrollTo; if (_widgetConstraints == null) { scrollTo = (height * _currentIndex!) - (MediaQuery.of(context).size.height / 4 + height / 2); } else { final widgetHeight = _widgetConstraints!.maxHeight; final minScroll = height * _currentIndex!; scrollTo = minScroll - widgetHeight / 2 + height / 2; } print( '${height * _currentIndex!}, ${MediaQuery.of(context).size.height / 2}'); if (0 > scrollTo) return; if (scrollTo > _controller.position.maxScrollExtent) { scrollTo = _controller.position.maxScrollExtent; } _animatedScroll = true; await _controller.animateTo(scrollTo, duration: const Duration(milliseconds: 250), curve: Curves.ease); _animatedScroll = false; } void _updatePosition(Duration position) { if (_loading) return; if (!_syncedLyrics) return; _currentIndex = _lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position); //Scroll to current lyric if (_currentIndex! < 0) return; if (_prevIndex == _currentIndex) return; //Update current lyric index setState(() {}); _prevIndex = _currentIndex; if (_freeScroll) return; _scrollToLyric(); } @override void initState() { SchedulerBinding.instance.addPostFrameCallback((_) { //Enable visualizer // if (settings.lyricsVisualizer) playerHelper.startVisualizer(); _playbackStateSub = AudioService.position.listen(_updatePosition); }); if (audioHandler.mediaItem.value != null) { _loadForId(audioHandler.mediaItem.value!.id); } /// Track change = ~exit~ reload lyrics _mediaItemSub = audioHandler.mediaItem.listen((mediaItem) { if (mediaItem == null) return; if (_controller.hasClients) _controller.jumpTo(0.0); _loadForId(mediaItem.id); }); super.initState(); } @override void dispose() { _mediaItemSub.cancel(); _playbackStateSub.cancel(); //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); } @override Widget build(BuildContext context) { return Column(children: [ if (_freeScroll && !_loading) Center( child: TextButton( onPressed: () { setState(() => _freeScroll = false); _scrollToLyric(); }, style: ButtonStyle( foregroundColor: MaterialStateProperty.all(Colors.white)), child: Text( _currentIndex! >= 0 ? (_lyrics?.lyrics?[_currentIndex!].text ?? '...') : '...', textAlign: TextAlign.center, )), ), Expanded( child: _error != null ? //Shouldn't really happen, empty lyrics have own text ErrorScreen(message: _error.toString()) : // Loading lyrics _loading ? const Center(child: CircularProgressIndicator()) : LayoutBuilder(builder: (context, constraints) { _widgetConstraints = constraints; return NotificationListener( onNotification: (ScrollStartNotification notification) { if (!_syncedLyrics) return false; final extentDiff = (notification.metrics.extentBefore - notification.metrics.extentAfter) .abs(); // avoid accidental clicks const extentThreshold = 10.0; if (extentDiff >= extentThreshold && !_animatedScroll && !_loading && !_freeScroll) { setState(() => _freeScroll = true); } return false; }, child: ScrollConfiguration( behavior: _scrollBehavior, child: ListView.builder( controller: _controller, itemCount: _lyrics!.lyrics!.length, itemBuilder: (BuildContext context, int i) { return Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.0), color: _currentIndex == i ? Colors.grey.withOpacity(0.25) : Colors.transparent, ), height: _syncedLyrics ? height : null, child: InkWell( borderRadius: BorderRadius.circular(8.0), onTap: _syncedLyrics && _lyrics!.id != null ? () => audioHandler.seek( _lyrics!.lyrics![i].offset!) : null, child: Center( child: Padding( padding: _currentIndex == i ? EdgeInsets.zero : const EdgeInsets .symmetric( horizontal: 1.0), child: Text( _lyrics!.lyrics![i].text!, textAlign: _syncedLyrics ? TextAlign.center : TextAlign.start, style: TextStyle( fontSize: _syncedLyrics ? 26.0 : 20.0, fontWeight: (_currentIndex == i) ? FontWeight.bold : FontWeight .normal), ), ), )))); }, ))); }), ), ]); } }