From 70039a4901b8e3f52c72c28e5a5d2962f10f033f Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 28 Aug 2024 18:41:32 +0800 Subject: [PATCH] :sparkles: Lyrics & sync with album color --- lib/main.dart | 2 + lib/providers/audio_player_stream.dart | 8 +- lib/providers/palette.dart | 12 ++ lib/router.dart | 29 +++ lib/screens/player/lyrics.dart | 35 ++++ lib/screens/player/view.dart | 20 +- lib/services/database/database.dart | 2 +- .../{lyrics.dart => lyrics/model.dart} | 0 lib/services/lyrics/provider.dart | 172 ++++++++++++++++++ lib/widgets/lyrics/synced.dart | 157 ++++++++++++++++ lib/widgets/player/bottom_player.dart | 30 +-- pubspec.lock | 24 +++ pubspec.yaml | 2 + 13 files changed, 455 insertions(+), 38 deletions(-) create mode 100644 lib/screens/player/lyrics.dart rename lib/services/{lyrics.dart => lyrics/model.dart} (100%) create mode 100644 lib/services/lyrics/provider.dart create mode 100644 lib/widgets/lyrics/synced.dart diff --git a/lib/main.dart b/lib/main.dart index b2a49d1..5748747 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:rhythm_box/providers/skip_segments.dart'; import 'package:rhythm_box/providers/spotify.dart'; import 'package:rhythm_box/providers/user_preferences.dart'; import 'package:rhythm_box/router.dart'; +import 'package:rhythm_box/services/lyrics/provider.dart'; import 'package:rhythm_box/services/server/active_sourced_track.dart'; import 'package:rhythm_box/services/server/routes/playback.dart'; import 'package:rhythm_box/services/server/server.dart'; @@ -58,6 +59,7 @@ class MyApp extends StatelessWidget { void _initializeProviders(BuildContext context) async { Get.lazyPut(() => SpotifyProvider()); + Get.lazyPut(() => SyncedLyricsProvider()); Get.put(DatabaseProvider()); diff --git a/lib/providers/audio_player_stream.dart b/lib/providers/audio_player_stream.dart index cb7eb7d..f6812fc 100644 --- a/lib/providers/audio_player_stream.dart +++ b/lib/providers/audio_player_stream.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:rhythm_box/providers/audio_player.dart'; import 'package:rhythm_box/providers/history.dart'; +import 'package:rhythm_box/providers/palette.dart'; import 'package:rhythm_box/providers/scrobbler.dart'; import 'package:rhythm_box/providers/skip_segments.dart'; import 'package:rhythm_box/providers/user_preferences.dart'; @@ -16,7 +17,6 @@ import 'package:rhythm_box/widgets/auto_cache_image.dart'; class AudioPlayerStreamProvider extends GetxController { late final AudioServices notificationService; - final Rxn palette = Rxn(); List? _subscriptions; @@ -58,9 +58,7 @@ class AudioPlayerStreamProvider extends GetxController { Future updatePalette() async { if (!Get.find().state.value.albumColorSync) { - if (palette.value != null) { - palette.value = null; - } + Get.find().clear(); return; } @@ -74,7 +72,7 @@ class AudioPlayerStreamProvider extends GetxController { (activeTrack.album?.images).asUrlString()!, ), ); - palette.value = newPalette; + Get.find().updatePalette(newPalette); } } diff --git a/lib/providers/palette.dart b/lib/providers/palette.dart index 5a37b3c..b4e18d8 100644 --- a/lib/providers/palette.dart +++ b/lib/providers/palette.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:palette_generator/palette_generator.dart'; @@ -6,6 +7,17 @@ class PaletteProvider extends GetxController { void updatePalette(PaletteGenerator? newPalette) { palette.value = newPalette; + print('call update!'); + print(newPalette); + if (newPalette != null) { + Get.changeTheme( + ThemeData.from( + colorScheme: + ColorScheme.fromSeed(seedColor: newPalette.dominantColor!.color), + useMaterial3: true, + ), + ); + } } void clear() { diff --git a/lib/router.dart b/lib/router.dart index 77b5ce3..0efd5cc 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,5 +1,9 @@ +import 'package:animations/animations.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:rhythm_box/screens/explore.dart'; +import 'package:rhythm_box/screens/player/lyrics.dart'; +import 'package:rhythm_box/screens/player/view.dart'; import 'package:rhythm_box/screens/playlist/view.dart'; import 'package:rhythm_box/screens/settings.dart'; import 'package:rhythm_box/shells/nav_shell.dart'; @@ -27,4 +31,29 @@ final router = GoRouter(routes: [ ), ], ), + ShellRoute( + pageBuilder: (context, state, child) => CustomTransitionPage( + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeThroughTransition( + fillColor: Theme.of(context).scaffoldBackgroundColor, + animation: animation, + secondaryAnimation: secondaryAnimation, + child: child, + ); + }, + ), + routes: [ + GoRoute( + path: '/player', + name: 'player', + builder: (context, state) => const PlayerScreen(), + ), + GoRoute( + path: '/player/lyrics', + name: 'playerLyrics', + builder: (context, state) => const LyricsScreen(), + ), + ], + ), ]); diff --git a/lib/screens/player/lyrics.dart b/lib/screens/player/lyrics.dart new file mode 100644 index 0000000..6d5f4c0 --- /dev/null +++ b/lib/screens/player/lyrics.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:rhythm_box/widgets/lyrics/synced.dart'; +import 'package:rhythm_box/widgets/player/bottom_player.dart'; + +class LyricsScreen extends StatelessWidget { + const LyricsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surface, + child: Scaffold( + appBar: AppBar( + title: const Text('Lyrics'), + ), + body: const Column( + children: [ + Expanded( + child: SyncedLyrics( + defaultTextZoom: 67, + ), + ), + ], + ), + bottomNavigationBar: const SizedBox( + height: 83, + child: Material( + elevation: 2, + child: BottomPlayer(usePop: true), + ), + ), + ), + ); + } +} diff --git a/lib/screens/player/view.dart b/lib/screens/player/view.dart index 8d05cf2..67d3594 100644 --- a/lib/screens/player/view.dart +++ b/lib/screens/player/view.dart @@ -5,6 +5,7 @@ import 'package:dismissible_page/dismissible_page.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:media_kit/media_kit.dart'; import 'package:rhythm_box/providers/audio_player.dart'; @@ -16,14 +17,7 @@ import 'package:rhythm_box/services/audio_services/image.dart'; import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; class PlayerScreen extends StatefulWidget { - final Duration durationCurrent, durationTotal, durationBuffered; - - const PlayerScreen({ - super.key, - required this.durationCurrent, - required this.durationTotal, - required this.durationBuffered, - }); + const PlayerScreen({super.key}); @override State createState() => _PlayerScreenState(); @@ -72,9 +66,9 @@ class _PlayerScreenState extends State { @override void initState() { super.initState(); - _durationCurrent = widget.durationCurrent; - _durationTotal = widget.durationTotal; - _bufferProgress = widget.durationBuffered.inMilliseconds.toDouble(); + _durationCurrent = audioPlayer.position; + _durationTotal = audioPlayer.duration; + _bufferProgress = audioPlayer.bufferedPosition.inMilliseconds.toDouble(); _subscriptions = [ audioPlayer.durationStream .listen((dur) => setState(() => _durationTotal = dur)), @@ -298,7 +292,9 @@ class _PlayerScreenState extends State { child: TextButton.icon( icon: const Icon(Icons.lyrics), label: const Text('Lyrics'), - onPressed: () {}, + onPressed: () { + GoRouter.of(context).pushNamed('playerLyrics'); + }, ), ), const Gap(4), diff --git a/lib/services/database/database.dart b/lib/services/database/database.dart index fc2fffc..0672745 100755 --- a/lib/services/database/database.dart +++ b/lib/services/database/database.dart @@ -9,7 +9,7 @@ import 'package:media_kit/media_kit.dart' hide Track; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:rhythm_box/services/color.dart'; -import 'package:rhythm_box/services/lyrics.dart'; +import 'package:rhythm_box/services/lyrics/model.dart'; import 'package:spotify/spotify.dart' hide Playlist; import 'package:rhythm_box/services/kv_store/encrypted_kv_store.dart'; import 'package:rhythm_box/services/kv_store/kv_store.dart'; diff --git a/lib/services/lyrics.dart b/lib/services/lyrics/model.dart similarity index 100% rename from lib/services/lyrics.dart rename to lib/services/lyrics/model.dart diff --git a/lib/services/lyrics/provider.dart b/lib/services/lyrics/provider.dart new file mode 100644 index 0000000..3460a2f --- /dev/null +++ b/lib/services/lyrics/provider.dart @@ -0,0 +1,172 @@ +import 'dart:developer'; + +import 'package:dio/dio.dart'; +import 'package:drift/drift.dart'; +import 'package:get/get.dart'; +import 'package:lrc/lrc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:rhythm_box/providers/database.dart'; +import 'package:rhythm_box/providers/spotify.dart'; +import 'package:rhythm_box/services/database/database.dart'; +import 'package:rhythm_box/services/lyrics/model.dart'; +import 'package:spotify/spotify.dart'; + +class SyncedLyricsProvider extends GetxController { + RxInt delay = 0.obs; + + Future getSpotifyLyrics(Track track, String? token) async { + final res = await Dio().getUri( + Uri.parse( + 'https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token', + ), + options: Options( + headers: { + 'User-Agent': + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36', + 'App-platform': 'WebPlayer', + 'authorization': 'Bearer $token' + }, + responseType: ResponseType.json, + validateStatus: (status) => true, + ), + ); + + if (res.statusCode != 200) { + return SubtitleSimple( + lyrics: [], + name: track.name!, + uri: res.realUri, + rating: 0, + provider: 'Spotify', + ); + } + + final linesRaw = + Map.castFrom(res.data)['lyrics'] + ?['lines'] as List?; + + final lines = linesRaw?.map((line) { + return LyricSlice( + time: Duration(milliseconds: int.parse(line['startTimeMs'])), + text: line['words'] as String, + ); + }).toList() ?? + []; + + return SubtitleSimple( + lyrics: lines, + name: track.name!, + uri: res.realUri, + rating: 100, + provider: 'Spotify', + ); + } + + Future getLRCLibLyrics(Track track) async { + final packageInfo = await PackageInfo.fromPlatform(); + + final res = await Dio().getUri( + Uri( + scheme: 'https', + host: 'lrclib.net', + path: '/api/get', + queryParameters: { + 'artist_name': track.artists?.first.name, + 'track_name': track.name, + 'album_name': track.album?.name, + 'duration': track.duration?.inSeconds.toString(), + }, + ), + options: Options( + headers: {'User-Agent': 'RhythmBox/${packageInfo.version}'}, + responseType: ResponseType.json, + ), + ); + + if (res.statusCode != 200) { + return SubtitleSimple( + lyrics: [], + name: track.name!, + uri: res.realUri, + rating: 0, + provider: 'LRCLib', + ); + } + + final json = res.data as Map; + + final syncedLyricsRaw = json['syncedLyrics'] as String?; + final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true + ? Lrc.parse(syncedLyricsRaw!) + .lyrics + .map(LyricSlice.fromLrcLine) + .toList() + : null; + + if (syncedLyrics?.isNotEmpty == true) { + return SubtitleSimple( + lyrics: syncedLyrics!, + name: track.name!, + uri: res.realUri, + rating: 100, + provider: 'LRCLib', + ); + } + + final plainLyrics = (json['plainLyrics'] as String) + .split('\n') + .map((line) => LyricSlice(text: line, time: Duration.zero)) + .toList(); + + return SubtitleSimple( + lyrics: plainLyrics, + name: track.name!, + uri: res.realUri, + rating: 0, + provider: 'LRCLib', + ); + } + + Future fetch(Track track) async { + try { + final database = Get.find().database; + final spotify = Get.find().api; + + final cachedLyrics = await (database.select(database.lyricsTable) + ..where((tbl) => tbl.trackId.equals(track.id!))) + .map((row) => row.data) + .getSingleOrNull(); + + SubtitleSimple? lyrics = cachedLyrics; + + final token = await spotify.getCredentials(); + + if (lyrics == null || lyrics.lyrics.isEmpty) { + lyrics = await getSpotifyLyrics(track, token.accessToken); + } + + if (lyrics.lyrics.isEmpty || lyrics.lyrics.length <= 5) { + lyrics = await getLRCLibLyrics(track); + } + + if (lyrics.lyrics.isEmpty) { + throw Exception('Unable to find lyrics'); + } + + if (cachedLyrics == null || cachedLyrics.lyrics.isEmpty) { + await database.into(database.lyricsTable).insert( + LyricsTableCompanion.insert( + trackId: track.id!, + data: lyrics, + ), + mode: InsertMode.replace, + ); + } + + return lyrics; + } catch (e, stackTrace) { + log('[Lyrics] Error: $e; Trace:\n$stackTrace'); + rethrow; + } + } +} diff --git a/lib/widgets/lyrics/synced.dart b/lib/widgets/lyrics/synced.dart new file mode 100644 index 0000000..463c86f --- /dev/null +++ b/lib/widgets/lyrics/synced.dart @@ -0,0 +1,157 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:get/get.dart'; +import 'package:rhythm_box/providers/audio_player.dart'; +import 'package:rhythm_box/services/audio_player/audio_player.dart'; +import 'package:rhythm_box/services/lyrics/model.dart'; +import 'package:rhythm_box/services/lyrics/provider.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; + +class SyncedLyrics extends StatefulWidget { + final int defaultTextZoom; + + const SyncedLyrics({ + super.key, + required this.defaultTextZoom, + }); + + @override + State createState() => _SyncedLyricsState(); +} + +class _SyncedLyricsState extends State { + late final AudioPlayerProvider _playback = Get.find(); + late final SyncedLyricsProvider _syncedLyrics = Get.find(); + + final AutoScrollController _autoScrollController = AutoScrollController(); + + late final int _textZoomLevel = widget.defaultTextZoom; + late Duration _durationCurrent = audioPlayer.position; + + SubtitleSimple? _lyric; + + bool get _isLyricSynced => + _lyric == null ? false : _lyric!.lyrics.any((x) => x.time.inSeconds > 0); + + Future _pullLyrics() async { + if (_playback.state.value.activeTrack == null) return; + final out = await _syncedLyrics.fetch(_playback.state.value.activeTrack!); + setState(() => _lyric = out); + } + + List? _subscriptions; + + Color get _unFocusColor => + Theme.of(context).colorScheme.onSurface.withOpacity(0.75); + + @override + void initState() { + super.initState(); + _subscriptions = [ + audioPlayer.positionStream + .listen((dur) => setState(() => _durationCurrent = dur)), + ]; + _pullLyrics(); + } + + @override + void dispose() { + _autoScrollController.dispose(); + if (_subscriptions != null) { + for (final subscription in _subscriptions!) { + subscription.cancel(); + } + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return CustomScrollView( + controller: _autoScrollController, + slivers: [ + if (_lyric != null && _lyric!.lyrics.isNotEmpty) + SliverList.builder( + itemCount: _lyric!.lyrics.length, + itemBuilder: (context, idx) { + final lyricSlice = _lyric!.lyrics[idx]; + final lyricNextSlice = idx + 1 < _lyric!.lyrics.length + ? _lyric!.lyrics[idx + 1] + : null; + final isActive = + _durationCurrent.inSeconds >= lyricSlice.time.inSeconds && + (lyricNextSlice == null || + lyricNextSlice.time.inSeconds > + _durationCurrent.inSeconds); + + if (_durationCurrent.inSeconds == lyricSlice.time.inSeconds && + _isLyricSynced) { + _autoScrollController.scrollToIndex( + idx, + preferPosition: AutoScrollPosition.middle, + ); + } + return AutoScrollTag( + key: ValueKey(idx), + index: idx, + controller: _autoScrollController, + child: lyricSlice.text.isEmpty + ? Container( + padding: idx == _lyric!.lyrics.length - 1 + ? EdgeInsets.only(bottom: size.height / 2) + : null, + ) + : Padding( + padding: idx == _lyric!.lyrics.length - 1 + ? const EdgeInsets.all(8.0).copyWith(bottom: 100) + : const EdgeInsets.all(8.0), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + style: TextStyle( + fontWeight: + isActive ? FontWeight.w500 : FontWeight.normal, + fontSize: + (isActive ? 28 : 26) * (_textZoomLevel / 100), + ), + textAlign: TextAlign.center, + child: InkWell( + onTap: () async { + final time = Duration( + seconds: lyricSlice.time.inSeconds - + _syncedLyrics.delay.value, + ); + if (time > audioPlayer.duration || + time.isNegative) { + return; + } + audioPlayer.seek(time); + }, + child: Builder(builder: (context) { + return Text( + lyricSlice.text, + style: TextStyle( + color: isActive + ? Theme.of(context).colorScheme.onSurface + : _unFocusColor, + fontSize: 16, + ), + ).animate(target: isActive ? 1 : 0).scale( + duration: 300.ms, + begin: const Offset(1, 1), + end: const Offset(1.3, 1.3), + ); + }).paddingSymmetric(horizontal: 12), + ), + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/widgets/player/bottom_player.dart b/lib/widgets/player/bottom_player.dart index 931cac8..d46aa5b 100644 --- a/lib/widgets/player/bottom_player.dart +++ b/lib/widgets/player/bottom_player.dart @@ -1,12 +1,11 @@ import 'dart:async'; import 'dart:math'; -import 'package:dismissible_page/dismissible_page.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:get/get.dart'; +import 'package:go_router/go_router.dart'; import 'package:rhythm_box/providers/audio_player.dart'; -import 'package:rhythm_box/screens/player/view.dart'; import 'package:rhythm_box/services/audio_player/audio_player.dart'; import 'package:rhythm_box/services/audio_services/image.dart'; import 'package:rhythm_box/widgets/auto_cache_image.dart'; @@ -14,7 +13,9 @@ import 'package:rhythm_box/widgets/player/track_details.dart'; import 'package:rhythm_box/widgets/tracks/querying_track_info.dart'; class BottomPlayer extends StatefulWidget { - const BottomPlayer({super.key}); + final bool usePop; + + const BottomPlayer({super.key, this.usePop = false}); @override State createState() => _BottomPlayerState(); @@ -45,15 +46,6 @@ class _BottomPlayerState extends State Duration _durationCurrent = Duration.zero; Duration _durationTotal = Duration.zero; - Duration _durationBuffered = Duration.zero; - - void _updateDurationCurrent(Duration dur) { - setState(() => _durationCurrent = dur); - } - - void _updateDurationTotal(Duration dur) { - setState(() => _durationTotal = dur); - } List? _subscriptions; @@ -75,8 +67,6 @@ class _BottomPlayerState extends State .listen((dur) => setState(() => _durationTotal = dur)), audioPlayer.positionStream .listen((dur) => setState(() => _durationCurrent = dur)), - audioPlayer.bufferedPositionStream - .listen((dur) => setState(() => _durationBuffered = dur)), _playback.state.listen((state) { if (state.playlist.medias.isNotEmpty && !_isLifted) { _animationController.animateTo(1); @@ -126,7 +116,7 @@ class _BottomPlayerState extends State end: _durationCurrent.inMilliseconds / max(_durationTotal.inMilliseconds, 1), ), - duration: const Duration(milliseconds: 100), + duration: const Duration(milliseconds: 1000), builder: (context, value, _) => LinearProgressIndicator( minHeight: 3, value: value, @@ -191,11 +181,11 @@ class _BottomPlayerState extends State ], ), onTap: () { - context.pushTransparentRoute(PlayerScreen( - durationCurrent: _durationCurrent, - durationTotal: _durationTotal, - durationBuffered: _durationBuffered, - )); + if (widget.usePop) { + GoRouter.of(context).pop(); + } else { + GoRouter.of(context).pushNamed('player'); + } }, ), ), diff --git a/pubspec.lock b/pubspec.lock index ab1057e..625dbcc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -30,6 +30,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.3" + animations: + dependency: "direct main" + description: + name: animations + sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb + url: "https://pub.dev" + source: hosted + version: "2.0.11" archive: dependency: transitive description: @@ -403,6 +411,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7c8a6594a9252dad30cc2ef16e33270b6248c4dedc3b3d06c86c4f3f4dc05ae5" + url: "https://pub.dev" + source: hosted + version: "4.5.0" flutter_broadcasts: dependency: "direct main" description: @@ -491,6 +507,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "02750b545c01ff4d8e9bbe8f27a7731aa3778402506c67daa1de7f5fc3f4befe" + url: "https://pub.dev" + source: hosted + version: "0.1.2" flutter_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index dd7aeda..3ee5794 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,8 @@ dependencies: dismissible_page: ^1.0.2 shared_preferences: ^2.3.2 scroll_to_index: ^3.0.1 + animations: ^2.0.11 + flutter_animate: ^4.5.0 dev_dependencies: flutter_test: