import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:gap/gap.dart'; import 'package:get/get.dart'; import 'package:rhythm_box/providers/audio_player.dart'; import 'package:rhythm_box/services/audio_player/audio_player.dart'; import 'package:rhythm_box/services/lyrics/model.dart'; import 'package:rhythm_box/services/lyrics/provider.dart'; import 'package:rhythm_box/widgets/sized_container.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; class SyncedLyrics extends StatefulWidget { final int defaultTextZoom; const SyncedLyrics({ super.key, required this.defaultTextZoom, }); @override State createState() => _SyncedLyricsState(); } class _SyncedLyricsState extends State { late final AudioPlayerProvider _playback = Get.find(); late final SyncedLyricsProvider _syncedLyrics = Get.find(); final AutoScrollController _autoScrollController = AutoScrollController(); late final int _textZoomLevel = widget.defaultTextZoom; SubtitleSimple? _lyric; String? _activeTrackId; bool get _isLyricSynced => _lyric == null ? false : _lyric!.lyrics.any((x) => x.time.inSeconds > 0); Future _pullLyrics() async { if (_playback.state.value.activeTrack == null) return; _activeTrackId = _playback.state.value.activeTrack!.id; final out = await _syncedLyrics.fetch(_playback.state.value.activeTrack!); setState(() => _lyric = out); } List? _subscriptions; Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withOpacity(0.5); void _syncLyricsProgress() { if (_isLyricSynced) { for (var idx = 0; idx < _lyric!.lyrics.length; idx++) { final lyricSlice = _lyric!.lyrics[idx]; final lyricNextSlice = idx + 1 < _lyric!.lyrics.length ? _lyric!.lyrics[idx + 1] : null; final isActive = _playback.durationCurrent.value.inSeconds >= lyricSlice.time.inSeconds && (lyricNextSlice == null || lyricNextSlice.time.inSeconds > _playback.durationCurrent.value.inSeconds); if (isActive) { _autoScrollController.scrollToIndex( idx, preferPosition: AutoScrollPosition.middle, ); return; } } } if (_lyric!.lyrics.isNotEmpty || !_isLyricSynced) { _autoScrollController.scrollToIndex( 0, preferPosition: AutoScrollPosition.begin, ); } } @override void initState() { super.initState(); _pullLyrics().then((_) { _syncLyricsProgress(); }); _subscriptions = [ _playback.state.listen((value) { if (value.activeTrack == null) return; if (value.activeTrack!.id != _activeTrackId) { _pullLyrics().then((_) { _syncLyricsProgress(); }); } }), ]; } @override void dispose() { _autoScrollController.dispose(); if (_subscriptions != null) { for (final subscription in _subscriptions!) { subscription.cancel(); } } super.dispose(); } @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; return CustomScrollView( cacheExtent: 10000, controller: _autoScrollController, slivers: [ const SliverGap(16), if (_lyric == null) const SliverFillRemaining( child: Center( child: CircularProgressIndicator(), ), ), if (_lyric != null && _lyric!.lyrics.isNotEmpty && !_isLyricSynced) SliverToBoxAdapter( child: Text( 'Lyrics isn\'t synced', textAlign: MediaQuery.of(context).size.width >= 720 ? TextAlign.center : TextAlign.left, ).paddingSymmetric( horizontal: 24, vertical: 8, ), ), if (_lyric != null && _lyric!.lyrics.isNotEmpty) SliverList.builder( itemCount: _lyric!.lyrics.length, itemBuilder: (context, idx) => Obx(() { final lyricSlice = _lyric!.lyrics[idx]; final lyricNextSlice = idx + 1 < _lyric!.lyrics.length ? _lyric!.lyrics[idx + 1] : null; final isActive = _playback.durationCurrent.value.inSeconds >= lyricSlice.time.inSeconds && (lyricNextSlice == null || lyricNextSlice.time.inSeconds > _playback.durationCurrent.value.inSeconds) && _isLyricSynced; if (_playback.durationCurrent.value.inSeconds == lyricSlice.time.inSeconds && _isLyricSynced) { _autoScrollController.scrollToIndex( idx, preferPosition: AutoScrollPosition.middle, ); } return AutoScrollTag( key: ValueKey(idx), index: idx, controller: _autoScrollController, child: lyricSlice.text.isEmpty ? Container( padding: idx == _lyric!.lyrics.length - 1 ? EdgeInsets.only(bottom: size.height / 2) : null, ) : Padding( padding: idx == _lyric!.lyrics.length - 1 ? const EdgeInsets.symmetric(vertical: 8) .copyWith(bottom: 80) : const EdgeInsets.symmetric(vertical: 8), child: AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 250), style: TextStyle( fontWeight: isActive ? FontWeight.w500 : FontWeight.normal, fontSize: (isActive ? 28 : 26) * (_textZoomLevel / 100), ), textAlign: TextAlign.center, child: InkWell( borderRadius: const BorderRadius.all( Radius.circular(8), ), onTap: () async { final time = Duration( seconds: lyricSlice.time.inSeconds - _syncedLyrics.delay.value, ); if (time > audioPlayer.duration || time.isNegative) { return; } audioPlayer.seek(time); }, child: Builder(builder: (context) { return AnimatedDefaultTextStyle( style: TextStyle( fontSize: isActive ? 20 : 16, color: isActive ? Theme.of(context).colorScheme.onSurface : _unFocusColor, ), duration: 500.ms, curve: Curves.decelerate, child: Text( lyricSlice.text, textAlign: MediaQuery.of(context).size.width >= 720 ? TextAlign.center : TextAlign.left, ), ); }).paddingSymmetric(horizontal: 24), ), ), ), ); }), ) else if (_lyric != null && _lyric!.lyrics.isEmpty) SliverFillRemaining( child: CenteredContainer( maxWidth: 280, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Lyrics Not Found', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), const Text( "This song haven't lyrics that recorded in our database.", textAlign: TextAlign.center, ), ], ), ), ), const SliverGap(16), ], ); } }