freezer/lib/ui/lyrics_screen.dart
Pato05 2862c9ec05
remove browser login for desktop
restore translations functionality
make scrollViews handle mouse pointers like touch, so that pull to refresh functionality is available
exit app if opening cache or settings fails (another instance running)
remove draggable_scrollbar and use builtin widget instead
fix email login
better way to manage lyrics (less updates and lookups in the lyrics List)
fix player_screen on mobile (too big -> just average :))
right click: use TapUp events instead
desktop: show context menu on triple dots button also
avoid showing connection error if the homepage is cached and available offline
i'm probably forgetting something idk
2023-10-25 00:32:28 +02:00

291 lines
11 KiB
Dart

import 'dart:async';
import 'package:async/async.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.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<LyricsWidget> createState() => _LyricsWidgetState();
}
class _LyricsWidgetState extends State<LyricsWidget> {
late StreamSubscription _mediaItemSub;
late StreamSubscription _playbackStateSub;
int? _currentIndex = -1;
Duration _nextPosition = Duration.zero;
final ScrollController _controller = ScrollController();
final double height = 90;
BoxConstraints? _widgetConstraints;
Lyrics? _lyrics;
bool _loading = true;
CancelableOperation<Lyrics>? _lyricsCancelable;
Object? _error;
bool _freeScroll = false;
bool _animatedScroll = false;
bool _syncedLyrics = false;
Future<void> _loadForId(String trackId) async {
// cancel current request, if applicable
await _lyricsCancelable?.cancel();
//Fetch
if (_loading == false && _lyrics != null) {
setState(() {
_freeScroll = false;
_loading = true;
_lyrics = null;
});
}
try {
_lyricsCancelable =
CancelableOperation.fromFuture(deezerAPI.lyrics(trackId));
final lyrics = await _lyricsCancelable!.valueOrCancellation(null);
if (lyrics == null) return;
_syncedLyrics = lyrics.sync;
if (!mounted) return;
setState(() {
_loading = false;
_lyrics = lyrics;
});
_nextPosition = Duration.zero;
SchedulerBinding.instance.addPostFrameCallback(
(_) => _updatePosition(audioHandler.playbackState.value.position));
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
});
}
}
void _scrollToLyric() {
if (!_controller.hasClients) return;
//Lyric height, screen height, appbar height
double scrollTo;
if (_widgetConstraints == null) {
scrollTo = (height * _currentIndex!) -
(MediaQuery.of(context).size.height / 4 + height / 2);
} else {
final widgetHeight = _widgetConstraints!.maxHeight;
final minScroll = height * _currentIndex!;
scrollTo = minScroll - widgetHeight / 2 + height / 2;
}
print(
'${height * _currentIndex!}, ${MediaQuery.of(context).size.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 < _nextPosition) return;
_currentIndex =
_lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position);
//Scroll to current lyric
if (_currentIndex! < 0) return;
//Update current lyric index
if (_currentIndex! < _lyrics!.lyrics!.length) {
// update nextPosition
_nextPosition = _lyrics!.lyrics![_currentIndex! + 1].offset!;
} else {
// dummy position so that the before-hand condition always returns false
_nextPosition = const Duration(days: 69);
}
setState(() => _currentIndex);
if (_freeScroll) return;
_scrollToLyric();
}
@override
void initState() {
SchedulerBinding.instance.addPostFrameCallback((_) {
//Enable visualizer
// if (settings.lyricsVisualizer) playerHelper.startVisualizer();
_playbackStateSub = AudioService.position.listen(_updatePosition);
});
if (audioHandler.mediaItem.value != null) {
_loadForId(audioHandler.mediaItem.value!.id);
}
/// Track change = ~exit~ reload lyrics
_mediaItemSub = audioHandler.mediaItem.listen((mediaItem) {
if (mediaItem == null) return;
if (_controller.hasClients) _controller.jumpTo(0.0);
_loadForId(mediaItem.id);
});
super.initState();
}
@override
void dispose() {
_mediaItemSub.cancel();
_playbackStateSub.cancel();
//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 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<ScrollStartNotification>(
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(
controller: _controller,
itemCount: _lyrics!.lyrics!.length,
itemBuilder: (BuildContext context, int i) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0),
child: Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(8.0),
color: _currentIndex == i
? Colors.grey.withOpacity(0.25)
: Colors.transparent,
),
height: _syncedLyrics ? height : null,
child: InkWell(
borderRadius:
BorderRadius.circular(8.0),
onTap: _syncedLyrics &&
_lyrics!.id != null
? () => audioHandler.seek(
_lyrics!.lyrics![i].offset!)
: null,
child: Center(
child: Padding(
padding: _currentIndex == i
? EdgeInsets.zero
: const EdgeInsets
.symmetric(
horizontal: 1.0),
child: 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),
),
),
))));
},
)));
}),
),
]);
}
}