Impl more features (clean up 20+ todo)

 Add query cache
This commit is contained in:
2024-08-27 16:37:31 +08:00
parent a162619a88
commit 2e17078fea
24 changed files with 907 additions and 503 deletions

View 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
});
}
}

View 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();
}

View 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();
}
}

View 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;
}
}

View 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();
}
}

View 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
}
}

View 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)));
}
}