import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:dio/dio.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/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 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; 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); double scrollTo; if (_widgetConstraints == null) { scrollTo = (actualHeight * _currentIndex!) - (MediaQuery.of(context).size.height / 4 + height / 2); } else { final widgetHeight = _widgetConstraints!.maxHeight; final minScroll = actualHeight * _currentIndex!; scrollTo = minScroll - widgetHeight / 2 + 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); 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) { print('fuck? $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) { return Stack( children: [ 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( padding: const EdgeInsets.symmetric( horizontal: 8.0), controller: _controller, itemExtent: !_syncedLyrics ? null : height + (_showTranslation ? additionalTranslationHeight : 0.0), itemCount: _lyrics!.lyrics!.length, itemBuilder: (BuildContext context, int i) { return DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8.0), color: _currentIndex == i ? Colors.grey.withOpacity(0.25) : Colors.transparent, ), child: InkWell( borderRadius: BorderRadius.circular(8.0), onTap: _syncedLyrics && _lyrics!.id != null ? () => audioHandler.seek( _lyrics!.lyrics![i].offset!) : null, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ 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), ), 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) Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.only(bottom: 8.0), child: ElevatedButton( onPressed: () { setState(() => _showTranslation = !_showTranslation); SchedulerBinding.instance .addPostFrameCallback((_) => _scrollToLyric()); }, child: Text(_showTranslation ? 'Without translation'.i18n : 'With translation'.i18n)), )), ], ); } }