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/player_bar.dart'; import 'package:freezer/ui/player_screen.dart'; import 'package:mini_music_visualizer/mini_music_visualizer.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), ], )); } } 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 ScrollController _controller = ScrollController(); static const double height = 110.0; static const double additionalTranslationHeight = 40.0; BoxConstraints? _widgetConstraints; Lyrics? _lyrics; bool _loading = true; CancelToken? _lyricsCancelToken; Object? _error; bool _freeScroll = false; bool _animatedScroll = false; bool _syncedLyrics = false; 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; 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; //Lyric height, screen height, appbar height final actualHeight = height + (_showTranslation ? additionalTranslationHeight : 0.0); // _currentIndex + 1 because there's also the initial one final minScroll = actualHeight * (_currentIndex + 1); double scrollTo = minScroll + height / 2; 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) .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); } @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; return Stack( children: [ _error != null ? ErrorScreen(message: _error.toString()) : // Loading lyrics _loading ? const Center(child: CircularProgressIndicator()) : LayoutBuilder(builder: (context, constraints) { _widgetConstraints = constraints; return NotificationListener( onNotification: (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: FadingEdgeScrollView.fromScrollView( gradientFractionOnStart: 0.25, gradientFractionOnEnd: 0.25, child: ListView.builder( padding: EdgeInsets.symmetric( horizontal: 8.0, 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)), ], ), ))); }, )))); }), 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(); }))) ], ); } }