✨ Lyrics & sync with album color
This commit is contained in:
		@@ -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());
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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<PaletteGenerator?> palette = Rxn<PaletteGenerator?>();
 | 
			
		||||
 | 
			
		||||
  List<StreamSubscription>? _subscriptions;
 | 
			
		||||
 | 
			
		||||
@@ -58,9 +58,7 @@ class AudioPlayerStreamProvider extends GetxController {
 | 
			
		||||
 | 
			
		||||
  Future<void> updatePalette() async {
 | 
			
		||||
    if (!Get.find<UserPreferencesProvider>().state.value.albumColorSync) {
 | 
			
		||||
      if (palette.value != null) {
 | 
			
		||||
        palette.value = null;
 | 
			
		||||
      }
 | 
			
		||||
      Get.find<PaletteProvider>().clear();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -74,7 +72,7 @@ class AudioPlayerStreamProvider extends GetxController {
 | 
			
		||||
          (activeTrack.album?.images).asUrlString()!,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      palette.value = newPalette;
 | 
			
		||||
      Get.find<PaletteProvider>().updatePalette(newPalette);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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() {
 | 
			
		||||
 
 | 
			
		||||
@@ -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(),
 | 
			
		||||
      ),
 | 
			
		||||
    ],
 | 
			
		||||
  ),
 | 
			
		||||
]);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										35
									
								
								lib/screens/player/lyrics.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								lib/screens/player/lyrics.dart
									
									
									
									
									
										Normal file
									
								
							@@ -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),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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<PlayerScreen> createState() => _PlayerScreenState();
 | 
			
		||||
@@ -72,9 +66,9 @@ class _PlayerScreenState extends State<PlayerScreen> {
 | 
			
		||||
  @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<PlayerScreen> {
 | 
			
		||||
                    child: TextButton.icon(
 | 
			
		||||
                      icon: const Icon(Icons.lyrics),
 | 
			
		||||
                      label: const Text('Lyrics'),
 | 
			
		||||
                      onPressed: () {},
 | 
			
		||||
                      onPressed: () {
 | 
			
		||||
                        GoRouter.of(context).pushNamed('playerLyrics');
 | 
			
		||||
                      },
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  const Gap(4),
 | 
			
		||||
 
 | 
			
		||||
@@ -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';
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										172
									
								
								lib/services/lyrics/provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								lib/services/lyrics/provider.dart
									
									
									
									
									
										Normal file
									
								
							@@ -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<SubtitleSimple> 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<dynamic, dynamic, String, dynamic>(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<SubtitleSimple> 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<String, dynamic>;
 | 
			
		||||
 | 
			
		||||
    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<SubtitleSimple> fetch(Track track) async {
 | 
			
		||||
    try {
 | 
			
		||||
      final database = Get.find<DatabaseProvider>().database;
 | 
			
		||||
      final spotify = Get.find<SpotifyProvider>().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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										157
									
								
								lib/widgets/lyrics/synced.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								lib/widgets/lyrics/synced.dart
									
									
									
									
									
										Normal file
									
								
							@@ -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<SyncedLyrics> createState() => _SyncedLyricsState();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class _SyncedLyricsState extends State<SyncedLyrics> {
 | 
			
		||||
  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<void> _pullLyrics() async {
 | 
			
		||||
    if (_playback.state.value.activeTrack == null) return;
 | 
			
		||||
    final out = await _syncedLyrics.fetch(_playback.state.value.activeTrack!);
 | 
			
		||||
    setState(() => _lyric = out);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  List<StreamSubscription>? _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),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
              );
 | 
			
		||||
            },
 | 
			
		||||
          ),
 | 
			
		||||
      ],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -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<BottomPlayer> createState() => _BottomPlayerState();
 | 
			
		||||
@@ -45,15 +46,6 @@ class _BottomPlayerState extends State<BottomPlayer>
 | 
			
		||||
 | 
			
		||||
  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<StreamSubscription>? _subscriptions;
 | 
			
		||||
 | 
			
		||||
@@ -75,8 +67,6 @@ class _BottomPlayerState extends State<BottomPlayer>
 | 
			
		||||
          .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<BottomPlayer>
 | 
			
		||||
                    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<BottomPlayer>
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
          onTap: () {
 | 
			
		||||
            context.pushTransparentRoute(PlayerScreen(
 | 
			
		||||
              durationCurrent: _durationCurrent,
 | 
			
		||||
              durationTotal: _durationTotal,
 | 
			
		||||
              durationBuffered: _durationBuffered,
 | 
			
		||||
            ));
 | 
			
		||||
            if (widget.usePop) {
 | 
			
		||||
              GoRouter.of(context).pop();
 | 
			
		||||
            } else {
 | 
			
		||||
              GoRouter.of(context).pushNamed('player');
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										24
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										24
									
								
								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
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user