freezer/lib/ui/lyrics_screen.dart
Pato05 070ab2d6f2
fix album art skipping song
new lyrics ui + improvements
2024-05-06 17:10:18 +02:00

379 lines
13 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: TextStyle(
color: Color.lerp(
Theme.of(context).colorScheme.onBackground,
Colors.black,
0.12),
fontSize: 20.0)),
],
),
)));
}
@override
Widget build(BuildContext context) {
final lyricsStyle = LyricsStyle.fromIndex(settings.lyricsStyle);
return Stack(
children: [
_error != null
? ErrorScreen(message: _error.toString())
:
// Loading lyrics
_loading
? const Center(child: CircularProgressIndicator())
: LayoutBuilder(builder: (context, constraints) {
return NotificationListener<ScrollStartNotification>(
onNotification: (notification) {
if (!_syncedLyrics) return false;
final extentDelta =
(notification.metrics.extentBefore -
notification.metrics.extentAfter)
.abs();
// avoid accidental clicks
const extentThreshold = 9000.0;
print('delta: $extentDelta');
if (extentDelta >= 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,
itemCount: _lyrics!.lyrics!.length + 1,
itemBuilder: (BuildContext context, int i) {
return AutoScrollTag(
key: ValueKey(i),
controller: _controller,
index: i,
child:
_buildLyricWidget(i, lyricsStyle));
},
))));
}),
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();
})))
],
);
}
}