397 lines
14 KiB
Dart
397 lines
14 KiB
Dart
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<LyricsWidget> createState() => _LyricsWidgetState();
|
|
}
|
|
|
|
class _LyricsWidgetState extends State<LyricsWidget>
|
|
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<void> _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<bool>(
|
|
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<ScrollNotification>(
|
|
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();
|
|
})))
|
|
],
|
|
);
|
|
}
|
|
}
|