Pato05
2862c9ec05
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
291 lines
11 KiB
Dart
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),
|
|
),
|
|
),
|
|
))));
|
|
},
|
|
)));
|
|
}),
|
|
),
|
|
]);
|
|
}
|
|
}
|