✨ Impl more features (clean up 20+ todo)
⚡ Add query cache
			
			
This commit is contained in:
		@@ -2,7 +2,14 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:get/get.dart';
 | 
			
		||||
import 'package:media_kit/media_kit.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/audio_player.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/audio_player_stream.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/database.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/spotify.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/user_preferences.dart';
 | 
			
		||||
import 'package:rhythm_box/router.dart';
 | 
			
		||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
 | 
			
		||||
import 'package:rhythm_box/services/server/routes/playback.dart';
 | 
			
		||||
@@ -51,7 +58,16 @@ class MyApp extends StatelessWidget {
 | 
			
		||||
 | 
			
		||||
  void _initializeProviders(BuildContext context) async {
 | 
			
		||||
    Get.lazyPut(() => SpotifyProvider());
 | 
			
		||||
    Get.lazyPut(() => ActiveSourcedTrackProvider());
 | 
			
		||||
 | 
			
		||||
    Get.put(ActiveSourcedTrackProvider());
 | 
			
		||||
    Get.put(AudioPlayerStreamProvider());
 | 
			
		||||
 | 
			
		||||
    Get.put(DatabaseProvider());
 | 
			
		||||
    Get.put(PlaybackHistoryProvider());
 | 
			
		||||
    Get.put(SegmentsProvider());
 | 
			
		||||
    Get.put(PaletteProvider());
 | 
			
		||||
    Get.put(ScrobblerProvider());
 | 
			
		||||
    Get.put(UserPreferencesProvider());
 | 
			
		||||
 | 
			
		||||
    Get.put(AudioPlayerProvider());
 | 
			
		||||
    Get.put(QueryingTrackInfoProvider());
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ abstract class PlatformInfo {
 | 
			
		||||
  static bool get isLinux => !kIsWeb && Platform.isLinux;
 | 
			
		||||
 | 
			
		||||
  static bool get isInFlatpak =>
 | 
			
		||||
      kIsWeb ? false : Platform.environment["FLATPAK_ID"] != null;
 | 
			
		||||
      kIsWeb ? false : Platform.environment['FLATPAK_ID'] != null;
 | 
			
		||||
 | 
			
		||||
  static bool get isWindows => !kIsWeb && Platform.isWindows;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										157
									
								
								lib/providers/audio_player_stream.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								lib/providers/audio_player_stream.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,157 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
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/scrobbler.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/skip_segments.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/user_preferences.dart';
 | 
			
		||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
 | 
			
		||||
import 'package:rhythm_box/services/audio_services/audio_services.dart';
 | 
			
		||||
import 'package:rhythm_box/services/audio_services/image.dart';
 | 
			
		||||
import 'package:rhythm_box/services/local_track.dart';
 | 
			
		||||
import 'package:rhythm_box/services/server/sourced_track.dart';
 | 
			
		||||
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;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onInit() {
 | 
			
		||||
    super.onInit();
 | 
			
		||||
    AudioServices.create().then(
 | 
			
		||||
      (value) => notificationService = value,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    _subscriptions = [
 | 
			
		||||
      subscribeToPlaylist(),
 | 
			
		||||
      subscribeToSkipSponsor(),
 | 
			
		||||
      subscribeToScrobbleChanged(),
 | 
			
		||||
      subscribeToPosition(),
 | 
			
		||||
      subscribeToPlayerError(),
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void dispose() {
 | 
			
		||||
    if (_subscriptions != null) {
 | 
			
		||||
      for (final subscription in _subscriptions!) {
 | 
			
		||||
        subscription.cancel();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    super.dispose();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> updatePalette() async {
 | 
			
		||||
    if (!Get.find<UserPreferences>().albumColorSync) {
 | 
			
		||||
      if (palette.value != null) {
 | 
			
		||||
        palette.value = null;
 | 
			
		||||
      }
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final AudioPlayerProvider playback = Get.find();
 | 
			
		||||
    final activeTrack = playback.state.value.activeTrack;
 | 
			
		||||
    if (activeTrack == null) return;
 | 
			
		||||
 | 
			
		||||
    if (activeTrack.album?.images != null) {
 | 
			
		||||
      final newPalette = await PaletteGenerator.fromImageProvider(
 | 
			
		||||
        AutoCacheImage.provider(
 | 
			
		||||
          (activeTrack.album?.images).asUrlString()!,
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
      palette.value = newPalette;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  StreamSubscription subscribeToPlaylist() {
 | 
			
		||||
    final AudioPlayerProvider playback = Get.find();
 | 
			
		||||
    return audioPlayer.playlistStream.listen((mpvPlaylist) {
 | 
			
		||||
      final activeTrack = playback.state.value.activeTrack;
 | 
			
		||||
      if (activeTrack != null) {
 | 
			
		||||
        notificationService.addTrack(activeTrack);
 | 
			
		||||
        updatePalette();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  StreamSubscription subscribeToSkipSponsor() {
 | 
			
		||||
    return audioPlayer.positionStream.listen((position) async {
 | 
			
		||||
      final currentSegments =
 | 
			
		||||
          await Get.find<SegmentsProvider>().fetchSegments();
 | 
			
		||||
 | 
			
		||||
      if (currentSegments?.segments.isNotEmpty != true ||
 | 
			
		||||
          position < const Duration(seconds: 3)) return;
 | 
			
		||||
 | 
			
		||||
      for (final segment in currentSegments!.segments) {
 | 
			
		||||
        final seconds = position.inSeconds;
 | 
			
		||||
 | 
			
		||||
        if (seconds < segment.start || seconds >= segment.end) continue;
 | 
			
		||||
 | 
			
		||||
        await audioPlayer.seek(Duration(seconds: segment.end + 1));
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  StreamSubscription subscribeToScrobbleChanged() {
 | 
			
		||||
    String? lastScrobbled;
 | 
			
		||||
    return audioPlayer.positionStream.listen((position) {
 | 
			
		||||
      try {
 | 
			
		||||
        final AudioPlayerProvider playback = Get.find();
 | 
			
		||||
        final uid = playback.state.value.activeTrack is LocalTrack
 | 
			
		||||
            ? (playback.state.value.activeTrack as LocalTrack).path
 | 
			
		||||
            : playback.state.value.activeTrack?.id;
 | 
			
		||||
 | 
			
		||||
        if (playback.state.value.activeTrack == null ||
 | 
			
		||||
            lastScrobbled == uid ||
 | 
			
		||||
            position.inSeconds < 30) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Get.find<ScrobblerProvider>()
 | 
			
		||||
            .scrobble(playback.state.value.activeTrack!);
 | 
			
		||||
        Get.find<PlaybackHistoryProvider>()
 | 
			
		||||
            .addTrack(playback.state.value.activeTrack!);
 | 
			
		||||
        lastScrobbled = uid;
 | 
			
		||||
      } catch (e, stack) {
 | 
			
		||||
        log('[Scrobbler] Error: $e; Trace:\n$stack');
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  StreamSubscription subscribeToPosition() {
 | 
			
		||||
    String lastTrack = ''; // used to prevent multiple calls to the same track
 | 
			
		||||
    final AudioPlayerProvider playback = Get.find();
 | 
			
		||||
    return audioPlayer.positionStream.listen((event) async {
 | 
			
		||||
      if (event < const Duration(seconds: 3) ||
 | 
			
		||||
          audioPlayer.playlist.index == -1 ||
 | 
			
		||||
          audioPlayer.playlist.index ==
 | 
			
		||||
              playback.state.value.tracks.length - 1) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      final nextTrack = RhythmMedia.fromMedia(
 | 
			
		||||
        audioPlayer.playlist.medias.elementAt(audioPlayer.playlist.index + 1),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      if (lastTrack == nextTrack.track.id || nextTrack.track is LocalTrack) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      try {
 | 
			
		||||
        await Get.find<SourcedTrackProvider>().fetch(nextTrack);
 | 
			
		||||
      } finally {
 | 
			
		||||
        lastTrack = nextTrack.track.id!;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  StreamSubscription subscribeToPlayerError() {
 | 
			
		||||
    return audioPlayer.errorStream.listen((event) {
 | 
			
		||||
      // Handle player error events here
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										6
									
								
								lib/providers/database.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/providers/database.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
import 'package:get/get.dart';
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
 | 
			
		||||
class DatabaseProvider extends GetxController {
 | 
			
		||||
  late final AppDatabase database = AppDatabase();
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										62
									
								
								lib/providers/history.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								lib/providers/history.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
import 'package:get/get.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/database.dart';
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
import 'package:spotify/spotify.dart';
 | 
			
		||||
 | 
			
		||||
class PlaybackHistoryProvider extends GetxController {
 | 
			
		||||
  final AppDatabase _db = Get.find<DatabaseProvider>().database;
 | 
			
		||||
 | 
			
		||||
  Future<void> _batchInsertHistoryEntries(
 | 
			
		||||
      List<HistoryTableCompanion> entries) async {
 | 
			
		||||
    await _db.batch((batch) {
 | 
			
		||||
      batch.insertAll(_db.historyTable, entries);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> addPlaylists(List<PlaylistSimple> playlists) async {
 | 
			
		||||
    await _batchInsertHistoryEntries([
 | 
			
		||||
      for (final playlist in playlists)
 | 
			
		||||
        HistoryTableCompanion.insert(
 | 
			
		||||
          type: HistoryEntryType.playlist,
 | 
			
		||||
          itemId: playlist.id!,
 | 
			
		||||
          data: playlist.toJson(),
 | 
			
		||||
        ),
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> addAlbums(List<AlbumSimple> albums) async {
 | 
			
		||||
    await _batchInsertHistoryEntries([
 | 
			
		||||
      for (final album in albums)
 | 
			
		||||
        HistoryTableCompanion.insert(
 | 
			
		||||
          type: HistoryEntryType.album,
 | 
			
		||||
          itemId: album.id!,
 | 
			
		||||
          data: album.toJson(),
 | 
			
		||||
        ),
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> addTracks(List<Track> tracks) async {
 | 
			
		||||
    await _batchInsertHistoryEntries([
 | 
			
		||||
      for (final track in tracks)
 | 
			
		||||
        HistoryTableCompanion.insert(
 | 
			
		||||
          type: HistoryEntryType.track,
 | 
			
		||||
          itemId: track.id!,
 | 
			
		||||
          data: track.toJson(),
 | 
			
		||||
        ),
 | 
			
		||||
    ]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> addTrack(Track track) async {
 | 
			
		||||
    await _db.into(_db.historyTable).insert(
 | 
			
		||||
          HistoryTableCompanion.insert(
 | 
			
		||||
            type: HistoryEntryType.track,
 | 
			
		||||
            itemId: track.id!,
 | 
			
		||||
            data: track.toJson(),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> clear() async {
 | 
			
		||||
    await _db.delete(_db.historyTable).go();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										14
									
								
								lib/providers/palette.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/providers/palette.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
import 'package:get/get.dart';
 | 
			
		||||
import 'package:palette_generator/palette_generator.dart';
 | 
			
		||||
 | 
			
		||||
class PaletteProvider extends GetxController {
 | 
			
		||||
  final Rx<PaletteGenerator?> palette = Rx<PaletteGenerator?>(null);
 | 
			
		||||
 | 
			
		||||
  void updatePalette(PaletteGenerator? newPalette) {
 | 
			
		||||
    palette.value = newPalette;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void clear() {
 | 
			
		||||
    palette.value = null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										140
									
								
								lib/providers/scrobbler.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								lib/providers/scrobbler.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,140 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:get/get.dart' hide Value;
 | 
			
		||||
import 'package:rhythm_box/providers/database.dart';
 | 
			
		||||
import 'package:rhythm_box/services/artist.dart';
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
import 'package:scrobblenaut/scrobblenaut.dart';
 | 
			
		||||
import 'package:spotify/spotify.dart';
 | 
			
		||||
 | 
			
		||||
class ScrobblerProvider extends GetxController {
 | 
			
		||||
  final StreamController<Track> _scrobbleController =
 | 
			
		||||
      StreamController<Track>.broadcast();
 | 
			
		||||
  final Rxn<Scrobblenaut?> scrobbler = Rxn<Scrobblenaut?>(null);
 | 
			
		||||
  late StreamSubscription _databaseSubscription;
 | 
			
		||||
  late StreamSubscription _scrobbleSubscription;
 | 
			
		||||
 | 
			
		||||
  static String apiKey = 'd2a75393e1141d0c9486eb77cc7b8892';
 | 
			
		||||
  static String apiSecret = '3ac3a5231a2e8a0dc98577c246101b78';
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onInit() {
 | 
			
		||||
    super.onInit();
 | 
			
		||||
    _initialize();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _initialize() async {
 | 
			
		||||
    final database = Get.find<DatabaseProvider>().database;
 | 
			
		||||
 | 
			
		||||
    final loginInfo = await (database.select(database.scrobblerTable)
 | 
			
		||||
          ..where((t) => t.id.equals(0)))
 | 
			
		||||
        .getSingleOrNull();
 | 
			
		||||
 | 
			
		||||
    _databaseSubscription =
 | 
			
		||||
        database.select(database.scrobblerTable).watch().listen((event) async {
 | 
			
		||||
      if (event.isNotEmpty) {
 | 
			
		||||
        try {
 | 
			
		||||
          scrobbler.value = Scrobblenaut(
 | 
			
		||||
            lastFM: await LastFM.authenticateWithPasswordHash(
 | 
			
		||||
              apiKey: apiKey,
 | 
			
		||||
              apiSecret: apiSecret,
 | 
			
		||||
              username: event.first.username,
 | 
			
		||||
              passwordHash: event.first.passwordHash.value,
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        } catch (e, stack) {
 | 
			
		||||
          log('[Scrobble] Error: $e; Trace:\n$stack');
 | 
			
		||||
          scrobbler.value = null;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        scrobbler.value = null;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    _scrobbleSubscription = _scrobbleController.stream.listen((track) async {
 | 
			
		||||
      try {
 | 
			
		||||
        await scrobbler.value?.track.scrobble(
 | 
			
		||||
          artist: track.artists!.first.name!,
 | 
			
		||||
          track: track.name!,
 | 
			
		||||
          album: track.album!.name!,
 | 
			
		||||
          chosenByUser: true,
 | 
			
		||||
          duration: track.duration,
 | 
			
		||||
          timestamp: DateTime.now().toUtc(),
 | 
			
		||||
          trackNumber: track.trackNumber,
 | 
			
		||||
        );
 | 
			
		||||
      } catch (e, stackTrace) {
 | 
			
		||||
        log('[Scrobble] Error: $e; Trace:\n$stackTrace');
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (loginInfo == null) {
 | 
			
		||||
      scrobbler.value = null;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    scrobbler.value = Scrobblenaut(
 | 
			
		||||
      lastFM: await LastFM.authenticateWithPasswordHash(
 | 
			
		||||
        apiKey: apiKey,
 | 
			
		||||
        apiSecret: apiSecret,
 | 
			
		||||
        username: loginInfo.username,
 | 
			
		||||
        passwordHash: loginInfo.passwordHash.value,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> login(String username, String password) async {
 | 
			
		||||
    final database = Get.find<DatabaseProvider>().database;
 | 
			
		||||
 | 
			
		||||
    final lastFm = await LastFM.authenticate(
 | 
			
		||||
      apiKey: apiKey,
 | 
			
		||||
      apiSecret: apiSecret,
 | 
			
		||||
      username: username,
 | 
			
		||||
      password: password,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (!lastFm.isAuth) throw Exception('Invalid credentials');
 | 
			
		||||
 | 
			
		||||
    await database.into(database.scrobblerTable).insert(
 | 
			
		||||
          ScrobblerTableCompanion.insert(
 | 
			
		||||
            id: const Value(0),
 | 
			
		||||
            username: username,
 | 
			
		||||
            passwordHash: DecryptedText(lastFm.passwordHash!),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
    scrobbler.value = Scrobblenaut(lastFM: lastFm);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> logout() async {
 | 
			
		||||
    scrobbler.value = null;
 | 
			
		||||
    final database = Get.find<DatabaseProvider>().database;
 | 
			
		||||
    await database.delete(database.scrobblerTable).go();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void scrobble(Track track) {
 | 
			
		||||
    _scrobbleController.add(track);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> love(Track track) async {
 | 
			
		||||
    await scrobbler.value?.track.love(
 | 
			
		||||
      artist: track.artists!.asString(),
 | 
			
		||||
      track: track.name!,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> unlove(Track track) async {
 | 
			
		||||
    await scrobbler.value?.track.unLove(
 | 
			
		||||
      artist: track.artists!.asString(),
 | 
			
		||||
      track: track.name!,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onClose() {
 | 
			
		||||
    _databaseSubscription.cancel();
 | 
			
		||||
    _scrobbleSubscription.cancel();
 | 
			
		||||
    _scrobbleController.close();
 | 
			
		||||
    super.onClose();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										117
									
								
								lib/providers/skip_segments.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								lib/providers/skip_segments.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:get/get.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/database.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/user_preferences.dart';
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
 | 
			
		||||
 | 
			
		||||
class SourcedSegments {
 | 
			
		||||
  final String source;
 | 
			
		||||
  final List<SkipSegmentTableData> segments;
 | 
			
		||||
 | 
			
		||||
  SourcedSegments({required this.source, required this.segments});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Future<List<SkipSegmentTableData>> getAndCacheSkipSegments(String id) async {
 | 
			
		||||
  final database = Get.find<DatabaseProvider>().database;
 | 
			
		||||
  try {
 | 
			
		||||
    final cached = await (database.select(database.skipSegmentTable)
 | 
			
		||||
          ..where((s) => s.trackId.equals(id)))
 | 
			
		||||
        .get();
 | 
			
		||||
 | 
			
		||||
    if (cached.isNotEmpty) {
 | 
			
		||||
      return cached;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final res = await Dio().getUri(
 | 
			
		||||
      Uri(
 | 
			
		||||
        scheme: 'https',
 | 
			
		||||
        host: 'sponsor.ajay.app',
 | 
			
		||||
        path: '/api/skipSegments',
 | 
			
		||||
        queryParameters: {
 | 
			
		||||
          'videoID': id,
 | 
			
		||||
          'category': [
 | 
			
		||||
            'sponsor',
 | 
			
		||||
            'selfpromo',
 | 
			
		||||
            'interaction',
 | 
			
		||||
            'intro',
 | 
			
		||||
            'outro',
 | 
			
		||||
            'music_offtopic'
 | 
			
		||||
          ],
 | 
			
		||||
          'actionType': 'skip'
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
      options: Options(
 | 
			
		||||
        responseType: ResponseType.json,
 | 
			
		||||
        validateStatus: (status) => (status ?? 0) < 500,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (res.data == 'Not Found') {
 | 
			
		||||
      return List.castFrom<dynamic, SkipSegmentTableData>([]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final data = res.data as List;
 | 
			
		||||
    final segments = data.map((obj) {
 | 
			
		||||
      final start = obj['segment'].first.toInt();
 | 
			
		||||
      final end = obj['segment'].last.toInt();
 | 
			
		||||
      return SkipSegmentTableCompanion.insert(
 | 
			
		||||
        trackId: id,
 | 
			
		||||
        start: start,
 | 
			
		||||
        end: end,
 | 
			
		||||
      );
 | 
			
		||||
    }).toList();
 | 
			
		||||
 | 
			
		||||
    await database.batch((b) {
 | 
			
		||||
      b.insertAll(database.skipSegmentTable, segments);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return await (database.select(database.skipSegmentTable)
 | 
			
		||||
          ..where((s) => s.trackId.equals(id)))
 | 
			
		||||
        .get();
 | 
			
		||||
  } catch (e, stack) {
 | 
			
		||||
    log('[SkipSegment] Error: $e; Trace:\n$stack');
 | 
			
		||||
    return List.castFrom<dynamic, SkipSegmentTableData>([]);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class SegmentsProvider extends GetxController {
 | 
			
		||||
  final Rx<SourcedSegments?> segments = Rx<SourcedSegments?>(null);
 | 
			
		||||
 | 
			
		||||
  Future<SourcedSegments?> fetchSegments() async {
 | 
			
		||||
    final track = Get.find<ActiveSourcedTrackProvider>().state.value;
 | 
			
		||||
    if (track == null) {
 | 
			
		||||
      segments.value = null;
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final userPreferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
    final skipNonMusic = userPreferences.skipNonMusic &&
 | 
			
		||||
        !(userPreferences.audioSource == AudioSource.piped &&
 | 
			
		||||
            userPreferences.searchMode == SearchMode.youtubeMusic);
 | 
			
		||||
 | 
			
		||||
    if (!skipNonMusic) {
 | 
			
		||||
      segments.value = SourcedSegments(
 | 
			
		||||
        segments: [],
 | 
			
		||||
        source: track.sourceInfo.id,
 | 
			
		||||
      );
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    final fetchedSegments = await getAndCacheSkipSegments(track.sourceInfo.id);
 | 
			
		||||
    segments.value = SourcedSegments(
 | 
			
		||||
      source: track.sourceInfo.id,
 | 
			
		||||
      segments: fetchedSegments,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return segments.value!;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onInit() {
 | 
			
		||||
    super.onInit();
 | 
			
		||||
    fetchSegments(); // Automatically load segments when controller is initialized
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										190
									
								
								lib/providers/user_preferences.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								lib/providers/user_preferences.dart
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,190 @@
 | 
			
		||||
import 'package:get/get.dart' hide Value;
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:rhythm_box/platform.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/audio_player_stream.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/database.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/palette.dart';
 | 
			
		||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
 | 
			
		||||
import 'package:rhythm_box/services/color.dart';
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
import 'package:rhythm_box/services/sourced_track/enums.dart';
 | 
			
		||||
import 'package:spotify/spotify.dart';
 | 
			
		||||
import 'package:window_manager/window_manager.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:path/path.dart';
 | 
			
		||||
import 'package:path_provider/path_provider.dart';
 | 
			
		||||
 | 
			
		||||
typedef UserPreferences = PreferencesTableData;
 | 
			
		||||
 | 
			
		||||
class UserPreferencesProvider extends GetxController {
 | 
			
		||||
  final Rx<UserPreferences> state = PreferencesTable.defaults().obs;
 | 
			
		||||
  late final AppDatabase db;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void onInit() {
 | 
			
		||||
    super.onInit();
 | 
			
		||||
    db = Get.find<DatabaseProvider>().database;
 | 
			
		||||
    _initializePreferences();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> _initializePreferences() async {
 | 
			
		||||
    var result = await (db.select(db.preferencesTable)
 | 
			
		||||
          ..where((tbl) => tbl.id.equals(0)))
 | 
			
		||||
        .getSingleOrNull();
 | 
			
		||||
    if (result == null) {
 | 
			
		||||
      await db.into(db.preferencesTable).insert(
 | 
			
		||||
            PreferencesTableCompanion.insert(
 | 
			
		||||
              id: const Value(0),
 | 
			
		||||
              downloadLocation: Value(await _getDefaultDownloadDirectory()),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
    }
 | 
			
		||||
    state.value = await (db.select(db.preferencesTable)
 | 
			
		||||
          ..where((tbl) => tbl.id.equals(0)))
 | 
			
		||||
        .getSingle();
 | 
			
		||||
 | 
			
		||||
    // Subscribe to updates
 | 
			
		||||
    (db.select(db.preferencesTable)..where((tbl) => tbl.id.equals(0)))
 | 
			
		||||
        .watchSingle()
 | 
			
		||||
        .listen((event) async {
 | 
			
		||||
      state.value = event;
 | 
			
		||||
 | 
			
		||||
      if (PlatformInfo.isDesktop) {
 | 
			
		||||
        await windowManager.setTitleBarStyle(
 | 
			
		||||
          state.value.systemTitleBar
 | 
			
		||||
              ? TitleBarStyle.normal
 | 
			
		||||
              : TitleBarStyle.hidden,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await audioPlayer.setAudioNormalization(state.value.normalizeAudio);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<String> _getDefaultDownloadDirectory() async {
 | 
			
		||||
    if (PlatformInfo.isAndroid) return '/storage/emulated/0/Download/RhythmBox';
 | 
			
		||||
 | 
			
		||||
    if (PlatformInfo.isMacOS) {
 | 
			
		||||
      return join((await getLibraryDirectory()).path, 'Caches');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return getDownloadsDirectory().then((dir) {
 | 
			
		||||
      return join(dir!.path, 'RhythmBox');
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> setData(PreferencesTableCompanion data) async {
 | 
			
		||||
    await (db.update(db.preferencesTable)..where((t) => t.id.equals(0)))
 | 
			
		||||
        .write(data);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<void> reset() async {
 | 
			
		||||
    await (db.update(db.preferencesTable)..where((t) => t.id.equals(0)))
 | 
			
		||||
        .replace(PreferencesTableCompanion.insert());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setStreamMusicCodec(SourceCodecs codec) {
 | 
			
		||||
    setData(PreferencesTableCompanion(streamMusicCodec: Value(codec)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setDownloadMusicCodec(SourceCodecs codec) {
 | 
			
		||||
    setData(PreferencesTableCompanion(downloadMusicCodec: Value(codec)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setThemeMode(ThemeMode mode) {
 | 
			
		||||
    setData(PreferencesTableCompanion(themeMode: Value(mode)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setRecommendationMarket(Market country) {
 | 
			
		||||
    setData(PreferencesTableCompanion(market: Value(country)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setAccentColorScheme(RhythmColor color) {
 | 
			
		||||
    setData(PreferencesTableCompanion(accentColorScheme: Value(color)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setAlbumColorSync(bool sync) {
 | 
			
		||||
    setData(PreferencesTableCompanion(albumColorSync: Value(sync)));
 | 
			
		||||
 | 
			
		||||
    if (!sync) {
 | 
			
		||||
      Get.find<PaletteProvider>().clear();
 | 
			
		||||
    } else {
 | 
			
		||||
      Get.find<AudioPlayerStreamProvider>().updatePalette();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setCheckUpdate(bool check) {
 | 
			
		||||
    setData(PreferencesTableCompanion(checkUpdate: Value(check)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setAudioQuality(SourceQualities quality) {
 | 
			
		||||
    setData(PreferencesTableCompanion(audioQuality: Value(quality)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setDownloadLocation(String downloadDir) {
 | 
			
		||||
    if (downloadDir.isEmpty) return;
 | 
			
		||||
    setData(PreferencesTableCompanion(downloadLocation: Value(downloadDir)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setLocalLibraryLocation(List<String> localLibraryDirs) {
 | 
			
		||||
    setData(PreferencesTableCompanion(
 | 
			
		||||
        localLibraryLocation: Value(localLibraryDirs)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setLayoutMode(LayoutMode mode) {
 | 
			
		||||
    setData(PreferencesTableCompanion(layoutMode: Value(mode)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setCloseBehavior(CloseBehavior behavior) {
 | 
			
		||||
    setData(PreferencesTableCompanion(closeBehavior: Value(behavior)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setShowSystemTrayIcon(bool show) {
 | 
			
		||||
    setData(PreferencesTableCompanion(showSystemTrayIcon: Value(show)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setLocale(Locale locale) {
 | 
			
		||||
    setData(PreferencesTableCompanion(locale: Value(locale)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setPipedInstance(String instance) {
 | 
			
		||||
    setData(PreferencesTableCompanion(pipedInstance: Value(instance)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setSearchMode(SearchMode mode) {
 | 
			
		||||
    setData(PreferencesTableCompanion(searchMode: Value(mode)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setSkipNonMusic(bool skip) {
 | 
			
		||||
    setData(PreferencesTableCompanion(skipNonMusic: Value(skip)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setAudioSource(AudioSource type) {
 | 
			
		||||
    setData(PreferencesTableCompanion(audioSource: Value(type)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setSystemTitleBar(bool isSystemTitleBar) {
 | 
			
		||||
    setData(PreferencesTableCompanion(systemTitleBar: Value(isSystemTitleBar)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setDiscordPresence(bool discordPresence) {
 | 
			
		||||
    setData(PreferencesTableCompanion(discordPresence: Value(discordPresence)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setAmoledDarkTheme(bool isAmoled) {
 | 
			
		||||
    setData(PreferencesTableCompanion(amoledDarkTheme: Value(isAmoled)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setNormalizeAudio(bool normalize) {
 | 
			
		||||
    setData(PreferencesTableCompanion(normalizeAudio: Value(normalize)));
 | 
			
		||||
    audioPlayer.setAudioNormalization(normalize);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setEndlessPlayback(bool endless) {
 | 
			
		||||
    setData(PreferencesTableCompanion(endlessPlayback: Value(endless)));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void setEnableConnect(bool enable) {
 | 
			
		||||
    setData(PreferencesTableCompanion(enableConnect: Value(enable)));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -21,7 +21,6 @@ import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';
 | 
			
		||||
part 'database.g.dart';
 | 
			
		||||
 | 
			
		||||
part 'tables/authentication.dart';
 | 
			
		||||
part 'tables/blacklist.dart';
 | 
			
		||||
part 'tables/preferences.dart';
 | 
			
		||||
part 'tables/scrobbler.dart';
 | 
			
		||||
part 'tables/skip_segment.dart';
 | 
			
		||||
@@ -39,7 +38,6 @@ part 'typeconverters/subtitle.dart';
 | 
			
		||||
@DriftDatabase(
 | 
			
		||||
  tables: [
 | 
			
		||||
    AuthenticationTable,
 | 
			
		||||
    BlacklistTable,
 | 
			
		||||
    PreferencesTable,
 | 
			
		||||
    ScrobblerTable,
 | 
			
		||||
    SkipSegmentTable,
 | 
			
		||||
 
 | 
			
		||||
@@ -281,276 +281,6 @@ class AuthenticationTableCompanion
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class $BlacklistTableTable extends BlacklistTable
 | 
			
		||||
    with TableInfo<$BlacklistTableTable, BlacklistTableData> {
 | 
			
		||||
  @override
 | 
			
		||||
  final GeneratedDatabase attachedDatabase;
 | 
			
		||||
  final String? _alias;
 | 
			
		||||
  $BlacklistTableTable(this.attachedDatabase, [this._alias]);
 | 
			
		||||
  static const VerificationMeta _idMeta = const VerificationMeta('id');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumn<int> id = GeneratedColumn<int>(
 | 
			
		||||
      'id', aliasedName, false,
 | 
			
		||||
      hasAutoIncrement: true,
 | 
			
		||||
      type: DriftSqlType.int,
 | 
			
		||||
      requiredDuringInsert: false,
 | 
			
		||||
      defaultConstraints:
 | 
			
		||||
          GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
 | 
			
		||||
  static const VerificationMeta _nameMeta = const VerificationMeta('name');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumn<String> name = GeneratedColumn<String>(
 | 
			
		||||
      'name', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.string, requiredDuringInsert: true);
 | 
			
		||||
  static const VerificationMeta _elementTypeMeta =
 | 
			
		||||
      const VerificationMeta('elementType');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumnWithTypeConverter<BlacklistedType, String>
 | 
			
		||||
      elementType = GeneratedColumn<String>('element_type', aliasedName, false,
 | 
			
		||||
              type: DriftSqlType.string, requiredDuringInsert: true)
 | 
			
		||||
          .withConverter<BlacklistedType>(
 | 
			
		||||
              $BlacklistTableTable.$converterelementType);
 | 
			
		||||
  static const VerificationMeta _elementIdMeta =
 | 
			
		||||
      const VerificationMeta('elementId');
 | 
			
		||||
  @override
 | 
			
		||||
  late final GeneratedColumn<String> elementId = GeneratedColumn<String>(
 | 
			
		||||
      'element_id', aliasedName, false,
 | 
			
		||||
      type: DriftSqlType.string, requiredDuringInsert: true);
 | 
			
		||||
  @override
 | 
			
		||||
  List<GeneratedColumn> get $columns => [id, name, elementType, elementId];
 | 
			
		||||
  @override
 | 
			
		||||
  String get aliasedName => _alias ?? actualTableName;
 | 
			
		||||
  @override
 | 
			
		||||
  String get actualTableName => $name;
 | 
			
		||||
  static const String $name = 'blacklist_table';
 | 
			
		||||
  @override
 | 
			
		||||
  VerificationContext validateIntegrity(Insertable<BlacklistTableData> instance,
 | 
			
		||||
      {bool isInserting = false}) {
 | 
			
		||||
    final context = VerificationContext();
 | 
			
		||||
    final data = instance.toColumns(true);
 | 
			
		||||
    if (data.containsKey('id')) {
 | 
			
		||||
      context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
 | 
			
		||||
    }
 | 
			
		||||
    if (data.containsKey('name')) {
 | 
			
		||||
      context.handle(
 | 
			
		||||
          _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta));
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_nameMeta);
 | 
			
		||||
    }
 | 
			
		||||
    context.handle(_elementTypeMeta, const VerificationResult.success());
 | 
			
		||||
    if (data.containsKey('element_id')) {
 | 
			
		||||
      context.handle(_elementIdMeta,
 | 
			
		||||
          elementId.isAcceptableOrUnknown(data['element_id']!, _elementIdMeta));
 | 
			
		||||
    } else if (isInserting) {
 | 
			
		||||
      context.missing(_elementIdMeta);
 | 
			
		||||
    }
 | 
			
		||||
    return context;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Set<GeneratedColumn> get $primaryKey => {id};
 | 
			
		||||
  @override
 | 
			
		||||
  BlacklistTableData map(Map<String, dynamic> data, {String? tablePrefix}) {
 | 
			
		||||
    final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
 | 
			
		||||
    return BlacklistTableData(
 | 
			
		||||
      id: attachedDatabase.typeMapping
 | 
			
		||||
          .read(DriftSqlType.int, data['${effectivePrefix}id'])!,
 | 
			
		||||
      name: attachedDatabase.typeMapping
 | 
			
		||||
          .read(DriftSqlType.string, data['${effectivePrefix}name'])!,
 | 
			
		||||
      elementType: $BlacklistTableTable.$converterelementType.fromSql(
 | 
			
		||||
          attachedDatabase.typeMapping.read(
 | 
			
		||||
              DriftSqlType.string, data['${effectivePrefix}element_type'])!),
 | 
			
		||||
      elementId: attachedDatabase.typeMapping
 | 
			
		||||
          .read(DriftSqlType.string, data['${effectivePrefix}element_id'])!,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  $BlacklistTableTable createAlias(String alias) {
 | 
			
		||||
    return $BlacklistTableTable(attachedDatabase, alias);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static JsonTypeConverter2<BlacklistedType, String, String>
 | 
			
		||||
      $converterelementType =
 | 
			
		||||
      const EnumNameConverter<BlacklistedType>(BlacklistedType.values);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class BlacklistTableData extends DataClass
 | 
			
		||||
    implements Insertable<BlacklistTableData> {
 | 
			
		||||
  final int id;
 | 
			
		||||
  final String name;
 | 
			
		||||
  final BlacklistedType elementType;
 | 
			
		||||
  final String elementId;
 | 
			
		||||
  const BlacklistTableData(
 | 
			
		||||
      {required this.id,
 | 
			
		||||
      required this.name,
 | 
			
		||||
      required this.elementType,
 | 
			
		||||
      required this.elementId});
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, Expression> toColumns(bool nullToAbsent) {
 | 
			
		||||
    final map = <String, Expression>{};
 | 
			
		||||
    map['id'] = Variable<int>(id);
 | 
			
		||||
    map['name'] = Variable<String>(name);
 | 
			
		||||
    {
 | 
			
		||||
      map['element_type'] = Variable<String>(
 | 
			
		||||
          $BlacklistTableTable.$converterelementType.toSql(elementType));
 | 
			
		||||
    }
 | 
			
		||||
    map['element_id'] = Variable<String>(elementId);
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  BlacklistTableCompanion toCompanion(bool nullToAbsent) {
 | 
			
		||||
    return BlacklistTableCompanion(
 | 
			
		||||
      id: Value(id),
 | 
			
		||||
      name: Value(name),
 | 
			
		||||
      elementType: Value(elementType),
 | 
			
		||||
      elementId: Value(elementId),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  factory BlacklistTableData.fromJson(Map<String, dynamic> json,
 | 
			
		||||
      {ValueSerializer? serializer}) {
 | 
			
		||||
    serializer ??= driftRuntimeOptions.defaultSerializer;
 | 
			
		||||
    return BlacklistTableData(
 | 
			
		||||
      id: serializer.fromJson<int>(json['id']),
 | 
			
		||||
      name: serializer.fromJson<String>(json['name']),
 | 
			
		||||
      elementType: $BlacklistTableTable.$converterelementType
 | 
			
		||||
          .fromJson(serializer.fromJson<String>(json['elementType'])),
 | 
			
		||||
      elementId: serializer.fromJson<String>(json['elementId']),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, dynamic> toJson({ValueSerializer? serializer}) {
 | 
			
		||||
    serializer ??= driftRuntimeOptions.defaultSerializer;
 | 
			
		||||
    return <String, dynamic>{
 | 
			
		||||
      'id': serializer.toJson<int>(id),
 | 
			
		||||
      'name': serializer.toJson<String>(name),
 | 
			
		||||
      'elementType': serializer.toJson<String>(
 | 
			
		||||
          $BlacklistTableTable.$converterelementType.toJson(elementType)),
 | 
			
		||||
      'elementId': serializer.toJson<String>(elementId),
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  BlacklistTableData copyWith(
 | 
			
		||||
          {int? id,
 | 
			
		||||
          String? name,
 | 
			
		||||
          BlacklistedType? elementType,
 | 
			
		||||
          String? elementId}) =>
 | 
			
		||||
      BlacklistTableData(
 | 
			
		||||
        id: id ?? this.id,
 | 
			
		||||
        name: name ?? this.name,
 | 
			
		||||
        elementType: elementType ?? this.elementType,
 | 
			
		||||
        elementId: elementId ?? this.elementId,
 | 
			
		||||
      );
 | 
			
		||||
  BlacklistTableData copyWithCompanion(BlacklistTableCompanion data) {
 | 
			
		||||
    return BlacklistTableData(
 | 
			
		||||
      id: data.id.present ? data.id.value : this.id,
 | 
			
		||||
      name: data.name.present ? data.name.value : this.name,
 | 
			
		||||
      elementType:
 | 
			
		||||
          data.elementType.present ? data.elementType.value : this.elementType,
 | 
			
		||||
      elementId: data.elementId.present ? data.elementId.value : this.elementId,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return (StringBuffer('BlacklistTableData(')
 | 
			
		||||
          ..write('id: $id, ')
 | 
			
		||||
          ..write('name: $name, ')
 | 
			
		||||
          ..write('elementType: $elementType, ')
 | 
			
		||||
          ..write('elementId: $elementId')
 | 
			
		||||
          ..write(')'))
 | 
			
		||||
        .toString();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => Object.hash(id, name, elementType, elementId);
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) =>
 | 
			
		||||
      identical(this, other) ||
 | 
			
		||||
      (other is BlacklistTableData &&
 | 
			
		||||
          other.id == this.id &&
 | 
			
		||||
          other.name == this.name &&
 | 
			
		||||
          other.elementType == this.elementType &&
 | 
			
		||||
          other.elementId == this.elementId);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class BlacklistTableCompanion extends UpdateCompanion<BlacklistTableData> {
 | 
			
		||||
  final Value<int> id;
 | 
			
		||||
  final Value<String> name;
 | 
			
		||||
  final Value<BlacklistedType> elementType;
 | 
			
		||||
  final Value<String> elementId;
 | 
			
		||||
  const BlacklistTableCompanion({
 | 
			
		||||
    this.id = const Value.absent(),
 | 
			
		||||
    this.name = const Value.absent(),
 | 
			
		||||
    this.elementType = const Value.absent(),
 | 
			
		||||
    this.elementId = const Value.absent(),
 | 
			
		||||
  });
 | 
			
		||||
  BlacklistTableCompanion.insert({
 | 
			
		||||
    this.id = const Value.absent(),
 | 
			
		||||
    required String name,
 | 
			
		||||
    required BlacklistedType elementType,
 | 
			
		||||
    required String elementId,
 | 
			
		||||
  })  : name = Value(name),
 | 
			
		||||
        elementType = Value(elementType),
 | 
			
		||||
        elementId = Value(elementId);
 | 
			
		||||
  static Insertable<BlacklistTableData> custom({
 | 
			
		||||
    Expression<int>? id,
 | 
			
		||||
    Expression<String>? name,
 | 
			
		||||
    Expression<String>? elementType,
 | 
			
		||||
    Expression<String>? elementId,
 | 
			
		||||
  }) {
 | 
			
		||||
    return RawValuesInsertable({
 | 
			
		||||
      if (id != null) 'id': id,
 | 
			
		||||
      if (name != null) 'name': name,
 | 
			
		||||
      if (elementType != null) 'element_type': elementType,
 | 
			
		||||
      if (elementId != null) 'element_id': elementId,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  BlacklistTableCompanion copyWith(
 | 
			
		||||
      {Value<int>? id,
 | 
			
		||||
      Value<String>? name,
 | 
			
		||||
      Value<BlacklistedType>? elementType,
 | 
			
		||||
      Value<String>? elementId}) {
 | 
			
		||||
    return BlacklistTableCompanion(
 | 
			
		||||
      id: id ?? this.id,
 | 
			
		||||
      name: name ?? this.name,
 | 
			
		||||
      elementType: elementType ?? this.elementType,
 | 
			
		||||
      elementId: elementId ?? this.elementId,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Map<String, Expression> toColumns(bool nullToAbsent) {
 | 
			
		||||
    final map = <String, Expression>{};
 | 
			
		||||
    if (id.present) {
 | 
			
		||||
      map['id'] = Variable<int>(id.value);
 | 
			
		||||
    }
 | 
			
		||||
    if (name.present) {
 | 
			
		||||
      map['name'] = Variable<String>(name.value);
 | 
			
		||||
    }
 | 
			
		||||
    if (elementType.present) {
 | 
			
		||||
      map['element_type'] = Variable<String>(
 | 
			
		||||
          $BlacklistTableTable.$converterelementType.toSql(elementType.value));
 | 
			
		||||
    }
 | 
			
		||||
    if (elementId.present) {
 | 
			
		||||
      map['element_id'] = Variable<String>(elementId.value);
 | 
			
		||||
    }
 | 
			
		||||
    return map;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return (StringBuffer('BlacklistTableCompanion(')
 | 
			
		||||
          ..write('id: $id, ')
 | 
			
		||||
          ..write('name: $name, ')
 | 
			
		||||
          ..write('elementType: $elementType, ')
 | 
			
		||||
          ..write('elementId: $elementId')
 | 
			
		||||
          ..write(')'))
 | 
			
		||||
        .toString();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class $PreferencesTableTable extends PreferencesTable
 | 
			
		||||
    with TableInfo<$PreferencesTableTable, PreferencesTableData> {
 | 
			
		||||
  @override
 | 
			
		||||
@@ -3226,7 +2956,6 @@ abstract class _$AppDatabase extends GeneratedDatabase {
 | 
			
		||||
  $AppDatabaseManager get managers => $AppDatabaseManager(this);
 | 
			
		||||
  late final $AuthenticationTableTable authenticationTable =
 | 
			
		||||
      $AuthenticationTableTable(this);
 | 
			
		||||
  late final $BlacklistTableTable blacklistTable = $BlacklistTableTable(this);
 | 
			
		||||
  late final $PreferencesTableTable preferencesTable =
 | 
			
		||||
      $PreferencesTableTable(this);
 | 
			
		||||
  late final $ScrobblerTableTable scrobblerTable = $ScrobblerTableTable(this);
 | 
			
		||||
@@ -3236,8 +2965,6 @@ abstract class _$AppDatabase extends GeneratedDatabase {
 | 
			
		||||
      $SourceMatchTableTable(this);
 | 
			
		||||
  late final $HistoryTableTable historyTable = $HistoryTableTable(this);
 | 
			
		||||
  late final $LyricsTableTable lyricsTable = $LyricsTableTable(this);
 | 
			
		||||
  late final Index uniqueBlacklist = Index('unique_blacklist',
 | 
			
		||||
      'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)');
 | 
			
		||||
  late final Index uniqTrackMatch = Index('uniq_track_match',
 | 
			
		||||
      'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)');
 | 
			
		||||
  @override
 | 
			
		||||
@@ -3246,14 +2973,12 @@ abstract class _$AppDatabase extends GeneratedDatabase {
 | 
			
		||||
  @override
 | 
			
		||||
  List<DatabaseSchemaEntity> get allSchemaEntities => [
 | 
			
		||||
        authenticationTable,
 | 
			
		||||
        blacklistTable,
 | 
			
		||||
        preferencesTable,
 | 
			
		||||
        scrobblerTable,
 | 
			
		||||
        skipSegmentTable,
 | 
			
		||||
        sourceMatchTable,
 | 
			
		||||
        historyTable,
 | 
			
		||||
        lyricsTable,
 | 
			
		||||
        uniqueBlacklist,
 | 
			
		||||
        uniqTrackMatch
 | 
			
		||||
      ];
 | 
			
		||||
}
 | 
			
		||||
@@ -3395,139 +3120,6 @@ typedef $$AuthenticationTableTableProcessedTableManager = ProcessedTableManager<
 | 
			
		||||
    ),
 | 
			
		||||
    AuthenticationTableData,
 | 
			
		||||
    PrefetchHooks Function()>;
 | 
			
		||||
typedef $$BlacklistTableTableCreateCompanionBuilder = BlacklistTableCompanion
 | 
			
		||||
    Function({
 | 
			
		||||
  Value<int> id,
 | 
			
		||||
  required String name,
 | 
			
		||||
  required BlacklistedType elementType,
 | 
			
		||||
  required String elementId,
 | 
			
		||||
});
 | 
			
		||||
typedef $$BlacklistTableTableUpdateCompanionBuilder = BlacklistTableCompanion
 | 
			
		||||
    Function({
 | 
			
		||||
  Value<int> id,
 | 
			
		||||
  Value<String> name,
 | 
			
		||||
  Value<BlacklistedType> elementType,
 | 
			
		||||
  Value<String> elementId,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
class $$BlacklistTableTableFilterComposer
 | 
			
		||||
    extends FilterComposer<_$AppDatabase, $BlacklistTableTable> {
 | 
			
		||||
  $$BlacklistTableTableFilterComposer(super.$state);
 | 
			
		||||
  ColumnFilters<int> get id => $state.composableBuilder(
 | 
			
		||||
      column: $state.table.id,
 | 
			
		||||
      builder: (column, joinBuilders) =>
 | 
			
		||||
          ColumnFilters(column, joinBuilders: joinBuilders));
 | 
			
		||||
 | 
			
		||||
  ColumnFilters<String> get name => $state.composableBuilder(
 | 
			
		||||
      column: $state.table.name,
 | 
			
		||||
      builder: (column, joinBuilders) =>
 | 
			
		||||
          ColumnFilters(column, joinBuilders: joinBuilders));
 | 
			
		||||
 | 
			
		||||
  ColumnWithTypeConverterFilters<BlacklistedType, BlacklistedType, String>
 | 
			
		||||
      get elementType => $state.composableBuilder(
 | 
			
		||||
          column: $state.table.elementType,
 | 
			
		||||
          builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
 | 
			
		||||
              column,
 | 
			
		||||
              joinBuilders: joinBuilders));
 | 
			
		||||
 | 
			
		||||
  ColumnFilters<String> get elementId => $state.composableBuilder(
 | 
			
		||||
      column: $state.table.elementId,
 | 
			
		||||
      builder: (column, joinBuilders) =>
 | 
			
		||||
          ColumnFilters(column, joinBuilders: joinBuilders));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class $$BlacklistTableTableOrderingComposer
 | 
			
		||||
    extends OrderingComposer<_$AppDatabase, $BlacklistTableTable> {
 | 
			
		||||
  $$BlacklistTableTableOrderingComposer(super.$state);
 | 
			
		||||
  ColumnOrderings<int> get id => $state.composableBuilder(
 | 
			
		||||
      column: $state.table.id,
 | 
			
		||||
      builder: (column, joinBuilders) =>
 | 
			
		||||
          ColumnOrderings(column, joinBuilders: joinBuilders));
 | 
			
		||||
 | 
			
		||||
  ColumnOrderings<String> get name => $state.composableBuilder(
 | 
			
		||||
      column: $state.table.name,
 | 
			
		||||
      builder: (column, joinBuilders) =>
 | 
			
		||||
          ColumnOrderings(column, joinBuilders: joinBuilders));
 | 
			
		||||
 | 
			
		||||
  ColumnOrderings<String> get elementType => $state.composableBuilder(
 | 
			
		||||
      column: $state.table.elementType,
 | 
			
		||||
      builder: (column, joinBuilders) =>
 | 
			
		||||
          ColumnOrderings(column, joinBuilders: joinBuilders));
 | 
			
		||||
 | 
			
		||||
  ColumnOrderings<String> get elementId => $state.composableBuilder(
 | 
			
		||||
      column: $state.table.elementId,
 | 
			
		||||
      builder: (column, joinBuilders) =>
 | 
			
		||||
          ColumnOrderings(column, joinBuilders: joinBuilders));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class $$BlacklistTableTableTableManager extends RootTableManager<
 | 
			
		||||
    _$AppDatabase,
 | 
			
		||||
    $BlacklistTableTable,
 | 
			
		||||
    BlacklistTableData,
 | 
			
		||||
    $$BlacklistTableTableFilterComposer,
 | 
			
		||||
    $$BlacklistTableTableOrderingComposer,
 | 
			
		||||
    $$BlacklistTableTableCreateCompanionBuilder,
 | 
			
		||||
    $$BlacklistTableTableUpdateCompanionBuilder,
 | 
			
		||||
    (
 | 
			
		||||
      BlacklistTableData,
 | 
			
		||||
      BaseReferences<_$AppDatabase, $BlacklistTableTable, BlacklistTableData>
 | 
			
		||||
    ),
 | 
			
		||||
    BlacklistTableData,
 | 
			
		||||
    PrefetchHooks Function()> {
 | 
			
		||||
  $$BlacklistTableTableTableManager(
 | 
			
		||||
      _$AppDatabase db, $BlacklistTableTable table)
 | 
			
		||||
      : super(TableManagerState(
 | 
			
		||||
          db: db,
 | 
			
		||||
          table: table,
 | 
			
		||||
          filteringComposer:
 | 
			
		||||
              $$BlacklistTableTableFilterComposer(ComposerState(db, table)),
 | 
			
		||||
          orderingComposer:
 | 
			
		||||
              $$BlacklistTableTableOrderingComposer(ComposerState(db, table)),
 | 
			
		||||
          updateCompanionCallback: ({
 | 
			
		||||
            Value<int> id = const Value.absent(),
 | 
			
		||||
            Value<String> name = const Value.absent(),
 | 
			
		||||
            Value<BlacklistedType> elementType = const Value.absent(),
 | 
			
		||||
            Value<String> elementId = const Value.absent(),
 | 
			
		||||
          }) =>
 | 
			
		||||
              BlacklistTableCompanion(
 | 
			
		||||
            id: id,
 | 
			
		||||
            name: name,
 | 
			
		||||
            elementType: elementType,
 | 
			
		||||
            elementId: elementId,
 | 
			
		||||
          ),
 | 
			
		||||
          createCompanionCallback: ({
 | 
			
		||||
            Value<int> id = const Value.absent(),
 | 
			
		||||
            required String name,
 | 
			
		||||
            required BlacklistedType elementType,
 | 
			
		||||
            required String elementId,
 | 
			
		||||
          }) =>
 | 
			
		||||
              BlacklistTableCompanion.insert(
 | 
			
		||||
            id: id,
 | 
			
		||||
            name: name,
 | 
			
		||||
            elementType: elementType,
 | 
			
		||||
            elementId: elementId,
 | 
			
		||||
          ),
 | 
			
		||||
          withReferenceMapper: (p0) => p0
 | 
			
		||||
              .map((e) => (e.readTable(table), BaseReferences(db, table, e)))
 | 
			
		||||
              .toList(),
 | 
			
		||||
          prefetchHooksCallback: null,
 | 
			
		||||
        ));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
typedef $$BlacklistTableTableProcessedTableManager = ProcessedTableManager<
 | 
			
		||||
    _$AppDatabase,
 | 
			
		||||
    $BlacklistTableTable,
 | 
			
		||||
    BlacklistTableData,
 | 
			
		||||
    $$BlacklistTableTableFilterComposer,
 | 
			
		||||
    $$BlacklistTableTableOrderingComposer,
 | 
			
		||||
    $$BlacklistTableTableCreateCompanionBuilder,
 | 
			
		||||
    $$BlacklistTableTableUpdateCompanionBuilder,
 | 
			
		||||
    (
 | 
			
		||||
      BlacklistTableData,
 | 
			
		||||
      BaseReferences<_$AppDatabase, $BlacklistTableTable, BlacklistTableData>
 | 
			
		||||
    ),
 | 
			
		||||
    BlacklistTableData,
 | 
			
		||||
    PrefetchHooks Function()>;
 | 
			
		||||
typedef $$PreferencesTableTableCreateCompanionBuilder
 | 
			
		||||
    = PreferencesTableCompanion Function({
 | 
			
		||||
  Value<int> id,
 | 
			
		||||
@@ -4727,8 +4319,6 @@ class $AppDatabaseManager {
 | 
			
		||||
  $AppDatabaseManager(this._db);
 | 
			
		||||
  $$AuthenticationTableTableTableManager get authenticationTable =>
 | 
			
		||||
      $$AuthenticationTableTableTableManager(_db, _db.authenticationTable);
 | 
			
		||||
  $$BlacklistTableTableTableManager get blacklistTable =>
 | 
			
		||||
      $$BlacklistTableTableTableManager(_db, _db.blacklistTable);
 | 
			
		||||
  $$PreferencesTableTableTableManager get preferencesTable =>
 | 
			
		||||
      $$PreferencesTableTableTableManager(_db, _db.preferencesTable);
 | 
			
		||||
  $$ScrobblerTableTableTableManager get scrobblerTable =>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,18 +0,0 @@
 | 
			
		||||
part of '../database.dart';
 | 
			
		||||
 | 
			
		||||
enum BlacklistedType {
 | 
			
		||||
  artist,
 | 
			
		||||
  track;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@TableIndex(
 | 
			
		||||
  name: "unique_blacklist",
 | 
			
		||||
  unique: true,
 | 
			
		||||
  columns: {#elementType, #elementId},
 | 
			
		||||
)
 | 
			
		||||
class BlacklistTable extends Table {
 | 
			
		||||
  IntColumn get id => integer().autoIncrement()();
 | 
			
		||||
  TextColumn get name => text()();
 | 
			
		||||
  TextColumn get elementType => textEnum<BlacklistedType>()();
 | 
			
		||||
  TextColumn get elementId => text()();
 | 
			
		||||
}
 | 
			
		||||
@@ -13,8 +13,7 @@ enum CloseBehavior {
 | 
			
		||||
 | 
			
		||||
enum AudioSource {
 | 
			
		||||
  youtube,
 | 
			
		||||
  piped,
 | 
			
		||||
  jiosaavn;
 | 
			
		||||
  piped;
 | 
			
		||||
 | 
			
		||||
  String get label => name[0].toUpperCase() + name.substring(1);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,8 @@
 | 
			
		||||
part of '../database.dart';
 | 
			
		||||
 | 
			
		||||
enum SourceType {
 | 
			
		||||
  youtube._("YouTube"),
 | 
			
		||||
  youtubeMusic._("YouTube Music"),
 | 
			
		||||
  jiosaavn._("JioSaavn");
 | 
			
		||||
  youtube._('YouTube'),
 | 
			
		||||
  youtubeMusic._('YouTube Music');
 | 
			
		||||
 | 
			
		||||
  final String label;
 | 
			
		||||
 | 
			
		||||
@@ -11,7 +10,7 @@ enum SourceType {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@TableIndex(
 | 
			
		||||
  name: "uniq_track_match",
 | 
			
		||||
  name: 'uniq_track_match',
 | 
			
		||||
  columns: {#trackId, #sourceId, #sourceType},
 | 
			
		||||
  unique: true,
 | 
			
		||||
)
 | 
			
		||||
 
 | 
			
		||||
@@ -6,14 +6,14 @@ class LocaleConverter extends TypeConverter<Locale, String> {
 | 
			
		||||
  @override
 | 
			
		||||
  Locale fromSql(String fromDb) {
 | 
			
		||||
    final rawMap = jsonDecode(fromDb) as Map<String, dynamic>;
 | 
			
		||||
    return Locale(rawMap["languageCode"], rawMap["countryCode"]);
 | 
			
		||||
    return Locale(rawMap['languageCode'], rawMap['countryCode']);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toSql(Locale value) {
 | 
			
		||||
    return jsonEncode({
 | 
			
		||||
      "languageCode": value.languageCode,
 | 
			
		||||
      "countryCode": value.countryCode,
 | 
			
		||||
      'languageCode': value.languageCode,
 | 
			
		||||
      'countryCode': value.countryCode,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,11 +5,11 @@ class StringListConverter extends TypeConverter<List<String>, String> {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  List<String> fromSql(String fromDb) {
 | 
			
		||||
    return fromDb.split(",").where((e) => e.isNotEmpty).toList();
 | 
			
		||||
    return fromDb.split(',').where((e) => e.isNotEmpty).toList();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toSql(List<String> value) {
 | 
			
		||||
    return value.join(",");
 | 
			
		||||
    return value.join(',');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +0,0 @@
 | 
			
		||||
enum SearchMode {
 | 
			
		||||
  youtube._('YouTube'),
 | 
			
		||||
  youtubeMusic._('YouTube Music');
 | 
			
		||||
 | 
			
		||||
  final String label;
 | 
			
		||||
 | 
			
		||||
  const SearchMode._(this.label);
 | 
			
		||||
 | 
			
		||||
  factory SearchMode.fromString(String key) {
 | 
			
		||||
    return SearchMode.values.firstWhere((e) => e.name == key);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import 'package:piped_client/piped_client.dart';
 | 
			
		||||
import 'package:rhythm_box/services/sourced_track/models/search.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
 | 
			
		||||
 | 
			
		||||
class YoutubeVideoInfo {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,7 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:get/get.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/user_preferences.dart';
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
import 'package:rhythm_box/services/utils.dart';
 | 
			
		||||
import 'package:spotify/spotify.dart';
 | 
			
		||||
 | 
			
		||||
@@ -40,8 +43,8 @@ abstract class SourcedTrack extends Track {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static SourcedTrack fromJson(Map<String, dynamic> json) {
 | 
			
		||||
    // TODO Follow user preferences
 | 
			
		||||
    const audioSource = 'youtube';
 | 
			
		||||
    final preferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
    final audioSource = preferences.audioSource;
 | 
			
		||||
 | 
			
		||||
    final sourceInfo = SourceInfo.fromJson(json);
 | 
			
		||||
    final source = SourceMap.fromJson(json);
 | 
			
		||||
@@ -52,7 +55,7 @@ abstract class SourcedTrack extends Track {
 | 
			
		||||
        .cast<SourceInfo>();
 | 
			
		||||
 | 
			
		||||
    return switch (audioSource) {
 | 
			
		||||
      'piped' => PipedSourcedTrack(
 | 
			
		||||
      AudioSource.piped => PipedSourcedTrack(
 | 
			
		||||
          source: source,
 | 
			
		||||
          siblings: siblings,
 | 
			
		||||
          sourceInfo: sourceInfo,
 | 
			
		||||
@@ -86,12 +89,13 @@ abstract class SourcedTrack extends Track {
 | 
			
		||||
  static Future<SourcedTrack> fetchFromTrack({
 | 
			
		||||
    required Track track,
 | 
			
		||||
  }) async {
 | 
			
		||||
    // TODO Follow user preferences
 | 
			
		||||
    const audioSource = 'youtube';
 | 
			
		||||
    final preferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
    final audioSource = preferences.audioSource;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      return switch (audioSource) {
 | 
			
		||||
        'piped' => await PipedSourcedTrack.fetchFromTrack(track: track),
 | 
			
		||||
        AudioSource.piped =>
 | 
			
		||||
          await PipedSourcedTrack.fetchFromTrack(track: track),
 | 
			
		||||
        _ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
 | 
			
		||||
      };
 | 
			
		||||
    } on TrackNotFoundError catch (_) {
 | 
			
		||||
@@ -110,11 +114,11 @@ abstract class SourcedTrack extends Track {
 | 
			
		||||
  static Future<List<SiblingType>> fetchSiblings({
 | 
			
		||||
    required Track track,
 | 
			
		||||
  }) {
 | 
			
		||||
    // TODO Follow user preferences
 | 
			
		||||
    const audioSource = 'youtube';
 | 
			
		||||
    final preferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
    final audioSource = preferences.audioSource;
 | 
			
		||||
 | 
			
		||||
    return switch (audioSource) {
 | 
			
		||||
      'piped' => PipedSourcedTrack.fetchSiblings(track: track),
 | 
			
		||||
      AudioSource.piped => PipedSourcedTrack.fetchSiblings(track: track),
 | 
			
		||||
      _ => YoutubeSourcedTrack.fetchSiblings(track: track),
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
@@ -128,15 +132,15 @@ abstract class SourcedTrack extends Track {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  String get url {
 | 
			
		||||
    // TODO Follow user preferences
 | 
			
		||||
    const streamMusicCodec = SourceCodecs.weba;
 | 
			
		||||
    final preferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
    final streamMusicCodec = preferences.streamMusicCodec;
 | 
			
		||||
 | 
			
		||||
    return getUrlOfCodec(streamMusicCodec);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  String getUrlOfCodec(SourceCodecs codec) {
 | 
			
		||||
    // TODO Follow user preferences
 | 
			
		||||
    const audioQuality = SourceQualities.high;
 | 
			
		||||
    final preferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
    final audioQuality = preferences.audioQuality;
 | 
			
		||||
 | 
			
		||||
    return source[codec]?[audioQuality] ??
 | 
			
		||||
        // this will ensure playback doesn't break
 | 
			
		||||
@@ -145,8 +149,8 @@ abstract class SourcedTrack extends Track {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  SourceCodecs get codec {
 | 
			
		||||
    // TODO Follow user preferences
 | 
			
		||||
    const streamMusicCodec = SourceCodecs.weba;
 | 
			
		||||
    final preferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
    final streamMusicCodec = preferences.streamMusicCodec;
 | 
			
		||||
 | 
			
		||||
    return streamMusicCodec;
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,10 @@
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:get/get.dart' hide Value;
 | 
			
		||||
import 'package:piped_client/piped_client.dart';
 | 
			
		||||
import 'package:rhythm_box/services/sourced_track/models/search.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/database.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/user_preferences.dart';
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
import 'package:rhythm_box/services/utils.dart';
 | 
			
		||||
import 'package:spotify/spotify.dart';
 | 
			
		||||
 | 
			
		||||
@@ -41,21 +45,62 @@ class PipedSourcedTrack extends SourcedTrack {
 | 
			
		||||
  static Future<SourcedTrack> fetchFromTrack({
 | 
			
		||||
    required Track track,
 | 
			
		||||
  }) async {
 | 
			
		||||
    // TODO Add cache query here
 | 
			
		||||
    final DatabaseProvider db = Get.find();
 | 
			
		||||
    final cachedSource = await (db.database.select(db.database.sourceMatchTable)
 | 
			
		||||
          ..where((s) => s.trackId.equals(track.id!))
 | 
			
		||||
          ..limit(1)
 | 
			
		||||
          ..orderBy([
 | 
			
		||||
            (s) =>
 | 
			
		||||
                OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
 | 
			
		||||
          ]))
 | 
			
		||||
        .getSingleOrNull();
 | 
			
		||||
 | 
			
		||||
    final siblings = await fetchSiblings(track: track);
 | 
			
		||||
    if (siblings.isEmpty) {
 | 
			
		||||
      throw TrackNotFoundError(track);
 | 
			
		||||
    final preferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
 | 
			
		||||
    if (cachedSource == null) {
 | 
			
		||||
      final siblings = await fetchSiblings(track: track);
 | 
			
		||||
      if (siblings.isEmpty) {
 | 
			
		||||
        throw TrackNotFoundError(track);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await db.database.into(db.database.sourceMatchTable).insert(
 | 
			
		||||
            SourceMatchTableCompanion.insert(
 | 
			
		||||
              trackId: track.id!,
 | 
			
		||||
              sourceId: siblings.first.info.id,
 | 
			
		||||
              sourceType: Value(
 | 
			
		||||
                preferences.searchMode == SearchMode.youtube
 | 
			
		||||
                    ? SourceType.youtube
 | 
			
		||||
                    : SourceType.youtubeMusic,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
      return PipedSourcedTrack(
 | 
			
		||||
        siblings: siblings.map((s) => s.info).skip(1).toList(),
 | 
			
		||||
        source: siblings.first.source as SourceMap,
 | 
			
		||||
        sourceInfo: siblings.first.info,
 | 
			
		||||
        track: track,
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      final client = _getClient();
 | 
			
		||||
      final manifest = await client.streams(cachedSource.sourceId);
 | 
			
		||||
 | 
			
		||||
      return PipedSourcedTrack(
 | 
			
		||||
        siblings: [],
 | 
			
		||||
        source: toSourceMap(manifest),
 | 
			
		||||
        sourceInfo: PipedSourceInfo(
 | 
			
		||||
          id: manifest.id,
 | 
			
		||||
          artist: manifest.uploader,
 | 
			
		||||
          artistUrl: manifest.uploaderUrl,
 | 
			
		||||
          pageUrl: 'https://www.youtube.com/watch?v=${manifest.id}',
 | 
			
		||||
          thumbnail: manifest.thumbnailUrl,
 | 
			
		||||
          title: manifest.title,
 | 
			
		||||
          duration: manifest.duration,
 | 
			
		||||
          album: null,
 | 
			
		||||
        ),
 | 
			
		||||
        track: track,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // TODO Insert to cache here
 | 
			
		||||
 | 
			
		||||
    return PipedSourcedTrack(
 | 
			
		||||
      siblings: siblings.map((s) => s.info).skip(1).toList(),
 | 
			
		||||
      source: siblings.first.source as SourceMap,
 | 
			
		||||
      sourceInfo: siblings.first.info,
 | 
			
		||||
      track: track,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static SourceMap toSourceMap(PipedStreamResponse manifest) {
 | 
			
		||||
@@ -114,11 +159,10 @@ class PipedSourcedTrack extends SourcedTrack {
 | 
			
		||||
    required Track track,
 | 
			
		||||
  }) async {
 | 
			
		||||
    final pipedClient = _getClient();
 | 
			
		||||
    final preferences = Get.find<UserPreferencesProvider>().state.value;
 | 
			
		||||
 | 
			
		||||
    // TODO Allow user search with normal youtube video (`youtube`)
 | 
			
		||||
    const searchMode = SearchMode.youtubeMusic;
 | 
			
		||||
    // TODO Follow user preferences
 | 
			
		||||
    const audioSource = 'youtube';
 | 
			
		||||
    final searchMode = preferences.searchMode;
 | 
			
		||||
    final audioSource = preferences.audioSource;
 | 
			
		||||
 | 
			
		||||
    final query = SourcedTrack.getSearchTerm(track);
 | 
			
		||||
 | 
			
		||||
@@ -130,8 +174,9 @@ class PipedSourcedTrack extends SourcedTrack {
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // when falling back to piped API make sure to use the YouTube mode
 | 
			
		||||
    const isYouTubeMusic =
 | 
			
		||||
        audioSource != 'piped' ? false : searchMode == SearchMode.youtubeMusic;
 | 
			
		||||
    final isYouTubeMusic = audioSource != AudioSource.piped
 | 
			
		||||
        ? false
 | 
			
		||||
        : searchMode == SearchMode.youtubeMusic;
 | 
			
		||||
 | 
			
		||||
    if (isYouTubeMusic) {
 | 
			
		||||
      final artists = (track.artists ?? [])
 | 
			
		||||
@@ -227,7 +272,18 @@ class PipedSourcedTrack extends SourcedTrack {
 | 
			
		||||
 | 
			
		||||
    final manifest = await pipedClient.streams(newSourceInfo.id);
 | 
			
		||||
 | 
			
		||||
    // TODO Save to cache here
 | 
			
		||||
    final DatabaseProvider db = Get.find();
 | 
			
		||||
    await db.database.into(db.database.sourceMatchTable).insert(
 | 
			
		||||
          SourceMatchTableCompanion.insert(
 | 
			
		||||
            trackId: id!,
 | 
			
		||||
            sourceId: newSourceInfo.id,
 | 
			
		||||
            sourceType: const Value(SourceType.youtube),
 | 
			
		||||
            // Because we're sorting by createdAt in the query
 | 
			
		||||
            // we have to update it to indicate priority
 | 
			
		||||
            createdAt: Value(DateTime.now()),
 | 
			
		||||
          ),
 | 
			
		||||
          mode: InsertMode.replace,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
    return PipedSourcedTrack(
 | 
			
		||||
      siblings: newSiblings,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,11 @@
 | 
			
		||||
import 'dart:developer';
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:drift/drift.dart';
 | 
			
		||||
import 'package:get/get.dart' hide Value;
 | 
			
		||||
import 'package:http/http.dart';
 | 
			
		||||
import 'package:rhythm_box/providers/database.dart';
 | 
			
		||||
import 'package:rhythm_box/services/database/database.dart';
 | 
			
		||||
import 'package:rhythm_box/services/utils.dart';
 | 
			
		||||
import 'package:spotify/spotify.dart';
 | 
			
		||||
import 'package:rhythm_box/services/song_link/song_link.dart';
 | 
			
		||||
@@ -43,19 +47,61 @@ class YoutubeSourcedTrack extends SourcedTrack {
 | 
			
		||||
  static Future<YoutubeSourcedTrack> fetchFromTrack({
 | 
			
		||||
    required Track track,
 | 
			
		||||
  }) async {
 | 
			
		||||
    // TODO Add cache query here
 | 
			
		||||
    final DatabaseProvider db = Get.find();
 | 
			
		||||
    final cachedSource = await (db.database.select(db.database.sourceMatchTable)
 | 
			
		||||
          ..where((s) => s.trackId.equals(track.id!))
 | 
			
		||||
          ..limit(1)
 | 
			
		||||
          ..orderBy([
 | 
			
		||||
            (s) =>
 | 
			
		||||
                OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
 | 
			
		||||
          ]))
 | 
			
		||||
        .get()
 | 
			
		||||
        .then((s) => s.firstOrNull);
 | 
			
		||||
 | 
			
		||||
    final siblings = await fetchSiblings(track: track);
 | 
			
		||||
    if (siblings.isEmpty) {
 | 
			
		||||
      throw TrackNotFoundError(track);
 | 
			
		||||
    if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) {
 | 
			
		||||
      final siblings = await fetchSiblings(track: track);
 | 
			
		||||
      if (siblings.isEmpty) {
 | 
			
		||||
        throw TrackNotFoundError(track);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await db.database.into(db.database.sourceMatchTable).insert(
 | 
			
		||||
            SourceMatchTableCompanion.insert(
 | 
			
		||||
              trackId: track.id!,
 | 
			
		||||
              sourceId: siblings.first.info.id,
 | 
			
		||||
              sourceType: const Value(SourceType.youtube),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
      return YoutubeSourcedTrack(
 | 
			
		||||
        siblings: siblings.map((s) => s.info).skip(1).toList(),
 | 
			
		||||
        source: siblings.first.source as SourceMap,
 | 
			
		||||
        sourceInfo: siblings.first.info,
 | 
			
		||||
        track: track,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // TODO Save to cache here
 | 
			
		||||
 | 
			
		||||
    final item = await youtubeClient.videos.get(cachedSource.sourceId);
 | 
			
		||||
    final manifest = await youtubeClient.videos.streamsClient
 | 
			
		||||
        .getManifest(
 | 
			
		||||
          cachedSource.sourceId,
 | 
			
		||||
        )
 | 
			
		||||
        .timeout(
 | 
			
		||||
          const Duration(seconds: 5),
 | 
			
		||||
          onTimeout: () => throw ClientException('Timeout'),
 | 
			
		||||
        );
 | 
			
		||||
    return YoutubeSourcedTrack(
 | 
			
		||||
      siblings: siblings.map((s) => s.info).skip(1).toList(),
 | 
			
		||||
      source: siblings.first.source as SourceMap,
 | 
			
		||||
      sourceInfo: siblings.first.info,
 | 
			
		||||
      siblings: [],
 | 
			
		||||
      source: toSourceMap(manifest),
 | 
			
		||||
      sourceInfo: YoutubeSourceInfo(
 | 
			
		||||
        id: item.id.value,
 | 
			
		||||
        artist: item.author,
 | 
			
		||||
        artistUrl: 'https://www.youtube.com/channel/${item.channelId}',
 | 
			
		||||
        pageUrl: item.url,
 | 
			
		||||
        thumbnail: item.thumbnails.highResUrl,
 | 
			
		||||
        title: item.title,
 | 
			
		||||
        duration: item.duration ?? Duration.zero,
 | 
			
		||||
        album: null,
 | 
			
		||||
      ),
 | 
			
		||||
      track: track,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
@@ -243,7 +289,19 @@ class YoutubeSourcedTrack extends SourcedTrack {
 | 
			
		||||
          onTimeout: () => throw ClientException('Timeout'),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
    // TODO Save to cache here
 | 
			
		||||
    final DatabaseProvider db = Get.find();
 | 
			
		||||
 | 
			
		||||
    await db.database.into(db.database.sourceMatchTable).insert(
 | 
			
		||||
          SourceMatchTableCompanion.insert(
 | 
			
		||||
            trackId: id!,
 | 
			
		||||
            sourceId: newSourceInfo.id,
 | 
			
		||||
            sourceType: const Value(SourceType.youtube),
 | 
			
		||||
            // Because we're sorting by createdAt in the query
 | 
			
		||||
            // we have to update it to indicate priority
 | 
			
		||||
            createdAt: Value(DateTime.now()),
 | 
			
		||||
          ),
 | 
			
		||||
          mode: InsertMode.replace,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
    return YoutubeSourcedTrack(
 | 
			
		||||
      siblings: newSiblings,
 | 
			
		||||
 
 | 
			
		||||
@@ -23,4 +23,11 @@ class AutoCacheImage extends StatelessWidget {
 | 
			
		||||
      height: height,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  static ImageProvider provider(String url) {
 | 
			
		||||
    if (PlatformInfo.canCacheImage) {
 | 
			
		||||
      return CachedNetworkImageProvider(url);
 | 
			
		||||
    }
 | 
			
		||||
    return NetworkImage(url);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								pubspec.lock
									
									
									
									
									
								
							@@ -837,6 +837,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.1"
 | 
			
		||||
  palette_generator:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: palette_generator
 | 
			
		||||
      sha256: d50fbcd69abb80c5baec66d700033b1a320108b1aa17a5961866a12c0abb7c0c
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.3.3+4"
 | 
			
		||||
  path:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@@ -997,6 +1005,15 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "0.1.9"
 | 
			
		||||
  scrobblenaut:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      path: "."
 | 
			
		||||
      ref: dart-3-support
 | 
			
		||||
      resolved-ref: d90cb75d71737f3cfa2de4469d48080c0f3eedc2
 | 
			
		||||
      url: "https://github.com/KRTirtho/scrobblenaut.git"
 | 
			
		||||
    source: git
 | 
			
		||||
    version: "3.0.0"
 | 
			
		||||
  shared_preferences:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
 
 | 
			
		||||
@@ -76,6 +76,11 @@ dependencies:
 | 
			
		||||
  path_provider: ^2.1.4
 | 
			
		||||
  sqlite3: ^2.4.6
 | 
			
		||||
  sqlite3_flutter_libs: ^0.5.24
 | 
			
		||||
  palette_generator: ^0.3.3+4
 | 
			
		||||
  scrobblenaut:
 | 
			
		||||
    git:
 | 
			
		||||
      url: https://github.com/KRTirtho/scrobblenaut.git
 | 
			
		||||
      ref: dart-3-support
 | 
			
		||||
 | 
			
		||||
dev_dependencies:
 | 
			
		||||
  flutter_test:
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user