Pato05
2a5a51e43f
fix lyrics add right click action to AlbumCard add desktop file script for linux automated tarball creation for linux don't preload old queue
383 lines
15 KiB
Dart
383 lines
15 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:dio/dio.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/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>
|
|
with WidgetsBindingObserver {
|
|
StreamSubscription? _mediaItemSub;
|
|
StreamSubscription? _positionSub;
|
|
int? _currentIndex = -1;
|
|
Duration _nextOffset = Duration.zero;
|
|
Duration _currentOffset = Duration.zero;
|
|
String? _currentTrackId;
|
|
final ScrollController _controller = ScrollController();
|
|
static const double height = 110.0;
|
|
static const double additionalTranslationHeight = 40.0;
|
|
BoxConstraints? _widgetConstraints;
|
|
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;
|
|
//Lyric height, screen height, appbar height
|
|
final actualHeight =
|
|
height + (_showTranslation ? additionalTranslationHeight : 0.0);
|
|
double scrollTo;
|
|
if (_widgetConstraints == null) {
|
|
scrollTo = (actualHeight * _currentIndex!) -
|
|
(MediaQuery.of(context).size.height / 4 + height / 2);
|
|
} else {
|
|
final widgetHeight = _widgetConstraints!.maxHeight;
|
|
final minScroll = actualHeight * _currentIndex!;
|
|
scrollTo = minScroll - widgetHeight / 2 + 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 < _nextOffset && position > _currentOffset) return;
|
|
|
|
_currentIndex =
|
|
_lyrics?.lyrics?.lastIndexWhere((l) => l.offset! <= position);
|
|
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) {
|
|
print('fuck? $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);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Stack(
|
|
children: [
|
|
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(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8.0),
|
|
controller: _controller,
|
|
itemExtent: !_syncedLyrics
|
|
? null
|
|
: height +
|
|
(_showTranslation
|
|
? additionalTranslationHeight
|
|
: 0.0),
|
|
itemCount: _lyrics!.lyrics!.length,
|
|
itemBuilder: (BuildContext context, int i) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius:
|
|
BorderRadius.circular(8.0),
|
|
color: _currentIndex == i
|
|
? Colors.grey.withOpacity(0.25)
|
|
: Colors.transparent,
|
|
),
|
|
child: InkWell(
|
|
borderRadius:
|
|
BorderRadius.circular(8.0),
|
|
onTap: _syncedLyrics &&
|
|
_lyrics!.id != null
|
|
? () => audioHandler.seek(
|
|
_lyrics!.lyrics![i].offset!)
|
|
: null,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
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),
|
|
),
|
|
if (_showTranslation)
|
|
Text(
|
|
_lyrics!.lyrics![i]
|
|
.translated!,
|
|
textAlign:
|
|
TextAlign.center,
|
|
style: TextStyle(
|
|
color: Color.lerp(
|
|
Theme.of(
|
|
context)
|
|
.colorScheme
|
|
.onBackground,
|
|
Colors.black,
|
|
0.12),
|
|
fontSize: 20.0)),
|
|
],
|
|
),
|
|
)));
|
|
},
|
|
)));
|
|
}),
|
|
),
|
|
]),
|
|
if (_availableTranslation)
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 8.0),
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
setState(() => _showTranslation = !_showTranslation);
|
|
SchedulerBinding.instance
|
|
.addPostFrameCallback((_) => _scrollToLyric());
|
|
},
|
|
child: Text(_showTranslation
|
|
? 'Without translation'.i18n
|
|
: 'With translation'.i18n)),
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
}
|